feat(ui): add resizable/minw/minh + anchor grammar to plugin markup

Owner direction (2026-09-07): plugin panels need a bigger default size
and real resizability. Plugin markup panels were fixed-size with no way
to opt in to drag-resize, and only <meter> exposed an anchor attribute
(comma-separated, silently dropping unknown tokens) — no other element
could stretch or reposition when its window resized.

<panel resizable="true" minw= minh=> is now the explicit opt-in (default
false — a panel with none of these attributes gets Resizable=false,
ResizeX=false, ResizeY=false, matching every plugin panel shipped today,
e.g. mosstank.xml's resize="none"). resizable="true" arms both axes and
defaults the min size to the authored w/h so a resizable panel never
shrinks below the layout its author tested; the pre-existing resize=
attribute still narrows to one axis on top of that.

anchor="left top right bottom" (space-separated, case-insensitive) now
applies uniformly via ApplyCommon to every element (<group>, <list>,
<menu>, <field>, <label>, <button>, <icon>, plus <meter>/<tab>/<toggle>/
<slider> for free) instead of just <meter>'s own comma-separated,
non-throwing parse. An unknown token now throws FormatException naming
the element, matching this file's "malformed markup throws at Build"
convention everywhere else. No new plumbing is needed for live re-layout
or group-relative child anchoring — UiElement.ApplyAnchor/AnchorEdges
already measure a child's margins against its own direct Parent's
Width/Height every draw, and RetailWindowManager.ResizeTo/UiRoot's
existing edge-drag resize already respect Resizable/ResizeX/ResizeY/
MinWidth/MinHeight generically for any registered window.

Mutation proof: reverted MarkupDocument.cs to its pre-change state and
reran the 25 new MarkupResizableAnchorTests — 17 failed (the anchor
grammar, resizable/minw/minh parsing, live re-layout, and golden-draw
tests), 8 passed trivially (cases asserting the unchanged no-attribute
default). Restoring the implementation turned all 25 green with no
regression in the existing 227 Markup/PluginSidePanel/RetailWindow/
Anchor-filtered tests (252/253, 1 pre-existing unrelated skip).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-07 15:35:14 +02:00
parent b36b036475
commit 3a3b40fab5
2 changed files with 395 additions and 8 deletions

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")),
@ -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 <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);
@ -1287,19 +1324,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}\">";
}
}