From 959a694823f7f90011317645ff3f77b82b7ee071 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 6 Sep 2026 20:21:16 +0200 Subject: [PATCH] =?UTF-8?q?feat(vt):=20Campaign=20VT=20slice=201=20Part=20?= =?UTF-8?q?B=20=E2=80=94=20multi-column=20=20markup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VVS HudList parity (docs/research/vtank-kb/08-ui-views.md 2-3): accepts children, each binding its own per-row source parallel to the row count. Row count is the longest bound column; a click in a text column selects (the list's own selected/onchange, unchanged), a click in a check/icon column fires that column's own required onchange/onclick with the row index and does NOT change selection. Last column always absorbs the remaining width, recomputed live off the list's own Width rather than baked in at Build. Per-cell horizontal clipping via UiRenderContext's existing PushClip/PopClip. The check-column glyph reuses 's own five-band lamp primitive (UiMarkupToggle.DrawLamp/colors promoted from private to internal) so it looks like every other checkbox. Unknown column type, a missing required column binding, combined with the legacy items/icons/colors list attributes, or any non- child of all throw FormatException at Build. A column-less is byte-for-byte the original single-text-column widget (new if/Columns branch in OnDraw/OnEvent; the legacy branch's code is untouched). New UiMarkupListColumn model (src/AcDream.App/UI/UiMarkupListColumn.cs) carries each column's kind/width/bindings; MarkupDocument's `list` case now builds either the legacy single-column fields or a Columns list, never both. MarkupListColumnsTests (27 new tests) cover parse/binding validation, draw-level column-offset/clipping/check-glyph/icon pins against the recording renderer, hit-test routing, and a backward- compatibility proof (a column-less list built through MarkupDocument produces a byte-identical draw record to a hand-built UiMarkupList with equivalent fields). Every new assertion was verified to fail first via targeted temporary mutations (hit-test isolation, clip removal, last- column-absorbs-remainder, required onchange/onclick, row-count = max), each reverted after confirming failure. docs/plugin-ui-markup.md gets a full Columns section (attribute grammar, a Monsters-tab-style example, the no-header-row idiom, backward compatibility) and the old single-text-column LIMITATION note is retired; the bindings truth table gains the six new column-attribute rows. Co-Authored-By: Claude Fable 5.1 --- docs/plugin-ui-markup.md | 104 ++- src/AcDream.App/UI/MarkupDocument.cs | 211 +++++- src/AcDream.App/UI/UiMarkupList.cs | 238 ++++++ src/AcDream.App/UI/UiMarkupListColumn.cs | 126 +++ src/AcDream.App/UI/UiMarkupToggle.cs | 16 +- .../UI/MarkupListColumnsTests.cs | 715 ++++++++++++++++++ 6 files changed, 1371 insertions(+), 39 deletions(-) create mode 100644 src/AcDream.App/UI/UiMarkupListColumn.cs create mode 100644 tests/AcDream.App.Tests/UI/MarkupListColumnsTests.cs diff --git a/docs/plugin-ui-markup.md b/docs/plugin-ui-markup.md index cf711a5f7..4752d8a79 100644 --- a/docs/plugin-ui-markup.md +++ b/docs/plugin-ui-markup.md @@ -78,6 +78,12 @@ check those four against the markup by eye. | `slider onchange` | Throws | `Action` | | `field onchange`, `field onsubmit`, `menu onchange` | Throws | `Action` | | `list onchange` | Throws | `Action` | +| `column items` (`type="text"`) | Throws — REQUIRED, unlike the single-column list's own `items` sugar it mirrors | `IReadOnlyList` | +| `column colors` (`type="text"`) | **Silent** if omitted (no per-row override, same rule as `list colors`); throws if present but mistyped | `IReadOnlyList` **or** `IReadOnlyList` | +| `column values` (`type="check"`) | Throws — REQUIRED (there is no "no check column" fallback the way `list icons` has "no icon column") | `IReadOnlyList` | +| `column values` (`type="icon"`) | Throws — REQUIRED | `IReadOnlyList` **or** `IReadOnlyList` | +| `column onchange` (`type="check"`) | Throws — REQUIRED (unlike the list's own optional `onchange`) | `Action` (row index) | +| `column onclick` (`type="icon"`) | Throws — REQUIRED | `Action` (row index) | 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 +106,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 `` 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 +117,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:** `` 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 `` +with `` children is no longer limited to one padded text column. A +`` with no `` 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 +298,88 @@ public IEnumerable 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 `` matches this by letting a `` declare +`` children instead of the single-column `items`/`colors`/`icons` +attributes: + +```xml + + + + + +``` + +This mirrors VTank's own Monsters tab (a name column plus several boolean +flag columns plus an icon-button column) — 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. + +### `` 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 | no (defaults to `0`) | Column width in px. **The LAST column in a `` ignores its own declared width and always absorbs whatever room remains** after every earlier column — recomputed every frame off the list's live width, so a resizable list re-flows its last column like every other retained widget | +| `items` | `type="text"` | yes | `{IReadOnlyList}` — one row of text per index | +| `colors` | `type="text"` | no | `{IReadOnlyList}`, `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` | +| `values` | `type="check"` | yes | `{IReadOnlyList}` — the checked state per row | +| `onchange` | `type="check"` | yes | `{Action}` — 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}` (or `IReadOnlyList`) — 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 `` above — one kind per column, not per row | +| `onclick` | `type="icon"` | yes | `{Action}` — 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 `` with an unrecognized `type`, +a missing required binding for its type, or any `` child element that +isn't `` at all, throws `FormatException` at `Build`. A `` with +`` children cannot ALSO use the single-column `items`/`colors`/`icons` +attributes on the `` element itself — pick one form per list. + +### 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 of empty checkboxes — short +columns never truncate the whole list). The list's own `selected`/`onchange` +attributes keep exactly their single-column meaning: a click in a **text** +column selects that row (and fires the list's `onchange` with the row index, +same as today). A click in a **check** or **icon** column 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. 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 `