fix(ui): vendor dropdown polish — authored arrow-cap with open/closed flip, downward popup, left-aligned rows
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
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 <noreply@anthropic.com>
This commit is contained in:
parent
5224e43890
commit
33b45ee581
5 changed files with 404 additions and 25 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -176,18 +176,68 @@ public sealed class VendorUiController : IRetainedPanelController
|
|||
/// literally what vendor's OWN button-face child (<c>0x1000034D</c>)
|
||||
/// resolves to in the fixture, and <c>0x060012B4</c> (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
|
||||
/// (<c>0x1000034D</c> label + <c>0x1000034E</c> a 17x19 arrow cap with
|
||||
/// its own Normal/Highlight sprites <c>0x060012B1</c>/<c>0x060012B2</c>),
|
||||
/// and <see cref="UiMenu"/>'s existing single-texture 3-slice
|
||||
/// <c>DrawButtonFace</c> (end caps sized for chat's 46px LED-arrow
|
||||
/// texture) does not model a second overlaid arrow icon. Reworking
|
||||
/// <c>DrawButtonFace</c> 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 — <see cref="UiMenu"/>'s
|
||||
/// single-texture 3-slice <c>DrawButtonFace</c> (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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>G6 (vendor gate finding, item 1 — the missing green arrow
|
||||
/// indicator): closes the arrow-cap half of AP-161's residual.</b>
|
||||
/// Retail's actual closed-button chrome IS a separate two-piece
|
||||
/// label+arrow assembly — label <c>0x1000034D</c> (100x18, HJustify
|
||||
/// LEFT, verified via the fixture) + a SEPARATE 17x19 arrow-cap image
|
||||
/// <c>0x1000034E</c> docked at the button's right edge (X=100,Y=0
|
||||
/// within the 117-wide button), with its own Normal/Highlight states
|
||||
/// resolving to <c>0x060012B1</c> (closed)/<c>0x060012B2</c> (open) —
|
||||
/// verified live via <c>tools/VendorLayoutScan</c>'s <c>resolved</c>
|
||||
/// command reading the element's two StateMedia images directly out of
|
||||
/// <c>client_local_English.dat</c>. <see cref="UiMenu.ArrowCapClosedSprite"/>/
|
||||
/// <see cref="UiMenu.ArrowCapOpenSprite"/> 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).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>G7 (vendor gate finding, item 2 — the popup opened upward instead
|
||||
/// of downward).</b> Retail's <c>UIElement_Menu::Open</c>
|
||||
/// (<c>pc:120210-120252</c>, <c>0x0046cc30</c>) places the popup at
|
||||
/// <c>ScreenY1(button)</c> (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 <c>ScreenY0(button) - popupHeight</c> (ABOVE).
|
||||
/// <c>UIElement::GetAttribute_Bool</c> (<c>pc:106749-106778</c>) defaults
|
||||
/// an ABSENT attribute to false. Element <c>0x100000BF</c>'s resolved
|
||||
/// attribute bag (the same fixture cited throughout this doc) carries NO
|
||||
/// property "5" at all — unlike chat's channel menu (<c>0x10000014</c> in
|
||||
/// LayoutDesc <c>0x21000006</c>), which explicitly authors property "5" =
|
||||
/// <c>true</c>. So retail opens vendor's dropdown DOWNWARD and chat's
|
||||
/// UPWARD — two different authored choices, not one hardcoded direction.
|
||||
/// <see cref="UiMenu.OpenUpward"/> (default <c>true</c>, preserving
|
||||
/// chat's untouched behavior) is set <c>false</c> here to match.
|
||||
/// Retail's <c>Open</c> 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 <see cref="UiMenu.OpenUpward"/>'s own
|
||||
/// doc for the citation.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>G8 (vendor gate finding, item 3 — popup/button text overflowing
|
||||
/// instead of sitting flush left).</b> <see cref="UiMenu.TextIndent"/>
|
||||
/// (19px) and <see cref="UiMenu.ButtonTextIndent"/> (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
|
||||
/// (<c>0x10000352</c>, live-dat verified <c>HJustify=Left</c>) has no
|
||||
/// checkbox child at all, and vendor's button label child
|
||||
/// (<c>0x1000034D</c>) is itself <c>HJustify=Left</c> at X=0 with no LED
|
||||
/// art — so both indents are set to 0 here, retail's own left-justified
|
||||
/// <c>UiText</c> convention for an icon-less label (every OTHER
|
||||
/// dat-driven <c>UiText</c> in this codebase sets <c>Padding=0f</c>).
|
||||
/// 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 (<c>Font 0x40000000</c>,
|
||||
/// live-measured via <c>tools/VendorLayoutScan measure</c>) —
|
||||
/// 19+92=111px > the 100px row, an 11px overflow; with 0px it fits
|
||||
/// with 8px to spare.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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: <c>UIElement_Menu::UpdateState</c>
|
||||
/// (pc:120101-120105, <c>0x0046cad0</c>) writes attribute <c>0xe</c> (<c>m_open</c>)
|
||||
/// 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
|
||||
/// <c>0x1000034E</c> — a 17x19 image docked at the right edge of the 117-wide
|
||||
/// button (X=100,Y=0), states Normal=<c>0x060012B1</c> (closed, verified live-dat
|
||||
/// via <c>tools/VendorLayoutScan resolved</c> reading its two StateMedia images)
|
||||
/// and Highlight=<c>0x060012B2</c> (open) — sibling of the label child
|
||||
/// <c>0x1000034D</c> (see <see cref="ButtonTextIndent"/>'s doc). Chat's own button
|
||||
/// face BAKES its arrow into the single <see cref="NormalSprite"/>/
|
||||
/// <see cref="PressedSprite"/> 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, <see cref="DrawArrowCap"/> skips a 0 id) and chat never sets them.
|
||||
/// </summary>
|
||||
public uint ArrowCapClosedSprite { get; set; }
|
||||
public uint ArrowCapOpenSprite { get; set; }
|
||||
/// <summary>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.</summary>
|
||||
public float ArrowCapWidth { get; set; } = 17f;
|
||||
public float ArrowCapHeight { get; set; } = 19f;
|
||||
|
||||
/// <summary>The arrow-cap sprite id <see cref="DrawArrowCap"/> would currently
|
||||
/// draw (0 if neither is authored) — a read-only projection of <c>_open</c> 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 <see cref="PopupScroll"/> is exposed for the same
|
||||
/// reason).</summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// G7 (vendor gate finding, item 2 — popup direction): port of retail
|
||||
/// <c>UIElement_Menu::Open</c> (pc:120210-120252, <c>0x0046cc30</c>)'s Y placement:
|
||||
/// <c>edi_3 = attr5 ? ScreenY0(button) - popupHeight // ABOVE
|
||||
/// : ScreenY1(button)</c> // BELOW (button's own bottom edge)
|
||||
/// where <c>attr5</c> is <c>UIElement::GetAttribute_Bool(this, 5, ...)</c> — a PER-MENU
|
||||
/// authored bool property, not a global convention. <c>GetAttribute_Bool</c>
|
||||
/// (pc:106749-106778, <c>0x00460be0</c>) defaults an ABSENT property to <c>false</c>
|
||||
/// (<c>InqProperty</c> fails -> <c>*arg3 = 0</c>). Verified against both fixtures:
|
||||
/// chat's channel menu (LayoutDesc <c>0x21000006</c>, element <c>0x10000014</c>)
|
||||
/// authors property "5" = <c>BoolValue: true</c> (opens UP), while vendor's category
|
||||
/// dropdown (LayoutDesc <c>0x21000012</c>, element <c>0x100000BF</c>) 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 <c>Open</c> — the
|
||||
/// direction is a fixed per-menu authored choice, not a runtime decision, so this
|
||||
/// port does not add one either (see <see cref="OpenUpward"/>'s own note on why no
|
||||
/// clamp was added). Default <c>true</c> preserves chat's exact upward geometry
|
||||
/// byte-for-byte (the class's only behavior before this property existed);
|
||||
/// <see cref="AcDream.App.UI.Layout.VendorUiController"/> sets this <c>false</c> to
|
||||
/// match its own authored (absent) attribute.
|
||||
/// </summary>
|
||||
public bool OpenUpward { get; set; } = true;
|
||||
|
||||
/// <summary>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 = -<see cref="OuterH"/>. Downward: the
|
||||
/// popup's TOP touches the button's BOTTOM (y=Height) — retail's <c>ScreenY1</c>.
|
||||
/// Shared by drawing, hit-testing, and event math so all three agree.</summary>
|
||||
private float PopupTop => OpenUpward ? -OuterH : Height;
|
||||
|
||||
public UiMenu() { CapturesPointerDrag = true; }
|
||||
|
||||
/// <summary>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
|
||||
}
|
||||
|
||||
/// <summary>G6: the closed/open arrow-cap overlay (see <see cref="ArrowCapClosedSprite"/>'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).</summary>
|
||||
private void DrawArrowCap(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> 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);
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
|
|
@ -215,7 +306,7 @@ public sealed class UiMenu : UiElement
|
|||
/// before G5.</summary>
|
||||
private void DrawGridPopup(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> 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 <see cref="UiScrollbar.ThumbRect"/> for the exact same thumb height.</summary>
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue