diff --git a/src/AcDream.App/UI/MarkupDocument.cs b/src/AcDream.App/UI/MarkupDocument.cs
index e27cd294..3be8a555 100644
--- a/src/AcDream.App/UI/MarkupDocument.cs
+++ b/src/AcDream.App/UI/MarkupDocument.cs
@@ -65,6 +65,7 @@ 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")),
});
break;
// 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;
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;
+ }
}
diff --git a/src/AcDream.App/UI/UiElement.cs b/src/AcDream.App/UI/UiElement.cs
index 48e1955b..e16c888f 100644
--- a/src/AcDream.App/UI/UiElement.cs
+++ b/src/AcDream.App/UI/UiElement.cs
@@ -4,6 +4,11 @@ using System.Numerics;
namespace AcDream.App.UI;
+/// Which parent edges a child keeps a fixed margin to on resize.
+/// Left+Right ⇒ width stretches; Top+Bottom ⇒ height stretches.
+[System.Flags]
+public enum AnchorEdges { None = 0, Left = 1, Top = 2, Right = 4, Bottom = 8 }
+
///
/// Base class for every UI widget in the retained-mode tree.
///
@@ -106,6 +111,10 @@ public abstract class UiElement
/// Allow vertical (height) resize. Ignored unless .
public bool ResizeY { get; set; } = true;
+ /// Edges this element anchors to in its parent. Default Left|Top
+ /// (pinned top-left, fixed size — no reflow). Left|Right stretches width.
+ public AnchorEdges Anchors { get; set; } = AnchorEdges.Left | AnchorEdges.Top;
+
// ── Tree structure ──────────────────────────────────────────────────
public UiElement? Parent { get; private set; }
@@ -170,6 +179,10 @@ public abstract class UiElement
{
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).
if (_children.Count > 0)
{
@@ -218,4 +231,51 @@ public abstract class UiElement
return OnHitTest(localX, localY) ? this : null;
}
+
+ // ── Anchor layout ────────────────────────────────────────────────────
+
+ private bool _anchorCaptured;
+ private float _amL, _amT, _amR, _amB, _aw0, _ah0;
+
+ /// Reposition/resize this element per , keeping
+ /// the margins captured (at first layout / design size) to each anchored edge.
+ /// Called by the parent each frame before drawing children.
+ 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;
+ }
+
+ /// 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.
+ 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);
+ }
}
diff --git a/src/AcDream.App/UI/assets/vitals.xml b/src/AcDream.App/UI/assets/vitals.xml
index 83d59c3b..08e065d6 100644
--- a/src/AcDream.App/UI/assets/vitals.xml
+++ b/src/AcDream.App/UI/assets/vitals.xml
@@ -1,5 +1,5 @@
-
-
-
-
+
+
+
+
diff --git a/tests/AcDream.App.Tests/UI/UiRootInputTests.cs b/tests/AcDream.App.Tests/UI/UiRootInputTests.cs
index 9ba7dae5..d3b3cc0b 100644
--- a/tests/AcDream.App.Tests/UI/UiRootInputTests.cs
+++ b/tests/AcDream.App.Tests/UI/UiRootInputTests.cs
@@ -112,4 +112,25 @@ public class UiRootInputTests
// bottom edge masked out (Y locked)
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);
+ }
}