From 33b45ee581eb0491bc7a4e5cc32dd7ce865e0f7a Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 8 Aug 2026 10:59:59 +0200 Subject: [PATCH] =?UTF-8?q?fix(ui):=20vendor=20dropdown=20polish=20?= =?UTF-8?q?=E2=80=94=20authored=20arrow-cap=20with=20open/closed=20flip,?= =?UTF-8?q?=20downward=20popup,=20left-aligned=20rows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three gate findings, each settled by authored data rather than invention: the button face is retail's two-piece assembly and the 17x19 arrow-cap 0x1000034E now renders with its authored Normal(closed)/Highlight(open) states; the popup direction is an AUTHORED attribute (UIElement_Menu::Open pc:120210-120252 — bool attr 5, chat authors upward=true, the vendor menu authors nothing and defaults downward), so both menus are now byte-faithful with no special case; and the 19/20px text indents were chat-specific checkbox/LED clearances the vendor rows don't author — measured against the live retail font, "Spell Components" overflowed by 11px and now fits with 8px to spare. Chat's menu defaults are bit-identical and its tests untouched. AP-161's arrow-cap note closes. #351 files the pre-existing FarLoad Debug flake (three sightings today, never in clean-room Release). Co-Authored-By: Claude Sonnet 4.5 --- docs/ISSUES.md | 8 ++ .../UI/Layout/VendorUiController.cs | 94 +++++++++++-- src/AcDream.App/UI/UiMenu.cs | 121 +++++++++++++++-- .../UI/Layout/VendorUiControllerTests.cs | 80 ++++++++++- tests/AcDream.App.Tests/UI/UiMenuTests.cs | 126 ++++++++++++++++++ 5 files changed, 404 insertions(+), 25 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index ab510ba1..3728c01c 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,6 +24,14 @@ What does NOT go here: - Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending. - Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed. +## #351 — LandblockBuildOriginTests.FarLoad_StripsEnvCellsAndPhysics flake (Debug, load-sensitive) + +**Status:** OPEN (filed 2026-08-08). Seen three times today in Debug runs +under parallel-agent load (5.4 implementer, Slice 6 corrections, dropdown +polish), each time proven pre-existing by re-running against the pristine +base commit; never reproduces in clean-room Release. An async streaming +timing assumption most likely; deflake when it next blocks a verdict. + ## #350 — Render-shadow ledger overflow after 2h42m: lifetime int counters in a never-reset accumulator **Status:** FIXED IN TREE 2026-08-08 (pending clean-room + landing). diff --git a/src/AcDream.App/UI/Layout/VendorUiController.cs b/src/AcDream.App/UI/Layout/VendorUiController.cs index 7e7f9399..47cb73c9 100644 --- a/src/AcDream.App/UI/Layout/VendorUiController.cs +++ b/src/AcDream.App/UI/Layout/VendorUiController.cs @@ -176,18 +176,68 @@ public sealed class VendorUiController : IRetainedPanelController /// literally what vendor's OWN button-face child (0x1000034D) /// resolves to in the fixture, and 0x060012B4 (the row /// template's "Highlight" state) is the paired open/pressed look. This - /// is a deliberate, bounded choice: retail's actual closed-button - /// chrome is a separate two-piece label+arrow assembly - /// (0x1000034D label + 0x1000034E a 17x19 arrow cap with - /// its own Normal/Highlight sprites 0x060012B1/0x060012B2), - /// and 's existing single-texture 3-slice - /// DrawButtonFace (end caps sized for chat's 46px LED-arrow - /// texture) does not model a second overlaid arrow icon. Reworking - /// DrawButtonFace into a two-piece label+arrow renderer would - /// have meant redesigning UiMenu's draw model for a purely cosmetic - /// gain while the actual reported bug (nothing renders at all) is - /// fixed by this simpler substitution; the arrow glyph gap is recorded - /// as a residual visual approximation (see the register, AP-161). + /// remains a deliberate, bounded choice — 's + /// single-texture 3-slice DrawButtonFace (end caps sized for + /// chat's 46px LED-arrow texture) is not redesigned into a two-piece + /// label+arrow renderer for this plain gold body. + /// + /// + /// G6 (vendor gate finding, item 1 — the missing green arrow + /// indicator): closes the arrow-cap half of AP-161's residual. + /// Retail's actual closed-button chrome IS a separate two-piece + /// label+arrow assembly — label 0x1000034D (100x18, HJustify + /// LEFT, verified via the fixture) + a SEPARATE 17x19 arrow-cap image + /// 0x1000034E docked at the button's right edge (X=100,Y=0 + /// within the 117-wide button), with its own Normal/Highlight states + /// resolving to 0x060012B1 (closed)/0x060012B2 (open) — + /// verified live via tools/VendorLayoutScan's resolved + /// command reading the element's two StateMedia images directly out of + /// client_local_English.dat. / + /// draw this AS AN OVERLAY on top + /// of the existing 3-slice body (additive, not a DrawButtonFace rewrite) + /// so the green arrow now flips down (closed)/up (open) exactly like + /// retail, without touching chat's face (whose arrow is baked into its + /// own texture and never sets these). + /// + /// + /// G7 (vendor gate finding, item 2 — the popup opened upward instead + /// of downward). Retail's UIElement_Menu::Open + /// (pc:120210-120252, 0x0046cc30) places the popup at + /// ScreenY1(button) (the button's own BOTTOM edge — i.e. BELOW + /// it) unless the menu authors bool attribute 5 true, in which case it + /// places it at ScreenY0(button) - popupHeight (ABOVE). + /// UIElement::GetAttribute_Bool (pc:106749-106778) defaults + /// an ABSENT attribute to false. Element 0x100000BF's resolved + /// attribute bag (the same fixture cited throughout this doc) carries NO + /// property "5" at all — unlike chat's channel menu (0x10000014 in + /// LayoutDesc 0x21000006), which explicitly authors property "5" = + /// true. So retail opens vendor's dropdown DOWNWARD and chat's + /// UPWARD — two different authored choices, not one hardcoded direction. + /// (default true, preserving + /// chat's untouched behavior) is set false here to match. + /// Retail's Open has no dynamic screen-edge clamp or flip of its + /// own — the direction is the fixed authored attribute, full stop — so + /// none is added here either; see 's own + /// doc for the citation. + /// + /// + /// G8 (vendor gate finding, item 3 — popup/button text overflowing + /// instead of sitting flush left). + /// (19px) and (20px) are CHAT's + /// own authored offsets — clearing space for a baked-in row checkbox and + /// a button-face LED socket, respectively. Vendor's row template + /// (0x10000352, live-dat verified HJustify=Left) has no + /// checkbox child at all, and vendor's button label child + /// (0x1000034D) is itself HJustify=Left at X=0 with no LED + /// art — so both indents are set to 0 here, retail's own left-justified + /// UiText convention for an icon-less label (every OTHER + /// dat-driven UiText in this codebase sets Padding=0f). + /// Reusing chat's 19px row indent measurably overflowed the authored + /// 100px-wide row: the longest category label, "Spell Components", + /// measures 92px at the default retail font (Font 0x40000000, + /// live-measured via tools/VendorLayoutScan measure) — + /// 19+92=111px > the 100px row, an 11px overflow; with 0px it fits + /// with 8px to spare. /// /// private const int TypeMenuRowsPerColumn = 6; @@ -197,6 +247,10 @@ public sealed class VendorUiController : IRetainedPanelController private const uint TypeMenuItemHighlightSprite = 0x060012B4u; private const uint TypeMenuNormalSprite = 0x060012B3u; private const uint TypeMenuPressedSprite = 0x060012B4u; + // G6: the separate arrow-cap overlay (element 0x1000034E) — see the + // class doc's "G6" paragraph above. + private const uint TypeMenuArrowCapClosedSprite = 0x060012B1u; + private const uint TypeMenuArrowCapOpenSprite = 0x060012B2u; // G5 (vendor gate finding): the popup's docked scrollbar (element // 0x10000351, verified via tools/VendorLayoutScan against the live dat — @@ -413,6 +467,22 @@ public sealed class VendorUiController : IRetainedPanelController _typeMenu.ScrollThumbBottomSprite = TypeMenuScrollThumbBottomSprite; _typeMenu.ScrollUpSprite = TypeMenuScrollUpSprite; _typeMenu.ScrollDownSprite = TypeMenuScrollDownSprite; + // G6: the separate open/closed arrow-cap overlay — see the class + // doc's "G6" paragraph above. + _typeMenu.ArrowCapClosedSprite = TypeMenuArrowCapClosedSprite; + _typeMenu.ArrowCapOpenSprite = TypeMenuArrowCapOpenSprite; + // G7: retail authors NO bool attribute 5 on this menu, so + // UIElement_Menu::Open's absent-defaults-false path opens the popup + // DOWNWARD (ScreenY1, the button's own bottom edge) — see the class + // doc's "G7" paragraph above. Chat's own menu is unaffected (its + // controller never touches this property, leaving UiMenu's true + // default). + _typeMenu.OpenUpward = false; + // G8: vendor's row/button-label children carry no checkbox/LED art + // to clear (unlike chat's), so both indents are flush-left (0) — + // see the class doc's "G8" paragraph above. + _typeMenu.TextIndent = 0f; + _typeMenu.ButtonTextIndent = 0f; _typeMenu.OnSelect = payload => { if (payload is uint mask) SelectCategory(mask); diff --git a/src/AcDream.App/UI/UiMenu.cs b/src/AcDream.App/UI/UiMenu.cs index 044b967f..e1cb9576 100644 --- a/src/AcDream.App/UI/UiMenu.cs +++ b/src/AcDream.App/UI/UiMenu.cs @@ -101,12 +101,57 @@ public sealed class UiMenu : UiElement private const int Border = RetailChromeSprites.Border; // 8-piece bevel thickness (5px) // The row sprites 0x0600124E/4D bake a checkbox/checkmark into the leftmost ~17px // square; the label starts just past it (box width + small gap) so text aligns with - // the box instead of overlapping it. - private const float TextIndent = 19f; + // the box instead of overlapping it. Settable (not const) because this is a CHAT- + // specific authored offset: vendor's category dropdown reuses a plain row-highlight + // sprite with no baked checkbox (LayoutDesc 0x21000043 row template 0x10000352 has + // no child glyph — verified via a live-dat scan), so VendorUiController overrides + // this to 0 — retail's own left-justified UiText convention for an icon-less label + // (every other dat-driven UiText in this codebase sets Padding=0f; see + // DatWidgetFactory.BuildText). Leaving chat's TextIndent baked in here would overflow + // vendor's 100px-wide row for its longest label ("Spell Components" measures 92px at + // the default retail font — 19+92=111 > 100, an 11px overflow; with 0, 92 < 100 fits). + public float TextIndent { get; set; } = 19f; // The button face sprite (0x06004D65/66) bakes a status LED (red→green) into its // left socket (~x4–20 of the 46px button); the caption starts past it so it doesn't - // render over the LED. - private const float ButtonTextIndent = 20f; + // render over the LED. Settable for the same reason as TextIndent: vendor's button + // face substitutes a row sprite with no LED, and its authored label child + // (0x1000034D) is itself HJustify=Left at X=0 — VendorUiController overrides this + // to 0 to match. + public float ButtonTextIndent { get; set; } = 20f; + + /// + /// G6 (vendor gate finding, item 1 — missing arrow indicator): a SEPARATE small + /// image piece some menus author to the right of the button face, whose visible + /// state flips between closed and open. Retail: UIElement_Menu::UpdateState + /// (pc:120101-120105, 0x0046cad0) writes attribute 0xe (m_open) + /// on every open/close, which drives the arrow-cap + /// child's own StateDesc selection between its "Normal" (closed) and "Highlight" + /// (open) states. Vendor's category dropdown authors this as element + /// 0x1000034E — a 17x19 image docked at the right edge of the 117-wide + /// button (X=100,Y=0), states Normal=0x060012B1 (closed, verified live-dat + /// via tools/VendorLayoutScan resolved reading its two StateMedia images) + /// and Highlight=0x060012B2 (open) — sibling of the label child + /// 0x1000034D (see 's doc). Chat's own button + /// face BAKES its arrow into the single / + /// texture already (the right cap of the 46px + /// 0x06004D65/66 LED-arrow art), so it needs no separate overlay — these default to + /// 0 (no-op, skips a 0 id) and chat never sets them. + /// + public uint ArrowCapClosedSprite { get; set; } + public uint ArrowCapOpenSprite { get; set; } + /// Authored native size of the arrow-cap overlay (17x19 for vendor's + /// dropdown) — drawn unstretched, right-anchored to the button's own width, exactly + /// mirroring the authored element's own X=Width-17,Y=0 placement. + public float ArrowCapWidth { get; set; } = 17f; + public float ArrowCapHeight { get; set; } = 19f; + + /// The arrow-cap sprite id would currently + /// draw (0 if neither is authored) — a read-only projection of _open onto + /// the two configured sprites, exposed so a controller/test can assert the + /// closed/open flip without a full render pass (the class has no OnDraw test seam + /// otherwise, matching how is exposed for the same + /// reason). + public uint CurrentArrowCapSprite => _open ? ArrowCapOpenSprite : ArrowCapClosedSprite; public UiDatFont? DatFont { get; set; } public AcDream.App.Rendering.BitmapFont? Font { get; set; } @@ -144,6 +189,36 @@ public sealed class UiMenu : UiElement private float OuterW => InteriorW + 2 * Border; private float OuterH => InteriorH + 2 * Border; + /// + /// G7 (vendor gate finding, item 2 — popup direction): port of retail + /// UIElement_Menu::Open (pc:120210-120252, 0x0046cc30)'s Y placement: + /// edi_3 = attr5 ? ScreenY0(button) - popupHeight // ABOVE + /// : ScreenY1(button) // BELOW (button's own bottom edge) + /// where attr5 is UIElement::GetAttribute_Bool(this, 5, ...) — a PER-MENU + /// authored bool property, not a global convention. GetAttribute_Bool + /// (pc:106749-106778, 0x00460be0) defaults an ABSENT property to false + /// (InqProperty fails -> *arg3 = 0). Verified against both fixtures: + /// chat's channel menu (LayoutDesc 0x21000006, element 0x10000014) + /// authors property "5" = BoolValue: true (opens UP), while vendor's category + /// dropdown (LayoutDesc 0x21000012, element 0x100000BF) has NO property + /// "5" at all in its resolved attribute bag (opens DOWN, the absent-defaults-false + /// case). There is no dynamic screen-edge flip/clamp anywhere in Open — the + /// direction is a fixed per-menu authored choice, not a runtime decision, so this + /// port does not add one either (see 's own note on why no + /// clamp was added). Default true preserves chat's exact upward geometry + /// byte-for-byte (the class's only behavior before this property existed); + /// sets this false to + /// match its own authored (absent) attribute. + /// + public bool OpenUpward { get; set; } = true; + + /// Local-space Y of the popup's own top-left corner, relative to the + /// button's local origin (button occupies y=0..Height). Upward: the popup's BOTTOM + /// touches the button's TOP (y=0), so top = -. Downward: the + /// popup's TOP touches the button's BOTTOM (y=Height) — retail's ScreenY1. + /// Shared by drawing, hit-testing, and event math so all three agree. + private float PopupTop => OpenUpward ? -OuterH : Height; + public UiMenu() { CapturesPointerDrag = true; } /// The menu draws its own button face + popup; its dat label/row children @@ -161,6 +236,9 @@ public sealed class UiMenu : UiElement if (tex != 0 && tw > 0) DrawButtonFace(ctx, tex, tw); } DrawLabel(ctx, ButtonLabelProvider?.Invoke() ?? "", ButtonTextIndent, (Height - LineH()) * 0.5f, TextColor); + + // G6: the open/closed arrow-cap overlay — see ArrowCapClosedSprite's doc comment. + if (resolve is not null) DrawArrowCap(ctx, resolve); } // 3-slice caps for the 46px LED-arrow button face (0x06004D65): a LEFT cap holding the @@ -178,6 +256,19 @@ public sealed class UiMenu : UiElement ctx.DrawSprite(tex, Width - FaceCapR, 0f, FaceCapR, Height, uR, 0f, 1f, 1f, Vector4.One); // arrow cap } + /// G6: the closed/open arrow-cap overlay (see 's + /// doc comment) — right-anchored, drawn at native size, unstretched (retail's own image + /// element draw, no 3-slice). No-op when neither sprite id is authored (chat's case). + private void DrawArrowCap(UiRenderContext ctx, Func resolve) + { + uint id = _open ? ArrowCapOpenSprite : ArrowCapClosedSprite; + if (id == 0) return; + var (tex, tw, th) = resolve(id); + if (tex == 0 || tw == 0 || th == 0) return; + float dx = Width - ArrowCapWidth; + ctx.DrawSprite(tex, dx, 0f, ArrowCapWidth, ArrowCapHeight, 0f, 0f, 1f, 1f, Vector4.One); + } + /// The button width that fits "LED cap + channel label + arrow cap" — retail /// sizes the talk-focus button to its selected label. The controller widens the button /// to this and reflows the input field to start after it. @@ -215,7 +306,7 @@ public sealed class UiMenu : UiElement /// before G5. private void DrawGridPopup(UiRenderContext ctx, Func resolve) { - float outerTop = -OuterH; // popup bottom sits at the button top (y=0) + float outerTop = PopupTop; // G7: direction-aware (see PopupTop's doc) float inX = Border, inY = outerTop + Border; // interior origin (inside the bevel) DrawBevel(ctx, resolve, 0f, outerTop, OuterW, OuterH); @@ -252,7 +343,7 @@ public sealed class UiMenu : UiElement { ConfigurePopupScroll(); - float outerTop = -OuterH; + float outerTop = PopupTop; // G7: direction-aware (see PopupTop's doc) float inX = Border, inY = outerTop + Border; DrawBevel(ctx, resolve, 0f, outerTop, OuterW, OuterH); @@ -372,8 +463,12 @@ public sealed class UiMenu : UiElement } protected override bool OnHitTest(float lx, float ly) - => _open ? (lx >= 0 && lx < OuterW && ly >= -OuterH && ly < Height) - : base.OnHitTest(lx, ly); + { + if (!_open) return base.OnHitTest(lx, ly); + if (lx < 0 || lx >= OuterW) return false; + // G7: the union of the button itself + the popup, whichever side it opens on. + return OpenUpward ? (ly >= -OuterH && ly < Height) : (ly >= 0 && ly < Height + OuterH); + } public override bool OnEvent(in UiEvent e) { @@ -405,11 +500,15 @@ public sealed class UiMenu : UiElement if (e.Type != UiEventType.MouseDown) return false; float lx = e.Data1, ly = e.Data2; - if (_open && ly < 0) // clicked inside the upward popup + // G7: direction-aware — the popup occupies ly<0 when it opens upward (chat), + // or ly>=Height (past the button's own bottom edge) when it opens downward + // (vendor). See PopupTop's doc comment. + bool clickedInPopup = OpenUpward ? ly < 0 : ly >= Height; + if (_open && clickedInPopup) { // Map into the bevel interior, then to (col,row). Clicks in the bevel ring // (outside the interior) just close the menu. - float ix = lx - Border, iy = ly - (-OuterH + Border); + float ix = lx - Border, iy = ly - (PopupTop + Border); if (Scrollable) return HandleScrollablePopupMouseDown(ix, iy); @@ -496,7 +595,7 @@ public sealed class UiMenu : UiElement /// case, reusing for the exact same thumb height. private void DragPopupThumb(float ly) { - float iy = ly - (-OuterH + Border); + float iy = ly - (PopupTop + Border); // G7: direction-aware ConfigurePopupScroll(); float decExtent = System.Math.Clamp(ScrollButtonExtent, 0f, InteriorH); float incExtent = System.Math.Clamp(ScrollButtonExtent, 0f, InteriorH - decExtent); diff --git a/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs index 1e7a0bf7..e2ea658d 100644 --- a/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs @@ -822,11 +822,14 @@ public sealed class VendorUiControllerTests // Food) -> row index 1, column 0. Derive the click point from the // widget's own live geometry (mirrors UiMenu.OnEvent's own math) // rather than a hardcoded pixel constant. + // + // G7: the popup opens DOWNWARD (retail authors no bool attribute 5 + // on 0x100000BF — see the class doc's "G7" paragraph), so its top + // sits at the button's own bottom edge (ly = Height), not -OuterH. const int border = 5; // RetailChromeSprites.Border (UiMenu's private bevel thickness) - float outerH = h.TypeMenu.RowsPerColumn * h.TypeMenu.RowHeight + 2 * border; const int targetRow = 1; float iy = targetRow * h.TypeMenu.RowHeight + h.TypeMenu.RowHeight / 2f; - float ly = iy - outerH + border; + float ly = h.TypeMenu.Height + iy + border; Assert.True(h.TypeMenu.OnEvent(new UiEvent(0, h.TypeMenu, UiEventType.MouseDown, 0, 10, (int)ly))); @@ -834,6 +837,79 @@ public sealed class VendorUiControllerTests Assert.Equal(FoodItemGuid, h.ItemList.GetItem(0)!.ItemId); } + [Fact] + public void CategoryMenu_OpensDownward_NotUpward_MatchingTheAuthoredAbsentAttribute5() + { + // G7: retail's UIElement_Menu::Open (pc:120210-120252) reads a PER-MENU + // bool attribute 5 to decide direction; it defaults ABSENT to false + // (pc:106749-106778) which places the popup at the button's own bottom + // edge. Element 0x100000BF's resolved attribute bag carries no + // property "5" at all (unlike chat's, which authors it true) — so + // OpenUpward must be false here, and the OLD upward click position + // (used before this fix) must no longer resolve anything. + var h = new Harness(); + h.State.Apply(VendorGuid, Profile(), new[] + { + new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500), + new VendorShopItem(FoodItemGuid, -1, 1u, "Bread", (uint)ItemType.Food, 100u, 5), + }); + + Assert.False(h.TypeMenu.OpenUpward); + + Assert.True(h.TypeMenu.OnEvent(new UiEvent(0, h.TypeMenu, UiEventType.MouseDown, 0, 10, 5))); // open + + // The OLD (pre-fix) upward click position for row 1 ("Food") — see the + // math this same test used before G7 — now lands above the button, + // where nothing lives; UiMenu treats it as an ordinary button click + // and just re-closes the still-open menu instead of picking a row. + const int border = 5; + float outerH = h.TypeMenu.RowsPerColumn * h.TypeMenu.RowHeight + 2 * border; + const int targetRow = 1; + float iy = targetRow * h.TypeMenu.RowHeight + h.TypeMenu.RowHeight / 2f; + float oldUpwardLy = iy - outerH + border; + Assert.True(h.TypeMenu.OnEvent(new UiEvent(0, h.TypeMenu, UiEventType.MouseDown, 0, 10, (int)oldUpwardLy))); + + // Selection is unchanged (still Armor, the fresh-open default) and the + // item list was not re-scoped to Food — proving the old position no + // longer hits the popup at all. + Assert.Equal("Armor", h.TypeMenu.Items.Single(i => Equals(i.Payload, h.TypeMenu.Selected)).Label); + Assert.Equal(ArmorItemGuid, h.ItemList.GetItem(0)!.ItemId); + } + + [Fact] + public void CategoryMenu_ArrowCapSprites_AreWiredAndDistinctForOpenVsClosed() + { + // G6: retail's authored arrow-cap element 0x1000034E flips between its + // Normal (closed, 0x060012B1) and Highlight (open, 0x060012B2) states — + // confirm the controller actually wires both, non-zero and distinct, + // and that opening the dropdown flips CurrentArrowCapSprite the same + // way UiMenuTests exercises the mechanism generically. + var h = new Harness(); + h.State.Apply(VendorGuid, Profile(), new[] + { + new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500), + }); + + Assert.NotEqual(0u, h.TypeMenu.ArrowCapClosedSprite); + Assert.NotEqual(0u, h.TypeMenu.ArrowCapOpenSprite); + Assert.NotEqual(h.TypeMenu.ArrowCapClosedSprite, h.TypeMenu.ArrowCapOpenSprite); + + Assert.Equal(h.TypeMenu.ArrowCapClosedSprite, h.TypeMenu.CurrentArrowCapSprite); // closed by default + Assert.True(h.TypeMenu.OnEvent(new UiEvent(0, h.TypeMenu, UiEventType.MouseDown, 0, 10, 5))); // open + Assert.Equal(h.TypeMenu.ArrowCapOpenSprite, h.TypeMenu.CurrentArrowCapSprite); + } + + [Fact] + public void CategoryMenu_TextIndentsAreFlushLeft_NotChatsCheckboxLedOffsets() + { + // G8: vendor's row template (0x10000352, live-dat HJustify=Left) has + // no checkbox child and its button-label child (0x1000034D) has no + // LED art either — both indents must be 0, not chat's 19px/20px. + var h = new Harness(); + Assert.Equal(0f, h.TypeMenu.TextIndent); + Assert.Equal(0f, h.TypeMenu.ButtonTextIndent); + } + [Fact] public void CloseButton_HidesTheWindowOnly_LeavesTheSessionOpenForARefreshInPlaceReopen() { diff --git a/tests/AcDream.App.Tests/UI/UiMenuTests.cs b/tests/AcDream.App.Tests/UI/UiMenuTests.cs index 15240604..5b780753 100644 --- a/tests/AcDream.App.Tests/UI/UiMenuTests.cs +++ b/tests/AcDream.App.Tests/UI/UiMenuTests.cs @@ -363,4 +363,130 @@ public class UiMenuTests Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, RawX(10), ly))); Assert.NotNull(fired); // popup was still open -> the click landed on a real row } + + // ── Vendor dropdown polish (items 1-3): arrow indicator, popup direction, + // left alignment. See VendorUiController's "G6"/"G7"/"G8" class-doc + // paragraphs for the retail citations these three fixes are ported from. + + [Fact] + public void ArrowCapSprite_FlipsBetweenClosedAndOpen_OnToggle() + { + // G6: UiMenu.CurrentArrowCapSprite projects _open onto the two + // configured sprites — the same selection DrawArrowCap makes every + // frame, exposed as a test seam since the class has no OnDraw harness. + var menu = new UiMenu + { + Width = 80f, Height = 18f, + Items = ChannelItems, + ArrowCapClosedSprite = 111u, + ArrowCapOpenSprite = 222u, + }; + + Assert.Equal(111u, menu.CurrentArrowCapSprite); // closed by default + + Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open + Assert.Equal(222u, menu.CurrentArrowCapSprite); + + // A click in the popup's bevel ring (below every real row, iy >= InteriorH) + // closes without picking anything — the arrow must flip straight back. + Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -1))); + Assert.Equal(111u, menu.CurrentArrowCapSprite); + } + + [Fact] + public void ArrowCapSprite_DefaultsToZero_NoOpForMenusThatDontAuthorOne() + { + // Chat's own controller never sets ArrowCapClosedSprite/OpenSprite (its + // arrow is baked into NormalSprite/PressedSprite already) — confirm the + // class default stays a no-op id so DrawArrowCap's `id == 0` guard fires. + var menu = new UiMenu(); + Assert.Equal(0u, menu.CurrentArrowCapSprite); + Assert.Equal(0u, menu.ArrowCapClosedSprite); + Assert.Equal(0u, menu.ArrowCapOpenSprite); + } + + [Fact] + public void OpenUpward_DefaultsTrue_PreservingChatsExistingUpwardGeometry() + { + // Every test above (chat's own shape) relies on this default never + // changing — G7 added the property but must not move chat's popup. + Assert.True(new UiMenu().OpenUpward); + } + + [Fact] + public void OpenUpward_False_MovesThePopupBelowTheButton_NotAbove() + { + var menu = new UiMenu + { + Width = 80f, Height = 18f, + Items = ChannelItems, + Selected = (object?)ChatChannelKind.Say, + EnabledProvider = ChannelAvailable, + OpenUpward = false, + }; + + object? fired = null; + menu.OnSelect = p => fired = p; + + Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open + + // Where "Chat to All" (index 2, Say) used to resolve when the popup + // opened UPWARD (see Select_AvailableLeftColumnItem_FiresOnSelect, + // ly=-76) is now empty space above the button. With OpenUpward=false + // this ly no longer satisfies "clicked in popup" at all, so UiMenu + // treats it as an ordinary button click and just re-closes the menu — + // it must NOT fire a selection. + Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -76))); + Assert.Null(fired); + + // Re-open and click the SAME row (index 2, "Chat to All"/Say) at its + // NEW, downward position: the popup's top is now at the button's own + // bottom edge (ly = Height), not -OuterH. + Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open + const int border = 5; // RetailChromeSprites.Border + float iy = 2 * menu.RowHeight + menu.RowHeight / 2f; + int ly = (int)(menu.Height + iy + border); + Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, ly))); + Assert.Equal(ChatChannelKind.Say, fired); + } + + [Fact] + public void OpenUpward_False_HitTestCoversTheButtonAndTheDownwardPopup() + { + var menu = new UiMenu { Width = 80f, Height = 18f, Items = ChannelItems, OpenUpward = false }; + Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open + + // Public surface only exposes hit-testing via the protected OnHitTest, + // so exercise it the same indirect way the rest of this file does: + // a MouseDown at a point inside the downward popup's bevel is accepted + // as "in popup" (closes without firing), proving the popup geometry + // itself now lives below Height rather than above 0. + float outerH = menu.RowsPerColumn * menu.RowHeight + 2 * 5; + int belowPopupBottom = (int)(menu.Height + outerH) + 1; // just past the popup's own bottom edge + // Outside the popup entirely -> falls through to the button-toggle path. + object? fired = null; + menu.OnSelect = p => fired = p; + Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, belowPopupBottom))); + Assert.Null(fired); + } + + [Fact] + public void TextIndent_And_ButtonTextIndent_DefaultToChatsBakedIconOffsets() + { + // G8: these used to be private consts (19f/19f-checkbox, 20f-LED); + // converting them to settable properties must not move chat's text. + var menu = new UiMenu(); + Assert.Equal(19f, menu.TextIndent); + Assert.Equal(20f, menu.ButtonTextIndent); + } + + [Fact] + public void TextIndent_And_ButtonTextIndent_AreSettable_ForIconLessMenus() + { + // Vendor's controller sets both to 0 (no checkbox/LED art to clear) — + // confirm the widget actually accepts the override. + var menu = new UiMenu { TextIndent = 0f, ButtonTextIndent = 0f }; + Assert.Equal(0f, menu.TextIndent); + Assert.Equal(0f, menu.ButtonTextIndent); + } }