diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs index 73ef8c5f..27fef4c8 100644 --- a/src/AcDream.App/UI/Layout/ChatWindowController.cs +++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs @@ -940,14 +940,28 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta /// private void SetUnreadIndicatorState(bool unread) { - if (_unreadIndicator is not IUiDatStateful stateful) + if (_unreadIndicator is null) return; - stateful.TrySetRetailState( - unread ? UiButtonStateMachine.Normal : GhostedStateId); - } - /// Retail state 0xD, the id its own click handler sets. - private const uint GhostedStateId = 13u; + // Apply the element's OWN authored per-state visibility (dat property + // 0x3B, "Invisible"), measured on 0x1000048C as: + // + // state 13 Ghosted 0x3B = True -> hidden + // state 1 Normal 0x3B = False -> shown + // + // UiDatElement applies 0x3B on a state change; UiButton does not, and + // this element builds as a button. So the property is applied here + // rather than left unhonoured — this is the authored data, not a + // visibility hack layered over it. + _unreadIndicator.Visible = unread; + + // Set the state too, for the media it selects. Deliberately only on + // the way IN: TrySetRetailState(Ghosted) means Enabled = false, and + // disabling the button would also refuse the click that scrolls to + // the newest text. + if (unread && _unreadIndicator is IUiDatStateful stateful) + stateful.TrySetRetailState(UiButtonStateMachine.Normal); + } /// 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 c6c9286e..d1223134 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs @@ -200,39 +200,32 @@ public class ChatWindowControllerTests } [Fact] - public void TheIndicatorIsDrivenByAuthoredStateNotVisibility() + public void TheIndicatorFollowsItsAuthoredPerStateVisibility() { - // 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. + // The element authors dat property 0x3B ("Invisible") per state, and + // it is what decides whether the thing is on screen: + // state 13 Ghosted 0x3B = True -> hidden + // state 1 Normal 0x3B = False -> shown + // (measured with LayoutDump --props on 0x2100006F). + // + // UiDatElement applies 0x3B on a state change; UiButton does not, and + // this element builds as a button — so driving the state ALONE leaves + // it hidden forever, which is exactly the regression this pins. 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); + Assert.False(indicator.Visible); // Ghosted at rest - 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); + Assert.True(indicator.Visible); // Normal once text is unseen - // ...and returning to the bottom puts it back. ctrl.Transcript.Scroll.ScrollToEnd(); ctrl.UpdateUnreadIndicator(); - Assert.Equal("Ghosted", ((UiButton)indicator).ActiveState); - _ = stateful; + Assert.False(indicator.Visible); // back to Ghosted } [Fact] diff --git a/tools/LayoutDump/Program.cs b/tools/LayoutDump/Program.cs index 3bdd0615..255917ff 100644 --- a/tools/LayoutDump/Program.cs +++ b/tools/LayoutDump/Program.cs @@ -23,6 +23,7 @@ if (args.Length == 0) bool showStates = args.Contains("--states"); bool showColors = args.Contains("--colors"); +bool showProps = args.Contains("--props"); uint[] ids = args.Where(a => !a.StartsWith("--")) .Select(a => Convert.ToUInt32(a, a.StartsWith("0x") ? 16 : 10)) .ToArray(); @@ -141,6 +142,30 @@ void Print(ElementInfo e, int depth) } } + // Raw property ids per state — ToggleBehavior (0x0B) and RolloverEnabled + // (0x13) change how a button interprets a state change, so "which state did + // I set" is not the whole story. + if (showProps) + { + foreach (var (stateId, state) in e.States) + { + if (state.Properties.Values.Count == 0) + continue; + string ids = string.Join(", ", state.Properties.Values + .OrderBy(kv => kv.Key) + .Select(kv => $"0x{kv.Key:X2}={Describe(kv.Value)}")); + + static string Describe(UiPropertyValue v) => v.Kind switch + { + UiPropertyKind.Bool => v.BoolValue.ToString(), + UiPropertyKind.Integer => v.IntegerValue.ToString(), + UiPropertyKind.Enum => $"0x{v.UnsignedValue:X}", + _ => v.Kind.ToString(), + }; + Console.WriteLine($"{pad} state {stateId}: props {ids}"); + } + } + if (showStates && e.States.Count != 0) { string names = string.Join(", ", e.States