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
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
321
tests/AcDream.App.Tests/UI/MarkupResizableAnchorTests.cs
Normal file
321
tests/AcDream.App.Tests/UI/MarkupResizableAnchorTests.cs
Normal 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><panel resizable="true" minw= minh=></c> grammar and the
|
||||
/// <c>anchor="left top right bottom"</c> attribute on <c><group></c>,
|
||||
/// <c><list></c>, <c><menu></c>, <c><field></c>,
|
||||
/// <c><label></c>, <c><button></c>, <c><icon></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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
302
tests/AcDream.App.Tests/UI/UiMarkupListScrollbarTests.cs
Normal file
302
tests/AcDream.App.Tests/UI/UiMarkupListScrollbarTests.cs
Normal 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><list></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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue