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

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