acdream/src/AcDream.App/UI/MarkupDocument.cs
Erik e9108277c4 feat: retail scrollbar chrome for markup <menu> overflow and <list> overflow
Owner live-client report 2026-09-07: "For scrollable dropdown or the meta
window we use the same assets as we do in for example chat or inventory
window."

<menu> markup wiring (MarkupDocument.cs): a plugin <menu> is now always
Scrollable (single-column, VTank HudCombo shape) instead of wrapping
overflow into more grid columns, with PopupScrollbarHideWhenDisabled=true
so the bar is entirely absent while the item count fits the "rows"
window. RetailScrollbarChrome.ApplyToMenuPopup wires the same chrome ids
the previous commit taught DrawScrollablePopupPlain to draw, for both
style="plain" and style="retail" markup menus.

<list> markup (UiMarkupList.cs / MarkupDocument.cs): a plugin <list>
(single-column or <column> multi-column) that overflows its own row
viewport now draws the retail scrollbar chrome at its right edge (VVS's
own placement, 16px wide) instead of being wheel-scroll-only with no
visible bar. The reserved 16px column only exists while rows actually
overflow, in both column-layout modes (ComputeColumnLayout receives the
already-shrunk width so the last/auto column absorbs the remainder
correctly); the bar is fully interactive (up/down arrows, track paging,
thumb drag) via a small UiScrollable projection kept in sync with the
list's own _topRow, which stays the single source of truth. Wheel
scrolling and a no-resolver hand-built list (draws nothing, no crash) are
unchanged.

