merge(vt): plain combo style for plugin <menu> (owner: the gold dropdowns go)

UiMenu.RetailButtonArt (default true; every retail user unchanged);
MarkupDocument sets plain for plugin markup, style="retail" opts back.
Lead-reviewed diff; 10 new tests; goldens for the retail path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-07 08:17:08 +02:00
commit cfa7030659
5 changed files with 382 additions and 1 deletions

View file

@ -108,9 +108,17 @@ vanishing from the built tree.
| `toggle` | Lamp-style checkbox | `x y w h text checked onclick color` |
| `slider` | Horizontal scalar | `x y w h value onchange` |
| `field` | Single-line editable text | `x y w h text maxlength clearonsubmit onchange onsubmit color background` |
| `menu` | Dropdown selector | `x y w h items selected onchange rows rowheight openupward` |
| `menu` | Dropdown selector | `x y w h items selected onchange rows rowheight openupward style` |
| `list` | Scrollable row list (+ Slice B icon column, + Campaign VT slice 1 multi-column) | `x y w h selected onchange rowheight` + either the single-column `items colors icons iconkind`, or one-to-many `<column>` children (see "Columns" below) — never both |
`menu style` is `plain` (the default) or `retail`: retail's gold pushbutton
art read as an out-of-place "big yellow button" next to a plugin's own dark
list boxes (owner live-client report, 2026-09-07), so a plugin `<menu>` now
draws the flat VTank/Decal `HudCombo` box (list-matching fill/border, a
left-aligned value, and a small ▾) by default; `style="retail"` opts back
into the gold face for a panel that genuinely wants it. Any other value
throws `FormatException` at `Build`.
Common to every element via `ApplyCommon`: `name`/`id` (a stable control
name), `visible` (literal `true`/`false` or a bound `bool` property),
`enabled` (same rule), and `tooltip` (a literal string or `{Binding}` shown

View file

@ -461,6 +461,7 @@ public static class MarkupDocument
Func<string?> menuSelected = BindString(
(string?)el.Attribute("selected"),
binding);
bool menuRetailButtonArt = ValidateMenuStyle((string?)el.Attribute("style"));
var menu = new UiMenu
{
Left = F(el, "x"),
@ -480,6 +481,7 @@ public static class MarkupDocument
PopupBgSprite = 0x0600124Cu,
ItemNormalSprite = 0x0600124Eu,
ItemHighlightSprite = 0x0600124Du,
RetailButtonArt = menuRetailButtonArt,
ButtonLabelProvider = () => menuSelected() ?? string.Empty,
OnSelect = payload =>
{
@ -633,6 +635,25 @@ public static class MarkupDocument
$"{context} must be did, spell, or item (got \"{other}\")"),
};
/// <summary>
/// Owner live-client report 2026-09-07 ("Those BIG gold/yellow buttons HAS
/// to go. That is not how vtank looks."): validates <c>&lt;menu
/// style="..."&gt;</c> and returns the <see cref="UiMenu.RetailButtonArt"/>
/// value it selects. Default (attribute absent, or explicit
/// <c>style="plain"</c>) is the flat VTank/Decal <c>HudCombo</c> box
/// (<c>false</c>) — retail's gold pushbutton art is now an explicit
/// <c>style="retail"</c> opt-in for a plugin panel that genuinely wants
/// it. Any other value is a Build-time author error, same rule as
/// <see cref="ValidateIconKind"/>.
/// </summary>
private static bool ValidateMenuStyle(string? style) => style switch
{
null or "plain" => false,
"retail" => true,
var other => throw new FormatException(
$"<menu style=\"{other}\"> must be plain or retail"),
};
/// <summary>
/// Builds the zero-argument icon resolver the <c>&lt;icon&gt;</c> element
/// uses: dispatch by <c>iconkind</c> (default <c>"did"</c>) to the

View file

@ -304,6 +304,41 @@ public sealed class UiMenu : UiElement
/// StateDesc (not a code symbol); ~0.5 neutral grey here pending a live cdb dump.</summary>
public Vector4 TextColorGhosted { get; set; } = new(0.5f, 0.5f, 0.5f, 1f);
/// <summary>
/// Owner live-client report 2026-09-07 ("Those BIG gold/yellow buttons HAS
/// to go. That is not how vtank looks."): whether the CLOSED-state button
/// face draws retail's gold pushbutton art (<see cref="NormalSprite"/>/
/// <see cref="PressedSprite"/> + the arrow-cap overlay) or the plain flat
/// box below (<see cref="DrawPlainClosedState"/>) that reads as the same
/// widget family as <see cref="UiMarkupList"/>'s dark list boxes — the
/// VTank/Decal <c>HudCombo</c> shape (<c>docs/research/vtank-kb/08-ui-views.md</c>
/// §2: a flat box, left-aligned current value, small down-arrow at the
/// right edge). Default TRUE so every existing non-plugin <see cref="UiMenu"/>
/// user (chat's channel menu, vendor's category dropdown, the Config
/// option menus, the retail confirmation-dialog menu, and every generic
/// dat Type-6 element built by <c>DatWidgetFactory</c>) keeps its exact
/// retail look untouched; <see cref="MarkupDocument"/> flips this to
/// <c>false</c> for plugin <c>&lt;menu&gt;</c> markup by default, with
/// <c>style="retail"</c> as the opt-out back to this art.
/// </summary>
public bool RetailButtonArt { get; set; } = true;
// ── Plain closed-state chrome (RetailButtonArt = false). Colors mirror
// UiMarkupList's own list chrome (BackgroundColor/BorderColor/TextColor)
// so a plugin's dropdown reads as the same widget family as its lists.
public Vector4 PlainBackgroundColor { get; set; } = new(0f, 0f, 0f, 0.92f);
public Vector4 PlainBorderColor { get; set; } = new(0.46f, 0.37f, 0.16f, 1f);
/// <summary>Border tint while the popup is open or the face is physically
/// pressed — the only visual change those states make in plain mode (no
/// gold art swap, ever).</summary>
public Vector4 PlainOpenBorderColor { get; set; } = new(0.70f, 0.58f, 0.24f, 1f);
public Vector4 PlainTextColor { get; set; } = new(0.91f, 0.87f, 0.76f, 1f);
public Vector4 PlainTriangleColor { get; set; } = new(0.91f, 0.87f, 0.76f, 1f);
/// <summary>Left-inset of the plain box's value text — matches
/// <see cref="UiMarkupList.Padding"/>'s default so a combo's text lines up
/// with the list rows beneath it.</summary>
public const float PlainPadding = 3f;
private bool _open;
/// <summary>
@ -457,6 +492,12 @@ public sealed class UiMenu : UiElement
protected override void OnDraw(UiRenderContext ctx)
{
if (!RetailButtonArt)
{
DrawPlainClosedState(ctx);
return;
}
var resolve = SpriteResolve;
// Button face (3-sliced so it can widen to fit the label) + the active-target label.
@ -486,6 +527,52 @@ public sealed class UiMenu : UiElement
if (resolve is not null) DrawArrowCap(ctx, resolve);
}
/// <summary>
/// The VTank/Decal <c>HudCombo</c> closed-state shape (see
/// <see cref="RetailButtonArt"/>'s doc comment): flat fill, 1px border,
/// left-aligned value text at <see cref="PlainPadding"/>, and a small ▾
/// triangle right-aligned — no sprite or DAT quad at all, drawn entirely
/// with <see cref="UiRenderContext.DrawFill"/>/<see cref="UiRenderContext.DrawRectOutline"/>
/// (the same untextured sprite-bucket primitives <see cref="UiMarkupList"/>
/// already uses for its own chrome). Open/pressed only tints the border —
/// never a sprite swap.
/// </summary>
private void DrawPlainClosedState(UiRenderContext ctx)
{
ctx.DrawFill(0f, 0f, Width, Height, PlainBackgroundColor);
Vector4 border = (_open || _facePressed) ? PlainOpenBorderColor : PlainBorderColor;
ctx.DrawRectOutline(0f, 0f, Width, Height, border, 1f);
string caption = ButtonLabelProvider?.Invoke() ?? "";
UiDatFont? captionFont = ButtonDatFont ?? DatFont;
float captionLineH = captionFont?.LineHeight ?? Font?.LineHeight ?? 14f;
float textY = (Height - captionLineH) * 0.5f;
if (captionFont is { } cf)
ctx.DrawStringDat(cf, caption, PlainPadding, textY, PlainTextColor, Outline, OutlineColor);
else
ctx.DrawString(caption, PlainPadding, textY, PlainTextColor, Font);
DrawPlainTriangle(ctx);
}
/// <summary>
/// A 7px-wide, 4px-tall ▾ glyph built from four stacked
/// <see cref="UiRenderContext.DrawFill"/> bands — the same "no DAT art,
/// just fills" technique <see cref="UiCheckLamp.Draw"/> uses for its lamp
/// glyph. Right-aligned with a small margin so it never crowds the box's
/// own border.
/// </summary>
private void DrawPlainTriangle(UiRenderContext ctx)
{
const float w = 7f, rightMargin = 6f;
float x = Width - rightMargin - w;
float y = (Height - 4f) * 0.5f;
ctx.DrawFill(x, y, w, 1f, PlainTriangleColor);
ctx.DrawFill(x + 1f, y + 1f, w - 2f, 1f, PlainTriangleColor);
ctx.DrawFill(x + 2f, y + 2f, w - 4f, 1f, PlainTriangleColor);
ctx.DrawFill(x + 3f, y + 3f, w - 6f, 1f, PlainTriangleColor);
}
// 3-slice caps for the 46px LED-arrow button face (0x06004D65): a LEFT cap holding the
// round LED socket, a stretchable plain-gold MIDDLE, and a RIGHT cap holding the arrow
// point. Slicing keeps the LED + arrow undistorted when the button widens to its label.

View file

@ -314,4 +314,58 @@ public class MarkupDocumentTests
Assert.Equal(0.78f, binding.AttackPower);
Assert.Equal(0.78f, slider.ScalarPositionSource!());
}
// ── S7 fix ("BIG gold/yellow buttons has to go" — owner live-client
// report 2026-09-07): <menu style="..."> selects UiMenu.RetailButtonArt.
private sealed class MenuStyleBinding
{
public IReadOnlyList<string> Choices => ["First", "Second"];
public string Selected { get; } = "First";
}
private static string MenuXml(string? styleAttribute) =>
"<panel x=\"0\" y=\"0\" w=\"240\" h=\"60\">" +
$"<menu x=\"4\" y=\"4\" w=\"120\" h=\"20\" items=\"{{Choices}}\" selected=\"{{Selected}}\"{styleAttribute}/>" +
"</panel>";
[Fact]
public void Menu_NoStyleAttribute_DefaultsToPlain_RetailButtonArtFalse()
{
var panel = MarkupDocument.Build(MenuXml(styleAttribute: ""), new MenuStyleBinding(), _ => (1u, 32, 32));
var menu = Assert.IsType<UiMenu>(panel.Children[0]);
Assert.False(menu.RetailButtonArt);
}
[Fact]
public void Menu_StylePlain_Explicit_RetailButtonArtFalse()
{
var panel = MarkupDocument.Build(
MenuXml(" style=\"plain\""), new MenuStyleBinding(), _ => (1u, 32, 32));
var menu = Assert.IsType<UiMenu>(panel.Children[0]);
Assert.False(menu.RetailButtonArt);
}
[Fact]
public void Menu_StyleRetail_OptsIntoTheGoldButtonArt()
{
var panel = MarkupDocument.Build(
MenuXml(" style=\"retail\""), new MenuStyleBinding(), _ => (1u, 32, 32));
var menu = Assert.IsType<UiMenu>(panel.Children[0]);
Assert.True(menu.RetailButtonArt);
}
[Fact]
public void Menu_UnknownStyle_ThrowsFormatException_NamingTheElement()
{
var ex = Assert.Throws<FormatException>(
() => MarkupDocument.Build(
MenuXml(" style=\"chrome\""), new MenuStyleBinding(), _ => (1u, 32, 32)));
Assert.Contains("menu", ex.Message);
Assert.Contains("chrome", ex.Message);
}
}

