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) and2e63391cc(<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:
commit
4ba0a557f6
14 changed files with 1431 additions and 166 deletions
|
|
@ -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><button name="Foo"></c> when it carries a <c>name</c>/<c>id</c>,
|
||||
/// else just <c><button></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}\">";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue