merge(vt): retail scrollbar chrome on plain <menu> popups and overflowing <list>s (owner: same assets as chat/inventory)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
commit
b71a8ea377
8 changed files with 787 additions and 47 deletions
|
|
@ -125,10 +125,17 @@ scrollbar, and a baked checkmark glyph on the current entry even with
|
||||||
`style="plain"`. The open popup now matches `<list>`'s own chrome too: a
|
`style="plain"`. The open popup now matches `<list>`'s own chrome too: a
|
||||||
flat fill + 1px border, one row per entry in the list text color, the
|
flat fill + 1px border, one row per entry in the list text color, the
|
||||||
current entry filled like a list selection, the hovered entry a slightly
|
current entry filled like a list selection, the hovered entry a slightly
|
||||||
lighter fill, and no checkmark; more entries than the row cap show a plain
|
lighter fill, and no checkmark. A `<menu>` popup always scrolls a single
|
||||||
1px-bordered scrollbar track with a flat thumb, no DAT scrollbar art.
|
column (rather than wrapping into more grid columns) once its item count
|
||||||
`style="retail"` keeps the sprite popup (gradient panel, checkmark-bearing
|
exceeds `rows`; a further owner directive (still 2026-09-07 — "For
|
||||||
row art, ornate scrollbar) exactly as before, unchanged.
|
scrollable dropdown or the meta window we use the same assets as we do in
|
||||||
|
for example chat or inventory window") moved that overflow scrollbar to
|
||||||
|
retail's own chrome — the exact sprites the chat window's transcript and
|
||||||
|
the inventory list already use — while the rows themselves stay the flat
|
||||||
|
style described above; a menu with too few items to overflow shows no bar
|
||||||
|
at all. `style="retail"` keeps the sprite popup rows (gradient panel,
|
||||||
|
checkmark-bearing row art) exactly as before, unchanged — only the
|
||||||
|
scrollbar chrome is shared between the two styles.
|
||||||
|
|
||||||
Common to every element via `ApplyCommon`: `name`/`id` (a stable control
|
Common to every element via `ApplyCommon`: `name`/`id` (a stable control
|
||||||
name), `visible` (literal `true`/`false` or a bound `bool` property),
|
name), `visible` (literal `true`/`false` or a bound `bool` property),
|
||||||
|
|
@ -461,10 +468,12 @@ separate concept for: `WPaddingOuter=3px` (the list's own left/right
|
||||||
margin), `WPadding=7px` (a gap BETWEEN columns), and a themed
|
margin), `WPadding=7px` (a gap BETWEEN columns), and a themed
|
||||||
`VScrollBarButtonSize=16px` (scrollbar width, reserved on the right). It
|
`VScrollBarButtonSize=16px` (scrollbar width, reserved on the right). It
|
||||||
also forces every `CheckColumn` to a fixed 13px regardless of its declared
|
also forces every `CheckColumn` to a fixed 13px regardless of its declared
|
||||||
`fixedwidth`. acdream's column model has no separate gap/scrollbar/
|
`fixedwidth`. acdream's column model has no separate gap/forced-width
|
||||||
forced-width concept — every column's declared `width` is its full cell
|
concept — every column's declared `width` is its full cell width, columns
|
||||||
width, columns sit directly adjacent with no gap, and a check column uses
|
sit directly adjacent with no gap, and a check column uses whatever `width`
|
||||||
whatever `width` it's given like any other column.
|
it's given like any other column. The 16px scrollbar column IS now
|
||||||
|
automatic (owner directive, 2026-09-07 — see "Scrollbar" below): a plugin
|
||||||
|
author never reserves it by hand.
|
||||||
|
|
||||||
To transcribe a real VTank column table (as in
|
To transcribe a real VTank column table (as in
|
||||||
`refs/vtank/uTank2.ViewXML.mainView.xml`) faithfully, declare each column's
|
`refs/vtank/uTank2.ViewXML.mainView.xml`) faithfully, declare each column's
|
||||||
|
|
@ -472,11 +481,26 @@ To transcribe a real VTank column table (as in
|
||||||
VVS's inter-column `WPadding` into the cell width itself, since acdream has
|
VVS's inter-column `WPadding` into the cell width itself, since acdream has
|
||||||
no separate gap). For a `CheckColumn`, use VVS's forced 13px as the
|
no separate gap). For a `CheckColumn`, use VVS's forced 13px as the
|
||||||
`fixedwidth` regardless of whatever `fixedwidth` the source XML declares
|
`fixedwidth` regardless of whatever `fixedwidth` the source XML declares
|
||||||
(`16 -> 13 + 7 = 20`, not `16 + 7 = 23`). Reserve VVS's 16px scrollbar width
|
(`16 -> 13 + 7 = 20`, not `16 + 7 = 23`). Do NOT also fold VVS's 16px
|
||||||
on the LAST column specifically (add it to that column's own pitch, or fold
|
scrollbar width into the last column's pitch or the list's total `w` — the
|
||||||
it into the list's total declared `w`) — acdream's list draws no scrollbar
|
list reserves that width itself, automatically, only while its rows
|
||||||
of its own today, but reserving the space keeps the transcribed proportions
|
actually overflow (see "Scrollbar" below); doing both would double-reserve
|
||||||
matching what a real VVS `HudList` would show once one exists.
|
it and starve the last column once the list has few enough rows to hide
|
||||||
|
the bar.
|
||||||
|
|
||||||
|
### Scrollbar
|
||||||
|
|
||||||
|
Once a `<list>`'s rows overflow its own height (either the single-column or
|
||||||
|
the `<column>` form), it reserves a 16px column at its right edge — VVS's
|
||||||
|
own `VScrollBarButtonSize` placement — and draws retail's scrollbar chrome
|
||||||
|
there: the same sprite ids the chat window's transcript and the inventory
|
||||||
|
list already draw through (owner live-client report 2026-09-07: "For
|
||||||
|
scrollable dropdown or the meta window we use the same assets as we do in
|
||||||
|
for example chat or inventory window"). Mouse wheel keeps working as
|
||||||
|
before; the bar itself is also fully interactive (up/down arrow clicks,
|
||||||
|
track paging, and thumb drag). A list whose rows all fit reserves no width
|
||||||
|
and draws no bar at all — the reservation and the chrome both come and go
|
||||||
|
together with actual overflow, never present "just in case."
|
||||||
|
|
||||||
### Backward compatibility
|
### Backward compatibility
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -482,6 +482,14 @@ public static class MarkupDocument
|
||||||
ItemNormalSprite = 0x0600124Eu,
|
ItemNormalSprite = 0x0600124Eu,
|
||||||
ItemHighlightSprite = 0x0600124Du,
|
ItemHighlightSprite = 0x0600124Du,
|
||||||
RetailButtonArt = menuRetailButtonArt,
|
RetailButtonArt = menuRetailButtonArt,
|
||||||
|
// Owner live-client report 2026-09-07: a plugin dropdown
|
||||||
|
// scrolls a single column (VTank's own HudCombo shape)
|
||||||
|
// rather than wrapping into more grid columns once it
|
||||||
|
// overflows its "rows" window; the scrollbar itself is
|
||||||
|
// hidden entirely (0x79 semantics) while everything
|
||||||
|
// fits, matching retail's vendor category popup.
|
||||||
|
Scrollable = true,
|
||||||
|
PopupScrollbarHideWhenDisabled = true,
|
||||||
ButtonLabelProvider = () => menuSelected() ?? string.Empty,
|
ButtonLabelProvider = () => menuSelected() ?? string.Empty,
|
||||||
OnSelect = payload =>
|
OnSelect = payload =>
|
||||||
{
|
{
|
||||||
|
|
@ -489,6 +497,12 @@ public static class MarkupDocument
|
||||||
menuChanged?.Invoke(value);
|
menuChanged?.Invoke(value);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
// The popup's own scrollbar always draws retail's chrome —
|
||||||
|
// "we use the same assets as we do in for example chat or
|
||||||
|
// inventory window" — regardless of RetailButtonArt (the
|
||||||
|
// owner's earlier plain-row directive only ever covered the
|
||||||
|
// ROWS, never this bar).
|
||||||
|
RetailScrollbarChrome.ApplyToMenuPopup(menu);
|
||||||
void RefreshMenu()
|
void RefreshMenu()
|
||||||
{
|
{
|
||||||
menu.Items = menuItems()
|
menu.Items = menuItems()
|
||||||
|
|
@ -545,6 +559,11 @@ public static class MarkupDocument
|
||||||
Height = F(el, "h"),
|
Height = F(el, "h"),
|
||||||
RowHeight = Math.Max(12f, FOr(el, "rowheight", 18f)),
|
RowHeight = Math.Max(12f, FOr(el, "rowheight", 18f)),
|
||||||
DatFont = datFont,
|
DatFont = datFont,
|
||||||
|
// Owner live-client report 2026-09-07: an overflowing
|
||||||
|
// <list> draws the same retail scrollbar chrome the chat
|
||||||
|
// window and inventory use — resolved through the same
|
||||||
|
// sprite resolver every other markup sink already uses.
|
||||||
|
SpriteResolve = resolve,
|
||||||
SelectedIndexSource = BindRequiredIntReader(
|
SelectedIndexSource = BindRequiredIntReader(
|
||||||
(string?)el.Attribute("selected"),
|
(string?)el.Attribute("selected"),
|
||||||
binding,
|
binding,
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,27 @@ internal static class RetailScrollbarChrome
|
||||||
bar.ThumbBotPressedSprite = ThumbBotPressed;
|
bar.ThumbBotPressedSprite = ThumbBotPressed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wires the retail vertical skin onto a <see cref="UiMenu"/> popup's own
|
||||||
|
/// procedural scrollbar properties (<see cref="UiMenu.ScrollTrackSprite"/>
|
||||||
|
/// etc). Owner live-client report 2026-09-07 ("For scrollable dropdown or
|
||||||
|
/// the meta window we use the same assets as we do in for example chat or
|
||||||
|
/// inventory window"): the popup's own <c>DrawPopupScrollbar</c> draws a
|
||||||
|
/// simpler Normal-only chrome (no hover/pressed states — matching how
|
||||||
|
/// <c>VendorUiController</c>/<c>ConfigOptionsPageController</c> already
|
||||||
|
/// wire these exact ids), so only the Normal-state constants are needed
|
||||||
|
/// here.
|
||||||
|
/// </summary>
|
||||||
|
internal static void ApplyToMenuPopup(UiMenu menu)
|
||||||
|
{
|
||||||
|
menu.ScrollTrackSprite = Track;
|
||||||
|
menu.ScrollThumbTopSprite = ThumbTopNormal;
|
||||||
|
menu.ScrollThumbSprite = ThumbMidNormal;
|
||||||
|
menu.ScrollThumbBottomSprite = ThumbBotNormal;
|
||||||
|
menu.ScrollUpSprite = UpNormal;
|
||||||
|
menu.ScrollDownSprite = DownNormal;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Wires the full retail horizontal skin onto <paramref name="bar"/>.
|
/// <summary>Wires the full retail horizontal skin onto <paramref name="bar"/>.
|
||||||
/// The leading (<see cref="UiScrollbar.UpSprite"/>) slot is the LEFT edge.</summary>
|
/// The leading (<see cref="UiScrollbar.UpSprite"/>) slot is the LEFT edge.</summary>
|
||||||
internal static void ApplyHorizontal(UiScrollbar bar)
|
internal static void ApplyHorizontal(UiScrollbar bar)
|
||||||
|
|
|
||||||
|
|
@ -73,9 +73,46 @@ public sealed class UiMarkupList : UiElement
|
||||||
public Vector4 TextColor { get; set; } = new(0.91f, 0.87f, 0.76f, 1f);
|
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);
|
public Vector4 SelectedColor { get; set; } = new(0.28f, 0.23f, 0.08f, 0.95f);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Owner live-client report 2026-09-07 ("For scrollable dropdown or the
|
||||||
|
/// meta window we use the same assets as we do in for example chat or
|
||||||
|
/// inventory window"): resolves the retail scrollbar chrome
|
||||||
|
/// (<see cref="RetailScrollbarChrome"/>) drawn at the list's right edge
|
||||||
|
/// once its rows overflow the viewport — the same resolver every other
|
||||||
|
/// markup sink (icons, DAT fonts) already uses. Null (a hand-built list
|
||||||
|
/// with no host resolver wired) keeps the list wheel-scrollable with no
|
||||||
|
/// visible bar at all, exactly as before this fix.
|
||||||
|
/// </summary>
|
||||||
|
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Reserved width of the retail scrollbar column — VVS's own
|
||||||
|
/// convention (right edge, 16px), matching <see cref="UiMenu.ScrollbarWidth"/>'s
|
||||||
|
/// authored value and <see cref="UiScrollbar"/>'s vertical skin.</summary>
|
||||||
|
private const float ScrollbarWidth = 16f;
|
||||||
|
|
||||||
|
/// <summary>Button extent along the scrollbar's own axis — same authored
|
||||||
|
/// 16px convention <see cref="UiScrollbar.DecrementButtonExtent"/> uses.</summary>
|
||||||
|
private const float ScrollButtonExtent = 16f;
|
||||||
|
|
||||||
private int _topRow;
|
private int _topRow;
|
||||||
private IReadOnlyList<UiMarkupListColumn>? _columns;
|
private IReadOnlyList<UiMarkupListColumn>? _columns;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pixel-based scroll projection used ONLY to feed
|
||||||
|
/// <see cref="UiScrollbar.ThumbRect"/>'s geometry math (thumb
|
||||||
|
/// size/position) from this list's own <see cref="_topRow"/> — the list
|
||||||
|
/// itself remains the single source of truth for scroll position (every
|
||||||
|
/// existing selection-follow/ClampTop/wheel path is unchanged); this is
|
||||||
|
/// re-synced from <see cref="_topRow"/> immediately before every draw or
|
||||||
|
/// scrollbar hit-test, never driven independently.
|
||||||
|
/// </summary>
|
||||||
|
private readonly UiScrollable _scroll = new();
|
||||||
|
|
||||||
|
private bool _draggingThumb;
|
||||||
|
private float _thumbDragOffset;
|
||||||
|
|
||||||
|
public UiMarkupList() { CapturesPointerDrag = true; }
|
||||||
|
|
||||||
// ── Fix round item 6: per-column caches (reused between Draw and OnEvent,
|
// ── Fix round item 6: per-column caches (reused between Draw and OnEvent,
|
||||||
// sized to Columns.Count by the Columns setter above) ────────────────────
|
// sized to Columns.Count by the Columns setter above) ────────────────────
|
||||||
private IReadOnlyList<string>?[] _cachedTextRows = Array.Empty<IReadOnlyList<string>?>();
|
private IReadOnlyList<string>?[] _cachedTextRows = Array.Empty<IReadOnlyList<string>?>();
|
||||||
|
|
@ -117,6 +154,9 @@ public sealed class UiMarkupList : UiElement
|
||||||
}
|
}
|
||||||
ClampTop(items.Count, visibleRows);
|
ClampTop(items.Count, visibleRows);
|
||||||
|
|
||||||
|
bool showScrollbar = items.Count > visibleRows;
|
||||||
|
float contentWidth = showScrollbar ? MathF.Max(0f, Width - ScrollbarWidth) : Width;
|
||||||
|
|
||||||
context.DrawFill(0f, 0f, Width, Height, BackgroundColor);
|
context.DrawFill(0f, 0f, Width, Height, BackgroundColor);
|
||||||
context.DrawRectOutline(0f, 0f, Width, Height, BorderColor, 1f);
|
context.DrawRectOutline(0f, 0f, Width, Height, BorderColor, 1f);
|
||||||
int end = Math.Min(items.Count, _topRow + visibleRows);
|
int end = Math.Min(items.Count, _topRow + visibleRows);
|
||||||
|
|
@ -124,7 +164,7 @@ public sealed class UiMarkupList : UiElement
|
||||||
{
|
{
|
||||||
float y = (index - _topRow) * RowHeight;
|
float y = (index - _topRow) * RowHeight;
|
||||||
if (index == selected)
|
if (index == selected)
|
||||||
context.DrawFill(1f, y + 1f, Width - 2f, RowHeight - 1f, SelectedColor);
|
context.DrawFill(1f, y + 1f, contentWidth - 2f, RowHeight - 1f, SelectedColor);
|
||||||
|
|
||||||
if (iconIds is not null && index < iconIds.Count && IconResolve is { } resolve)
|
if (iconIds is not null && index < iconIds.Count && IconResolve is { } resolve)
|
||||||
{
|
{
|
||||||
|
|
@ -160,6 +200,9 @@ public sealed class UiMarkupList : UiElement
|
||||||
else
|
else
|
||||||
context.DrawString(text, textX, textY, textColor);
|
context.DrawString(text, textX, textY, textColor);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showScrollbar)
|
||||||
|
DrawScrollbar(context, contentWidth, items.Count, visibleRows);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override bool OnEvent(in UiEvent e)
|
public override bool OnEvent(in UiEvent e)
|
||||||
|
|
@ -168,17 +211,24 @@ public sealed class UiMarkupList : UiElement
|
||||||
return OnEventColumns(e, columns);
|
return OnEventColumns(e, columns);
|
||||||
|
|
||||||
IReadOnlyList<string> items = ItemsSource();
|
IReadOnlyList<string> items = ItemsSource();
|
||||||
|
int visibleRows = VisibleRows;
|
||||||
|
float contentWidth = items.Count > visibleRows
|
||||||
|
? MathF.Max(0f, Width - ScrollbarWidth)
|
||||||
|
: Width;
|
||||||
|
if (TryHandleScrollbarEvent(e, contentWidth, items.Count, visibleRows))
|
||||||
|
return true;
|
||||||
|
|
||||||
if (e.Type == UiEventType.Scroll)
|
if (e.Type == UiEventType.Scroll)
|
||||||
{
|
{
|
||||||
_topRow -= Math.Sign(e.Data0);
|
_topRow -= Math.Sign(e.Data0);
|
||||||
ClampTop(items.Count, VisibleRows);
|
ClampTop(items.Count, visibleRows);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (e.Type != UiEventType.MouseDown || !Enabled)
|
if (e.Type != UiEventType.MouseDown || !Enabled)
|
||||||
return false;
|
return false;
|
||||||
int row = (int)MathF.Floor(e.Data2 / MathF.Max(1f, RowHeight));
|
int row = (int)MathF.Floor(e.Data2 / MathF.Max(1f, RowHeight));
|
||||||
int index = _topRow + row;
|
int index = _topRow + row;
|
||||||
if (row >= 0 && row < VisibleRows && index >= 0 && index < items.Count)
|
if (row >= 0 && row < visibleRows && index >= 0 && index < items.Count)
|
||||||
SelectionChanged?.Invoke(index);
|
SelectionChanged?.Invoke(index);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -304,9 +354,15 @@ public sealed class UiMarkupList : UiElement
|
||||||
}
|
}
|
||||||
_cachedRowCount = rowCount;
|
_cachedRowCount = rowCount;
|
||||||
|
|
||||||
ComputeColumnLayout(columns, Width);
|
|
||||||
|
|
||||||
int visibleRows = VisibleRows;
|
int visibleRows = VisibleRows;
|
||||||
|
// The reserved scrollbar column only exists once rows actually
|
||||||
|
// overflow the viewport (owner directive: reserve 16px only when
|
||||||
|
// the bar is shown) — computed BEFORE ComputeColumnLayout so the
|
||||||
|
// last (always-auto) column's remainder already accounts for it.
|
||||||
|
bool showScrollbar = rowCount > visibleRows;
|
||||||
|
float contentWidth = showScrollbar ? MathF.Max(0f, Width - ScrollbarWidth) : Width;
|
||||||
|
ComputeColumnLayout(columns, contentWidth);
|
||||||
|
|
||||||
int selected = SelectedIndexSource();
|
int selected = SelectedIndexSource();
|
||||||
if (selected >= 0 && selected < rowCount)
|
if (selected >= 0 && selected < rowCount)
|
||||||
{
|
{
|
||||||
|
|
@ -325,7 +381,7 @@ public sealed class UiMarkupList : UiElement
|
||||||
{
|
{
|
||||||
float y = (index - _topRow) * RowHeight;
|
float y = (index - _topRow) * RowHeight;
|
||||||
if (index == selected)
|
if (index == selected)
|
||||||
context.DrawFill(1f, y + 1f, Width - 2f, RowHeight - 1f, SelectedColor);
|
context.DrawFill(1f, y + 1f, contentWidth - 2f, RowHeight - 1f, SelectedColor);
|
||||||
|
|
||||||
for (int c = 0; c < columns.Count; c++)
|
for (int c = 0; c < columns.Count; c++)
|
||||||
{
|
{
|
||||||
|
|
@ -362,6 +418,9 @@ public sealed class UiMarkupList : UiElement
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showScrollbar)
|
||||||
|
DrawScrollbar(context, contentWidth, rowCount, visibleRows);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void DrawTextCell(
|
private void DrawTextCell(
|
||||||
|
|
@ -448,11 +507,17 @@ public sealed class UiMarkupList : UiElement
|
||||||
// event arriving before any Draw is a harmless no-op rather than a
|
// event arriving before any Draw is a harmless no-op rather than a
|
||||||
// crash.
|
// crash.
|
||||||
int rowCount = _cachedRowCount;
|
int rowCount = _cachedRowCount;
|
||||||
|
int visibleRows = VisibleRows;
|
||||||
|
float contentWidth = rowCount > visibleRows
|
||||||
|
? MathF.Max(0f, Width - ScrollbarWidth)
|
||||||
|
: Width;
|
||||||
|
if (TryHandleScrollbarEvent(e, contentWidth, rowCount, visibleRows))
|
||||||
|
return true;
|
||||||
|
|
||||||
if (e.Type == UiEventType.Scroll)
|
if (e.Type == UiEventType.Scroll)
|
||||||
{
|
{
|
||||||
_topRow -= Math.Sign(e.Data0);
|
_topRow -= Math.Sign(e.Data0);
|
||||||
ClampTop(rowCount, VisibleRows);
|
ClampTop(rowCount, visibleRows);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (e.Type != UiEventType.MouseDown || !Enabled)
|
if (e.Type != UiEventType.MouseDown || !Enabled)
|
||||||
|
|
@ -460,7 +525,7 @@ public sealed class UiMarkupList : UiElement
|
||||||
|
|
||||||
int row = (int)MathF.Floor(e.Data2 / MathF.Max(1f, RowHeight));
|
int row = (int)MathF.Floor(e.Data2 / MathF.Max(1f, RowHeight));
|
||||||
int index = _topRow + row;
|
int index = _topRow + row;
|
||||||
if (row < 0 || row >= VisibleRows || index < 0 || index >= rowCount)
|
if (row < 0 || row >= visibleRows || index < 0 || index >= rowCount)
|
||||||
return true; // swallow the press; clicks past the last row do nothing
|
return true; // swallow the press; clicks past the last row do nothing
|
||||||
|
|
||||||
float localX = e.Data1;
|
float localX = e.Data1;
|
||||||
|
|
@ -515,4 +580,162 @@ public sealed class UiMarkupList : UiElement
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Retail scrollbar chrome (owner live-client report 2026-09-07) ───────
|
||||||
|
//
|
||||||
|
// "For scrollable dropdown or the meta window we use the same assets as
|
||||||
|
// we do in for example chat or inventory window": once a list's rows
|
||||||
|
// overflow its own viewport, it draws the SAME sprite ids
|
||||||
|
// (RetailScrollbarChrome's vertical skin) as the chat SpewBox and the
|
||||||
|
// inventory UiItemList, at VVS's own placement (right edge, 16px wide).
|
||||||
|
// Geometry reuses UiScrollbar.ThumbRect via a small UiScrollable
|
||||||
|
// projection kept in sync with this list's own _topRow — the list stays
|
||||||
|
// the single source of truth for scroll position; the projection only
|
||||||
|
// feeds the shared thumb-size/position math.
|
||||||
|
|
||||||
|
/// <summary>Re-syncs <see cref="_scroll"/>'s content/view extents and
|
||||||
|
/// offset from this list's own <see cref="_topRow"/> — call immediately
|
||||||
|
/// before reading its <see cref="UiScrollbar.ThumbRect"/> geometry.</summary>
|
||||||
|
private void ConfigureScroll(int rowCount, int visibleRows)
|
||||||
|
{
|
||||||
|
int lineHeight = Math.Max(1, (int)MathF.Round(RowHeight));
|
||||||
|
_scroll.LineHeight = lineHeight;
|
||||||
|
_scroll.SetExtents(rowCount * lineHeight, visibleRows * lineHeight);
|
||||||
|
_scroll.SetScrollY(_topRow * lineHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawScrollbar(UiRenderContext ctx, float x, int rowCount, int visibleRows)
|
||||||
|
{
|
||||||
|
if (SpriteResolve is not { } resolve) return;
|
||||||
|
ConfigureScroll(rowCount, visibleRows);
|
||||||
|
|
||||||
|
float decExtent = Math.Clamp(ScrollButtonExtent, 0f, Height);
|
||||||
|
float incExtent = Math.Clamp(ScrollButtonExtent, 0f, Height - decExtent);
|
||||||
|
|
||||||
|
DrawTiledSprite(ctx, resolve, RetailScrollbarChrome.Track, x, 0f, ScrollbarWidth, Height);
|
||||||
|
DrawFlatSprite(ctx, resolve, RetailScrollbarChrome.UpNormal, x, 0f, ScrollbarWidth, decExtent);
|
||||||
|
DrawFlatSprite(ctx, resolve, RetailScrollbarChrome.DownNormal, x, Height - incExtent, ScrollbarWidth, incExtent);
|
||||||
|
|
||||||
|
float trackTop = decExtent;
|
||||||
|
float trackLen = MathF.Max(0f, Height - decExtent - incExtent);
|
||||||
|
var (ty, th) = UiScrollbar.ThumbRect(_scroll, trackTop, trackLen);
|
||||||
|
const float capH = 3f;
|
||||||
|
if (th >= 2f * capH)
|
||||||
|
{
|
||||||
|
DrawFlatSprite(ctx, resolve, RetailScrollbarChrome.ThumbTopNormal, x, ty, ScrollbarWidth, capH);
|
||||||
|
DrawTiledSprite(ctx, resolve, RetailScrollbarChrome.ThumbMidNormal, x, ty + capH, ScrollbarWidth, th - 2f * capH);
|
||||||
|
DrawFlatSprite(ctx, resolve, RetailScrollbarChrome.ThumbBotNormal, x, ty + th - capH, ScrollbarWidth, th <= 0f ? 0f : capH);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
DrawFlatSprite(ctx, resolve, RetailScrollbarChrome.ThumbMidNormal, x, ty, ScrollbarWidth, th);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Draw a sprite stretched 1:1 to the dest rect — same
|
||||||
|
/// convention <see cref="UiMenu"/>/<see cref="UiScrollbar"/> use for
|
||||||
|
/// their own button/thumb-cap art.</summary>
|
||||||
|
private static void DrawFlatSprite(
|
||||||
|
UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
|
||||||
|
uint id, float x, float y, float w, float h)
|
||||||
|
{
|
||||||
|
if (id == 0 || w <= 0f || h <= 0f) return;
|
||||||
|
var (tex, _, _) = resolve(id);
|
||||||
|
if (tex == 0) return;
|
||||||
|
ctx.DrawSprite(tex, x, y, w, h, 0f, 0f, 1f, 1f, Vector4.One);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Draw a sprite tiled (UV-repeat at native size) to fill the
|
||||||
|
/// dest rect — same convention as the track/thumb-middle draws
|
||||||
|
/// elsewhere in the retail scrollbar chrome.</summary>
|
||||||
|
private static void DrawTiledSprite(
|
||||||
|
UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
|
||||||
|
uint id, float x, float y, float w, float h)
|
||||||
|
{
|
||||||
|
if (id == 0 || w <= 0f || h <= 0f) return;
|
||||||
|
var (tex, tw, th) = resolve(id);
|
||||||
|
if (tex == 0 || tw == 0 || th == 0) return;
|
||||||
|
ctx.DrawSprite(tex, x, y, w, h, 0f, 0f, w / tw, h / th, Vector4.One);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Scrollbar hit-testing shared by the legacy single-column
|
||||||
|
/// <see cref="OnEvent"/> and <see cref="OnEventColumns"/> — arrows, track
|
||||||
|
/// paging, and thumb drag all work exactly as they do in
|
||||||
|
/// <see cref="UiScrollbar"/>/the chat window's own docked bar, driving
|
||||||
|
/// this list's own <see cref="_topRow"/>. Returns false (never handled)
|
||||||
|
/// once the rows fit the viewport — a non-overflowing list has no bar
|
||||||
|
/// and its area is ordinary row/content space.
|
||||||
|
/// </summary>
|
||||||
|
private bool TryHandleScrollbarEvent(in UiEvent e, float contentWidth, int rowCount, int visibleRows)
|
||||||
|
{
|
||||||
|
if (_draggingThumb)
|
||||||
|
{
|
||||||
|
if (e.Type == UiEventType.MouseMove)
|
||||||
|
{
|
||||||
|
DragThumb(e.Data2, rowCount, visibleRows);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (e.Type is UiEventType.MouseUp or UiEventType.CaptureChanged)
|
||||||
|
{
|
||||||
|
_draggingThumb = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rowCount <= visibleRows) return false;
|
||||||
|
if (e.Type != UiEventType.MouseDown || !Enabled) return false;
|
||||||
|
if (e.Data1 < contentWidth) return false; // click landed in row content, not the bar
|
||||||
|
|
||||||
|
ConfigureScroll(rowCount, visibleRows);
|
||||||
|
float decExtent = Math.Clamp(ScrollButtonExtent, 0f, Height);
|
||||||
|
float incExtent = Math.Clamp(ScrollButtonExtent, 0f, Height - decExtent);
|
||||||
|
float ly = e.Data2;
|
||||||
|
|
||||||
|
if (ly < decExtent) { StepRow(-1, rowCount, visibleRows); return true; }
|
||||||
|
if (ly >= Height - incExtent) { StepRow(1, rowCount, visibleRows); return true; }
|
||||||
|
|
||||||
|
float trackTop = decExtent;
|
||||||
|
float trackLen = MathF.Max(0f, Height - decExtent - incExtent);
|
||||||
|
var (ty, th) = UiScrollbar.ThumbRect(_scroll, trackTop, trackLen);
|
||||||
|
if (ly >= ty && ly <= ty + th)
|
||||||
|
{
|
||||||
|
_draggingThumb = true;
|
||||||
|
_thumbDragOffset = ly - ty;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
PageRow(ly < ty ? -1 : 1, rowCount, visibleRows);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DragThumb(float ly, int rowCount, int visibleRows)
|
||||||
|
{
|
||||||
|
ConfigureScroll(rowCount, visibleRows);
|
||||||
|
float decExtent = Math.Clamp(ScrollButtonExtent, 0f, Height);
|
||||||
|
float incExtent = Math.Clamp(ScrollButtonExtent, 0f, Height - decExtent);
|
||||||
|
float trackTop = decExtent;
|
||||||
|
float trackLen = MathF.Max(0f, Height - decExtent - incExtent);
|
||||||
|
var (_, thumbH) = UiScrollbar.ThumbRect(_scroll, trackTop, trackLen);
|
||||||
|
float travel = MathF.Max(1f, trackLen - thumbH);
|
||||||
|
float ratio = (ly - _thumbDragOffset - trackTop) / travel;
|
||||||
|
_scroll.SetPositionRatio(ratio);
|
||||||
|
|
||||||
|
int lineHeight = Math.Max(1, (int)MathF.Round(RowHeight));
|
||||||
|
_topRow = (int)MathF.Round((float)_scroll.ScrollY / lineHeight);
|
||||||
|
ClampTop(rowCount, visibleRows);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StepRow(int lines, int rowCount, int visibleRows)
|
||||||
|
{
|
||||||
|
_topRow += lines;
|
||||||
|
ClampTop(rowCount, visibleRows);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PageRow(int pages, int rowCount, int visibleRows)
|
||||||
|
{
|
||||||
|
_topRow += pages * visibleRows;
|
||||||
|
ClampTop(rowCount, visibleRows);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -884,9 +884,19 @@ public sealed class UiMenu : UiElement
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Plain counterpart of <see cref="DrawScrollablePopup"/> — same
|
/// <summary>Plain counterpart of <see cref="DrawScrollablePopup"/> — same
|
||||||
/// <see cref="VisibleTopRow"/>-sliced single column, plain
|
/// <see cref="VisibleTopRow"/>-sliced single column and plain
|
||||||
/// selected/hover row fills, and a plain scrollbar
|
/// selected/hover row fills, but the SCROLLBAR itself draws retail's own
|
||||||
/// (<see cref="DrawPopupScrollbarPlain"/>) instead of the sprite chrome.</summary>
|
/// chrome (owner live-client report 2026-09-07: "For scrollable dropdown
|
||||||
|
/// or the meta window we use the same assets as we do in for example
|
||||||
|
/// chat or inventory window") via the shared <see cref="DrawPopupScrollbar"/>
|
||||||
|
/// helper — the exact ids <see cref="RetailScrollbarChrome.ApplyToMenuPopup"/>
|
||||||
|
/// wires onto <see cref="ScrollTrackSprite"/> etc, the SAME sprite ids the
|
||||||
|
/// chat SpewBox/inventory <see cref="UiItemList"/> scrollbar uses. Rows
|
||||||
|
/// stay plain by design (the owner accepted the flat dark list; only the
|
||||||
|
/// bar was objectionable). A menu built with no <see cref="SpriteResolve"/>
|
||||||
|
/// at all (a hand-built test/legacy fixture) falls back to the fully
|
||||||
|
/// flat <see cref="DrawPopupScrollbarPlain"/> rather than silently
|
||||||
|
/// drawing nothing.</summary>
|
||||||
private void DrawScrollablePopupPlain(UiRenderContext ctx)
|
private void DrawScrollablePopupPlain(UiRenderContext ctx)
|
||||||
{
|
{
|
||||||
ConfigurePopupScroll();
|
ConfigurePopupScroll();
|
||||||
|
|
@ -918,17 +928,26 @@ public sealed class UiMenu : UiElement
|
||||||
avail ? PlainTextColor : TextColorGhosted);
|
avail ? PlainTextColor : TextColorGhosted);
|
||||||
}
|
}
|
||||||
|
|
||||||
DrawPopupScrollbarPlain(ctx, inX + ColumnWidth, inY);
|
if (SpriteResolve is { } resolve)
|
||||||
|
DrawPopupScrollbar(ctx, resolve, inX + ColumnWidth, inY);
|
||||||
|
else
|
||||||
|
DrawPopupScrollbarPlain(ctx, inX + ColumnWidth, inY);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Plain counterpart of <see cref="DrawPopupScrollbar"/>: a 1px-bordered
|
/// NO-RESOLVER FALLBACK ONLY (see <see cref="DrawScrollablePopupPlain"/>'s
|
||||||
/// track and a flat thumb, both in <see cref="PlainBorderColor"/> — no DAT
|
/// own doc comment — the 2026-09-07 owner directive moved the normal
|
||||||
/// thumb/track/arrow-button art at all. Shares the exact same
|
/// plain-popup scrollbar to retail's own chrome via
|
||||||
/// <see cref="UiScrollbar.ThumbRect"/> geometry (so the thumb's drawn
|
/// <see cref="DrawPopupScrollbar"/>). This draws a 1px-bordered track and
|
||||||
/// position matches <see cref="HandleScrollablePopupMouseDown"/>'s hit-test
|
/// a flat thumb, both in <see cref="PlainBorderColor"/> — no DAT
|
||||||
/// math), but draws no separate up/down button glyphs — plain mode has no
|
/// thumb/track/arrow-button art at all — for the rare case a
|
||||||
/// art for them and the click regions already work through geometry alone
|
/// <see cref="UiMenu"/> is built with <see cref="Scrollable"/> true but no
|
||||||
|
/// <see cref="SpriteResolve"/> at all (a hand-built test/legacy fixture).
|
||||||
|
/// Shares the exact same <see cref="UiScrollbar.ThumbRect"/> geometry (so
|
||||||
|
/// the thumb's drawn position matches
|
||||||
|
/// <see cref="HandleScrollablePopupMouseDown"/>'s hit-test math), but
|
||||||
|
/// draws no separate up/down button glyphs — plain mode has no art for
|
||||||
|
/// them and the click regions already work through geometry alone
|
||||||
/// (<see cref="HandleScrollablePopupMouseDown"/> is unchanged).
|
/// (<see cref="HandleScrollablePopupMouseDown"/> is unchanged).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void DrawPopupScrollbarPlain(UiRenderContext ctx, float x, float y)
|
private void DrawPopupScrollbarPlain(UiRenderContext ctx, float x, float y)
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,8 @@
|
||||||
|
using System.Linq;
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.App.Rendering;
|
||||||
|
using AcDream.App.Rendering.Gpu;
|
||||||
|
using AcDream.App.Tests.Rendering.Gpu;
|
||||||
using AcDream.App.UI;
|
using AcDream.App.UI;
|
||||||
|
|
||||||
namespace AcDream.App.Tests.UI;
|
namespace AcDream.App.Tests.UI;
|
||||||
|
|
@ -368,4 +373,101 @@ public class MarkupDocumentTests
|
||||||
Assert.Contains("menu", ex.Message);
|
Assert.Contains("menu", ex.Message);
|
||||||
Assert.Contains("chrome", ex.Message);
|
Assert.Contains("chrome", ex.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Owner live-client report 2026-09-07 ("For scrollable dropdown or
|
||||||
|
// the meta window we use the same assets as we do in for example chat
|
||||||
|
// or inventory window"): a markup <menu> scrolls a single column
|
||||||
|
// (rather than wrapping into grid columns) once it overflows its "rows"
|
||||||
|
// window, and that popup's scrollbar draws the SAME chrome ids the chat
|
||||||
|
// window/inventory scrollbar resolves through.
|
||||||
|
|
||||||
|
private sealed class OverflowMenuBinding
|
||||||
|
{
|
||||||
|
public IReadOnlyList<string> Choices { get; } =
|
||||||
|
Enumerable.Range(0, 12).Select(i => $"row{i}").ToList();
|
||||||
|
public string Selected { get; } = "row0";
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Menu_Markup_IsAlwaysScrollable_WithRetailScrollbarChromeWired()
|
||||||
|
{
|
||||||
|
var panel = MarkupDocument.Build(
|
||||||
|
MenuXml(styleAttribute: ""), new MenuStyleBinding(), _ => (1u, 32, 32));
|
||||||
|
var menu = Assert.IsType<UiMenu>(panel.Children[0]);
|
||||||
|
|
||||||
|
Assert.True(menu.Scrollable);
|
||||||
|
Assert.True(menu.PopupScrollbarHideWhenDisabled);
|
||||||
|
Assert.Equal(RetailScrollbarChrome.Track, menu.ScrollTrackSprite);
|
||||||
|
Assert.Equal(RetailScrollbarChrome.ThumbTopNormal, menu.ScrollThumbTopSprite);
|
||||||
|
Assert.Equal(RetailScrollbarChrome.ThumbMidNormal, menu.ScrollThumbSprite);
|
||||||
|
Assert.Equal(RetailScrollbarChrome.ThumbBotNormal, menu.ScrollThumbBottomSprite);
|
||||||
|
Assert.Equal(RetailScrollbarChrome.UpNormal, menu.ScrollUpSprite);
|
||||||
|
Assert.Equal(RetailScrollbarChrome.DownNormal, menu.ScrollDownSprite);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Menu_Markup_StyleRetail_IsAlsoScrollable_WithTheSameChrome()
|
||||||
|
{
|
||||||
|
var panel = MarkupDocument.Build(
|
||||||
|
MenuXml(" style=\"retail\""), new MenuStyleBinding(), _ => (1u, 32, 32));
|
||||||
|
var menu = Assert.IsType<UiMenu>(panel.Children[0]);
|
||||||
|
|
||||||
|
Assert.True(menu.RetailButtonArt);
|
||||||
|
Assert.True(menu.Scrollable);
|
||||||
|
Assert.Equal(RetailScrollbarChrome.Track, menu.ScrollTrackSprite);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Menu_Markup_OverflowingItems_DrawsRetailScrollbarChrome_OnOpen()
|
||||||
|
{
|
||||||
|
var binding = new OverflowMenuBinding();
|
||||||
|
const string xml =
|
||||||
|
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
|
||||||
|
"<menu x=\"4\" y=\"4\" w=\"120\" h=\"20\" items=\"{Choices}\" " +
|
||||||
|
"selected=\"{Selected}\" openupward=\"false\"/>" +
|
||||||
|
"</panel>";
|
||||||
|
var panel = MarkupDocument.Build(xml, binding, id => (id, 8, 8));
|
||||||
|
var menu = Assert.IsType<UiMenu>(panel.Children[0]);
|
||||||
|
|
||||||
|
// Default rows=7, 12 items -> overflow.
|
||||||
|
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, Data1: 10, Data2: 10)));
|
||||||
|
Assert.True(menu.IsOpen);
|
||||||
|
|
||||||
|
var device = new RecordingGpuDevice();
|
||||||
|
var renderer = new TextRenderer(device, new NullGpuFrameSourceForMenuTests(), "unused");
|
||||||
|
renderer.Begin(new Vector2(200f, 200f));
|
||||||
|
var ctx = new UiRenderContext(renderer, new Vector2(200f, 200f));
|
||||||
|
menu.DrawOverlays(ctx);
|
||||||
|
|
||||||
|
int TrackQuads() => renderer.DebugSpriteSegmentVerts
|
||||||
|
.Where(s => s.Texture == RetailScrollbarChrome.Track)
|
||||||
|
.Sum(s => s.Verts.Count) / 48;
|
||||||
|
Assert.True(TrackQuads() > 0, "expected the overflowing popup to draw the retail scrollbar track");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Menu_Markup_FewItems_DrawsNoScrollbarChrome_OnOpen()
|
||||||
|
{
|
||||||
|
var panel = MarkupDocument.Build(MenuXml(styleAttribute: ""), new MenuStyleBinding(), id => (id, 8, 8));
|
||||||
|
var menu = Assert.IsType<UiMenu>(panel.Children[0]);
|
||||||
|
|
||||||
|
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, Data1: 10, Data2: 10)));
|
||||||
|
Assert.True(menu.IsOpen);
|
||||||
|
|
||||||
|
var device = new RecordingGpuDevice();
|
||||||
|
var renderer = new TextRenderer(device, new NullGpuFrameSourceForMenuTests(), "unused");
|
||||||
|
renderer.Begin(new Vector2(200f, 200f));
|
||||||
|
var ctx = new UiRenderContext(renderer, new Vector2(200f, 200f));
|
||||||
|
menu.DrawOverlays(ctx);
|
||||||
|
|
||||||
|
int TrackQuads() => renderer.DebugSpriteSegmentVerts
|
||||||
|
.Where(s => s.Texture == RetailScrollbarChrome.Track)
|
||||||
|
.Sum(s => s.Verts.Count) / 48;
|
||||||
|
Assert.Equal(0, TrackQuads());
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class NullGpuFrameSourceForMenuTests : ICurrentGpuFrameSource
|
||||||
|
{
|
||||||
|
public IGpuFrame? CurrentFrame => null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
302
tests/AcDream.App.Tests/UI/UiMarkupListScrollbarTests.cs
Normal file
302
tests/AcDream.App.Tests/UI/UiMarkupListScrollbarTests.cs
Normal file
|
|
@ -0,0 +1,302 @@
|
||||||
|
using System.Linq;
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Owner live-client report 2026-09-07 ("For scrollable dropdown or the meta
|
||||||
|
/// window we use the same assets as we do in for example chat or inventory
|
||||||
|
/// window"): a plugin-markup <c><list></c> (<see cref="UiMarkupList"/>)
|
||||||
|
/// that overflows its own row viewport now draws the same
|
||||||
|
/// <see cref="RetailScrollbarChrome"/> sprites the chat SpewBox and the
|
||||||
|
/// inventory <c>UiItemList</c> use, docked at the list's own right edge
|
||||||
|
/// (VVS's own placement: 16px wide). Before this, an overflowing list only
|
||||||
|
/// scrolled by mouse wheel with no visible bar at all.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Covers: sprite emission gated on actual overflow (a fitting list emits
|
||||||
|
/// none), the reserved 16px column shrink applying ONLY when the bar shows
|
||||||
|
/// (both single-column and multi-column layout), and that the bar is fully
|
||||||
|
/// interactive (up/down arrow clicks and a thumb drag both move
|
||||||
|
/// <c>_topRow</c>, provable the same way <c>MarkupListColumnsTests</c>'
|
||||||
|
/// own <c>Scroll_OffsetIsRespectedBySubsequentHitTests</c> proves wheel
|
||||||
|
/// scrolling: read the moved position back through a subsequent row
|
||||||
|
/// click/select).
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UiMarkupListScrollbarTests
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
private const int FloatsPerQuad = 48; // 6 vertices/quad x 8 floats/vertex (AppendQuad).
|
||||||
|
|
||||||
|
private static int QuadCount(
|
||||||
|
System.Collections.Generic.IReadOnlyList<(uint Texture, System.Collections.Generic.IReadOnlyList<float> Verts)> segs,
|
||||||
|
uint texture)
|
||||||
|
=> segs.Where(s => s.Texture == texture).Sum(s => s.Verts.Count) / FloatsPerQuad;
|
||||||
|
|
||||||
|
// A distinctive resolver: every sprite id resolves to itself as the
|
||||||
|
// texture (so QuadCount(segs, id) proves that EXACT chrome id drew),
|
||||||
|
// with a non-zero native size so DrawTiled/DrawSprite never no-op.
|
||||||
|
private static (uint tex, int w, int h) Resolve(uint id) => (id, 16, 16);
|
||||||
|
|
||||||
|
// ── Single-column mode ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SingleColumn_Overflowing_DrawsRetailScrollbarChromeAtRightEdge()
|
||||||
|
{
|
||||||
|
var list = new UiMarkupList
|
||||||
|
{
|
||||||
|
Width = 100f, Height = 40f, RowHeight = 18f, // 2 visible rows
|
||||||
|
SpriteResolve = Resolve,
|
||||||
|
SelectedIndexSource = () => -1,
|
||||||
|
ItemsSource = () => Enumerable.Range(0, 10).Select(i => $"row{i}").ToArray(),
|
||||||
|
};
|
||||||
|
var (renderer, ctx) = MakeContext(200f, 200f);
|
||||||
|
|
||||||
|
list.DrawSelfAndChildren(ctx);
|
||||||
|
var segs = renderer.DebugSpriteSegmentVerts;
|
||||||
|
|
||||||
|
Assert.Equal(1, QuadCount(segs, RetailScrollbarChrome.Track));
|
||||||
|
Assert.Equal(1, QuadCount(segs, RetailScrollbarChrome.UpNormal));
|
||||||
|
Assert.Equal(1, QuadCount(segs, RetailScrollbarChrome.DownNormal));
|
||||||
|
|
||||||
|
// Track drawn at the list's own right edge, reserving 16px, full height.
|
||||||
|
var trackSeg = Assert.Single(segs, s => s.Texture == RetailScrollbarChrome.Track);
|
||||||
|
Assert.Equal(84f, trackSeg.Verts[0], 2); // x = Width(100) - 16
|
||||||
|
Assert.Equal(0f, trackSeg.Verts[1], 2);
|
||||||
|
Assert.Equal(100f, trackSeg.Verts[8], 2); // x + w = Width
|
||||||
|
Assert.Equal(40f, trackSeg.Verts[9], 2); // y + h = Height
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SingleColumn_ContentFits_DrawsNoScrollbarChromeAtAll()
|
||||||
|
{
|
||||||
|
var list = new UiMarkupList
|
||||||
|
{
|
||||||
|
Width = 100f, Height = 40f, RowHeight = 18f, // 2 visible rows
|
||||||
|
SpriteResolve = Resolve,
|
||||||
|
SelectedIndexSource = () => -1,
|
||||||
|
ItemsSource = () => new[] { "only-one-row" },
|
||||||
|
};
|
||||||
|
var (renderer, ctx) = MakeContext(200f, 200f);
|
||||||
|
|
||||||
|
list.DrawSelfAndChildren(ctx);
|
||||||
|
var segs = renderer.DebugSpriteSegmentVerts;
|
||||||
|
|
||||||
|
Assert.Equal(0, QuadCount(segs, RetailScrollbarChrome.Track));
|
||||||
|
Assert.Equal(0, QuadCount(segs, RetailScrollbarChrome.UpNormal));
|
||||||
|
Assert.Equal(0, QuadCount(segs, RetailScrollbarChrome.DownNormal));
|
||||||
|
Assert.Equal(0, QuadCount(segs, RetailScrollbarChrome.ThumbMidNormal));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SingleColumn_NoSpriteResolveWired_DrawsNoScrollbar_NoCrash()
|
||||||
|
{
|
||||||
|
// A hand-built list with no host resolver (SpriteResolve stays null)
|
||||||
|
// must not throw and must draw no chrome at all — matches the
|
||||||
|
// pre-existing "wheel-only, no visible bar" contract for that case.
|
||||||
|
var list = new UiMarkupList
|
||||||
|
{
|
||||||
|
Width = 100f, Height = 40f, RowHeight = 18f,
|
||||||
|
SelectedIndexSource = () => -1,
|
||||||
|
ItemsSource = () => Enumerable.Range(0, 10).Select(i => $"row{i}").ToArray(),
|
||||||
|
};
|
||||||
|
var (renderer, ctx) = MakeContext(200f, 200f);
|
||||||
|
|
||||||
|
list.DrawSelfAndChildren(ctx);
|
||||||
|
var segs = renderer.DebugSpriteSegmentVerts;
|
||||||
|
|
||||||
|
// The background fill/border still draw (untextured, id 0) — only
|
||||||
|
// the scrollbar chrome itself is gated on a resolver being wired.
|
||||||
|
Assert.Equal(0, QuadCount(segs, RetailScrollbarChrome.Track));
|
||||||
|
Assert.Equal(0, QuadCount(segs, RetailScrollbarChrome.UpNormal));
|
||||||
|
Assert.Equal(0, QuadCount(segs, RetailScrollbarChrome.DownNormal));
|
||||||
|
Assert.Equal(0, QuadCount(segs, RetailScrollbarChrome.ThumbMidNormal));
|
||||||
|
Assert.DoesNotContain(segs, s => s.Texture != 0u);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SingleColumn_UpArrowClick_ScrollsUpByOneRow()
|
||||||
|
{
|
||||||
|
var list = new UiMarkupList
|
||||||
|
{
|
||||||
|
Width = 100f, Height = 40f, RowHeight = 18f, // 2 visible rows of 10
|
||||||
|
SpriteResolve = Resolve,
|
||||||
|
SelectedIndexSource = () => -1,
|
||||||
|
ItemsSource = () => Enumerable.Range(0, 10).Select(i => $"row{i}").ToArray(),
|
||||||
|
};
|
||||||
|
var (_, ctx) = MakeContext(200f, 200f);
|
||||||
|
list.DrawSelfAndChildren(ctx);
|
||||||
|
|
||||||
|
// Scroll down 3 rows via wheel first (proven convention from
|
||||||
|
// MarkupListColumnsTests), then click the up arrow once and confirm
|
||||||
|
// a row click resolves one row higher.
|
||||||
|
for (int i = 0; i < 3; i++)
|
||||||
|
list.OnEvent(new UiEvent { Type = UiEventType.Scroll, Data0 = -1 });
|
||||||
|
list.DrawSelfAndChildren(ctx);
|
||||||
|
|
||||||
|
int? selected = null;
|
||||||
|
list.SelectionChanged = row => selected = row;
|
||||||
|
|
||||||
|
// Up-arrow button occupies the scrollbar's own top 16px, x in
|
||||||
|
// [84,100).
|
||||||
|
Assert.True(list.OnEvent(new UiEvent
|
||||||
|
{
|
||||||
|
Type = UiEventType.MouseDown, Data1 = 90, Data2 = 5,
|
||||||
|
}));
|
||||||
|
list.DrawSelfAndChildren(ctx);
|
||||||
|
|
||||||
|
// Row 0 of the (now one-row-higher) view is absolute row 2.
|
||||||
|
list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 10, Data2 = 2 });
|
||||||
|
Assert.Equal(2, selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SingleColumn_ThumbDrag_MovesTopRowAndIsReadableByASubsequentClick()
|
||||||
|
{
|
||||||
|
var list = new UiMarkupList
|
||||||
|
{
|
||||||
|
Width = 100f, Height = 40f, RowHeight = 18f, // 2 of 10 rows visible
|
||||||
|
SpriteResolve = Resolve,
|
||||||
|
SelectedIndexSource = () => -1,
|
||||||
|
ItemsSource = () => Enumerable.Range(0, 10).Select(i => $"row{i}").ToArray(),
|
||||||
|
};
|
||||||
|
var (_, ctx) = MakeContext(200f, 200f);
|
||||||
|
list.DrawSelfAndChildren(ctx);
|
||||||
|
|
||||||
|
// Track spans y in [16,24) (Height 40 - 16 up - 16 down = 8px track,
|
||||||
|
// thumb ratio 2/10=0.2 but floored to the 8px MinThumb). Press
|
||||||
|
// squarely inside the thumb (drawn at the very top initially) then
|
||||||
|
// drag to the bottom of the track to scroll to the end.
|
||||||
|
Assert.True(list.OnEvent(new UiEvent
|
||||||
|
{
|
||||||
|
Type = UiEventType.MouseDown, Data1 = 90, Data2 = 18,
|
||||||
|
}));
|
||||||
|
list.OnEvent(new UiEvent { Type = UiEventType.MouseMove, Data1 = 90, Data2 = 40 });
|
||||||
|
list.OnEvent(new UiEvent { Type = UiEventType.MouseUp, Data1 = 90, Data2 = 40 });
|
||||||
|
list.DrawSelfAndChildren(ctx);
|
||||||
|
|
||||||
|
int? selected = null;
|
||||||
|
list.SelectionChanged = row => selected = row;
|
||||||
|
// Click the FIRST visible row after dragging to the end — must
|
||||||
|
// resolve to the last possible top row (10-2=8), not row 0.
|
||||||
|
list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 10, Data2 = 2 });
|
||||||
|
Assert.Equal(8, selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Multi-column mode ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Columns_Overflowing_ReservesSixteenPixels_LastColumnShrinksAccordingly()
|
||||||
|
{
|
||||||
|
// 100px-wide list, one fixed 20px column + one auto (last) column
|
||||||
|
// that would otherwise absorb 80px; with the bar reserved it must
|
||||||
|
// absorb only 80-16=64px.
|
||||||
|
var list = new UiMarkupList
|
||||||
|
{
|
||||||
|
Width = 100f, Height = 40f, RowHeight = 18f, // 2 visible rows
|
||||||
|
SpriteResolve = Resolve,
|
||||||
|
SelectedIndexSource = () => -1,
|
||||||
|
Columns = new[]
|
||||||
|
{
|
||||||
|
UiMarkupListColumn.Text(20f, () => Enumerable.Range(0, 10).Select(i => $"a{i}").ToArray(), null),
|
||||||
|
UiMarkupListColumn.Icon(
|
||||||
|
0f, () => Enumerable.Range(0, 10).Select(i => (uint)(i + 1)).ToArray(),
|
||||||
|
id => (id, 16, 16), _ => { }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
var (renderer, ctx) = MakeContext(200f, 200f);
|
||||||
|
|
||||||
|
list.DrawSelfAndChildren(ctx);
|
||||||
|
var segs = renderer.DebugSpriteSegmentVerts;
|
||||||
|
|
||||||
|
Assert.Equal(1, QuadCount(segs, RetailScrollbarChrome.Track));
|
||||||
|
|
||||||
|
// The icon column's cell now runs [20,84) (100-16 scrollbar): 62px
|
||||||
|
// usable extent (cellW-2), scale=1 (16px icon fits), centered ->
|
||||||
|
// x = 20 + 1 + (62-16)/2 = 44.
|
||||||
|
var iconQuad = Assert.Single(segs, s => s.Texture == 1u);
|
||||||
|
Assert.Equal(44f, iconQuad.Verts[0], 2);
|
||||||
|
Assert.True(iconQuad.Verts[8] <= 84f + 0.01f,
|
||||||
|
$"expected the icon column's cell to shrink for the reserved scrollbar, got right edge {iconQuad.Verts[8]}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Columns_ContentFits_NoReservation_LastColumnKeepsFullRemainder()
|
||||||
|
{
|
||||||
|
var list = new UiMarkupList
|
||||||
|
{
|
||||||
|
Width = 100f, Height = 40f, RowHeight = 18f, // 2 visible rows, 1 row of data
|
||||||
|
SpriteResolve = Resolve,
|
||||||
|
SelectedIndexSource = () => -1,
|
||||||
|
Columns = new[]
|
||||||
|
{
|
||||||
|
UiMarkupListColumn.Text(20f, () => new[] { "a" }, null),
|
||||||
|
UiMarkupListColumn.Icon(0f, () => new uint[] { 1u }, id => (id, 16, 16), _ => { }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
var (renderer, ctx) = MakeContext(200f, 200f);
|
||||||
|
|
||||||
|
list.DrawSelfAndChildren(ctx);
|
||||||
|
var segs = renderer.DebugSpriteSegmentVerts;
|
||||||
|
|
||||||
|
Assert.Equal(0, QuadCount(segs, RetailScrollbarChrome.Track));
|
||||||
|
// No reservation: the cell is the full [20,100) 80px (78px usable
|
||||||
|
// extent), scale still clamps to 1 (16px icon), but centered in the
|
||||||
|
// WIDER cell it lands further right than the reserved case's x=44:
|
||||||
|
// x = 20 + 1 + (78-16)/2 = 52.
|
||||||
|
var iconQuad = Assert.Single(segs, s => s.Texture == 1u);
|
||||||
|
Assert.Equal(52f, iconQuad.Verts[0], 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Columns_Overflowing_UpArrowClick_ScrollsUpByOneRow()
|
||||||
|
{
|
||||||
|
var list = new UiMarkupList
|
||||||
|
{
|
||||||
|
Width = 100f, Height = 40f, RowHeight = 18f, // 2 of 10 rows visible
|
||||||
|
SpriteResolve = Resolve,
|
||||||
|
SelectedIndexSource = () => -1,
|
||||||
|
Columns = new[]
|
||||||
|
{
|
||||||
|
UiMarkupListColumn.Text(100f, () => Enumerable.Range(0, 10).Select(i => $"a{i}").ToArray(), null),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
var (_, ctx) = MakeContext(200f, 200f);
|
||||||
|
list.DrawSelfAndChildren(ctx);
|
||||||
|
|
||||||
|
for (int i = 0; i < 3; i++)
|
||||||
|
list.OnEvent(new UiEvent { Type = UiEventType.Scroll, Data0 = -1 });
|
||||||
|
list.DrawSelfAndChildren(ctx);
|
||||||
|
|
||||||
|
int? selected = null;
|
||||||
|
list.SelectionChanged = row => selected = row;
|
||||||
|
|
||||||
|
Assert.True(list.OnEvent(new UiEvent
|
||||||
|
{
|
||||||
|
Type = UiEventType.MouseDown, Data1 = 90, Data2 = 5,
|
||||||
|
}));
|
||||||
|
list.DrawSelfAndChildren(ctx);
|
||||||
|
|
||||||
|
list.OnEvent(new UiEvent { Type = UiEventType.MouseDown, Data1 = 10, Data2 = 2 });
|
||||||
|
Assert.Equal(2, selected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -359,8 +359,17 @@ public sealed class UiMenuPlainStyleTests
|
||||||
Assert.Equal(RetailChromeSprites.Border + UiMenu.PlainPadding, glyphSeg.Verts[0], 3);
|
Assert.Equal(RetailChromeSprites.Border + UiMenu.PlainPadding, glyphSeg.Verts[0], 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Owner live-client report 2026-09-07 ("For scrollable dropdown or the
|
||||||
|
// meta window we use the same assets as we do in for example chat or
|
||||||
|
// inventory window"): the plain popup's SCROLLBAR now draws retail's own
|
||||||
|
// chrome (the exact sprite ids RetailScrollbarChrome wires onto
|
||||||
|
// chat/inventory's own bar) — only the ROWS stayed plain. These two tests
|
||||||
|
// used to pin a fully flat/untextured scrollbar; they now pin the
|
||||||
|
// opposite: real sprite draws for the bar, untouched plain fills for the
|
||||||
|
// rows, and no visible bar at all once the content fits.
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Plain_OpenPopup_ScrollableOverflow_DrawsPlainTrackAndFlatThumb_NoDatArt()
|
public void Plain_OpenPopup_ScrollableOverflow_DrawsRetailScrollbarChrome_RowsStayPlain()
|
||||||
{
|
{
|
||||||
int resolveCalls = 0;
|
int resolveCalls = 0;
|
||||||
var menu = MakePopupMenu(retailButtonArt: false, itemCount: 12, rowsPerColumn: 5, scrollable: true,
|
var menu = MakePopupMenu(retailButtonArt: false, itemCount: 12, rowsPerColumn: 5, scrollable: true,
|
||||||
|
|
@ -373,27 +382,39 @@ public sealed class UiMenuPlainStyleTests
|
||||||
var segs = renderer.DebugSpriteSegmentVerts;
|
var segs = renderer.DebugSpriteSegmentVerts;
|
||||||
|
|
||||||
Assert.True(menu.PopupScroll.HasOverflow);
|
Assert.True(menu.PopupScroll.HasOverflow);
|
||||||
Assert.Equal(0, resolveCalls);
|
// Track + up + down + thumb top/mid/bottom = 6 resolved sprite ids —
|
||||||
Assert.Equal(0, QuadCount(segs, menu.ScrollTrackSprite));
|
// the SAME chrome ids the chat/inventory scrollbar resolves through
|
||||||
Assert.Equal(0, QuadCount(segs, menu.ScrollThumbSprite));
|
// the same SpriteResolve seam, no longer the flat DrawFill-only path.
|
||||||
|
Assert.Equal(6, resolveCalls);
|
||||||
|
Assert.Equal(1, QuadCount(segs, menu.ScrollTrackSprite));
|
||||||
|
Assert.Equal(1, QuadCount(segs, menu.ScrollUpSprite));
|
||||||
|
Assert.Equal(1, QuadCount(segs, menu.ScrollDownSprite));
|
||||||
|
Assert.Equal(1, QuadCount(segs, menu.ScrollThumbTopSprite));
|
||||||
|
Assert.Equal(1, QuadCount(segs, menu.ScrollThumbSprite));
|
||||||
|
Assert.Equal(1, QuadCount(segs, menu.ScrollThumbBottomSprite));
|
||||||
|
|
||||||
float outerTop = menu.Height;
|
float outerTop = menu.Height;
|
||||||
float inX = RetailChromeSprites.Border, inY = outerTop + RetailChromeSprites.Border;
|
float inX = RetailChromeSprites.Border, inY = outerTop + RetailChromeSprites.Border;
|
||||||
float scrollbarX = inX + PlainColumnWidth;
|
|
||||||
|
|
||||||
|
// The rows are untouched by the chrome swap: still a plain fill, no
|
||||||
|
// DAT row/checkbox art at all (RetailButtonArt=false's own contract).
|
||||||
Assert.True(HasFillQuad(segs, inX, inY, PlainColumnWidth, PlainRowHeight, menu.PlainSelectedColor),
|
Assert.True(HasFillQuad(segs, inX, inY, PlainColumnWidth, PlainRowHeight, menu.PlainSelectedColor),
|
||||||
"expected visible row 0 (selected/current) filled with PlainSelectedColor");
|
"expected visible row 0 (selected/current) still filled with PlainSelectedColor");
|
||||||
Assert.True(HasFillQuad(segs, scrollbarX, inY, menu.ScrollbarWidth, 5 * PlainRowHeight, menu.PlainBackgroundColor),
|
Assert.Equal(0, QuadCount(segs, menu.ItemHighlightSprite));
|
||||||
"expected the scrollbar track background fill");
|
Assert.Equal(0, QuadCount(segs, menu.ItemNormalSprite));
|
||||||
|
|
||||||
// popup bg(1)+outline(4) + selected row(1) + scrollbar bg(1)+outline(4) + thumb(1) = 12.
|
// popup bg(1)+outline(4) + selected row(1) = 6 untextured quads;
|
||||||
Assert.Equal(12, QuadCount(segs, 0u));
|
// the scrollbar itself no longer contributes any (it is all sprite
|
||||||
|
// draws now).
|
||||||
|
Assert.Equal(6, QuadCount(segs, 0u));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Plain_ScrollablePopup_ContentFits_DrawsTrackWithNoThumb()
|
public void Plain_ScrollablePopup_ContentFits_DrawsNoScrollbarAtAll()
|
||||||
{
|
{
|
||||||
var menu = MakePopupMenu(retailButtonArt: false, itemCount: 3, rowsPerColumn: 5, scrollable: true);
|
int resolveCalls = 0;
|
||||||
|
var menu = MakePopupMenu(retailButtonArt: false, itemCount: 3, rowsPerColumn: 5, scrollable: true,
|
||||||
|
countResolveCall: n => resolveCalls += n);
|
||||||
OpenAndHover(menu);
|
OpenAndHover(menu);
|
||||||
|
|
||||||
var (renderer, ctx) = MakeContext(200f, 200f);
|
var (renderer, ctx) = MakeContext(200f, 200f);
|
||||||
|
|
@ -402,9 +423,18 @@ public sealed class UiMenuPlainStyleTests
|
||||||
|
|
||||||
Assert.False(menu.PopupScroll.HasOverflow);
|
Assert.False(menu.PopupScroll.HasOverflow);
|
||||||
|
|
||||||
// popup bg(1)+outline(4) + scrollbar bg(1)+outline(4) = 10, no thumb quad
|
// Content-fits still draws the track + up/down buttons (retail's own
|
||||||
// (nothing selected/hovered here either).
|
// proportion-0x88-defaults-to-1.0 rule — a content-fits bar shows a
|
||||||
Assert.Equal(10, QuadCount(segs, 0u));
|
// full-track thumb elsewhere in this class), but no thumb: 3 resolves.
|
||||||
|
Assert.Equal(3, resolveCalls);
|
||||||
|
Assert.Equal(1, QuadCount(segs, menu.ScrollTrackSprite));
|
||||||
|
Assert.Equal(1, QuadCount(segs, menu.ScrollUpSprite));
|
||||||
|
Assert.Equal(1, QuadCount(segs, menu.ScrollDownSprite));
|
||||||
|
Assert.Equal(0, QuadCount(segs, menu.ScrollThumbSprite));
|
||||||
|
|
||||||
|
// popup bg(1)+outline(4) = 5 untextured quads (nothing
|
||||||
|
// selected/hovered here either, and the scrollbar draws no fills).
|
||||||
|
Assert.Equal(5, QuadCount(segs, 0u));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue