diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index 2a636103..a037ed75 100644 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -27,10 +27,12 @@ namespace AcDream.App.UI.Layout; /// /// /// -/// The expand-detail overlay present in the front container carries ONLY named -/// states ("HideDetail"/"ShowDetail") — no "" DirectState entry — so the -/// TryGetValue("") filter in excludes it -/// automatically. +/// The expand-detail icon overlay present in EACH container (back + front) +/// carries ONLY named states ("HideDetail"/"ShowDetail") — no "" DirectState +/// entry — so the TryGetValue("") filter in +/// excludes it from slice extraction; then absorbs +/// it into for the retail +/// HideDetail/ShowDetail click toggle. /// /// public static class DatWidgetFactory @@ -76,6 +78,15 @@ public static class DatWidgetFactory UiElement e = info.Type switch { UiRadar.RetailClassId => new UiRadar(), // gmRadarUI (Register 0x004D8B80) + // The vitals window roots — gmVitalsUI (0x10000009 @0x004BFE10), + // gmFloatyVitalsUI (0x1000004D, stacked 0x2100006C), and + // gmFloatySideVitalsUI (0x10000056, side-by-side 0x21000075). All + // three share the inherited HideDetail/ShowDetail press toggle + // (gmVitalsUI::ListenToElementMessage @0x004BFC00) — see UiVitalsRoot. + UiVitalsRoot.GmVitalsClassId + or UiVitalsRoot.GmFloatyVitalsClassId + or UiVitalsRoot.GmFloatySideVitalsClassId + => new UiVitalsRoot(info, resolve), 1 => BuildButton(info, resolve, elementFont, fontResolve, stringResolve), // UIElement_Button 2 => new UiDatElement(info, resolve) // UIElement_Dragbar (Register @ 0x0046C840) { @@ -446,8 +457,11 @@ public static class DatWidgetFactory /// │ ├── left-cap image (→ front-left sprite) /// │ ├── center image (→ front-tile sprite) /// │ ├── right-cap image (→ front-right sprite) - /// │ └── expand overlay (named "ShowDetail"/"HideDetail" only — NO DirectState — IGNORED) - /// └── text label (Type 0) (IGNORED — Fill/Label providers bound by VitalsController) + /// │ └── expand overlay (named "ShowDetail"/"HideDetail" only — NO DirectState — + /// │ absorbed as the bright fill-clipped detail icon; the back + /// │ container has a matching dim one — see ConfigureDetailOverlay) + /// └── text label (Type 0) (built as a real UiText child by LayoutImporter's meter + /// carve-out; Fill/Label providers bound by VitalsController) /// /// /// @@ -508,6 +522,35 @@ public static class DatWidgetFactory m.FrontLeft = fl; m.FrontTile = ft; m.FrontRight = fr; + + // The expand-detail icon overlays (the 0x100004A9 child of EACH + // container — dim icon in the back track, bright icon in the + // fill-clipped front layer; health heart 0x06007490/91, stamina + // sword 0x06007492/93, mana scepter 0x06007494/95). They author + // media only for the named ShowDetail state (HideDetail authors + // File=0), which is why SliceIds' DirectState filter already + // excludes them from the slice extraction above. Absorb them into + // the meter alongside the slices so the HideDetail/ShowDetail + // click toggle (gmVitalsUI::ListenToElementMessage @0x004BFC00) + // can draw retail's graphical vitals mode. PassToChildren on the + // meter's own media-less HideDetail/ShowDetail StateDescs gates + // the cascade to the absorbed number label + // (UIElement::SetState @0x00464E70). + ElementInfo? backOverlay = DetailOverlay(containers[0]); + ElementInfo? frontOverlay = DetailOverlay(containers[1]); + if (backOverlay is not null || frontOverlay is not null) + { + bool passToChildren = info.States.Values.Any( + static s => s.Name is "HideDetail" or "ShowDetail" && s.PassToChildren); + m.ConfigureDetailOverlay( + backOverlay is not null ? backOverlay.StateMedia["ShowDetail"].File : 0u, + backOverlay?.X ?? 0f, backOverlay?.Y ?? 0f, + backOverlay?.Width ?? 0f, backOverlay?.Height ?? 0f, + frontOverlay is not null ? frontOverlay.StateMedia["ShowDetail"].File : 0u, + frontOverlay?.X ?? 0f, frontOverlay?.Y ?? 0f, + frontOverlay?.Width ?? 0f, frontOverlay?.Height ?? 0f, + passToChildren); + } } else if (containers.Count == 1 && containers[0].StateMedia.ContainsKey("")) { @@ -617,6 +660,18 @@ public static class DatWidgetFactory => container.Children.Count(c => c.StateMedia.TryGetValue("", out var media) && media.File != 0) >= 3; + /// + /// Finds a container's expand-detail icon overlay: the child that authors a + /// non-zero image for the named ShowDetail state and NO DirectState image + /// (the 0x100004A9 shape in both vitals layouts 0x2100006C / 0x21000075). + /// Returns null when the container has none (non-vitals meters). + /// + private static ElementInfo? DetailOverlay(ElementInfo container) + => container.Children.FirstOrDefault(static c => + !c.StateMedia.ContainsKey("") + && c.StateMedia.TryGetValue("ShowDetail", out var media) + && media.File != 0); + private static bool HasStatefulFill(ElementInfo container) => container.States.Any(pair => pair.Key != UiStateInfo.DirectStateId diff --git a/src/AcDream.App/UI/Layout/LayoutImporter.cs b/src/AcDream.App/UI/Layout/LayoutImporter.cs index 32cbaa16..6bc56c04 100644 --- a/src/AcDream.App/UI/Layout/LayoutImporter.cs +++ b/src/AcDream.App/UI/Layout/LayoutImporter.cs @@ -181,11 +181,15 @@ public static class LayoutImporter // widgets via FindElement and bind LinesProvider without injecting new runtime nodes. // // Type-3 children are SKIPPED here because BuildMeter already consumed them (they - // carry the 3-slice sprite ids, not text content; building them again would - // double-draw the bar art). All other child types are built normally. + // carry the 3-slice sprite ids + the ShowDetail icon overlays, not text content; + // building them again would double-draw the bar art). All other child types are + // built normally. // - // Safe for vitals: the health/stamina/mana meters have ONLY Type-3 slice children - // (no text children). This loop finds nothing for them → no change to vitals. + // For vitals this loop builds exactly the cur/max number label (0x100000EB/ED/EF, + // Type 0 → merged Type 12 → UiText): VitalsController binds its LinesProvider, and + // the HideDetail/ShowDetail cascade from the window root reaches it THROUGH the + // meter (UiMeter.TrySetRetailState forwards to stateful children), flipping its + // per-state 0x3B visibility exactly like retail's PassToChildren SetState walk. foreach (var child in info.Children) { if (child.Type == 3) continue; // slice containers: already consumed by BuildMeter diff --git a/src/AcDream.App/UI/Layout/UiVitalsRoot.cs b/src/AcDream.App/UI/Layout/UiVitalsRoot.cs new file mode 100644 index 00000000..429a0560 --- /dev/null +++ b/src/AcDream.App/UI/Layout/UiVitalsRoot.cs @@ -0,0 +1,95 @@ +using System; + +namespace AcDream.App.UI.Layout; + +/// +/// Behavioral root widget for the vitals windows — retail's gmVitalsUI +/// family (gmVitalsUI 0x10000009, gmFloatyVitalsUI 0x1000004D = +/// the stacked window LayoutDesc 0x2100006C, gmFloatySideVitalsUI +/// 0x10000056 = the side-by-side window LayoutDesc 0x21000075; registrations +/// @0x004BFE10 / @0x004CED90 / @0x004D0490). +/// +/// +/// The click toggle (gmVitalsUI::ListenToElementMessage @0x004BFC00, +/// inherited verbatim by both floaty subclasses): on Element_mouse_press +/// (0x1C) with dwParam1 7 (left) or 0xA (right) the root flips +/// SetState(m_state == HideDetail ? ShowDetail : HideDetail). +/// State semantics from the authored data + UIElement::OnSetAttribute +/// case 8 (0x3B = invisible, SetVisible(value == 0) @0x00462DAE): +/// +/// +/// Undef (login default; DefaultState is authored +/// Undef and nothing calls SetState at init): numbers visible, no icons — +/// visually identical to HideDetail. +/// HideDetail (0x10000006): the numeric mode — the +/// cur/max labels author {0x3B:false} (visible); the icon overlays author +/// File=0 (nothing). +/// ShowDetail (0x10000007): the graphical mode — +/// labels author {0x3B:true} (hidden); each bar shows its authored icon pair +/// (dim back + bright fill-clipped front: heart / sword / scepter). +/// +/// +/// +/// The first press from Undef lands on HideDetail (retail's exact expression: +/// ecx = (m_state == 0x10000006); SetState(ecx + 0x10000006) — any +/// state that is not HideDetail, including the initial Undef, goes to +/// HideDetail first), so the first click appears to do nothing and the second +/// enters icon mode. NOT persisted anywhere: gmGamePlayUI::SaveScreenLayout +/// @0x004EAD50 writes only window rects, and no PlayerModule option is +/// touched — the mode resets to Undef every session, per window. +/// +/// +/// +/// Press routing: retail broadcasts the press to the pressed element +/// and forwards up the parent chain (UIElement::ListenToElementMessage +/// @0x00462340 → ForwardElementMessage), but UIElement_Dragbar +/// (@0x0046C850) and UIElement_Resizebar (@0x0046B930) both return 2 +/// (consumed) unconditionally — a press that starts a window move or resize +/// never reaches the vitals root. Mirrored here: presses whose hit target is a +/// move handle or resize grip are ignored. The handler returns false so the +/// event keeps bubbling, matching retail's fall-through to the base handler. +/// +/// +public sealed class UiVitalsRoot : UiDatElement +{ + /// gmVitalsUI registered element class (@0x004BFE1A). + public const uint GmVitalsClassId = 0x10000009u; + /// gmFloatyVitalsUI registered element class (@0x004CED9A) — the stacked window root. + public const uint GmFloatyVitalsClassId = 0x1000004Du; + /// gmFloatySideVitalsUI registered element class (@0x004D049A) — the side-by-side window root. + public const uint GmFloatySideVitalsClassId = 0x10000056u; + + public UiVitalsRoot(ElementInfo info, Func resolve) + : base(info, resolve) + { + } + + public override bool OnEvent(in UiEvent e) + { + if (e.Type is UiEventType.MouseDown or UiEventType.RightDown + && !PressConsumedByChrome(e.Target)) + { + // gmVitalsUI::ListenToElementMessage @0x004BFC04: + // this->SetState(m_state == HideDetail ? ShowDetail : HideDetail) + // then falls through to the base handler (keep bubbling → false). + TrySetRetailState( + ActiveRetailStateId == RetailUiStateIds.HideDetail + ? RetailUiStateIds.ShowDetail + : RetailUiStateIds.HideDetail); + } + return false; + } + + /// + /// True when the pressed element is (or sits inside) a window-move handle + /// or resize grip — the two retail element classes that consume the press + /// before it can bubble to the vitals root. + /// + private bool PressConsumedByChrome(UiElement? target) + { + for (UiElement? w = target; w is not null && w != this; w = w.Parent) + if (w.WindowMoveHandle || w is UiResizeGrip) + return true; + return false; + } +} diff --git a/src/AcDream.App/UI/UiMeter.cs b/src/AcDream.App/UI/UiMeter.cs index db74eaaf..6a90d87e 100644 --- a/src/AcDream.App/UI/UiMeter.cs +++ b/src/AcDream.App/UI/UiMeter.cs @@ -25,6 +25,23 @@ public sealed class UiMeter : UiElement, IUiDatStateful private readonly Dictionary _stateLabels = new(); private (string Text, UiMeterLabelAlign Align)? _activeStateLabel; + // Vitals ShowDetail icon overlays (see ConfigureDetailOverlay). + private bool _detailConfigured; + private bool _detailPassToChildren; + private uint _detailBackSprite; + private uint _detailFrontSprite; + private (float X, float Y, float W, float H) _detailBackRect; + private (float X, float Y, float W, float H) _detailFrontRect; + + /// True when this meter absorbed the vitals detail-icon overlays. Exposed for tests. + internal bool HasDetailOverlay => _detailConfigured; + /// The dim back-container detail icon (ShowDetail media). Exposed for tests. + internal uint DetailBackSprite => _detailBackSprite; + /// The bright fill-clipped front-container detail icon. Exposed for tests. + internal uint DetailFrontSprite => _detailFrontSprite; + /// The back overlay's authored meter-local rect. Exposed for tests. + internal (float X, float Y, float W, float H) DetailBackRect => _detailBackRect; + /// Dat element id, set by the layout importer so duplicated page copies can be scoped. public uint ElementId { get; set; } @@ -114,8 +131,54 @@ public sealed class UiMeter : UiElement, IUiDatStateful _stateLabels[stateId] = (text, align); } + /// + /// Registers the vitals "detail" icon overlays absorbed from the meter's + /// two slice containers (the 0x100004A9 children — health heart + /// 0x06007490/91, stamina sword 0x06007492/93, mana scepter 0x06007494/95). + /// Retail authors each icon TWICE: a dim version in the BACK container + /// (drawn unclipped over the empty track) and a bright version in the + /// FRONT container (fill-clipped with the rest of the front layer — + /// UIElement_Meter::DrawChildren @0x0046FBD0 clips the whole + /// m_pcChildImage child, element id 2, to the 0x69 fraction), so + /// the icon itself fills up with the vital. Both overlays author media + /// ONLY for ShowDetail (HideDetail authors File=0), so they + /// draw solely in that state. Rects are the overlays' authored X/Y/W/H + /// local to the meter (the containers span the meter at 0,0). + /// + internal void ConfigureDetailOverlay( + uint backSprite, float backX, float backY, float backW, float backH, + uint frontSprite, float frontX, float frontY, float frontW, float frontH, + bool passToChildren) + { + _detailBackSprite = backSprite; + _detailBackRect = (backX, backY, backW, backH); + _detailFrontSprite = frontSprite; + _detailFrontRect = (frontX, frontY, frontW, frontH); + _detailConfigured = backSprite != 0 || frontSprite != 0; + _detailPassToChildren = passToChildren; + } + public bool TrySetRetailState(uint stateId) { + // Vitals detail toggle (gmVitalsUI::ListenToElementMessage @0x004BFC00 + // flips the window root between HideDetail 0x10000006 and ShowDetail + // 0x10000007; the meter receives the state through the authored + // PassToChildren cascade — UIElement::SetState @0x00464E70). The + // meter's own HideDetail/ShowDetail StateDescs are media-less and + // exist purely to keep propagating (PassToChildren=true), so forward + // to the absorbed text child (whose per-state 0x3B hides the numbers + // in ShowDetail) and let OnDraw key the icon overlays off the state. + if (_detailConfigured + && stateId is RetailUiStateIds.HideDetail or RetailUiStateIds.ShowDetail) + { + ActiveRetailStateId = stateId; + if (_detailPassToChildren) + foreach (UiElement child in Children) + if (child is IUiDatStateful stateful) + stateful.TrySetRetailState(stateId); + return true; + } + bool hasFill = _stateFillSprites.TryGetValue(stateId, out uint spriteId); bool hasLabel = _stateLabels.TryGetValue(stateId, out var caption); if (!hasFill && !hasLabel) @@ -198,9 +261,23 @@ public sealed class UiMeter : UiElement, IUiDatStateful // drawn at FULL width too but horizontally CLIPPED to the fill fraction. // The front carries its own right-cap (shown at 100%); clipping below 100% // removes it and reveals the back track's right-cap — retail's scissor-fill. + // + // ShowDetail icon overlays ride their authored containers: the dim BACK + // icon draws over the full track (its 0x100004A9 child draws AFTER the + // three slice children — higher ReadOrder within the back container); + // the bright FRONT icon is clipped with the rest of the front layer to + // the fill fraction (retail clips the whole element-id-2 child). + bool detail = _detailConfigured + && ActiveRetailStateId == RetailUiStateIds.ShowDetail; DrawHBar(ctx, resolve, BackLeft, BackTile, BackRight, Width); + if (detail) + DrawDetailIcon(ctx, resolve, _detailBackSprite, _detailBackRect, Width); if (pct is not null && p > 0f) + { DrawHBar(ctx, resolve, FrontLeft, FrontTile, FrontRight, Width * p); + if (detail) + DrawDetailIcon(ctx, resolve, _detailFrontSprite, _detailFrontRect, Width * p); + } } } else @@ -302,6 +379,30 @@ public sealed class UiMeter : UiElement, IUiDatStateful ctx.DrawSprite(tex, 0f, y, w, visibleH, 0f, v0, 1f, v1, System.Numerics.Vector4.One); } + /// + /// Draws one ShowDetail icon overlay at its authored meter-local rect, + /// horizontally clipped to local px (the back icon + /// passes the full width; the front icon passes Width * fraction, + /// mirroring retail's whole-front-container fill clip). The visible portion + /// is UV-cropped so the icon reveals left-to-right with the fill. Height is + /// clamped to the meter's box — retail clips children to the parent rect + /// (the stamina FRONT overlay authors H=28 in a 16px bar; retail shows 16). + /// + private void DrawDetailIcon( + UiRenderContext ctx, Func resolve, + uint spriteId, (float X, float Y, float W, float H) rect, float clipW) + { + if (spriteId == 0 || rect.W <= 0f || rect.H <= 0f) return; + var (tex, _, _) = resolve(spriteId); + if (tex == 0) return; + float visibleW = MathF.Min(rect.W, clipW - rect.X); + if (visibleW <= 0f) return; + float h = MathF.Min(rect.H, Height - rect.Y); + if (h <= 0f) return; + float u1 = visibleW / rect.W; + ctx.DrawSprite(tex, rect.X, rect.Y, visibleW, h, 0f, 0f, u1, 1f, Vector4.One); + } + /// Draw a slice over local [, /// pieceX+], with the texture repeating every /// px (UV-repeat — the UI texture is GL_REPEAT-wrapped). diff --git a/src/AcDream.App/UI/UiText.cs b/src/AcDream.App/UI/UiText.cs index 5bd2ab0e..1a9f64e3 100644 --- a/src/AcDream.App/UI/UiText.cs +++ b/src/AcDream.App/UI/UiText.cs @@ -386,6 +386,22 @@ public sealed class UiText : UiElement, IUiDatStateful && TryColor(color, out Vector4 resolvedColor)) DefaultColor = resolvedColor; + // Per-state Invisible (dat property 0x3B): retail's SetState applies the + // committed state's properties through UIElement::OnSetAttribute, whose + // case 8 (@0x00462DAE, property id 0x33 + 8 = 0x3B) is + // `SetVisible(value == 0)`. Same NAMED-states-only scoping as + // UiDatElement.TrySetRetailState (a DirectState 0x3B is the + // construction-time "authored invisible" class — #408, separately + // gated). First consumer here: the vitals cur/max number labels + // (0x100000EB/ED/EF) author HideDetail={0x3B:false} / + // ShowDetail={0x3B:true} — the numbers hide when the click toggle + // switches the window to the graphical icon mode. + if (stateId != UiStateInfo.DirectStateId + && state is not null + && state.Properties.Values.TryGetValue(0x3Bu, out var invisibleProp) + && invisibleProp.Kind == UiPropertyKind.Bool) + Visible = !invisibleProp.BoolValue; + // Retail's state cascade also swaps the element's AUTHORED string // when the incoming state carries its own 0x17 (the friends row's // status cell: 'Online'/'Offline' with per-state colors). Resolved diff --git a/tests/AcDream.App.Tests/UI/Layout/VitalsDetailToggleTests.cs b/tests/AcDream.App.Tests/UI/Layout/VitalsDetailToggleTests.cs new file mode 100644 index 00000000..75fb4e4f --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/VitalsDetailToggleTests.cs @@ -0,0 +1,199 @@ +using AcDream.App.UI; +using AcDream.App.UI.Layout; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// The retail vitals numeric/graphical click toggle, against the committed +/// vitals fixture (vitals_2100006C.json) — no dats, no GL. +/// +/// Retail mechanism (fully derived 2026-08-17): +/// +/// gmVitalsUI::ListenToElementMessage @0x004BFC00 — +/// mouse press (msg 0x1C) with dwParam1 7 (left) or 0xA (right) flips +/// SetState(m_state == HideDetail ? ShowDetail : HideDetail). +/// UIElement::SetState @0x00464E70 — the state +/// cascades through the authored PassToChildren chain (root and meters +/// author media-less HideDetail/ShowDetail with PassToChildren=true). +/// The cur/max labels author per-state 0x3B (invisible — +/// UIElement::OnSetAttribute case 8 @0x00462DAE is +/// SetVisible(value == 0)): HideDetail={0x3B:false} (numbers shown), +/// ShowDetail={0x3B:true} (numbers hidden). +/// The 0x100004A9 overlay children author icon media only +/// for ShowDetail (heart 0x06007490/91, sword 0x06007492/93, scepter +/// 0x06007494/95) — dim back icon unclipped, bright front icon fill-clipped. +/// UIElement_Dragbar @0x0046C850 / +/// UIElement_Resizebar @0x0046B930 consume presses (return 2) — a +/// window move/resize press never toggles. +/// Not persisted: gmGamePlayUI::SaveScreenLayout +/// @0x004EAD50 writes window rects only; each window resets to the +/// authored Undef default per session. +/// +/// +[Trait("Category", "Conformance")] +public class VitalsDetailToggleTests +{ + private const uint DragBarTop = 0x1000063Cu; // Type 2 — WindowMoveHandle + private const uint GripTopLeft = 0x1000063Bu; // Type 9 — UiResizeGrip + + // ── Import shape ───────────────────────────────────────────────────────── + + [Fact] + public void VitalsTree_RootIsVitalsRootWidget() + { + var layout = FixtureLoader.LoadVitals(); + Assert.IsType(layout.Root); + } + + [Fact] + public void VitalsTree_MetersAbsorbDetailIconOverlays() + { + var layout = FixtureLoader.LoadVitals(); + + // MeterId → (dim back icon, bright front icon) from the authored + // 0x100004A9 ShowDetail media (format doc §11 + installed-DAT probe). + (uint MeterId, uint Back, uint Front)[] cases = + [ + (VitalsController.Health, 0x06007490u, 0x06007491u), // heart + (VitalsController.Stamina, 0x06007492u, 0x06007493u), // sword + (VitalsController.Mana, 0x06007494u, 0x06007495u), // scepter + ]; + + foreach (var (meterId, back, front) in cases) + { + var m = Assert.IsType(layout.FindElement(meterId)); + Assert.True(m.HasDetailOverlay); + Assert.Equal(back, m.DetailBackSprite); + Assert.Equal(front, m.DetailFrontSprite); + } + + // Health's authored overlay rect: 18x16 at x=66 (the heart). + var health = Assert.IsType(layout.FindElement(VitalsController.Health)); + Assert.Equal((66f, 0f, 18f, 16f), health.DetailBackRect); + } + + // ── The press toggle ───────────────────────────────────────────────────── + + [Fact] + public void Press_TogglesUndefThenHideDetailThenShowDetail() + { + var layout = FixtureLoader.LoadVitals(); + var root = Assert.IsType(layout.Root); + var stamina = Assert.IsType(layout.FindElement(VitalsController.Stamina)); + var staminaText = Assert.IsType(layout.FindElement(VitalsController.StaminaText)); + + // Login default: authored DefaultState is Undef and nothing calls + // SetState — numbers visible, no icons. + Assert.True(staminaText.Visible); + + // Press 1 (from Undef): retail lands on HideDetail — visually identical + // (labels author {0x3B:false} there), never back to Undef afterwards. + Press(root, root); + Assert.Equal(RetailUiStateIds.HideDetail, root.ActiveRetailStateId); + Assert.Equal(RetailUiStateIds.HideDetail, stamina.ActiveRetailStateId); + Assert.True(staminaText.Visible); + + // Press 2: ShowDetail — the graphical mode. Numbers hidden, icons on. + Press(root, root); + Assert.Equal(RetailUiStateIds.ShowDetail, root.ActiveRetailStateId); + Assert.Equal(RetailUiStateIds.ShowDetail, stamina.ActiveRetailStateId); + Assert.False(staminaText.Visible); + + // Press 3: back to numeric. + Press(root, root); + Assert.Equal(RetailUiStateIds.HideDetail, root.ActiveRetailStateId); + Assert.True(staminaText.Visible); + } + + [Fact] + public void RightPress_TogglesLikeLeftPress() + { + // Retail accepts dwParam1 7 (left) OR 0xA (right) — @0x004BFC19. + var layout = FixtureLoader.LoadVitals(); + var root = Assert.IsType(layout.Root); + + Press(root, root, UiEventType.RightDown); + Assert.Equal(RetailUiStateIds.HideDetail, root.ActiveRetailStateId); + Press(root, root, UiEventType.RightDown); + Assert.Equal(RetailUiStateIds.ShowDetail, root.ActiveRetailStateId); + } + + [Fact] + public void Press_CascadesToAllThreeLabels() + { + var layout = FixtureLoader.LoadVitals(); + var root = Assert.IsType(layout.Root); + + Press(root, root); // Undef → HideDetail + Press(root, root); // HideDetail → ShowDetail + foreach (uint textId in new[] + { + VitalsController.HealthText, + VitalsController.StaminaText, + VitalsController.ManaText, + }) + { + var text = Assert.IsType(layout.FindElement(textId)); + Assert.False(text.Visible); + } + } + + // ── Chrome exclusions ──────────────────────────────────────────────────── + + [Fact] + public void Press_OnDragBar_DoesNotToggle() + { + // Retail UIElement_Dragbar::ListenToElementMessage returns 2 (consumed) + // for every message — the press never reaches gmVitalsUI. + var layout = FixtureLoader.LoadVitals(); + var root = Assert.IsType(layout.Root); + var dragBar = layout.FindElement(DragBarTop); + Assert.NotNull(dragBar); + Assert.True(dragBar!.WindowMoveHandle); + + uint before = root.ActiveRetailStateId; + Press(root, dragBar); + Assert.Equal(before, root.ActiveRetailStateId); + } + + [Fact] + public void Press_OnResizeGrip_DoesNotToggle() + { + // Retail UIElement_Resizebar::ListenToElementMessage returns 2 likewise. + var layout = FixtureLoader.LoadVitals(); + var root = Assert.IsType(layout.Root); + var grip = layout.FindElement(GripTopLeft); + Assert.NotNull(grip); + Assert.IsType(grip); + + uint before = root.ActiveRetailStateId; + Press(root, grip!); + Assert.Equal(before, root.ActiveRetailStateId); + } + + // ── Independence: each window keeps its own state ───────────────────────── + + [Fact] + public void TwoWindows_ToggleIndependently() + { + // Retail: gmFloatyVitalsUI and gmFloatySideVitalsUI each carry their + // own m_state; toggling one never touches the other. + var a = FixtureLoader.LoadVitals(); + var b = FixtureLoader.LoadVitals(); + var rootA = Assert.IsType(a.Root); + var rootB = Assert.IsType(b.Root); + + Press(rootA, rootA); + Press(rootA, rootA); + Assert.Equal(RetailUiStateIds.ShowDetail, rootA.ActiveRetailStateId); + Assert.NotEqual(RetailUiStateIds.ShowDetail, rootB.ActiveRetailStateId); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + /// Delivers a press to as UiRoot's bubble + /// walk would: the hit target rides in . + private static void Press(UiVitalsRoot root, UiElement target, + int type = UiEventType.MouseDown) + => root.OnEvent(new UiEvent(target.EventId, target, type)); +}