fix(vt): plugin <list> draws no selection band by default in either mode

Slice-7 resemblance re-check found UiMarkupList's column-less items= mode
and its <column> mode disagreed on whether a selected row gets a
persistent SelectedColor fill, so a plugin's Buffs lists highlighted a
row while the Monsters/Meta grids looked different for the same widget.
Real VVS lists (VTank's HudList) draw no such fill at all.

UiMarkupList.SelectionBandEnabled (default false) now gates the fill in
both OnDraw's legacy branch and DrawColumns; MarkupDocument parses
<list selectionband="true"> (same literal-bool convention as
openupward/clearonsubmit) to opt a single list back in. selected/onchange
semantics and scroll-into-view of the selected row are unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-07 17:49:27 +02:00
parent 69638584e8
commit 3cec9df4ab
4 changed files with 158 additions and 3 deletions

View file

@ -110,7 +110,7 @@ vanishing from the built tree.
| `slider` | Horizontal scalar | `x y w h value onchange anchor` |
| `field` | Single-line editable text | `x y w h text maxlength clearonsubmit onchange onsubmit color background anchor` |
| `menu` | Dropdown selector | `x y w h items selected onchange rows rowheight openupward style anchor` |
| `list` | Scrollable row list (+ Slice B icon column, + Campaign VT slice 1 multi-column) | `x y w h selected onchange rowheight anchor` + either the single-column `items colors icons iconkind`, or one-to-many `<column>` children (see "Columns" below) — never both |
| `list` | Scrollable row list (+ Slice B icon column, + Campaign VT slice 1 multi-column) | `x y w h selected onchange rowheight selectionband anchor` + either the single-column `items colors icons iconkind`, or one-to-many `<column>` children (see "Columns" below) — never both |
`menu style` is `plain` (the default) or `retail`: retail's gold pushbutton
art read as an out-of-place "big yellow button" next to a plugin's own dark
@ -410,6 +410,11 @@ attributes:
</list>
```
VVS lists draw no persistent row-selection fill, so `<list>` matches that by
default in both the single-column and `<column>` forms — a plugin that wants
a visible band anyway sets `selectionband="true"` (row selection itself,
including scroll-into-view, is unaffected either way).
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

View file

@ -594,6 +594,12 @@ public static class MarkupDocument
binding,
"list selected"),
SelectionChanged = listChanged,
// Campaign VT slice 7 resemblance re-check: VVS lists draw
// no persistent row-selection fill by default (matches
// both single-column and <column> mode now — see
// UiMarkupList.SelectionBandEnabled). A plugin that wants
// one back opts in with <list selectionband="true">.
SelectionBandEnabled = B(el, "selectionband", false),
};
if (listUsesColumns)

View file

@ -73,6 +73,25 @@ public sealed class UiMarkupList : UiElement
public Vector4 TextColor { get; set; } = new(0.91f, 0.87f, 0.76f, 1f);
public Vector4 SelectedColor { get; set; } = new(0.28f, 0.23f, 0.08f, 0.95f);
/// <summary>
/// Campaign VT slice 7 resemblance re-check (2026-09-07): real VVS lists
/// (VTank's own <c>HudList</c>) draw no persistent row-selection fill at
/// all — before this fix the column-less <c>items=</c> mode drew
/// <see cref="SelectedColor"/> under the selected row while the
/// <c>&lt;column&gt;</c> mode did the same, so a plugin's Buffs lists
/// highlighted a row while the Monsters/Meta grids happened not to (or
/// vice versa, depending on which mode a given list used) — same-looking
/// widgets, inconsistent behavior. Default false now suppresses the fill
/// in BOTH <see cref="OnDraw"/>'s legacy branch and
/// <see cref="DrawColumns"/>, matching VVS. <c>&lt;list
/// selectionband="true"&gt;</c> (parsed in <see cref="MarkupDocument"/>'s
/// <c>case "list"</c>) opts a single list back into a visible band for
/// plugins that want one. This gates ONLY the fill — <see cref="SelectedIndexSource"/>,
/// <see cref="SelectionChanged"/>, and the selected-row scroll-into-view
/// logic in <see cref="OnDraw"/>/<see cref="DrawColumns"/> are unchanged.
/// </summary>
public bool SelectionBandEnabled { get; set; }
/// <summary>
/// Owner live-client report 2026-09-07 ("For scrollable dropdown or the
/// meta window we use the same assets as we do in for example chat or
@ -163,7 +182,7 @@ public sealed class UiMarkupList : UiElement
for (int index = _topRow; index < end; index++)
{
float y = (index - _topRow) * RowHeight;
if (index == selected)
if (index == selected && SelectionBandEnabled)
context.DrawFill(1f, y + 1f, contentWidth - 2f, RowHeight - 1f, SelectedColor);
if (iconIds is not null && index < iconIds.Count && IconResolve is { } resolve)
@ -380,7 +399,7 @@ public sealed class UiMarkupList : UiElement
for (int index = _topRow; index < end; index++)
{
float y = (index - _topRow) * RowHeight;
if (index == selected)
if (index == selected && SelectionBandEnabled)
context.DrawFill(1f, y + 1f, contentWidth - 2f, RowHeight - 1f, SelectedColor);
for (int c = 0; c < columns.Count; c++)

View file

@ -1126,6 +1126,18 @@ public sealed class MarkupListColumnsTests
/// 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.
///
/// <para>
/// Deliberately updated for the slice-7 resemblance re-check's
/// <see cref="UiMarkupList.SelectionBandEnabled"/> fix: <c>SelectedIndex
/// = 1</c> (a real selected row) previously meant this byte-for-byte
/// comparison implicitly included the <see cref="UiMarkupList.SelectedColor"/>
/// fill quad on BOTH sides. Neither the markup XML nor the hand-built
/// widget below sets <c>selectionband</c>/<see cref="UiMarkupList.SelectionBandEnabled"/>,
/// so that fill is now absent from both — the explicit assertion at the
/// end locks in that the new no-band default applies here too, rather
/// than leaving it to accidentally fall out of the byte-for-byte diff.
/// </para>
/// </summary>
[Fact]
public void ColumnLessList_ProducesTheIdenticalDrawRecordToTheHandBuiltWidget()
@ -1181,6 +1193,119 @@ public sealed class MarkupListColumnsTests
}
Assert.Null(viaMarkup.Columns);
// Neither side opted into selectionband="true" — row 1 IS selected
// (LegacyBinding.SelectedIndex = 1) but the new default draws no
// SelectedColor fill for it, on either the markup or the hand-built
// path.
Assert.False(viaMarkup.SelectionBandEnabled);
Assert.False(handBuilt.SelectionBandEnabled);
Assert.DoesNotContain(markupVerts, s => s.Texture == 0u
&& Chunk(s.Verts).Any(v => ColorMatches(v, viaMarkup.SelectedColor)));
}
// ── Slice 7 resemblance re-check: selectionband default + opt-in ────────
private sealed class SelectionBandBinding
{
public IReadOnlyList<string> Choices => new[] { "First", "Second" };
public int Selected { get; set; } = 1;
public Action<int> SelectIndex => _ => { };
}
/// <summary>Any untextured (fill) quad in <paramref name="segs"/> whose vertex
/// color matches <paramref name="color"/> — used to detect the selection-band
/// fill regardless of its exact geometry.</summary>
private static bool HasFillOfColor(
IEnumerable<(uint Texture, IReadOnlyList<float> Verts)> segs, Vector4 color)
=> segs.Where(s => s.Texture == 0u)
.SelectMany(s => Chunk(s.Verts))
.Any(v => ColorMatches(v, color));
/// <summary>
/// Slice-7 resemblance re-check finding: VVS lists (VTank's real
/// <c>HudList</c>) draw no persistent row-selection fill, so a
/// column-less <c>&lt;list items="..."&gt;</c> must match that by
/// default — <see cref="UiMarkupList.SelectionBandEnabled"/> defaults
/// false. <c>selectionband="true"</c> opts a single list back into the
/// visible <see cref="UiMarkupList.SelectedColor"/> band.
/// </summary>
[Theory]
[InlineData(false)]
[InlineData(true)]
public void SingleColumnList_SelectionBand_DefaultsOffAndAttributeOptsIn(bool enabled)
{
var binding = new SelectionBandBinding();
string attr = enabled ? " selectionband=\"true\"" : "";
string xml =
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" rowheight=\"18\" " +
$"items=\"{{Choices}}\" selected=\"{{Selected}}\" onchange=\"{{SelectIndex}}\"{attr}/>" +
"</panel>";
var panel = MarkupDocument.Build(xml, binding, Sprite);
var list = Assert.IsType<UiMarkupList>(panel.Children[0]);
Assert.Equal(enabled, list.SelectionBandEnabled);
var (renderer, ctx) = MakeContext(200f, 200f);
list.DrawSelfAndChildren(ctx);
Assert.Equal(enabled, HasFillOfColor(renderer.DebugSpriteSegmentVerts, list.SelectedColor));
}
/// <summary>Same fix, exercised through <c>&lt;column&gt;</c> mode
/// (<see cref="UiMarkupList.DrawColumns"/>) — the gate applies to both
/// draw branches of the shared widget, not just the legacy one.</summary>
[Theory]
[InlineData(false)]
[InlineData(true)]
public void ColumnModeList_SelectionBand_DefaultsOffAndAttributeOptsIn(bool enabled)
{
var binding = new SelectionBandBinding();
string attr = enabled ? " selectionband=\"true\"" : "";
string xml =
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
$"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" rowheight=\"18\" selected=\"{{Selected}}\" onchange=\"{{SelectIndex}}\"{attr}>" +
" <column type=\"text\" width=\"*\" items=\"{Choices}\"/>" +
"</list></panel>";
var panel = MarkupDocument.Build(xml, binding, Sprite);
var list = Assert.IsType<UiMarkupList>(panel.Children[0]);
Assert.Equal(enabled, list.SelectionBandEnabled);
var (renderer, ctx) = MakeContext(200f, 200f);
list.DrawSelfAndChildren(ctx);
Assert.Equal(enabled, HasFillOfColor(renderer.DebugSpriteSegmentVerts, list.SelectedColor));
}
/// <summary>Parse-only pin: <c>selectionband</c> follows the same silent
/// literal-bool convention as <c>openupward</c>/<c>clearonsubmit</c>
/// (<see cref="MarkupDocument"/>'s private <c>B</c> helper) — omitted
/// defaults false, and <c>"true"</c> sets the property, with no draw
/// involved at all.</summary>
[Fact]
public void ListSelectionBandAttribute_ParsesToProperty()
{
var binding = new SelectionBandBinding();
const string xmlDefault =
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" items=\"{Choices}\" " +
"selected=\"{Selected}\" onchange=\"{SelectIndex}\"/>" +
"</panel>";
const string xmlEnabled =
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" items=\"{Choices}\" " +
"selected=\"{Selected}\" onchange=\"{SelectIndex}\" selectionband=\"true\"/>" +
"</panel>";
var defaultList = Assert.IsType<UiMarkupList>(
MarkupDocument.Build(xmlDefault, binding, Sprite).Children[0]);
var enabledList = Assert.IsType<UiMarkupList>(
MarkupDocument.Build(xmlEnabled, binding, Sprite).Children[0]);
Assert.False(defaultList.SelectionBandEnabled);
Assert.True(enabledList.SelectionBandEnabled);
}
// ── Fix round: end-to-end MarkupDocument builds (fix item 10) ───────────