diff --git a/docs/plugin-ui-markup.md b/docs/plugin-ui-markup.md
index 7d0dd3f16..0c17e932e 100644
--- a/docs/plugin-ui-markup.md
+++ b/docs/plugin-ui-markup.md
@@ -313,13 +313,14 @@ The two forms cannot be mixed on one list.
selected="{SelectedMonster}" onchange="{SelectMonster}">
-
+
+
```
| Attribute | Column type | Required | Binding |
|---|---|---|---|
-| `type` | all | yes | `text`, `check`, or `icon` |
+| `type` | all | yes | `text`, `check`, `icon`, or `button` |
| `width` | all | see below | pixels, or `*` for auto |
| `items` | `text` | yes | `IReadOnlyList` |
| `colors` | `text` | no | `IReadOnlyList` or ``, `0xRRGGBB` per row |
@@ -329,6 +330,9 @@ The two forms cannot be mixed on one list.
| `values` | `icon` | yes | `IReadOnlyList` or `` |
| `iconkind` | `icon` | no | `did` (default), `spell`, or `item` |
| `onclick` | `icon` | yes | `Action` with the row index |
+| `items` | `button` | yes | `IReadOnlyList`, each row's caption; an empty caption draws no button |
+| `onclick` | `button` | yes | `Action` with the row index; the click does not select the row |
+| `buttonw`, `buttonh` | `button` | no | pixels; the button's size, centred in its cell (default: the whole cell) |
Widths: `*` shares the remaining width equally with every other `*` column.
The last column is always automatic; it takes the whole remainder unless
@@ -336,12 +340,34 @@ another column is also `*`. Every other column needs a positive `width` or
`*`. Columns that would overflow the list are clamped and later ones draw
nothing. Remainder pixels go to the last sharing column.
-Rows: the row count is the longest bound column. A `text` or `icon` cell past
-its own column's data draws nothing; a `check` cell draws unchecked. A click
+Rows: the row count is the longest bound column. A `text`, `icon` or `button`
+cell past its own column's data draws nothing; a `check` cell draws unchecked. A click
in a `text` column without `onclick` selects the row and fires the list's
`onchange`; any other click fires the column's own callback and leaves the
selection alone. A click past a column's own data does nothing.
+A `button` cell is drawn as a button one pixel inside its cell, or at
+`buttonw` by `buttonh` centred in the cell (never larger than the cell; set
+either alone to size only that side), with its caption centred in the list's
+text colour and cut off at the button's edges. Without a theme its face and
+border are those of an unthemed ``. On a themed window the face is the
+theme's background colour (the list itself is drawn in the field colour that
+a themed `` uses) and the border is the theme's border colour. Unlike
+a ``, it lightens while hovered and darkens while pressed. A disabled
+list draws its buttons' face, border and caption faded and fires none of them.
+
+It fires like a ``, when the left button comes up over the button it
+went down on, so a press dragged off the button cancels it; a click beside a
+smaller button does nothing. Because `onclick` gets a row index, a press is
+also cancelled if, before the release, the list scrolls, its row count changes,
+or the pressed button's own caption changes or becomes empty. Changes in the
+row's other columns, such as a distance that updates every frame, do not
+cancel it, so a plugin that reorders rows with the same count should change
+the caption too, or the click fires for whatever item is at the pressed row.
+A cell with an empty caption is not a button and ignores clicks.
+On a host older than button columns, a `type="button"` column fails the build
+of its window as an unknown type.
+
There is no header row; place `` elements above the list. A `check`
cell draws the same lamp glyph as ``. When rows overflow the list's
height it reserves a 16-pixel scrollbar at its right edge, drawn with the
diff --git a/src/AcDream.App/UI/MarkupDocument.cs b/src/AcDream.App/UI/MarkupDocument.cs
index 1ebc1fb41..291e00408 100644
--- a/src/AcDream.App/UI/MarkupDocument.cs
+++ b/src/AcDream.App/UI/MarkupDocument.cs
@@ -1009,12 +1009,29 @@ private static UiMarkupListColumn BuildListColumn(
: null;
return UiMarkupListColumn.Icon(width, valuesSource, resolve, onClick, isAutoWidth);
}
+ case "button":
+ {
+ var captionSource = BindStringList(
+ (string?)columnEl.Attribute("items"), binding, ColumnContext(index, "button", "items"));
+ var onClick = BindRequiredIntAction(
+ (string?)columnEl.Attribute("onclick"), binding, ColumnContext(index, "button", "onclick"));
+ return UiMarkupListColumn.Button(width, captionSource, onClick, isAutoWidth,
+ ButtonSize(columnEl, "buttonw", index), ButtonSize(columnEl, "buttonh", index));
+ }
default:
throw new FormatException(
- $"column[{index}] has unknown type=\"{type}\" (expected text, check, or icon)");
+ $"column[{index}] has unknown type=\"{type}\" (expected text, check, icon, or button)");
}
}
+ private static float ButtonSize(XElement columnEl, string attribute, int index)
+ {
+ float size = FOr(columnEl, attribute, 0f);
+ if (size < 0f)
+ throw new FormatException($"{ColumnContext(index, "button", attribute)} must not be negative");
+ return size;
+ }
+
private static string ColumnContext(int index, string type, string attribute) =>
$"column[{index}] type=\"{type}\" {attribute}";
diff --git a/src/AcDream.App/UI/PluginMarkupTheme.cs b/src/AcDream.App/UI/PluginMarkupTheme.cs
index 41fbc226d..8e42bb0d4 100644
--- a/src/AcDream.App/UI/PluginMarkupTheme.cs
+++ b/src/AcDream.App/UI/PluginMarkupTheme.cs
@@ -91,10 +91,13 @@ void Bind(Func get, Action set)
case UiMarkupList list:
var lb = list.BackgroundColor; var le = list.BorderColor; var lt = list.TextColor;
var ls = list.SelectedColor; var band = list.SelectionBandEnabled;
+ var lbf = list.ButtonColor; var lbe = list.ButtonBorderColor;
panel.AddThemeAction(p => {
list.ThemePalette = p; list.BackgroundColor = p?.Field ?? lb;
list.BorderColor = p?.Border ?? le; list.TextColor = p?.Text ?? lt;
list.SelectedColor = p?.Selected ?? ls; list.SelectionBandEnabled = p is not null || band;
+ // The list's own ground is the field colour, so its buttons take the window's.
+ list.ButtonColor = p?.Background ?? lbf; list.ButtonBorderColor = p?.Border ?? lbe;
});
break;
case UiScrollbar scroll:
diff --git a/src/AcDream.App/UI/UiControlState.cs b/src/AcDream.App/UI/UiControlState.cs
new file mode 100644
index 000000000..7993df7d1
--- /dev/null
+++ b/src/AcDream.App/UI/UiControlState.cs
@@ -0,0 +1,4 @@
+namespace AcDream.App.UI;
+
+/// The look a control takes from the pointer and its own enabled flag.
+internal enum UiControlState { Normal, Hovered, Pressed, Disabled }
diff --git a/src/AcDream.App/UI/UiMarkupList.cs b/src/AcDream.App/UI/UiMarkupList.cs
index 91daf704b..0caab39b1 100644
--- a/src/AcDream.App/UI/UiMarkupList.cs
+++ b/src/AcDream.App/UI/UiMarkupList.cs
@@ -29,6 +29,8 @@ public IReadOnlyList? Columns
_scratchIsAuto = new bool[count];
_scratchFixedWidth = new float[count];
_cachedRowCount = 0;
+ _hasButtons = value?.Any(static c => c.Kind == UiMarkupListColumnKind.Button) ?? false;
+ ClearButtonPointer();
}
}
public UiDatFont? DatFont { get; set; }
@@ -39,6 +41,10 @@ public IReadOnlyList? Columns
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);
+ /// The face of a button column's cells; Classic matches a markup <button> .
+ public Vector4 ButtonColor { get; set; } = new(0.1f, 0.1f, 0.15f, 0.8f);
+ public Vector4 ButtonBorderColor { get; set; } = new(0.45f, 0.45f, 0.55f, 1f);
+
public bool SelectionBandEnabled { get; set; }
public Func? SpriteResolve { get; set; }
@@ -68,8 +74,28 @@ public IReadOnlyList? Columns
private float[] _scratchFixedWidth = Array.Empty();
private int _cachedRowCount;
+ // The button cell under the pointer and the one the left button went
+ // down on, as (row, column); -1 for none.
+ private bool _hasButtons;
+ private int _hoverRow = -1, _hoverColumn = -1;
+ private int _pressRow = -1, _pressColumn = -1;
+
+ // What the list showed when the button went down: a release fires by row
+ // index, so the press is dropped if the list scrolls, its row count
+ // changes, or the pressed button's caption changes before then.
+ private int _pressTopRow, _pressRowCount;
+ private string _pressCaption = string.Empty;
+
+ // The pointer's last position in list space, so the hovered button can be
+ // found again when the rows move under a pointer that did not.
+ private bool _pointerKnown;
+ private float _pointerX, _pointerY;
+
public override bool HandlesClick => true;
+ /// Button cells show hover, so a list with any takes moves while hovered.
+ public override bool ReceivesHoverMouseMove => _hasButtons;
+
protected override void OnDraw(UiRenderContext context)
{
if (Columns is { Count: > 0 } columns)
@@ -239,28 +265,7 @@ private void ComputeColumnLayout(IReadOnlyList columns, floa
private void DrawColumns(UiRenderContext context, IReadOnlyList columns)
{
- int rowCount = 0;
- for (int c = 0; c < columns.Count; c++)
- {
- var col = columns[c];
- switch (col.Kind)
- {
- case UiMarkupListColumnKind.Text:
- _cachedTextRows[c] = col.TextSource!();
- _cachedColorRows[c] = col.ColorsSource?.Invoke();
- rowCount = Math.Max(rowCount, _cachedTextRows[c]!.Count);
- break;
- case UiMarkupListColumnKind.Check:
- _cachedCheckRows[c] = col.CheckSource!();
- rowCount = Math.Max(rowCount, _cachedCheckRows[c]!.Count);
- break;
- case UiMarkupListColumnKind.Icon:
- _cachedIconRows[c] = col.IconValuesSource!();
- rowCount = Math.Max(rowCount, _cachedIconRows[c]!.Count);
- break;
- }
- }
- _cachedRowCount = rowCount;
+ int rowCount = RefreshRows(columns);
int visibleRows = VisibleRows;
bool showScrollbar = rowCount > visibleRows;
@@ -280,6 +285,11 @@ private void DrawColumns(UiRenderContext context, IReadOnlyListReads every column's rows from its binding; returns the row count, the longest column.
+ private int RefreshRows(IReadOnlyList columns)
+ {
+ int rowCount = 0;
+ for (int c = 0; c < columns.Count; c++)
+ {
+ var col = columns[c];
+ switch (col.Kind)
+ {
+ case UiMarkupListColumnKind.Text:
+ _cachedTextRows[c] = col.TextSource!();
+ _cachedColorRows[c] = col.ColorsSource?.Invoke();
+ rowCount = Math.Max(rowCount, _cachedTextRows[c]!.Count);
+ break;
+ case UiMarkupListColumnKind.Button:
+ _cachedTextRows[c] = col.TextSource!();
+ rowCount = Math.Max(rowCount, _cachedTextRows[c]!.Count);
+ break;
+ case UiMarkupListColumnKind.Check:
+ _cachedCheckRows[c] = col.CheckSource!();
+ rowCount = Math.Max(rowCount, _cachedCheckRows[c]!.Count);
+ break;
+ case UiMarkupListColumnKind.Icon:
+ _cachedIconRows[c] = col.IconValuesSource!();
+ rowCount = Math.Max(rowCount, _cachedIconRows[c]!.Count);
+ break;
+ }
+ }
+ _cachedRowCount = rowCount;
+ return rowCount;
+ }
+
private void DrawTextCell(
UiRenderContext context, IReadOnlyList? texts, IReadOnlyList? colors,
int index, float cellX, float y)
@@ -376,6 +421,225 @@ private void DrawIconCell(
0f, 0f, 1f, 1f, Vector4.One);
}
+ ///
+ /// A button cell inset by a pixel all round, so neighbouring buttons and
+ /// rows do not touch, with its caption centred in the list's text colour
+ /// and cut at the button's edges. A disabled list draws face, border and
+ /// caption at the alpha a disabled toggle's caption takes.
+ ///
+ private void DrawButtonCell(UiRenderContext context, int column, int index, float cellX, float cellW, float y)
+ {
+ if (!HasButtonCaption(column, index, out string caption))
+ return;
+ (float x, float top, float w, float h) = ButtonRect(_columns![column], cellX, cellW, y);
+ UiControlState state = ButtonState(index, column);
+ Vector4 face = state switch
+ {
+ UiControlState.Hovered => Vector4.Lerp(ButtonColor, ButtonBorderColor, 0.3f) with { W = ButtonColor.W },
+ UiControlState.Pressed => Vector4.Lerp(ButtonColor, Vector4.Zero, 0.25f) with { W = ButtonColor.W },
+ _ => ButtonColor,
+ };
+ Vector4 border = ButtonBorderColor;
+ Vector4 textColor = TextColor;
+ if (state == UiControlState.Disabled)
+ {
+ face = Dimmed(face);
+ border = Dimmed(border);
+ textColor = Dimmed(textColor);
+ }
+ context.DrawFill(x, top, w, h, face);
+ context.DrawRectOutline(x, top, w, h, border, 1f);
+
+ context.PushClip(x, top, w, h);
+ try
+ {
+ if (DatFont is { } font)
+ {
+ context.DrawStringDat(font, caption,
+ x + (w - font.MeasureWidth(caption)) * 0.5f,
+ top + (h - font.LineHeight) * 0.5f,
+ textColor, ThemePalette is null);
+ }
+ else if (context.DefaultFont is { } fallback)
+ {
+ context.DrawString(caption,
+ x + (w - fallback.MeasureWidth(caption)) * 0.5f,
+ top + (h - fallback.LineHeight) * 0.5f,
+ textColor);
+ }
+ }
+ finally
+ {
+ context.PopClip();
+ }
+ }
+
+ private static Vector4 Dimmed(Vector4 color) => color with { W = color.W * UiMarkupToggle.DisabledAlpha };
+
+ ///
+ /// Where a button sits in its cell: its column's size, centred, never past
+ /// the cell's one-pixel inset; a size of 0 fills the inset cell.
+ ///
+ private (float x, float y, float w, float h) ButtonRect(
+ UiMarkupListColumn column, float cellX, float cellW, float rowY)
+ {
+ float maxW = MathF.Max(0f, cellW - 2f), maxH = MathF.Max(0f, RowHeight - 2f);
+ float w = column.ButtonWidth > 0f ? MathF.Min(column.ButtonWidth, maxW) : maxW;
+ float h = column.ButtonHeight > 0f ? MathF.Min(column.ButtonHeight, maxH) : maxH;
+ return (MathF.Floor(cellX + (cellW - w) * 0.5f), MathF.Floor(rowY + (RowHeight - h) * 0.5f), w, h);
+ }
+
+ private bool HasButtonCaption(int column, int index, out string caption)
+ {
+ caption = string.Empty;
+ if (_columns is not { } columns || column < 0 || column >= columns.Count
+ || columns[column].Kind != UiMarkupListColumnKind.Button)
+ return false;
+ if (_cachedTextRows[column] is not { } captions || index < 0 || index >= captions.Count)
+ return false;
+ caption = captions[index] ?? string.Empty;
+ return caption.Length > 0;
+ }
+
+ ///
+ /// The look of one button cell: pressed only while the pointer is still
+ /// over the button it went down on, and no other button hovers while one
+ /// is held.
+ ///
+ private UiControlState ButtonState(int index, int column)
+ {
+ if (!Enabled)
+ return UiControlState.Disabled;
+ bool over = _hoverRow == index && _hoverColumn == column;
+ if (_pressRow >= 0)
+ return _pressRow == index && _pressColumn == column && over
+ ? UiControlState.Pressed
+ : UiControlState.Normal;
+ return over ? UiControlState.Hovered : UiControlState.Normal;
+ }
+
+ internal UiControlState ButtonStateForTest(int index, int column) =>
+ HasButtonCaption(column, index, out _) ? ButtonState(index, column) : UiControlState.Normal;
+
+ /// The button cell at a point inside the list, or (-1, -1) where there is none.
+ private (int index, int column) ButtonCellAt(float localX, float localY, float contentWidth, int visibleRows)
+ {
+ if (_columns is not { } columns || localX < 0f || localX >= contentWidth)
+ return (-1, -1);
+ int row = (int)MathF.Floor(localY / MathF.Max(1f, RowHeight));
+ int index = _topRow + row;
+ if (row < 0 || row >= visibleRows)
+ return (-1, -1);
+ for (int c = 0; c < columns.Count; c++)
+ {
+ (float cellX, float cellW) = _cachedLayout[c];
+ if (localX < cellX || localX >= cellX + cellW)
+ continue;
+ if (!HasButtonCaption(c, index, out _))
+ return (-1, -1);
+ (float bx, float by, float bw, float bh) = ButtonRect(columns[c], cellX, cellW, row * RowHeight);
+ bool onButton = localX >= bx && localX < bx + bw && localY >= by && localY < by + bh;
+ return onButton ? (index, c) : (-1, -1);
+ }
+ return (-1, -1);
+ }
+
+ private void ClearButtonPointer()
+ {
+ _hoverRow = _hoverColumn = -1;
+ _pressRow = _pressColumn = -1;
+ _pointerKnown = false;
+ }
+
+ protected override void OnEnabledChanged()
+ {
+ if (!Enabled)
+ _pressRow = _pressColumn = -1;
+ }
+
+ private void TrackPointer(in UiEvent e)
+ {
+ _pointerKnown = true;
+ (_pointerX, _pointerY) = (e.Data1, e.Data2);
+ }
+
+ /// Finds the hovered button again from the last pointer position, after the rows moved.
+ private void RefreshHover(float contentWidth, int visibleRows) =>
+ (_hoverRow, _hoverColumn) = _pointerKnown && !_draggingThumb
+ ? ButtonCellAt(_pointerX, _pointerY, contentWidth, visibleRows)
+ : (-1, -1);
+
+ private void RememberPressedRow()
+ {
+ _pressTopRow = _topRow;
+ _pressRowCount = _cachedRowCount;
+ HasButtonCaption(_pressColumn, _pressRow, out _pressCaption);
+ }
+
+ ///
+ /// Drops the press when the row under it may no longer be the item that was
+ /// pressed: the list scrolled, its row count changed, or the pressed
+ /// button's caption changed or went empty. Other columns are left out on
+ /// purpose: a live column such as a distance changes every frame and would
+ /// make the buttons beside it unreliable.
+ ///
+ private void CancelPressIfRowsMoved()
+ {
+ if (_pressRow < 0)
+ return;
+ bool moved = _topRow != _pressTopRow
+ || _cachedRowCount != _pressRowCount
+ || !HasButtonCaption(_pressColumn, _pressRow, out string caption)
+ || !string.Equals(caption, _pressCaption, StringComparison.Ordinal);
+ if (moved)
+ _pressRow = _pressColumn = -1;
+ }
+
+ ///
+ /// Follows the pointer over button cells and fires a button when the left
+ /// button comes up over the one it went down on. Only a release that fires
+ /// a button is handled here; everything else carries on to the list.
+ ///
+ private bool TrackButtonPointer(
+ in UiEvent e, IReadOnlyList columns, float contentWidth, int visibleRows)
+ {
+ switch (e.Type)
+ {
+ case UiEventType.HoverEnter:
+ case UiEventType.MouseMove:
+ case UiEventType.MouseDown:
+ TrackPointer(e);
+ RefreshHover(contentWidth, visibleRows);
+ return false;
+ case UiEventType.HoverLeave:
+ _pointerKnown = false;
+ _hoverRow = _hoverColumn = -1;
+ return false;
+ case UiEventType.CaptureChanged:
+ _pressRow = _pressColumn = -1;
+ return false;
+ case UiEventType.MouseUp:
+ {
+ TrackPointer(e);
+ if (_pressRow >= 0)
+ {
+ // Read the rows as they are now, not as last drawn.
+ RefreshRows(columns);
+ CancelPressIfRowsMoved();
+ }
+ (int index, int column) = (_pressRow, _pressColumn);
+ _pressRow = _pressColumn = -1;
+ (_hoverRow, _hoverColumn) = ButtonCellAt(e.Data1, e.Data2, contentWidth, visibleRows);
+ if (index < 0 || !Enabled || (_hoverRow, _hoverColumn) != (index, column))
+ return false;
+ columns[column].ButtonClicked?.Invoke(index);
+ return true;
+ }
+ default:
+ return false;
+ }
+ }
+
private bool OnEventColumns(in UiEvent e, IReadOnlyList columns)
{
int rowCount = _cachedRowCount;
@@ -383,6 +647,8 @@ private bool OnEventColumns(in UiEvent e, IReadOnlyList colu
float contentWidth = rowCount > visibleRows
? MathF.Max(0f, Width - ScrollbarWidth)
: Width;
+ if (_hasButtons && TrackButtonPointer(e, columns, contentWidth, visibleRows))
+ return true;
if (TryHandleScrollbarEvent(e, contentWidth, rowCount, visibleRows))
return true;
@@ -390,6 +656,14 @@ private bool OnEventColumns(in UiEvent e, IReadOnlyList colu
{
_topRow -= Math.Sign(e.Data0);
ClampTop(rowCount, visibleRows);
+ if (_hasButtons)
+ {
+ // The rows moved under the pointer: a press no longer names
+ // the row it went down on, and the hovered button may differ.
+ CancelPressIfRowsMoved();
+ TrackPointer(e);
+ RefreshHover(contentWidth, visibleRows);
+ }
return true;
}
if (e.Type != UiEventType.MouseDown || !Enabled)
@@ -427,6 +701,14 @@ private bool OnEventColumns(in UiEvent e, IReadOnlyList colu
if (index < (_cachedIconRows[c]?.Count ?? 0))
columns[c].IconClicked?.Invoke(index);
break;
+ case UiMarkupListColumnKind.Button:
+ // Fires on release over the same button; see TrackButtonPointer.
+ if ((_hoverRow, _hoverColumn) == (index, c))
+ {
+ (_pressRow, _pressColumn) = (index, c);
+ RememberPressedRow();
+ }
+ break;
}
break;
}
diff --git a/src/AcDream.App/UI/UiMarkupListColumn.cs b/src/AcDream.App/UI/UiMarkupListColumn.cs
index 3fedea06c..aac48351a 100644
--- a/src/AcDream.App/UI/UiMarkupListColumn.cs
+++ b/src/AcDream.App/UI/UiMarkupListColumn.cs
@@ -5,6 +5,7 @@ public enum UiMarkupListColumnKind
Text,
Check,
Icon,
+ Button,
}
public sealed class UiMarkupListColumn
@@ -24,6 +25,12 @@ public sealed class UiMarkupListColumn
public Func? IconResolve { get; internal init; }
public Action? IconClicked { get; internal init; }
+ public Action? ButtonClicked { get; internal init; }
+
+ /// A button's size inside its cell; 0 fills the cell. Never larger than the cell.
+ public float ButtonWidth { get; internal init; }
+ public float ButtonHeight { get; internal init; }
+
public static UiMarkupListColumn Text(
float width,
Func> textSource,
@@ -67,9 +74,33 @@ public static UiMarkupListColumn Icon(
IconClicked = onClick,
};
+ ///
+ /// A column of buttons: each row's caption is drawn on a button face, an
+ /// empty caption draws no button, and a click on one fires
+ /// with the row index without selecting the row.
+ /// A button by
+ /// is centred in its cell; 0 fills the cell that way.
+ ///
+ public static UiMarkupListColumn Button(
+ float width,
+ Func> captionSource,
+ Action onClick,
+ bool isAutoWidth = false,
+ float buttonWidth = 0f,
+ float buttonHeight = 0f) => new()
+ {
+ Kind = UiMarkupListColumnKind.Button,
+ Width = width,
+ IsAutoWidth = isAutoWidth,
+ TextSource = captionSource,
+ ButtonClicked = onClick,
+ ButtonWidth = buttonWidth,
+ ButtonHeight = buttonHeight,
+ };
+
public int RowCount() => Kind switch
{
- UiMarkupListColumnKind.Text => TextSource?.Invoke().Count ?? 0,
+ UiMarkupListColumnKind.Text or UiMarkupListColumnKind.Button => TextSource?.Invoke().Count ?? 0,
UiMarkupListColumnKind.Check => CheckSource?.Invoke().Count ?? 0,
UiMarkupListColumnKind.Icon => IconValuesSource?.Invoke().Count ?? 0,
_ => 0,
diff --git a/src/AcDream.App/UI/UiMarkupToggle.cs b/src/AcDream.App/UI/UiMarkupToggle.cs
index fbc84b986..fc63e9190 100644
--- a/src/AcDream.App/UI/UiMarkupToggle.cs
+++ b/src/AcDream.App/UI/UiMarkupToggle.cs
@@ -19,6 +19,9 @@ public sealed class UiMarkupToggle : UiElement
public bool IsChecked => CheckedSource?.Invoke() ?? false;
+ /// The alpha a disabled toggle's caption draws at; a disabled list button dims by it too.
+ internal const float DisabledAlpha = 0.42f;
+
public override bool HandlesClick => true;
private readonly UiKeyboardActivation _keyboardActivation = new();
@@ -46,7 +49,7 @@ protected override void OnDraw(UiRenderContext ctx)
Vector4 textColor = TextColorSource?.Invoke() ?? TextColor;
Vector4 color = Enabled
? textColor
- : new Vector4(textColor.X, textColor.Y, textColor.Z, 0.42f);
+ : textColor with { W = DisabledAlpha };
float y = DatFont is { } font
? (Height - font.LineHeight) * 0.5f
: 1f;
diff --git a/tests/AcDream.App.Tests/UI/ListButtonColumnTests.cs b/tests/AcDream.App.Tests/UI/ListButtonColumnTests.cs
new file mode 100644
index 000000000..e1dcb14c6
--- /dev/null
+++ b/tests/AcDream.App.Tests/UI/ListButtonColumnTests.cs
@@ -0,0 +1,621 @@
+using System.Numerics;
+using AcDream.App.Rendering;
+using AcDream.App.Rendering.Gpu;
+using AcDream.App.Tests.Rendering.Gpu;
+using AcDream.App.UI;
+using Xunit;
+
+namespace AcDream.App.Tests.UI;
+
+public sealed class ListButtonColumnTests
+{
+ private static (uint, int, int) Sprite(uint id) => (1u, 32, 32);
+
+ private sealed class FoundBinding
+ {
+ public IReadOnlyList Names { get; } = new[] { "Drudge", "Rat", "Mosswart" };
+ public IReadOnlyList Captions { get; } = new[] { "Select", "", "Select" };
+ public int Selected { get; set; } = -1;
+ public List Acted { get; } = new();
+ public Action Act => row => Acted.Add(row);
+ }
+
+ private static string FoundXml(string buttonColumn) =>
+ "" +
+ "" +
+ " " +
+ buttonColumn +
+ "
";
+
+ [Fact]
+ public void ButtonColumn_BindsItsCaptionsAndOnclick()
+ {
+ var binding = new FoundBinding();
+ var panel = MarkupDocument.Build(
+ FoundXml(""),
+ binding, Sprite);
+ var list = Assert.IsType(panel.Children[0]);
+
+ var column = list.Columns![1];
+ Assert.Equal(UiMarkupListColumnKind.Button, column.Kind);
+ Assert.Equal(binding.Captions, column.TextSource!());
+ Assert.Equal(3, column.RowCount());
+ column.ButtonClicked!(2);
+ Assert.Equal(new[] { 2 }, binding.Acted);
+ }
+
+ [Fact]
+ public void ButtonColumn_MissingOnclick_ThrowsAtBuild()
+ {
+ var ex = Assert.Throws(() => MarkupDocument.Build(
+ FoundXml(""),
+ new FoundBinding(), Sprite));
+ Assert.Contains("column[1] type=\"button\" onclick", ex.Message);
+ }
+
+ [Fact]
+ public void ButtonColumn_MissingItems_ThrowsAtBuild()
+ {
+ var ex = Assert.Throws(() => MarkupDocument.Build(
+ FoundXml(""),
+ new FoundBinding(), Sprite));
+ Assert.Contains("column[1] type=\"button\" items", ex.Message);
+ }
+
+ [Fact]
+ public void UnknownColumnType_NamesButtonAmongTheExpectedTypes()
+ {
+ var ex = Assert.Throws(() => MarkupDocument.Build(
+ FoundXml(""),
+ new FoundBinding(), Sprite));
+ Assert.Contains("button", ex.Message);
+ }
+
+ private static UiMarkupList MakeList(
+ List acted, List selections, string[]? captions = null)
+ {
+ captions ??= new[] { "Go", "", "Go" };
+ var list = new UiMarkupList
+ {
+ Width = 100f, Height = 60f, RowHeight = 20f,
+ SelectedIndexSource = () => -1,
+ SelectionChanged = row => selections.Add(row),
+ BackgroundColor = default, BorderColor = default,
+ Columns = new[]
+ {
+ UiMarkupListColumn.Text(50f, () => new[] { "a", "b", "c" }, null),
+ UiMarkupListColumn.Button(50f, () => captions, row => acted.Add(row)),
+ },
+ };
+ var (_, ctx) = MakeContext(200f, 200f);
+ list.DrawSelfAndChildren(ctx);
+ return list;
+ }
+
+ [Fact]
+ public void PressAndReleaseOnAButton_FiresOnclickWithTheRow_WithoutSelecting()
+ {
+ var acted = new List();
+ var selections = new List();
+ var list = MakeList(acted, selections);
+
+ // Row 2 (y = 40..60), button column (x = 50..100).
+ list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 70, Data2 = 45 });
+ Assert.Empty(acted);
+ list.OnEvent(new UiEvent { Type = UiEventType.MouseUp, Data1 = 72, Data2 = 47 });
+
+ Assert.Equal(new[] { 2 }, acted);
+ Assert.Empty(selections);
+ }
+
+ [Fact]
+ public void ReleaseOffThePressedButton_DoesNotFire()
+ {
+ var acted = new List();
+ var list = MakeList(acted, new List());
+
+ list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 70, Data2 = 5 });
+ list.OnEvent(new UiEvent { Type = UiEventType.MouseUp, Data1 = 70, Data2 = 45 });
+ list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 70, Data2 = 5 });
+ list.OnEvent(new UiEvent { Type = UiEventType.MouseUp, Data1 = 10, Data2 = 5 });
+
+ Assert.Empty(acted);
+ }
+
+ [Fact]
+ public void EmptyCaption_IsNoButton_AndAClickOnItDoesNothing()
+ {
+ var acted = new List();
+ var selections = new List();
+ var list = MakeList(acted, selections);
+
+ list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 70, Data2 = 25 });
+ list.OnEvent(new UiEvent { Type = UiEventType.MouseUp, Data1 = 70, Data2 = 25 });
+
+ Assert.Empty(acted);
+ Assert.Empty(selections);
+ Assert.Equal(UiControlState.Normal, list.ButtonStateForTest(1, 1));
+ }
+
+ [Fact]
+ public void ButtonCell_ShowsHoverAndPressOnlyOnTheCellUnderThePointer()
+ {
+ var list = MakeList(new List(), new List());
+ Assert.True(list.ReceivesHoverMouseMove);
+
+ list.OnEvent(new UiEvent { Type = UiEventType.HoverEnter, Data1 = 70, Data2 = 5 });
+ Assert.Equal(UiControlState.Hovered, list.ButtonStateForTest(0, 1));
+ Assert.Equal(UiControlState.Normal, list.ButtonStateForTest(2, 1));
+
+ list.OnEvent(new UiEvent { Type = UiEventType.MouseMove, Data1 = 70, Data2 = 45 });
+ Assert.Equal(UiControlState.Normal, list.ButtonStateForTest(0, 1));
+ Assert.Equal(UiControlState.Hovered, list.ButtonStateForTest(2, 1));
+
+ list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 70, Data2 = 45 });
+ Assert.Equal(UiControlState.Pressed, list.ButtonStateForTest(2, 1));
+
+ // Dragged off the pressed button: it lets go of its pressed look.
+ list.OnEvent(new UiEvent { Type = UiEventType.MouseMove, Data1 = 70, Data2 = 5 });
+ Assert.Equal(UiControlState.Normal, list.ButtonStateForTest(2, 1));
+ Assert.Equal(UiControlState.Normal, list.ButtonStateForTest(0, 1));
+
+ list.OnEvent(new UiEvent { Type = UiEventType.MouseUp, Data1 = 70, Data2 = 5 });
+ Assert.Equal(UiControlState.Hovered, list.ButtonStateForTest(0, 1));
+
+ list.OnEvent(new UiEvent { Type = UiEventType.HoverLeave });
+ Assert.Equal(UiControlState.Normal, list.ButtonStateForTest(0, 1));
+ }
+
+ [Fact]
+ public void ListWithoutButtonColumns_TakesNoHoverMoves()
+ {
+ var list = new UiMarkupList
+ {
+ Columns = new[] { UiMarkupListColumn.Text(50f, () => new[] { "a" }, null) },
+ };
+ Assert.False(list.ReceivesHoverMouseMove);
+ }
+
+ [Fact]
+ public void DisabledList_ShowsItsButtonsDisabled()
+ {
+ var list = MakeList(new List(), new List());
+ list.Enabled = false;
+ Assert.Equal(UiControlState.Disabled, list.ButtonStateForTest(0, 1));
+ }
+
+ [Fact]
+ public void ClassicButtonCell_DrawsAFaceInsideItsCell_OnlyWhereThereIsACaption()
+ {
+ var face = new Vector4(0.2f, 0.4f, 0.6f, 1f);
+ var list = new UiMarkupList
+ {
+ Width = 100f, Height = 60f, RowHeight = 20f,
+ SelectedIndexSource = () => -1,
+ BackgroundColor = default, BorderColor = default,
+ ButtonColor = face,
+ Columns = new[]
+ {
+ UiMarkupListColumn.Text(50f, () => new[] { "a", "b", "c" }, null),
+ UiMarkupListColumn.Button(50f, () => new[] { "Go", "", "Go" }, _ => { }),
+ },
+ };
+ var (renderer, ctx) = MakeContext(200f, 200f);
+
+ list.DrawSelfAndChildren(ctx);
+
+ var faceVerts = renderer.DebugSpriteSegmentVerts
+ .Where(s => s.Texture == 0u)
+ .SelectMany(s => Chunk(s.Verts))
+ .Where(v => ColorMatches(v, face))
+ .ToList();
+ Assert.NotEmpty(faceVerts);
+ Assert.All(faceVerts, v => Assert.InRange(v[0], 50f, 100f));
+ // Rows 0 and 2 only: nothing in row 1 (y = 20..40).
+ Assert.Contains(faceVerts, v => v[1] < 20f);
+ Assert.Contains(faceVerts, v => v[1] > 40f);
+ Assert.DoesNotContain(faceVerts, v => v[1] > 21f && v[1] < 39f);
+ }
+
+ [Fact]
+ public void ButtonColumn_BindsItsButtonSize()
+ {
+ var panel = MarkupDocument.Build(
+ FoundXml(""),
+ new FoundBinding(), Sprite);
+ var column = Assert.IsType(panel.Children[0]).Columns![1];
+ Assert.Equal(40f, column.ButtonWidth);
+ Assert.Equal(12f, column.ButtonHeight);
+ }
+
+ [Fact]
+ public void ButtonColumn_NegativeButtonSize_ThrowsAtBuild()
+ {
+ var ex = Assert.Throws(() => MarkupDocument.Build(
+ FoundXml(""),
+ new FoundBinding(), Sprite));
+ Assert.Contains("column[1] type=\"button\" buttonh", ex.Message);
+ }
+
+ [Fact]
+ public void SizedButton_IsCentredInItsCell_AndOnlyTheButtonTakesClicks()
+ {
+ var face = new Vector4(0.2f, 0.4f, 0.6f, 1f);
+ var acted = new List();
+ var list = new UiMarkupList
+ {
+ Width = 100f, Height = 60f, RowHeight = 20f,
+ SelectedIndexSource = () => -1,
+ BackgroundColor = default, BorderColor = default,
+ ButtonColor = face,
+ Columns = new[]
+ {
+ UiMarkupListColumn.Text(50f, () => new[] { "a" }, null),
+ // Cell x = 50..100, y = 0..20; a 30x10 button sits at x = 60..90, y = 5..15.
+ UiMarkupListColumn.Button(50f, () => new[] { "Go" }, row => acted.Add(row),
+ buttonWidth: 30f, buttonHeight: 10f),
+ },
+ };
+ var (renderer, ctx) = MakeContext(200f, 200f);
+ list.DrawSelfAndChildren(ctx);
+
+ var faceVerts = renderer.DebugSpriteSegmentVerts
+ .Where(s => s.Texture == 0u)
+ .SelectMany(s => Chunk(s.Verts))
+ .Where(v => ColorMatches(v, face))
+ .ToList();
+ Assert.NotEmpty(faceVerts);
+ Assert.All(faceVerts, v => Assert.InRange(v[0], 60f, 90f));
+ Assert.All(faceVerts, v => Assert.InRange(v[1], 5f, 15f));
+
+ // Inside the cell but beside the button: nothing.
+ list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 55, Data2 = 10 });
+ list.OnEvent(new UiEvent { Type = UiEventType.MouseUp, Data1 = 55, Data2 = 10 });
+ list.OnEvent(new UiEvent { Type = UiEventType.MouseMove, Data1 = 75, Data2 = 2 });
+ Assert.Equal(UiControlState.Normal, list.ButtonStateForTest(0, 1));
+ Assert.Empty(acted);
+
+ list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 75, Data2 = 10 });
+ list.OnEvent(new UiEvent { Type = UiEventType.MouseUp, Data1 = 75, Data2 = 10 });
+ Assert.Equal(new[] { 0 }, acted);
+ }
+
+ [Fact]
+ public void SizedButton_NeverGrowsPastItsCell()
+ {
+ var face = new Vector4(0.2f, 0.4f, 0.6f, 1f);
+ var list = new UiMarkupList
+ {
+ Width = 100f, Height = 60f, RowHeight = 20f,
+ SelectedIndexSource = () => -1,
+ BackgroundColor = default, BorderColor = default,
+ ButtonColor = face,
+ Columns = new[]
+ {
+ UiMarkupListColumn.Text(50f, () => new[] { "a" }, null),
+ UiMarkupListColumn.Button(50f, () => new[] { "Go" }, _ => { },
+ buttonWidth: 500f, buttonHeight: 500f),
+ },
+ };
+ var (renderer, ctx) = MakeContext(200f, 200f);
+ list.DrawSelfAndChildren(ctx);
+
+ var faceVerts = renderer.DebugSpriteSegmentVerts
+ .Where(s => s.Texture == 0u)
+ .SelectMany(s => Chunk(s.Verts))
+ .Where(v => ColorMatches(v, face))
+ .ToList();
+ Assert.All(faceVerts, v => Assert.InRange(v[0], 51f, 99f));
+ Assert.All(faceVerts, v => Assert.InRange(v[1], 1f, 19f));
+ }
+
+ // ── A press names the row it went down on ───────────
+
+ private sealed class LiveRows
+ {
+ public string[] Names = { "a", "b", "c", "d", "e" };
+ public string[] Captions = { "Go", "Go", "Go", "Go", "Go" };
+ public List Acted { get; } = new();
+ }
+
+ /// A 60-high list of 20-high rows (three visible), names in x = 0..50, buttons in 50..84.
+ private static UiMarkupList MakeLiveList(LiveRows rows)
+ {
+ var list = new UiMarkupList
+ {
+ Width = 100f, Height = 60f, RowHeight = 20f,
+ SelectedIndexSource = () => -1,
+ BackgroundColor = default, BorderColor = default,
+ Columns = new[]
+ {
+ // Fresh arrays every read, the way a markup binding hands them over.
+ UiMarkupListColumn.Text(50f, () => rows.Names.ToArray(), null),
+ UiMarkupListColumn.Button(50f, () => rows.Captions.ToArray(), row => rows.Acted.Add(row)),
+ },
+ };
+ Draw(list);
+ return list;
+ }
+
+ private static void Draw(UiMarkupList list)
+ {
+ var (_, ctx) = MakeContext(200f, 200f);
+ list.DrawSelfAndChildren(ctx);
+ }
+
+ private static UiEvent At(int type, int x, int y) => new() { Type = type, Data1 = x, Data2 = y };
+
+ [Fact]
+ public void RowsRedrawnWithTheSameContent_StillFire()
+ {
+ var rows = new LiveRows();
+ var list = MakeLiveList(rows);
+
+ list.OnEvent(At(UiEventType.MouseDown, 60, 25));
+ Draw(list);
+ list.OnEvent(At(UiEventType.MouseUp, 60, 25));
+
+ Assert.Equal(new[] { 1 }, rows.Acted);
+ }
+
+ [Fact]
+ public void RowRemovedAboveThePress_BeforeRelease_DoesNotFireForTheRowThatMovedIn()
+ {
+ var rows = new LiveRows();
+ var list = MakeLiveList(rows);
+
+ list.OnEvent(At(UiEventType.MouseDown, 60, 25)); // row 1, "b"
+ rows.Names = new[] { "b", "c", "d", "e" }; // "a" goes; "c" is now row 1
+ rows.Captions = new[] { "Go", "Go", "Go", "Go" };
+ list.OnEvent(At(UiEventType.MouseUp, 60, 25));
+
+ Assert.Empty(rows.Acted);
+ }
+
+ [Fact]
+ public void PressedButtonsCaptionChangedBeforeRelease_DoesNotFire_EvenWithNoDrawBetween()
+ {
+ var rows = new LiveRows();
+ var list = MakeLiveList(rows);
+
+ list.OnEvent(At(UiEventType.MouseDown, 60, 25)); // row 1
+ rows.Captions = new[] { "Go", "Stop", "Go", "Go", "Go" };
+ list.OnEvent(At(UiEventType.MouseUp, 60, 25));
+
+ Assert.Empty(rows.Acted);
+ }
+
+ [Fact]
+ public void PressedButtonsCaptionEmptiedBeforeRelease_DoesNotFire()
+ {
+ var rows = new LiveRows();
+ var list = MakeLiveList(rows);
+
+ list.OnEvent(At(UiEventType.MouseDown, 60, 25)); // row 1
+ rows.Captions = new[] { "Go", "", "Go", "Go", "Go" };
+ Draw(list);
+ rows.Captions = new[] { "Go", "Go", "Go", "Go", "Go" };
+ list.OnEvent(At(UiEventType.MouseUp, 60, 25));
+
+ Assert.Empty(rows.Acted);
+ }
+
+ [Fact]
+ public void OtherColumnChangedBetweenPressAndRelease_StillFires()
+ {
+ var rows = new LiveRows();
+ var list = MakeLiveList(rows);
+
+ list.OnEvent(At(UiEventType.MouseDown, 60, 25)); // row 1
+ rows.Names = new[] { "a", "b 12.3m", "c", "d", "e" }; // a live column ticks
+ Draw(list);
+ rows.Names = new[] { "a", "b 11.9m", "c", "d", "e" };
+ list.OnEvent(At(UiEventType.MouseUp, 60, 25));
+
+ Assert.Equal(new[] { 1 }, rows.Acted);
+ }
+
+ [Fact]
+ public void ButtonCaptionChangedWhilePressed_DropsThePressedLookAtTheNextDraw()
+ {
+ var rows = new LiveRows();
+ var list = MakeLiveList(rows);
+
+ list.OnEvent(At(UiEventType.MouseDown, 60, 25));
+ Assert.Equal(UiControlState.Pressed, list.ButtonStateForTest(1, 1));
+ rows.Captions = new[] { "Go", "Stop", "Go", "Go", "Go" };
+ Draw(list);
+
+ Assert.Equal(UiControlState.Hovered, list.ButtonStateForTest(1, 1));
+ }
+
+ [Fact]
+ public void ListScrolledWhilePressed_DoesNotFire()
+ {
+ var rows = new LiveRows();
+ var list = MakeLiveList(rows);
+
+ list.OnEvent(At(UiEventType.MouseDown, 60, 25)); // row 1
+ list.OnEvent(new UiEvent { Type = UiEventType.Scroll, Data0 = -1, Data1 = 60, Data2 = 25 });
+ // Row 1 is now drawn at y = 0..20; release on it there.
+ list.OnEvent(At(UiEventType.MouseUp, 60, 5));
+
+ Assert.Empty(rows.Acted);
+ }
+
+ [Fact]
+ public void WheelScroll_WithNoPointerMove_MovesTheHoverToTheButtonNowUnderThePointer()
+ {
+ var rows = new LiveRows();
+ var list = MakeLiveList(rows);
+ var root = new UiRoot { Width = 800, Height = 600 };
+ root.AddChild(list);
+
+ root.OnMouseMove(60, 5);
+ Assert.Equal(UiControlState.Hovered, list.ButtonStateForTest(0, 1));
+
+ root.OnScroll(-1);
+
+ Assert.Equal(UiControlState.Normal, list.ButtonStateForTest(0, 1));
+ Assert.Equal(UiControlState.Hovered, list.ButtonStateForTest(1, 1));
+ }
+
+ [Fact]
+ public void ThroughUiRoot_PressAndRelease_FiresOnce()
+ {
+ var rows = new LiveRows();
+ var list = MakeLiveList(rows);
+ var root = new UiRoot { Width = 800, Height = 600 };
+ root.AddChild(list);
+
+ root.OnMouseMove(60, 45);
+ root.OnMouseDown(UiMouseButton.Left, 60, 45);
+ root.OnMouseUp(UiMouseButton.Left, 60, 45);
+
+ Assert.Equal(new[] { 2 }, rows.Acted);
+ }
+
+ [Fact]
+ public void ThroughUiRoot_CaptureTakenAway_ClearsThePress()
+ {
+ var rows = new LiveRows();
+ var list = MakeLiveList(rows);
+ var other = new UiPanel { Left = 300, Top = 300, Width = 20, Height = 20 };
+ var root = new UiRoot { Width = 800, Height = 600 };
+ root.AddChild(list);
+ root.AddChild(other);
+
+ root.OnMouseMove(60, 45);
+ root.OnMouseDown(UiMouseButton.Left, 60, 45);
+ Assert.Same(list, root.Captured);
+ Assert.Equal(UiControlState.Pressed, list.ButtonStateForTest(2, 1));
+
+ root.SetCapture(other);
+
+ Assert.NotEqual(UiControlState.Pressed, list.ButtonStateForTest(2, 1));
+ list.OnEvent(At(UiEventType.MouseUp, 60, 45));
+ Assert.Empty(rows.Acted);
+ }
+
+ [Fact]
+ public void DisabledList_DoesNotFire()
+ {
+ var acted = new List();
+ var list = MakeList(acted, new List());
+ list.Enabled = false;
+
+ list.OnEvent(At(UiEventType.MouseDown, 70, 5));
+ list.OnEvent(At(UiEventType.MouseUp, 70, 5));
+
+ Assert.Empty(acted);
+ }
+
+ [Fact]
+ public void ListDisabledWhilePressed_DoesNotFire_EvenIfEnabledAgainBeforeRelease()
+ {
+ var acted = new List();
+ var list = MakeList(acted, new List());
+
+ list.OnEvent(At(UiEventType.MouseDown, 70, 5));
+ list.Enabled = false;
+ list.Enabled = true;
+ list.OnEvent(At(UiEventType.MouseUp, 70, 5));
+
+ Assert.Empty(acted);
+ }
+
+ // ── Drawing ───────────
+
+ [Fact]
+ public void DisabledList_DrawsItsButtonFaceAndBorderFaded()
+ {
+ var face = new Vector4(0.2f, 0.4f, 0.6f, 1f);
+ var border = new Vector4(0.7f, 0.1f, 0.3f, 1f);
+ var list = new UiMarkupList
+ {
+ Width = 100f, Height = 60f, RowHeight = 20f,
+ SelectedIndexSource = () => -1,
+ BackgroundColor = default, BorderColor = default,
+ ButtonColor = face, ButtonBorderColor = border,
+ Enabled = false,
+ Columns = new[]
+ {
+ UiMarkupListColumn.Text(50f, () => new[] { "a" }, null),
+ UiMarkupListColumn.Button(50f, () => new[] { "Go" }, _ => { }),
+ },
+ };
+ var (renderer, ctx) = MakeContext(200f, 200f);
+ list.DrawSelfAndChildren(ctx);
+
+ var verts = renderer.DebugSpriteSegmentVerts
+ .Where(s => s.Texture == 0u)
+ .SelectMany(s => Chunk(s.Verts))
+ .ToList();
+ float faded = UiMarkupToggle.DisabledAlpha;
+ Assert.Contains(verts, v => ColorMatches(v, face with { W = faded }));
+ Assert.Contains(verts, v => ColorMatches(v, border with { W = faded }));
+ Assert.DoesNotContain(verts, v => ColorMatches(v, face));
+ Assert.DoesNotContain(verts, v => ColorMatches(v, border));
+ }
+
+ [Fact]
+ public void CaptionWiderThanItsButton_IsCutAtTheButtonEdges()
+ {
+ var glyphs = new Dictionary
+ {
+ ['W'] = new DatReaderWriter.Types.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);
+ var list = new UiMarkupList
+ {
+ Width = 100f, Height = 60f, RowHeight = 20f, DatFont = font,
+ SelectedIndexSource = () => -1,
+ BackgroundColor = default, BorderColor = default,
+ Columns = new[]
+ {
+ UiMarkupListColumn.Text(50f, () => new[] { "" }, null),
+ // Cell x = 50..100; a 20-wide button sits at x = 65..85. The caption is 80 wide.
+ UiMarkupListColumn.Button(50f, () => new[] { "WWWWWWWWWW" }, _ => { }, buttonWidth: 20f),
+ },
+ };
+ var (renderer, ctx) = MakeContext(200f, 200f);
+ list.DrawSelfAndChildren(ctx);
+
+ var glyphVerts = renderer.DebugSpriteSegmentVerts
+ .Where(s => s.Texture == 1u)
+ .SelectMany(s => Chunk(s.Verts))
+ .ToList();
+ Assert.NotEmpty(glyphVerts);
+ Assert.All(glyphVerts, v => Assert.InRange(v[0], 65f - 0.01f, 85f + 0.01f));
+ }
+
+ private static IEnumerable Chunk(IReadOnlyList 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;
+
+ 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;
+ }
+}