diff --git a/docs/plugin-ui-markup.md b/docs/plugin-ui-markup.md index 51b72a24..3f1be965 100644 --- a/docs/plugin-ui-markup.md +++ b/docs/plugin-ui-markup.md @@ -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 — +``, 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 `` 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 `` 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 `` 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 +`` 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 `` with `` 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 ``, 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 +``'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 +`` 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 + + + + +``` + +Here the outer `` stretches with the panel on every edge, and the +`` 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 `` 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). diff --git a/src/AcDream.App/UI/MarkupDocument.cs b/src/AcDream.App/UI/MarkupDocument.cs index 3dc76d2e..00c70e15 100644 --- a/src/AcDream.App/UI/MarkupDocument.cs +++ b/src/AcDream.App/UI/MarkupDocument.cs @@ -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 '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) + /// + /// Parses anchor="left top right bottom" (space-separated, any + /// subset of the four tokens, case-insensitive) into . + /// Absent/blank defaults to Left | Top — 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. ) — the + /// message names the offending element via + /// so a plugin author with several anchored siblings can find which one + /// is wrong. + /// + 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; + } + + /// Identifies a markup element for a Build-time error message: + /// <button name="Foo"> when it carries a name/id, + /// else just <button>. + 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}\">"; } } diff --git a/tests/AcDream.App.Tests/UI/MarkupResizableAnchorTests.cs b/tests/AcDream.App.Tests/UI/MarkupResizableAnchorTests.cs new file mode 100644 index 00000000..27feb01f --- /dev/null +++ b/tests/AcDream.App.Tests/UI/MarkupResizableAnchorTests.cs @@ -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; + +/// +/// 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 <panel resizable="true" minw= minh=> grammar and the +/// anchor="left top right bottom" attribute on <group>, +/// <list>, <menu>, <field>, +/// <label>, <button>, <icon>. +/// Semantics are the existing / +/// / 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). +/// +public sealed class MarkupResizableAnchorTests +{ + private sealed class ListBinding + { + public IReadOnlyList Items => ["A", "B", "C"]; + public int Selected { get; set; } = -1; + public Action 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 = ""; + 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 = ""; + 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 = + ""; + 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 = + ""; + 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 = + "" + + "