feat(chat): CT-C1 — the unseen-text indicator
Campaign CT slice C1, completing Group C. The authored element was already in the layout and simply never bound: 0x1000048C, a 16x16 button at the transcript's bottom-left. It now lights when a line arrives while the transcript is scrolled up, and clicking it jumps to the newest text. Half of this slice turned out to be done already, and checking rather than assuming is what kept it that way. The plan called for porting retail's rule that IsAtVerticalEnd is sampled BEFORE the new line lands, so a player reading back is not yanked to the bottom. UiScrollable.SetExtents already does exactly that via preserveEnd, and chat gets it by default — so the scroll behaviour was untouched and only the indicator was missing. Rewriting it would have been churn on correct code. The flag clears on reaching the bottom by ANY means, not only by clicking the indicator. Clearing only on the click would leave it lit over text the player had already scrolled down and read, which is worse than not having it. Detection samples the scroll position before the rebuild, at the one moment we know new content arrived (the revision advancing). The first build after bind is deliberately excluded — a fresh window has not "missed" anything. Solution builds clean; full hermetic gate green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
9f6d79b7e0
commit
550621efb2
3 changed files with 103 additions and 0 deletions
|
|
@ -52,6 +52,13 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
|
|||
private const uint RootId = 0x10000600u; // gmFloatyMainChatUI window root, 410x100
|
||||
private const uint TranscriptPanelId = 0x10000010u;
|
||||
private const uint TranscriptId = 0x10000011u; // Type-12 prototype — skipped by factory
|
||||
|
||||
/// <summary>
|
||||
/// Retail's "there is new text you have not seen" indicator, shown only
|
||||
/// while the transcript is scrolled off the bottom
|
||||
/// (<c>ChatInterface::ListenToElementMessage @0x004F51C0</c>).
|
||||
/// </summary>
|
||||
private const uint UnreadIndicatorId = 0x1000048Cu;
|
||||
private const uint TrackId = 0x10000012u;
|
||||
private const uint InputBarId = 0x10000013u;
|
||||
private const uint MenuId = 0x10000014u;
|
||||
|
|
@ -143,6 +150,14 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
|
|||
/// </summary>
|
||||
private readonly List<IReadOnlyList<(int Start, int Length, ChatTextTag Tag)>?>
|
||||
_cachedTranscriptTags = new();
|
||||
|
||||
private UiElement? _unreadIndicator;
|
||||
|
||||
/// <summary>
|
||||
/// Set when a line arrives while the transcript is scrolled up, cleared
|
||||
/// the moment the view is back at the bottom.
|
||||
/// </summary>
|
||||
private bool _hasUnseenText;
|
||||
private long _cachedTranscriptRevision = -1;
|
||||
private ulong _cachedFilter;
|
||||
private float _cachedTranscriptWrapWidth = float.NaN;
|
||||
|
|
@ -353,6 +368,15 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
|
|||
// @0x004CCE10 -> ChatInterface::StartTell @0x004F41F0.
|
||||
c.Transcript.OnCharClick = pos => c.TryStartTellFromTag(pos);
|
||||
|
||||
// ── Unread indicator ─────────────────────────────────────────────
|
||||
c._unreadIndicator = layout.FindElement(UnreadIndicatorId);
|
||||
if (c._unreadIndicator is not null)
|
||||
{
|
||||
c._unreadIndicator.Visible = false;
|
||||
if (c._unreadIndicator is UiButton unread)
|
||||
unread.OnClick = c.ScrollToNewestAndClearUnread;
|
||||
}
|
||||
|
||||
// ── Input ────────────────────────────────────────────────────────
|
||||
// Editable/selectable/one-line semantics and state sprites came from the
|
||||
// imported property/state bags. The controller supplies runtime services only.
|
||||
|
|
@ -781,6 +805,16 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
|
|||
return _cachedTranscriptLines;
|
||||
}
|
||||
|
||||
// Sampled BEFORE the rebuild, exactly as retail samples IsAtVerticalEnd
|
||||
// before the new line lands: if the player had scrolled up, the
|
||||
// arriving text is unseen and the view must NOT be yanked down.
|
||||
if (_cachedTranscriptRevision != revision
|
||||
&& _cachedTranscriptRevision >= 0
|
||||
&& !Transcript.Scroll.AtEnd)
|
||||
{
|
||||
_hasUnseenText = true;
|
||||
}
|
||||
|
||||
var detailed = vm.RecentLinesDetailed();
|
||||
if (detailed.Count == 0)
|
||||
{
|
||||
|
|
@ -856,6 +890,29 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
|
|||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Jumps to the newest text and clears the unread flag — retail's own
|
||||
/// handler for a click on the indicator.
|
||||
/// </summary>
|
||||
internal void ScrollToNewestAndClearUnread()
|
||||
{
|
||||
Transcript.Scroll.ScrollToEnd();
|
||||
_hasUnseenText = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Keeps the indicator in step with the view. Reaching the bottom by ANY
|
||||
/// means clears it — the wheel, the scrollbar, or the click above — so it
|
||||
/// cannot be left lit over text the player has already read.
|
||||
/// </summary>
|
||||
internal void UpdateUnreadIndicator()
|
||||
{
|
||||
if (Transcript.Scroll.AtEnd)
|
||||
_hasUnseenText = false;
|
||||
if (_unreadIndicator is not null)
|
||||
_unreadIndicator.Visible = _hasUnseenText;
|
||||
}
|
||||
|
||||
/// <summary>Aims the chat entry at <paramref name="name"/> and focuses it.</summary>
|
||||
internal void StartTell(string name)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -861,6 +861,10 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
NegativeEffectsController?.Tick();
|
||||
LinkStatusUiController?.Tick();
|
||||
IndicatorBarController?.Tick();
|
||||
// Campaign CT slice C1: keeps the "unseen text" indicator in step with
|
||||
// the transcript's scroll position, so reaching the bottom by ANY
|
||||
// means clears it.
|
||||
_chatWindowController?.UpdateUnreadIndicator();
|
||||
JumpPowerbarController?.Tick();
|
||||
SecureTradeController?.Tick();
|
||||
SelectedObjectController?.Tick(deltaSeconds);
|
||||
|
|
|
|||
|
|
@ -158,6 +158,48 @@ public class ChatWindowControllerTests
|
|||
Assert.NotNull(ctrl);
|
||||
}
|
||||
|
||||
// ── CT-C1: the unseen-text indicator ────────────────────────────────
|
||||
|
||||
private static ChatWindowController BindController()
|
||||
{
|
||||
var (rootInfo, layout, vm) = BuildTestTree();
|
||||
var bus = new CaptureBus();
|
||||
ChatWindowController? ctrl = ChatWindowController.Bind(
|
||||
rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex);
|
||||
Assert.NotNull(ctrl);
|
||||
return ctrl!;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReachingTheBottomClearsTheUnseenFlagHoweverYouGotThere()
|
||||
{
|
||||
// Retail clears on arrival at the bottom, not only on clicking the
|
||||
// indicator — otherwise scrolling down with the wheel would leave it
|
||||
// lit over text the player has just read.
|
||||
ChatWindowController ctrl = BindController();
|
||||
ctrl.Transcript.Scroll.SetExtents(contentHeight: 500, viewHeight: 100);
|
||||
ctrl.Transcript.Scroll.SetScrollY(0); // scrolled up
|
||||
Assert.False(ctrl.Transcript.Scroll.AtEnd);
|
||||
|
||||
ctrl.Transcript.Scroll.ScrollToEnd();
|
||||
ctrl.UpdateUnreadIndicator();
|
||||
|
||||
Assert.True(ctrl.Transcript.Scroll.AtEnd);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClickingTheIndicatorJumpsToTheNewestText()
|
||||
{
|
||||
ChatWindowController ctrl = BindController();
|
||||
ctrl.Transcript.Scroll.SetExtents(contentHeight: 500, viewHeight: 100);
|
||||
ctrl.Transcript.Scroll.SetScrollY(0);
|
||||
Assert.False(ctrl.Transcript.Scroll.AtEnd);
|
||||
|
||||
ctrl.ScrollToNewestAndClearUnread();
|
||||
|
||||
Assert.True(ctrl.Transcript.Scroll.AtEnd);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StartTell_PrefillsTheEntryAndPutsTheCaretAtTheEnd()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue