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
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue