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:
commit
da0fcb3741
9 changed files with 2543 additions and 89 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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,43 +543,55 @@ 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,
|
||||||
};
|
};
|
||||||
// Slice B: <list icons="{IconIds}" iconkind="did|spell|item">.
|
|
||||||
// Same two rules as <button icon> above: iconkind validates
|
if (listUsesColumns)
|
||||||
// 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");
|
int lastColumnIndex = listChildren.Count - 1;
|
||||||
ValidateIconKind(listIconKind);
|
list.Columns = listChildren
|
||||||
// Residual round finding N2: same rule as the button's
|
.Select((columnEl, index) => BuildListColumn(
|
||||||
// icon reader above — BindUintList must run
|
columnEl, binding, icons, index, index == lastColumnIndex))
|
||||||
// UNCONDITIONALLY so icons="notabinding" (a malformed,
|
.ToList();
|
||||||
// non-{Binding} literal — list icons has no literal
|
}
|
||||||
// grammar) throws FormatException at Build even with no
|
else
|
||||||
// resolver wired. Only the assignment stays gated.
|
{
|
||||||
Func<IReadOnlyList<uint>> listIconIdsReader =
|
list.ItemsSource = BindStringList(
|
||||||
BindUintList(listIcons, binding, "list icons");
|
(string?)el.Attribute("items"),
|
||||||
if (icons is not null)
|
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))
|
||||||
{
|
{
|
||||||
list.IconIdsSource = listIconIdsReader;
|
string? listIconKind = (string?)el.Attribute("iconkind");
|
||||||
list.IconResolve = BuildRowIconResolve(listIconKind, icons);
|
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);
|
ApplyCommon(list, el, binding);
|
||||||
|
|
@ -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><icon></c>, <c><button
|
||||||
|
/// icon></c>, <c><list icons></c>) prefixes the throw message so
|
||||||
|
/// <c><column type="icon"></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><column type="check" values="{IReadOnlyList<bool>}"></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><column type="icon" values="..."></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><column></c>'s <c>onchange</c> (check)/<c>onclick</c> (icon) —
|
||||||
|
/// unlike every other <c>Action<int></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><column></c>
|
||||||
|
/// child of <c><list></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><list icons></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><column width></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] == '}';
|
||||||
|
|
||||||
|
|
|
||||||
41
src/AcDream.App/UI/UiCheckLamp.cs
Normal file
41
src/AcDream.App/UI/UiCheckLamp.cs
Normal 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><toggle></c>
|
||||||
|
/// element) and <see cref="UiMarkupList"/>'s <c><column type="check"></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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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><column></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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
171
src/AcDream.App/UI/UiMarkupListColumn.cs
Normal file
171
src/AcDream.App/UI/UiMarkupListColumn.cs
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
namespace AcDream.App.UI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The three column kinds a plugin markup <c><list><column></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><column></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><column type="text" items="{IReadOnlyList<string>}"></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<int>}"</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><column type="check" values="{IReadOnlyList<bool>}"></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><column type="icon" values="{IReadOnlyList<uint>}"></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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
1402
tests/AcDream.App.Tests/UI/MarkupListColumnsTests.cs
Normal file
1402
tests/AcDream.App.Tests/UI/MarkupListColumnsTests.cs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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><list><column></c>)
|
||||||
|
/// joined this set here — mosstank.xml itself has no <c><column></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,24 +77,80 @@ public sealed class MossTankMarkupContractTests
|
||||||
StringComparer.Ordinal);
|
StringComparer.Ordinal);
|
||||||
|
|
||||||
foreach (XElement element in root.DescendantsAndSelf())
|
foreach (XElement element in root.DescendantsAndSelf())
|
||||||
{
|
AssertElementBindingsMatchRetainedUiDelegateShape(element, byName);
|
||||||
AssertBindingType(element, "onclick", typeof(Action), byName);
|
}
|
||||||
AssertBindingType(
|
|
||||||
element,
|
|
||||||
"onsubmit",
|
|
||||||
typeof(Action<string>),
|
|
||||||
byName);
|
|
||||||
|
|
||||||
Type? changeType = element.Name.LocalName switch
|
/// <summary>
|
||||||
{
|
/// Fix round item 9: <c><column></c>'s own <c>onchange</c> (a
|
||||||
"field" or "menu" => typeof(Action<string>),
|
/// <c>type="check"</c> column) and <c>onclick</c> (<c>type="icon"</c>,
|
||||||
"slider" => typeof(Action<float>),
|
/// or a <c>type="text"</c> column's fix-item-1 optional onclick) are
|
||||||
"list" => typeof(Action<int>),
|
/// BOTH <c>Action<int></c> (the row index) — never the plain
|
||||||
_ => null,
|
/// <c>Action</c> every other element's <c>onclick</c> resolves to.
|
||||||
};
|
/// Extracted out of <see cref="EveryInteractiveBindingMatchesTheRetainedUiDelegateShape"/>
|
||||||
if (changeType is not null)
|
/// so <see cref="Column_OnchangeAndOnclick_MustBeActionOfInt"/> can drive
|
||||||
AssertBindingType(element, "onchange", changeType, byName);
|
/// it directly against a synthetic <c><column></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,
|
||||||
|
"onsubmit",
|
||||||
|
typeof(Action<string>),
|
||||||
|
byName);
|
||||||
|
|
||||||
|
Type? changeType = element.Name.LocalName switch
|
||||||
|
{
|
||||||
|
"field" or "menu" => typeof(Action<string>),
|
||||||
|
"slider" => typeof(Action<float>),
|
||||||
|
"list" => typeof(Action<int>),
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
if (changeType is not null)
|
||||||
|
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",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue