acdream/src/AcDream.App/UI/UiMarkupListColumn.cs
Erik 8fb4909cc8 fix(vt): list column fix round 8/11 — shared UiCheckLamp + factory-only column
New internal static UiCheckLamp (LampSize=11f, the four checked/
unchecked colors, Draw) is the ONE definition of the five-band lamp
glyph, promoted out of UiMarkupToggle's private/internal fields —
UiMarkupToggle.OnDraw and UiMarkupList.DrawCheckCell both call
UiCheckLamp.Draw now instead of each carrying (or one exposing to the
other) its own copy of the same five DrawFill calls and four colors.

DrawCheckCell also centers the lamp horizontally in its cell instead of
drawing it flush left at cellX+1 — matching DrawIconCell, which already
centers its sprite. A check column declared wider than the glyph itself
(routine under fix item 11's PITCH-based authoring convention) no longer
strands the glyph in the cell's left edge.

UiMarkupListColumn's settable members are now internal init (Kind/Width
lost their `required` modifier — C# forbids `required` pairing with a
setter less visible than the containing public type, CS9032 — every
factory already sets both unconditionally, so this is a compiler-level
demotion, not a behavior change) — the type is constructible only
through its Text/Check/Icon factories from any external assembly (a
plugin) with no InternalsVisibleTo grant, so it can never assemble an
inconsistent instance via object-initializer syntax.

Replaced the inert draw-offset assertion in
Columns_CheckThenIcon_EachCellDrawsInsideItsOwnColumnBounds (check
column FIRST, so its own-cell assertion held trivially even with
completely broken column offsets) with
Columns_IconThenCheck_EachCellDrawsInsideItsOwnColumnBounds — check
column now SECOND, so the assertion can only pass if the glyph actually
moved into its own [20,100) cell.

New test: a single 50px-wide check column's glyph lands near the cell's
midpoint (~22.5) rather than the old flush-left x=4 — shown to fail
first before the centering change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 21:16:00 +02:00

171 lines
8 KiB
C#

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