From e9108277c4e8438bf3555c4fa0b0a54e38d0b993 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 7 Sep 2026 15:43:00 +0200 Subject: [PATCH] feat: retail scrollbar chrome for markup overflow and overflow 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." markup wiring (MarkupDocument.cs): a plugin is now always Scrollable (single-column, VTank HudCombo shape) instead of wrapping overflow into more grid columns, with PopupScrollbarHideWhenDisabled=true so the bar is entirely absent while the item count fits the "rows" window. RetailScrollbarChrome.ApplyToMenuPopup wires the same chrome ids the previous commit taught DrawScrollablePopupPlain to draw, for both style="plain" and style="retail" markup menus. markup (UiMarkupList.cs / MarkupDocument.cs): a plugin (single-column or multi-column) that overflows its own row viewport now draws the retail scrollbar chrome at its right edge (VVS's own placement, 16px wide) instead of being wheel-scroll-only with no visible bar. The reserved 16px column only exists while rows actually overflow, in both column-layout modes (ComputeColumnLayout receives the already-shrunk width so the last/auto column absorbs the remainder correctly); the bar is fully interactive (up/down arrows, track paging, thumb drag) via a small UiScrollable projection kept in sync with the list's own _topRow, which stays the single source of truth. Wheel scrolling and a no-resolver hand-built list (draws nothing, no crash) are unchanged. Mutation shown to fail first: new UiMarkupListScrollbarTests/MarkupDocumentTests cases were written against pre-change UiMarkupList/MarkupDocument and failed (no scrollbar sprites ever emitted since UiMarkupList had no SpriteResolve property at all, and markup never set Scrollable) before the implementation landed; after: SingleColumn_Overflowing_DrawsRetailScrollbarChromeAtRightEdge and Columns_Overflowing_ReservesSixteenPixels_LastColumnShrinksAccordingly pin sprite ids + exact reserved-width geometry, *_ContentFits_DrawsNo(Scrollbar|ReservationLastColumnKeepsFullRemainder) pin the no-overflow/no-bar case, *_UpArrowClick_ScrollsUpByOneRow and ThumbDrag_MovesTopRowAndIsReadableByASubsequentClick pin interactivity via a following row click resolving to the moved position (mirroring MarkupListColumnsTests' own wheel-scroll pin), and the four new MarkupDocumentTests menu cases pin Scrollable/PopupScrollbarHideWhenDisabled/ the six chrome-id properties plus an end-to-end open-popup draw for both the overflowing (draws chrome) and non-overflowing (draws none) cases. Every pre-existing MarkupListColumnsTests/MarkupDocumentTests case stays green unchanged (none of their fixtures overflow their own viewport). docs/plugin-ui-markup.md updated: the style paragraph and a new "Scrollbar" section describe the new chrome + auto-reservation, and the PITCH-transcription guidance is corrected to say the 16px scrollbar column is now automatic (no more manual fold-in/double-reservation advice). Co-Authored-By: Claude Fable 5.1 --- docs/plugin-ui-markup.md | 50 ++- src/AcDream.App/UI/MarkupDocument.cs | 19 ++ src/AcDream.App/UI/UiMarkupList.cs | 239 +++++++++++++- .../UI/MarkupDocumentTests.cs | 102 ++++++ .../UI/UiMarkupListScrollbarTests.cs | 302 ++++++++++++++++++ 5 files changed, 691 insertions(+), 21 deletions(-) create mode 100644 tests/AcDream.App.Tests/UI/UiMarkupListScrollbarTests.cs 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/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/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); + } +}