feat: retail scrollbar chrome for markup <menu> overflow and <list> 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." <menu> markup wiring (MarkupDocument.cs): a plugin <menu> 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. <list> markup (UiMarkupList.cs / MarkupDocument.cs): a plugin <list> (single-column or <column> 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 <menu> 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 <menu> style paragraph and a new <list> "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 <noreply@anthropic.com>
This commit is contained in:
parent
67aba8c386
commit
e9108277c4
5 changed files with 691 additions and 21 deletions
|
|
@ -482,6 +482,14 @@ public static class MarkupDocument
|
|||
ItemNormalSprite = 0x0600124Eu,
|
||||
ItemHighlightSprite = 0x0600124Du,
|
||||
RetailButtonArt = menuRetailButtonArt,
|
||||
// Owner live-client report 2026-09-07: a plugin dropdown
|
||||
// scrolls a single column (VTank's own HudCombo shape)
|
||||
// rather than wrapping into more grid columns once it
|
||||
// overflows its "rows" window; the scrollbar itself is
|
||||
// hidden entirely (0x79 semantics) while everything
|
||||
// fits, matching retail's vendor category popup.
|
||||
Scrollable = true,
|
||||
PopupScrollbarHideWhenDisabled = true,
|
||||
ButtonLabelProvider = () => menuSelected() ?? string.Empty,
|
||||
OnSelect = payload =>
|
||||
{
|
||||
|
|
@ -489,6 +497,12 @@ public static class MarkupDocument
|
|||
menuChanged?.Invoke(value);
|
||||
},
|
||||
};
|
||||
// The popup's own scrollbar always draws retail's chrome —
|
||||
// "we use the same assets as we do in for example chat or
|
||||
// inventory window" — regardless of RetailButtonArt (the
|
||||
// owner's earlier plain-row directive only ever covered the
|
||||
// ROWS, never this bar).
|
||||
RetailScrollbarChrome.ApplyToMenuPopup(menu);
|
||||
void RefreshMenu()
|
||||
{
|
||||
menu.Items = menuItems()
|
||||
|
|
@ -545,6 +559,11 @@ public static class MarkupDocument
|
|||
Height = F(el, "h"),
|
||||
RowHeight = Math.Max(12f, FOr(el, "rowheight", 18f)),
|
||||
DatFont = datFont,
|
||||
// Owner live-client report 2026-09-07: an overflowing
|
||||
// <list> draws the same retail scrollbar chrome the chat
|
||||
// window and inventory use — resolved through the same
|
||||
// sprite resolver every other markup sink already uses.
|
||||
SpriteResolve = resolve,
|
||||
SelectedIndexSource = BindRequiredIntReader(
|
||||
(string?)el.Attribute("selected"),
|
||||
binding,
|
||||
|
|
|
|||
|
|
@ -73,9 +73,46 @@ public sealed class UiMarkupList : UiElement
|
|||
public Vector4 TextColor { get; set; } = new(0.91f, 0.87f, 0.76f, 1f);
|
||||
public Vector4 SelectedColor { get; set; } = new(0.28f, 0.23f, 0.08f, 0.95f);
|
||||
|
||||
/// <summary>
|
||||
/// Owner live-client report 2026-09-07 ("For scrollable dropdown or the
|
||||
/// meta window we use the same assets as we do in for example chat or
|
||||
/// inventory window"): resolves the retail scrollbar chrome
|
||||
/// (<see cref="RetailScrollbarChrome"/>) drawn at the list's right edge
|
||||
/// once its rows overflow the viewport — the same resolver every other
|
||||
/// markup sink (icons, DAT fonts) already uses. Null (a hand-built list
|
||||
/// with no host resolver wired) keeps the list wheel-scrollable with no
|
||||
/// visible bar at all, exactly as before this fix.
|
||||
/// </summary>
|
||||
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
|
||||
|
||||
/// <summary>Reserved width of the retail scrollbar column — VVS's own
|
||||
/// convention (right edge, 16px), matching <see cref="UiMenu.ScrollbarWidth"/>'s
|
||||
/// authored value and <see cref="UiScrollbar"/>'s vertical skin.</summary>
|
||||
private const float ScrollbarWidth = 16f;
|
||||
|
||||
/// <summary>Button extent along the scrollbar's own axis — same authored
|
||||
/// 16px convention <see cref="UiScrollbar.DecrementButtonExtent"/> uses.</summary>
|
||||
private const float ScrollButtonExtent = 16f;
|
||||
|
||||
private int _topRow;
|
||||
private IReadOnlyList<UiMarkupListColumn>? _columns;
|
||||
|
||||
/// <summary>
|
||||
/// Pixel-based scroll projection used ONLY to feed
|
||||
/// <see cref="UiScrollbar.ThumbRect"/>'s geometry math (thumb
|
||||
/// size/position) from this list's own <see cref="_topRow"/> — the list
|
||||
/// itself remains the single source of truth for scroll position (every
|
||||
/// existing selection-follow/ClampTop/wheel path is unchanged); this is
|
||||
/// re-synced from <see cref="_topRow"/> immediately before every draw or
|
||||
/// scrollbar hit-test, never driven independently.
|
||||
/// </summary>
|
||||
private readonly UiScrollable _scroll = new();
|
||||
|
||||
private bool _draggingThumb;
|
||||
private float _thumbDragOffset;
|
||||
|
||||
public UiMarkupList() { CapturesPointerDrag = true; }
|
||||
|
||||
// ── Fix round item 6: per-column caches (reused between Draw and OnEvent,
|
||||
// sized to Columns.Count by the Columns setter above) ────────────────────
|
||||
private IReadOnlyList<string>?[] _cachedTextRows = Array.Empty<IReadOnlyList<string>?>();
|
||||
|
|
@ -117,6 +154,9 @@ public sealed class UiMarkupList : UiElement
|
|||
}
|
||||
ClampTop(items.Count, visibleRows);
|
||||
|
||||
bool showScrollbar = items.Count > visibleRows;
|
||||
float contentWidth = showScrollbar ? MathF.Max(0f, Width - ScrollbarWidth) : Width;
|
||||
|
||||
context.DrawFill(0f, 0f, Width, Height, BackgroundColor);
|
||||
context.DrawRectOutline(0f, 0f, Width, Height, BorderColor, 1f);
|
||||
int end = Math.Min(items.Count, _topRow + visibleRows);
|
||||
|
|
@ -124,7 +164,7 @@ public sealed class UiMarkupList : UiElement
|
|||
{
|
||||
float y = (index - _topRow) * RowHeight;
|
||||
if (index == selected)
|
||||
context.DrawFill(1f, y + 1f, Width - 2f, RowHeight - 1f, SelectedColor);
|
||||
context.DrawFill(1f, y + 1f, contentWidth - 2f, RowHeight - 1f, SelectedColor);
|
||||
|
||||
if (iconIds is not null && index < iconIds.Count && IconResolve is { } resolve)
|
||||
{
|
||||
|
|
@ -160,6 +200,9 @@ public sealed class UiMarkupList : UiElement
|
|||
else
|
||||
context.DrawString(text, textX, textY, textColor);
|
||||
}
|
||||
|
||||
if (showScrollbar)
|
||||
DrawScrollbar(context, contentWidth, items.Count, visibleRows);
|
||||
}
|
||||
|
||||
public override bool OnEvent(in UiEvent e)
|
||||
|
|
@ -168,17 +211,24 @@ public sealed class UiMarkupList : UiElement
|
|||
return OnEventColumns(e, columns);
|
||||
|
||||
IReadOnlyList<string> items = ItemsSource();
|
||||
int visibleRows = VisibleRows;
|
||||
float contentWidth = items.Count > visibleRows
|
||||
? MathF.Max(0f, Width - ScrollbarWidth)
|
||||
: Width;
|
||||
if (TryHandleScrollbarEvent(e, contentWidth, items.Count, visibleRows))
|
||||
return true;
|
||||
|
||||
if (e.Type == UiEventType.Scroll)
|
||||
{
|
||||
_topRow -= Math.Sign(e.Data0);
|
||||
ClampTop(items.Count, VisibleRows);
|
||||
ClampTop(items.Count, visibleRows);
|
||||
return true;
|
||||
}
|
||||
if (e.Type != UiEventType.MouseDown || !Enabled)
|
||||
return false;
|
||||
int row = (int)MathF.Floor(e.Data2 / MathF.Max(1f, RowHeight));
|
||||
int index = _topRow + row;
|
||||
if (row >= 0 && row < VisibleRows && index >= 0 && index < items.Count)
|
||||
if (row >= 0 && row < visibleRows && index >= 0 && index < items.Count)
|
||||
SelectionChanged?.Invoke(index);
|
||||
return true;
|
||||
}
|
||||
|
|
@ -304,9 +354,15 @@ public sealed class UiMarkupList : UiElement
|
|||
}
|
||||
_cachedRowCount = rowCount;
|
||||
|
||||
ComputeColumnLayout(columns, Width);
|
||||
|
||||
int visibleRows = VisibleRows;
|
||||
// The reserved scrollbar column only exists once rows actually
|
||||
// overflow the viewport (owner directive: reserve 16px only when
|
||||
// the bar is shown) — computed BEFORE ComputeColumnLayout so the
|
||||
// last (always-auto) column's remainder already accounts for it.
|
||||
bool showScrollbar = rowCount > visibleRows;
|
||||
float contentWidth = showScrollbar ? MathF.Max(0f, Width - ScrollbarWidth) : Width;
|
||||
ComputeColumnLayout(columns, contentWidth);
|
||||
|
||||
int selected = SelectedIndexSource();
|
||||
if (selected >= 0 && selected < rowCount)
|
||||
{
|
||||
|
|
@ -325,7 +381,7 @@ public sealed class UiMarkupList : UiElement
|
|||
{
|
||||
float y = (index - _topRow) * RowHeight;
|
||||
if (index == selected)
|
||||
context.DrawFill(1f, y + 1f, Width - 2f, RowHeight - 1f, SelectedColor);
|
||||
context.DrawFill(1f, y + 1f, contentWidth - 2f, RowHeight - 1f, SelectedColor);
|
||||
|
||||
for (int c = 0; c < columns.Count; c++)
|
||||
{
|
||||
|
|
@ -362,6 +418,9 @@ public sealed class UiMarkupList : UiElement
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showScrollbar)
|
||||
DrawScrollbar(context, contentWidth, rowCount, visibleRows);
|
||||
}
|
||||
|
||||
private void DrawTextCell(
|
||||
|
|
@ -448,11 +507,17 @@ public sealed class UiMarkupList : UiElement
|
|||
// event arriving before any Draw is a harmless no-op rather than a
|
||||
// crash.
|
||||
int rowCount = _cachedRowCount;
|
||||
int visibleRows = VisibleRows;
|
||||
float contentWidth = rowCount > visibleRows
|
||||
? MathF.Max(0f, Width - ScrollbarWidth)
|
||||
: Width;
|
||||
if (TryHandleScrollbarEvent(e, contentWidth, rowCount, visibleRows))
|
||||
return true;
|
||||
|
||||
if (e.Type == UiEventType.Scroll)
|
||||
{
|
||||
_topRow -= Math.Sign(e.Data0);
|
||||
ClampTop(rowCount, VisibleRows);
|
||||
ClampTop(rowCount, visibleRows);
|
||||
return true;
|
||||
}
|
||||
if (e.Type != UiEventType.MouseDown || !Enabled)
|
||||
|
|
@ -460,7 +525,7 @@ public sealed class UiMarkupList : UiElement
|
|||
|
||||
int row = (int)MathF.Floor(e.Data2 / MathF.Max(1f, RowHeight));
|
||||
int index = _topRow + row;
|
||||
if (row < 0 || row >= VisibleRows || index < 0 || index >= rowCount)
|
||||
if (row < 0 || row >= visibleRows || index < 0 || index >= rowCount)
|
||||
return true; // swallow the press; clicks past the last row do nothing
|
||||
|
||||
float localX = e.Data1;
|
||||
|
|
@ -515,4 +580,162 @@ public sealed class UiMarkupList : UiElement
|
|||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Retail scrollbar chrome (owner live-client report 2026-09-07) ───────
|
||||
//
|
||||
// "For scrollable dropdown or the meta window we use the same assets as
|
||||
// we do in for example chat or inventory window": once a list's rows
|
||||
// overflow its own viewport, it draws the SAME sprite ids
|
||||
// (RetailScrollbarChrome's vertical skin) as the chat SpewBox and the
|
||||
// inventory UiItemList, at VVS's own placement (right edge, 16px wide).
|
||||
// Geometry reuses UiScrollbar.ThumbRect via a small UiScrollable
|
||||
// projection kept in sync with this list's own _topRow — the list stays
|
||||
// the single source of truth for scroll position; the projection only
|
||||
// feeds the shared thumb-size/position math.
|
||||
|
||||
/// <summary>Re-syncs <see cref="_scroll"/>'s content/view extents and
|
||||
/// offset from this list's own <see cref="_topRow"/> — call immediately
|
||||
/// before reading its <see cref="UiScrollbar.ThumbRect"/> geometry.</summary>
|
||||
private void ConfigureScroll(int rowCount, int visibleRows)
|
||||
{
|
||||
int lineHeight = Math.Max(1, (int)MathF.Round(RowHeight));
|
||||
_scroll.LineHeight = lineHeight;
|
||||
_scroll.SetExtents(rowCount * lineHeight, visibleRows * lineHeight);
|
||||
_scroll.SetScrollY(_topRow * lineHeight);
|
||||
}
|
||||
|
||||
private void DrawScrollbar(UiRenderContext ctx, float x, int rowCount, int visibleRows)
|
||||
{
|
||||
if (SpriteResolve is not { } resolve) return;
|
||||
ConfigureScroll(rowCount, visibleRows);
|
||||
|
||||
float decExtent = Math.Clamp(ScrollButtonExtent, 0f, Height);
|
||||
float incExtent = Math.Clamp(ScrollButtonExtent, 0f, Height - decExtent);
|
||||
|
||||
DrawTiledSprite(ctx, resolve, RetailScrollbarChrome.Track, x, 0f, ScrollbarWidth, Height);
|
||||
DrawFlatSprite(ctx, resolve, RetailScrollbarChrome.UpNormal, x, 0f, ScrollbarWidth, decExtent);
|
||||
DrawFlatSprite(ctx, resolve, RetailScrollbarChrome.DownNormal, x, Height - incExtent, ScrollbarWidth, incExtent);
|
||||
|
||||
float trackTop = decExtent;
|
||||
float trackLen = MathF.Max(0f, Height - decExtent - incExtent);
|
||||
var (ty, th) = UiScrollbar.ThumbRect(_scroll, trackTop, trackLen);
|
||||
const float capH = 3f;
|
||||
if (th >= 2f * capH)
|
||||
{
|
||||
DrawFlatSprite(ctx, resolve, RetailScrollbarChrome.ThumbTopNormal, x, ty, ScrollbarWidth, capH);
|
||||
DrawTiledSprite(ctx, resolve, RetailScrollbarChrome.ThumbMidNormal, x, ty + capH, ScrollbarWidth, th - 2f * capH);
|
||||
DrawFlatSprite(ctx, resolve, RetailScrollbarChrome.ThumbBotNormal, x, ty + th - capH, ScrollbarWidth, th <= 0f ? 0f : capH);
|
||||
}
|
||||
else
|
||||
{
|
||||
DrawFlatSprite(ctx, resolve, RetailScrollbarChrome.ThumbMidNormal, x, ty, ScrollbarWidth, th);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Draw a sprite stretched 1:1 to the dest rect — same
|
||||
/// convention <see cref="UiMenu"/>/<see cref="UiScrollbar"/> use for
|
||||
/// their own button/thumb-cap art.</summary>
|
||||
private static void DrawFlatSprite(
|
||||
UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
|
||||
uint id, float x, float y, float w, float h)
|
||||
{
|
||||
if (id == 0 || w <= 0f || h <= 0f) return;
|
||||
var (tex, _, _) = resolve(id);
|
||||
if (tex == 0) return;
|
||||
ctx.DrawSprite(tex, x, y, w, h, 0f, 0f, 1f, 1f, Vector4.One);
|
||||
}
|
||||
|
||||
/// <summary>Draw a sprite tiled (UV-repeat at native size) to fill the
|
||||
/// dest rect — same convention as the track/thumb-middle draws
|
||||
/// elsewhere in the retail scrollbar chrome.</summary>
|
||||
private static void DrawTiledSprite(
|
||||
UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
|
||||
uint id, float x, float y, float w, float h)
|
||||
{
|
||||
if (id == 0 || w <= 0f || h <= 0f) return;
|
||||
var (tex, tw, th) = resolve(id);
|
||||
if (tex == 0 || tw == 0 || th == 0) return;
|
||||
ctx.DrawSprite(tex, x, y, w, h, 0f, 0f, w / tw, h / th, Vector4.One);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scrollbar hit-testing shared by the legacy single-column
|
||||
/// <see cref="OnEvent"/> and <see cref="OnEventColumns"/> — arrows, track
|
||||
/// paging, and thumb drag all work exactly as they do in
|
||||
/// <see cref="UiScrollbar"/>/the chat window's own docked bar, driving
|
||||
/// this list's own <see cref="_topRow"/>. Returns false (never handled)
|
||||
/// once the rows fit the viewport — a non-overflowing list has no bar
|
||||
/// and its area is ordinary row/content space.
|
||||
/// </summary>
|
||||
private bool TryHandleScrollbarEvent(in UiEvent e, float contentWidth, int rowCount, int visibleRows)
|
||||
{
|
||||
if (_draggingThumb)
|
||||
{
|
||||
if (e.Type == UiEventType.MouseMove)
|
||||
{
|
||||
DragThumb(e.Data2, rowCount, visibleRows);
|
||||
return true;
|
||||
}
|
||||
if (e.Type is UiEventType.MouseUp or UiEventType.CaptureChanged)
|
||||
{
|
||||
_draggingThumb = false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (rowCount <= visibleRows) return false;
|
||||
if (e.Type != UiEventType.MouseDown || !Enabled) return false;
|
||||
if (e.Data1 < contentWidth) return false; // click landed in row content, not the bar
|
||||
|
||||
ConfigureScroll(rowCount, visibleRows);
|
||||
float decExtent = Math.Clamp(ScrollButtonExtent, 0f, Height);
|
||||
float incExtent = Math.Clamp(ScrollButtonExtent, 0f, Height - decExtent);
|
||||
float ly = e.Data2;
|
||||
|
||||
if (ly < decExtent) { StepRow(-1, rowCount, visibleRows); return true; }
|
||||
if (ly >= Height - incExtent) { StepRow(1, rowCount, visibleRows); return true; }
|
||||
|
||||
float trackTop = decExtent;
|
||||
float trackLen = MathF.Max(0f, Height - decExtent - incExtent);
|
||||
var (ty, th) = UiScrollbar.ThumbRect(_scroll, trackTop, trackLen);
|
||||
if (ly >= ty && ly <= ty + th)
|
||||
{
|
||||
_draggingThumb = true;
|
||||
_thumbDragOffset = ly - ty;
|
||||
}
|
||||
else
|
||||
{
|
||||
PageRow(ly < ty ? -1 : 1, rowCount, visibleRows);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void DragThumb(float ly, int rowCount, int visibleRows)
|
||||
{
|
||||
ConfigureScroll(rowCount, visibleRows);
|
||||
float decExtent = Math.Clamp(ScrollButtonExtent, 0f, Height);
|
||||
float incExtent = Math.Clamp(ScrollButtonExtent, 0f, Height - decExtent);
|
||||
float trackTop = decExtent;
|
||||
float trackLen = MathF.Max(0f, Height - decExtent - incExtent);
|
||||
var (_, thumbH) = UiScrollbar.ThumbRect(_scroll, trackTop, trackLen);
|
||||
float travel = MathF.Max(1f, trackLen - thumbH);
|
||||
float ratio = (ly - _thumbDragOffset - trackTop) / travel;
|
||||
_scroll.SetPositionRatio(ratio);
|
||||
|
||||
int lineHeight = Math.Max(1, (int)MathF.Round(RowHeight));
|
||||
_topRow = (int)MathF.Round((float)_scroll.ScrollY / lineHeight);
|
||||
ClampTop(rowCount, visibleRows);
|
||||
}
|
||||
|
||||
private void StepRow(int lines, int rowCount, int visibleRows)
|
||||
{
|
||||
_topRow += lines;
|
||||
ClampTop(rowCount, visibleRows);
|
||||
}
|
||||
|
||||
private void PageRow(int pages, int rowCount, int visibleRows)
|
||||
{
|
||||
_topRow += pages * visibleRows;
|
||||
ClampTop(rowCount, visibleRows);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue