feat(D.2b): anchor layout — vital bars stretch with window; drop Vitals heading
Add AnchorEdges [Flags] enum and Anchors property (default Left|Top, so all existing elements are unchanged) to UiElement. ApplyAnchor() captures the design-time margins on first call then recomputes Left/Top/Width/Height each frame; DrawSelfAndChildren drives it for every child before painting. ComputeAnchoredRect is public + static so it can be unit-tested without a running frame loop. MarkupDocument.Build gains a private Anchor() CSV parser and threads it into the <meter> initializer via the anchor= attribute. vitals.xml: remove title="Vitals" (retail vitals has no heading) and add anchor="left,top,right" to all three meter bars so they stretch when the panel is dragged wider. Two new xUnit tests in UiRootInputTests: Left+Right stretches width; Left+Top only keeps fixed size. All 19 App.Tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
af91b8432a
commit
f911b5f0af
4 changed files with 102 additions and 4 deletions
|
|
@ -65,6 +65,7 @@ public static class MarkupDocument
|
||||||
BarColor = Color((string?)el.Attribute("color")),
|
BarColor = Color((string?)el.Attribute("color")),
|
||||||
Fill = BindFloat((string?)el.Attribute("fill"), binding),
|
Fill = BindFloat((string?)el.Attribute("fill"), binding),
|
||||||
Label = () => (cur(), max()) is (uint c, uint m) ? $"{c}/{m}" : null,
|
Label = () => (cur(), max()) is (uint c, uint m) ? $"{c}/{m}" : null,
|
||||||
|
Anchors = Anchor((string?)el.Attribute("anchor")),
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
// future element kinds (label, button, image) added here
|
// future element kinds (label, button, image) added here
|
||||||
|
|
@ -123,4 +124,20 @@ public static class MarkupDocument
|
||||||
if (expr is null || expr.Length < 3 || expr[0] != '{' || expr[^1] != '}') return null;
|
if (expr is null || expr.Length < 3 || expr[0] != '{' || expr[^1] != '}') return null;
|
||||||
return binding.GetType().GetProperty(expr[1..^1]);
|
return binding.GetType().GetProperty(expr[1..^1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static AnchorEdges Anchor(string? csv)
|
||||||
|
{
|
||||||
|
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
|
||||||
|
{
|
||||||
|
"left" => AnchorEdges.Left,
|
||||||
|
"top" => AnchorEdges.Top,
|
||||||
|
"right" => AnchorEdges.Right,
|
||||||
|
"bottom" => AnchorEdges.Bottom,
|
||||||
|
_ => AnchorEdges.None,
|
||||||
|
};
|
||||||
|
return a == AnchorEdges.None ? AnchorEdges.Left | AnchorEdges.Top : a;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,11 @@ using System.Numerics;
|
||||||
|
|
||||||
namespace AcDream.App.UI;
|
namespace AcDream.App.UI;
|
||||||
|
|
||||||
|
/// <summary>Which parent edges a child keeps a fixed margin to on resize.
|
||||||
|
/// Left+Right ⇒ width stretches; Top+Bottom ⇒ height stretches.</summary>
|
||||||
|
[System.Flags]
|
||||||
|
public enum AnchorEdges { None = 0, Left = 1, Top = 2, Right = 4, Bottom = 8 }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Base class for every UI widget in the retained-mode tree.
|
/// Base class for every UI widget in the retained-mode tree.
|
||||||
///
|
///
|
||||||
|
|
@ -106,6 +111,10 @@ public abstract class UiElement
|
||||||
/// <summary>Allow vertical (height) resize. Ignored unless <see cref="Resizable"/>.</summary>
|
/// <summary>Allow vertical (height) resize. Ignored unless <see cref="Resizable"/>.</summary>
|
||||||
public bool ResizeY { get; set; } = true;
|
public bool ResizeY { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>Edges this element anchors to in its parent. Default Left|Top
|
||||||
|
/// (pinned top-left, fixed size — no reflow). Left|Right stretches width.</summary>
|
||||||
|
public AnchorEdges Anchors { get; set; } = AnchorEdges.Left | AnchorEdges.Top;
|
||||||
|
|
||||||
// ── Tree structure ──────────────────────────────────────────────────
|
// ── Tree structure ──────────────────────────────────────────────────
|
||||||
public UiElement? Parent { get; private set; }
|
public UiElement? Parent { get; private set; }
|
||||||
|
|
||||||
|
|
@ -170,6 +179,10 @@ public abstract class UiElement
|
||||||
{
|
{
|
||||||
OnDraw(ctx);
|
OnDraw(ctx);
|
||||||
|
|
||||||
|
// Anchor layout: reflow children to this element's current size.
|
||||||
|
for (int i = 0; i < _children.Count; i++)
|
||||||
|
_children[i].ApplyAnchor(Width, Height);
|
||||||
|
|
||||||
// Children painted back-to-front (lowest ZOrder first).
|
// Children painted back-to-front (lowest ZOrder first).
|
||||||
if (_children.Count > 0)
|
if (_children.Count > 0)
|
||||||
{
|
{
|
||||||
|
|
@ -218,4 +231,51 @@ public abstract class UiElement
|
||||||
|
|
||||||
return OnHitTest(localX, localY) ? this : null;
|
return OnHitTest(localX, localY) ? this : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Anchor layout ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private bool _anchorCaptured;
|
||||||
|
private float _amL, _amT, _amR, _amB, _aw0, _ah0;
|
||||||
|
|
||||||
|
/// <summary>Reposition/resize this element per <see cref="Anchors"/>, keeping
|
||||||
|
/// the margins captured (at first layout / design size) to each anchored edge.
|
||||||
|
/// Called by the parent each frame before drawing children.</summary>
|
||||||
|
internal void ApplyAnchor(float parentW, float parentH)
|
||||||
|
{
|
||||||
|
if (Anchors == AnchorEdges.None) return;
|
||||||
|
if (!_anchorCaptured)
|
||||||
|
{
|
||||||
|
_amL = Left; _amT = Top;
|
||||||
|
_amR = parentW - (Left + Width);
|
||||||
|
_amB = parentH - (Top + Height);
|
||||||
|
_aw0 = Width; _ah0 = Height;
|
||||||
|
_anchorCaptured = true;
|
||||||
|
}
|
||||||
|
var (x, y, w, h) = ComputeAnchoredRect(Anchors, _amL, _amT, _amR, _amB, _aw0, _ah0, parentW, parentH);
|
||||||
|
Left = x; Top = y; Width = w; Height = h;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Compute an anchored child rect. Left&Right ⇒ stretch width
|
||||||
|
/// (keep both margins); Right only ⇒ pin to right at fixed width; otherwise
|
||||||
|
/// pin left at fixed width. Same logic vertically.</summary>
|
||||||
|
public static (float x, float y, float w, float h) ComputeAnchoredRect(
|
||||||
|
AnchorEdges a, float mL, float mT, float mR, float mB,
|
||||||
|
float w0, float h0, float parentW, float parentH)
|
||||||
|
{
|
||||||
|
bool l = (a & AnchorEdges.Left) != 0, r = (a & AnchorEdges.Right) != 0;
|
||||||
|
float x, w;
|
||||||
|
if (l && r) { x = mL; w = parentW - mR - mL; }
|
||||||
|
else if (r) { w = w0; x = parentW - mR - w0; }
|
||||||
|
else { x = mL; w = w0; }
|
||||||
|
|
||||||
|
bool t = (a & AnchorEdges.Top) != 0, b = (a & AnchorEdges.Bottom) != 0;
|
||||||
|
float y, h;
|
||||||
|
if (t && b) { y = mT; h = parentH - mB - mT; }
|
||||||
|
else if (b) { h = h0; y = parentH - mB - h0; }
|
||||||
|
else { y = mT; h = h0; }
|
||||||
|
|
||||||
|
if (w < 0) w = 0;
|
||||||
|
if (h < 0) h = 0;
|
||||||
|
return (x, y, w, h);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<panel id="acdream.vitals" x="10" y="30" w="220" h="96" title="Vitals" resize="x">
|
<panel id="acdream.vitals" x="10" y="30" w="220" h="96" resize="x">
|
||||||
<meter id="health" x="8" y="24" w="200" h="14" fill="{HealthPercent}" cur="{HealthCurrent}" max="{HealthMax}" color="#FFC70D0D"/>
|
<meter id="health" x="8" y="24" w="200" h="14" fill="{HealthPercent}" cur="{HealthCurrent}" max="{HealthMax}" color="#FFC70D0D" anchor="left,top,right"/>
|
||||||
<meter id="stamina" x="8" y="44" w="200" h="14" fill="{StaminaPercent}" cur="{StaminaCurrent}" max="{StaminaMax}" color="#FFD49E1F"/>
|
<meter id="stamina" x="8" y="44" w="200" h="14" fill="{StaminaPercent}" cur="{StaminaCurrent}" max="{StaminaMax}" color="#FFD49E1F" anchor="left,top,right"/>
|
||||||
<meter id="mana" x="8" y="64" w="200" h="14" fill="{ManaPercent}" cur="{ManaCurrent}" max="{ManaMax}" color="#FF1F33D9"/>
|
<meter id="mana" x="8" y="64" w="200" h="14" fill="{ManaPercent}" cur="{ManaCurrent}" max="{ManaMax}" color="#FF1F33D9" anchor="left,top,right"/>
|
||||||
</panel>
|
</panel>
|
||||||
|
|
|
||||||
|
|
@ -112,4 +112,25 @@ public class UiRootInputTests
|
||||||
// bottom edge masked out (Y locked)
|
// bottom edge masked out (Y locked)
|
||||||
Assert.True((UiRoot.HitEdges(panel, 200, 200, 5) & ResizeEdges.Bottom) == 0);
|
Assert.True((UiRoot.HitEdges(panel, 200, 200, 5) & ResizeEdges.Bottom) == 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ComputeAnchoredRect_LeftRight_StretchesWidth()
|
||||||
|
{
|
||||||
|
// bar at x=8,w=200 in a 220-wide parent (right margin 12). Parent grows to 300.
|
||||||
|
var (x, _, w, _) = UiElement.ComputeAnchoredRect(
|
||||||
|
AnchorEdges.Left | AnchorEdges.Right | AnchorEdges.Top,
|
||||||
|
mL: 8, mT: 24, mR: 12, mB: 58, w0: 200, h0: 14, parentW: 300, parentH: 96);
|
||||||
|
Assert.Equal(8f, x);
|
||||||
|
Assert.Equal(280f, w); // 300 - 12 - 8
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ComputeAnchoredRect_LeftTopOnly_KeepsFixedSizeAndOrigin()
|
||||||
|
{
|
||||||
|
var (x, y, w, h) = UiElement.ComputeAnchoredRect(
|
||||||
|
AnchorEdges.Left | AnchorEdges.Top,
|
||||||
|
mL: 8, mT: 24, mR: 12, mB: 58, w0: 200, h0: 14, parentW: 300, parentH: 96);
|
||||||
|
Assert.Equal(8f, x); Assert.Equal(24f, y);
|
||||||
|
Assert.Equal(200f, w); Assert.Equal(14f, h);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue