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