feat(D.2b): wire UiHost input + moveable windows (UiRoot window-drag + WantCapture gate)
- UiElement: add Draggable flag; left-drag on a draggable element repositions it as a floating window instead of starting a drag-drop sequence. - UiRoot: add WantsMouse/WantsKeyboard properties (mirrors ImGui's WantCaptureMouse pattern); add FindDraggable helper; inject _windowDragTarget state machine into OnMouseDown/OnMouseMove/OnMouseUp so draggable windows track the pointer offset. - UiNineSlicePanel: set Draggable=true so retail window frames are movable by default. - GameWindow: OR _uiHost?.Root.WantsMouse|WantsKeyboard into the SilkMouseSource wantCaptureMouse/wantCaptureKeyboard delegates and the direct MouseMove gate so game actions (movement, world-pick) are suppressed while the pointer is over a retail window — no double-handling with the InputDispatcher. - GameWindow: wire all Silk Mice/Keyboards to UiHost after construction so the UiRoot tree receives live input. - Tests: 3 new UiRootInputTests covering WantsMouse hit-test, window-drag reposition, and non-draggable panel immobility. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2f4520ee12
commit
4acecffcd6
5 changed files with 124 additions and 5 deletions
|
|
@ -978,8 +978,10 @@ public sealed class GameWindow : IDisposable
|
||||||
_kbSource = new AcDream.App.Input.SilkKeyboardSource(firstKb);
|
_kbSource = new AcDream.App.Input.SilkKeyboardSource(firstKb);
|
||||||
_mouseSource = new AcDream.App.Input.SilkMouseSource(
|
_mouseSource = new AcDream.App.Input.SilkMouseSource(
|
||||||
firstMouse,
|
firstMouse,
|
||||||
wantCaptureMouse: () => DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse,
|
wantCaptureMouse: () => (DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse)
|
||||||
wantCaptureKeyboard: () => DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureKeyboard);
|
|| (_uiHost?.Root.WantsMouse ?? false),
|
||||||
|
wantCaptureKeyboard: () => (DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureKeyboard)
|
||||||
|
|| (_uiHost?.Root.WantsKeyboard ?? false));
|
||||||
_mouseSource.ModifierProbe = () => _kbSource.CurrentModifiers;
|
_mouseSource.ModifierProbe = () => _kbSource.CurrentModifiers;
|
||||||
_inputDispatcher = new AcDream.UI.Abstractions.Input.InputDispatcher(
|
_inputDispatcher = new AcDream.UI.Abstractions.Input.InputDispatcher(
|
||||||
_kbSource, _mouseSource, _keyBindings);
|
_kbSource, _mouseSource, _keyBindings);
|
||||||
|
|
@ -1045,7 +1047,8 @@ public sealed class GameWindow : IDisposable
|
||||||
// K.1b §E: explicit WantCaptureMouse defense-in-depth on the
|
// K.1b §E: explicit WantCaptureMouse defense-in-depth on the
|
||||||
// surviving direct-mouse handler. Suppresses RMB orbit /
|
// surviving direct-mouse handler. Suppresses RMB orbit /
|
||||||
// FlyCamera look while ImGui has the mouse focus.
|
// 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;
|
_lastMouseX = pos.X;
|
||||||
_lastMouseY = pos.Y;
|
_lastMouseY = pos.Y;
|
||||||
|
|
@ -1745,6 +1748,13 @@ public sealed class GameWindow : IDisposable
|
||||||
_vitalsVm ??= new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer);
|
_vitalsVm ??= new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer);
|
||||||
_uiHost = new AcDream.App.UI.UiHost(_gl, shadersDir, _debugFont);
|
_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!;
|
var cache = _textureCache!;
|
||||||
(uint, int, int) ResolveChrome(uint id)
|
(uint, int, int) ResolveChrome(uint id)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,11 @@ public abstract class UiElement
|
||||||
/// <summary>Painter's-algorithm z-order within siblings. Higher = on top.</summary>
|
/// <summary>Painter's-algorithm z-order within siblings. Higher = on top.</summary>
|
||||||
public int ZOrder { get; set; }
|
public int ZOrder { get; set; }
|
||||||
|
|
||||||
|
/// <summary>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).</summary>
|
||||||
|
public bool Draggable { get; set; }
|
||||||
|
|
||||||
// ── Tree structure ──────────────────────────────────────────────────
|
// ── Tree structure ──────────────────────────────────────────────────
|
||||||
public UiElement? Parent { get; private set; }
|
public UiElement? Parent { get; private set; }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ public sealed class UiNineSlicePanel : UiPanel
|
||||||
_resolve = resolve;
|
_resolve = resolve;
|
||||||
BackgroundColor = Vector4.Zero; // suppress the base flat-rect fill
|
BackgroundColor = Vector4.Zero; // suppress the base flat-rect fill
|
||||||
BorderColor = Vector4.Zero;
|
BorderColor = Vector4.Zero;
|
||||||
|
Draggable = true; // retail windows are movable
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -49,12 +49,25 @@ public sealed class UiRoot : UiElement
|
||||||
/// <summary>Widget with mouse capture (during click-drag).</summary>
|
/// <summary>Widget with mouse capture (during click-drag).</summary>
|
||||||
public UiElement? Captured { get; private set; }
|
public UiElement? Captured { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public bool WantsMouse => Captured is not null || HitTestTopDown(MouseX, MouseY).element is not null;
|
||||||
|
|
||||||
|
/// <summary>True when a widget holds keyboard focus (e.g. a focused chat input).</summary>
|
||||||
|
public bool WantsKeyboard => KeyboardFocus is not null;
|
||||||
|
|
||||||
/// <summary>Current drag source (set between drag-begin and drop/cancel).</summary>
|
/// <summary>Current drag source (set between drag-begin and drop/cancel).</summary>
|
||||||
public UiElement? DragSource { get; private set; }
|
public UiElement? DragSource { get; private set; }
|
||||||
public object? DragPayload { get; private set; }
|
public object? DragPayload { get; private set; }
|
||||||
private UiElement? _lastDragHoverTarget;
|
private UiElement? _lastDragHoverTarget;
|
||||||
private int _pressX, _pressY;
|
private int _pressX, _pressY;
|
||||||
private bool _dragCandidate;
|
private bool _dragCandidate;
|
||||||
|
private UiElement? _windowDragTarget;
|
||||||
|
private int _windowDragOffX, _windowDragOffY;
|
||||||
private const int DragDistanceThreshold = 3; // pixels, retail-observed
|
private const int DragDistanceThreshold = 3; // pixels, retail-observed
|
||||||
|
|
||||||
// Hover / tooltip tracking.
|
// Hover / tooltip tracking.
|
||||||
|
|
@ -120,6 +133,14 @@ public sealed class UiRoot : UiElement
|
||||||
MouseX = x;
|
MouseX = x;
|
||||||
MouseY = y;
|
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
|
// If we have capture, deliver MouseMove to the captured widget
|
||||||
// AND drive drag state machine; do NOT fall through.
|
// AND drive drag state machine; do NOT fall through.
|
||||||
if (Captured is not null)
|
if (Captured is not null)
|
||||||
|
|
@ -165,9 +186,22 @@ public sealed class UiRoot : UiElement
|
||||||
// Set keyboard focus if target accepts it.
|
// Set keyboard focus if target accepts it.
|
||||||
if (target.AcceptsFocus) SetKeyboardFocus(target);
|
if (target.AcceptsFocus) SetKeyboardFocus(target);
|
||||||
|
|
||||||
// Capture + arm drag candidate (drag promotes on subsequent MouseMove > threshold).
|
|
||||||
SetCapture(target);
|
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).
|
// Dispatch raw MouseDown event (retail uses WM_LBUTTONDOWN = 0x201).
|
||||||
int rawType = btn switch
|
int rawType = btn switch
|
||||||
|
|
@ -187,6 +221,13 @@ public sealed class UiRoot : UiElement
|
||||||
MouseX = x; MouseY = y;
|
MouseX = x; MouseY = y;
|
||||||
UpdateButtonFlag(btn, down: false);
|
UpdateButtonFlag(btn, down: false);
|
||||||
|
|
||||||
|
if (_windowDragTarget is not null)
|
||||||
|
{
|
||||||
|
_windowDragTarget = null;
|
||||||
|
ReleaseCapture();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (DragSource is not null)
|
if (DragSource is not null)
|
||||||
{
|
{
|
||||||
FinishDrag(x, y);
|
FinishDrag(x, y);
|
||||||
|
|
@ -436,6 +477,16 @@ public sealed class UiRoot : UiElement
|
||||||
return (null, 0, 0);
|
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)
|
private static bool ContainsAbsolute(UiElement e, int x, int y)
|
||||||
{
|
{
|
||||||
var sp = e.ScreenPosition;
|
var sp = e.ScreenPosition;
|
||||||
|
|
|
||||||
52
tests/AcDream.App.Tests/UI/UiRootInputTests.cs
Normal file
52
tests/AcDream.App.Tests/UI/UiRootInputTests.cs
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue