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>` |
| `field onchange`, `field onsubmit`, `menu onchange` | Throws | `Action<string>` |
| `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.
@ -100,7 +109,7 @@ vanishing from the built tree.
| `slider` | Horizontal scalar | `x y w h value onchange` |
| `field` | Single-line editable text | `x y w h text maxlength clearonsubmit onchange onsubmit color background` |
| `menu` | Dropdown selector | `x y w h items selected onchange rows rowheight openupward` |
| `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
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
root is not parsed (unlike every child element, where a literal is fine).
**LIMITATION:** `<list>` has exactly one text column (plus the optional
Slice B icon column) — there is no multi-column list yet. A plugin that
needs tabular rows today pads its own fixed-width text (`$"{name,-16}{value,6}"`).
Real multi-column support is deferred to the MossTank plugin work.
Multi-column lists are real (Campaign VT slice 1 Part B, below) — a `<list>`
with `<column>` children is no longer limited to one padded text column. A
`<list>` with no `<column>` children stays exactly the older single-column
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
`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`
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 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
live DAT — see `tests/AcDream.App.Tests/UI/`. `PluginSidePanelTests` exercises
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.