merge(vt): slice 1 Part B — multi-column <list> markup (review-closed)

Campaign VT slice 1 Part B: <list><column type=text|check|icon> with
per-column bindings, text onclick, width="*" auto share, row-bound
callback guards, shared UiCheckLamp, Meta/Monsters-shaped end-to-end
tests. Two Opus lenses + fix round + narrow re-review: MERGE-READY.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-06 21:40:54 +02:00
commit da0fcb3741
9 changed files with 2543 additions and 89 deletions

View file

@ -78,6 +78,15 @@ check those four against the markup by eye.
| `slider onchange` | Throws | `Action<float>` | | `slider onchange` | Throws | `Action<float>` |
| `field onchange`, `field onsubmit`, `menu onchange` | Throws | `Action<string>` | | `field onchange`, `field onsubmit`, `menu onchange` | Throws | `Action<string>` |
| `list onchange` | Throws | `Action<int>` | | `list onchange` | Throws | `Action<int>` |
| `column items` (`type="text"`) | Throws — REQUIRED, unlike the single-column list's own `items` sugar it mirrors | `IReadOnlyList<string>` |
| `column colors` (`type="text"`) | **Silent** if omitted (no per-row override, same rule as `list colors`); throws if present but mistyped | `IReadOnlyList<uint>` **or** `IReadOnlyList<int>` |
| `column onclick` (`type="text"`) | **Silent** if omitted (keeps the original select-the-row behavior); throws if present but mistyped | `Action<int>` (row index) |
| `column values` (`type="check"`) | Throws — REQUIRED (there is no "no check column" fallback the way `list icons` has "no icon column") | `IReadOnlyList<bool>` |
| `column values` (`type="icon"`) | Throws — REQUIRED | `IReadOnlyList<uint>` **or** `IReadOnlyList<int>` |
| `column onchange` (`type="check"`) | Throws — REQUIRED (unlike the list's own optional `onchange`) | `Action<int>` (row index) |
| `column onclick` (`type="icon"`) | Throws — REQUIRED | `Action<int>` (row index) |
| `column width` (any type, NOT the list's last column) | Throws if missing, unparseable, or `<= 0` — UNLESS it is the literal `"*"` | `float`, or the literal `"*"` for auto |
| `column width` (the list's LAST column) | **Silent** — never validated, never used for layout (it always absorbs the remainder), UNLESS it is the literal `"*"` (then it joins the auto-sharing group instead of taking 100% of the remainder alone) | `float`, or the literal `"*"` |
The icon-id row is the one binding here whose failure mode depends on WHEN you look: a typo'd property name is caught immediately at `Build`, but a property that exists yet holds the wrong kind of value at runtime is only ever discovered later, from inside a live draw. The icon-id row is the one binding here whose failure mode depends on WHEN you look: a typo'd property name is caught immediately at `Build`, but a property that exists yet holds the wrong kind of value at runtime is only ever discovered later, from inside a live draw.
@ -100,7 +109,7 @@ vanishing from the built tree.
| `slider` | Horizontal scalar | `x y w h value onchange` | | `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` | | `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` |
| `list` | Scrollable row list (+ Slice B icon column) | `x y w h items colors selected onchange rowheight icons iconkind` | | `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 |
Common to every element via `ApplyCommon`: `name`/`id` (a stable control Common to every element via `ApplyCommon`: `name`/`id` (a stable control
name), `visible` (literal `true`/`false` or a bound `bool` property), name), `visible` (literal `true`/`false` or a bound `bool` property),
@ -111,10 +120,11 @@ through `ApplyCommon` (no `name`/`enabled`/`tooltip`), and its `visible`
attribute accepts a `{Binding}` only — a literal `visible="true"` on the attribute accepts a `{Binding}` only — a literal `visible="true"` on the
root is not parsed (unlike every child element, where a literal is fine). root is not parsed (unlike every child element, where a literal is fine).
**LIMITATION:** `<list>` has exactly one text column (plus the optional Multi-column lists are real (Campaign VT slice 1 Part B, below) — a `<list>`
Slice B icon column) — there is no multi-column list yet. A plugin that with `<column>` children is no longer limited to one padded text column. A
needs tabular rows today pads its own fixed-width text (`$"{name,-16}{value,6}"`). `<list>` with no `<column>` children stays exactly the older single-column
Real multi-column support is deferred to the MossTank plugin work. form (`items`/`colors`/`icons`/`iconkind` on the element itself); the two
forms are mutually exclusive on one element.
`list colors`' values are `0xRRGGBB` (opaque, no alpha channel), while every `list colors`' values are `0xRRGGBB` (opaque, no alpha channel), while every
`color=`/`background=`/`border=` attribute elsewhere is `#AARRGGBB` (alpha `color=`/`background=`/`border=` attribute elsewhere is `#AARRGGBB` (alpha
@ -291,6 +301,171 @@ public IEnumerable<string> SpellRows =>
tile, no composited badge — uses `iconkind="did"` instead, with `icons` tile, no composited badge — uses `iconkind="did"` instead, with `icons`
yielding `IconId` rather than `SpellId`.) yielding `IconId` rather than `SpellId`.)
## Columns (Campaign VT slice 1 Part B)
VVS's `HudList` (the VirindiViewService list control VTank's own `mainView.xml`
uses) supports N independently-typed columns per row — text, checkbox, and
icon cells side by side in one scrolling grid, each with its own `Click(row,
col)`. acdream's `<list>` matches this by letting a `<list>` declare
`<column>` children instead of the single-column `items`/`colors`/`icons`
attributes:
```xml
<list x="8" y="24" w="256" h="120" rowheight="18"
selected="{SelectedMonster}" onchange="{SelectMonster}">
<column type="check" width="20" values="{MonsterFester}" onchange="{ToggleFester}"/>
<column type="text" width="127" items="{MonsterNames}" onclick="{PingMonster}"/>
<column type="icon" width="*" iconkind="did" values="{MonsterIcons}" onclick="{MoveMonsterUp}"/>
</list>
```
This mirrors VTank's own Monsters tab (several boolean flag columns, a name
column, and icon-button columns) — see
`docs/research/vtank-kb/08-ui-views.md` §3's "Multi-column lists with typed
columns" gap and its proposed extension, which this implements verbatim. The
`rowheight="18"` above is the widget's own default (`UiMarkupList.RowHeight`),
chosen to match VVS's own row pitch exactly: `Padding*2 + ControlHeight` =
`1*2 + 16` = `18` (`docs/research/vtank-kb/08-ui-views.md`'s `HudList` row:
`Padding=1px`, `ControlHeight=16px`) — an author who omits `rowheight`
entirely already gets VVS's pitch for free.
### `<column>` attribute grammar
| Attribute | Applies to | Required | Meaning |
|---|---|---|---|
| `type` | every column | yes | `text`, `check`, or `icon` — any other value throws `FormatException` at `Build` |
| `width` | every column | see below | Column width in px, or `"*"` for auto. See "Width semantics" below — the rules differ for the LAST column in a `<list>` vs. every other column |
| `items` | `type="text"` | yes | `{IReadOnlyList<string>}` — one row of text per index |
| `colors` | `type="text"` | no | `{IReadOnlyList<uint>}`, `0xRRGGBB` per row (same grammar as the single-column list's own `colors`); omitted rows (or the whole attribute) fall back to the list's `TextColor` |
| `onclick` | `type="text"` | no | `{Action<int>}` — fired with the ROW INDEX on a click anywhere in the cell INSTEAD of selecting the row. Omitted (the default) keeps the original select-the-row behavior; present but malformed throws `FormatException` at `Build`. None of VTank's eight lists actually relies on row selection — every real text cell in `mainView.xml` is wired as an action target — so a new column is usually written WITH an `onclick` |
| `values` | `type="check"` | yes | `{IReadOnlyList<bool>}` — the checked state per row |
| `onchange` | `type="check"` | yes | `{Action<int>}` — fired with the ROW INDEX on a click anywhere in the cell; the plugin flips its own bool, the column never mutates `values`' backing collection itself |
| `values` | `type="icon"` | yes | `{IReadOnlyList<uint>}` (or `IReadOnlyList<int>`) — one icon id per row, same id-space rules as `list icons` |
| `iconkind` | `type="icon"` | no (defaults `"did"`) | `did`/`spell`/`item`, same three-source dispatch as `<list icons iconkind>` above — one kind per column, not per row |
| `onclick` | `type="icon"` | yes | `{Action<int>}` — fired with the ROW INDEX on a click anywhere in the cell |
Unlike the single-column list's optional `icons`/`onchange`, a `check`/`icon`
column's own `values` and `onchange`/`onclick` are **required** — a column
that can never fire anything, or has nothing to draw, is a Build-time author
error, not a silently-inert control. A `<column>` with an unrecognized `type`,
a missing required binding for its type, or any `<list>` child element that
isn't `<column>` at all, throws `FormatException` at `Build`. A `<list>` with
`<column>` children cannot ALSO use the single-column `items`/`colors`/`icons`
attributes on the `<list>` element itself — pick one form per list. Every
column-attribute throw message identifies the offending column by position
and declared type — `column[2] type="check" values`, not just `"column
values"` — so a list with several columns of the same type still points at
the right one.
### Width semantics
`width="*"` means AUTO: this column shares the list's remaining width
EQUALLY with every other auto column, VVS's own "0-width column auto-sizes"
rule (`docs/research/vtank-kb/08-ui-views.md`'s `HudList` row: "a 0-width
text/button/edit/list/fixedlayout/notebook column auto-sizes... share the
remaining width equally"). `width="*"` is legal on ANY column, including the
last.
The LAST column in a `<list>` is special: it is ALWAYS treated as auto —
sharing the remaining width like every other auto column when one or more
earlier columns also declare `width="*"`, or absorbing 100% of the remainder
by itself when no other column does (the original, still-default behavior).
Its own declared `width` (or omitting `width` entirely) is never validated
and never used for layout — only an explicit `width="*"` on the last column
actually changes anything (it makes the last column share evenly with
earlier auto columns instead of taking the whole remainder alone). When two
or more columns end up sharing, integer-division remainder goes to the LAST
one — e.g. three columns sharing 100px split 33/33/34, not 33/33/33 with 1px
unaccounted for.
Every OTHER (non-last) column's declared `width` is validated at `Build`: **a
missing, unparseable, or non-positive `width` throws `FormatException`**
naming the column's index and declared type (`column[0] type="text" width
must be a positive number or "*", got (missing)`) — UNLESS it is `width="*"`.
At layout time (recomputed every frame off the list's live width, so a
resizable list re-flows like every other retained widget), a declared width
that would overflow the list's total width is CLAMPED to whatever room is
actually left, walked left to right — every column after the overflow point
gets `0` width and draws nothing (a `<=0`-width cell is skipped entirely, the
same as today).
### Row count, selection, and clicks
Row count is the longest bound column (a text column with 20 rows next to a
check column with only 5 simply draws 15 rows past its own data — see "Short
columns past their own row count" below for what each column kind does
there). The list's own `selected`/`onchange` attributes keep exactly their
single-column meaning: a click in a **text** column without its own
`onclick` selects that row (and fires the list's `onchange` with the row
index, same as today). A click in a **check** or **icon** column, or a
**text** column that DOES declare its own `onclick`, instead fires that
column's own `onchange`/`onclick` and does **not** change the list's
selection — VVS's per-cell `Click(row, col)` folded into a per-column
callback, since acdream's binding model is per-attribute rather than
per-cell. A click landing on a row past that SPECIFIC column's own bound
data (even though the row is valid for the list overall, because some OTHER
column has more rows) fires nothing — no callback, no crash. Scrolling works
exactly as the single-column list already does.
### No header row
VVS's `HudList` has no built-in header row either — the column-caption
glyphs seen in VTank's own `mainView.xml` (e.g. the Monsters tab's single-letter
"F"/"B"/"G"/"I"/… flag headers) are ordinary `StaticText` controls placed
manually above the list. acdream matches this for free: put a `<label>` (or
several, one per column, hand-positioned) directly above the `<list>` — there
is no dedicated header markup to learn.
### Check-column glyph
A `type="check"` cell draws with the exact same five-band lamp glyph as
`<toggle>` (both now share one `UiCheckLamp` primitive — its checked/
unchecked colors and `Draw` method), so a column checkbox reads identically
to every other checkbox in the client rather than a bespoke box-and-tick.
The glyph is centered horizontally in its cell (matching how an icon cell
already centers its sprite) — a check column is routinely declared wider
than the glyph itself under the PITCH convention below.
### Short columns past their own row count
A **text** or **icon** cell past its own column's row count draws nothing —
there is no sensible default string or icon to show. A **check** cell past
its own column's row count still draws the lamp, UNCHECKED — VVS
materializes every cell in a row regardless of which columns actually have
data for it, and there is always a sensible default for a boolean (false).
### The PITCH convention for transcribing a VTank column table
VVS's own `HudList` reserves geometry acdream's column model doesn't have a
separate concept for: `WPaddingOuter=3px` (the list's own left/right
margin), `WPadding=7px` (a gap BETWEEN columns), and a themed
`VScrollBarButtonSize=16px` (scrollbar width, reserved on the right). It
also forces every `CheckColumn` to a fixed 13px regardless of its declared
`fixedwidth`. acdream's column model has no separate gap/scrollbar/
forced-width concept — every column's declared `width` is its full cell
width, columns sit directly adjacent with no gap, and a check column uses
whatever `width` it's given like any other column.
To transcribe a real VTank column table (as in
`refs/vtank/uTank2.ViewXML.mainView.xml`) faithfully, declare each column's
**PITCH** instead of its raw `fixedwidth`: `pitch = fixedwidth + 7` (baking
VVS's inter-column `WPadding` into the cell width itself, since acdream has
no separate gap). For a `CheckColumn`, use VVS's forced 13px as the
`fixedwidth` regardless of whatever `fixedwidth` the source XML declares
(`16 -> 13 + 7 = 20`, not `16 + 7 = 23`). Reserve VVS's 16px scrollbar width
on the LAST column specifically (add it to that column's own pitch, or fold
it into the list's total declared `w`) — acdream's list draws no scrollbar
of its own today, but reserving the space keeps the transcribed proportions
matching what a real VVS `HudList` would show once one exists.
### Backward compatibility
A `<list>` with no `<column>` children is byte-for-byte the original
single-text-column widget — every existing panel (including every current
MossTank tab) keeps working unchanged; `<column>` is additive, not a
migration.
## The plugin shelf (Slice A) ## The plugin shelf (Slice A)
The shelf (`AcDream.App.UI.PluginSidePanel`) is the right-edge strip of The shelf (`AcDream.App.UI.PluginSidePanel`) is the right-edge strip of
@ -327,3 +502,13 @@ like every other window.
in-test class recording which id/kind it was asked to resolve) rather than a in-test class recording which id/kind it was asked to resolve) rather than a
live DAT — see `tests/AcDream.App.Tests/UI/`. `PluginSidePanelTests` exercises live DAT — see `tests/AcDream.App.Tests/UI/`. `PluginSidePanelTests` exercises
the shelf's drag/collapse/hide/persistence behavior against a bare `UiRoot`. the shelf's drag/collapse/hide/persistence behavior against a bare `UiRoot`.
`MarkupListColumnsTests` covers the Columns extension above: per-column
binding-type validation (every throw naming its column by index and type),
width semantics (`"*"` sharing, the last-column-always-auto rule, the
overflow clamp), the per-column row-bound click guard, draw-level
column-offset/clipping/check-glyph pins against the same recording-renderer
apparatus, hit-test routing (text selects unless it has its own `onclick`;
check/icon/onclick-text fire their own callback and never touch selection),
a backward-compatibility proof that a column-less `<list>` is unaffected,
and two full `MarkupDocument.Build` end-to-end tests transcribing VTank's
real Monsters- and Meta-tab column shapes.

View file

@ -509,6 +509,32 @@ public static class MarkupDocument
$"<list onchange=\"{listChangeName}\"> did not resolve to an " $"<list onchange=\"{listChangeName}\"> did not resolve to an "
+ $"Action<int> property on {binding.GetType().Name}"); + $"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 var list = new UiMarkupList
{ {
Left = F(el, "x"), Left = F(el, "x"),
@ -517,20 +543,31 @@ public static class MarkupDocument
Height = F(el, "h"), Height = F(el, "h"),
RowHeight = Math.Max(12f, FOr(el, "rowheight", 18f)), RowHeight = Math.Max(12f, FOr(el, "rowheight", 18f)),
DatFont = datFont, DatFont = datFont,
ItemsSource = BindStringList(
(string?)el.Attribute("items"),
binding,
"list items"),
ItemColorsSource = BindUintList(
(string?)el.Attribute("colors"),
binding,
"list colors"),
SelectedIndexSource = BindRequiredIntReader( SelectedIndexSource = BindRequiredIntReader(
(string?)el.Attribute("selected"), (string?)el.Attribute("selected"),
binding, binding,
"list selected"), "list selected"),
SelectionChanged = listChanged, 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">. // Slice B: <list icons="{IconIds}" iconkind="did|spell|item">.
// Same two rules as <button icon> above: iconkind validates // Same two rules as <button icon> above: iconkind validates
// regardless of resolver wiring (finding 4), and // regardless of resolver wiring (finding 4), and
@ -556,6 +593,7 @@ public static class MarkupDocument
list.IconResolve = BuildRowIconResolve(listIconKind, icons); list.IconResolve = BuildRowIconResolve(listIconKind, icons);
} }
} }
}
ApplyCommon(list, el, binding); ApplyCommon(list, el, binding);
parent.AddChild(list); parent.AddChild(list);
break; break;
@ -578,13 +616,21 @@ public static class MarkupDocument
/// with no <see cref="IMarkupIconResolver"/> wired at all. A malformed /// with no <see cref="IMarkupIconResolver"/> wired at all. A malformed
/// attribute is a Build-time author error regardless of what the host /// attribute is a Build-time author error regardless of what the host
/// happens to support. /// 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> /// </summary>
private static string ValidateIconKind(string? iconKind) => private static string ValidateIconKind(string? iconKind, string context = "iconkind") =>
(iconKind ?? "did") switch (iconKind ?? "did") switch
{ {
"did" or "spell" or "item" => iconKind ?? "did", "did" or "spell" or "item" => iconKind ?? "did",
var other => throw new FormatException( var other => throw new FormatException(
$"unknown iconkind \"{other}\" (expected did, spell, or item)"), $"{context} must be did, spell, or item (got \"{other}\")"),
}; };
/// <summary> /// <summary>
@ -884,6 +930,182 @@ public static class MarkupDocument
+ "IEnumerable<int> property on " + binding.GetType().Name); + "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) => private static bool IsBinding(string value) =>
value.Length > 2 && value[0] == '{' && value[^1] == '}'; value.Length > 2 && value[0] == '{' && value[^1] == '}';

View file

@ -0,0 +1,41 @@
using System.Numerics;
namespace AcDream.App.UI;
/// <summary>
/// Fix round item 8: the five-band lamp glyph checkbox primitive, promoted
/// out of <see cref="UiMarkupToggle"/> into its own shared static so BOTH
/// <see cref="UiMarkupToggle"/> (the standalone <c>&lt;toggle&gt;</c>
/// element) and <see cref="UiMarkupList"/>'s <c>&lt;column type="check"&gt;</c>
/// cell draw the IDENTICAL glyph from ONE definition rather than two
/// independently-maintained copies of the same five <c>DrawFill</c> calls
/// and four colors.
/// </summary>
internal static class UiCheckLamp
{
/// <summary>The lamp glyph's fixed on-screen size in px (both axes).</summary>
public const float LampSize = 11f;
public static readonly Vector4 CheckedOuter = new(0.36f, 0.58f, 0.12f, 1f);
public static readonly Vector4 CheckedInner = new(0.52f, 1f, 0.08f, 1f);
public static readonly Vector4 UncheckedOuter = new(0.26f, 0.22f, 0.13f, 1f);
public static readonly Vector4 UncheckedInner = new(0.38f, 0.34f, 0.23f, 1f);
/// <summary>
/// Draws the lamp with its top-left at <paramref name="x"/>,
/// <paramref name="y"/> — a <see cref="LampSize"/> x <see cref="LampSize"/>
/// footprint. Five bands form the small circular indicator without
/// introducing a plugin bitmap or a new renderer primitive.
/// </summary>
public static void Draw(UiRenderContext ctx, float x, float y, bool isChecked)
{
Vector4 outer = isChecked ? CheckedOuter : UncheckedOuter;
Vector4 inner = isChecked ? CheckedInner : UncheckedInner;
ctx.DrawFill(x + 3f, y, 5f, 1f, outer);
ctx.DrawFill(x + 1f, y + 1f, 9f, 2f, outer);
ctx.DrawFill(x, y + 3f, 11f, 5f, outer);
ctx.DrawFill(x + 1f, y + 8f, 9f, 2f, outer);
ctx.DrawFill(x + 3f, y + 10f, 5f, 1f, outer);
ctx.DrawFill(x + 3f, y + 3f, 5f, 5f, inner);
}
}

View file

@ -32,6 +32,39 @@ public sealed class UiMarkupList : UiElement
public Func<uint, (uint tex, int w, int h)>? IconResolve { get; set; } public Func<uint, (uint tex, int w, int h)>? IconResolve { get; set; }
public Func<int> SelectedIndexSource { get; set; } = static () => -1; public Func<int> SelectedIndexSource { get; set; } = static () => -1;
public Action<int>? SelectionChanged { get; set; } public Action<int>? SelectionChanged { get; set; }
/// <summary>
/// Campaign VT slice 1 Part B (VVS <c>HudList</c> parity — multi-column
/// lists, <c>docs/research/vtank-kb/08-ui-views.md</c> §2-3). Null (the
/// default) keeps every list built without <c>&lt;column&gt;</c> children
/// byte-for-byte the original single-text-column widget below —
/// <see cref="OnDraw"/>/<see cref="OnEvent"/> only take the per-cell path
/// when this is non-null and non-empty, and every legacy
/// Items/IconIds/ItemColors field is then ignored (mutually exclusive by
/// construction: <see cref="MarkupDocument"/> never sets both).
/// </summary>
public IReadOnlyList<UiMarkupListColumn>? Columns
{
get => _columns;
set
{
_columns = value;
// Fix round item 6: the per-column sidecar arrays and the
// layout/scratch arrays are sized to Columns.Count exactly ONCE
// here (reset whenever a new Columns list is assigned) rather
// than freshly allocated every Draw/OnEvent call — see
// DrawColumns/ComputeColumnLayout/OnEventColumns below, none of
// which allocate an array of their own any more.
int count = value?.Count ?? 0;
_cachedTextRows = new IReadOnlyList<string>?[count];
_cachedColorRows = new IReadOnlyList<uint>?[count];
_cachedCheckRows = new IReadOnlyList<bool>?[count];
_cachedIconRows = new IReadOnlyList<uint>?[count];
_cachedLayout = new (float x, float w)[count];
_scratchIsAuto = new bool[count];
_scratchFixedWidth = new float[count];
_cachedRowCount = 0;
}
}
public UiDatFont? DatFont { get; set; } public UiDatFont? DatFont { get; set; }
public float RowHeight { get; set; } = 18f; public float RowHeight { get; set; } = 18f;
public float Padding { get; set; } = 3f; public float Padding { get; set; } = 3f;
@ -41,11 +74,29 @@ public sealed class UiMarkupList : UiElement
public Vector4 SelectedColor { get; set; } = new(0.28f, 0.23f, 0.08f, 0.95f); public Vector4 SelectedColor { get; set; } = new(0.28f, 0.23f, 0.08f, 0.95f);
private int _topRow; private int _topRow;
private IReadOnlyList<UiMarkupListColumn>? _columns;
// ── Fix round item 6: per-column caches (reused between Draw and OnEvent,
// sized to Columns.Count by the Columns setter above) ────────────────────
private IReadOnlyList<string>?[] _cachedTextRows = Array.Empty<IReadOnlyList<string>?>();
private IReadOnlyList<uint>?[] _cachedColorRows = Array.Empty<IReadOnlyList<uint>?>();
private IReadOnlyList<bool>?[] _cachedCheckRows = Array.Empty<IReadOnlyList<bool>?>();
private IReadOnlyList<uint>?[] _cachedIconRows = Array.Empty<IReadOnlyList<uint>?>();
private (float x, float w)[] _cachedLayout = Array.Empty<(float, float)>();
private bool[] _scratchIsAuto = Array.Empty<bool>();
private float[] _scratchFixedWidth = Array.Empty<float>();
private int _cachedRowCount;
public override bool HandlesClick => true; public override bool HandlesClick => true;
protected override void OnDraw(UiRenderContext context) protected override void OnDraw(UiRenderContext context)
{ {
if (Columns is { Count: > 0 } columns)
{
DrawColumns(context, columns);
return;
}
IReadOnlyList<string> items = ItemsSource(); IReadOnlyList<string> items = ItemsSource();
IReadOnlyList<uint> itemColors = ItemColorsSource(); IReadOnlyList<uint> itemColors = ItemColorsSource();
IReadOnlyList<uint>? iconIds = IconIdsSource?.Invoke(); IReadOnlyList<uint>? iconIds = IconIdsSource?.Invoke();
@ -113,6 +164,9 @@ public sealed class UiMarkupList : UiElement
public override bool OnEvent(in UiEvent e) public override bool OnEvent(in UiEvent e)
{ {
if (Columns is { Count: > 0 } columns)
return OnEventColumns(e, columns);
IReadOnlyList<string> items = ItemsSource(); IReadOnlyList<string> items = ItemsSource();
if (e.Type == UiEventType.Scroll) if (e.Type == UiEventType.Scroll)
{ {
@ -140,4 +194,325 @@ public sealed class UiMarkupList : UiElement
((value >> 8) & 0xFFu) / 255f, ((value >> 8) & 0xFFu) / 255f,
(value & 0xFFu) / 255f, (value & 0xFFu) / 255f,
1f); 1f);
// ── Multi-column mode (Campaign VT slice 1 Part B) ──────────────────────
/// <summary>
/// Per-column (x, width) in local space — computed fresh every call off
/// the list's live <see cref="UiElement.Width"/> rather than baked in at
/// Build, so a resized list re-flows its columns like every other
/// retained widget. Shared by <see cref="DrawColumns"/> and
/// <see cref="OnEventColumns"/> so the drawn cell boundaries and the
/// hit-test boundaries can never drift apart.
///
/// <para>
/// Fix round item 2 (width semantics): the LAST column is ALWAYS treated
/// as auto regardless of its own declared <see cref="UiMarkupListColumn.Width"/>
/// or <see cref="UiMarkupListColumn.IsAutoWidth"/> — this is the
/// pre-existing "last column absorbs the remainder" contract, now
/// generalized as "the last column is always a member of the auto set".
/// Any OTHER column marked <c>IsAutoWidth</c> (an authored
/// <c>width="*"</c>) joins that same auto set. Every non-auto column's
/// declared width is walked left to right and CLAMPED against whatever
/// room is actually left (a declared width that would overflow the
/// list's total width is cut down to what remains, and every column
/// after the overflow point gets 0 — "declared widths that exceed the
/// list width" degradation). The leftover width after every non-auto
/// column is then split EQUALLY among the auto set (VVS's "0-width
/// columns share the remainder" rule), with the last column absorbing
/// whatever integer-division remainder is left over — when the auto set
/// is just {last column} (the common case, no explicit <c>"*"</c>
/// anywhere), this reduces to exactly the original "last column gets
/// 100% of the remainder" behavior.
/// </para>
/// </summary>
private void ComputeColumnLayout(IReadOnlyList<UiMarkupListColumn> columns, float totalWidth)
{
int n = columns.Count;
float x = 0f;
float sumFixed = 0f;
int autoCount = 0;
for (int i = 0; i < n; i++)
{
bool last = i == n - 1;
bool auto = last || columns[i].IsAutoWidth;
_scratchIsAuto[i] = auto;
if (auto)
{
autoCount++;
continue;
}
float avail = MathF.Max(0f, totalWidth - x);
float w = MathF.Min(MathF.Max(0f, columns[i].Width), avail);
_scratchFixedWidth[i] = w;
x += w;
sumFixed += w;
}
float remaining = MathF.Max(0f, totalWidth - sumFixed);
float share = autoCount > 0 ? MathF.Floor(remaining / autoCount) : 0f;
float cursor = 0f;
for (int i = 0; i < n; i++)
{
float w;
if (_scratchIsAuto[i])
{
bool isLast = i == n - 1;
w = isLast
? MathF.Max(0f, remaining - share * (autoCount - 1))
: share;
}
else
{
w = _scratchFixedWidth[i];
}
_cachedLayout[i] = (cursor, w);
cursor += w;
}
}
private void DrawColumns(UiRenderContext context, IReadOnlyList<UiMarkupListColumn> columns)
{
// Materialize every column's row source exactly once for this frame —
// matches the single-column path's ItemsSource()/ItemColorsSource()
// calls above. Only one of the four cached arrays is populated at a
// given column index (per that column's Kind); the others stay null.
// Fix round item 6: these are the SAME instance-field arrays
// OnEventColumns reads (sized to Columns.Count by the Columns
// setter) — a click no longer re-invokes any of these Funcs.
int rowCount = 0;
for (int c = 0; c < columns.Count; c++)
{
var col = columns[c];
switch (col.Kind)
{
case UiMarkupListColumnKind.Text:
_cachedTextRows[c] = col.TextSource!();
_cachedColorRows[c] = col.ColorsSource?.Invoke();
rowCount = Math.Max(rowCount, _cachedTextRows[c]!.Count);
break;
case UiMarkupListColumnKind.Check:
_cachedCheckRows[c] = col.CheckSource!();
rowCount = Math.Max(rowCount, _cachedCheckRows[c]!.Count);
break;
case UiMarkupListColumnKind.Icon:
_cachedIconRows[c] = col.IconValuesSource!();
rowCount = Math.Max(rowCount, _cachedIconRows[c]!.Count);
break;
}
}
_cachedRowCount = rowCount;
ComputeColumnLayout(columns, Width);
int visibleRows = VisibleRows;
int selected = SelectedIndexSource();
if (selected >= 0 && selected < rowCount)
{
if (selected < _topRow)
_topRow = selected;
else if (selected >= _topRow + visibleRows)
_topRow = selected - visibleRows + 1;
}
ClampTop(rowCount, visibleRows);
context.DrawFill(0f, 0f, Width, Height, BackgroundColor);
context.DrawRectOutline(0f, 0f, Width, Height, BorderColor, 1f);
int end = Math.Min(rowCount, _topRow + visibleRows);
for (int index = _topRow; index < end; index++)
{
float y = (index - _topRow) * RowHeight;
if (index == selected)
context.DrawFill(1f, y + 1f, Width - 2f, RowHeight - 1f, SelectedColor);
for (int c = 0; c < columns.Count; c++)
{
(float cellX, float cellW) = _cachedLayout[c];
if (cellW <= 0f)
continue;
// Per-cell horizontal clipping: no column's content (an
// over-long text row above all) can bleed into its neighbor.
// Fix round item 4: try/finally around the whole per-cell
// draw (matching UiButton.cs/UiElement.cs's own clip
// discipline) — a cell draw that throws (a hostile/buggy
// plugin icon resolver, say) must still balance the clip
// stack rather than leaking this PushClip forever.
context.PushClip(cellX, y, cellW, RowHeight);
try
{
switch (columns[c].Kind)
{
case UiMarkupListColumnKind.Text:
DrawTextCell(context, _cachedTextRows[c], _cachedColorRows[c], index, cellX, y);
break;
case UiMarkupListColumnKind.Check:
DrawCheckCell(context, _cachedCheckRows[c], index, cellX, cellW, y);
break;
case UiMarkupListColumnKind.Icon:
DrawIconCell(context, columns[c], _cachedIconRows[c], index, cellX, cellW, y);
break;
}
}
finally
{
context.PopClip();
}
}
}
}
private void DrawTextCell(
UiRenderContext context, IReadOnlyList<string>? texts, IReadOnlyList<uint>? colors,
int index, float cellX, float y)
{
if (texts is null || index >= texts.Count)
return;
string text = texts[index];
Vector4 color = colors is { } cc && index < cc.Count ? Rgb(cc[index]) : TextColor;
float textX = cellX + Padding;
float textY = y + MathF.Max(0f, (RowHeight - (DatFont?.LineHeight ?? 14f)) * 0.5f);
if (DatFont is { } font)
context.DrawStringDat(font, text, textX, textY, color, true);
else
context.DrawString(text, textX, textY, color);
}
/// <summary>
/// Draws the same five-band lamp glyph <see cref="UiMarkupToggle"/> uses
/// (both now share <see cref="UiCheckLamp"/>'s one definition — fix round
/// item 8), so a check column reads exactly like every other checkbox in
/// the client (contract requirement: reuse the toggle's own primitive
/// rather than a bespoke box-and-tick). Centered horizontally in its
/// cell, matching how <see cref="DrawIconCell"/> already centers its
/// sprite — a check cell is not always as narrow as the glyph itself
/// (fix round item 11's PITCH-based authoring convention routinely
/// declares check columns wider than <see cref="UiCheckLamp.LampSize"/>).
///
/// <para>
/// Fix round item 7: unlike text/icon cells (which draw nothing past
/// their own column's row count — there is no sensible default string or
/// icon), a check cell past its own bound data still draws the
/// UNCHECKED lamp. VVS materializes every cell in the row regardless of
/// which columns actually have data for it; docs/plugin-ui-markup.md
/// already documented this ("short columns simply have nothing to draw"
/// was never meant to apply to check specifically) — this makes the code
/// agree.
/// </para>
/// </summary>
private void DrawCheckCell(
UiRenderContext context, IReadOnlyList<bool>? flags, int index, float cellX, float cellW, float y)
{
bool isChecked = flags is not null && index < flags.Count && flags[index];
float extent = MathF.Max(0f, cellW - 2f);
float lampX = cellX + 1f + MathF.Max(0f, extent - UiCheckLamp.LampSize) * 0.5f;
float lampY = y + MathF.Max(1f, (RowHeight - UiCheckLamp.LampSize) * 0.5f);
UiCheckLamp.Draw(context, lampX, lampY, isChecked);
}
private void DrawIconCell(
UiRenderContext context, UiMarkupListColumn column, IReadOnlyList<uint>? ids,
int index, float cellX, float cellW, float y)
{
if (ids is null || index >= ids.Count || column.IconResolve is not { } resolve)
return;
uint id = ids[index];
if (id == 0u)
return;
(uint tex, int w, int h) = resolve(id);
if (tex == 0u || w <= 0 || h <= 0)
return;
float extentW = MathF.Max(0f, cellW - 2f);
float extentH = MathF.Max(0f, RowHeight - 2f);
float scale = MathF.Min(extentW / w, extentH / h);
float drawWidth = w * scale;
float drawHeight = h * scale;
context.DrawSprite(
tex,
cellX + 1f + (extentW - drawWidth) * 0.5f,
y + (RowHeight - drawHeight) * 0.5f,
drawWidth, drawHeight,
0f, 0f, 1f, 1f, Vector4.One);
}
private bool OnEventColumns(in UiEvent e, IReadOnlyList<UiMarkupListColumn> columns)
{
// Fix round item 6: rowCount/layout come from the LAST Draw call's
// materialization (_cachedRowCount/_cachedLayout, populated by
// DrawColumns/ComputeColumnLayout above) — no re-invoking every
// column's source Func or recomputing layout on every event. This
// mirrors normal frame order (draw, then handle input); before the
// first Draw these caches are all zeroed (sized but empty), so an
// event arriving before any Draw is a harmless no-op rather than a
// crash.
int rowCount = _cachedRowCount;
if (e.Type == UiEventType.Scroll)
{
_topRow -= Math.Sign(e.Data0);
ClampTop(rowCount, VisibleRows);
return true;
}
if (e.Type != UiEventType.MouseDown || !Enabled)
return false;
int row = (int)MathF.Floor(e.Data2 / MathF.Max(1f, RowHeight));
int index = _topRow + row;
if (row < 0 || row >= VisibleRows || index < 0 || index >= rowCount)
return true; // swallow the press; clicks past the last row do nothing
float localX = e.Data1;
for (int c = 0; c < columns.Count; c++)
{
(float cellX, float cellW) = _cachedLayout[c];
if (localX < cellX || localX >= cellX + cellW)
continue;
switch (columns[c].Kind)
{
case UiMarkupListColumnKind.Text:
// Fix round finding 1: a text column with its own
// onclick fires THAT instead of selecting — none of
// VTank's eight lists actually uses row selection, every
// real text cell is an action target. A text column
// without onclick keeps the original select-the-row
// behavior (the list's own selected/onchange), which is
// NOT subject to the per-column row-bound guard below —
// selection is a list-level concept, already bounded by
// the overall rowCount check above.
if (columns[c].TextClicked is { } onTextClick)
{
// Fix round finding 3: a per-column row-bound guard
// — this column's own bound row count can be
// SHORTER than the overall (max-across-columns) row
// count the outer index check above allows, so a
// click past THIS column's own data must still fire
// nothing (matches the draw side, which already
// skips drawing a cell past its own column's rows).
// Reuses the SAME cached materialization Draw built.
if (index < (_cachedTextRows[c]?.Count ?? 0))
onTextClick(index);
}
else
{
SelectionChanged?.Invoke(index);
}
break;
case UiMarkupListColumnKind.Check:
// A click in a check/icon column fires that column's own
// callback and does NOT change selection. Same
// per-column row-bound guard as the text-onclick case.
if (index < (_cachedCheckRows[c]?.Count ?? 0))
columns[c].CheckChanged?.Invoke(index);
break;
case UiMarkupListColumnKind.Icon:
if (index < (_cachedIconRows[c]?.Count ?? 0))
columns[c].IconClicked?.Invoke(index);
break;
}
break;
}
return true;
}
} }

View file

@ -0,0 +1,171 @@
namespace AcDream.App.UI;
/// <summary>
/// The three column kinds a plugin markup <c>&lt;list&gt;&lt;column&gt;</c> can
/// declare (Campaign VT slice 1 Part B, VVS <c>HudList</c> parity —
/// <c>docs/research/vtank-kb/08-ui-views.md</c> §2-3). Mirrors VVS's
/// <c>TextColumn</c>/<c>CheckColumn</c>/<c>IconColumn</c> progids.
/// </summary>
public enum UiMarkupListColumnKind
{
Text,
Check,
Icon,
}
/// <summary>
/// One column of a multi-column <see cref="UiMarkupList"/>. Built once by
/// <see cref="MarkupDocument"/> from a <c>&lt;column&gt;</c> element and then
/// only read by the widget — the plugin binding remains the sole owner of
/// every row's data, exactly like the single-column list's own
/// <see cref="UiMarkupList.ItemsSource"/>.
///
/// <para>
/// <see cref="Width"/> is the column's DECLARED width in px, meaningful only
/// when <see cref="IsAutoWidth"/> is false. The LAST column in a list always
/// ignores its own declared width/auto-ness at layout time — it always
/// absorbs whatever room remains after every earlier column. Any OTHER
/// column marked <see cref="IsAutoWidth"/> (fix round item 2, <c>width="*"</c>
/// — VVS's own "0-width column auto-sizes" convention) shares that same
/// remaining room equally with every other auto column, the last column
/// absorbing the rounding slack — see <see cref="UiMarkupList"/>'s
/// column-layout helper, which recomputes this dynamically off the list's
/// live <c>Width</c> rather than baking it in at Build.
/// </para>
///
/// <para>
/// Fix round item 8: every settable member is <c>internal init</c> — this
/// type is constructible only through its <see cref="Text"/>/
/// <see cref="Check"/>/<see cref="Icon"/> factories. A plugin (an external
/// assembly with no <c>InternalsVisibleTo</c> grant) can never assemble an
/// inconsistent instance (e.g. <see cref="Kind"/> Text with
/// <see cref="CheckChanged"/> set) via object-initializer syntax; only the
/// three factories, which each set exactly the fields their own kind uses,
/// can construct one.
/// </para>
/// </summary>
public sealed class UiMarkupListColumn
{
// Fix round item 8: `required` cannot pair with a setter less visible
// than the type itself (CS9032) — since Kind/Width are now internal
// init, every factory sets both unconditionally instead (compiler
// enforcement moves from "required" to "the only three call sites all
// do it").
public UiMarkupListColumnKind Kind { get; internal init; }
public float Width { get; internal init; }
/// <summary>
/// Fix round item 2: <c>width="*"</c> — this column shares the list's
/// remaining width equally with every other auto column (the last
/// column in the list is ALWAYS treated as auto regardless of this flag
/// or its own declared <see cref="Width"/> — see the class doc above).
/// </summary>
public bool IsAutoWidth { get; internal init; }
// ── text ──────────────────────────────────────────────────────────────
/// <summary><c>&lt;column type="text" items="{IReadOnlyList&lt;string&gt;}"&gt;</c>.</summary>
public Func<IReadOnlyList<string>>? TextSource { get; internal init; }
/// <summary>
/// Optional per-row text color override, mirroring the single-column
/// list's own <c>colors</c> attribute. Null (the default, when the
/// column has no <c>colors</c> attribute at all) means every row in this
/// column draws with the list's <see cref="UiMarkupList.TextColor"/>.
/// </summary>
public Func<IReadOnlyList<uint>>? ColorsSource { get; internal init; }
/// <summary>
/// Fix round finding 1: optional <c>onclick="{Action&lt;int&gt;}"</c> on a
/// text column. Fired with the ROW INDEX on a click anywhere in the cell
/// INSTEAD of selecting the row, when present. Null (the default — no
/// <c>onclick</c> attribute at all) keeps today's original behavior: a
/// click in this cell selects the row and fires the list's own
/// <c>onchange</c>, exactly as before this fix. None of VTank's eight
/// lists actually uses row selection — every real text cell is an action
/// target — but the select-on-click default stays for any acdream markup
/// that already relies on it.
/// </summary>
public Action<int>? TextClicked { get; internal init; }
// ── check ─────────────────────────────────────────────────────────────
/// <summary><c>&lt;column type="check" values="{IReadOnlyList&lt;bool&gt;}"&gt;</c>.</summary>
public Func<IReadOnlyList<bool>>? CheckSource { get; internal init; }
/// <summary>
/// Fired with the ROW INDEX on a click anywhere in this cell — the
/// plugin flips its own bool; the column never mutates
/// <see cref="CheckSource"/>'s backing collection itself. Required (a
/// check column with no <c>onchange</c> throws at Build).
/// </summary>
public Action<int>? CheckChanged { get; internal init; }
// ── icon ──────────────────────────────────────────────────────────────
/// <summary><c>&lt;column type="icon" values="{IReadOnlyList&lt;uint&gt;}"&gt;</c> — one icon id per row.</summary>
public Func<IReadOnlyList<uint>>? IconValuesSource { get; internal init; }
/// <summary>
/// Resolves one <see cref="IconValuesSource"/> entry to a drawable icon,
/// dispatched by the column's own <c>iconkind</c> — built by
/// <see cref="MarkupDocument"/> from the shared
/// <see cref="IMarkupIconResolver"/>. Null when no resolver is wired on
/// the host (the column then draws no icons, matching every other
/// Slice-B icon sink's "no resolver → draws nothing" rule).
/// </summary>
public Func<uint, (uint tex, int w, int h)>? IconResolve { get; internal init; }
/// <summary>
/// Fired with the ROW INDEX on a click anywhere in this cell. Required
/// (an icon column with no <c>onclick</c> throws at Build).
/// </summary>
public Action<int>? IconClicked { get; internal init; }
public static UiMarkupListColumn Text(
float width,
Func<IReadOnlyList<string>> textSource,
Func<IReadOnlyList<uint>>? colorsSource,
Action<int>? onClick = null,
bool isAutoWidth = false) => new()
{
Kind = UiMarkupListColumnKind.Text,
Width = width,
IsAutoWidth = isAutoWidth,
TextSource = textSource,
ColorsSource = colorsSource,
TextClicked = onClick,
};
public static UiMarkupListColumn Check(
float width,
Func<IReadOnlyList<bool>> checkSource,
Action<int> onChange,
bool isAutoWidth = false) => new()
{
Kind = UiMarkupListColumnKind.Check,
Width = width,
IsAutoWidth = isAutoWidth,
CheckSource = checkSource,
CheckChanged = onChange,
};
public static UiMarkupListColumn Icon(
float width,
Func<IReadOnlyList<uint>> valuesSource,
Func<uint, (uint tex, int w, int h)>? resolve,
Action<int> onClick,
bool isAutoWidth = false) => new()
{
Kind = UiMarkupListColumnKind.Icon,
Width = width,
IsAutoWidth = isAutoWidth,
IconValuesSource = valuesSource,
IconResolve = resolve,
IconClicked = onClick,
};
/// <summary>
/// This column's own row count — the widget takes
/// <c>Max</c> across every column in the list ("row count = the longest
/// bound column" per the slice's contract).
/// </summary>
public int RowCount() => Kind switch
{
UiMarkupListColumnKind.Text => TextSource?.Invoke().Count ?? 0,
UiMarkupListColumnKind.Check => CheckSource?.Invoke().Count ?? 0,
UiMarkupListColumnKind.Icon => IconValuesSource?.Invoke().Count ?? 0,
_ => 0,
};
}

View file

@ -8,15 +8,6 @@ namespace AcDream.App.UI;
/// </summary> /// </summary>
public sealed class UiMarkupToggle : UiElement public sealed class UiMarkupToggle : UiElement
{ {
private static readonly Vector4 CheckedOuter =
new(0.36f, 0.58f, 0.12f, 1f);
private static readonly Vector4 CheckedInner =
new(0.52f, 1f, 0.08f, 1f);
private static readonly Vector4 UncheckedOuter =
new(0.26f, 0.22f, 0.13f, 1f);
private static readonly Vector4 UncheckedInner =
new(0.38f, 0.34f, 0.23f, 1f);
public string Text { get; set; } = string.Empty; public string Text { get; set; } = string.Empty;
public Func<string?>? TextSource { get; set; } public Func<string?>? TextSource { get; set; }
public Func<bool>? CheckedSource { get; set; } public Func<bool>? CheckedSource { get; set; }
@ -39,9 +30,10 @@ public sealed class UiMarkupToggle : UiElement
protected override void OnDraw(UiRenderContext ctx) protected override void OnDraw(UiRenderContext ctx)
{ {
Vector4 outer = IsChecked ? CheckedOuter : UncheckedOuter; // Fix round item 8: the lamp glyph is now the SHARED
Vector4 inner = IsChecked ? CheckedInner : UncheckedInner; // UiCheckLamp.Draw primitive — UiMarkupList's <column type="check">
DrawLamp(ctx, 1f, MathF.Max(1f, (Height - 11f) * 0.5f), outer, inner); // cell draws the exact same glyph from the same one definition.
UiCheckLamp.Draw(ctx, 1f, MathF.Max(1f, (Height - UiCheckLamp.LampSize) * 0.5f), IsChecked);
string caption = TextSource?.Invoke() ?? Text; string caption = TextSource?.Invoke() ?? Text;
Vector4 color = Enabled Vector4 color = Enabled
@ -55,21 +47,4 @@ public sealed class UiMarkupToggle : UiElement
else else
ctx.DrawString(caption, 17f, y, color); ctx.DrawString(caption, 17f, y, color);
} }
private static void DrawLamp(
UiRenderContext ctx,
float x,
float y,
Vector4 outer,
Vector4 inner)
{
// Five bands form the small circular indicator without introducing a
// plugin bitmap or a new renderer primitive.
ctx.DrawFill(x + 3f, y, 5f, 1f, outer);
ctx.DrawFill(x + 1f, y + 1f, 9f, 2f, outer);
ctx.DrawFill(x, y + 3f, 11f, 5f, outer);
ctx.DrawFill(x + 1f, y + 8f, 9f, 2f, outer);
ctx.DrawFill(x + 3f, y + 10f, 5f, 1f, outer);
ctx.DrawFill(x + 3f, y + 3f, 5f, 5f, inner);
}
} }

View file

@ -115,6 +115,16 @@ public sealed class UiRenderContext
_clipStack.RemoveAt(_clipStack.Count - 1); _clipStack.RemoveAt(_clipStack.Count - 1);
} }
/// <summary>
/// Test-only: the number of <see cref="PushClip"/> calls not yet matched
/// by a <see cref="PopClip"/>. Used to prove a per-cell draw that throws
/// mid-draw (e.g. a plugin's icon resolver) still leaves the clip stack
/// balanced — see <see cref="UiMarkupList"/>'s per-cell
/// <c>try</c>/<c>finally</c> around <c>PushClip</c>/<c>PopClip</c> (fix
/// round item 4). <c>InternalsVisibleTo</c> to <c>AcDream.App.Tests</c>.
/// </summary>
internal int ClipStackDepth => _clipStack.Count;
/// <summary> /// <summary>
/// True when the current accumulated clip is non-null and has zero (or negative) /// True when the current accumulated clip is non-null and has zero (or negative)
/// area — CT-GF1 fix-round subtree cull, porting retail's /// area — CT-GF1 fix-round subtree cull, porting retail's

File diff suppressed because it is too large Load diff

View file

@ -6,6 +6,29 @@ namespace AcDream.Plugins.MossTank.Tests;
public sealed class MossTankMarkupContractTests public sealed class MossTankMarkupContractTests
{ {
/// <summary>
/// Fix round item 9: every element name whose interactive attributes
/// (onclick/onchange/onsubmit) this contract validates and requires a
/// real handler for — shared by
/// <see cref="EveryInteractiveControlDeclaresARealHandlerBinding"/> and
/// <see cref="TextlessAndAbbreviatedControlsHaveAccessibleRetailTooltips"/>.
/// <c>column</c> (Campaign VT slice 1 Part B's <c>&lt;list&gt;&lt;column&gt;</c>)
/// joined this set here — mosstank.xml itself has no <c>&lt;column&gt;</c>
/// elements yet, so this is a zero-behavior-change addition against the
/// current file (see <see cref="InteractiveElementNames_IncludesColumn"/>
/// for the direct pin).
/// </summary>
private static readonly string[] InteractiveElementNames =
[
"tab", "button", "toggle", "slider", "field", "menu", "list", "column",
];
[Fact]
public void InteractiveElementNames_IncludesColumn()
{
Assert.Contains("column", InteractiveElementNames);
}
[Fact] [Fact]
public void VtankTabOrderAndEveryBindingResolveAgainstTheLivePanel() public void VtankTabOrderAndEveryBindingResolveAgainstTheLivePanel()
{ {
@ -54,7 +77,31 @@ public sealed class MossTankMarkupContractTests
StringComparer.Ordinal); StringComparer.Ordinal);
foreach (XElement element in root.DescendantsAndSelf()) foreach (XElement element in root.DescendantsAndSelf())
AssertElementBindingsMatchRetainedUiDelegateShape(element, byName);
}
/// <summary>
/// Fix round item 9: <c>&lt;column&gt;</c>'s own <c>onchange</c> (a
/// <c>type="check"</c> column) and <c>onclick</c> (<c>type="icon"</c>,
/// or a <c>type="text"</c> column's fix-item-1 optional onclick) are
/// BOTH <c>Action&lt;int&gt;</c> (the row index) — never the plain
/// <c>Action</c> every other element's <c>onclick</c> resolves to.
/// Extracted out of <see cref="EveryInteractiveBindingMatchesTheRetainedUiDelegateShape"/>
/// so <see cref="Column_OnchangeAndOnclick_MustBeActionOfInt"/> can drive
/// it directly against a synthetic <c>&lt;column&gt;</c> element —
/// mosstank.xml itself has none yet.
/// </summary>
private static void AssertElementBindingsMatchRetainedUiDelegateShape(
XElement element,
IReadOnlyDictionary<string, PropertyInfo> byName)
{ {
if (element.Name.LocalName == "column")
{
AssertBindingType(element, "onchange", typeof(Action<int>), byName);
AssertBindingType(element, "onclick", typeof(Action<int>), byName);
return;
}
AssertBindingType(element, "onclick", typeof(Action), byName); AssertBindingType(element, "onclick", typeof(Action), byName);
AssertBindingType( AssertBindingType(
element, element,
@ -72,6 +119,38 @@ public sealed class MossTankMarkupContractTests
if (changeType is not null) if (changeType is not null)
AssertBindingType(element, "onchange", changeType, byName); AssertBindingType(element, "onchange", changeType, byName);
} }
private sealed class ColumnBindingProbe
{
public Action<int> RowAction { get; } = _ => { };
public Action PlainAction { get; } = () => { };
}
[Fact]
public void Column_OnchangeAndOnclick_MustBeActionOfInt()
{
var byName = typeof(ColumnBindingProbe)
.GetProperties(BindingFlags.Instance | BindingFlags.Public)
.ToDictionary(static property => property.Name, StringComparer.Ordinal);
// Correctly typed Action<int> — must not throw.
var goodColumn = new XElement(
"column",
new XAttribute("type", "check"),
new XAttribute("onchange", "{RowAction}"));
AssertElementBindingsMatchRetainedUiDelegateShape(goodColumn, byName);
// A column's onclick bound to a PLAIN Action (the shape every other
// element's onclick uses) must be rejected — proves the dispatch
// actually enforces Action<int> for <column> specifically, rather
// than silently accepting whatever the generic non-column path
// would have allowed.
var badColumn = new XElement(
"column",
new XAttribute("type", "icon"),
new XAttribute("onclick", "{PlainAction}"));
Assert.Throws<Xunit.Sdk.EqualException>(
() => AssertElementBindingsMatchRetainedUiDelegateShape(badColumn, byName));
} }
[Fact] [Fact]
@ -80,10 +159,7 @@ public sealed class MossTankMarkupContractTests
XDocument document = XDocument.Load( XDocument document = XDocument.Load(
Path.Combine(AppContext.BaseDirectory, "mosstank.xml")); Path.Combine(AppContext.BaseDirectory, "mosstank.xml"));
XElement root = Assert.IsType<XElement>(document.Root); XElement root = Assert.IsType<XElement>(document.Root);
HashSet<string> interactive = new( HashSet<string> interactive = new(InteractiveElementNames, StringComparer.Ordinal);
[
"tab", "button", "toggle", "slider", "field", "menu", "list",
], StringComparer.Ordinal);
XElement[] controls = root.Descendants() XElement[] controls = root.Descendants()
.Where(element => interactive.Contains(element.Name.LocalName)) .Where(element => interactive.Contains(element.Name.LocalName))
@ -134,10 +210,7 @@ public sealed class MossTankMarkupContractTests
XDocument document = XDocument.Load( XDocument document = XDocument.Load(
Path.Combine(AppContext.BaseDirectory, "mosstank.xml")); Path.Combine(AppContext.BaseDirectory, "mosstank.xml"));
XElement root = Assert.IsType<XElement>(document.Root); XElement root = Assert.IsType<XElement>(document.Root);
string[] interactive = string[] interactive = InteractiveElementNames;
[
"tab", "button", "toggle", "slider", "field", "menu", "list",
];
HashSet<string> terse = new( HashSet<string> terse = new(
[ [
"+", "-", "↑", "↓", "F", "B", "G", "I", "Y", "V", "A", "+", "-", "↑", "↓", "F", "B", "G", "I", "Y", "V", "A",