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 `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

View file

@ -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,

View file

@ -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)

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 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);
}
} }

View file

@ -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)

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; 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;
}
} }

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); 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]