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

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