diff --git a/docs/plugin-ui-markup.md b/docs/plugin-ui-markup.md index c779859a..51b72a24 100644 --- a/docs/plugin-ui-markup.md +++ b/docs/plugin-ui-markup.md @@ -125,10 +125,17 @@ scrollbar, and a baked checkmark glyph on the current entry even with `style="plain"`. The open popup now matches ``'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 `` 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 ``'s rows overflow its own height (either the single-column or +the `` 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 diff --git a/src/AcDream.App/UI/MarkupDocument.cs b/src/AcDream.App/UI/MarkupDocument.cs index 3d14d983..3dc76d2e 100644 --- a/src/AcDream.App/UI/MarkupDocument.cs +++ b/src/AcDream.App/UI/MarkupDocument.cs @@ -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 + // 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, diff --git a/src/AcDream.App/UI/RetailScrollbarChrome.cs b/src/AcDream.App/UI/RetailScrollbarChrome.cs index 0dc3623d..5c427027 100644 --- a/src/AcDream.App/UI/RetailScrollbarChrome.cs +++ b/src/AcDream.App/UI/RetailScrollbarChrome.cs @@ -84,6 +84,27 @@ internal static class RetailScrollbarChrome bar.ThumbBotPressedSprite = ThumbBotPressed; } + /// + /// Wires the retail vertical skin onto a popup's own + /// procedural scrollbar properties ( + /// 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 DrawPopupScrollbar draws a + /// simpler Normal-only chrome (no hover/pressed states — matching how + /// VendorUiController/ConfigOptionsPageController already + /// wire these exact ids), so only the Normal-state constants are needed + /// here. + /// + internal static void ApplyToMenuPopup(UiMenu menu) + { + menu.ScrollTrackSprite = Track; + menu.ScrollThumbTopSprite = ThumbTopNormal; + menu.ScrollThumbSprite = ThumbMidNormal; + menu.ScrollThumbBottomSprite = ThumbBotNormal; + menu.ScrollUpSprite = UpNormal; + menu.ScrollDownSprite = DownNormal; + } + /// Wires the full retail horizontal skin onto . /// The leading () slot is the LEFT edge. internal static void ApplyHorizontal(UiScrollbar bar) diff --git a/src/AcDream.App/UI/UiMarkupList.cs b/src/AcDream.App/UI/UiMarkupList.cs index 09dffb6f..967a4165 100644 --- a/src/AcDream.App/UI/UiMarkupList.cs +++ b/src/AcDream.App/UI/UiMarkupList.cs @@ -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); + /// + /// 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 + /// () 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. + /// + public Func? SpriteResolve { get; set; } + + /// Reserved width of the retail scrollbar column — VVS's own + /// convention (right edge, 16px), matching 's + /// authored value and 's vertical skin. + private const float ScrollbarWidth = 16f; + + /// Button extent along the scrollbar's own axis — same authored + /// 16px convention uses. + private const float ScrollButtonExtent = 16f; + private int _topRow; private IReadOnlyList? _columns; + /// + /// Pixel-based scroll projection used ONLY to feed + /// 's geometry math (thumb + /// size/position) from this list's own — 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 immediately before every draw or + /// scrollbar hit-test, never driven independently. + /// + 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?[] _cachedTextRows = Array.Empty?>(); @@ -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 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. + + /// Re-syncs 's content/view extents and + /// offset from this list's own — call immediately + /// before reading its geometry. + 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); + } + } + + /// Draw a sprite stretched 1:1 to the dest rect — same + /// convention / use for + /// their own button/thumb-cap art. + private static void DrawFlatSprite( + UiRenderContext ctx, Func 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); + } + + /// 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. + private static void DrawTiledSprite( + UiRenderContext ctx, Func 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); + } + + /// + /// Scrollbar hit-testing shared by the legacy single-column + /// and — arrows, track + /// paging, and thumb drag all work exactly as they do in + /// /the chat window's own docked bar, driving + /// this list's own . 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. + /// + 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); + } } diff --git a/src/AcDream.App/UI/UiMenu.cs b/src/AcDream.App/UI/UiMenu.cs index 95a03f66..f183310b 100644 --- a/src/AcDream.App/UI/UiMenu.cs +++ b/src/AcDream.App/UI/UiMenu.cs @@ -884,9 +884,19 @@ public sealed class UiMenu : UiElement } /// Plain counterpart of — same - /// -sliced single column, plain - /// selected/hover row fills, and a plain scrollbar - /// () instead of the sprite chrome. + /// -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 + /// helper — the exact ids + /// wires onto etc, the SAME sprite ids the + /// chat SpewBox/inventory scrollbar uses. Rows + /// stay plain by design (the owner accepted the flat dark list; only the + /// bar was objectionable). A menu built with no + /// at all (a hand-built test/legacy fixture) falls back to the fully + /// flat rather than silently + /// drawing nothing. 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); } /// - /// Plain counterpart of : a 1px-bordered - /// track and a flat thumb, both in — no DAT - /// thumb/track/arrow-button art at all. Shares the exact same - /// geometry (so the thumb's drawn - /// position matches '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 's + /// own doc comment — the 2026-09-07 owner directive moved the normal + /// plain-popup scrollbar to retail's own chrome via + /// ). This draws a 1px-bordered track and + /// a flat thumb, both in — no DAT + /// thumb/track/arrow-button art at all — for the rare case a + /// is built with true but no + /// at all (a hand-built test/legacy fixture). + /// Shares the exact same geometry (so + /// the thumb's drawn position matches + /// '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 /// ( is unchanged). /// private void DrawPopupScrollbarPlain(UiRenderContext ctx, float x, float y) diff --git a/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs b/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs index 1a1f864f..c4059197 100644 --- a/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs +++ b/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs @@ -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 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 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(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(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 = + "" + + "" + + ""; + var panel = MarkupDocument.Build(xml, binding, id => (id, 8, 8)); + var menu = Assert.IsType(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(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; + } } diff --git a/tests/AcDream.App.Tests/UI/UiMarkupListScrollbarTests.cs b/tests/AcDream.App.Tests/UI/UiMarkupListScrollbarTests.cs new file mode 100644 index 00000000..4bea75c5 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiMarkupListScrollbarTests.cs @@ -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; + +/// +/// 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 <list> () +/// that overflows its own row viewport now draws the same +/// sprites the chat SpewBox and the +/// inventory UiItemList 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. +/// +/// +/// 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 +/// _topRow, provable the same way MarkupListColumnsTests' +/// own Scroll_OffsetIsRespectedBySubsequentHitTests proves wheel +/// scrolling: read the moved position back through a subsequent row +/// click/select). +/// +/// +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 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); + } +} diff --git a/tests/AcDream.App.Tests/UI/UiMenuPlainStyleTests.cs b/tests/AcDream.App.Tests/UI/UiMenuPlainStyleTests.cs index 66c5bf68..98d09738 100644 --- a/tests/AcDream.App.Tests/UI/UiMenuPlainStyleTests.cs +++ b/tests/AcDream.App.Tests/UI/UiMenuPlainStyleTests.cs @@ -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]