merge(vt): bring in retail scrollbar chrome + resizable/anchor markup

Merges claude/latest-main-sync-497549 into the slice-7 panel worktree
so round D can build on both: b71a8ea37 (retail scrollbar chrome on
plain <menu> popups and overflowing <list>s — chat/inventory sprite
ids, always-scrollable single-column menu) and 2e63391cc (<panel
resizable minw minh> + anchor="left top right bottom" markup).

Conflicts resolved keeping both intents:
- Ledger (docs/plans/2026-09-07-campaign-vt-slice7-tabs.md): unioned
  both branches' entries into one chronological timeline instead of
  picking a side.
- docs/plugin-ui-markup.md: kept both attribute additions per element
  (slider min/max/style, menu scroll/style) AND anchor on every row.
- src/AcDream.App/UI/UiMarkupList.cs: kept this branch's fix round B
  item 10 (VVS HudList grids have no row-selection highlight) over the
  sync branch's older SelectedColor band draw in the <column> grid
  path — the legacy single-column list path is unaffected either way.
- src/AcDream.App/UI/MarkupDocument.cs: the auto-merge left two
  `Scrollable =` initializers on the same <menu> object (CS1912).
  Kept the sync branch's `Scrollable = true` (VTank's HudCombo is
  always a single scrolling column, never a wrapping grid) and
  dropped this branch's `Scrollable = B(el, "scroll", false)` opt-in,
  since the owner-driven always-scrollable design supersedes the
  S7.2 opt-in one. Updated MarkupDocumentTests.cs to match: removed
  Build_MenuWithNoScrollAttribute_KeepsScrollableFalse (asserted the
  now-false opt-in default) and
  Menu_Scroll_DrawsAPlainFlatThumbFillWhenTheMarkupItemCountOverflowsTheVisibleRows
  (asserted a flat DrawFill thumb; the scrollbar is sprite-chrome for
  every menu style now) — both fully superseded by
  Menu_Markup_IsAlwaysScrollable_WithRetailScrollbarChromeWired and
  UiMenuPlainStyleTests.Plain_OpenPopup_ScrollableOverflow_
  DrawsRetailScrollbarChrome_RowsStayPlain.

Verified: dotnet build AcDream.slnx -c Release green; MossTank suite
678/678; App markup/plugin filter 242/242 (241 before this commit's
test-file trim, +1 net from the merge's own new tests, 0 red).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-07 16:01:51 +02:00
commit 4ba0a557f6
14 changed files with 1431 additions and 166 deletions

View file

@ -56,7 +56,31 @@ public static class MarkupDocument
Height = F(root, "h"),
};
// 2026-09-07 (docs/plans — owner direction "the size of the entire
// window needs to be enlarged for default and should also be
// resizeable"): a plugin panel is FIXED-SIZE by default —
// resizable="true" is the opt-in that arms real user drag-resize
// (both axes; UiRoot's generic edge/grip-drag mechanism already
// exists for every UiElement with Resizable=true — see
// UiElement.Resizable/ResizeX/ResizeY and RetailWindowManager.ResizeTo).
// minw/minh set the floor UiRoot's live drag and
// RetailWindowLayoutPersistence's restore clamp both already honor
// (UiElement.MinWidth/MinHeight); they default to the AUTHORED w/h so
// a resizable panel never shrinks below the layout its author tested.
bool resizable = B(root, "resizable", false);
panel.Resizable = resizable;
panel.MinWidth = FOr(root, "minw", panel.Width);
panel.MinHeight = FOr(root, "minh", panel.Height);
panel.ResizeX = resizable;
panel.ResizeY = resizable;
// Optional per-window resize-axis lock: resize="x" | "y" | "both" | "none".
// Only meaningful once resizable="true" already armed the master
// switch above — Resizable=false (the default) blocks any drag-resize
// regardless of these axis flags, so this attribute alone can no
// longer make a panel resizable the way it silently could before
// resizable="true" existed (UiNineSlicePanel's own Resizable=true
// constructor default used to make the master switch a no-op).
string? resize = (string?)root.Attribute("resize");
if (resize is not null)
{
@ -141,7 +165,8 @@ public static class MarkupDocument
BarColor = Color((string?)el.Attribute("color")),
Fill = BindFloat((string?)el.Attribute("fill"), binding),
Label = () => (cur(), max()) is (uint c, uint m) ? $"{c}/{m}" : null,
Anchors = Anchor((string?)el.Attribute("anchor")),
// anchor= is applied uniformly for every element by
// ApplyCommon below; no per-element handling needed here.
SpriteResolve = resolve,
BackLeft = Hex((string?)el.Attribute("backleft")),
BackTile = Hex((string?)el.Attribute("backtile")),
@ -512,13 +537,6 @@ public static class MarkupDocument
RowHeight = Math.Max(12f, FOr(el, "rowheight", 18f)),
ColumnWidth = Math.Max(20f, F(el, "w")),
OpenUpward = B(el, "openupward", false),
// KB 08 §3 gap: VVS's HudCombo is always a single scrolling
// column (HudCombo.cs:35,102-146); acdream's <menu> instead
// wraps overflow into extra columns unless the author opts
// into UiMenu.Scrollable. Default false keeps every existing
// <menu> (none of which set scroll=) wrapping exactly as
// before.
Scrollable = B(el, "scroll", false),
// Same track/thumb/arrow chrome ConfigOptionsPageController
// and VendorUiController already apply to their own
// Scrollable menus — harmless to set unconditionally since
@ -535,6 +553,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 =>
{
@ -542,6 +568,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()
@ -598,6 +630,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,
@ -1191,6 +1228,18 @@ public static class MarkupDocument
{
element.Name = (string?)source.Attribute("name")
?? (string?)source.Attribute("id");
// 2026-09-07: anchor="left top right bottom" (space-separated; any
// subset; default "left top" — today's fixed placement) on ANY
// markup element. Semantics are identical to UiElement.Anchors/
// AnchorEdges/ApplyAnchor: "left right" stretches width with the
// parent, "top bottom" stretches height, "right" alone pins to the
// right edge at fixed width. A <group>'s own children resolve their
// anchor relative to the GROUP (their direct Parent), not the panel,
// because UiElement.ApplyAnchor always measures against Parent.Width/
// Height — no extra propagation code is needed for that.
element.Anchors = ParseAnchor((string?)source.Attribute("anchor"), source);
BindBool((string?)source.Attribute("visible"), binding,
value => element.Visible = value,
sourceReader => element.VisibleSource = sourceReader);
@ -1341,19 +1390,48 @@ public static class MarkupDocument
System.Globalization.CultureInfo.InvariantCulture, out var v) ? v : 0u;
}
private static AnchorEdges Anchor(string? csv)
/// <summary>
/// Parses <c>anchor="left top right bottom"</c> (space-separated, any
/// subset of the four tokens, case-insensitive) into <see cref="AnchorEdges"/>.
/// Absent/blank defaults to <c>Left | Top</c> — today's fixed top-left
/// placement, unchanged. An unrecognized token is a Build-time author
/// error, same "malformed markup throws" rule every other attribute in
/// this grammar follows (see e.g. <see cref="ValidateIconKind"/>) — the
/// message names the offending element via <see cref="ElementIdentity"/>
/// so a plugin author with several anchored siblings can find which one
/// is wrong.
/// </summary>
private static AnchorEdges ParseAnchor(string? tokens, XElement source)
{
if (string.IsNullOrWhiteSpace(csv)) return AnchorEdges.Left | AnchorEdges.Top;
var a = AnchorEdges.None;
foreach (var part in csv.Split(',', System.StringSplitOptions.TrimEntries | System.StringSplitOptions.RemoveEmptyEntries))
a |= part.ToLowerInvariant() switch
if (string.IsNullOrWhiteSpace(tokens))
return AnchorEdges.Left | AnchorEdges.Top;
var edges = AnchorEdges.None;
foreach (string token in tokens.Split(
(char[]?)null, System.StringSplitOptions.RemoveEmptyEntries))
{
edges |= token.ToLowerInvariant() switch
{
"left" => AnchorEdges.Left,
"top" => AnchorEdges.Top,
"right" => AnchorEdges.Right,
"bottom" => AnchorEdges.Bottom,
_ => AnchorEdges.None,
_ => throw new FormatException(
$"{ElementIdentity(source)} anchor=\"{tokens}\" has unknown token "
+ $"\"{token}\" (expected left, top, right, bottom)"),
};
return a == AnchorEdges.None ? AnchorEdges.Left | AnchorEdges.Top : a;
}
return edges;
}
/// <summary>Identifies a markup element for a Build-time error message:
/// <c>&lt;button name="Foo"&gt;</c> when it carries a <c>name</c>/<c>id</c>,
/// else just <c>&lt;button&gt;</c>.</summary>
private static string ElementIdentity(XElement source)
{
string? name = (string?)source.Attribute("name") ?? (string?)source.Attribute("id");
return name is null
? $"<{source.Name.LocalName}>"
: $"<{source.Name.LocalName} name=\"{name}\">";
}
}

View file

@ -84,6 +84,27 @@ internal static class RetailScrollbarChrome
bar.ThumbBotPressedSprite = ThumbBotPressed;
}
/// <summary>
/// Wires the retail vertical skin onto a <see cref="UiMenu"/> popup's own
/// procedural scrollbar properties (<see cref="UiMenu.ScrollTrackSprite"/>
/// etc). Owner live-client report 2026-09-07 ("For scrollable dropdown or
/// the meta window we use the same assets as we do in for example chat or
/// inventory window"): the popup's own <c>DrawPopupScrollbar</c> draws a
/// simpler Normal-only chrome (no hover/pressed states — matching how
/// <c>VendorUiController</c>/<c>ConfigOptionsPageController</c> already
/// wire these exact ids), so only the Normal-state constants are needed
/// here.
/// </summary>
internal static void ApplyToMenuPopup(UiMenu menu)
{
menu.ScrollTrackSprite = Track;
menu.ScrollThumbTopSprite = ThumbTopNormal;
menu.ScrollThumbSprite = ThumbMidNormal;
menu.ScrollThumbBottomSprite = ThumbBotNormal;
menu.ScrollUpSprite = UpNormal;
menu.ScrollDownSprite = DownNormal;
}
/// <summary>Wires the full retail horizontal skin onto <paramref name="bar"/>.
/// The leading (<see cref="UiScrollbar.UpSprite"/>) slot is the LEFT edge.</summary>
internal static void ApplyHorizontal(UiScrollbar bar)

View file

@ -73,9 +73,46 @@ public sealed class UiMarkupList : UiElement
public Vector4 TextColor { get; set; } = new(0.91f, 0.87f, 0.76f, 1f);
public Vector4 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)
{
@ -367,6 +423,9 @@ public sealed class UiMarkupList : UiElement
}
}
}
if (showScrollbar)
DrawScrollbar(context, contentWidth, rowCount, visibleRows);
}
private void DrawTextCell(
@ -453,11 +512,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)
@ -465,7 +530,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;
@ -520,4 +585,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);
}
}

View file

@ -884,9 +884,19 @@ public sealed class UiMenu : UiElement
}
/// <summary>Plain counterpart of <see cref="DrawScrollablePopup"/> — same
/// <see cref="VisibleTopRow"/>-sliced single column, plain
/// selected/hover row fills, and a plain scrollbar
/// (<see cref="DrawPopupScrollbarPlain"/>) instead of the sprite chrome.</summary>
/// <see cref="VisibleTopRow"/>-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 <see cref="DrawPopupScrollbar"/>
/// helper — the exact ids <see cref="RetailScrollbarChrome.ApplyToMenuPopup"/>
/// wires onto <see cref="ScrollTrackSprite"/> etc, the SAME sprite ids the
/// chat SpewBox/inventory <see cref="UiItemList"/> scrollbar uses. Rows
/// stay plain by design (the owner accepted the flat dark list; only the
/// bar was objectionable). A menu built with no <see cref="SpriteResolve"/>
/// at all (a hand-built test/legacy fixture) falls back to the fully
/// flat <see cref="DrawPopupScrollbarPlain"/> rather than silently
/// drawing nothing.</summary>
private void DrawScrollablePopupPlain(UiRenderContext ctx)
{
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);
}
/// <summary>
/// Plain counterpart of <see cref="DrawPopupScrollbar"/>: a 1px-bordered
/// track and a flat thumb, both in <see cref="PlainBorderColor"/> — no DAT
/// thumb/track/arrow-button art at all. Shares the exact same
/// <see cref="UiScrollbar.ThumbRect"/> geometry (so the thumb's drawn
/// position matches <see cref="HandleScrollablePopupMouseDown"/>'s hit-test
/// math), but draws no separate up/down button glyphs — plain mode has no
/// art for them and the click regions already work through geometry alone
/// NO-RESOLVER FALLBACK ONLY (see <see cref="DrawScrollablePopupPlain"/>'s
/// own doc comment — the 2026-09-07 owner directive moved the normal
/// plain-popup scrollbar to retail's own chrome via
/// <see cref="DrawPopupScrollbar"/>). This draws a 1px-bordered track and
/// a flat thumb, both in <see cref="PlainBorderColor"/> — no DAT
/// thumb/track/arrow-button art at all — for the rare case a
/// <see cref="UiMenu"/> is built with <see cref="Scrollable"/> true but no
/// <see cref="SpriteResolve"/> at all (a hand-built test/legacy fixture).
/// Shares the exact same <see cref="UiScrollbar.ThumbRect"/> geometry (so
/// the thumb's drawn position matches
/// <see cref="HandleScrollablePopupMouseDown"/>'s hit-test math), but
/// draws no separate up/down button glyphs — plain mode has no art for
/// them and the click regions already work through geometry alone
/// (<see cref="HandleScrollablePopupMouseDown"/> is unchanged).
/// </summary>
private void DrawPopupScrollbarPlain(UiRenderContext ctx, float x, float y)