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:
Erik 2026-09-07 15:44:26 +02:00
commit b71a8ea377
8 changed files with 787 additions and 47 deletions

View file

@ -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
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
lighter fill, and no checkmark; more entries than the row cap show a plain
1px-bordered scrollbar track with a flat thumb, no DAT scrollbar art.
`style="retail"` keeps the sprite popup (gradient panel, checkmark-bearing
row art, ornate scrollbar) exactly as before, unchanged.
lighter fill, and no checkmark. A `<menu>` popup always scrolls a single
column (rather than wrapping into more grid columns) once its item count
exceeds `rows`; a further owner directive (still 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") 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
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
`VScrollBarButtonSize=16px` (scrollbar width, reserved on the right). It
also forces every `CheckColumn` to a fixed 13px regardless of its declared
`fixedwidth`. acdream's column model has no separate gap/scrollbar/
forced-width concept — every column's declared `width` is its full cell
width, columns sit directly adjacent with no gap, and a check column uses
whatever `width` it's given like any other column.
`fixedwidth`. acdream's column model has no separate gap/forced-width
concept — every column's declared `width` is its full cell width, columns
sit directly adjacent with no gap, and a check column uses whatever `width`
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
`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
no separate gap). For a `CheckColumn`, use VVS's forced 13px as the
`fixedwidth` regardless of whatever `fixedwidth` the source XML declares
(`16 -> 13 + 7 = 20`, not `16 + 7 = 23`). Reserve VVS's 16px scrollbar width
on the LAST column specifically (add it to that column's own pitch, or fold
it into the list's total declared `w`) — acdream's list draws no scrollbar
of its own today, but reserving the space keeps the transcribed proportions
matching what a real VVS `HudList` would show once one exists.
(`16 -> 13 + 7 = 20`, not `16 + 7 = 23`). Do NOT also fold VVS's 16px
scrollbar width into the last column's pitch or the list's total `w` — the
list reserves that width itself, automatically, only while its rows
actually overflow (see "Scrollbar" below); doing both would double-reserve
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

View file

@ -482,6 +482,14 @@ public static class MarkupDocument
ItemNormalSprite = 0x0600124Eu,
ItemHighlightSprite = 0x0600124Du,
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,
OnSelect = payload =>
{
@ -489,6 +497,12 @@ public static class MarkupDocument
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()
{
menu.Items = menuItems()
@ -545,6 +559,11 @@ public static class MarkupDocument
Height = F(el, "h"),
RowHeight = Math.Max(12f, FOr(el, "rowheight", 18f)),
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(
(string?)el.Attribute("selected"),
binding,

View file

@ -84,6 +84,27 @@ internal static class RetailScrollbarChrome
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"/>.
/// The leading (<see cref="UiScrollbar.UpSprite"/>) slot is the LEFT edge.</summary>
internal static void ApplyHorizontal(UiScrollbar bar)

View file

@ -73,9 +73,46 @@ public sealed class UiMarkupList : UiElement
public Vector4 TextColor { get; set; } = new(0.91f, 0.87f, 0.76f, 1f);
public Vector4 SelectedColor { get; set; } = new(0.28f, 0.23f, 0.08f, 0.95f);
/// <summary>
/// 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 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,
// sized to Columns.Count by the Columns setter above) ────────────────────
private IReadOnlyList<string>?[] _cachedTextRows = Array.Empty<IReadOnlyList<string>?>();
@ -117,6 +154,9 @@ public sealed class UiMarkupList : UiElement
}
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.DrawRectOutline(0f, 0f, Width, Height, BorderColor, 1f);
int end = Math.Min(items.Count, _topRow + visibleRows);
@ -124,7 +164,7 @@ public sealed class UiMarkupList : UiElement
{
float y = (index - _topRow) * RowHeight;
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)
{
@ -160,6 +200,9 @@ public sealed class UiMarkupList : UiElement
else
context.DrawString(text, textX, textY, textColor);
}
if (showScrollbar)
DrawScrollbar(context, contentWidth, items.Count, visibleRows);
}
public override bool OnEvent(in UiEvent e)
@ -168,17 +211,24 @@ public sealed class UiMarkupList : UiElement
return OnEventColumns(e, columns);
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)
{
_topRow -= Math.Sign(e.Data0);
ClampTop(items.Count, VisibleRows);
ClampTop(items.Count, visibleRows);
return true;
}
if (e.Type != UiEventType.MouseDown || !Enabled)
return false;
int row = (int)MathF.Floor(e.Data2 / MathF.Max(1f, RowHeight));
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);
return true;
}
@ -304,9 +354,15 @@ public sealed class UiMarkupList : UiElement
}
_cachedRowCount = rowCount;
ComputeColumnLayout(columns, Width);
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();
if (selected >= 0 && selected < rowCount)
{
@ -325,7 +381,7 @@ public sealed class UiMarkupList : UiElement
{
float y = (index - _topRow) * RowHeight;
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++)
{
@ -362,6 +418,9 @@ public sealed class UiMarkupList : UiElement
}
}
}
if (showScrollbar)
DrawScrollbar(context, contentWidth, rowCount, visibleRows);
}
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
// crash.
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)
{
_topRow -= Math.Sign(e.Data0);
ClampTop(rowCount, VisibleRows);
ClampTop(rowCount, visibleRows);
return true;
}
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 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
float localX = e.Data1;
@ -515,4 +580,162 @@ public sealed class UiMarkupList : UiElement
}
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);
}
}

View file

@ -884,9 +884,19 @@ public sealed class UiMenu : UiElement
}
/// <summary>Plain counterpart of <see cref="DrawScrollablePopup"/> — same
/// <see cref="VisibleTopRow"/>-sliced single column, plain
/// selected/hover row fills, and a plain scrollbar
/// (<see cref="DrawPopupScrollbarPlain"/>) instead of the sprite chrome.</summary>
/// <see cref="VisibleTopRow"/>-sliced single column and plain
/// selected/hover row fills, but the SCROLLBAR itself draws retail's own
/// 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)
{
ConfigurePopupScroll();
@ -918,17 +928,26 @@ public sealed class UiMenu : UiElement
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>
/// Plain counterpart of <see cref="DrawPopupScrollbar"/>: a 1px-bordered
/// track and a flat thumb, both in <see cref="PlainBorderColor"/> — no DAT
/// thumb/track/arrow-button art at all. 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
/// NO-RESOLVER FALLBACK ONLY (see <see cref="DrawScrollablePopupPlain"/>'s
/// own doc comment — the 2026-09-07 owner directive moved the normal
/// plain-popup scrollbar to retail's own chrome via
/// <see cref="DrawPopupScrollbar"/>). This draws a 1px-bordered track and
/// a flat thumb, both in <see cref="PlainBorderColor"/> — no DAT
/// thumb/track/arrow-button art at all — for the rare case a
/// <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).
/// </summary>
private void DrawPopupScrollbarPlain(UiRenderContext ctx, float x, float y)

View file

@ -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;
namespace AcDream.App.Tests.UI;
@ -368,4 +373,101 @@ public class MarkupDocumentTests
Assert.Contains("menu", 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;
}
}

View 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>&lt;list&gt;</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);
}
}

View file

@ -359,8 +359,17 @@ public sealed class UiMenuPlainStyleTests
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]
public void Plain_OpenPopup_ScrollableOverflow_DrawsPlainTrackAndFlatThumb_NoDatArt()
public void Plain_OpenPopup_ScrollableOverflow_DrawsRetailScrollbarChrome_RowsStayPlain()
{
int resolveCalls = 0;
var menu = MakePopupMenu(retailButtonArt: false, itemCount: 12, rowsPerColumn: 5, scrollable: true,
@ -373,27 +382,39 @@ public sealed class UiMenuPlainStyleTests
var segs = renderer.DebugSpriteSegmentVerts;
Assert.True(menu.PopupScroll.HasOverflow);
Assert.Equal(0, resolveCalls);
Assert.Equal(0, QuadCount(segs, menu.ScrollTrackSprite));
Assert.Equal(0, QuadCount(segs, menu.ScrollThumbSprite));
// Track + up + down + thumb top/mid/bottom = 6 resolved sprite ids —
// the SAME chrome ids the chat/inventory scrollbar resolves through
// 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 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),
"expected visible row 0 (selected/current) filled with PlainSelectedColor");
Assert.True(HasFillQuad(segs, scrollbarX, inY, menu.ScrollbarWidth, 5 * PlainRowHeight, menu.PlainBackgroundColor),
"expected the scrollbar track background fill");
"expected visible row 0 (selected/current) still filled with PlainSelectedColor");
Assert.Equal(0, QuadCount(segs, menu.ItemHighlightSprite));
Assert.Equal(0, QuadCount(segs, menu.ItemNormalSprite));
// popup bg(1)+outline(4) + selected row(1) + scrollbar bg(1)+outline(4) + thumb(1) = 12.
Assert.Equal(12, QuadCount(segs, 0u));
// popup bg(1)+outline(4) + selected row(1) = 6 untextured quads;
// the scrollbar itself no longer contributes any (it is all sprite
// draws now).
Assert.Equal(6, QuadCount(segs, 0u));
}
[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);
var (renderer, ctx) = MakeContext(200f, 200f);
@ -402,9 +423,18 @@ public sealed class UiMenuPlainStyleTests
Assert.False(menu.PopupScroll.HasOverflow);
// popup bg(1)+outline(4) + scrollbar bg(1)+outline(4) = 10, no thumb quad
// (nothing selected/hovered here either).
Assert.Equal(10, QuadCount(segs, 0u));
// Content-fits still draws the track + up/down buttons (retail's own
// proportion-0x88-defaults-to-1.0 rule — a content-fits bar shows a
// 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]