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

@ -496,24 +496,16 @@ public class MarkupDocumentTests
}
// ── Campaign VT slice 7 S7.2: <menu scroll="true"> (KB 08 §3 gap) ────
[Fact]
public void Build_MenuWithNoScrollAttribute_KeepsScrollableFalse()
{
const string xml = """
<panel x="0" y="0" w="160" h="40">
<menu x="4" y="4" w="120" h="20" items="{Choices}"
selected="{Selected}" onchange="{SelectChoice}" />
</panel>
""";
var binding = new EditorBinding();
UiNineSlicePanel panel = MarkupDocument.Build(
xml, binding, _ => (1u, 32, 32));
var menu = Assert.IsType<UiMenu>(panel.Children[0]);
Assert.False(menu.Scrollable);
}
//
// Superseded by the retail-scrollbar-chrome merge (`b71a8ea37`, slice 7
// round D): a markup <menu> is now ALWAYS Scrollable (VTank's HudCombo
// is always a single scrolling column, never a wrapping grid), so the
// `scroll` attribute this comment used to gate no longer changes
// anything — see Menu_Markup_IsAlwaysScrollable_WithRetailScrollbarChromeWired
// below for the current contract. `Build_MenuWithNoScrollAttribute_
// KeepsScrollableFalse` (which asserted the opt-in default) was removed
// here in the same commit that folded the two branches together, since
// it now directly contradicts the shipped behavior.
[Fact]
public void Build_MenuWithScrollAttribute_SetsUiMenuScrollableAndItsChromeSprites()
@ -581,71 +573,17 @@ public class MarkupDocumentTests
s => s.Texture == menu.ScrollThumbSprite);
}
// Plain sibling (default style, no style attribute): the flat thumb fill
// is an untextured DrawFill quad (UiTextureTableHandle.None == 0) sized
// ScrollbarWidth-2 wide and tinted PlainBorderColor — see
// UiMenu.DrawPopupScrollbarPlain. This is what actually renders today for
// any markup menu that doesn't opt into style="retail".
[Fact]
public void Menu_Scroll_DrawsAPlainFlatThumbFillWhenTheMarkupItemCountOverflowsTheVisibleRows()
{
const string xml = """
<panel x="0" y="0" w="160" h="40">
<menu x="4" y="4" w="120" h="18" items="{ManyChoices}"
selected="{Selected}" onchange="{SelectChoice}"
rows="6" rowheight="18" scroll="true" />
</panel>
""";
var binding = new ManyChoicesBinding();
UiNineSlicePanel panel = MarkupDocument.Build(
xml, binding, id => (id, 16, 16));
var menu = Assert.IsType<UiMenu>(panel.Children[0]);
Assert.False(menu.RetailButtonArt);
var device = new RecordingGpuDevice();
var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused");
renderer.Begin(new Vector2(800f, 600f));
var ctx = new UiRenderContext(renderer, new Vector2(800f, 600f));
menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5));
menu.OnEvent(new UiEvent(0, menu, UiEventType.Scroll, Data0: 0));
Assert.True(menu.PopupScroll.HasOverflow);
menu.DrawOverlays(ctx);
// An untextured segment batches every DrawFill call in submission order
// together (same texture=0 for background/border/row-fills/thumb), so
// scan per-QUAD (6 verts x 8 floats = 48 floats) inside each segment
// rather than treating a whole segment as one quad.
const int floatsPerQuad = 6 * 8;
float expectedThumbWidth = menu.ScrollbarWidth - 2f;
bool foundThumb = renderer.DebugSpriteSegmentVerts.Any(s =>
{
if (s.Texture != 0u) return false;
for (int q = 0; q + floatsPerQuad <= s.Verts.Count; q += floatsPerQuad)
{
float xMin = float.MaxValue, xMax = float.MinValue;
for (int v = 0; v < 6; v++)
{
float x = s.Verts[q + v * 8];
if (x < xMin) xMin = x;
if (x > xMax) xMax = x;
}
float width = xMax - xMin;
if (MathF.Abs(width - expectedThumbWidth) > 0.5f) continue;
float r = s.Verts[q + 4], g = s.Verts[q + 5], b = s.Verts[q + 6], a = s.Verts[q + 7];
if (MathF.Abs(r - menu.PlainBorderColor.X) < 0.01f
&& MathF.Abs(g - menu.PlainBorderColor.Y) < 0.01f
&& MathF.Abs(b - menu.PlainBorderColor.Z) < 0.01f
&& MathF.Abs(a - menu.PlainBorderColor.W) < 0.01f)
return true;
}
return false;
});
Assert.True(foundThumb, "expected a plain flat thumb fill (untextured quad, "
+ $"width~{expectedThumbWidth}, tinted PlainBorderColor) among the drawn segments");
}
// Plain-style scrollbar chrome (default style, no style attribute): this
// used to assert a flat untextured DrawFill thumb (UiMenu.
// DrawPopupScrollbarPlain). Superseded by the retail-scrollbar-chrome
// merge (`b71a8ea37`, slice 7 round D, owner: "we use the same assets as
// we do in for example chat or inventory window") — a plain-style
// popup's scrollbar now draws the SAME sprite chrome (track/up/down/
// three-part thumb) the chat/inventory scrollbar resolves through,
// regardless of RetailButtonArt; only the ROW fills stay plain. See
// UiMenuPlainStyleTests.Plain_OpenPopup_ScrollableOverflow_
// DrawsRetailScrollbarChrome_RowsStayPlain for the current coverage of
// this exact scenario (plain menu, overflowing markup item count).
private sealed class ManyChoicesBinding
{
@ -713,4 +651,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 <menu> 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<string> 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<UiMenu>(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<UiMenu>(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 =
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
"<menu x=\"4\" y=\"4\" w=\"120\" h=\"20\" items=\"{Choices}\" " +
"selected=\"{Selected}\" openupward=\"false\"/>" +
"</panel>";
var panel = MarkupDocument.Build(xml, binding, id => (id, 8, 8));
var menu = Assert.IsType<UiMenu>(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<UiMenu>(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;
}
}

View file

@ -0,0 +1,321 @@
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;
/// <summary>
/// 2026-09-07 (owner direction: "The size of the entire window needs to be
/// enlarged for default and should also be resizeable"): plugin markup's
/// new <c>&lt;panel resizable="true" minw= minh=&gt;</c> grammar and the
/// <c>anchor="left top right bottom"</c> attribute on <c>&lt;group&gt;</c>,
/// <c>&lt;list&gt;</c>, <c>&lt;menu&gt;</c>, <c>&lt;field&gt;</c>,
/// <c>&lt;label&gt;</c>, <c>&lt;button&gt;</c>, <c>&lt;icon&gt;</c>.
/// Semantics are the existing <see cref="UiElement.Anchors"/>/
/// <see cref="AnchorEdges"/>/<see cref="UiElement.ApplyAnchor"/> machinery —
/// these tests prove MarkupDocument wires the two new attribute grammars
/// into that machinery correctly, not the machinery itself (already covered
/// by other UiElement anchor/resize tests).
/// </summary>
public sealed class MarkupResizableAnchorTests
{
private sealed class ListBinding
{
public IReadOnlyList<string> Items => ["A", "B", "C"];
public int Selected { get; set; } = -1;
public Action<int> OnSelect => value => Selected = value;
}
// ── resizable / minw / minh parse tests ─────────────────────────────────
[Fact]
public void Build_PanelWithoutResizableAttribute_IsFixedSizeByDefault()
{
// The golden default: a panel that predates this feature (no
// resizable/minw/minh anywhere) must end up with the master switch
// OFF and both axes locked — this is the "exactly as today" contract
// item 2 of the plan requires.
const string xml = "<panel x=\"0\" y=\"0\" w=\"300\" h=\"200\"></panel>";
var panel = MarkupDocument.Build(xml, new object(), _ => (1u, 32, 32));
Assert.False(panel.Resizable);
Assert.False(panel.ResizeX);
Assert.False(panel.ResizeY);
Assert.Equal(300f, panel.MinWidth);
Assert.Equal(200f, panel.MinHeight);
}
[Fact]
public void Build_PanelResizableTrue_ArmsBothAxesAndDefaultsMinToAuthoredSize()
{
const string xml = "<panel x=\"0\" y=\"0\" w=\"300\" h=\"200\" resizable=\"true\"></panel>";
var panel = MarkupDocument.Build(xml, new object(), _ => (1u, 32, 32));
Assert.True(panel.Resizable);
Assert.True(panel.ResizeX);
Assert.True(panel.ResizeY);
Assert.Equal(300f, panel.MinWidth);
Assert.Equal(200f, panel.MinHeight);
}
[Fact]
public void Build_PanelResizableTrueWithMinwMinh_OverridesTheAuthoredSizeFloor()
{
const string xml =
"<panel x=\"0\" y=\"0\" w=\"300\" h=\"200\" resizable=\"true\" minw=\"150\" minh=\"90\"></panel>";
var panel = MarkupDocument.Build(xml, new object(), _ => (1u, 32, 32));
Assert.Equal(150f, panel.MinWidth);
Assert.Equal(90f, panel.MinHeight);
}
[Fact]
public void Build_PanelResizableTrueWithResizeAxisLock_NarrowsToOneAxis()
{
// The pre-existing resize="x"|"y"|"both"|"none" attribute still
// layers on top of resizable="true" to narrow which axis actually
// drags — it just can no longer be the SOLE switch (resizable is).
const string xml =
"<panel x=\"0\" y=\"0\" w=\"300\" h=\"200\" resizable=\"true\" resize=\"x\"></panel>";
var panel = MarkupDocument.Build(xml, new object(), _ => (1u, 32, 32));
Assert.True(panel.Resizable);
Assert.True(panel.ResizeX);
Assert.False(panel.ResizeY);
}
// ── anchor grammar parse tests ───────────────────────────────────────────
[Theory]
[InlineData("group")]
[InlineData("list")]
[InlineData("menu")]
[InlineData("field")]
[InlineData("label")]
[InlineData("button")]
[InlineData("icon")]
public void Build_ElementWithoutAnchorAttribute_DefaultsToLeftTop(string tag)
{
string xml = WrapSingle(tag, anchor: null);
var panel = MarkupDocument.Build(xml, new ListBinding(), _ => (1u, 32, 32));
UiElement element = panel.Children[0];
Assert.Equal(AnchorEdges.Left | AnchorEdges.Top, element.Anchors);
}
[Theory]
[InlineData("group")]
[InlineData("list")]
[InlineData("menu")]
[InlineData("field")]
[InlineData("label")]
[InlineData("button")]
[InlineData("icon")]
public void Build_ElementAnchorLeftRight_SetsBothHorizontalEdges(string tag)
{
string xml = WrapSingle(tag, anchor: "left right");
var panel = MarkupDocument.Build(xml, new ListBinding(), _ => (1u, 32, 32));
UiElement element = panel.Children[0];
Assert.Equal(AnchorEdges.Left | AnchorEdges.Right, element.Anchors);
}
[Fact]
public void Build_AnchorAllFourTokens_SetsEveryEdge()
{
string xml = WrapSingle("group", anchor: "left top right bottom");
var panel = MarkupDocument.Build(xml, new ListBinding(), _ => (1u, 32, 32));
Assert.Equal(
AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom,
panel.Children[0].Anchors);
}
[Fact]
public void Build_AnchorIsCaseInsensitiveAndOrderIndependent()
{
string xml = WrapSingle("button", anchor: "BOTTOM Right");
var panel = MarkupDocument.Build(xml, new ListBinding(), _ => (1u, 32, 32));
Assert.Equal(AnchorEdges.Bottom | AnchorEdges.Right, panel.Children[0].Anchors);
}
[Fact]
public void Build_UnknownAnchorToken_ThrowsNamingTheElement()
{
const string xml =
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
"<button name=\"Fire1\" x=\"0\" y=\"0\" w=\"40\" h=\"20\" text=\"Go\" anchor=\"left frotz\"/>" +
"</panel>";
FormatException ex = Assert.Throws<FormatException>(
() => MarkupDocument.Build(xml, new object(), _ => (1u, 32, 32)));
Assert.Contains("Fire1", ex.Message);
Assert.Contains("frotz", ex.Message);
}
private static string WrapSingle(string tag, string? anchor)
{
string anchorAttr = anchor is null ? string.Empty : $" anchor=\"{anchor}\"";
string inner = tag switch
{
"group" => $"<group x=\"10\" y=\"10\" w=\"100\" h=\"60\"{anchorAttr}></group>",
"list" => $"<list x=\"10\" y=\"10\" w=\"100\" h=\"60\" items=\"{{Items}}\" " +
$"selected=\"{{Selected}}\" onchange=\"{{OnSelect}}\"{anchorAttr}/>",
"menu" => $"<menu x=\"10\" y=\"10\" w=\"100\" h=\"20\" items=\"{{Items}}\"{anchorAttr}/>",
"field" => $"<field x=\"10\" y=\"10\" w=\"100\" h=\"20\"{anchorAttr}/>",
"label" => $"<label x=\"10\" y=\"10\" text=\"Hi\"{anchorAttr}/>",
"button" => $"<button x=\"10\" y=\"10\" w=\"40\" h=\"20\" text=\"Go\"{anchorAttr}/>",
"icon" => $"<icon x=\"10\" y=\"10\" w=\"32\" h=\"32\" did=\"0x06000001\"{anchorAttr}/>",
_ => throw new ArgumentOutOfRangeException(nameof(tag)),
};
return "<panel x=\"0\" y=\"0\" w=\"200\" h=\"150\">" + inner + "</panel>";
}
// ── dynamic anchor re-layout tests (recording renderer) ─────────────────
private sealed class NullGpuFrameSource : ICurrentGpuFrameSource
{
public IGpuFrame? CurrentFrame => null;
}
private static UiRenderContext MakeContext(float w, float h)
{
var device = new RecordingGpuDevice();
var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused");
renderer.Begin(new Vector2(w, h));
return new UiRenderContext(renderer, new Vector2(w, h));
}
[Fact]
public void ResizingPanel_LeftRightList_WidensWithThePanel()
{
const string xml = """
<panel x="0" y="0" w="300" h="200" resizable="true" minw="200" minh="150">
<list anchor="left right" x="10" y="10" w="280" h="150"
items="{Items}" selected="{Selected}" onchange="{OnSelect}"/>
</panel>
""";
var panel = MarkupDocument.Build(xml, new ListBinding(), _ => (1u, 32, 32));
var list = Assert.IsType<UiMarkupList>(panel.Children[0]);
UiRenderContext ctx = MakeContext(600f, 400f);
// First draw at the authored width captures the list's 10px margin to
// each side (10 = 300 - (10 + 280)) as its anchor baseline.
panel.DrawSelfAndChildren(ctx);
Assert.Equal(280f, list.Width);
// A live drag-resize (RetailWindowManager.ResizeTo) mutates Width
// directly; the next draw re-applies the captured 10px margins
// against the NEW panel width.
panel.Width = 500f;
panel.DrawSelfAndChildren(ctx);
Assert.Equal(480f, list.Width); // 500 - 10 - 10
Assert.Equal(10f, list.Left); // left margin preserved
}
[Fact]
public void ResizingPanel_RightAnchoredButton_MovesWithTheRightEdge()
{
const string xml = """
<panel x="0" y="0" w="300" h="200" resizable="true" minw="200" minh="150">
<button anchor="right" x="250" y="10" w="40" h="20" text="X"/>
</panel>
""";
var panel = MarkupDocument.Build(xml, new object(), _ => (1u, 32, 32));
var button = Assert.IsType<UiSimpleButton>(panel.Children[0]);
UiRenderContext ctx = MakeContext(600f, 400f);
panel.DrawSelfAndChildren(ctx);
Assert.Equal(250f, button.Left); // unchanged: 10px margin to the right edge
Assert.Equal(40f, button.Width); // fixed width — right-only anchor never stretches
panel.Width = 500f;
panel.DrawSelfAndChildren(ctx);
Assert.Equal(450f, button.Left); // 500 - 10 - 40: follows the right edge
Assert.Equal(40f, button.Width); // still fixed width
}
[Fact]
public void ResizingPanel_GroupStretchesBothAxesAndNestedListFollowsGroupWidth()
{
// Group anchors to every edge (generalizing "top bottom stretches" to
// both axes so this one test can drive BOTH panel dimensions), and
// its nested list anchors left+right RELATIVE TO THE GROUP — proving
// "groups propagate to children" (item 1): the list's own margins are
// captured against the group's Width, not the panel's.
const string xml = """
<panel x="0" y="0" w="300" h="200" resizable="true" minw="200" minh="150">
<group anchor="left top right bottom" x="10" y="10" w="280" h="180">
<list anchor="left right" x="5" y="5" w="270" h="170"
items="{Items}" selected="{Selected}" onchange="{OnSelect}"/>
</group>
</panel>
""";
var panel = MarkupDocument.Build(xml, new ListBinding(), _ => (1u, 32, 32));
var group = Assert.IsType<UiPanel>(panel.Children[0]);
var list = Assert.IsType<UiMarkupList>(group.Children[0]);
UiRenderContext ctx = MakeContext(600f, 400f);
panel.DrawSelfAndChildren(ctx);
Assert.Equal(280f, group.Width);
Assert.Equal(180f, group.Height);
Assert.Equal(270f, list.Width);
panel.Width = 500f;
panel.Height = 300f;
panel.DrawSelfAndChildren(ctx);
// Group stretches on both axes (10px margin preserved on every side).
Assert.Equal(480f, group.Width); // 500 - 10 - 10
Assert.Equal(280f, group.Height); // 300 - 10 - 10
// The nested list follows the GROUP's new width (5px margin to each
// side of the group, not the panel).
Assert.Equal(470f, list.Width); // 480 - 5 - 5
}
// ── golden: a plain panel with none of the new attributes is unaffected ──
[Fact]
public void Build_PlainPanel_DrawsIdenticallyAcrossRepeatedBuilds()
{
// No resizable/minw/minh/anchor anywhere — the pre-existing markup
// shape every current plugin panel uses today. Two independent
// builds + draws of the identical markup must produce byte-identical
// recorded GPU call sequences and geometry, proving the new
// ApplyCommon anchor-parsing code path is a no-op on the default
// path this feature must not disturb.
const string xml = """
<panel x="0" y="0" w="200" h="120" title="V">
<group x="4" y="4" w="180" h="40" background="#FF102030">
<button x="4" y="4" w="60" h="20" text="Go"/>
<label x="4" y="28" text="Hi"/>
</group>
<list x="4" y="48" w="180" h="60" items="{Items}" selected="{Selected}" onchange="{OnSelect}"/>
</panel>
""";
var panelA = MarkupDocument.Build(xml, new ListBinding(), _ => (7u, 32, 32));
var panelB = MarkupDocument.Build(xml, new ListBinding(), _ => (7u, 32, 32));
var deviceA = new RecordingGpuDevice();
var rendererA = new TextRenderer(deviceA, new NullGpuFrameSource(), "unused");
rendererA.Begin(new Vector2(400f, 300f));
panelA.DrawSelfAndChildren(new UiRenderContext(rendererA, new Vector2(400f, 300f)));
var deviceB = new RecordingGpuDevice();
var rendererB = new TextRenderer(deviceB, new NullGpuFrameSource(), "unused");
rendererB.Begin(new Vector2(400f, 300f));
panelB.DrawSelfAndChildren(new UiRenderContext(rendererB, new Vector2(400f, 300f)));
Assert.Equal(deviceA.Calls, deviceB.Calls);
Assert.False(panelA.Resizable);
Assert.Equal(AnchorEdges.Left | AnchorEdges.Top, panelA.Children[0].Anchors);
Assert.Equal(AnchorEdges.Left | AnchorEdges.Top, panelB.Children[0].Anchors);
}
}

View file

@ -424,6 +424,81 @@ public sealed class RetailWindowLayoutPersistenceTests : IDisposable
Assert.Equal((77f, 88f), (saved.X, saved.Y));
}
// ── 2026-09-07: a resizable plugin markup panel's resized geometry
// persists and restores exactly like any other registered window — no
// persistence-layer change was needed for this, since RetailWindowLayoutPersistence
// already clamps against the frame's own MinWidth/MinHeight/MaxWidth/
// MaxHeight (Apply's ClampDimension) for every registered handle. ────────
[Fact]
public void ResizableMarkupPluginPanel_ResizedSize_RoundTripsThroughPersistence()
{
const string xml =
"<panel x=\"0\" y=\"0\" w=\"300\" h=\"200\" resizable=\"true\" minw=\"200\" minh=\"150\"></panel>";
var panel = MarkupDocument.Build(xml, new object(), _ => (1u, 32, 32));
var store = new SettingsStore(PathName);
var root = new UiRoot { Width = 800, Height = 600 };
root.AddChild(panel);
RetailWindowHandle handle = root.RegisterWindow("plugin-resizable-panel", panel);
using var persistence = new RetailWindowLayoutPersistence(
root.WindowManager, store, () => "Alice", () => (800, 600));
handle.MoveTo(60f, 70f);
handle.ResizeTo(420f, 260f);
UiWindowLayout saved = Assert.IsType<UiWindowLayout>(
store.LoadWindowLayout("Alice", "800x600", "plugin-resizable-panel", default));
Assert.Equal((60f, 70f, 420f, 260f), (saved.X, saved.Y, saved.Width, saved.Height));
// Simulate a fresh session: rebuild the same markup (a fresh, un-resized
// panel) and restore — the saved 420x260 must come back, not the
// authored 300x200.
var freshPanel = MarkupDocument.Build(xml, new object(), _ => (1u, 32, 32));
var freshRoot = new UiRoot { Width = 800, Height = 600 };
freshRoot.AddChild(freshPanel);
RetailWindowHandle freshHandle = freshRoot.RegisterWindow(
"plugin-resizable-panel", freshPanel);
using var freshPersistence = new RetailWindowLayoutPersistence(
freshRoot.WindowManager, store, () => "Alice", () => (800, 600));
freshPersistence.RestoreAll();
Assert.Equal((420f, 260f), (freshPanel.Width, freshPanel.Height));
Assert.Equal((60f, 70f), (freshPanel.Left, freshPanel.Top));
}
[Fact]
public void ResizableMarkupPluginPanel_RestoreClampsBelowFloorToAuthoredMin()
{
// A legacy save from before this feature's minw/minh existed (or one
// from a plugin update that raised its floor) can carry a size below
// the CURRENT authored minimum. Restore must clamp up to that floor —
// this is what RetailWindowLayoutPersistence.Apply's ClampDimension
// already does against frame.MinWidth/MinHeight for every registered
// window; it only bites here because MarkupDocument now sets those
// fields from minw/minh instead of leaving them at UiElement's
// generic 40x40 default.
const string xml =
"<panel x=\"0\" y=\"0\" w=\"300\" h=\"200\" resizable=\"true\" minw=\"200\" minh=\"150\"></panel>";
var store = new SettingsStore(PathName);
store.SaveWindowLayout(
"Alice",
"800x600",
"plugin-resizable-floor",
new UiWindowLayout(60f, 70f, 80f, 60f, true, false, false));
var panel = MarkupDocument.Build(xml, new object(), _ => (1u, 32, 32));
var root = new UiRoot { Width = 800, Height = 600 };
root.AddChild(panel);
root.RegisterWindow("plugin-resizable-floor", panel);
using var persistence = new RetailWindowLayoutPersistence(
root.WindowManager, store, () => "Alice", () => (800, 600));
persistence.RestoreAll();
Assert.Equal((200f, 150f), (panel.Width, panel.Height));
}
private static RetailWindowHandle Mount(
UiRoot root,
string name,

View file

@ -250,6 +250,47 @@ public sealed class RetailWindowManagerTests
transitions);
}
// ── 2026-09-07: plugin markup's resizable="true" panel through the real
// window-manager ResizeTo path (no host wiring beyond what MarkupDocument
// already sets on the panel — Resizable/ResizeX/ResizeY/MinWidth/MinHeight
// are ordinary UiElement properties RetailWindowManager.ResizeTo already
// respects for any registered window). ───────────────────────────────────
[Fact]
public void ResizableMarkupPluginWindow_AcceptsResizeWithinMinAndParentConstraints()
{
const string xml =
"<panel x=\"0\" y=\"0\" w=\"300\" h=\"200\" resizable=\"true\" minw=\"250\" minh=\"150\"></panel>";
var panel = MarkupDocument.Build(xml, new object(), _ => (1u, 32, 32));
var root = new UiRoot { Width = 800, Height = 600 };
root.AddChild(panel);
RetailWindowHandle handle = root.RegisterWindow("plugin-resizable", panel);
Assert.True(handle.ResizeTo(500f, 400f));
Assert.Equal((500f, 400f), (panel.Width, panel.Height));
// Below the authored floor clamps to minw/minh rather than shrinking further.
Assert.True(handle.ResizeTo(50f, 50f));
Assert.Equal((250f, 150f), (panel.Width, panel.Height));
}
[Fact]
public void NonResizableMarkupPluginWindow_RefusesResize()
{
const string xml = "<panel x=\"0\" y=\"0\" w=\"300\" h=\"200\"></panel>";
var panel = MarkupDocument.Build(xml, new object(), _ => (1u, 32, 32));
var root = new UiRoot { Width = 800, Height = 600 };
root.AddChild(panel);
RetailWindowHandle handle = root.RegisterWindow("plugin-fixed", panel);
int resized = 0;
handle.Resized += _ => resized++;
handle.ResizeTo(500f, 400f);
Assert.Equal((300f, 200f), (panel.Width, panel.Height));
Assert.Equal(0, resized);
}
private sealed class RecordingController : IRetainedPanelController
{
public int ShownCount { get; private set; }

View file

@ -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;
/// <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"): a plugin-markup <c>&lt;list&gt;</c> (<see cref="UiMarkupList"/>)
/// that overflows its own row viewport now draws the same
/// <see cref="RetailScrollbarChrome"/> sprites the chat SpewBox and the
/// inventory <c>UiItemList</c> 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.
///
/// <para>
/// 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
/// <c>_topRow</c>, provable the same way <c>MarkupListColumnsTests</c>'
/// own <c>Scroll_OffsetIsRespectedBySubsequentHitTests</c> proves wheel
/// scrolling: read the moved position back through a subsequent row
/// click/select).
/// </para>
/// </summary>
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<float> 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);
}
}

View file

@ -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]