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