Mutation shown to fail first: new
UiMarkupListScrollbarTests/MarkupDocumentTests cases were written against
pre-change UiMarkupList/MarkupDocument and failed (no scrollbar sprites
ever emitted since UiMarkupList had no SpriteResolve property at all, and
<menu> markup never set Scrollable) before the implementation landed;
after: SingleColumn_Overflowing_DrawsRetailScrollbarChromeAtRightEdge and
Columns_Overflowing_ReservesSixteenPixels_LastColumnShrinksAccordingly
pin sprite ids + exact reserved-width geometry,
*_ContentFits_DrawsNo(Scrollbar|ReservationLastColumnKeepsFullRemainder)
pin the no-overflow/no-bar case, *_UpArrowClick_ScrollsUpByOneRow and
ThumbDrag_MovesTopRowAndIsReadableByASubsequentClick pin interactivity via
a following row click resolving to the moved position (mirroring
MarkupListColumnsTests' own wheel-scroll pin), and the four new
MarkupDocumentTests menu cases pin Scrollable/PopupScrollbarHideWhenDisabled/
the six chrome-id properties plus an end-to-end open-popup draw for both
the overflowing (draws chrome) and non-overflowing (draws none) cases.
Every pre-existing MarkupListColumnsTests/MarkupDocumentTests case stays
green unchanged (none of their fixtures overflow their own viewport).

docs/plugin-ui-markup.md updated: the <menu> style paragraph and a new
<list> "Scrollbar" section describe the new chrome + auto-reservation, and
the PITCH-transcription guidance is corrected to say the 16px scrollbar
column is now automatic (no more manual fold-in/double-reservation advice).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 15:43:00 +02:00

1324 lines
62 KiB
C#

using System;
using System.Globalization;
using System.Numerics;
using System.Reflection;
using System.Xml.Linq;
using AcDream.Plugin.Abstractions;
namespace AcDream.App.UI;
/// <summary>
/// Parses our KSML-style panel markup (mirrors retail's ElementDesc fields)
/// into a live <see cref="UiElement"/> subtree. <c>{Binding}</c> attribute
/// values resolve against a supplied object by property name (reflection).
/// This is the format the future LayoutDesc importer will emit. See D.2b spec §7.
/// </summary>
public static class MarkupDocument
{
// Retail's generic runtime-text tooltip skin. Plugin controls have no
// LayoutDesc of their own, so a tooltip= attribute explicitly opts them
// into the same popup that game-code SetTooltip call sites use.
private const uint RuntimeTooltipRootElementId = 0x10000397u;
private const uint RuntimeTooltipLayoutDid = 0x21000041u;
/// <param name="xml">Raw XML markup for a single panel.</param>
/// <param name="binding">Object whose public properties are bound to <c>{PropName}</c> attributes.</param>
/// <param name="resolve">Surface id → (GL handle, width, height) for chrome sprites.</param>
/// <param name="style">Optional controls.ini stylesheet for the title color.</param>
/// <param name="datFont">
/// Retail interface font. Supplied by the host so plugin panels render
/// their text through the same glyph path as authored panels; without it
/// they fall back to the development bitmap font and look foreign.
/// </param>
/// <param name="icons">
/// Slice B (<c>docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md</c>):
/// resolves <c>&lt;icon&gt;</c>, <c>&lt;button icon&gt;</c>, and
/// <c>&lt;list icons&gt;</c> ids to drawable DAT icons. Null (the
/// default, and what every pre-Slice-B caller still passes) makes those
/// three surfaces resolve to nothing rather than throwing — a panel
/// authored against Slice B markup still loads under a host/test that
/// has not wired icon resolution.
/// </param>
public static UiNineSlicePanel Build(
string xml, object binding, Func<uint, (uint, int, int)> resolve,
ControlsIni? style = null, UiDatFont? datFont = null,
IMarkupIconResolver? icons = null)
{
var root = XDocument.Parse(xml).Root ?? throw new FormatException("empty markup");
if (root.Name.LocalName != "panel")
throw new FormatException($"root must be <panel>, got <{root.Name.LocalName}>");
var panel = new UiNineSlicePanel(resolve)
{
Left = F(root, "x"),
Top = F(root, "y"),
Width = F(root, "w"),
Height = F(root, "h"),
};
// Optional per-window resize-axis lock: resize="x" | "y" | "both" | "none".
string? resize = (string?)root.Attribute("resize");
if (resize is not null)
{
panel.ResizeX = resize is "x" or "both";
panel.ResizeY = resize is "y" or "both";
}
// Panel-level visibility binding: lets a plugin keep its window out of
// the way until it has something to act on (e.g. hidden at character
// select, shown in world).
string? visible = (string?)root.Attribute("visible");
if (visible is not null && IsBinding(visible))
{
PropertyInfo? flag = binding.GetType().GetProperty(visible[1..^1]);
if (flag is null || flag.PropertyType != typeof(bool))
{
throw new FormatException(
$"<panel visible=\"{visible}\"> did not resolve to a bool property "
+ $"on {binding.GetType().Name}");
}
panel.VisibleSource = () => flag.GetValue(binding) is true;
}
string? title = (string?)root.Attribute("title");
if (!string.IsNullOrEmpty(title))
{
Vector4 tc = style is not null && style.TryColor("title", "color", out var c) ? c : Vector4.One;
panel.AddChild(new UiLabel
{
Text = title, Left = 8, Top = 4, TextColor = tc, DatFont = datFont,
});
}
foreach (var el in root.Elements())
AddElement(panel, el, binding, resolve, datFont, icons);
return panel;
}
private static void AddElement(
UiElement parent,
XElement el,
object binding,
Func<uint, (uint, int, int)> resolve,
UiDatFont? datFont,
IMarkupIconResolver? icons)
{
switch (el.Name.LocalName)
{
case "group":
var group = new UiPanel
{
Left = F(el, "x"),
Top = F(el, "y"),
Width = F(el, "w"),
Height = F(el, "h"),
BackgroundColor = el.Attribute("background") is null
? Vector4.Zero
: Color((string?)el.Attribute("background")),
BorderColor = el.Attribute("border") is null
? Vector4.Zero
: Color((string?)el.Attribute("border")),
BorderThickness = el.Attribute("border") is null ? 0f : 1f,
// Transparent layout groups do not claim empty space, while
// their interactive descendants remain hittable.
ClickThrough = true,
};
ApplyCommon(group, el, binding);
parent.AddChild(group);
foreach (XElement child in el.Elements())
AddElement(group, child, binding, resolve, datFont, icons);
break;
case "meter":
var cur = BindUint((string?)el.Attribute("cur"), binding);
var max = BindUint((string?)el.Attribute("max"), binding);
var meter = new UiMeter
{
Left = F(el, "x"),
Top = F(el, "y"),
Width = F(el, "w"),
Height = F(el, "h"),
BarColor = Color((string?)el.Attribute("color")),
Fill = BindFloat((string?)el.Attribute("fill"), binding),
Label = () => (cur(), max()) is (uint c, uint m) ? $"{c}/{m}" : null,
Anchors = Anchor((string?)el.Attribute("anchor")),
SpriteResolve = resolve,
BackLeft = Hex((string?)el.Attribute("backleft")),
BackTile = Hex((string?)el.Attribute("backtile")),
BackRight = Hex((string?)el.Attribute("backright")),
FrontLeft = Hex((string?)el.Attribute("frontleft")),
FrontTile = Hex((string?)el.Attribute("fronttile")),
FrontRight = Hex((string?)el.Attribute("frontright")),
};
ApplyCommon(meter, el, binding);
parent.AddChild(meter);
break;
case "label":
// Text may be a literal or a {Binding}. Bound labels re-read
// their property every frame through the Func, so a plugin
// updates its status line by assigning a property rather
// than by touching UI objects from its own thread.
var label = new UiLabel
{
Left = F(el, "x"),
Top = F(el, "y"),
TextSource = BindString((string?)el.Attribute("text"), binding),
DatFont = datFont,
};
if (el.Attribute("color") is not null)
label.TextColor = Color((string?)el.Attribute("color"));
ApplyCommon(label, el, binding);
parent.AddChild(label);
break;
case "button":
// onclick binds to an Action property on the binding
// object. Resolved once at build time: a button whose
// handler silently failed to bind is a bug worth failing
// loudly for, and MarkupDocument.Build is already inside
// the host's try/catch that reports panel load failures.
string? clickName = (string?)el.Attribute("onclick");
Action? onClick = BindAction(clickName, binding);
if (clickName is not null && onClick is null)
{
throw new FormatException(
$"<button onclick=\"{clickName}\"> did not resolve to an "
+ $"Action property on {binding.GetType().Name}");
}
// UiSimpleButton, not the dat-sprite UiButton: a plugin
// panel has no LayoutDesc behind it and no StateDesc
// sprites to name, so the plain rect-and-text button is the
// one that can actually render from markup alone.
var button = new UiSimpleButton
{
Left = F(el, "x"),
Top = F(el, "y"),
Width = F(el, "w"),
Height = F(el, "h"),
Text = (string?)el.Attribute("text") ?? string.Empty,
DatFont = datFont,
};
// A bound caption lets the button re-label itself (Buff /
// Stop) from the same binding object.
string? caption = (string?)el.Attribute("text");
if (caption is not null && IsBinding(caption))
button.TextSource = BindString(caption, binding);
if (el.Attribute("color") is not null)
button.TextColor = Color((string?)el.Attribute("color"));
if (el.Attribute("background") is not null)
button.BackgroundColor = Color(
(string?)el.Attribute("background"));
if (el.Attribute("border") is not null)
button.BorderColor = Color(
(string?)el.Attribute("border"));
// Slice B: <button icon="..." iconkind="did|spell|item">.
// Review fix round finding 4: the iconkind literal is
// validated here regardless of whether a resolver is
// wired — a typo like iconkind="spel" must throw at
// Build on every host, not only ones with icon support
// turned on. Finding 7: IconSource is only ASSIGNED when
// a resolver exists — UiSimpleButton now reserves its
// icon column whenever IconSource is non-null (see
// UiPanel.cs), so setting it to an always-empty func on
// an icons:null host would permanently reserve a column
// that never draws anything.
string? buttonIcon = (string?)el.Attribute("icon");
if (buttonIcon is not null)
{
string? buttonIconKind = (string?)el.Attribute("iconkind");
ValidateIconKind(buttonIconKind);
// Residual round finding N2: BindUintLiteralOrBinding
// must run UNCONDITIONALLY — same rule as
// ValidateIconKind just above — so a malformed
// icon="{Typo}" throws FormatException at Build on
// every host, not only ones with icons wired. Only
// the ASSIGNMENT onto button.IconSource stays gated
// on icons is not null (finding 7: an always-empty
// IconSource would permanently reserve the icon
// column on a resolver-less host).
Func<uint> buttonIconReader =
BindUintLiteralOrBinding(buttonIcon, binding, "button icon");
if (icons is not null)
{
button.IconSource = BuildIconSource(
buttonIconKind,
buttonIconReader,
icons);
}
}
ApplyCommon(button, el, binding);
if (onClick is not null)
button.Click += onClick;
parent.AddChild(button);
break;
case "icon":
{
// Residual round finding N7: unlike <button>/<list>,
// <icon> derives its kind from WHICH of did/spell/item is
// set (below) — iconkind is meaningless here and was
// previously silently ignored (a plugin author's
// iconkind="spell" typo on an <icon did="..."> would
// never do what it looked like it did). Reject it
// loudly instead, same "malformed markup throws at
// Build" rule every other attribute in this grammar
// follows.
if (el.Attribute("iconkind") is not null)
{
throw new FormatException(
"iconkind applies to button and list; icon derives its kind from did/spell/item");
}
string? didAttr = (string?)el.Attribute("did");
string? spellAttr = (string?)el.Attribute("spell");
string? itemAttr = (string?)el.Attribute("item");
int sourceCount = (didAttr is not null ? 1 : 0)
+ (spellAttr is not null ? 1 : 0)
+ (itemAttr is not null ? 1 : 0);
if (sourceCount != 1)
{
throw new FormatException(
"<icon> requires exactly one of did/spell/item");
}
string iconKind = didAttr is not null ? "did"
: spellAttr is not null ? "spell"
: "item";
string iconExpression = didAttr ?? spellAttr ?? itemAttr!;
Func<uint> iconReader = BindUintLiteralOrBinding(
iconExpression, binding, $"icon {iconKind}");
var icon = new UiMarkupIcon
{
Left = F(el, "x"),
Top = F(el, "y"),
Width = FOr(el, "w", 32f),
Height = FOr(el, "h", 32f),
IconSource = BuildIconSource(iconKind, iconReader, icons),
};
ApplyCommon(icon, el, binding);
// A tooltip needs this element to be a real hit-test
// target — see UiMarkupIcon's own doc comment. Review fix
// round finding 9: an EMPTY tooltip="" must not swallow
// clicks either — match ApplyCommon's own
// !IsNullOrWhiteSpace predicate rather than a bare
// attribute-presence check.
string? iconTooltip = (string?)el.Attribute("tooltip");
if (!string.IsNullOrWhiteSpace(iconTooltip))
icon.ClickThrough = false;
parent.AddChild(icon);
break;
}
case "tab":
string? tabClickName = (string?)el.Attribute("onclick");
Action? tabClick = BindAction(tabClickName, binding);
if (tabClickName is not null && tabClick is null)
{
throw new FormatException(
$"<tab onclick=\"{tabClickName}\"> did not resolve to an "
+ $"Action property on {binding.GetType().Name}");
}
var tab = new UiMarkupTabButton
{
Left = F(el, "x"),
Top = F(el, "y"),
Width = F(el, "w"),
Height = F(el, "h"),
Text = (string?)el.Attribute("text") ?? string.Empty,
DatFont = datFont,
SelectedSource = BindRequiredBoolReader(
(string?)el.Attribute("selected"),
binding,
"tab selected"),
};
ApplyCommon(tab, el, binding);
if (tabClick is not null)
tab.Click += tabClick;
parent.AddChild(tab);
break;
case "toggle":
string? toggleClickName = (string?)el.Attribute("onclick");
Action? toggleClick = BindAction(toggleClickName, binding);
if (toggleClickName is not null && toggleClick is null)
{
throw new FormatException(
$"<toggle onclick=\"{toggleClickName}\"> did not resolve to an "
+ $"Action property on {binding.GetType().Name}");
}
string? toggleCaption = (string?)el.Attribute("text");
var toggle = new UiMarkupToggle
{
Left = F(el, "x"),
Top = F(el, "y"),
Width = F(el, "w"),
Height = F(el, "h"),
Text = toggleCaption ?? string.Empty,
TextSource = BindString(toggleCaption, binding),
CheckedSource = BindRequiredBoolReader(
(string?)el.Attribute("checked"),
binding,
"toggle checked"),
DatFont = datFont,
Toggle = toggleClick,
};
if (el.Attribute("color") is not null)
toggle.TextColor = Color((string?)el.Attribute("color"));
ApplyCommon(toggle, el, binding);
parent.AddChild(toggle);
break;
case "slider":
string? changeName = (string?)el.Attribute("onchange");
Action<float>? changed = BindFloatAction(changeName, binding);
if (changeName is not null && changed is null)
{
throw new FormatException(
$"<slider onchange=\"{changeName}\"> did not resolve to an "
+ $"Action<float> property on {binding.GetType().Name}");
}
var slider = new UiScrollbar
{
Left = F(el, "x"),
Top = F(el, "y"),
Width = F(el, "w"),
Height = F(el, "h"),
Horizontal = true,
SpriteResolve = resolve,
ScalarPositionSource = BindFloat(
(string?)el.Attribute("value"),
binding),
ScalarChanged = changed,
};
RetailScrollbarChrome.ApplyHorizontal(slider);
ApplyCommon(slider, el, binding);
parent.AddChild(slider);
break;
case "field":
string? fieldChangeName = (string?)el.Attribute("onchange");
Action<string>? fieldChanged = BindStringAction(
fieldChangeName,
binding);
if (fieldChangeName is not null && fieldChanged is null)
{
throw new FormatException(
$"<field onchange=\"{fieldChangeName}\"> did not resolve to an "
+ $"Action<string> property on {binding.GetType().Name}");
}
string? submitName = (string?)el.Attribute("onsubmit");
Action<string>? submitted = BindStringAction(submitName, binding);
if (submitName is not null && submitted is null)
{
throw new FormatException(
$"<field onsubmit=\"{submitName}\"> did not resolve to an "
+ $"Action<string> property on {binding.GetType().Name}");
}
var field = new UiField
{
Left = F(el, "x"),
Top = F(el, "y"),
Width = F(el, "w"),
Height = F(el, "h"),
DatFont = datFont,
BackgroundColor = el.Attribute("background") is null
? new Vector4(0f, 0f, 0f, 0.9f)
: Color((string?)el.Attribute("background")),
TextColor = el.Attribute("color") is null
? new Vector4(0.91f, 0.87f, 0.76f, 1f)
: Color((string?)el.Attribute("color")),
MaxCharacters = Math.Max(1, I(el, "maxlength", 128)),
ClearOnSubmit = B(el, "clearonsubmit", false),
RecordHistory = false,
OnTextChanged = fieldChanged,
OnSubmit = submitted,
};
field.SetText(BindString((string?)el.Attribute("text"), binding)());
ApplyCommon(field, el, binding);
parent.AddChild(field);
break;
case "menu":
string? menuChangeName = (string?)el.Attribute("onchange");
Action<string>? menuChanged = BindStringAction(
menuChangeName,
binding);
if (menuChangeName is not null && menuChanged is null)
{
throw new FormatException(
$"<menu onchange=\"{menuChangeName}\"> did not resolve to an "
+ $"Action<string> property on {binding.GetType().Name}");
}
Func<IReadOnlyList<string>> menuItems = BindStringList(
(string?)el.Attribute("items"),
binding,
"menu items");
Func<string?> menuSelected = BindString(
(string?)el.Attribute("selected"),
binding);
bool menuRetailButtonArt = ValidateMenuStyle((string?)el.Attribute("style"));
var menu = new UiMenu
{
Left = F(el, "x"),
Top = F(el, "y"),
Width = F(el, "w"),
Height = F(el, "h"),
DatFont = datFont,
SpriteResolve = resolve,
RowsPerColumn = Math.Max(1, I(el, "rows", 7)),
RowHeight = Math.Max(12f, FOr(el, "rowheight", 18f)),
ColumnWidth = Math.Max(20f, F(el, "w")),
OpenUpward = B(el, "openupward", false),
TextIndent = 6f,
ButtonTextIndent = 6f,
NormalSprite = 0x06004D65u,
PressedSprite = 0x06004D66u,
PopupBgSprite = 0x0600124Cu,
ItemNormalSprite = 0x0600124Eu,
ItemHighlightSprite = 0x0600124Du,
RetailButtonArt = menuRetailButtonArt,
// Owner live-client report 2026-09-07: a plugin dropdown
// scrolls a single column (VTank's own HudCombo shape)
// rather than wrapping into more grid columns once it
// overflows its "rows" window; the scrollbar itself is
// hidden entirely (0x79 semantics) while everything
// fits, matching retail's vendor category popup.
Scrollable = true,
PopupScrollbarHideWhenDisabled = true,
ButtonLabelProvider = () => menuSelected() ?? string.Empty,
OnSelect = payload =>
{
if (payload is string value)
menuChanged?.Invoke(value);
},
};
// The popup's own scrollbar always draws retail's chrome —
// "we use the same assets as we do in for example chat or
// inventory window" — regardless of RetailButtonArt (the
// owner's earlier plain-row directive only ever covered the
// ROWS, never this bar).
RetailScrollbarChrome.ApplyToMenuPopup(menu);
void RefreshMenu()
{
menu.Items = menuItems()
.Select(static value => new UiMenu.MenuItem(value, value))
.ToArray();
menu.Selected = menuSelected();
}
RefreshMenu();
menu.BeforeOpen = RefreshMenu;
ApplyCommon(menu, el, binding);
parent.AddChild(menu);
break;
case "list":
string? listChangeName = (string?)el.Attribute("onchange");
Action<int>? listChanged = BindIntAction(listChangeName, binding);
if (listChangeName is not null && listChanged is null)
{
throw new FormatException(
$"<list onchange=\"{listChangeName}\"> did not resolve to an "
+ $"Action<int> property on {binding.GetType().Name}");
}
// Campaign VT slice 1 Part B: <list><column .../></list>
// (docs/research/vtank-kb/08-ui-views.md §3). A non-<column>
// child is always malformed — the element previously had no
// children at all, so this is purely additive.
var listChildren = el.Elements().ToList();
foreach (var child in listChildren)
{
if (child.Name.LocalName != "column")
{
throw new FormatException(
$"<list> children must all be <column>, got <{child.Name.LocalName}>");
}
}
bool listUsesColumns = listChildren.Count > 0;
if (listUsesColumns
&& (el.Attribute("items") is not null
|| el.Attribute("icons") is not null
|| el.Attribute("colors") is not null))
{
throw new FormatException(
"<list> with <column> children cannot also use the "
+ "items/icons/colors attributes (Slice B's own single-column "
+ "form) — express every row source as a <column> instead");
}
var list = new UiMarkupList
{
Left = F(el, "x"),
Top = F(el, "y"),
Width = F(el, "w"),
Height = F(el, "h"),
RowHeight = Math.Max(12f, FOr(el, "rowheight", 18f)),
DatFont = datFont,
// Owner live-client report 2026-09-07: an overflowing
// <list> draws the same retail scrollbar chrome the chat
// window and inventory use — resolved through the same
// sprite resolver every other markup sink already uses.
SpriteResolve = resolve,
SelectedIndexSource = BindRequiredIntReader(
(string?)el.Attribute("selected"),
binding,
"list selected"),
SelectionChanged = listChanged,
};
if (listUsesColumns)
{
int lastColumnIndex = listChildren.Count - 1;
list.Columns = listChildren
.Select((columnEl, index) => BuildListColumn(
columnEl, binding, icons, index, index == lastColumnIndex))
.ToList();
}
else
{
list.ItemsSource = BindStringList(
(string?)el.Attribute("items"),
binding,
"list items");
list.ItemColorsSource = BindUintList(
(string?)el.Attribute("colors"),
binding,
"list colors");
// Slice B: <list icons="{IconIds}" iconkind="did|spell|item">.
// Same two rules as <button icon> above: iconkind validates
// regardless of resolver wiring (finding 4), and
// IconIdsSource/IconResolve are only set when a resolver
// exists (finding 7) — UiMarkupList already reserves its
// icon column whenever IconIdsSource is non-null.
string? listIcons = (string?)el.Attribute("icons");
if (!string.IsNullOrWhiteSpace(listIcons))
{
string? listIconKind = (string?)el.Attribute("iconkind");
ValidateIconKind(listIconKind);
// Residual round finding N2: same rule as the button's
// icon reader above — BindUintList must run
// UNCONDITIONALLY so icons="notabinding" (a malformed,
// non-{Binding} literal — list icons has no literal
// grammar) throws FormatException at Build even with no
// resolver wired. Only the assignment stays gated.
Func<IReadOnlyList<uint>> listIconIdsReader =
BindUintList(listIcons, binding, "list icons");
if (icons is not null)
{
list.IconIdsSource = listIconIdsReader;
list.IconResolve = BuildRowIconResolve(listIconKind, icons);
}
}
}
ApplyCommon(list, el, binding);
parent.AddChild(list);
break;
default:
// Review fix round finding 11: an unknown or miscased
// element name previously vanished silently (the switch had
// no default arm) — the same "malformed markup throws at
// Build" rule every other element already follows.
throw new FormatException($"unknown element <{el.Name.LocalName}>");
}
}
/// <summary>
/// Review fix round finding 4: validates an <c>iconkind</c> attribute
/// (default <c>"did"</c>) UNCONDITIONALLY — before either
/// <see cref="BuildIconSource"/> or <see cref="BuildRowIconResolve"/>'s
/// null-resolver early return, so <c>iconkind="spel"</c> throws
/// <see cref="FormatException"/> at <c>Build</c> on every host, even one
/// with no <see cref="IMarkupIconResolver"/> wired at all. A malformed
/// attribute is a Build-time author error regardless of what the host
/// happens to support.
///
/// <para>
/// Fix round item 5: <paramref name="context"/> (default <c>"iconkind"</c>
/// for the non-column call sites — <c>&lt;icon&gt;</c>, <c>&lt;button
/// icon&gt;</c>, <c>&lt;list icons&gt;</c>) prefixes the throw message so
/// <c>&lt;column type="icon"&gt;</c>'s own call site can identify which
/// column failed (<c>column[2] type="icon" iconkind</c>).
/// </para>
/// </summary>
private static string ValidateIconKind(string? iconKind, string context = "iconkind") =>
(iconKind ?? "did") switch
{
"did" or "spell" or "item" => iconKind ?? "did",
var other => throw new FormatException(
$"{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
/// matching <see cref="IMarkupIconResolver"/> method, normalizing
/// <c>did</c> through <see cref="PluginIcons.Normalize"/> (spell/item ids
/// are never DAT RenderSurface DIDs, so they never pass through it).
/// Null <paramref name="icons"/> (no resolver wired) always resolves to
/// nothing rather than throwing — a standalone <c>&lt;icon&gt;</c> draws
/// nothing either way, so there is no column-reservation concern here
/// the way there is for <c>&lt;button icon&gt;</c>/<c>&lt;list icons&gt;</c>
/// (see their own call sites in <see cref="AddElement"/>).
/// </summary>
private static Func<(uint tex, int w, int h)> BuildIconSource(
string? iconKind, Func<uint> idReader, IMarkupIconResolver? icons)
{
string kind = ValidateIconKind(iconKind);
if (icons is null)
return static () => (0u, 0, 0);
return kind switch
{
"did" => () => icons.ResolveDid(
PluginIcons.Normalize(idReader())),
"spell" => () => icons.ResolveSpell(idReader()),
"item" => () => icons.ResolveItem(idReader()),
_ => throw new InvalidOperationException(
"unreachable — ValidateIconKind already rejected anything else"),
};
}
/// <summary>
/// Same dispatch as <see cref="BuildIconSource"/>, shaped for
/// <c>&lt;list icons&gt;</c>'s per-row resolve (the row's own icon id is
/// the argument rather than a captured reader). Callers only invoke this
/// after confirming <paramref name="icons"/> is non-null (see the
/// <c>&lt;list&gt;</c> case in <see cref="AddElement"/>) so
/// <see cref="UiMarkupList.IconResolve"/> is never set to an
/// always-empty delegate.
/// </summary>
private static Func<uint, (uint tex, int w, int h)> BuildRowIconResolve(
string? iconKind, IMarkupIconResolver icons)
{
string kind = ValidateIconKind(iconKind);
return kind switch
{
"did" => id => icons.ResolveDid(
PluginIcons.Normalize(id)),
"spell" => icons.ResolveSpell,
"item" => icons.ResolveItem,
_ => throw new InvalidOperationException(
"unreachable — ValidateIconKind already rejected anything else"),
};
}
/// <summary>
/// Resolves a <c>did</c>/<c>spell</c>/<c>item</c> attribute to a live
/// <see cref="uint"/> reader: a <c>{Prop}</c> binding re-reads a property
/// every frame; a literal accepts hex (<c>0x...</c>) or decimal, matching
/// every other markup id attribute's "malformed literal throws at Build"
/// rule.
/// </summary>
/// <remarks>
/// Review fix round finding 5: accepts ANY integral property type
/// (<see cref="int"/>, <see cref="long"/>, <see cref="uint"/>,
/// <see cref="ushort"/>, a nullable of any of those, …), not only an
/// exact <see cref="uint"/> match — matching <see cref="BindUint"/>'s own
/// leniency below. Decal-facing bindings are commonly <c>int</c> end to
/// end (e.g. MosswartMassacre's <c>HudPictureBox.Image</c>), so requiring
/// a literal <c>uint</c> property rejected every one of them at Build. A
/// property whose runtime value cannot convert (a non-numeric type) still
/// throws — just from <see cref="Convert.ToUInt32(object, IFormatProvider)"/>
/// at read time rather than a type check at Build, the same tradeoff
/// <see cref="BindUint"/> already makes.
/// </remarks>
/// <remarks>
/// Residual round finding N3: a NEGATIVE bound value (Decal's own
/// convention for "no icon" — e.g. <c>HudPictureBox.Image = -1</c>) used
/// to throw <see cref="OverflowException"/> straight out of
/// <see cref="Convert.ToUInt32(object, IFormatProvider)"/> — every frame,
/// from inside <c>UiSimpleButton.OnDraw</c>. DECIDED: any value that does
/// not fit in a <see cref="uint"/> — negative, or above
/// <see cref="uint.MaxValue"/> — maps to <c>0u</c> (draws nothing) rather
/// than throwing at draw time. <see cref="Convert.ToUInt32(object, IFormatProvider)"/>
/// already throws exactly <see cref="OverflowException"/> for both of
/// those cases (never for a non-numeric type, which still throws
/// <see cref="InvalidCastException"/>/<see cref="FormatException"/> as
/// before), so catching only that one exception type is sufficient.
/// </remarks>
private static Func<uint> BindUintLiteralOrBinding(
string expression, object binding, string context)
{
if (!IsBinding(expression))
{
uint literal = ParseUintLiteral(expression, context);
return () => literal;
}
PropertyInfo? property = binding.GetType().GetProperty(expression[1..^1]);
if (property is null)
{
throw new FormatException(
$"{expression} did not resolve to a property on "
+ binding.GetType().Name + $" ({context})");
}
return () => property.GetValue(binding) switch
{
uint u => u,
null => 0u,
var v => ToUintOrZero(v),
};
}
/// <summary>
/// <see cref="Convert.ToUInt32(object, IFormatProvider)"/>, mapping an
/// out-of-range value (negative, or above <see cref="uint.MaxValue"/>) to
/// <c>0u</c> instead of propagating <see cref="OverflowException"/>. See
/// <see cref="BindUintLiteralOrBinding"/>'s finding N3 remark.
/// </summary>
private static uint ToUintOrZero(object value)
{
try
{
return Convert.ToUInt32(value, CultureInfo.InvariantCulture);
}
catch (OverflowException)
{
return 0u;
}
}
private static uint ParseUintLiteral(string text, string context)
{
string trimmed = text.Trim();
if (trimmed.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
{
if (uint.TryParse(trimmed.AsSpan(2), NumberStyles.HexNumber,
CultureInfo.InvariantCulture, out uint hex))
return hex;
}
else if (uint.TryParse(trimmed, NumberStyles.Integer,
CultureInfo.InvariantCulture, out uint dec))
{
return dec;
}
throw new FormatException($"{context}=\"{text}\" is not a valid uint literal");
}
/// <summary>
/// Resolves <c>{PropName}</c> to a live string reader, or returns the
/// literal text unchanged. The indirection matters: binding to a
/// <see cref="Func{T}"/> rather than copying the value once is what makes a
/// plugin's status text update as its state changes.
/// </summary>
private static Func<string?> BindString(string? attribute, object binding)
{
if (attribute is null)
return static () => null;
if (!IsBinding(attribute))
return () => attribute;
string name = attribute[1..^1];
PropertyInfo? property = binding.GetType().GetProperty(name);
if (property is null)
return () => attribute;
return () => property.GetValue(binding)?.ToString();
}
/// <summary>Resolves <c>{PropName}</c> to an <see cref="Action"/> property.</summary>
private static Action? BindAction(string? attribute, object binding)
{
if (attribute is null || !IsBinding(attribute))
return null;
string name = attribute[1..^1];
PropertyInfo? property = binding.GetType().GetProperty(name);
if (property is null || !typeof(Action).IsAssignableFrom(property.PropertyType))
return null;
// Read through on each click rather than capturing the delegate now, so
// a binding object may swap its handler (or null it out while busy)
// without rebuilding the panel.
return () => (property.GetValue(binding) as Action)?.Invoke();
}
private static Action<float>? BindFloatAction(
string? attribute,
object binding)
{
if (attribute is null || !IsBinding(attribute))
return null;
string name = attribute[1..^1];
PropertyInfo? property = binding.GetType().GetProperty(name);
if (property is null
|| !typeof(Action<float>).IsAssignableFrom(property.PropertyType))
return null;
return value => (property.GetValue(binding) as Action<float>)?.Invoke(value);
}
private static Action<string>? BindStringAction(
string? attribute,
object binding)
{
if (attribute is null || !IsBinding(attribute))
return null;
string name = attribute[1..^1];
PropertyInfo? property = binding.GetType().GetProperty(name);
if (property is null
|| !typeof(Action<string>).IsAssignableFrom(property.PropertyType))
{
return null;
}
return value => (property.GetValue(binding) as Action<string>)?.Invoke(value);
}
private static Action<int>? BindIntAction(string? attribute, object binding)
{
if (attribute is null || !IsBinding(attribute))
return null;
PropertyInfo? property = binding.GetType().GetProperty(attribute[1..^1]);
if (property is null
|| !typeof(Action<int>).IsAssignableFrom(property.PropertyType))
{
return null;
}
return value => (property.GetValue(binding) as Action<int>)?.Invoke(value);
}
private static Func<IReadOnlyList<string>> BindStringList(
string? expression,
object binding,
string context)
{
if (string.IsNullOrWhiteSpace(expression) || !IsBinding(expression))
throw new FormatException($"{context} must be a string-list binding");
PropertyInfo? property = binding.GetType().GetProperty(expression[1..^1]);
if (property is null
|| !typeof(IEnumerable<string>).IsAssignableFrom(property.PropertyType))
{
throw new FormatException(
$"{expression} did not resolve to an IEnumerable<string> property on "
+ binding.GetType().Name);
}
return () => property.GetValue(binding) is IEnumerable<string> values
? values.ToArray()
: Array.Empty<string>();
}
/// <remarks>
/// Review fix round finding 5: also accepts <see cref="IEnumerable{T}"/>
/// of <see cref="int"/> — Decal is <c>int</c> end to end
/// (MosswartMassacre's <c>FlagTrackerView.cs</c> feeds
/// <c>HudPictureBox.Image</c> from <c>int</c> ids), so a plugin porting
/// that convention hands the host <c>IEnumerable&lt;int&gt;</c>, not
/// <c>IEnumerable&lt;uint&gt;</c>.
/// </remarks>
/// <remarks>
/// Residual round finding N3: the per-element conversion used to be an
/// UNCHECKED reinterpret (<c>-1</c> silently wrapped to <c>0xFFFFFFFF</c>
/// — a bogus, almost-certainly-unresolvable id drawn as if it were a real
/// one, rather than the "no icon" <c>0u</c> the same negative value maps
/// to on the scalar path — see <see cref="ToUintOrZero"/>). DECIDED: a
/// negative element now maps to <c>0u</c> here too, matching the scalar
/// path's contract exactly (both "no icon" conventions agree).
/// </remarks>
private static Func<IReadOnlyList<uint>> BindUintList(
string? expression,
object binding,
string context)
{
if (string.IsNullOrWhiteSpace(expression))
return static () => Array.Empty<uint>();
if (!IsBinding(expression))
throw new FormatException($"{context} must be a uint-list binding");
PropertyInfo? property = binding.GetType().GetProperty(expression[1..^1]);
if (property is null)
{
throw new FormatException(
$"{expression} did not resolve to an IEnumerable<uint> or "
+ "IEnumerable<int> property on " + binding.GetType().Name);
}
if (typeof(IEnumerable<uint>).IsAssignableFrom(property.PropertyType))
{
return () => property.GetValue(binding) is IEnumerable<uint> values
? values.ToArray()
: Array.Empty<uint>();
}
if (typeof(IEnumerable<int>).IsAssignableFrom(property.PropertyType))
{
return () => property.GetValue(binding) is IEnumerable<int> values
? values.Select(static v => v < 0 ? 0u : (uint)v).ToArray()
: Array.Empty<uint>();
}
throw new FormatException(
$"{expression} did not resolve to an IEnumerable<uint> or "
+ "IEnumerable<int> property on " + binding.GetType().Name);
}
/// <summary>
/// <c>&lt;column type="check" values="{IReadOnlyList&lt;bool&gt;}"&gt;</c>
/// (Campaign VT slice 1 Part B). Required — unlike <see cref="BindUintList"/>'s
/// "silent if omitted" carve-out for the optional <c>list colors</c>
/// attribute, a check column with no <c>values</c> binding is a Build-time
/// author error (there is nothing sensible to draw).
/// </summary>
private static Func<IReadOnlyList<bool>> BindBoolList(
string? expression, object binding, string context)
{
if (string.IsNullOrWhiteSpace(expression) || !IsBinding(expression))
throw new FormatException($"{context} must be a bool-list binding");
PropertyInfo? property = binding.GetType().GetProperty(expression[1..^1]);
if (property is null
|| !typeof(IEnumerable<bool>).IsAssignableFrom(property.PropertyType))
{
throw new FormatException(
$"{expression} did not resolve to an IEnumerable<bool> property on "
+ binding.GetType().Name + $" ({context})");
}
return () => property.GetValue(binding) is IEnumerable<bool> values
? values.ToArray()
: Array.Empty<bool>();
}
/// <summary>
/// Same grammar as <see cref="BindUintList"/> but REQUIRED — used by
/// <c>&lt;column type="icon" values="..."&gt;</c>, where (unlike the
/// single-column list's optional <c>icons</c> attribute) there is no
/// "no icon column at all" fallback: an icon column with no
/// <c>values</c> binding is a Build-time author error.
/// </summary>
private static Func<IReadOnlyList<uint>> BindRequiredUintList(
string? expression, object binding, string context)
{
if (string.IsNullOrWhiteSpace(expression))
throw new FormatException($"{context} must be a uint-list binding");
return BindUintList(expression, binding, context);
}
/// <summary>
/// <c>&lt;column&gt;</c>'s <c>onchange</c> (check)/<c>onclick</c> (icon) —
/// unlike every other <c>Action&lt;int&gt;</c> sink in this file (the
/// list's own <c>onchange</c>, which is optional), a column callback is
/// REQUIRED: a check/icon column that never fires anything is a
/// Build-time author error, not a silently-inert control.
/// </summary>
private static Action<int> BindRequiredIntAction(
string? attribute, object binding, string context)
{
if (attribute is null || !IsBinding(attribute))
throw new FormatException($"{context} must be an Action<int> binding");
PropertyInfo? property = binding.GetType().GetProperty(attribute[1..^1]);
if (property is null || !typeof(Action<int>).IsAssignableFrom(property.PropertyType))
{
throw new FormatException(
$"{attribute} did not resolve to an Action<int> property on "
+ binding.GetType().Name + $" ({context})");
}
return value => (property.GetValue(binding) as Action<int>)?.Invoke(value);
}
/// <summary>
/// Builds one <see cref="UiMarkupListColumn"/> from a <c>&lt;column&gt;</c>
/// child of <c>&lt;list&gt;</c> (Campaign VT slice 1 Part B —
/// <c>docs/research/vtank-kb/08-ui-views.md</c> §3's proposed extension).
/// <paramref name="icons"/> follows the same "validate iconkind
/// unconditionally, wire the resolver only when one exists" rule as the
/// legacy <c>&lt;list icons&gt;</c> path (<see cref="BuildRowIconResolve"/>'s
/// own call site above): a malformed <c>iconkind</c> throws at Build even
/// on a resolver-less host, but <see cref="UiMarkupListColumn.IconResolve"/>
/// stays null (draws nothing) rather than ever pointing at a null resolver.
/// </summary>
private static UiMarkupListColumn BuildListColumn(
XElement columnEl, object binding, IMarkupIconResolver? icons, int index, bool isLast)
{
string? type = (string?)columnEl.Attribute("type");
(float width, bool isAutoWidth) = ParseColumnWidth(columnEl, index, type, isLast);
switch (type)
{
case "text":
{
var textSource = BindStringList(
(string?)columnEl.Attribute("items"), binding, ColumnContext(index, "text", "items"));
string? colorsAttr = (string?)columnEl.Attribute("colors");
Func<IReadOnlyList<uint>>? colorsSource = colorsAttr is null
? null
: BindUintList(colorsAttr, binding, ColumnContext(index, "text", "colors"));
// Fix round finding 1: optional onclick — a text cell that
// declares one fires it with the row index instead of
// selecting; one that doesn't keeps the original
// select-on-click behavior. Same "resolve if binding-shaped,
// throw only if malformed" rule as the list's own onchange
// above — omitting the attribute entirely is fine.
string? textOnClickAttr = (string?)columnEl.Attribute("onclick");
Action<int>? textOnClick = BindIntAction(textOnClickAttr, binding);
if (textOnClickAttr is not null && textOnClick is null)
{
throw new FormatException(
$"{ColumnContext(index, "text", "onclick")} did not resolve to an "
+ $"Action<int> property on {binding.GetType().Name}");
}
return UiMarkupListColumn.Text(width, textSource, colorsSource, textOnClick, isAutoWidth);
}
case "check":
{
var checkSource = BindBoolList(
(string?)columnEl.Attribute("values"), binding, ColumnContext(index, "check", "values"));
var onChange = BindRequiredIntAction(
(string?)columnEl.Attribute("onchange"), binding, ColumnContext(index, "check", "onchange"));
return UiMarkupListColumn.Check(width, checkSource, onChange, isAutoWidth);
}
case "icon":
{
var valuesSource = BindRequiredUintList(
(string?)columnEl.Attribute("values"), binding, ColumnContext(index, "icon", "values"));
string? iconKind = (string?)columnEl.Attribute("iconkind");
ValidateIconKind(iconKind, ColumnContext(index, "icon", "iconkind"));
var onClick = BindRequiredIntAction(
(string?)columnEl.Attribute("onclick"), binding, ColumnContext(index, "icon", "onclick"));
Func<uint, (uint, int, int)>? resolve = icons is not null
? BuildRowIconResolve(iconKind, icons)
: null;
return UiMarkupListColumn.Icon(width, valuesSource, resolve, onClick, isAutoWidth);
}
default:
throw new FormatException(
$"column[{index}] has unknown type=\"{type}\" (expected text, check, or icon)");
}
}
/// <summary>
/// Fix round finding 5: every column-attribute throw message identifies
/// the offending column by position and declared type
/// (<c>column[2] type="check" values</c>) rather than the generic
/// <c>"column values"</c> the initial slice used — a plugin author with
/// several columns of the same <c>type</c> needs the index to find which
/// one is wrong.
/// </summary>
private static string ColumnContext(int index, string type, string attribute) =>
$"column[{index}] type=\"{type}\" {attribute}";
/// <summary>
/// Fix round item 2: <c>&lt;column width&gt;</c> semantics. <c>"*"</c>
/// (any column, including the last) means auto — this column shares the
/// list's remaining width equally with every other auto column at
/// layout time (see <see cref="UiMarkupList"/>'s column-layout helper).
/// A NON-last column with a missing, unparseable, or non-positive width
/// is a Build-time author error (there is nothing sensible to lay out).
/// The LAST column is exempt from this validation entirely — it always
/// absorbs whatever room remains regardless of its own declared width,
/// so an invalid value there is harmless and never thrown; only an
/// explicit <c>"*"</c> there is actually meaningful (it makes the last
/// column share evenly with any OTHER auto columns instead of taking
/// 100% of the remainder alone — see <see cref="UiMarkupListColumn"/>'s
/// own doc for exactly how the last column's implicit auto-ness
/// combines with an explicit one).
/// </summary>
private static (float width, bool isAutoWidth) ParseColumnWidth(
XElement columnEl, int index, string? type, bool isLast)
{
string? raw = (string?)columnEl.Attribute("width");
if (raw == "*")
return (0f, true);
if (isLast)
return (F(columnEl, "width"), false);
if (!float.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out float width)
|| width <= 0f)
{
throw new FormatException(
$"{ColumnContext(index, type ?? "(missing)", "width")} must be a positive "
+ "number or \"*\", got " + (raw is null ? "(missing)" : $"\"{raw}\""));
}
return (width, false);
}
private static bool IsBinding(string value) =>
value.Length > 2 && value[0] == '{' && value[^1] == '}';
private static void ApplyCommon(
UiElement element,
XElement source,
object binding)
{
element.Name = (string?)source.Attribute("name")
?? (string?)source.Attribute("id");
BindBool((string?)source.Attribute("visible"), binding,
value => element.Visible = value,
sourceReader => element.VisibleSource = sourceReader);
BindBool((string?)source.Attribute("enabled"), binding,
value => element.Enabled = value,
sourceReader => element.EnabledSource = sourceReader);
string? tooltip = (string?)source.Attribute("tooltip");
if (!string.IsNullOrWhiteSpace(tooltip))
{
element.RuntimeTooltipTextSource = BindString(tooltip, binding);
element.AuthoredTooltipRootElementId = RuntimeTooltipRootElementId;
element.AuthoredTooltipLayoutDid = RuntimeTooltipLayoutDid;
element.AuthoredTooltipEnabled = true;
}
}
private static void BindBool(
string? expression,
object binding,
Action<bool> setLiteral,
Action<Func<bool>> setSource)
{
if (string.IsNullOrWhiteSpace(expression))
return;
if (!IsBinding(expression))
{
if (bool.TryParse(expression, out bool literal))
setLiteral(literal);
return;
}
PropertyInfo? property = binding.GetType().GetProperty(expression[1..^1]);
if (property is null || property.PropertyType != typeof(bool))
{
throw new FormatException(
$"{expression} did not resolve to a bool property on "
+ binding.GetType().Name);
}
setSource(() => property.GetValue(binding) is true);
}
private static Func<bool> BindRequiredBoolReader(
string? expression,
object binding,
string context)
{
if (string.IsNullOrWhiteSpace(expression) || !IsBinding(expression))
throw new FormatException($"{context} must be a bool binding");
PropertyInfo? property = binding.GetType().GetProperty(expression[1..^1]);
if (property is null || property.PropertyType != typeof(bool))
{
throw new FormatException(
$"{expression} did not resolve to a bool property on "
+ binding.GetType().Name);
}
return () => property.GetValue(binding) is true;
}
private static Func<int> BindRequiredIntReader(
string? expression,
object binding,
string context)
{
if (string.IsNullOrWhiteSpace(expression) || !IsBinding(expression))
throw new FormatException($"{context} must be an int binding");
PropertyInfo? property = binding.GetType().GetProperty(expression[1..^1]);
if (property is null || property.PropertyType != typeof(int))
{
throw new FormatException(
$"{expression} did not resolve to an int property on "
+ binding.GetType().Name);
}
return () => property.GetValue(binding) is int value ? value : -1;
}
private static float F(XElement e, string attr)
=> float.TryParse((string?)e.Attribute(attr), NumberStyles.Float,
CultureInfo.InvariantCulture, out var v) ? v : 0f;
private static float FOr(XElement e, string attr, float fallback)
=> float.TryParse((string?)e.Attribute(attr), NumberStyles.Float,
CultureInfo.InvariantCulture, out float value) ? value : fallback;
private static int I(XElement e, string attr, int fallback)
=> int.TryParse((string?)e.Attribute(attr), NumberStyles.Integer,
CultureInfo.InvariantCulture, out int value) ? value : fallback;
private static bool B(XElement e, string attr, bool fallback)
=> bool.TryParse((string?)e.Attribute(attr), out bool value)
? value
: fallback;
/// <summary>
/// Parses <c>#AARRGGBB</c> → RGBA <see cref="Vector4"/> (alpha first, matching
/// controls.ini convention). Falls back to opaque white on bad input.
/// </summary>
private static Vector4 Color(string? hex)
{
if (hex is { Length: 9 } && hex[0] == '#'
&& uint.TryParse(hex.AsSpan(1), NumberStyles.HexNumber,
CultureInfo.InvariantCulture, out uint argb))
return new Vector4(
((argb >> 16) & 0xFF) / 255f,
((argb >> 8) & 0xFF) / 255f,
(argb & 0xFF) / 255f,
((argb >> 24) & 0xFF) / 255f);
return Vector4.One;
}
private static Func<float?> BindFloat(string? expr, object binding)
{
var pi = Prop(expr, binding);
if (pi is null) return () => 0f;
return () => pi.GetValue(binding) switch
{
float f => f,
null => (float?)null,
var v => Convert.ToSingle(v, CultureInfo.InvariantCulture),
};
}
private static Func<uint?> BindUint(string? expr, object binding)
{
var pi = Prop(expr, binding);
if (pi is null) return () => null;
return () => pi.GetValue(binding) switch
{
uint u => u,
null => (uint?)null,
var v => Convert.ToUInt32(v, CultureInfo.InvariantCulture),
};
}
private static PropertyInfo? Prop(string? expr, object binding)
{
if (expr is null || expr.Length < 3 || expr[0] != '{' || expr[^1] != '}') return null;
return binding.GetType().GetProperty(expr[1..^1]);
}
private static uint Hex(string? s)
{
if (string.IsNullOrWhiteSpace(s)) return 0;
var t = s.Trim();
if (t.StartsWith("0x", System.StringComparison.OrdinalIgnoreCase)) t = t[2..];
return uint.TryParse(t, System.Globalization.NumberStyles.HexNumber,
System.Globalization.CultureInfo.InvariantCulture, out var v) ? v : 0u;
}
private static AnchorEdges Anchor(string? csv)
{
if (string.IsNullOrWhiteSpace(csv)) return AnchorEdges.Left | AnchorEdges.Top;
var a = AnchorEdges.None;
foreach (var part in csv.Split(',', System.StringSplitOptions.TrimEntries | System.StringSplitOptions.RemoveEmptyEntries))
a |= part.ToLowerInvariant() switch
{
"left" => AnchorEdges.Left,
"top" => AnchorEdges.Top,
"right" => AnchorEdges.Right,
"bottom" => AnchorEdges.Bottom,
_ => AnchorEdges.None,
};
return a == AnchorEdges.None ? AnchorEdges.Left | AnchorEdges.Top : a;
}
}