merge(vt): resizable plugin panels (resizable/minw/minh) and anchor markup (owner: larger default, resizable)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-07 15:45:06 +02:00
commit 2e63391cc4
5 changed files with 605 additions and 27 deletions

View file

@ -36,8 +36,9 @@ in-memory KSML string instead of a file path — the route to reach for when a
panel is small enough not to need its own shipped `.xml` asset.
Every registered window gets a stable persisted key
(`plugin:{pluginId}:{windowId}`), drag, resize (where the markup opts in),
the global UI lock, and a button in the shared plugin shelf
(`plugin:{pluginId}:{windowId}`), drag, resize (where the markup opts in —
`<panel resizable="true">`, see "Resizable panels and anchors" below), the
global UI lock, and a button in the shared plugin shelf
(`ShowInSidePanel = true`, the default). Hiding or minimizing a window never
disables the plugin or pauses its `Tick`.
@ -98,18 +99,18 @@ vanishing from the built tree.
| Element | Purpose | Key attributes |
|---|---|---|
| `panel` (root) | The window itself | `x y w h title resize visible` |
| `group` | Transparent layout container | `x y w h background border visible` |
| `label` | Static or bound text | `x y text color` |
| `button` | Clickable rect + caption (+ Slice B icon) | `x y w h text color background border onclick icon iconkind` |
| `icon` | Slice B: a standalone DAT icon | `x y w h did spell item tooltip` |
| `panel` (root) | The window itself | `x y w h title resize resizable minw minh visible` |
| `group` | Transparent layout container | `x y w h background border visible anchor` |
| `label` | Static or bound text | `x y text color anchor` |
| `button` | Clickable rect + caption (+ Slice B icon) | `x y w h text color background border onclick icon iconkind anchor` |
| `icon` | Slice B: a standalone DAT icon | `x y w h did spell item tooltip anchor` |
| `meter` | Retail-style nine-slice bar | `x y w h fill cur max color anchor backleft/backtile/backright frontleft/fronttile/frontright` |
| `tab` | Selectable tab button | `x y w h text selected onclick` |
| `toggle` | Lamp-style checkbox | `x y w h text checked onclick color` |
| `slider` | Horizontal scalar | `x y w h value onchange` |
| `field` | Single-line editable text | `x y w h text maxlength clearonsubmit onchange onsubmit color background` |
| `menu` | Dropdown selector | `x y w h items selected onchange rows rowheight openupward style` |
| `list` | Scrollable row list (+ Slice B icon column, + Campaign VT slice 1 multi-column) | `x y w h selected onchange rowheight` + either the single-column `items colors icons iconkind`, or one-to-many `<column>` children (see "Columns" below) — never both |
| `tab` | Selectable tab button | `x y w h text selected onclick anchor` |
| `toggle` | Lamp-style checkbox | `x y w h text checked onclick color anchor` |
| `slider` | Horizontal scalar | `x y w h value onchange anchor` |
| `field` | Single-line editable text | `x y w h text maxlength clearonsubmit onchange onsubmit color background anchor` |
| `menu` | Dropdown selector | `x y w h items selected onchange rows rowheight openupward style anchor` |
| `list` | Scrollable row list (+ Slice B icon column, + Campaign VT slice 1 multi-column) | `x y w h selected onchange rowheight anchor` + either the single-column `items colors icons iconkind`, or one-to-many `<column>` children (see "Columns" below) — never both |
`menu style` is `plain` (the default) or `retail`: retail's gold pushbutton
art read as an out-of-place "big yellow button" next to a plugin's own dark
@ -139,12 +140,15 @@ scrollbar chrome is shared between the two styles.
Common to every element via `ApplyCommon`: `name`/`id` (a stable control
name), `visible` (literal `true`/`false` or a bound `bool` property),
`enabled` (same rule), and `tooltip` (a literal string or `{Binding}` shown
`enabled` (same rule), `tooltip` (a literal string or `{Binding}` shown
through retail's own runtime tooltip popup, empty/whitespace treated as no
tooltip). The root `<panel>` is the one exception: it does **not** go
through `ApplyCommon` (no `name`/`enabled`/`tooltip`), and its `visible`
attribute accepts a `{Binding}` only — a literal `visible="true"` on the
root is not parsed (unlike every child element, where a literal is fine).
tooltip), and `anchor` (which edges of the element's PARENT it keeps a fixed
margin to on resize — see "Resizable panels and anchors" below). The root
`<panel>` is the one exception: it does **not** go through `ApplyCommon` (no
`name`/`enabled`/`tooltip`/`anchor` — a top-level window is never anchored to
its own parent, only dragged/resized directly), and its `visible` attribute
accepts a `{Binding}` only — a literal `visible="true"` on the root is not
parsed (unlike every child element, where a literal is fine).
Multi-column lists are real (Campaign VT slice 1 Part B, below) — a `<list>`
with `<column>` children is no longer limited to one padded text column. A
@ -161,6 +165,67 @@ the `0x` prefix to parse as hex; an all-digit string with no prefix
(`did="165"`) parses as **decimal**, not hex — `did="165"` and `did="0x165"`
are different ids.
## Resizable panels and anchors
A plugin panel is **fixed-size by default** — this matches every panel
shipped before 2026-09-07 (e.g. `mosstank.xml`'s `resize="none"`). A window
opts into real user drag-resize with `<panel resizable="true">`, and every
non-root element opts its OWN geometry into following that resize with
`anchor="..."`. The two attributes are independent: a resizable panel whose
children have no `anchor` just gets bigger/smaller with empty space at the
bottom-right (today's default placement, `Left|Top`); a panel with anchored
children but `resizable` left at its default `false` never actually resizes,
so the anchors never have anything to react to.
| Attribute | Element | Meaning |
|---|---|---|
| `resizable` | `panel` (root) | `"true"` arms the window for user drag-resize on both axes (edges + corners, same mechanism chat windows use); default `false` — fixed size, exactly as before this attribute existed |
| `minw` / `minh` | `panel` (root) | The floor a drag-resize (and a persisted-layout restore) will not shrink below. Default: the panel's own authored `w`/`h` — a resizable panel never shrinks past the layout its author actually tested. Only meaningful when `resizable="true"` |
| `resize` | `panel` (root) | Pre-existing per-axis lock (`x`/`y`/`both`/`none`) that narrows `resizable="true"` to one axis; has no effect on its own now that `resizable` (default `false`) is the master switch |
| `anchor` | `group` `list` `menu` `field` `label` `button` `icon` (and `meter`/`tab`/`toggle`/`slider`) | Space-separated subset of `left top right bottom` (case-insensitive), naming which edges of the element's **direct parent** it keeps a fixed margin to as that parent resizes. Default (attribute absent) is `left top` — today's fixed placement, unchanged |
`anchor` semantics are exactly `AcDream.App.UI.UiElement.Anchors`/
`AnchorEdges`/`ApplyAnchor` (already used by every retail-imported window):
- `left top` (the default) — pinned top-left at a fixed size; never stretches.
- `left right` — stretches WIDTH to track the parent (both side margins stay
fixed).
- `top bottom` — stretches HEIGHT the same way, vertically.
- `left top right bottom` — stretches on both axes.
- `right` alone (no `left`) — pins to the parent's right edge at a FIXED
width, moving as the parent resizes rather than stretching. `bottom` alone
is the same, vertically.
An element's parent is whatever markup element directly contains it — for a
`<group>`'s children, that is the GROUP, not the panel. This is how a group
propagates resize to its own contents: give the group
`anchor="left top right bottom"` so it stretches with the panel, and give a
`<list>` inside it `anchor="left right"` so the list stretches with the
GROUP's width in turn. An unrecognized token (a typo like
`anchor="left rihgt"`) throws `FormatException` at `Build`, naming the
offending element by its `name`/`id` — the same "malformed markup throws"
rule every other attribute in this grammar follows.
No other markup or host wiring is needed to make a panel resizable: once
`resizable="true"` sets the window's `Resizable`/`ResizeX`/`ResizeY`/
`MinWidth`/`MinHeight`, the SAME drag-resize, persistence (save/restore
across sessions, clamped to `minw`/`minh`), and UI-lock behavior every other
retained window already has just applies.
```xml
<panel x="0" y="0" w="420" h="320" title="MossTank" resizable="true" minw="360" minh="260">
<group anchor="left top right bottom" x="8" y="8" w="404" h="304" border="#FF4A3A14">
<label x="4" y="4" text="Monsters"/>
<list anchor="left right top bottom" x="4" y="24" w="396" h="276"
items="{MonsterNames}" selected="{SelectedMonster}" onchange="{SelectMonster}"/>
</group>
</panel>
```
Here the outer `<group>` stretches with the panel on every edge, and the
`<list>` inside it stretches with the GROUP on every edge in turn — dragging
the window's corner grows the whole list, not just empty panel background.
## The icon-id grammar (Slice B)
Decal/VirindiViewService plugins (the reference usage this ported:
@ -554,4 +619,14 @@ apparatus, hit-test routing (text selects unless it has its own `onclick`;
check/icon/onclick-text fire their own callback and never touch selection),
a backward-compatibility proof that a column-less `<list>` is unaffected,
and two full `MarkupDocument.Build` end-to-end tests transcribing VTank's
real Monsters- and Meta-tab column shapes.
real Monsters- and Meta-tab column shapes. `MarkupResizableAnchorTests`
covers `resizable`/`minw`/`minh` parsing, the `anchor` grammar (default,
every token combination, the unknown-token throw) across every element
listed above, live re-layout against the same recording-renderer apparatus
(a stretching list, a right-anchored button that moves, a group whose resize
propagates to its own anchored children), and a golden proving a panel with
none of these attributes draws byte-identically to itself across repeated
builds. `RetailWindowManagerTests`/`RetailWindowLayoutPersistenceTests`
cover a resizable markup panel through the real `ResizeTo`/save-restore
paths (accepts within `minw`/`minh`, a fixed panel refuses, a restored size
below the CURRENT floor clamps up to it).

View file

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

View file

@ -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; }