From 67aba8c386d6dde87eb54d816ae6b16a622a37ce Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 7 Sep 2026 15:42:25 +0200 Subject: [PATCH 1/2] fix: plain popup scrollbar draws retail chrome, not a flat bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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-style popup's scrollable-overflow scrollbar (DrawScrollablePopupPlain / DrawPopupScrollbarPlain in UiMenu.cs) drew a home-made flat 1px track + flat thumb instead of the gold track + up/down arrow buttons + thumb the chat SpewBox and inventory UiItemList already use through RetailScrollbarChrome. The owner only ever objected to the retail ROW art (checkmark glyph, gradient panel) — the bar itself was never in scope for the plain-row fix, so this change touches only the scrollbar draw call and leaves the plain row rendering untouched. DrawScrollablePopupPlain now calls the existing DrawPopupScrollbar helper (the same procedural sprite-chrome draw VendorUiController/ ConfigOptionsPageController already use) whenever a SpriteResolve is wired, falling back to the old flat DrawPopupScrollbarPlain only for a hand-built UiMenu with no resolver at all. New RetailScrollbarChrome.ApplyToMenuPopup(UiMenu) wires the same vertical skin ids (Track/Up/Down/ThumbTop/Mid/Bot Normal) the chat/inventory scrollbar uses onto a menu's own ScrollTrackSprite/etc properties. Mutation shown to fail first: UiMenuPlainStyleTests's Plain_OpenPopup_ScrollableOverflow_DrawsPlainTrackAndFlatThumb_NoDatArt and Plain_ScrollablePopup_ContentFits_DrawsTrackWithNoThumb asserted resolveCalls==0 and an all-fill scrollbar — both failed (6 resolve calls, 6 sprite quads instead of 0) against the new DrawPopupScrollbar call before being rewritten to Plain_OpenPopup_ScrollableOverflow_DrawsRetailScrollbarChrome_RowsStayPlain and Plain_ScrollablePopup_ContentFits_DrawsNoScrollbarAtAll, which pin the new sprite-chrome behavior (6 resolved ids on overflow: track, up, down, thumb top/mid/bottom; 3 on content-fits: track+up+down, no thumb; 0 on a menu built with no resolver) while re-asserting the rows are still plain fills with zero retail row-sprite quads. Retail's own RetailButtonArt=true popup path (DrawGridPopup/DrawScrollablePopup) is untouched — its regression golden (Retail_OpenPopup_DrawIsByteForByteUnchanged_RegressionGolden) still passes byte-for-byte. Co-Authored-By: Claude Fable 5.1 --- src/AcDream.App/UI/RetailScrollbarChrome.cs | 21 +++++++ src/AcDream.App/UI/UiMenu.cs | 41 +++++++++---- .../UI/UiMenuPlainStyleTests.cs | 60 ++++++++++++++----- 3 files changed, 96 insertions(+), 26 deletions(-) diff --git a/src/AcDream.App/UI/RetailScrollbarChrome.cs b/src/AcDream.App/UI/RetailScrollbarChrome.cs index 0dc3623d0..5c427027a 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/UiMenu.cs b/src/AcDream.App/UI/UiMenu.cs index 95a03f664..f183310b4 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/UiMenuPlainStyleTests.cs b/tests/AcDream.App.Tests/UI/UiMenuPlainStyleTests.cs index 66c5bf689..98d097386 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] From e9108277c4e8438bf3555c4fa0b0a54e38d0b993 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 7 Sep 2026 15:43:00 +0200 Subject: [PATCH 2/2] 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 c779859a4..51b72a24c 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 3d14d983a..3dc76d2e4 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 09dffb6f0..967a4165b 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 1a1f864fd..c40591975 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 000000000..4bea75c51 --- /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); + } +}