diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index bea54e36..ca649ec2 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -978,8 +978,10 @@ public sealed class GameWindow : IDisposable
_kbSource = new AcDream.App.Input.SilkKeyboardSource(firstKb);
_mouseSource = new AcDream.App.Input.SilkMouseSource(
firstMouse,
- wantCaptureMouse: () => DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse,
- wantCaptureKeyboard: () => DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureKeyboard);
+ wantCaptureMouse: () => (DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse)
+ || (_uiHost?.Root.WantsMouse ?? false),
+ wantCaptureKeyboard: () => (DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureKeyboard)
+ || (_uiHost?.Root.WantsKeyboard ?? false));
_mouseSource.ModifierProbe = () => _kbSource.CurrentModifiers;
_inputDispatcher = new AcDream.UI.Abstractions.Input.InputDispatcher(
_kbSource, _mouseSource, _keyBindings);
@@ -1045,7 +1047,8 @@ public sealed class GameWindow : IDisposable
// K.1b §E: explicit WantCaptureMouse defense-in-depth on the
// surviving direct-mouse handler. Suppresses RMB orbit /
// FlyCamera look while ImGui has the mouse focus.
- if (DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse)
+ if ((DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse)
+ || (_uiHost?.Root.WantsMouse ?? false))
{
_lastMouseX = pos.X;
_lastMouseY = pos.Y;
@@ -1745,6 +1748,13 @@ public sealed class GameWindow : IDisposable
_vitalsVm ??= new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer);
_uiHost = new AcDream.App.UI.UiHost(_gl, shadersDir, _debugFont);
+ // Feed Silk input to the UiRoot tree so windows drag / close / select.
+ // UiRoot consumes UI events; the game InputDispatcher (subscribed to the
+ // same devices) is gated off via WantCaptureMouse/Keyboard above when the
+ // pointer is over a widget — no double-handling.
+ foreach (var m in _input!.Mice) _uiHost.WireMouse(m);
+ foreach (var kb in _input!.Keyboards) _uiHost.WireKeyboard(kb);
+
var cache = _textureCache!;
(uint, int, int) ResolveChrome(uint id)
{
diff --git a/src/AcDream.App/UI/UiElement.cs b/src/AcDream.App/UI/UiElement.cs
index ae9a0a7c..1989ce7c 100644
--- a/src/AcDream.App/UI/UiElement.cs
+++ b/src/AcDream.App/UI/UiElement.cs
@@ -88,6 +88,11 @@ public abstract class UiElement
/// Painter's-algorithm z-order within siblings. Higher = on top.
public int ZOrder { get; set; }
+ /// If true, a left-drag on this element (or a non-draggable child of
+ /// it) repositions it as a movable window. Intended for top-level panels,
+ /// whose Left/Top are screen coordinates (Root sits at the origin).
+ public bool Draggable { get; set; }
+
// ── 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 2f04229a..f1bbd2d1 100644
--- a/src/AcDream.App/UI/UiNineSlicePanel.cs
+++ b/src/AcDream.App/UI/UiNineSlicePanel.cs
@@ -27,6 +27,7 @@ public sealed class UiNineSlicePanel : UiPanel
_resolve = resolve;
BackgroundColor = Vector4.Zero; // suppress the base flat-rect fill
BorderColor = Vector4.Zero;
+ Draggable = true; // retail windows are movable
}
///
diff --git a/src/AcDream.App/UI/UiRoot.cs b/src/AcDream.App/UI/UiRoot.cs
index 7df41739..523f5cff 100644
--- a/src/AcDream.App/UI/UiRoot.cs
+++ b/src/AcDream.App/UI/UiRoot.cs
@@ -49,12 +49,25 @@ public sealed class UiRoot : UiElement
/// Widget with mouse capture (during click-drag).
public UiElement? Captured { get; private set; }
+ ///
+ /// True when the pointer is over a widget OR a widget holds mouse capture.
+ /// The host ORs this into the InputDispatcher's WantCaptureMouse gate so game
+ /// actions (movement, world-pick) are suppressed while the user interacts with
+ /// a retail window — mirrors ImGui's WantCaptureMouse.
+ ///
+ public bool WantsMouse => Captured is not null || HitTestTopDown(MouseX, MouseY).element is not null;
+
+ /// True when a widget holds keyboard focus (e.g. a focused chat input).
+ public bool WantsKeyboard => KeyboardFocus is not null;
+
/// Current drag source (set between drag-begin and drop/cancel).
public UiElement? DragSource { get; private set; }
public object? DragPayload { get; private set; }
private UiElement? _lastDragHoverTarget;
private int _pressX, _pressY;
private bool _dragCandidate;
+ private UiElement? _windowDragTarget;
+ private int _windowDragOffX, _windowDragOffY;
private const int DragDistanceThreshold = 3; // pixels, retail-observed
// Hover / tooltip tracking.
@@ -120,6 +133,14 @@ public sealed class UiRoot : UiElement
MouseX = x;
MouseY = y;
+ // Window-move drag takes precedence over drag-drop / hover / fall-through.
+ if (_windowDragTarget is not null)
+ {
+ _windowDragTarget.Left = x - _windowDragOffX;
+ _windowDragTarget.Top = y - _windowDragOffY;
+ return;
+ }
+
// If we have capture, deliver MouseMove to the captured widget
// AND drive drag state machine; do NOT fall through.
if (Captured is not null)
@@ -165,9 +186,22 @@ public sealed class UiRoot : UiElement
// Set keyboard focus if target accepts it.
if (target.AcceptsFocus) SetKeyboardFocus(target);
- // Capture + arm drag candidate (drag promotes on subsequent MouseMove > threshold).
SetCapture(target);
- _dragCandidate = true;
+
+ // 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)
+ {
+ _windowDragTarget = draggable;
+ _windowDragOffX = x - (int)draggable.Left;
+ _windowDragOffY = y - (int)draggable.Top;
+ _dragCandidate = false;
+ }
+ else
+ {
+ _dragCandidate = true;
+ }
// Dispatch raw MouseDown event (retail uses WM_LBUTTONDOWN = 0x201).
int rawType = btn switch
@@ -187,6 +221,13 @@ public sealed class UiRoot : UiElement
MouseX = x; MouseY = y;
UpdateButtonFlag(btn, down: false);
+ if (_windowDragTarget is not null)
+ {
+ _windowDragTarget = null;
+ ReleaseCapture();
+ return;
+ }
+
if (DragSource is not null)
{
FinishDrag(x, y);
@@ -436,6 +477,16 @@ public sealed class UiRoot : UiElement
return (null, 0, 0);
}
+ private static UiElement? FindDraggable(UiElement? e)
+ {
+ while (e is not null)
+ {
+ if (e.Draggable) return e;
+ e = e.Parent;
+ }
+ return null;
+ }
+
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
new file mode 100644
index 00000000..31bb0bca
--- /dev/null
+++ b/tests/AcDream.App.Tests/UI/UiRootInputTests.cs
@@ -0,0 +1,52 @@
+using System.Numerics;
+using AcDream.App.UI;
+
+namespace AcDream.App.Tests.UI;
+
+public class UiRootInputTests
+{
+ [Fact]
+ public void WantsMouse_TrueOverWidget_FalseOverEmptySpace()
+ {
+ var root = new UiRoot { Width = 800, Height = 600 };
+ var panel = new UiPanel { Left = 10, Top = 10, Width = 100, Height = 50 };
+ root.AddChild(panel);
+
+ root.OnMouseMove(50, 30); // inside the panel
+ Assert.True(root.WantsMouse);
+
+ root.OnMouseMove(500, 400); // empty space
+ Assert.False(root.WantsMouse);
+ }
+
+ [Fact]
+ public void WindowDrag_RepositionsDraggablePanel_StopsOnRelease()
+ {
+ var root = new UiRoot { Width = 800, Height = 600 };
+ var panel = new UiPanel { Left = 10, Top = 10, Width = 100, Height = 50, Draggable = true };
+ root.AddChild(panel);
+
+ root.OnMouseDown(UiMouseButton.Left, 20, 20); // grab at (10,10) into the panel
+ root.OnMouseMove(120, 90); // drag
+ Assert.Equal(110f, panel.Left); // 120 - 10
+ Assert.Equal(80f, panel.Top); // 90 - 10
+
+ root.OnMouseUp(UiMouseButton.Left, 120, 90);
+ root.OnMouseMove(300, 300); // released — must not move
+ Assert.Equal(110f, panel.Left);
+ Assert.Equal(80f, panel.Top);
+ }
+
+ [Fact]
+ public void NonDraggablePanel_DoesNotMoveOnDrag()
+ {
+ var root = new UiRoot { Width = 800, Height = 600 };
+ var panel = new UiPanel { Left = 10, Top = 10, Width = 100, Height = 50 }; // Draggable defaults false
+ root.AddChild(panel);
+
+ root.OnMouseDown(UiMouseButton.Left, 20, 20);
+ root.OnMouseMove(120, 90);
+ Assert.Equal(10f, panel.Left);
+ Assert.Equal(10f, panel.Top);
+ }
+}