fix(vt): list column fix round 1/11 — text column optional onclick

None of VTank's eight HudList instances relies on row selection
(PluginCore.cs Monsters/Items/Meta/Route/Buffs/Consumables cell
handlers) — every real text cell is an action target. Add an optional
onclick="{Action<int>}" to <column type="text">: when present, a click
fires it with the row index INSTEAD of selecting; when absent, the
original select-the-row behavior is unchanged. BuildListColumn now
takes the column's own index (used for its onclick throw message and
threaded through for the rest of the fix round's column-context
messages); the unknown-<column type> throw also picked up the index
as a side effect of that signature change.

New tests (MarkupListColumnsTests): the onclick attribute binds and
falls back correctly when absent, a malformed onclick throws at Build,
and a click actually reaches the bound callback instead of firing
SelectionChanged — each shown to fail first (missing TextClicked
member / no 4-arg Text() overload before the production change).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-06 20:49:43 +02:00
parent 959a694823
commit fd521f7816
4 changed files with 119 additions and 9 deletions

View file

@ -553,7 +553,7 @@ public static class MarkupDocument
if (listUsesColumns)
{
list.Columns = listChildren
.Select(columnEl => BuildListColumn(columnEl, binding, icons))
.Select((columnEl, index) => BuildListColumn(columnEl, binding, icons, index))
.ToList();
}
else
@ -994,7 +994,7 @@ public static class MarkupDocument
/// stays null (draws nothing) rather than ever pointing at a null resolver.
/// </summary>
private static UiMarkupListColumn BuildListColumn(
XElement columnEl, object binding, IMarkupIconResolver? icons)
XElement columnEl, object binding, IMarkupIconResolver? icons, int index)
{
string? type = (string?)columnEl.Attribute("type");
float width = F(columnEl, "width");
@ -1008,7 +1008,21 @@ public static class MarkupDocument
Func<IReadOnlyList<uint>>? colorsSource = colorsAttr is null
? null
: BindUintList(colorsAttr, binding, "column colors");
return UiMarkupListColumn.Text(width, textSource, colorsSource);
// 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);
}
case "check":
{
@ -1033,10 +1047,21 @@ public static class MarkupDocument
}
default:
throw new FormatException(
$"unknown <column type=\"{type}\"> (expected text, check, or icon)");
$"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}";
private static bool IsBinding(string value) =>
value.Length > 2 && value[0] == '{' && value[^1] == '}';

View file

@ -360,10 +360,16 @@ public sealed class UiMarkupList : UiElement
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);
// 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).
if (columns[c].TextClicked is { } onTextClick)
onTextClick(index);
else
SelectionChanged?.Invoke(index);
break;
case UiMarkupListColumnKind.Check:
// A click in a check/icon column fires that column's own

View file

@ -46,6 +46,18 @@ public sealed class UiMarkupListColumn
/// column draws with the list's <see cref="UiMarkupList.TextColor"/>.
/// </summary>
public Func<IReadOnlyList<uint>>? ColorsSource { get; 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; init; }
// ── check ─────────────────────────────────────────────────────────────
/// <summary><c>&lt;column type="check" values="{IReadOnlyList&lt;bool&gt;}"&gt;</c>.</summary>
@ -79,12 +91,14 @@ public sealed class UiMarkupListColumn
public static UiMarkupListColumn Text(
float width,
Func<IReadOnlyList<string>> textSource,
Func<IReadOnlyList<uint>>? colorsSource) => new()
Func<IReadOnlyList<uint>>? colorsSource,
Action<int>? onClick = null) => new()
{
Kind = UiMarkupListColumnKind.Text,
Width = width,
TextSource = textSource,
ColorsSource = colorsSource,
TextClicked = onClick,
};
public static UiMarkupListColumn Check(

View file

@ -324,6 +324,71 @@ public sealed class MarkupListColumnsTests
Assert.Throws<FormatException>(() => MarkupDocument.Build(xml, binding, Sprite));
}
// ── Fix round: text column optional onclick (fix item 1) ────────────────
[Fact]
public void TextColumn_OptionalOnclickAttribute_BindsAndDoesNotBreakWithoutIt()
{
var binding = new ThreeColumnBinding();
const string withOnclick =
"<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}\" onclick=\"{ClickIcon}\"/>" +
"</list></panel>";
var panelWith = MarkupDocument.Build(withOnclick, binding, Sprite);
var listWith = Assert.IsType<UiMarkupList>(panelWith.Children[0]);
Assert.NotNull(listWith.Columns![0].TextClicked);
const string withoutOnclick =
"<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(withoutOnclick, binding, Sprite);
var listWithout = Assert.IsType<UiMarkupList>(panelWithout.Children[0]);
Assert.Null(listWithout.Columns![0].TextClicked);
}
[Fact]
public void TextColumn_MalformedOnclickAttribute_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}\" onclick=\"notabinding\"/>" +
"</list></panel>";
var ex = Assert.Throws<FormatException>(() => MarkupDocument.Build(xml, binding, Sprite));
Assert.Contains("column[0] type=\"text\" onclick", ex.Message);
}
[Fact]
public void ClickInTextColumn_WithOnclick_FiresItInsteadOfSelecting()
{
var clicked = new List<int>();
var selections = new List<int>();
var list = new UiMarkupList
{
Width = 60f, Height = 200f, RowHeight = 20f,
SelectedIndexSource = () => -1,
SelectionChanged = row => selections.Add(row),
Columns = new[]
{
UiMarkupListColumn.Text(
60f, () => new[] { "a", "b", "c" }, null, row => clicked.Add(row)),
},
};
var (_, ctx) = MakeContext(200f, 200f);
list.DrawSelfAndChildren(ctx);
// Row 1 (y = 20..40).
list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 10, Data2 = 25 });
Assert.Equal(new[] { 1 }, clicked);
Assert.Empty(selections);
}
// ── Draw-level: column x-offsets, check glyph, icon, clipping ────────────
private static (TextRenderer renderer, UiRenderContext ctx) MakeContext(float w, float h)