feat(vt): Campaign VT slice 1 Part B — multi-column <list> markup
VVS HudList parity (docs/research/vtank-kb/08-ui-views.md 2-3): <list> accepts <column type="text|check|icon" width=... [iconkind] [colors]/> 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 <toggle>'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, <column> combined with the legacy items/icons/colors list attributes, or any non-<column> child of <list> all throw FormatException at Build. A column-less <list> 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 <noreply@anthropic.com>
This commit is contained in:
parent
1b81836747
commit
959a694823
6 changed files with 1371 additions and 39 deletions
|
|
@ -78,6 +78,12 @@ 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 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) |
|
||||
|
||||
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 `<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 +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:** `<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 +298,88 @@ 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="17"
|
||||
selected="{SelectedMonster}" onchange="{SelectMonster}">
|
||||
<column type="text" width="140" items="{MonsterNames}"/>
|
||||
<column type="check" width="24" values="{MonsterFester}" onchange="{ToggleFester}"/>
|
||||
<column type="icon" width="24" iconkind="did" values="{MonsterIcons}" onclick="{PingMonster}"/>
|
||||
</list>
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
### `<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 | no (defaults to `0`) | Column width in px. **The LAST column in a `<list>` 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<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` |
|
||||
| `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.
|
||||
|
||||
### 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 `<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>` (`UiMarkupToggle`'s checked/unchecked colors and `DrawLamp`
|
||||
primitive), so a column checkbox reads identically to every other checkbox
|
||||
in the client rather than a bespoke box-and-tick.
|
||||
|
||||
### 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 +416,8 @@ 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, draw-level column-offset/clipping/check-glyph pins
|
||||
against the same recording-renderer apparatus, hit-test routing (text
|
||||
selects; check/icon fire their own callback and never touch selection), and
|
||||
a backward-compatibility proof that a column-less `<list>` is unaffected.
|
||||
|
|
|
|||
|
|
@ -509,6 +509,32 @@ public static class MarkupDocument
|
|||
$"<list onchange=\"{listChangeName}\"> did not resolve to an "
|
||||
+ $"Action<int> property on {binding.GetType().Name}");
|
||||
}
|
||||
|
||||
// Campaign VT slice 1 Part B: <list><column .../></list>
|
||||
// (docs/research/vtank-kb/08-ui-views.md §3). A non-<column>
|
||||
// child is always malformed — the element previously had no
|
||||
// children at all, so this is purely additive.
|
||||
var listChildren = el.Elements().ToList();
|
||||
foreach (var child in listChildren)
|
||||
{
|
||||
if (child.Name.LocalName != "column")
|
||||
{
|
||||
throw new FormatException(
|
||||
$"<list> children must all be <column>, got <{child.Name.LocalName}>");
|
||||
}
|
||||
}
|
||||
bool listUsesColumns = listChildren.Count > 0;
|
||||
if (listUsesColumns
|
||||
&& (el.Attribute("items") is not null
|
||||
|| el.Attribute("icons") is not null
|
||||
|| el.Attribute("colors") is not null))
|
||||
{
|
||||
throw new FormatException(
|
||||
"<list> with <column> children cannot also use the "
|
||||
+ "items/icons/colors attributes (Slice B's own single-column "
|
||||
+ "form) — express every row source as a <column> instead");
|
||||
}
|
||||
|
||||
var list = new UiMarkupList
|
||||
{
|
||||
Left = F(el, "x"),
|
||||
|
|
@ -517,43 +543,53 @@ public static class MarkupDocument
|
|||
Height = F(el, "h"),
|
||||
RowHeight = Math.Max(12f, FOr(el, "rowheight", 18f)),
|
||||
DatFont = datFont,
|
||||
ItemsSource = BindStringList(
|
||||
(string?)el.Attribute("items"),
|
||||
binding,
|
||||
"list items"),
|
||||
ItemColorsSource = BindUintList(
|
||||
(string?)el.Attribute("colors"),
|
||||
binding,
|
||||
"list colors"),
|
||||
SelectedIndexSource = BindRequiredIntReader(
|
||||
(string?)el.Attribute("selected"),
|
||||
binding,
|
||||
"list selected"),
|
||||
SelectionChanged = listChanged,
|
||||
};
|
||||
// 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))
|
||||
|
||||
if (listUsesColumns)
|
||||
{
|
||||
string? listIconKind = (string?)el.Attribute("iconkind");
|
||||
ValidateIconKind(listIconKind);
|
||||
// Residual round finding N2: same rule as the button's
|
||||
// icon reader above — BindUintList must run
|
||||
// UNCONDITIONALLY so icons="notabinding" (a malformed,
|
||||
// non-{Binding} literal — list icons has no literal
|
||||
// grammar) throws FormatException at Build even with no
|
||||
// resolver wired. Only the assignment stays gated.
|
||||
Func<IReadOnlyList<uint>> listIconIdsReader =
|
||||
BindUintList(listIcons, binding, "list icons");
|
||||
if (icons is not null)
|
||||
list.Columns = listChildren
|
||||
.Select(columnEl => BuildListColumn(columnEl, binding, icons))
|
||||
.ToList();
|
||||
}
|
||||
else
|
||||
{
|
||||
list.ItemsSource = BindStringList(
|
||||
(string?)el.Attribute("items"),
|
||||
binding,
|
||||
"list items");
|
||||
list.ItemColorsSource = BindUintList(
|
||||
(string?)el.Attribute("colors"),
|
||||
binding,
|
||||
"list colors");
|
||||
// Slice B: <list icons="{IconIds}" iconkind="did|spell|item">.
|
||||
// Same two rules as <button icon> above: iconkind validates
|
||||
// regardless of resolver wiring (finding 4), and
|
||||
// IconIdsSource/IconResolve are only set when a resolver
|
||||
// exists (finding 7) — UiMarkupList already reserves its
|
||||
// icon column whenever IconIdsSource is non-null.
|
||||
string? listIcons = (string?)el.Attribute("icons");
|
||||
if (!string.IsNullOrWhiteSpace(listIcons))
|
||||
{
|
||||
list.IconIdsSource = listIconIdsReader;
|
||||
list.IconResolve = BuildRowIconResolve(listIconKind, icons);
|
||||
string? listIconKind = (string?)el.Attribute("iconkind");
|
||||
ValidateIconKind(listIconKind);
|
||||
// Residual round finding N2: same rule as the button's
|
||||
// icon reader above — BindUintList must run
|
||||
// UNCONDITIONALLY so icons="notabinding" (a malformed,
|
||||
// non-{Binding} literal — list icons has no literal
|
||||
// grammar) throws FormatException at Build even with no
|
||||
// resolver wired. Only the assignment stays gated.
|
||||
Func<IReadOnlyList<uint>> listIconIdsReader =
|
||||
BindUintList(listIcons, binding, "list icons");
|
||||
if (icons is not null)
|
||||
{
|
||||
list.IconIdsSource = listIconIdsReader;
|
||||
list.IconResolve = BuildRowIconResolve(listIconKind, icons);
|
||||
}
|
||||
}
|
||||
}
|
||||
ApplyCommon(list, el, binding);
|
||||
|
|
@ -884,6 +920,123 @@ public static class MarkupDocument
|
|||
+ "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)
|
||||
{
|
||||
string? type = (string?)columnEl.Attribute("type");
|
||||
float width = F(columnEl, "width");
|
||||
switch (type)
|
||||
{
|
||||
case "text":
|
||||
{
|
||||
var textSource = BindStringList(
|
||||
(string?)columnEl.Attribute("items"), binding, "column items");
|
||||
string? colorsAttr = (string?)columnEl.Attribute("colors");
|
||||
Func<IReadOnlyList<uint>>? colorsSource = colorsAttr is null
|
||||
? null
|
||||
: BindUintList(colorsAttr, binding, "column colors");
|
||||
return UiMarkupListColumn.Text(width, textSource, colorsSource);
|
||||
}
|
||||
case "check":
|
||||
{
|
||||
var checkSource = BindBoolList(
|
||||
(string?)columnEl.Attribute("values"), binding, "column values");
|
||||
var onChange = BindRequiredIntAction(
|
||||
(string?)columnEl.Attribute("onchange"), binding, "column onchange");
|
||||
return UiMarkupListColumn.Check(width, checkSource, onChange);
|
||||
}
|
||||
case "icon":
|
||||
{
|
||||
var valuesSource = BindRequiredUintList(
|
||||
(string?)columnEl.Attribute("values"), binding, "column values");
|
||||
string? iconKind = (string?)columnEl.Attribute("iconkind");
|
||||
ValidateIconKind(iconKind);
|
||||
var onClick = BindRequiredIntAction(
|
||||
(string?)columnEl.Attribute("onclick"), binding, "column onclick");
|
||||
Func<uint, (uint, int, int)>? resolve = icons is not null
|
||||
? BuildRowIconResolve(iconKind, icons)
|
||||
: null;
|
||||
return UiMarkupListColumn.Icon(width, valuesSource, resolve, onClick);
|
||||
}
|
||||
default:
|
||||
throw new FormatException(
|
||||
$"unknown <column type=\"{type}\"> (expected text, check, or icon)");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsBinding(string value) =>
|
||||
value.Length > 2 && value[0] == '{' && value[^1] == '}';
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,17 @@ public sealed class UiMarkupList : UiElement
|
|||
public Func<uint, (uint tex, int w, int h)>? IconResolve { get; set; }
|
||||
public Func<int> SelectedIndexSource { get; set; } = static () => -1;
|
||||
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; set; }
|
||||
public UiDatFont? DatFont { get; set; }
|
||||
public float RowHeight { get; set; } = 18f;
|
||||
public float Padding { get; set; } = 3f;
|
||||
|
|
@ -46,6 +57,12 @@ public sealed class UiMarkupList : UiElement
|
|||
|
||||
protected override void OnDraw(UiRenderContext context)
|
||||
{
|
||||
if (Columns is { Count: > 0 } columns)
|
||||
{
|
||||
DrawColumns(context, columns);
|
||||
return;
|
||||
}
|
||||
|
||||
IReadOnlyList<string> items = ItemsSource();
|
||||
IReadOnlyList<uint> itemColors = ItemColorsSource();
|
||||
IReadOnlyList<uint>? iconIds = IconIdsSource?.Invoke();
|
||||
|
|
@ -113,6 +130,9 @@ public sealed class UiMarkupList : UiElement
|
|||
|
||||
public override bool OnEvent(in UiEvent e)
|
||||
{
|
||||
if (Columns is { Count: > 0 } columns)
|
||||
return OnEventColumns(e, columns);
|
||||
|
||||
IReadOnlyList<string> items = ItemsSource();
|
||||
if (e.Type == UiEventType.Scroll)
|
||||
{
|
||||
|
|
@ -140,4 +160,222 @@ public sealed class UiMarkupList : UiElement
|
|||
((value >> 8) & 0xFFu) / 255f,
|
||||
(value & 0xFFu) / 255f,
|
||||
1f);
|
||||
|
||||
// ── Multi-column mode (Campaign VT slice 1 Part B) ──────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Per-column (x, width) in local space. The last column ignores its own
|
||||
/// declared <see cref="UiMarkupListColumn.Width"/> and always absorbs
|
||||
/// whatever room remains — 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.
|
||||
/// </summary>
|
||||
private static (float x, float w)[] ColumnLayout(
|
||||
IReadOnlyList<UiMarkupListColumn> columns, float totalWidth)
|
||||
{
|
||||
var layout = new (float x, float w)[columns.Count];
|
||||
float x = 0f;
|
||||
for (int i = 0; i < columns.Count; i++)
|
||||
{
|
||||
bool isLast = i == columns.Count - 1;
|
||||
float w = isLast
|
||||
? MathF.Max(0f, totalWidth - x)
|
||||
: MathF.Max(0f, columns[i].Width);
|
||||
layout[i] = (x, w);
|
||||
x += w;
|
||||
}
|
||||
return layout;
|
||||
}
|
||||
|
||||
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 arrays is populated at a given
|
||||
// column index (per that column's Kind); the others stay null.
|
||||
var textRows = new IReadOnlyList<string>?[columns.Count];
|
||||
var colorRows = new IReadOnlyList<uint>?[columns.Count];
|
||||
var checkRows = new IReadOnlyList<bool>?[columns.Count];
|
||||
var iconRows = new IReadOnlyList<uint>?[columns.Count];
|
||||
int rowCount = 0;
|
||||
for (int c = 0; c < columns.Count; c++)
|
||||
{
|
||||
var col = columns[c];
|
||||
switch (col.Kind)
|
||||
{
|
||||
case UiMarkupListColumnKind.Text:
|
||||
textRows[c] = col.TextSource!();
|
||||
colorRows[c] = col.ColorsSource?.Invoke();
|
||||
rowCount = Math.Max(rowCount, textRows[c]!.Count);
|
||||
break;
|
||||
case UiMarkupListColumnKind.Check:
|
||||
checkRows[c] = col.CheckSource!();
|
||||
rowCount = Math.Max(rowCount, checkRows[c]!.Count);
|
||||
break;
|
||||
case UiMarkupListColumnKind.Icon:
|
||||
iconRows[c] = col.IconValuesSource!();
|
||||
rowCount = Math.Max(rowCount, iconRows[c]!.Count);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var layout = ColumnLayout(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) = layout[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.
|
||||
context.PushClip(cellX, y, cellW, RowHeight);
|
||||
switch (columns[c].Kind)
|
||||
{
|
||||
case UiMarkupListColumnKind.Text:
|
||||
DrawTextCell(context, textRows[c], colorRows[c], index, cellX, y);
|
||||
break;
|
||||
case UiMarkupListColumnKind.Check:
|
||||
DrawCheckCell(context, checkRows[c], index, cellX, y);
|
||||
break;
|
||||
case UiMarkupListColumnKind.Icon:
|
||||
DrawIconCell(context, columns[c], iconRows[c], index, cellX, cellW, y);
|
||||
break;
|
||||
}
|
||||
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,
|
||||
/// so a check column reads exactly like every other checkbox in the
|
||||
/// client (contract requirement: reuse the toggle's own primitives rather
|
||||
/// than a bespoke box-and-tick).
|
||||
/// </summary>
|
||||
private void DrawCheckCell(
|
||||
UiRenderContext context, IReadOnlyList<bool>? flags, int index, float cellX, float y)
|
||||
{
|
||||
if (flags is null || index >= flags.Count)
|
||||
return;
|
||||
bool isChecked = flags[index];
|
||||
Vector4 outer = isChecked ? UiMarkupToggle.CheckedOuter : UiMarkupToggle.UncheckedOuter;
|
||||
Vector4 inner = isChecked ? UiMarkupToggle.CheckedInner : UiMarkupToggle.UncheckedInner;
|
||||
UiMarkupToggle.DrawLamp(
|
||||
context, cellX + 1f, y + MathF.Max(1f, (RowHeight - 11f) * 0.5f), outer, inner);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
int rowCount = 0;
|
||||
for (int c = 0; c < columns.Count; c++)
|
||||
rowCount = Math.Max(rowCount, columns[c].RowCount());
|
||||
|
||||
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
|
||||
|
||||
var layout = ColumnLayout(columns, Width);
|
||||
float localX = e.Data1;
|
||||
for (int c = 0; c < columns.Count; c++)
|
||||
{
|
||||
(float cellX, float cellW) = layout[c];
|
||||
if (localX < cellX || localX >= cellX + cellW)
|
||||
continue;
|
||||
switch (columns[c].Kind)
|
||||
{
|
||||
case UiMarkupListColumnKind.Text:
|
||||
// Text-column click selects — the list's own
|
||||
// selected/onchange, unchanged in meaning from the
|
||||
// single-column list.
|
||||
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.
|
||||
columns[c].CheckChanged?.Invoke(index);
|
||||
break;
|
||||
case UiMarkupListColumnKind.Icon:
|
||||
columns[c].IconClicked?.Invoke(index);
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
126
src/AcDream.App/UI/UiMarkupListColumn.cs
Normal file
126
src/AcDream.App/UI/UiMarkupListColumn.cs
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
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. The final column
|
||||
/// in a list ignores its own declared width at layout time — it always
|
||||
/// absorbs whatever room remains after every earlier column (VVS's
|
||||
/// "0-width column auto-sizes" convention narrowed to "only the last column
|
||||
/// auto-sizes", per the slice's contract) — 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>
|
||||
/// </summary>
|
||||
public sealed class UiMarkupListColumn
|
||||
{
|
||||
public required UiMarkupListColumnKind Kind { get; init; }
|
||||
public required float Width { get; init; }
|
||||
|
||||
// ── text ──────────────────────────────────────────────────────────────
|
||||
/// <summary><c><column type="text" items="{IReadOnlyList<string>}"></c>.</summary>
|
||||
public Func<IReadOnlyList<string>>? TextSource { get; 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; init; }
|
||||
|
||||
// ── check ─────────────────────────────────────────────────────────────
|
||||
/// <summary><c><column type="check" values="{IReadOnlyList<bool>}"></c>.</summary>
|
||||
public Func<IReadOnlyList<bool>>? CheckSource { get; 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; init; }
|
||||
|
||||
// ── icon ──────────────────────────────────────────────────────────────
|
||||
/// <summary><c><column type="icon" values="{IReadOnlyList<uint>}"></c> — one icon id per row.</summary>
|
||||
public Func<IReadOnlyList<uint>>? IconValuesSource { get; 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; 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; init; }
|
||||
|
||||
public static UiMarkupListColumn Text(
|
||||
float width,
|
||||
Func<IReadOnlyList<string>> textSource,
|
||||
Func<IReadOnlyList<uint>>? colorsSource) => new()
|
||||
{
|
||||
Kind = UiMarkupListColumnKind.Text,
|
||||
Width = width,
|
||||
TextSource = textSource,
|
||||
ColorsSource = colorsSource,
|
||||
};
|
||||
|
||||
public static UiMarkupListColumn Check(
|
||||
float width,
|
||||
Func<IReadOnlyList<bool>> checkSource,
|
||||
Action<int> onChange) => new()
|
||||
{
|
||||
Kind = UiMarkupListColumnKind.Check,
|
||||
Width = width,
|
||||
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) => new()
|
||||
{
|
||||
Kind = UiMarkupListColumnKind.Icon,
|
||||
Width = width,
|
||||
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,13 +8,17 @@ namespace AcDream.App.UI;
|
|||
/// </summary>
|
||||
public sealed class UiMarkupToggle : UiElement
|
||||
{
|
||||
private static readonly Vector4 CheckedOuter =
|
||||
// Internal (not private): Campaign VT slice 1 Part B's <list><column
|
||||
// type="check"> cell draws the exact same five-band lamp glyph so a
|
||||
// column checkbox looks like every other checkbox in the client — see
|
||||
// UiMarkupList.DrawCheckCell.
|
||||
internal static readonly Vector4 CheckedOuter =
|
||||
new(0.36f, 0.58f, 0.12f, 1f);
|
||||
private static readonly Vector4 CheckedInner =
|
||||
internal static readonly Vector4 CheckedInner =
|
||||
new(0.52f, 1f, 0.08f, 1f);
|
||||
private static readonly Vector4 UncheckedOuter =
|
||||
internal static readonly Vector4 UncheckedOuter =
|
||||
new(0.26f, 0.22f, 0.13f, 1f);
|
||||
private static readonly Vector4 UncheckedInner =
|
||||
internal static readonly Vector4 UncheckedInner =
|
||||
new(0.38f, 0.34f, 0.23f, 1f);
|
||||
|
||||
public string Text { get; set; } = string.Empty;
|
||||
|
|
@ -56,7 +60,9 @@ public sealed class UiMarkupToggle : UiElement
|
|||
ctx.DrawString(caption, 17f, y, color);
|
||||
}
|
||||
|
||||
private static void DrawLamp(
|
||||
// Internal (not private): shared with UiMarkupList's <column type="check">
|
||||
// cell draw — see the field comments above.
|
||||
internal static void DrawLamp(
|
||||
UiRenderContext ctx,
|
||||
float x,
|
||||
float y,
|
||||
|
|
|
|||
715
tests/AcDream.App.Tests/UI/MarkupListColumnsTests.cs
Normal file
715
tests/AcDream.App.Tests/UI/MarkupListColumnsTests.cs
Normal file
|
|
@ -0,0 +1,715 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Rendering.Gpu;
|
||||
using AcDream.App.Tests.Rendering.Gpu;
|
||||
using AcDream.App.UI;
|
||||
using DatReaderWriter.Types;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign VT slice 1 Part B (<c>docs/plans/2026-09-06-campaign-vt-slice1-files-and-columns.md</c>):
|
||||
/// multi-column <c><list><column></c> markup, VVS <c>HudList</c>
|
||||
/// parity per <c>docs/research/vtank-kb/08-ui-views.md</c> §2-3.
|
||||
///
|
||||
/// <para>
|
||||
/// Covers: parse + per-column binding-type validation (good markup binds;
|
||||
/// every documented throw case throws with the expected message), draw-level
|
||||
/// pins against the same recording-renderer apparatus <c>MarkupIconTests</c>
|
||||
/// uses (column x-offsets, the check glyph, the icon column, per-cell
|
||||
/// clipping), hit-test routing (text selects, check/icon fire their own
|
||||
/// per-row callback and do NOT change selection, past-the-end and scroll
|
||||
/// offset), and a backward-compatibility proof that a column-less
|
||||
/// <c><list></c> is unaffected.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class MarkupListColumnsTests
|
||||
{
|
||||
private static (uint, int, int) Sprite(uint id) => (1u, 32, 32);
|
||||
|
||||
// ── B1: parse + per-column binding-type validation ──────────────────────
|
||||
|
||||
private sealed class ThreeColumnBinding
|
||||
{
|
||||
public IReadOnlyList<string> Names { get; } = new[] { "Mosswart", "Drudge", "Rat" };
|
||||
public IReadOnlyList<bool> Fester { get; } = new[] { true, false };
|
||||
public IReadOnlyList<uint> Icons { get; } = new[] { 7735u, 0u, 42u };
|
||||
public int Selected { get; set; } = -1;
|
||||
public int FesterToggled { get; private set; } = -1;
|
||||
public int IconClickedRow { get; private set; } = -1;
|
||||
public Action<int> ToggleFester => row => FesterToggled = row;
|
||||
public Action<int> ClickIcon => row => IconClickedRow = row;
|
||||
}
|
||||
|
||||
private const string ThreeColumnXml =
|
||||
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
|
||||
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" rowheight=\"18\" selected=\"{Selected}\">" +
|
||||
" <column type=\"text\" width=\"80\" items=\"{Names}\"/>" +
|
||||
" <column type=\"check\" width=\"20\" values=\"{Fester}\" onchange=\"{ToggleFester}\"/>" +
|
||||
" <column type=\"icon\" width=\"20\" iconkind=\"did\" values=\"{Icons}\" onclick=\"{ClickIcon}\"/>" +
|
||||
"</list>" +
|
||||
"</panel>";
|
||||
|
||||
private sealed class FakeIconResolver : IMarkupIconResolver
|
||||
{
|
||||
public readonly List<(string Method, uint Id)> Calls = new();
|
||||
public (uint tex, int w, int h) ResolveDid(uint did)
|
||||
{
|
||||
Calls.Add(("did", did));
|
||||
return did == 0u ? (0u, 0, 0) : (did, 16, 16);
|
||||
}
|
||||
public (uint tex, int w, int h) ResolveSpell(uint spellId)
|
||||
{
|
||||
Calls.Add(("spell", spellId));
|
||||
return spellId == 0u ? (0u, 0, 0) : (spellId, 16, 16);
|
||||
}
|
||||
public (uint tex, int w, int h) ResolveItem(uint objectId)
|
||||
{
|
||||
Calls.Add(("item", objectId));
|
||||
return objectId == 0u ? (0u, 0, 0) : (objectId, 16, 16);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Columns_TextCheckIcon_BindEachColumnsOwnPerRowSource()
|
||||
{
|
||||
var resolver = new FakeIconResolver();
|
||||
var binding = new ThreeColumnBinding();
|
||||
|
||||
var panel = MarkupDocument.Build(ThreeColumnXml, binding, Sprite, icons: resolver);
|
||||
var list = Assert.IsType<UiMarkupList>(panel.Children[0]);
|
||||
|
||||
Assert.NotNull(list.Columns);
|
||||
Assert.Equal(3, list.Columns!.Count);
|
||||
Assert.Equal(UiMarkupListColumnKind.Text, list.Columns[0].Kind);
|
||||
Assert.Equal(UiMarkupListColumnKind.Check, list.Columns[1].Kind);
|
||||
Assert.Equal(UiMarkupListColumnKind.Icon, list.Columns[2].Kind);
|
||||
|
||||
Assert.Equal(binding.Names, list.Columns[0].TextSource!());
|
||||
Assert.Equal(binding.Fester, list.Columns[1].CheckSource!());
|
||||
Assert.Equal(binding.Icons, list.Columns[2].IconValuesSource!());
|
||||
|
||||
// Row count is the longest bound column (Names has 3 rows; Fester
|
||||
// only 2) — short columns simply have nothing to draw for row 2, they
|
||||
// don't truncate the whole list.
|
||||
Assert.Equal(3, list.Columns.Max(c => c.RowCount()));
|
||||
|
||||
// The legacy single-column fields stay at their untouched defaults —
|
||||
// proves MarkupDocument never populates both surfaces at once.
|
||||
Assert.Empty(list.ItemsSource());
|
||||
Assert.Null(list.IconIdsSource);
|
||||
|
||||
list.Columns[1].CheckChanged!(1);
|
||||
Assert.Equal(1, binding.FesterToggled);
|
||||
list.Columns[2].IconClicked!(0);
|
||||
Assert.Equal(0, binding.IconClickedRow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ColumnLessList_ColumnsPropertyStaysNull_LegacyPathUntouched()
|
||||
{
|
||||
var binding = new ThreeColumnBinding();
|
||||
const string xml =
|
||||
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
|
||||
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" items=\"{Names}\" selected=\"{Selected}\"/>" +
|
||||
"</panel>";
|
||||
|
||||
var panel = MarkupDocument.Build(xml, binding, Sprite);
|
||||
var list = Assert.IsType<UiMarkupList>(panel.Children[0]);
|
||||
|
||||
Assert.Null(list.Columns);
|
||||
Assert.Equal(binding.Names, list.ItemsSource());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Column_UnknownType_ThrowsAtBuild()
|
||||
{
|
||||
var binding = new ThreeColumnBinding();
|
||||
const string xml =
|
||||
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
|
||||
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" selected=\"{Selected}\">" +
|
||||
" <column type=\"bogus\" width=\"80\" items=\"{Names}\"/>" +
|
||||
"</list></panel>";
|
||||
|
||||
var ex = Assert.Throws<FormatException>(
|
||||
() => MarkupDocument.Build(xml, binding, Sprite));
|
||||
Assert.Contains("bogus", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TextColumn_MissingItems_ThrowsAtBuild()
|
||||
{
|
||||
var binding = new ThreeColumnBinding();
|
||||
const string xml =
|
||||
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
|
||||
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" selected=\"{Selected}\">" +
|
||||
" <column type=\"text\" width=\"80\"/>" +
|
||||
"</list></panel>";
|
||||
|
||||
Assert.Throws<FormatException>(() => MarkupDocument.Build(xml, binding, Sprite));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckColumn_MissingValues_ThrowsAtBuild()
|
||||
{
|
||||
var binding = new ThreeColumnBinding();
|
||||
const string xml =
|
||||
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
|
||||
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" selected=\"{Selected}\">" +
|
||||
" <column type=\"check\" width=\"20\" onchange=\"{ToggleFester}\"/>" +
|
||||
"</list></panel>";
|
||||
|
||||
Assert.Throws<FormatException>(() => MarkupDocument.Build(xml, binding, Sprite));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckColumn_MissingOnchange_ThrowsAtBuild()
|
||||
{
|
||||
var binding = new ThreeColumnBinding();
|
||||
const string xml =
|
||||
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
|
||||
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" selected=\"{Selected}\">" +
|
||||
" <column type=\"check\" width=\"20\" values=\"{Fester}\"/>" +
|
||||
"</list></panel>";
|
||||
|
||||
Assert.Throws<FormatException>(() => MarkupDocument.Build(xml, binding, Sprite));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IconColumn_MissingValues_ThrowsAtBuild()
|
||||
{
|
||||
var binding = new ThreeColumnBinding();
|
||||
const string xml =
|
||||
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
|
||||
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" selected=\"{Selected}\">" +
|
||||
" <column type=\"icon\" width=\"20\" onclick=\"{ClickIcon}\"/>" +
|
||||
"</list></panel>";
|
||||
|
||||
Assert.Throws<FormatException>(() => MarkupDocument.Build(xml, binding, Sprite));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IconColumn_MissingOnclick_ThrowsAtBuild()
|
||||
{
|
||||
var binding = new ThreeColumnBinding();
|
||||
const string xml =
|
||||
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
|
||||
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" selected=\"{Selected}\">" +
|
||||
" <column type=\"icon\" width=\"20\" values=\"{Icons}\"/>" +
|
||||
"</list></panel>";
|
||||
|
||||
Assert.Throws<FormatException>(() => MarkupDocument.Build(xml, binding, Sprite));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IconColumn_UnknownIconKind_ThrowsAtBuild_EvenWithNoResolverWired()
|
||||
{
|
||||
var binding = new ThreeColumnBinding();
|
||||
const string xml =
|
||||
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
|
||||
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" selected=\"{Selected}\">" +
|
||||
" <column type=\"icon\" width=\"20\" iconkind=\"spel\" values=\"{Icons}\" onclick=\"{ClickIcon}\"/>" +
|
||||
"</list></panel>";
|
||||
|
||||
Assert.Throws<FormatException>(() => MarkupDocument.Build(xml, binding, Sprite));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IconColumn_NoResolverWired_IconResolveStaysNull_ButValuesStillBind()
|
||||
{
|
||||
var binding = new ThreeColumnBinding();
|
||||
const string xml =
|
||||
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
|
||||
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" selected=\"{Selected}\">" +
|
||||
" <column type=\"icon\" width=\"20\" values=\"{Icons}\" onclick=\"{ClickIcon}\"/>" +
|
||||
"</list></panel>";
|
||||
|
||||
var panel = MarkupDocument.Build(xml, binding, Sprite); // no `icons:` resolver
|
||||
var list = Assert.IsType<UiMarkupList>(panel.Children[0]);
|
||||
|
||||
Assert.NotNull(list.Columns);
|
||||
Assert.Null(list.Columns![0].IconResolve);
|
||||
Assert.Equal(binding.Icons, list.Columns[0].IconValuesSource!());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Columns_CombinedWithLegacyItemsAttribute_ThrowsAtBuild()
|
||||
{
|
||||
var binding = new ThreeColumnBinding();
|
||||
const string xml =
|
||||
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
|
||||
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" items=\"{Names}\" selected=\"{Selected}\">" +
|
||||
" <column type=\"text\" width=\"80\" items=\"{Names}\"/>" +
|
||||
"</list></panel>";
|
||||
|
||||
Assert.Throws<FormatException>(() => MarkupDocument.Build(xml, binding, Sprite));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Columns_CombinedWithLegacyColorsAttribute_ThrowsAtBuild()
|
||||
{
|
||||
var binding = new ThreeColumnBinding();
|
||||
const string xml =
|
||||
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
|
||||
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" colors=\"{Icons}\" selected=\"{Selected}\">" +
|
||||
" <column type=\"text\" width=\"80\" items=\"{Names}\"/>" +
|
||||
"</list></panel>";
|
||||
|
||||
Assert.Throws<FormatException>(() => MarkupDocument.Build(xml, binding, Sprite));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Columns_CombinedWithLegacyIconsAttribute_ThrowsAtBuild()
|
||||
{
|
||||
var binding = new ThreeColumnBinding();
|
||||
const string xml =
|
||||
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
|
||||
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" icons=\"{Icons}\" selected=\"{Selected}\">" +
|
||||
" <column type=\"text\" width=\"80\" items=\"{Names}\"/>" +
|
||||
"</list></panel>";
|
||||
|
||||
Assert.Throws<FormatException>(() => MarkupDocument.Build(xml, binding, Sprite));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NonColumnChildOfList_ThrowsAtBuild()
|
||||
{
|
||||
var binding = new ThreeColumnBinding();
|
||||
const string xml =
|
||||
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
|
||||
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" selected=\"{Selected}\">" +
|
||||
" <label x=\"0\" y=\"0\" text=\"stray\"/>" +
|
||||
"</list></panel>";
|
||||
|
||||
var ex = Assert.Throws<FormatException>(
|
||||
() => MarkupDocument.Build(xml, binding, Sprite));
|
||||
Assert.Contains("label", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TextColumn_OptionalColorsAttribute_BindsWhenPresent_NullWhenAbsent()
|
||||
{
|
||||
var binding = new ThreeColumnBinding();
|
||||
const string withColors =
|
||||
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
|
||||
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" selected=\"{Selected}\">" +
|
||||
" <column type=\"text\" width=\"80\" items=\"{Names}\" colors=\"{Icons}\"/>" +
|
||||
"</list></panel>";
|
||||
var panelWith = MarkupDocument.Build(withColors, binding, Sprite);
|
||||
var listWith = Assert.IsType<UiMarkupList>(panelWith.Children[0]);
|
||||
Assert.NotNull(listWith.Columns![0].ColorsSource);
|
||||
Assert.Equal(binding.Icons, listWith.Columns[0].ColorsSource!());
|
||||
|
||||
const string withoutColors =
|
||||
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
|
||||
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" selected=\"{Selected}\">" +
|
||||
" <column type=\"text\" width=\"80\" items=\"{Names}\"/>" +
|
||||
"</list></panel>";
|
||||
var panelWithout = MarkupDocument.Build(withoutColors, binding, Sprite);
|
||||
var listWithout = Assert.IsType<UiMarkupList>(panelWithout.Children[0]);
|
||||
Assert.Null(listWithout.Columns![0].ColorsSource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TextColumn_MalformedColorsAttribute_ThrowsAtBuild()
|
||||
{
|
||||
var binding = new ThreeColumnBinding();
|
||||
const string xml =
|
||||
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
|
||||
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" selected=\"{Selected}\">" +
|
||||
" <column type=\"text\" width=\"80\" items=\"{Names}\" colors=\"notabinding\"/>" +
|
||||
"</list></panel>";
|
||||
|
||||
Assert.Throws<FormatException>(() => MarkupDocument.Build(xml, binding, Sprite));
|
||||
}
|
||||
|
||||
// ── Draw-level: column x-offsets, check glyph, icon, clipping ────────────
|
||||
|
||||
private static (TextRenderer renderer, UiRenderContext ctx) MakeContext(float w, float h)
|
||||
{
|
||||
var device = new RecordingGpuDevice();
|
||||
var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused");
|
||||
renderer.Begin(new Vector2(w, h));
|
||||
var ctx = new UiRenderContext(renderer, new Vector2(w, h));
|
||||
return (renderer, ctx);
|
||||
}
|
||||
|
||||
private sealed class NullGpuFrameSource : ICurrentGpuFrameSource
|
||||
{
|
||||
public IGpuFrame? CurrentFrame => null;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Columns_CheckThenIcon_EachCellDrawsInsideItsOwnColumnBounds()
|
||||
{
|
||||
// col0 = check, width 20; col1 (last) = icon, absorbs the remainder
|
||||
// of a 100-wide list (80px). Both columns render row 0; the check
|
||||
// glyph's inner-lamp fill (a distinct, deliberately non-default color)
|
||||
// and the icon sprite must each stay within their own [cellX, cellX+w)
|
||||
// window — proving the column offsets actually took effect, not just
|
||||
// that both draw somewhere.
|
||||
var list = new UiMarkupList
|
||||
{
|
||||
Width = 100f, Height = 40f, RowHeight = 18f,
|
||||
SelectedIndexSource = () => -1,
|
||||
BackgroundColor = default, BorderColor = default,
|
||||
Columns = new[]
|
||||
{
|
||||
UiMarkupListColumn.Check(20f, () => new[] { true }, _ => { }),
|
||||
UiMarkupListColumn.Icon(
|
||||
20f, () => new uint[] { 9u }, id => (id, 16, 16), _ => { }),
|
||||
},
|
||||
};
|
||||
var (renderer, ctx) = MakeContext(200f, 200f);
|
||||
|
||||
list.DrawSelfAndChildren(ctx);
|
||||
|
||||
// The check glyph's inner-lamp fill is a distinctive color (not the
|
||||
// default black/zero used by every other fill in this test) —
|
||||
// isolate its vertices by color match. One quad emits 6 vertices
|
||||
// sharing the same color, so take the first rather than requiring
|
||||
// exactly one match.
|
||||
var checkVertex = renderer.DebugSpriteSegmentVerts
|
||||
.SelectMany(s => Chunk(s.Verts))
|
||||
.First(v => ColorMatches(v, UiMarkupToggle.CheckedInner));
|
||||
Assert.True(checkVertex[0] >= 0f && checkVertex[0] < 20f,
|
||||
$"expected the check glyph inside [0,20), got x={checkVertex[0]}");
|
||||
|
||||
// The icon column drew a real (non-zero-width) sprite quad on its
|
||||
// own resolved texture (9u), entirely at or past x=20 (col1's start).
|
||||
var iconQuad = Assert.Single(renderer.DebugSpriteSegmentVerts, s => s.Texture == 9u);
|
||||
Assert.True(iconQuad.Verts[0] >= 20f - 0.01f,
|
||||
$"expected the icon column's sprite at or past x=20, got x={iconQuad.Verts[0]}");
|
||||
float iconWidth = iconQuad.Verts[8] - iconQuad.Verts[0];
|
||||
Assert.True(iconWidth > 0f, $"expected a non-zero icon quad width, got {iconWidth}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckColumn_DrawsCheckedAndUncheckedGlyphsPerRow()
|
||||
{
|
||||
var list = new UiMarkupList
|
||||
{
|
||||
Width = 60f, Height = 60f, RowHeight = 18f,
|
||||
SelectedIndexSource = () => -1,
|
||||
BackgroundColor = default, BorderColor = default,
|
||||
Columns = new[]
|
||||
{
|
||||
UiMarkupListColumn.Check(60f, () => new[] { true, false }, _ => { }),
|
||||
},
|
||||
};
|
||||
var (renderer, ctx) = MakeContext(200f, 200f);
|
||||
|
||||
list.DrawSelfAndChildren(ctx);
|
||||
|
||||
var quads = renderer.DebugSpriteSegmentVerts.SelectMany(s => Chunk(s.Verts)).ToList();
|
||||
Assert.Contains(quads, v => ColorMatches(v, UiMarkupToggle.CheckedInner));
|
||||
Assert.Contains(quads, v => ColorMatches(v, UiMarkupToggle.UncheckedInner));
|
||||
// Row 0's checked glyph sits above row 1's unchecked glyph.
|
||||
var checkedY = quads.First(v => ColorMatches(v, UiMarkupToggle.CheckedInner))[1];
|
||||
var uncheckedY = quads.First(v => ColorMatches(v, UiMarkupToggle.UncheckedInner))[1];
|
||||
Assert.True(uncheckedY > checkedY,
|
||||
$"expected row 1's glyph ({uncheckedY}) below row 0's ({checkedY})");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IconColumn_DrawsResolvedRowIcon_AndSkipsAMissingOne()
|
||||
{
|
||||
var list = new UiMarkupList
|
||||
{
|
||||
Width = 60f, Height = 40f, RowHeight = 18f,
|
||||
SelectedIndexSource = () => -1,
|
||||
BackgroundColor = default, BorderColor = default,
|
||||
Columns = new[]
|
||||
{
|
||||
UiMarkupListColumn.Icon(
|
||||
60f, () => new uint[] { 9u, 0u }, id => id == 0u ? (0u, 0, 0) : (id, 16, 16),
|
||||
_ => { }),
|
||||
},
|
||||
};
|
||||
var (renderer, ctx) = MakeContext(200f, 200f);
|
||||
|
||||
list.DrawSelfAndChildren(ctx);
|
||||
|
||||
Assert.Contains(renderer.DebugSpriteSegmentVerts, s => s.Texture == 9u);
|
||||
// Row 1's id (0u) resolves to nothing — there is exactly one drawn
|
||||
// icon quad total (6 vertices; AppendQuad's two-triangle layout).
|
||||
int iconVertexCount = renderer.DebugSpriteSegmentVerts
|
||||
.Where(s => s.Texture == 9u)
|
||||
.Sum(s => s.Verts.Count / 8);
|
||||
Assert.Equal(6, iconVertexCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LastColumn_AbsorbsRemainingWidth_RegardlessOfItsOwnDeclaredWidth()
|
||||
{
|
||||
// col0 (text) declares 30px; col1 (icon, LAST) declares only 5px, but
|
||||
// being last it must absorb the true remainder of a 100px-wide list
|
||||
// (70px), not its own tiny declared value — a 16x16 icon fit into a
|
||||
// genuine 70px-wide cell scales up to its full 16px size, whereas a
|
||||
// 5px cell would force it down to ~3px (extentW=3, scale=3/16).
|
||||
var list = new UiMarkupList
|
||||
{
|
||||
Width = 100f, Height = 40f, RowHeight = 18f,
|
||||
SelectedIndexSource = () => -1,
|
||||
BackgroundColor = default, BorderColor = default,
|
||||
Columns = new[]
|
||||
{
|
||||
UiMarkupListColumn.Text(30f, () => new[] { "" }, null),
|
||||
UiMarkupListColumn.Icon(5f, () => new uint[] { 9u }, id => (id, 16, 16), _ => { }),
|
||||
},
|
||||
};
|
||||
var (renderer, ctx) = MakeContext(200f, 200f);
|
||||
|
||||
list.DrawSelfAndChildren(ctx);
|
||||
|
||||
var iconQuad = Assert.Single(renderer.DebugSpriteSegmentVerts, s => s.Texture == 9u);
|
||||
float drawnWidth = iconQuad.Verts[8] - iconQuad.Verts[0];
|
||||
Assert.True(drawnWidth > 10f,
|
||||
$"expected the icon to render near its full 16px size in the ~70px remainder "
|
||||
+ $"cell (not squeezed into its declared 5px), got width {drawnWidth}");
|
||||
// Also at/after col0's 30px boundary — never inside col0's own cell.
|
||||
Assert.True(iconQuad.Verts[0] >= 30f - 0.01f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TextColumn_OverLongText_ClipsToItsOwnCellWidth()
|
||||
{
|
||||
var glyphs = new Dictionary<char, FontCharDesc>
|
||||
{
|
||||
['W'] = new FontCharDesc { Unicode = 'W', Width = 8, Height = 8 },
|
||||
};
|
||||
var font = new UiDatFont(
|
||||
fgTex: 1u, fgW: 32, fgH: 32,
|
||||
bgTex: 0, bgW: 0, bgH: 0,
|
||||
lineHeight: 16f, baselineOffset: 12f,
|
||||
glyphs);
|
||||
|
||||
// A narrow (10px) first column with a long run of wide glyphs must
|
||||
// never draw past x=10 — the SECOND column starts immediately after
|
||||
// and must never see any of the first column's glyph geometry.
|
||||
var list = new UiMarkupList
|
||||
{
|
||||
Width = 100f, Height = 40f, RowHeight = 18f, DatFont = font,
|
||||
SelectedIndexSource = () => -1,
|
||||
BackgroundColor = default, BorderColor = default,
|
||||
Columns = new[]
|
||||
{
|
||||
UiMarkupListColumn.Text(10f, () => new[] { "WWWWWWWWWW" }, null),
|
||||
UiMarkupListColumn.Text(90f, () => new[] { "" }, null),
|
||||
},
|
||||
};
|
||||
var (renderer, ctx) = MakeContext(200f, 200f);
|
||||
|
||||
list.DrawSelfAndChildren(ctx);
|
||||
|
||||
var glyphVertices = renderer.DebugSpriteSegmentVerts
|
||||
.Where(s => s.Texture == 1u)
|
||||
.SelectMany(s => Chunk(s.Verts))
|
||||
.ToList();
|
||||
Assert.NotEmpty(glyphVertices);
|
||||
foreach (var vertex in glyphVertices)
|
||||
{
|
||||
float x = vertex[0];
|
||||
Assert.True(x <= 10f + 0.01f,
|
||||
$"expected every glyph vertex clipped inside the 10px column, got x={x}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Split a flat 8-floats/vertex, 6-vertices/quad buffer into per-vertex arrays.</summary>
|
||||
private static IEnumerable<float[]> Chunk(IReadOnlyList<float> verts)
|
||||
{
|
||||
for (int i = 0; i + 8 <= verts.Count; i += 8)
|
||||
yield return verts.Skip(i).Take(8).ToArray();
|
||||
}
|
||||
|
||||
private static bool ColorMatches(float[] vertex, Vector4 color) =>
|
||||
MathF.Abs(vertex[4] - color.X) < 0.001f
|
||||
&& MathF.Abs(vertex[5] - color.Y) < 0.001f
|
||||
&& MathF.Abs(vertex[6] - color.Z) < 0.001f
|
||||
&& MathF.Abs(vertex[7] - color.W) < 0.001f;
|
||||
|
||||
// ── Hit-test: text selects, check/icon fire their own callback ───────────
|
||||
|
||||
private static UiMarkupList MakeHitTestList(
|
||||
out List<int> selections, out List<int> checkFires, out List<int> iconFires,
|
||||
int rowCount = 3, float width = 60f)
|
||||
{
|
||||
var sel = new List<int>();
|
||||
var chk = new List<int>();
|
||||
var icn = new List<int>();
|
||||
selections = sel; checkFires = chk; iconFires = icn;
|
||||
|
||||
var textRows = Enumerable.Range(0, rowCount).Select(i => $"row{i}").ToArray();
|
||||
var checkRows = Enumerable.Range(0, rowCount).Select(i => i % 2 == 0).ToArray();
|
||||
var iconRows = Enumerable.Range(0, rowCount).Select(i => (uint)(i + 1)).ToArray();
|
||||
|
||||
return new UiMarkupList
|
||||
{
|
||||
Width = width, Height = 200f, RowHeight = 20f,
|
||||
SelectedIndexSource = () => -1,
|
||||
SelectionChanged = row => sel.Add(row),
|
||||
Columns = new[]
|
||||
{
|
||||
UiMarkupListColumn.Text(20f, () => textRows, null),
|
||||
UiMarkupListColumn.Check(20f, () => checkRows, row => chk.Add(row)),
|
||||
UiMarkupListColumn.Icon(20f, () => iconRows, id => (id, 16, 16), row => icn.Add(row)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClickInTextColumn_SelectsAndDoesNotFireCheckOrIcon()
|
||||
{
|
||||
var list = MakeHitTestList(out var sel, out var chk, out var icn);
|
||||
|
||||
// Row 1 (y = 20..40), x = 10 (inside the text column [0,20)).
|
||||
list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 10, Data2 = 25 });
|
||||
|
||||
Assert.Equal(new[] { 1 }, sel);
|
||||
Assert.Empty(chk);
|
||||
Assert.Empty(icn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClickInCheckColumn_FiresCheckCallbackWithRowIndex_AndDoesNotSelect()
|
||||
{
|
||||
var list = MakeHitTestList(out var sel, out var chk, out var icn);
|
||||
|
||||
// Row 2 (y = 40..60), x = 30 (inside the check column [20,40)).
|
||||
list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 30, Data2 = 45 });
|
||||
|
||||
Assert.Equal(new[] { 2 }, chk);
|
||||
Assert.Empty(sel);
|
||||
Assert.Empty(icn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClickInIconColumn_FiresIconCallbackWithRowIndex_AndDoesNotSelect()
|
||||
{
|
||||
var list = MakeHitTestList(out var sel, out var chk, out var icn);
|
||||
|
||||
// Row 0 (y = 0..20), x = 50 (inside the icon column [40,60)).
|
||||
list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 50, Data2 = 5 });
|
||||
|
||||
Assert.Equal(new[] { 0 }, icn);
|
||||
Assert.Empty(sel);
|
||||
Assert.Empty(chk);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClickPastTheLastRow_DoesNothing_ButStillSwallowsThePress()
|
||||
{
|
||||
var list = MakeHitTestList(out var sel, out var chk, out var icn, rowCount: 2);
|
||||
|
||||
// Only 2 rows (0..40); click at y=100 lands well past the data.
|
||||
bool handled = list.OnEvent(
|
||||
new UiEvent { Type = UiEventType.MouseDown, Data1 = 10, Data2 = 100 });
|
||||
|
||||
Assert.True(handled);
|
||||
Assert.Empty(sel);
|
||||
Assert.Empty(chk);
|
||||
Assert.Empty(icn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scroll_OffsetIsRespectedBySubsequentHitTests()
|
||||
{
|
||||
var list = MakeHitTestList(out var sel, out _, out _, rowCount: 10, width: 60f);
|
||||
list.Height = 40f; // 2 visible rows of a 10-row list — forces real scrolling
|
||||
|
||||
// Force a draw so the widget has computed VisibleRows/topRow state at
|
||||
// least once (mirrors normal frame order: draw, then handle input).
|
||||
var (_, ctx) = MakeContext(200f, 200f);
|
||||
list.DrawSelfAndChildren(ctx);
|
||||
|
||||
// Scroll down 3 rows (negative wheel data = scroll down, matching the
|
||||
// legacy single-column list's own convention: _topRow -= Sign(Data0)).
|
||||
for (int i = 0; i < 3; i++)
|
||||
list.OnEvent(new UiEvent { Type = UiEventType.Scroll, Data0 = -1 });
|
||||
list.DrawSelfAndChildren(ctx); // clamp/re-settle topRow like a real frame would
|
||||
|
||||
// Click the FIRST visible row after scrolling 3 rows down — should
|
||||
// resolve to absolute row 3, not row 0.
|
||||
list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 10, Data2 = 5 });
|
||||
|
||||
Assert.Equal(new[] { 3 }, sel);
|
||||
}
|
||||
|
||||
// ── Backward compatibility: column-less list is unaffected ──────────────
|
||||
|
||||
private sealed class LegacyBinding
|
||||
{
|
||||
public IReadOnlyList<string> Choices => new[] { "First", "Second" };
|
||||
public IReadOnlyList<uint> ChoiceColors => new[] { 0xFF0000u, 0x00FF00u };
|
||||
public int SelectedIndex { get; set; } = 1;
|
||||
public Action<int> SelectIndex => _ => { };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression pin for "without <column> children the element is
|
||||
/// byte-for-byte the old single-text-column list": builds the SAME
|
||||
/// column-less markup through <see cref="MarkupDocument"/> (which now
|
||||
/// routes through the new if/else split in the "list" case) and compares
|
||||
/// its draw output against a <see cref="UiMarkupList"/> constructed BY
|
||||
/// HAND with equivalent fields, bypassing <see cref="MarkupDocument"/>
|
||||
/// entirely — i.e. against the widget's own unmodified
|
||||
/// <c>OnDraw</c>/legacy branch, not a snapshot captured from a different
|
||||
/// commit. Every vertex float must match exactly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ColumnLessList_ProducesTheIdenticalDrawRecordToTheHandBuiltWidget()
|
||||
{
|
||||
var glyphs = new Dictionary<char, FontCharDesc>
|
||||
{
|
||||
['F'] = new FontCharDesc { Unicode = 'F', Width = 8, Height = 8 },
|
||||
};
|
||||
var font = new UiDatFont(
|
||||
fgTex: 1u, fgW: 32, fgH: 32,
|
||||
bgTex: 0, bgW: 0, bgH: 0,
|
||||
lineHeight: 16f, baselineOffset: 12f,
|
||||
glyphs);
|
||||
|
||||
var binding = new LegacyBinding();
|
||||
const string xml =
|
||||
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
|
||||
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" rowheight=\"18\" " +
|
||||
"items=\"{Choices}\" colors=\"{ChoiceColors}\" " +
|
||||
"selected=\"{SelectedIndex}\" onchange=\"{SelectIndex}\"/>" +
|
||||
"</panel>";
|
||||
|
||||
var panel = MarkupDocument.Build(xml, binding, Sprite);
|
||||
var viaMarkup = Assert.IsType<UiMarkupList>(panel.Children[0]);
|
||||
viaMarkup.DatFont = font; // markup panels always get a host font; irrelevant to the pin itself
|
||||
viaMarkup.BackgroundColor = default;
|
||||
viaMarkup.BorderColor = default;
|
||||
|
||||
var handBuilt = new UiMarkupList
|
||||
{
|
||||
Width = 180f, Height = 60f, RowHeight = 18f, DatFont = font,
|
||||
ItemsSource = () => binding.Choices,
|
||||
ItemColorsSource = () => binding.ChoiceColors,
|
||||
SelectedIndexSource = () => binding.SelectedIndex,
|
||||
BackgroundColor = default,
|
||||
BorderColor = default,
|
||||
};
|
||||
|
||||
var (rendererMarkup, ctxMarkup) = MakeContext(200f, 200f);
|
||||
viaMarkup.DrawSelfAndChildren(ctxMarkup);
|
||||
var (rendererHand, ctxHand) = MakeContext(200f, 200f);
|
||||
handBuilt.DrawSelfAndChildren(ctxHand);
|
||||
|
||||
var markupVerts = rendererMarkup.DebugSpriteSegmentVerts.ToList();
|
||||
var handVerts = rendererHand.DebugSpriteSegmentVerts.ToList();
|
||||
Assert.Equal(handVerts.Count, markupVerts.Count);
|
||||
for (int i = 0; i < handVerts.Count; i++)
|
||||
{
|
||||
Assert.Equal(handVerts[i].Texture, markupVerts[i].Texture);
|
||||
Assert.Equal(handVerts[i].Verts.Count, markupVerts[i].Verts.Count);
|
||||
for (int j = 0; j < handVerts[i].Verts.Count; j++)
|
||||
Assert.Equal(handVerts[i].Verts[j], markupVerts[i].Verts[j], 4);
|
||||
}
|
||||
|
||||
Assert.Null(viaMarkup.Columns);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue