diff --git a/src/AcDream.App/UI/MarkupDocument.cs b/src/AcDream.App/UI/MarkupDocument.cs index 3d14d983..1ff22d9c 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")), @@ -1137,6 +1162,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); @@ -1287,19 +1324,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 = + "" + + "