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