diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs index 777739d5..73ef8c5f 100644 --- a/src/AcDream.App/UI/Layout/ChatWindowController.cs +++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs @@ -153,6 +153,12 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta private UiElement? _unreadIndicator; + /// Test seam: the bound unseen-text indicator, if the layout has one. + internal UiElement? UnreadIndicatorForTest => _unreadIndicator; + + /// Test seam: force the unseen flag without faking a line arrival. + internal void SetUnreadForTest(bool unread) => _hasUnseenText = unread; + /// /// Set when a line arrives while the transcript is scrolled up, cleared /// the moment the view is back at the bottom. @@ -372,7 +378,12 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta c._unreadIndicator = layout.FindElement(UnreadIndicatorId); if (c._unreadIndicator is not null) { - c._unreadIndicator.Visible = false; + // Driven by authored STATE, not visibility. Retail's own click + // handler ends in SetState(0xD) — Ghosted — which is also the + // element's authored default, and the unread look is state 1 + // (Normal). Ghosted authors no media at all, so it draws nothing + // without needing to be hidden. + c.SetUnreadIndicatorState(unread: false); if (c._unreadIndicator is UiButton unread) unread.OnClick = c.ScrollToNewestAndClearUnread; } @@ -909,10 +920,35 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta { if (Transcript.Scroll.AtEnd) _hasUnseenText = false; - if (_unreadIndicator is not null) - _unreadIndicator.Visible = _hasUnseenText; + SetUnreadIndicatorState(_hasUnseenText); } + /// + /// Puts the indicator into its authored unread (Normal) or idle + /// (Ghosted) state. + /// + /// + /// The blink is authored, not coded. The element's Normal state + /// carries SIX image frames (measured: LayoutDump --states on + /// 0x2100006F reports media=13/6 for state 1, against 0/0 + /// for Ghosted), so retail's flashing comes from cycling that media list. + /// Our importer keeps only ONE file per state + /// (), so the indicator + /// currently shows a static frame. Multi-frame state media is its own + /// capability; the state machinery here is right either way, and gains the + /// animation for free once that lands. + /// + private void SetUnreadIndicatorState(bool unread) + { + if (_unreadIndicator is not IUiDatStateful stateful) + return; + stateful.TrySetRetailState( + unread ? UiButtonStateMachine.Normal : GhostedStateId); + } + + /// Retail state 0xD, the id its own click handler sets. + private const uint GhostedStateId = 13u; + /// Aims the chat entry at and focuses it. internal void StartTell(string name) { diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs index 7cf98d0d..c6c9286e 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs @@ -123,6 +123,17 @@ public class ChatWindowControllerTests info.StateMedia["Highlight"] = (0x2u, 1); return info; } + // The unseen-text indicator (CT-C1). Authors Normal and Ghosted media + // the way the real element does, so TrySetRetailState can resolve both + // — Ghosted is its authored DEFAULT and is what retail's own click + // handler sets (SetState(0xD)). + var unread = new ElementInfo + { + Id = 0x1000048Cu, Type = 1, X = 0, Y = 57, Width = 16, Height = 16, + }; + unread.StateMedia["Normal"] = (0x3u, 1); + unread.StateMedia["Ghosted"] = (0x4u, 1); + var indicator1 = MakeIndicator(0x10000522u, 5); var indicator2 = MakeIndicator(0x10000523u, 22); var indicator3 = MakeIndicator(0x10000524u, 39); @@ -135,6 +146,7 @@ public class ChatWindowControllerTests root.Children.Add(transcriptPanel); root.Children.Add(inputBar); root.Children.Add(maxMinNode); + root.Children.Add(unread); root.Children.Add(indicator1); root.Children.Add(indicator2); root.Children.Add(indicator3); @@ -187,6 +199,42 @@ public class ChatWindowControllerTests Assert.True(ctrl.Transcript.Scroll.AtEnd); } + [Fact] + public void TheIndicatorIsDrivenByAuthoredStateNotVisibility() + { + // Retail's own click handler ends in SetState(0xD) — Ghosted — which + // is also the element's authored default; the unread look is state 1 + // (Normal), whose media list carries SIX frames and is where the + // flashing comes from. Hiding the element instead would look almost + // right and could never blink. + ChatWindowController ctrl = BindController(); + + UiElement indicator = Assert.IsAssignableFrom( + ctrl.UnreadIndicatorForTest); + + // Visibility is NOT the mechanism: the element stays visible and + // changes STATE. Ghosted authors no media on the real element, so it + // draws nothing without being hidden. + Assert.True(indicator.Visible); + + var stateful = Assert.IsAssignableFrom(indicator); + Assert.Equal("Ghosted", ((UiButton)indicator).ActiveState); + + // A line arriving while scrolled up flips it to Normal — the state + // whose authored media carries the six flash frames. + ctrl.Transcript.Scroll.SetExtents(contentHeight: 500, viewHeight: 100); + ctrl.Transcript.Scroll.SetScrollY(0); + ctrl.SetUnreadForTest(true); + ctrl.UpdateUnreadIndicator(); + Assert.Equal("Normal", ((UiButton)indicator).ActiveState); + + // ...and returning to the bottom puts it back. + ctrl.Transcript.Scroll.ScrollToEnd(); + ctrl.UpdateUnreadIndicator(); + Assert.Equal("Ghosted", ((UiButton)indicator).ActiveState); + _ = stateful; + } + [Fact] public void ClickingTheIndicatorJumpsToTheNewestText() {