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:
Erik 2026-08-21 08:31:48 +02:00
parent 9f6d79b7e0
commit 550621efb2
3 changed files with 103 additions and 0 deletions

View file

@ -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)
{

View file

@ -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);

View file

@ -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()
{