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:
parent
b36b036475
commit
3a3b40fab5
2 changed files with 395 additions and 8 deletions
|
|
@ -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><button name="Foo"></c> when it carries a <c>name</c>/<c>id</c>,
|
||||
/// else just <c><button></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}\">";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue