From de4f0167ef8f5f81822fcc07a4ed08c417952191 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 14 Jun 2026 18:27:57 +0200 Subject: [PATCH] feat(D.2b): window resize (UiRoot edge-grip resize-drag mode) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add parallel resize mode to the UiRoot retained-mode input state machine. A left-drag starting within ResizeGrip=5px of a Resizable window's edge or corner resizes it (min-size clamped); interior drags on a Draggable window still reposition it. Changes: - UiElement: Resizable, MinWidth, MinHeight properties - UiRoot: ResizeEdges flags enum; _resizeTarget state fields; FindWindow (replaces FindDraggable, matches Draggable||Resizable); HitEdges (static, internal, testable); ResizeRect (static, public, testable); OnMouseDown checks edge-grip before move; OnMouseMove resize branch precedes move; OnMouseUp clears _resizeTarget - UiNineSlicePanel: Resizable = true (retail windows are resizable) - UiRootInputTests: 4 new tests — ResizeRect_RightBottom, ResizeRect_LeftTop (min-clamp + origin shift), HitEdges_DetectsCornerAndInteriorNone, EdgeDrag_ResizesPanel_InteriorDragMoves (full integration path) Note on test coordinate: right-edge grab uses x=298 (2px inside the panel's hit-test boundary) rather than x=300 (exactly at edge, misses OnHitTest's strict `<` check). This is intentional — the grip zone extends inward from the edge boundary, so a click 2px inside correctly lands in both the hit-test rect AND the resize-grip zone. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/UI/UiElement.cs | 8 ++ src/AcDream.App/UI/UiNineSlicePanel.cs | 1 + src/AcDream.App/UI/UiRoot.cs | 93 +++++++++++++++++-- .../AcDream.App.Tests/UI/UiRootInputTests.cs | 53 +++++++++++ 4 files changed, 145 insertions(+), 10 deletions(-) diff --git a/src/AcDream.App/UI/UiElement.cs b/src/AcDream.App/UI/UiElement.cs index 1989ce7c..30c4b260 100644 --- a/src/AcDream.App/UI/UiElement.cs +++ b/src/AcDream.App/UI/UiElement.cs @@ -93,6 +93,14 @@ public abstract class UiElement /// whose Left/Top are screen coordinates (Root sits at the origin). public bool Draggable { get; set; } + /// If true, a left-drag starting near this element's edge/corner + /// resizes it (window resize). Intended for top-level panels. + public bool Resizable { get; set; } + + /// Minimum size enforced while resizing. + public float MinWidth { get; set; } = 40f; + public float MinHeight { get; set; } = 40f; + // ── Tree structure ────────────────────────────────────────────────── public UiElement? Parent { get; private set; } diff --git a/src/AcDream.App/UI/UiNineSlicePanel.cs b/src/AcDream.App/UI/UiNineSlicePanel.cs index f1bbd2d1..576da3e1 100644 --- a/src/AcDream.App/UI/UiNineSlicePanel.cs +++ b/src/AcDream.App/UI/UiNineSlicePanel.cs @@ -28,6 +28,7 @@ public sealed class UiNineSlicePanel : UiPanel BackgroundColor = Vector4.Zero; // suppress the base flat-rect fill BorderColor = Vector4.Zero; Draggable = true; // retail windows are movable + Resizable = true; // retail windows are resizable } /// diff --git a/src/AcDream.App/UI/UiRoot.cs b/src/AcDream.App/UI/UiRoot.cs index 523f5cff..1b72ec9f 100644 --- a/src/AcDream.App/UI/UiRoot.cs +++ b/src/AcDream.App/UI/UiRoot.cs @@ -4,6 +4,10 @@ using System.Numerics; namespace AcDream.App.UI; +/// Which edges of a window a resize-drag is affecting (corners combine two). +[System.Flags] +public enum ResizeEdges { None = 0, Left = 1, Right = 2, Top = 4, Bottom = 8 } + /// /// Top-level UI container. Implements the retail "Device" responsibilities /// (mouse cursor tracking, keyboard focus, modal overlay, mouse capture, @@ -68,6 +72,11 @@ public sealed class UiRoot : UiElement private bool _dragCandidate; private UiElement? _windowDragTarget; private int _windowDragOffX, _windowDragOffY; + private UiElement? _resizeTarget; + private ResizeEdges _resizeEdges; + private float _resizeStartX, _resizeStartY, _resizeStartW, _resizeStartH; + private int _resizeMouseX, _resizeMouseY; + private const int ResizeGrip = 5; // px proximity to an edge to start a resize private const int DragDistanceThreshold = 3; // pixels, retail-observed // Hover / tooltip tracking. @@ -133,6 +142,18 @@ public sealed class UiRoot : UiElement MouseX = x; MouseY = y; + // Window resize takes precedence over move / drag-drop / hover. + if (_resizeTarget is not null) + { + var (nx, ny, nw, nh) = ResizeRect( + _resizeStartX, _resizeStartY, _resizeStartW, _resizeStartH, + _resizeEdges, x - _resizeMouseX, y - _resizeMouseY, + _resizeTarget.MinWidth, _resizeTarget.MinHeight); + _resizeTarget.Left = nx; _resizeTarget.Top = ny; + _resizeTarget.Width = nw; _resizeTarget.Height = nh; + return; + } + // Window-move drag takes precedence over drag-drop / hover / fall-through. if (_windowDragTarget is not null) { @@ -188,15 +209,30 @@ public sealed class UiRoot : UiElement SetCapture(target); - // Window-move: if the target or an ancestor is Draggable, a left-drag - // repositions that window instead of starting a drag-drop. - var draggable = FindDraggable(target); - if (btn == UiMouseButton.Left && draggable is not null) + // Window resize / move: find the window (Draggable or Resizable ancestor). + // A left-drag starting near an edge resizes; interior drag repositions; + // otherwise it's a normal drag-drop candidate. + var window = FindWindow(target); + if (btn == UiMouseButton.Left && window is not null) { - _windowDragTarget = draggable; - _windowDragOffX = x - (int)draggable.Left; - _windowDragOffY = y - (int)draggable.Top; - _dragCandidate = false; + var edges = window.Resizable ? HitEdges(window, x, y, ResizeGrip) : ResizeEdges.None; + if (edges != ResizeEdges.None) + { + _resizeTarget = window; + _resizeEdges = edges; + _resizeStartX = window.Left; _resizeStartY = window.Top; + _resizeStartW = window.Width; _resizeStartH = window.Height; + _resizeMouseX = x; _resizeMouseY = y; + _dragCandidate = false; + } + else if (window.Draggable) + { + _windowDragTarget = window; + _windowDragOffX = x - (int)window.Left; + _windowDragOffY = y - (int)window.Top; + _dragCandidate = false; + } + else { _dragCandidate = true; } } else { @@ -221,6 +257,13 @@ public sealed class UiRoot : UiElement MouseX = x; MouseY = y; UpdateButtonFlag(btn, down: false); + if (_resizeTarget is not null) + { + _resizeTarget = null; + ReleaseCapture(); + return; + } + if (_windowDragTarget is not null) { _windowDragTarget = null; @@ -477,16 +520,46 @@ public sealed class UiRoot : UiElement return (null, 0, 0); } - private static UiElement? FindDraggable(UiElement? e) + private static UiElement? FindWindow(UiElement? e) { while (e is not null) { - if (e.Draggable) return e; + if (e.Draggable || e.Resizable) return e; e = e.Parent; } return null; } + /// Which edges of 's screen rect the point + /// (,) is within px of. + /// None if the point is outside the grip-expanded box entirely. + internal static ResizeEdges HitEdges(UiElement w, int x, int y, int grip) + { + float l = w.Left, t = w.Top, r = w.Left + w.Width, b = w.Top + w.Height; + if (x < l - grip || x > r + grip || y < t - grip || y > b + grip) return ResizeEdges.None; + var e = ResizeEdges.None; + if (System.Math.Abs(x - l) <= grip) e |= ResizeEdges.Left; + if (System.Math.Abs(x - r) <= grip) e |= ResizeEdges.Right; + if (System.Math.Abs(y - t) <= grip) e |= ResizeEdges.Top; + if (System.Math.Abs(y - b) <= grip) e |= ResizeEdges.Bottom; + return e; + } + + /// Compute a resized rect from a start rect + drag delta + which edges, + /// clamping to (,). Left/Top edges + /// move the origin so the opposite edge stays put. + public static (float x, float y, float w, float h) ResizeRect( + float startX, float startY, float startW, float startH, + ResizeEdges edges, float dx, float dy, float minW, float minH) + { + float x = startX, y = startY, w = startW, h = startH; + if ((edges & ResizeEdges.Right) != 0) w = System.Math.Max(minW, startW + dx); + if ((edges & ResizeEdges.Bottom) != 0) h = System.Math.Max(minH, startH + dy); + if ((edges & ResizeEdges.Left) != 0) { float nw = System.Math.Max(minW, startW - dx); x = startX + (startW - nw); w = nw; } + if ((edges & ResizeEdges.Top) != 0) { float nh = System.Math.Max(minH, startH - dy); y = startY + (startH - nh); h = nh; } + return (x, y, w, h); + } + private static bool ContainsAbsolute(UiElement e, int x, int y) { var sp = e.ScreenPosition; diff --git a/tests/AcDream.App.Tests/UI/UiRootInputTests.cs b/tests/AcDream.App.Tests/UI/UiRootInputTests.cs index 31bb0bca..6ea9e317 100644 --- a/tests/AcDream.App.Tests/UI/UiRootInputTests.cs +++ b/tests/AcDream.App.Tests/UI/UiRootInputTests.cs @@ -49,4 +49,57 @@ public class UiRootInputTests Assert.Equal(10f, panel.Left); Assert.Equal(10f, panel.Top); } + + [Fact] + public void ResizeRect_RightBottom_GrowsSizeOnly() + { + var (x, y, w, h) = UiRoot.ResizeRect(10, 20, 100, 50, + ResizeEdges.Right | ResizeEdges.Bottom, dx: 30, dy: 15, minW: 40, minH: 40); + Assert.Equal(10f, x); Assert.Equal(20f, y); + Assert.Equal(130f, w); Assert.Equal(65f, h); + } + + [Fact] + public void ResizeRect_LeftTop_MovesOriginAndClampsToMin() + { + // Drag left edge right by 80 on a 100-wide / min-40 window: width clamps to 40, + // origin shifts so the RIGHT edge (110) stays put → x = 70. + var (x, _, w, _) = UiRoot.ResizeRect(10, 20, 100, 50, + ResizeEdges.Left, dx: 80, dy: 0, minW: 40, minH: 40); + Assert.Equal(40f, w); + Assert.Equal(70f, x); + } + + [Fact] + public void HitEdges_DetectsCornerAndInteriorNone() + { + var panel = new UiPanel { Left = 100, Top = 100, Width = 200, Height = 100 }; + // bottom-right corner (300,200) + Assert.Equal(ResizeEdges.Right | ResizeEdges.Bottom, UiRoot.HitEdges(panel, 300, 200, 5)); + // deep interior → no edges + Assert.Equal(ResizeEdges.None, UiRoot.HitEdges(panel, 200, 150, 5)); + } + + [Fact] + public void EdgeDrag_ResizesPanel_InteriorDragMoves() + { + var root = new UiRoot { Width = 800, Height = 600 }; + var panel = new UiPanel { Left = 100, Top = 100, Width = 200, Height = 100, + Draggable = true, Resizable = true, MinWidth = 40, MinHeight = 40 }; + root.AddChild(panel); + + // grab just inside the right edge (x=298, within ResizeGrip=5 of x=300) and drag right → wider, same origin + root.OnMouseDown(UiMouseButton.Left, 298, 150); + root.OnMouseMove(338, 150); + Assert.Equal(240f, panel.Width); + Assert.Equal(100f, panel.Left); + root.OnMouseUp(UiMouseButton.Left, 338, 150); + + // grab the interior and drag → moves + root.OnMouseDown(UiMouseButton.Left, 200, 150); + root.OnMouseMove(220, 170); + Assert.Equal(120f, panel.Left); + Assert.Equal(120f, panel.Top); + root.OnMouseUp(UiMouseButton.Left, 220, 170); + } }