From cc11e077a49ba4ad6d7bb0d3b53d88c43f996792 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 7 Sep 2026 08:15:07 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat(vtank):=20slice=207=20fix=20=E2=80=94?= =?UTF-8?q?=20UiMenu=20plain=20closed=20state,=20gold=20art=20opt-in?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner live-client report 2026-09-07: "Those BIG gold/yellow buttons HAS to go. That is not how vtank looks." VTank/Decal's HudCombo is a flat dark box (background/border matching its own HudList) with a left-aligned value and a small down-arrow — retail's gold pushbutton art (the 3-slice LED-arrow face UiMenu.DrawButtonFace draws) is a different widget family entirely. Adds UiMenu.RetailButtonArt (default true, so every existing non-markup UiMenu caller — chat's channel menu, vendor's category dropdown, Config's option menus, the retail confirmation dialog, and DatWidgetFactory's generic Type-6 element — keeps its byte-identical retail face) plus DrawPlainClosedState/DrawPlainTriangle, which draw the flat box entirely with UiRenderContext.DrawFill/DrawRectOutline (no sprite or DAT quad at all) using colors mirroring UiMarkupList's own chrome (background 0,0,0,0.92; border 0.46,0.37,0.16,1; text 0.91,0.87,0.76,1). Open/pressed only tints the border (0.70,0.58,0.24,1) — never a sprite swap. Mutation check: temporarily disabled the new `if (!RetailButtonArt)` branch in OnDraw (reverting it to the pre-fix unconditional retail path) — 3 of the 6 new UiMenuPlainStyleTests failed exactly as expected (Plain_ClosedState_DrawsNoTexturedFaceQuad, Plain_ClosedState_DrawsFillOutlineTextAndTriangle, Plain_ClosedState_TriangleSitsRightAligned_TextSitsAtListPadding); the 3 retail-path/default-value tests kept passing since they don't exercise the removed branch. Restored before committing. Co-Authored-By: Claude Fable 5.1 --- src/AcDream.App/UI/UiMenu.cs | 87 ++++++++ .../UI/UiMenuPlainStyleTests.cs | 211 ++++++++++++++++++ 2 files changed, 298 insertions(+) create mode 100644 tests/AcDream.App.Tests/UI/UiMenuPlainStyleTests.cs diff --git a/src/AcDream.App/UI/UiMenu.cs b/src/AcDream.App/UI/UiMenu.cs index d33df13b..810886a0 100644 --- a/src/AcDream.App/UI/UiMenu.cs +++ b/src/AcDream.App/UI/UiMenu.cs @@ -304,6 +304,41 @@ public sealed class UiMenu : UiElement /// StateDesc (not a code symbol); ~0.5 neutral grey here pending a live cdb dump. public Vector4 TextColorGhosted { get; set; } = new(0.5f, 0.5f, 0.5f, 1f); + /// + /// 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 (/ + /// + the arrow-cap overlay) or the plain flat + /// box below () that reads as the same + /// widget family as 's dark list boxes — the + /// VTank/Decal HudCombo shape (docs/research/vtank-kb/08-ui-views.md + /// §2: a flat box, left-aligned current value, small down-arrow at the + /// right edge). Default TRUE so every existing non-plugin + /// 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 DatWidgetFactory) keeps its exact + /// retail look untouched; flips this to + /// false for plugin <menu> markup by default, with + /// style="retail" as the opt-out back to this art. + /// + 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); + /// 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). + 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); + /// Left-inset of the plain box's value text — matches + /// 's default so a combo's text lines up + /// with the list rows beneath it. + public const float PlainPadding = 3f; + private bool _open; /// @@ -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); } + /// + /// The VTank/Decal HudCombo closed-state shape (see + /// 's doc comment): flat fill, 1px border, + /// left-aligned value text at , and a small ▾ + /// triangle right-aligned — no sprite or DAT quad at all, drawn entirely + /// with / + /// (the same untextured sprite-bucket primitives + /// already uses for its own chrome). Open/pressed only tints the border — + /// never a sprite swap. + /// + 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); + } + + /// + /// A 7px-wide, 4px-tall ▾ glyph built from four stacked + /// bands — the same "no DAT art, + /// just fills" technique uses for its lamp + /// glyph. Right-aligned with a small margin so it never crowds the box's + /// own border. + /// + 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. diff --git a/tests/AcDream.App.Tests/UI/UiMenuPlainStyleTests.cs b/tests/AcDream.App.Tests/UI/UiMenuPlainStyleTests.cs new file mode 100644 index 00000000..013181e6 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiMenuPlainStyleTests.cs @@ -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; + +/// +/// 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 <menu> closed state does not match VTank's +/// own HudCombo shape, a flat dark box with a thin border, left- +/// aligned value text, and a small down-arrow — the same widget family as +/// 's own list boxes +/// (docs/research/vtank-kb/08-ui-views.md §2). +/// +/// +/// Draw-level pins against the same / +/// apparatus MarkupIconTests uses: the +/// plain closed state ( = 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 +/// caller) is untouched — it still resolves and draws its gold +/// / face. +/// +/// +/// +/// merges CONTIGUOUS +/// same-texture draws into one segment (NextSpriteSeg'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. +/// +/// +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 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 + { + ['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)); + } +} From 19c831211b437dbf5e9045cdad56ab14eb458d33 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 7 Sep 2026 08:15:23 +0200 Subject: [PATCH 2/3] =?UTF-8?q?feat(vtank):=20slice=207=20fix=20=E2=80=94?= =?UTF-8?q?=20=20selects=20plain=20vs=20retail=20art?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the new UiMenu.RetailButtonArt switch (previous commit) into plugin markup: (also the default when the attribute is absent) builds RetailButtonArt=false so a plugin's dropdown gets the flat VTank-matching box; style="retail" opts a panel back into the gold pushbutton face. Any other value throws FormatException at Build naming the element, matching the existing validation convention (ValidateIconKind). Mutation check: temporarily stubbed ValidateMenuStyle to always return true (as if the switch didn't exist) — 3 of the 4 new MarkupDocumentTests.Menu_* tests failed exactly as expected (Menu_NoStyleAttribute_DefaultsToPlain_RetailButtonArtFalse, Menu_StylePlain_Explicit_RetailButtonArtFalse, Menu_UnknownStyle_ThrowsFormatException_NamingTheElement); the style="retail" test passed trivially either way, as expected for that case. Restored before committing. Co-Authored-By: Claude Fable 5.1 --- src/AcDream.App/UI/MarkupDocument.cs | 21 ++++++++ .../UI/MarkupDocumentTests.cs | 54 +++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/src/AcDream.App/UI/MarkupDocument.cs b/src/AcDream.App/UI/MarkupDocument.cs index c152a043..3d14d983 100644 --- a/src/AcDream.App/UI/MarkupDocument.cs +++ b/src/AcDream.App/UI/MarkupDocument.cs @@ -461,6 +461,7 @@ public static class MarkupDocument Func 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}\")"), }; + /// + /// Owner live-client report 2026-09-07 ("Those BIG gold/yellow buttons HAS + /// to go. That is not how vtank looks."): validates <menu + /// style="..."> and returns the + /// value it selects. Default (attribute absent, or explicit + /// style="plain") is the flat VTank/Decal HudCombo box + /// (false) — retail's gold pushbutton art is now an explicit + /// style="retail" opt-in for a plugin panel that genuinely wants + /// it. Any other value is a Build-time author error, same rule as + /// . + /// + private static bool ValidateMenuStyle(string? style) => style switch + { + null or "plain" => false, + "retail" => true, + var other => throw new FormatException( + $" must be plain or retail"), + }; + /// /// Builds the zero-argument icon resolver the <icon> element /// uses: dispatch by iconkind (default "did") to the diff --git a/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs b/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs index bb3cb9b1..1a1f864f 100644 --- a/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs +++ b/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs @@ -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): selects UiMenu.RetailButtonArt. + + private sealed class MenuStyleBinding + { + public IReadOnlyList Choices => ["First", "Second"]; + public string Selected { get; } = "First"; + } + + private static string MenuXml(string? styleAttribute) => + "" + + $"" + + ""; + + [Fact] + public void Menu_NoStyleAttribute_DefaultsToPlain_RetailButtonArtFalse() + { + var panel = MarkupDocument.Build(MenuXml(styleAttribute: ""), new MenuStyleBinding(), _ => (1u, 32, 32)); + var menu = Assert.IsType(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(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(panel.Children[0]); + + Assert.True(menu.RetailButtonArt); + } + + [Fact] + public void Menu_UnknownStyle_ThrowsFormatException_NamingTheElement() + { + var ex = Assert.Throws( + () => MarkupDocument.Build( + MenuXml(" style=\"chrome\""), new MenuStyleBinding(), _ => (1u, 32, 32))); + + Assert.Contains("menu", ex.Message); + Assert.Contains("chrome", ex.Message); + } } From d8846d7a2da14da375589be5243663e014ce082c Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 7 Sep 2026 08:15:32 +0200 Subject: [PATCH 3/3] docs(vt): document in plugin-ui-markup.md Adds style to the attribute row and one sentence explaining why plain is now the default (owner report: retail's gold pushbutton art read as an out-of-place button next to a plugin's own dark list boxes) and what style="retail" opts back into. Co-Authored-By: Claude Fable 5.1 --- docs/plugin-ui-markup.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/plugin-ui-markup.md b/docs/plugin-ui-markup.md index ee601902..ff591cec 100644 --- a/docs/plugin-ui-markup.md +++ b/docs/plugin-ui-markup.md @@ -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 `` 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 `` 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