# Conflicts: # docs/plans/2026-09-07-campaign-vt-slice7-tabs.md # docs/plugin-ui-markup.md # src/AcDream.App/UI/UiMarkupList.cs
1571 lines
70 KiB
C#
1571 lines
70 KiB
C#
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);
|
|
Assert.Contains("column[0]", 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>";
|
|
|
|
var ex = Assert.Throws<FormatException>(() => MarkupDocument.Build(xml, binding, Sprite));
|
|
Assert.Contains("column[0] type=\"text\" items", ex.Message);
|
|
}
|
|
|
|
[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>";
|
|
|
|
var ex = Assert.Throws<FormatException>(() => MarkupDocument.Build(xml, binding, Sprite));
|
|
Assert.Contains("column[0] type=\"check\" values", ex.Message);
|
|
}
|
|
|
|
[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>";
|
|
|
|
var ex = Assert.Throws<FormatException>(() => MarkupDocument.Build(xml, binding, Sprite));
|
|
Assert.Contains("column[0] type=\"check\" onchange", ex.Message);
|
|
}
|
|
|
|
[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>";
|
|
|
|
var ex = Assert.Throws<FormatException>(() => MarkupDocument.Build(xml, binding, Sprite));
|
|
Assert.Contains("column[0] type=\"icon\" values", ex.Message);
|
|
}
|
|
|
|
[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>";
|
|
|
|
var ex = Assert.Throws<FormatException>(() => MarkupDocument.Build(xml, binding, Sprite));
|
|
Assert.Contains("column[0] type=\"icon\" onclick", ex.Message);
|
|
}
|
|
|
|
[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>";
|
|
|
|
var ex = Assert.Throws<FormatException>(() => MarkupDocument.Build(xml, binding, Sprite));
|
|
Assert.Contains("column[0] type=\"icon\" iconkind", ex.Message);
|
|
}
|
|
|
|
[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>";
|
|
|
|
var ex = Assert.Throws<FormatException>(() => MarkupDocument.Build(xml, binding, Sprite));
|
|
Assert.Contains("items/icons/colors", ex.Message);
|
|
}
|
|
|
|
[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>";
|
|
|
|
var ex = Assert.Throws<FormatException>(() => MarkupDocument.Build(xml, binding, Sprite));
|
|
Assert.Contains("items/icons/colors", ex.Message);
|
|
}
|
|
|
|
[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>";
|
|
|
|
var ex = Assert.Throws<FormatException>(() => MarkupDocument.Build(xml, binding, Sprite));
|
|
Assert.Contains("items/icons/colors", ex.Message);
|
|
}
|
|
|
|
[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>";
|
|
|
|
var ex = Assert.Throws<FormatException>(() => MarkupDocument.Build(xml, binding, Sprite));
|
|
Assert.Contains("column[0] type=\"text\" colors", ex.Message);
|
|
}
|
|
|
|
// ── 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);
|
|
}
|
|
|
|
// ── Fix round: width semantics (fix item 2) ──────────────────────────────
|
|
|
|
[Fact]
|
|
public void NonLastColumn_MissingWidth_ThrowsAtBuild_NamingColumnIndexAndType()
|
|
{
|
|
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\" items=\"{Names}\"/>" +
|
|
" <column type=\"text\" width=\"40\" items=\"{Names}\"/>" +
|
|
"</list></panel>";
|
|
|
|
var ex = Assert.Throws<FormatException>(() => MarkupDocument.Build(xml, binding, Sprite));
|
|
Assert.Contains("column[0] type=\"text\" width", ex.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public void NonLastColumn_UnparseableWidth_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=\"wide\" items=\"{Names}\"/>" +
|
|
" <column type=\"text\" width=\"40\" items=\"{Names}\"/>" +
|
|
"</list></panel>";
|
|
|
|
var ex = Assert.Throws<FormatException>(() => MarkupDocument.Build(xml, binding, Sprite));
|
|
Assert.Contains("column[0] type=\"text\" width", ex.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public void NonLastColumn_NonPositiveWidth_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=\"0\" values=\"{Fester}\" onchange=\"{ToggleFester}\"/>" +
|
|
" <column type=\"text\" width=\"40\" items=\"{Names}\"/>" +
|
|
"</list></panel>";
|
|
|
|
var ex = Assert.Throws<FormatException>(() => MarkupDocument.Build(xml, binding, Sprite));
|
|
Assert.Contains("column[0] type=\"check\" width", ex.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public void NonLastColumn_WidthStar_DoesNotThrow_ParsesAsAutoWidth()
|
|
{
|
|
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=\"*\" items=\"{Names}\"/>" +
|
|
" <column type=\"text\" width=\"40\" items=\"{Names}\"/>" +
|
|
"</list></panel>";
|
|
|
|
var panel = MarkupDocument.Build(xml, binding, Sprite);
|
|
var list = Assert.IsType<UiMarkupList>(panel.Children[0]);
|
|
Assert.NotNull(list.Columns);
|
|
}
|
|
|
|
[Fact]
|
|
public void LastColumn_MissingOrInvalidWidth_DoesNotThrow_StillAbsorbsRemainder()
|
|
{
|
|
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=\"40\" items=\"{Names}\"/>" +
|
|
" <column type=\"text\" items=\"{Names}\"/>" +
|
|
"</list></panel>";
|
|
|
|
var panel = MarkupDocument.Build(xml, binding, Sprite);
|
|
var list = Assert.IsType<UiMarkupList>(panel.Children[0]);
|
|
Assert.NotNull(list.Columns);
|
|
}
|
|
|
|
[Fact]
|
|
public void AutoWidthColumns_ShareRemainingWidthEqually_LastAbsorbsRoundingSlack()
|
|
{
|
|
// 3 columns, list width 100: col0 fixed 10, col1 auto ("*"), col2
|
|
// (last) auto too. Remaining = 90, split between 2 auto columns:
|
|
// floor(90/2)=45 each with no slack to prove the interesting case,
|
|
// so use 101 instead — remaining=91, share=floor(91/2)=45, and the
|
|
// LAST auto column must absorb the odd pixel (46), not col1.
|
|
var list = new UiMarkupList
|
|
{
|
|
Width = 101f, Height = 40f, RowHeight = 18f,
|
|
SelectedIndexSource = () => -1,
|
|
BackgroundColor = default, BorderColor = default,
|
|
Columns = new[]
|
|
{
|
|
UiMarkupListColumn.Text(10f, () => new[] { "" }, null),
|
|
UiMarkupListColumn.Icon(
|
|
0f, () => new uint[] { 20u }, id => (id, 16, 16), _ => { }, isAutoWidth: true),
|
|
UiMarkupListColumn.Icon(
|
|
0f, () => new uint[] { 21u }, id => (id, 16, 16), _ => { }, isAutoWidth: true),
|
|
},
|
|
};
|
|
var (renderer, ctx) = MakeContext(200f, 200f);
|
|
|
|
list.DrawSelfAndChildren(ctx);
|
|
|
|
var col1Quad = Assert.Single(renderer.DebugSpriteSegmentVerts, s => s.Texture == 20u);
|
|
var col2Quad = Assert.Single(renderer.DebugSpriteSegmentVerts, s => s.Texture == 21u);
|
|
// col1 (non-last auto): cell [10,55) — a 16x16 icon centered in a
|
|
// 45px-wide cell must start at/after x=10 and stay inside the cell.
|
|
Assert.True(col1Quad.Verts[0] >= 10f - 0.01f && col1Quad.Verts[8] <= 55f + 0.01f,
|
|
$"expected col1's icon inside [10,55), got x=[{col1Quad.Verts[0]},{col1Quad.Verts[8]}]");
|
|
// col2 (last auto): cell [55,101) — the rounding slack (46px, not
|
|
// 45px) landed here, so its full 16px icon must start at/after x=55.
|
|
Assert.True(col2Quad.Verts[0] >= 55f - 0.01f,
|
|
$"expected col2's icon at/after x=55 (10 + 45), got x={col2Quad.Verts[0]}");
|
|
}
|
|
|
|
[Fact]
|
|
public void DeclaredWidthsExceedingListWidth_ClampTheOverflowingColumn_LaterColumnsGetZero()
|
|
{
|
|
// col0 declares 80px in a 50px-wide list — clamps to 50. col1 (a
|
|
// fixed, non-last, non-auto column) and col2 (last) both get 0: col1
|
|
// never draws (cellW<=0 skip), col2 (last, always-auto) absorbs
|
|
// whatever's left, which is also 0.
|
|
var list = new UiMarkupList
|
|
{
|
|
Width = 50f, Height = 40f, RowHeight = 18f,
|
|
SelectedIndexSource = () => -1,
|
|
BackgroundColor = default, BorderColor = default,
|
|
Columns = new[]
|
|
{
|
|
UiMarkupListColumn.Icon(
|
|
80f, () => new uint[] { 7u }, id => (id, 16, 16), _ => { }),
|
|
UiMarkupListColumn.Icon(
|
|
20f, () => new uint[] { 8u }, id => (id, 16, 16), _ => { }),
|
|
UiMarkupListColumn.Icon(
|
|
20f, () => new uint[] { 9u }, id => (id, 16, 16), _ => { }),
|
|
},
|
|
};
|
|
var (renderer, ctx) = MakeContext(200f, 200f);
|
|
|
|
list.DrawSelfAndChildren(ctx);
|
|
|
|
Assert.Contains(renderer.DebugSpriteSegmentVerts, s => s.Texture == 7u);
|
|
Assert.DoesNotContain(renderer.DebugSpriteSegmentVerts, s => s.Texture == 8u);
|
|
Assert.DoesNotContain(renderer.DebugSpriteSegmentVerts, s => s.Texture == 9u);
|
|
var clampedQuad = Assert.Single(renderer.DebugSpriteSegmentVerts, s => s.Texture == 7u);
|
|
Assert.True(clampedQuad.Verts[8] - clampedQuad.Verts[0] <= 50f + 0.01f,
|
|
"expected col0's icon clamped inside the 50px list width");
|
|
}
|
|
|
|
[Fact]
|
|
public void ColumnGrids_NeverDrawARowSelectionBand()
|
|
{
|
|
// Fix round B item 10 (owner/oracle: VVS's own HudList grids —
|
|
// Monsters/Meta/Route/Items and every other <list><column> grid —
|
|
// have no row-selection highlight at all; only the per-cell click
|
|
// callbacks survive). DrawColumns must never paint SelectedColor,
|
|
// even when SelectedIndexSource reports a real in-range row.
|
|
var list = new UiMarkupList
|
|
{
|
|
Width = 100f, Height = 60f, RowHeight = 18f,
|
|
SelectedIndexSource = () => 1,
|
|
BackgroundColor = new Vector4(0f, 0f, 0f, 1f),
|
|
BorderColor = default,
|
|
SelectedColor = new Vector4(1f, 0f, 0f, 1f), // distinct, unmistakable
|
|
Columns = new[]
|
|
{
|
|
UiMarkupListColumn.Text(100f, () => new[] { "Row0", "Row1", "Row2" }, null),
|
|
},
|
|
};
|
|
var (renderer, ctx) = MakeContext(200f, 200f);
|
|
|
|
list.DrawSelfAndChildren(ctx);
|
|
|
|
bool anySelectedFill = renderer.DebugSpriteSegmentVerts.Any(s =>
|
|
{
|
|
if (s.Texture != 0u)
|
|
return false;
|
|
for (int q = 0; q + 48 <= s.Verts.Count; q += 48)
|
|
{
|
|
float r = s.Verts[q + 4], g = s.Verts[q + 5], b = s.Verts[q + 6], a = s.Verts[q + 7];
|
|
if (MathF.Abs(r - list.SelectedColor.X) < 0.01f
|
|
&& MathF.Abs(g - list.SelectedColor.Y) < 0.01f
|
|
&& MathF.Abs(b - list.SelectedColor.Z) < 0.01f
|
|
&& MathF.Abs(a - list.SelectedColor.W) < 0.01f)
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
});
|
|
Assert.False(anySelectedFill, "expected no SelectedColor fill in a column-based grid");
|
|
}
|
|
|
|
// ── 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;
|
|
}
|
|
|
|
// ── Fix round: try/finally around every per-cell clip (fix item 4) ──────
|
|
|
|
[Fact]
|
|
public void PerCellDrawException_StillBalancesTheClipStack()
|
|
{
|
|
// col0's icon resolver throws mid-draw (a hostile/buggy plugin
|
|
// resolver) — the per-cell PushClip for that cell must still be
|
|
// popped before the exception propagates, matching UiButton.cs
|
|
// /UiElement.cs's own try/finally clip pattern. Without it, the one
|
|
// leaked PushClip (plus UiElement's own outer ambient-clip pop
|
|
// consuming the wrong stack entry on the way out) leaves the
|
|
// context's clip stack permanently off by one.
|
|
var list = new UiMarkupList
|
|
{
|
|
Width = 60f, Height = 40f, RowHeight = 18f,
|
|
SelectedIndexSource = () => -1,
|
|
Columns = new[]
|
|
{
|
|
UiMarkupListColumn.Icon(
|
|
60f,
|
|
() => new uint[] { 99u },
|
|
_ => throw new InvalidOperationException("boom"),
|
|
_ => { }),
|
|
},
|
|
};
|
|
var (_, ctx) = MakeContext(200f, 200f);
|
|
int depthBefore = ctx.ClipStackDepth;
|
|
|
|
Assert.Throws<InvalidOperationException>(() => list.DrawSelfAndChildren(ctx));
|
|
|
|
Assert.Equal(depthBefore, ctx.ClipStackDepth);
|
|
}
|
|
|
|
[Fact]
|
|
public void Columns_IconThenCheck_EachCellDrawsInsideItsOwnColumnBounds()
|
|
{
|
|
// col0 = icon, width 20; col1 (last) = check, absorbs the remainder
|
|
// of a 100-wide list (80px). Fix round item 8: the check column is
|
|
// now SECOND (not first, as the original draft had it) — a check
|
|
// glyph in col0's [0,20) position would satisfy a ">=0 and <20"
|
|
// assertion trivially even if the column-offset math were completely
|
|
// broken (e.g. everything drawing at x=0); putting it second makes
|
|
// the assertion a REAL pin — it can only pass if the glyph actually
|
|
// moved to col1's [20,100) cell.
|
|
var list = new UiMarkupList
|
|
{
|
|
Width = 100f, Height = 40f, RowHeight = 18f,
|
|
SelectedIndexSource = () => -1,
|
|
BackgroundColor = default, BorderColor = default,
|
|
Columns = new[]
|
|
{
|
|
UiMarkupListColumn.Icon(
|
|
20f, () => new uint[] { 9u }, id => (id, 16, 16), _ => { }),
|
|
UiMarkupListColumn.Check(20f, () => new[] { true }, _ => { }),
|
|
},
|
|
};
|
|
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, UiCheckLamp.CheckedInner));
|
|
Assert.True(checkVertex[0] >= 20f - 0.01f && checkVertex[0] < 100f,
|
|
$"expected the check glyph inside [20,100), got x={checkVertex[0]}");
|
|
|
|
// The icon column drew a real (non-zero-width) sprite quad on its
|
|
// own resolved texture (9u), entirely inside col0's [0,20) cell.
|
|
var iconQuad = Assert.Single(renderer.DebugSpriteSegmentVerts, s => s.Texture == 9u);
|
|
Assert.True(iconQuad.Verts[0] < 20f,
|
|
$"expected the icon column's sprite inside [0,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, UiCheckLamp.CheckedInner));
|
|
Assert.Contains(quads, v => ColorMatches(v, UiCheckLamp.UncheckedInner));
|
|
// Row 0's checked glyph sits above row 1's unchecked glyph.
|
|
var checkedY = quads.First(v => ColorMatches(v, UiCheckLamp.CheckedInner))[1];
|
|
var uncheckedY = quads.First(v => ColorMatches(v, UiCheckLamp.UncheckedInner))[1];
|
|
Assert.True(uncheckedY > checkedY,
|
|
$"expected row 1's glyph ({uncheckedY}) below row 0's ({checkedY})");
|
|
}
|
|
|
|
// ── Fix round: short check columns draw the unchecked lamp past their
|
|
// own row count (fix item 7) ────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void CheckColumn_DrawsUncheckedLamp_ForRowsPastItsOwnRowCount()
|
|
{
|
|
// col0 (text) has 3 rows, driving the overall row count. col1 (last,
|
|
// check) only binds 1 row of its own — VVS materializes every cell
|
|
// regardless (docs/plugin-ui-markup.md already documented this), so
|
|
// rows 1 and 2 must STILL draw an unchecked lamp, not nothing.
|
|
var list = new UiMarkupList
|
|
{
|
|
Width = 60f, Height = 60f, RowHeight = 18f,
|
|
SelectedIndexSource = () => -1,
|
|
BackgroundColor = default, BorderColor = default,
|
|
Columns = new[]
|
|
{
|
|
UiMarkupListColumn.Text(20f, () => new[] { "a", "b", "c" }, null),
|
|
UiMarkupListColumn.Check(20f, () => new[] { true }, _ => { }),
|
|
},
|
|
};
|
|
var (renderer, ctx) = MakeContext(200f, 200f);
|
|
|
|
list.DrawSelfAndChildren(ctx);
|
|
|
|
var uncheckedQuads = renderer.DebugSpriteSegmentVerts
|
|
.SelectMany(s => Chunk(s.Verts))
|
|
.Where(v => ColorMatches(v, UiCheckLamp.UncheckedInner))
|
|
.ToList();
|
|
// One unchecked lamp for row 1 (bool value false — not this test's
|
|
// case) never applies here since col1's only row (0) is TRUE; rows 1
|
|
// and 2 have no bound value at all and must still draw UNCHECKED.
|
|
Assert.True(uncheckedQuads.Count >= 2,
|
|
$"expected an unchecked lamp for both row 1 and row 2 (past col1's own "
|
|
+ $"1-row data), got {uncheckedQuads.Count} unchecked lamp quad(s)");
|
|
}
|
|
|
|
// ── Fix round: shared UiCheckLamp, centered check glyph (fix item 8) ────
|
|
|
|
[Fact]
|
|
public void CheckColumn_GlyphIsHorizontallyCenteredInItsCell_LikeTheIconCellAlreadyIs()
|
|
{
|
|
// A single (last) check column absorbing a 50px-wide list: the old
|
|
// flush-left placement put the lamp's inner fill at x=4 regardless
|
|
// of cell width; centered (like DrawIconCell already centers its
|
|
// sprite) it must sit near the cell's midpoint instead.
|
|
var list = new UiMarkupList
|
|
{
|
|
Width = 50f, Height = 20f, RowHeight = 18f,
|
|
SelectedIndexSource = () => -1,
|
|
BackgroundColor = default, BorderColor = default,
|
|
Columns = new[]
|
|
{
|
|
UiMarkupListColumn.Check(50f, () => new[] { true }, _ => { }),
|
|
},
|
|
};
|
|
var (renderer, ctx) = MakeContext(200f, 200f);
|
|
|
|
list.DrawSelfAndChildren(ctx);
|
|
|
|
var innerVertex = renderer.DebugSpriteSegmentVerts
|
|
.SelectMany(s => Chunk(s.Verts))
|
|
.First(v => ColorMatches(v, UiCheckLamp.CheckedInner));
|
|
// Flush-left would put this at x=4; centered in a 50px cell (extent
|
|
// 48, lamp size 11) it lands at 1 + (48-11)/2 + 3 = 22.5.
|
|
Assert.True(innerVertex[0] > 15f,
|
|
$"expected the check glyph centered in its 50px cell (~22.5), got x={innerVertex[0]}");
|
|
}
|
|
|
|
[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);
|
|
var (_, ctx) = MakeContext(200f, 200f);
|
|
list.DrawSelfAndChildren(ctx); // frame order: draw, then handle input
|
|
|
|
// 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);
|
|
var (_, ctx) = MakeContext(200f, 200f);
|
|
list.DrawSelfAndChildren(ctx);
|
|
|
|
// 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);
|
|
var (_, ctx) = MakeContext(200f, 200f);
|
|
list.DrawSelfAndChildren(ctx);
|
|
|
|
// 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);
|
|
var (_, ctx) = MakeContext(200f, 200f);
|
|
list.DrawSelfAndChildren(ctx);
|
|
|
|
// 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);
|
|
}
|
|
|
|
// ── Fix round: per-column row-bound guard on callbacks (fix item 3) ─────
|
|
|
|
[Fact]
|
|
public void ClickInShortCheckColumn_PastItsOwnRowCount_FiresNothing()
|
|
{
|
|
// col0 = text, 3 rows (drives the overall row count). col1 (last) =
|
|
// check, only 1 row of its own. A click on row 2 (index 2) lands
|
|
// within the OVERALL row count (0..3) and inside col1's cell, but
|
|
// col1's own CheckSource only covers row 0 — the click must fire
|
|
// nothing, not throw and not fall back to some default value.
|
|
var fired = new List<int>();
|
|
var list = new UiMarkupList
|
|
{
|
|
Width = 60f, Height = 200f, RowHeight = 20f,
|
|
SelectedIndexSource = () => -1,
|
|
Columns = new[]
|
|
{
|
|
UiMarkupListColumn.Text(20f, () => new[] { "a", "b", "c" }, null),
|
|
UiMarkupListColumn.Check(20f, () => new[] { true }, row => fired.Add(row)),
|
|
},
|
|
};
|
|
var (_, ctx) = MakeContext(200f, 200f);
|
|
list.DrawSelfAndChildren(ctx);
|
|
|
|
// Row 2 (y = 40..60), x = 30 (inside col1's [20,60) cell).
|
|
list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 30, Data2 = 45 });
|
|
|
|
Assert.Empty(fired);
|
|
}
|
|
|
|
[Fact]
|
|
public void ClickInShortIconColumn_PastItsOwnRowCount_FiresNothing()
|
|
{
|
|
var fired = new List<int>();
|
|
var list = new UiMarkupList
|
|
{
|
|
Width = 60f, Height = 200f, RowHeight = 20f,
|
|
SelectedIndexSource = () => -1,
|
|
Columns = new[]
|
|
{
|
|
UiMarkupListColumn.Text(20f, () => new[] { "a", "b", "c" }, null),
|
|
UiMarkupListColumn.Icon(
|
|
20f, () => new uint[] { 9u }, id => (id, 16, 16), row => fired.Add(row)),
|
|
},
|
|
};
|
|
var (_, ctx) = MakeContext(200f, 200f);
|
|
list.DrawSelfAndChildren(ctx);
|
|
|
|
list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 30, Data2 = 45 });
|
|
|
|
Assert.Empty(fired);
|
|
}
|
|
|
|
[Fact]
|
|
public void ClickInShortTextColumn_WithOnclick_PastItsOwnRowCount_FiresNothing()
|
|
{
|
|
var fired = 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(
|
|
20f, () => new[] { "only-row-0" }, null, row => fired.Add(row)),
|
|
UiMarkupListColumn.Text(20f, () => new[] { "a", "b", "c" }, null),
|
|
},
|
|
};
|
|
var (_, ctx) = MakeContext(200f, 200f);
|
|
list.DrawSelfAndChildren(ctx);
|
|
|
|
// Row 2, x=10 (inside col0's onclick-bearing cell) — col0 only has
|
|
// row 0, so nothing fires; the click must NOT fall back to
|
|
// SelectionChanged either (that's a different column's affordance).
|
|
list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 10, Data2 = 45 });
|
|
|
|
Assert.Empty(fired);
|
|
Assert.Empty(selections);
|
|
}
|
|
|
|
// ── Fix round: kill per-frame/per-event allocations (fix item 6) ────────
|
|
|
|
[Fact]
|
|
public void ClickEvent_DoesNotReinvokeColumnSourceFunctions_ReusesDrawSideMaterialization()
|
|
{
|
|
// Each column's source Func increments its own counter every time
|
|
// it's invoked. One Draw call must invoke each exactly once
|
|
// (materializing the frame's rows); any number of SUBSEQUENT click
|
|
// events must invoke NONE of them again — the click path reuses the
|
|
// draw-side materialized rows/row-count/layout rather than
|
|
// re-invoking every source and re-laying-out on every event.
|
|
int textCalls = 0, checkCalls = 0, iconCalls = 0;
|
|
var list = new UiMarkupList
|
|
{
|
|
Width = 60f, Height = 200f, RowHeight = 20f,
|
|
SelectedIndexSource = () => -1,
|
|
Columns = new[]
|
|
{
|
|
UiMarkupListColumn.Text(20f, () => { textCalls++; return new[] { "a", "b", "c" }; }, null),
|
|
UiMarkupListColumn.Check(
|
|
20f,
|
|
() => { checkCalls++; return new[] { true, false, true }; },
|
|
_ => { }),
|
|
UiMarkupListColumn.Icon(
|
|
20f,
|
|
() => { iconCalls++; return new uint[] { 1u, 2u, 3u }; },
|
|
id => (id, 16, 16),
|
|
_ => { }),
|
|
},
|
|
};
|
|
var (_, ctx) = MakeContext(200f, 200f);
|
|
|
|
list.DrawSelfAndChildren(ctx);
|
|
Assert.Equal(1, textCalls);
|
|
Assert.Equal(1, checkCalls);
|
|
Assert.Equal(1, iconCalls);
|
|
|
|
for (int i = 0; i < 5; i++)
|
|
{
|
|
list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 10, Data2 = 25 });
|
|
list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 30, Data2 = 45 });
|
|
list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 50, Data2 = 5 });
|
|
}
|
|
|
|
Assert.Equal(1, textCalls);
|
|
Assert.Equal(1, checkCalls);
|
|
Assert.Equal(1, iconCalls);
|
|
}
|
|
|
|
[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.
|
|
///
|
|
/// <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()
|
|
{
|
|
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);
|
|
|
|
// 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><list items="..."></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><column></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) ───────────
|
|
|
|
private static UiDatFont MakeAsciiFont()
|
|
{
|
|
var glyphs = new Dictionary<char, FontCharDesc>();
|
|
foreach (char c in "ABCDEFGH")
|
|
glyphs[c] = new FontCharDesc { Unicode = c, Width = 6, Height = 8 };
|
|
return new UiDatFont(
|
|
fgTex: 1u, fgW: 32, fgH: 32,
|
|
bgTex: 0, bgW: 0, bgH: 0,
|
|
lineHeight: 16f, baselineOffset: 12f,
|
|
glyphs);
|
|
}
|
|
|
|
private sealed class MetaShapedBinding
|
|
{
|
|
public IReadOnlyList<uint> Delete { get; } = new uint[] { 101u, 102u };
|
|
public IReadOnlyList<uint> MoveUp { get; } = new uint[] { 201u, 202u };
|
|
public IReadOnlyList<uint> MoveDown { get; } = new uint[] { 301u, 302u };
|
|
public IReadOnlyList<string> State { get; } = new[] { "A", "B" };
|
|
public IReadOnlyList<string> Condition { get; } = new[] { "C", "D" };
|
|
public IReadOnlyList<string> Action { get; } = new[] { "E", "F" };
|
|
public int Selected { get; set; } = -1;
|
|
public List<int> Selections { get; } = new();
|
|
public Action<int> SelectRow => row => Selections.Add(row);
|
|
public List<int> DeleteClicks { get; } = new();
|
|
public Action<int> ClickDelete => row => DeleteClicks.Add(row);
|
|
public List<int> MoveUpClicks { get; } = new();
|
|
public Action<int> ClickMoveUp => row => MoveUpClicks.Add(row);
|
|
public List<int> MoveDownClicks { get; } = new();
|
|
public Action<int> ClickMoveDown => row => MoveDownClicks.Add(row);
|
|
public List<int> ActionClicks { get; } = new();
|
|
public Action<int> ClickAction => row => ActionClicks.Add(row);
|
|
}
|
|
|
|
/// <summary>
|
|
/// VTank's Meta tab (<c>refs/vtank/uTank2.ViewXML.mainView.xml</c>'s
|
|
/// <c>lstMetaRules</c>, ~line 292): 3 icon columns (16px each), a 150px
|
|
/// text column, then TWO trailing 0-width (auto) text columns — the real
|
|
/// VVS shape our <c>width="*"</c> convention exists to express. The list
|
|
/// width (703) is chosen so the two auto columns' shares DON'T divide
|
|
/// evenly (505/2 = 252 remainder 1), proving the last one absorbs the
|
|
/// rounding slack rather than both getting an identical share.
|
|
/// </summary>
|
|
[Fact]
|
|
public void EndToEnd_MetaShapedSixColumnList_AutoColumnsShareRemainder_EachKindRoutesCorrectly()
|
|
{
|
|
var resolver = new FakeIconResolver();
|
|
var binding = new MetaShapedBinding();
|
|
const string xml =
|
|
"<panel x=\"0\" y=\"0\" w=\"720\" h=\"140\">" +
|
|
"<list x=\"0\" y=\"0\" w=\"703\" h=\"60\" rowheight=\"18\" " +
|
|
"selected=\"{Selected}\" onchange=\"{SelectRow}\">" +
|
|
" <column type=\"icon\" width=\"16\" iconkind=\"item\" values=\"{Delete}\" onclick=\"{ClickDelete}\"/>" +
|
|
" <column type=\"icon\" width=\"16\" iconkind=\"item\" values=\"{MoveUp}\" onclick=\"{ClickMoveUp}\"/>" +
|
|
" <column type=\"icon\" width=\"16\" iconkind=\"item\" values=\"{MoveDown}\" onclick=\"{ClickMoveDown}\"/>" +
|
|
" <column type=\"text\" width=\"150\" items=\"{State}\"/>" +
|
|
" <column type=\"text\" width=\"*\" items=\"{Condition}\"/>" +
|
|
" <column type=\"text\" width=\"*\" items=\"{Action}\" onclick=\"{ClickAction}\"/>" +
|
|
"</list></panel>";
|
|
|
|
var panel = MarkupDocument.Build(xml, binding, Sprite, datFont: MakeAsciiFont(), icons: resolver);
|
|
var list = Assert.IsType<UiMarkupList>(panel.Children[0]);
|
|
Assert.Equal(6, list.Columns!.Count);
|
|
Assert.Equal(
|
|
new[]
|
|
{
|
|
UiMarkupListColumnKind.Icon, UiMarkupListColumnKind.Icon, UiMarkupListColumnKind.Icon,
|
|
UiMarkupListColumnKind.Text, UiMarkupListColumnKind.Text, UiMarkupListColumnKind.Text,
|
|
},
|
|
list.Columns.Select(c => c.Kind));
|
|
|
|
var (renderer, ctx) = MakeContext(800f, 200f);
|
|
list.DrawSelfAndChildren(ctx);
|
|
|
|
// Column ranges: [0,16) [16,32) [32,48) [48,198) [198,450) [450,703).
|
|
// The two auto text columns split 505 leftover px as 252/253 — the
|
|
// LAST one (Action) absorbs the extra pixel.
|
|
var deleteQuad = Assert.Single(renderer.DebugSpriteSegmentVerts, s => s.Texture == 101u);
|
|
Assert.True(deleteQuad.Verts[0] < 16f);
|
|
var moveUpQuad = Assert.Single(renderer.DebugSpriteSegmentVerts, s => s.Texture == 201u);
|
|
Assert.True(moveUpQuad.Verts[0] >= 16f && moveUpQuad.Verts[0] < 32f);
|
|
var moveDownQuad = Assert.Single(renderer.DebugSpriteSegmentVerts, s => s.Texture == 301u);
|
|
Assert.True(moveDownQuad.Verts[0] >= 32f && moveDownQuad.Verts[0] < 48f);
|
|
|
|
var glyphs = renderer.DebugSpriteSegmentVerts
|
|
.Where(s => s.Texture == 1u)
|
|
.SelectMany(s => Chunk(s.Verts))
|
|
.ToList();
|
|
Assert.Contains(glyphs, v => v[0] >= 48f && v[0] < 198f); // State
|
|
Assert.Contains(glyphs, v => v[0] >= 198f && v[0] < 450f); // Condition (auto, 252px)
|
|
Assert.Contains(glyphs, v => v[0] >= 450f && v[0] < 703f); // Action (auto, 253px — the slack)
|
|
|
|
// Click routing: each column kind reaches its own callback with the
|
|
// right row; the plain text column (State) still selects.
|
|
list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 8, Data2 = 0 }); // col0 row0
|
|
list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 24, Data2 = 18 }); // col1 row1
|
|
list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 40, Data2 = 0 }); // col2 row0
|
|
list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 60, Data2 = 18 }); // col3 (State) row1
|
|
list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 600, Data2 = 0 }); // col5 (Action) row0
|
|
|
|
Assert.Equal(new[] { 0 }, binding.DeleteClicks);
|
|
Assert.Equal(new[] { 1 }, binding.MoveUpClicks);
|
|
Assert.Equal(new[] { 0 }, binding.MoveDownClicks);
|
|
Assert.Equal(new[] { 1 }, binding.Selections); // col3 click selected row1
|
|
Assert.Equal(new[] { 0 }, binding.ActionClicks);
|
|
}
|
|
|
|
private sealed class MonsterShapedBinding
|
|
{
|
|
public IReadOnlyList<bool> Checks { get; } = new[] { true, false };
|
|
public IReadOnlyList<string> Texts { get; } = new[] { "A", "B" };
|
|
public IReadOnlyList<uint> Icons0 { get; } = new uint[] { 501u, 502u };
|
|
public IReadOnlyList<uint> Icons1 { get; } = new uint[] { 601u, 602u };
|
|
public int Selected { get; set; } = -1;
|
|
|
|
public List<(int Column, int Row)> Check0Fires { get; } = new();
|
|
public Action<int> OnCheck0 => row => Check0Fires.Add((0, row));
|
|
public Action<int> OnCheckRest => _ => { };
|
|
|
|
public List<(int Column, int Row)> Text0Fires { get; } = new();
|
|
public Action<int> OnText0 => row => Text0Fires.Add((0, row));
|
|
public Action<int> OnTextRest => _ => { };
|
|
|
|
public List<(int Column, int Row)> Icon0Fires { get; } = new();
|
|
public Action<int> OnIcon0 => row => Icon0Fires.Add((0, row));
|
|
public Action<int> OnIcon1 => _ => { };
|
|
}
|
|
|
|
/// <summary>
|
|
/// VTank's Monsters tab (<c>refs/vtank/uTank2.ViewXML.mainView.xml</c>'s
|
|
/// <c>lstMonsters</c>, ~lines 111-140): 14 check columns (16px each,
|
|
/// clFester..clCorrosion), 7 text columns (clMonName 120, clPriority 20,
|
|
/// clDamageType 56, clExVulnType 56, clWeaponToUse 80, clOffHand 80,
|
|
/// clPetDamageType 56), then 2 icon columns (clMoveUp/clMoveDown, 16px
|
|
/// each). Every declared width is a plain positive number — no
|
|
/// <c>"*"</c> anywhere, matching the real file — so the LAST column
|
|
/// (clMoveDown) still absorbs the remainder "as today", which this test
|
|
/// pins by sizing the list to the EXACT sum of every column's declared
|
|
/// width (724px): the last column ends up with exactly its own declared
|
|
/// 16px, not stretched or starved.
|
|
/// </summary>
|
|
[Fact]
|
|
public void EndToEnd_MonstersShapedTwentyThreeColumnList_EachKindRoutesCorrectly()
|
|
{
|
|
var resolver = new FakeIconResolver();
|
|
var binding = new MonsterShapedBinding();
|
|
|
|
var columns = new System.Text.StringBuilder();
|
|
for (int i = 0; i < 14; i++)
|
|
{
|
|
string onchange = i == 0 ? "{OnCheck0}" : "{OnCheckRest}";
|
|
columns.Append(
|
|
$"<column type=\"check\" width=\"16\" values=\"{{Checks}}\" onchange=\"{onchange}\"/>");
|
|
}
|
|
int[] textWidths = { 120, 20, 56, 56, 80, 80, 56 };
|
|
for (int i = 0; i < textWidths.Length; i++)
|
|
{
|
|
string onclick = i == 0 ? "{OnText0}" : "{OnTextRest}";
|
|
columns.Append(
|
|
$"<column type=\"text\" width=\"{textWidths[i]}\" items=\"{{Texts}}\" onclick=\"{onclick}\"/>");
|
|
}
|
|
columns.Append("<column type=\"icon\" width=\"16\" iconkind=\"item\" values=\"{Icons0}\" onclick=\"{OnIcon0}\"/>");
|
|
columns.Append("<column type=\"icon\" width=\"16\" iconkind=\"item\" values=\"{Icons1}\" onclick=\"{OnIcon1}\"/>");
|
|
|
|
string xml =
|
|
"<panel x=\"0\" y=\"0\" w=\"740\" h=\"140\">" +
|
|
"<list x=\"0\" y=\"0\" w=\"724\" h=\"60\" rowheight=\"18\" selected=\"{Selected}\">" +
|
|
columns +
|
|
"</list></panel>";
|
|
|
|
var panel = MarkupDocument.Build(xml, binding, Sprite, datFont: MakeAsciiFont(), icons: resolver);
|
|
var list = Assert.IsType<UiMarkupList>(panel.Children[0]);
|
|
Assert.Equal(23, list.Columns!.Count);
|
|
Assert.Equal(14, list.Columns.Count(c => c.Kind == UiMarkupListColumnKind.Check));
|
|
Assert.Equal(7, list.Columns.Count(c => c.Kind == UiMarkupListColumnKind.Text));
|
|
Assert.Equal(2, list.Columns.Count(c => c.Kind == UiMarkupListColumnKind.Icon));
|
|
|
|
var (renderer, ctx) = MakeContext(800f, 200f);
|
|
list.DrawSelfAndChildren(ctx);
|
|
|
|
// col0 (check): [0,16). col14 (first text, clMonName): [224,344).
|
|
// col21 (first icon, clMoveUp): [692,708). col22 (LAST, clMoveDown):
|
|
// [708,724) — exactly its own declared 16px since every width here
|
|
// is a plain fixedwidth number (no "*"), the "no auto column"
|
|
// no-op-through-ColumnLayout case.
|
|
var checkGlyph = renderer.DebugSpriteSegmentVerts
|
|
.SelectMany(s => Chunk(s.Verts))
|
|
.First(v => ColorMatches(v, UiCheckLamp.CheckedInner));
|
|
Assert.True(checkGlyph[0] < 16f);
|
|
|
|
var textGlyphs = renderer.DebugSpriteSegmentVerts
|
|
.Where(s => s.Texture == 1u)
|
|
.SelectMany(s => Chunk(s.Verts))
|
|
.ToList();
|
|
Assert.Contains(textGlyphs, v => v[0] >= 224f && v[0] < 344f);
|
|
|
|
var icon0Quad = Assert.Single(renderer.DebugSpriteSegmentVerts, s => s.Texture == 501u);
|
|
// Both icon columns share texture 501u/502u pairs per row, but only
|
|
// ONE quad per texture id total across both columns (each row binds
|
|
// the SAME Icons source to both columns) — assert its x falls in
|
|
// EITHER icon column's range, proving the icon kind draws there and
|
|
// nowhere else.
|
|
Assert.True(icon0Quad.Verts[0] >= 692f && icon0Quad.Verts[0] < 724f);
|
|
|
|
// Click routing: one representative column per kind reaches its own
|
|
// callback with the right row; the OTHER same-kind columns (sharing
|
|
// a dummy callback) stay untouched.
|
|
list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 8, Data2 = 18 }); // col0 (check) row1
|
|
list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 260, Data2 = 0 }); // col14 (text) row0
|
|
list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 700, Data2 = 18 }); // col21 (icon) row1
|
|
|
|
Assert.Equal(new[] { (0, 1) }, binding.Check0Fires);
|
|
Assert.Equal(new[] { (0, 0) }, binding.Text0Fires);
|
|
Assert.Equal(new[] { (0, 1) }, binding.Icon0Fires);
|
|
}
|
|
}
|