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); + } }