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:
Erik 2026-06-14 18:02:27 +02:00
parent 2f4520ee12
commit 4acecffcd6
5 changed files with 124 additions and 5 deletions

View file

@ -88,6 +88,11 @@ public abstract class UiElement
/// <summary>Painter's-algorithm z-order within siblings. Higher = on top.</summary>
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 ──────────────────────────────────────────────────
public UiElement? Parent { get; private set; }

View file

@ -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
}
/// <summary>

View file

@ -49,12 +49,25 @@ public sealed class UiRoot : UiElement
/// <summary>Widget with mouse capture (during click-drag).</summary>
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>
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;