diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs index ccdf8f7b..777739d5 100644 --- a/src/AcDream.App/UI/Layout/ChatWindowController.cs +++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs @@ -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 + + /// + /// Retail's "there is new text you have not seen" indicator, shown only + /// while the transcript is scrolled off the bottom + /// (ChatInterface::ListenToElementMessage @0x004F51C0). + /// + 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 /// private readonly List?> _cachedTranscriptTags = new(); + + private UiElement? _unreadIndicator; + + /// + /// Set when a line arrives while the transcript is scrolled up, cleared + /// the moment the view is back at the bottom. + /// + 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; } + /// + /// Jumps to the newest text and clears the unread flag — retail's own + /// handler for a click on the indicator. + /// + internal void ScrollToNewestAndClearUnread() + { + Transcript.Scroll.ScrollToEnd(); + _hasUnseenText = false; + } + + /// + /// 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. + /// + internal void UpdateUnreadIndicator() + { + if (Transcript.Scroll.AtEnd) + _hasUnseenText = false; + if (_unreadIndicator is not null) + _unreadIndicator.Visible = _hasUnseenText; + } + /// Aims the chat entry at and focuses it. internal void StartTell(string name) { diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index faad50d6..e666c753 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -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); diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs index 46f7d5c4..7cf98d0d 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs @@ -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() {