View file

@ -0,0 +1,211 @@
using System.Linq;
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Tests.Rendering.Gpu;
using AcDream.App.UI;
using DatReaderWriter.Types;
using Xunit;
namespace AcDream.App.Tests.UI;
/// <summary>
/// S7 fix ("BIG gold/yellow buttons has to go" — owner live-client report
/// 2026-09-07, looking at the live client: retail's gold pushbutton art on
/// the plugin-markup <c>&lt;menu&gt;</c> closed state does not match VTank's
/// own <c>HudCombo</c> shape, a flat dark box with a thin border, left-
/// aligned value text, and a small down-arrow — the same widget family as
/// <see cref="UiMarkupList"/>'s own list boxes
/// (<c>docs/research/vtank-kb/08-ui-views.md</c> §2).
///
/// <para>
/// Draw-level pins against the same <see cref="RecordingGpuDevice"/>/
/// <see cref="TextRenderer"/> apparatus <c>MarkupIconTests</c> uses: the
/// plain closed state (<see cref="UiMenu.RetailButtonArt"/> = false) emits
/// no textured button-face quad at all, only untextured fills (background +
/// 1px border + the ▾ triangle) plus the DAT-font caption glyphs; the retail
/// closed state (the class default, and every non-markup <see cref="UiMenu"/>
/// caller) is untouched — it still resolves and draws its gold
/// <see cref="UiMenu.NormalSprite"/>/<see cref="UiMenu.PressedSprite"/> face.
/// </para>
///
/// <para>
/// <see cref="TextRenderer.DebugSpriteSegmentVerts"/> merges CONTIGUOUS
/// same-texture draws into one segment (<c>NextSpriteSeg</c>'s "extend the
/// current same-texture run" rule) — a segment boundary appears only where
/// the texture actually changes in submission order. So these tests count
/// QUADS (48 floats = 6 verts × 8 floats each) summed across every segment
/// of a given texture, rather than assuming one segment per draw call.
/// </para>
/// </summary>
public sealed class UiMenuPlainStyleTests
{
private const int FloatsPerQuad = 48; // 6 vertices/quad × 8 floats/vertex (AppendQuad).
private sealed class NullGpuFrameSource : ICurrentGpuFrameSource
{
public IGpuFrame? CurrentFrame => null;
}
private static (TextRenderer renderer, UiRenderContext ctx) MakeContext(float w, float h)
{
var device = new RecordingGpuDevice();
var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused");
renderer.Begin(new Vector2(w, h));
var ctx = new UiRenderContext(renderer, new Vector2(w, h));
return (renderer, ctx);
}
private static int QuadCount(
System.Collections.Generic.IReadOnlyList<(uint Texture, System.Collections.Generic.IReadOnlyList<float> Verts)> segs,
uint texture)
=> segs.Where(s => s.Texture == texture).Sum(s => s.Verts.Count) / FloatsPerQuad;
// A distinctive, obviously-not-zero texture id for the retail gold face —
// if the plain path ever regressed into calling SpriteResolve for its
// face, this id would show up in the recorded segments.
private const uint FaceTexture = 77u;
private const uint FontTexture = 1u;
private static UiDatFont MakeFont()
{
var glyphs = new System.Collections.Generic.Dictionary<char, FontCharDesc>
{
['W'] = new FontCharDesc { Unicode = 'W', Width = 8, Height = 8 },
};
return new UiDatFont(
fgTex: FontTexture, fgW: 32, fgH: 32,
bgTex: 0, bgW: 0, bgH: 0,
lineHeight: 16f, baselineOffset: 12f,
glyphs);
}
private static UiMenu MakeMenu(bool retailButtonArt) => new()
{
Width = 100f, Height = 20f,
DatFont = MakeFont(),
SpriteResolve = _ => (FaceTexture, 46, 17),
RetailButtonArt = retailButtonArt,
NormalSprite = 0x06004D65u,
PressedSprite = 0x06004D66u,
Items = new[] { new UiMenu.MenuItem("Warrior", (object?)"Warrior") },
ButtonLabelProvider = () => "W",
};
[Fact]
public void Plain_ClosedState_DrawsNoTexturedFaceQuad()
{
var menu = MakeMenu(retailButtonArt: false);
var (renderer, ctx) = MakeContext(200f, 200f);
menu.DrawSelfAndChildren(ctx);
Assert.Equal(0, QuadCount(renderer.DebugSpriteSegmentVerts, FaceTexture));
}
[Fact]
public void Plain_ClosedState_DrawsFillOutlineTextAndTriangle()
{
var menu = MakeMenu(retailButtonArt: false);
var (renderer, ctx) = MakeContext(200f, 200f);
menu.DrawSelfAndChildren(ctx);
var segs = renderer.DebugSpriteSegmentVerts;
// Exactly: background fill (1 quad) + 1px outline (4 sides) +
// the ▾ triangle (4 stacked bands) = 9 untextured quads.
Assert.Equal(9, QuadCount(segs, 0u));
// The caption glyph drew exactly one quad through the DAT font texture.
Assert.Equal(1, QuadCount(segs, FontTexture));
}
[Fact]
public void Plain_ClosedState_TriangleSitsRightAligned_TextSitsAtListPadding()
{
var menu = MakeMenu(retailButtonArt: false);
var (renderer, ctx) = MakeContext(200f, 200f);
menu.DrawSelfAndChildren(ctx);
// The glyph's dest quad starts at UiMenu.PlainPadding (left-aligned,
// no arrow-cap offset baked in the way the retail face indents it).
var glyphSeg = Assert.Single(renderer.DebugSpriteSegmentVerts, s => s.Texture == FontTexture);
Assert.Equal(UiMenu.PlainPadding, glyphSeg.Verts[0], 3);
// DrawPlainClosedState submits fill, then the 4 outline sides
// (all texture 0, merged into one segment), THEN the caption glyph
// (texture 1, its own segment), THEN the 4 triangle bands (texture 0
// again — a NEW segment, since the glyph draw broke the run). That
// last texture-0 segment is exactly the triangle: 4 quads, 192 floats.
var untexturedSegs = renderer.DebugSpriteSegmentVerts.Where(s => s.Texture == 0u).ToList();
Assert.Equal(2, untexturedSegs.Count); // [fill+outline], [triangle]
var triangleSeg = untexturedSegs[^1];
Assert.Equal(4 * FloatsPerQuad, triangleSeg.Verts.Count);
// Each band's right edge (Verts[8] within its own 48-float quad chunk
// — the second AppendQuad vertex's X, same convention
// MarkupIconTests.UiMarkupList_IconColumn_... uses) must sit within
// the box's right 13px (7px glyph + 6px margin), never touching the
// left-aligned text.
for (int q = 0; q < 4; q++)
{
float rightEdgeX = triangleSeg.Verts[q * FloatsPerQuad + 8];
Assert.True(rightEdgeX <= menu.Width - 6f + 0.01f);
Assert.True(rightEdgeX >= menu.Width - 13f - 0.01f);
}
}
[Fact]
public void Retail_ClosedState_StillEmitsItsGoldFaceSprite()
{
var menu = MakeMenu(retailButtonArt: true);
var (renderer, ctx) = MakeContext(200f, 200f);
menu.DrawSelfAndChildren(ctx);
Assert.True(QuadCount(renderer.DebugSpriteSegmentVerts, FaceTexture) > 0);
}
[Fact]
public void RetailButtonArt_DefaultsTrue_SoNonMarkupCallersAreUnaffected()
{
// Every existing UiMenu construction site (chat, vendor, config
// options, the retail confirmation dialog, DatWidgetFactory's generic
// Type-6 element) never sets RetailButtonArt at all — the class
// default must keep drawing retail's gold art.
Assert.True(new UiMenu().RetailButtonArt);
}
[Fact]
public void Retail_ClosedState_DrawIsByteForByteUnchanged_RegressionGolden()
{
// A golden pin for a plain (non-markup) UiMenu built exactly the way
// pre-S7 code built one: no RetailButtonArt set at all (class
// default). Its drawn quad counts/textures must be identical to what
// the retail branch always produced — the S7 style switch must not
// have touched this path at all.
UiMenu menu = new()
{
Width = 100f, Height = 20f,
DatFont = MakeFont(),
SpriteResolve = _ => (FaceTexture, 46, 17),
NormalSprite = 0x06004D65u,
PressedSprite = 0x06004D66u,
Items = new[] { new UiMenu.MenuItem("Warrior", (object?)"Warrior") },
ButtonLabelProvider = () => "W",
};
var (renderer, ctx) = MakeContext(200f, 200f);
menu.DrawSelfAndChildren(ctx);
var segs = renderer.DebugSpriteSegmentVerts;
// 3-slice face (LED cap, stretched body, arrow cap) = 3 quads of
// FaceTexture, plus exactly one caption glyph quad (FontTexture). No
// arrow-cap overlay sprite (ids left at 0 -> DrawArrowCap no-ops) and
// no untextured fill/outline/triangle quad at all — the plain path is
// never reached for a menu that never sets RetailButtonArt.
Assert.Equal(3, QuadCount(segs, FaceTexture));
Assert.Equal(1, QuadCount(segs, FontTexture));
Assert.Equal(0, QuadCount(segs, 0u));
}
}