diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs
index 2f28bb53..6fd3c60c 100644
--- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs
+++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs
@@ -77,6 +77,15 @@ public static class DatWidgetFactory
{
UiRadar.RetailClassId => new UiRadar(), // gmRadarUI (Register 0x004D8B80)
1 => BuildButton(info, resolve, elementFont, fontResolve, stringResolve), // UIElement_Button
+ 2 => new UiDatElement(info, resolve) // UIElement_Dragbar (Register @ 0x0046C840)
+ {
+ // The authored window-move handle: it must claim the pointer
+ // (UiDatElement defaults to ClickThrough decoration) so a press
+ // starts the window move and hover shows the move cursor
+ // (StartMouseMoving @ 0x0046C760 → UIElement::StartMovement).
+ WindowMoveHandle = true,
+ ClickThrough = false,
+ },
IndicatorBarController.BurdenClassId
or IndicatorBarController.EffectsClassId
or IndicatorBarController.LinkClassId
diff --git a/src/AcDream.App/UI/UiElement.cs b/src/AcDream.App/UI/UiElement.cs
index ea484b1e..f2fe46d1 100644
--- a/src/AcDream.App/UI/UiElement.cs
+++ b/src/AcDream.App/UI/UiElement.cs
@@ -195,6 +195,15 @@ public abstract class UiElement
/// whose Left/Top are screen coordinates (Root sits at the origin).
public bool Draggable { get; set; }
+ /// Authored window-move handle (retail UIElement_Dragbar, element
+ /// class 2, Register @ 0x0046C840): a left-press inside this element's subtree
+ /// moves its top-level window even when that window is not whole-surface
+ /// (retail StartMouseMoving @ 0x0046C760 calls
+ /// UIElement::StartMovement on the parent window). Set by the DAT widget
+ /// factory for Type-2 layout elements — e.g. the 600 x 5 strip along the top of the
+ /// combat/spell bar. Hovering it shows the window-move cursor.
+ public bool WindowMoveHandle { get; set; }
+
/// Clamp a dragged top-level window fully inside its parent. Retail
/// gmRadarUI::MoveTo enables this; most windows retain the toolkit default.
public bool ConstrainDragToParent { get; set; }
diff --git a/src/AcDream.App/UI/UiRoot.cs b/src/AcDream.App/UI/UiRoot.cs
index 24c88b65..c6eccb43 100644
--- a/src/AcDream.App/UI/UiRoot.cs
+++ b/src/AcDream.App/UI/UiRoot.cs
@@ -101,11 +101,17 @@ public sealed class UiRoot : UiElement
get
{
var target = Pick(MouseX, MouseY);
- var window = FindWindow(target);
- if (UiLocked || target is null || window is not { Draggable: true })
+ if (UiLocked || target is null)
return false;
if (HoverResizeEdges != ResizeEdges.None)
return false;
+ // An authored drag handle (retail UIElement_Dragbar, class 2) shows the
+ // move cursor even on a window that is not whole-surface Draggable.
+ if (FindDragHandleWindow(target) is not null)
+ return true;
+ var window = FindWindow(target);
+ if (window is not { Draggable: true })
+ return false;
return !target.IsDragSource
&& !target.CapturesPointerDrag
&& !target.HandlesClick;
@@ -444,22 +450,37 @@ public sealed class UiRoot : UiElement
// A left-drag starting near an edge resizes; interior drag repositions;
// otherwise it's a normal drag-drop candidate.
var window = FindWindow(target);
+ // Authored drag handle (retail UIElement_Dragbar, element class 2): a press
+ // inside the handle subtree moves its top-level window even when that window
+ // is not whole-surface Draggable — e.g. the combat/spell bar's authored top
+ // strip (StartMouseMoving @ 0x0046C760 → UIElement::StartMovement on parent).
+ var handleWindow = FindDragHandleWindow(target);
+ var raiseWindow = window ?? handleWindow;
// Retail-faithful: pressing on a window raises it above its peers.
- if (window is not null) BringToFront(window);
- if (btn == UiMouseButton.Left && window is not null && !UiLocked)
+ if (raiseWindow is not null) BringToFront(raiseWindow);
+ if (btn == UiMouseButton.Left && raiseWindow is not null && !UiLocked)
{
- var edges = window.Resizable ? HitEdges(window, x, y, ResizeGrip) : ResizeEdges.None;
+ var edges = window is { Resizable: true }
+ ? HitEdges(window, x, y, ResizeGrip)
+ : ResizeEdges.None;
if (edges != ResizeEdges.None)
{
// Edge resize still wins, even over a CapturesPointerDrag child:
// a resizable chat window can be resized from its frame.
_resizeTarget = window;
_resizeEdges = edges;
- _resizeStartX = window.Left; _resizeStartY = window.Top;
+ _resizeStartX = window!.Left; _resizeStartY = window.Top;
_resizeStartW = window.Width; _resizeStartH = window.Height;
_resizeMouseX = x; _resizeMouseY = y;
_dragCandidate = false;
}
+ else if (handleWindow is not null)
+ {
+ _windowDragTarget = handleWindow;
+ _windowDragOffX = x - (int)handleWindow.Left;
+ _windowDragOffY = y - (int)handleWindow.Top;
+ _dragCandidate = false;
+ }
else if (target.IsDragSource)
{
// A drag SOURCE (e.g. an occupied item cell) inside a Draggable window
@@ -480,7 +501,7 @@ public sealed class UiRoot : UiElement
// the Click over the same element. (A HandlesClick widget is not a drag candidate.)
_dragCandidate = false;
}
- else if (window.Draggable)
+ else if (window is { Draggable: true })
{
_windowDragTarget = window;
_windowDragOffX = x - (int)window.Left;
@@ -946,6 +967,23 @@ public sealed class UiRoot : UiElement
return null;
}
+ /// The top-level window moved by an authored drag handle at or above
+ /// , or null when the press is not inside a
+ /// subtree. Retail's UIElement_Dragbar
+ /// (element class 2) calls UIElement::StartMovement on its parent window
+ /// (StartMouseMoving @ 0x0046C760); in our mounted tree that parent's
+ /// analogue is the outer frame — the ancestor sitting directly under the root —
+ /// because wraps the imported layout root.
+ private UiElement? FindDragHandleWindow(UiElement? e)
+ {
+ while (e is not null && !ReferenceEquals(e, this) && !e.WindowMoveHandle)
+ e = e.Parent;
+ if (e is null || ReferenceEquals(e, this)) return null;
+ while (e.Parent is not null && !ReferenceEquals(e.Parent, this))
+ e = e.Parent;
+ return e;
+ }
+
/// Which edges of 's screen rect the point
/// (,) is within px of.
/// None if the point is outside the grip-expanded box entirely.
diff --git a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs
index f4c3b449..602352fd 100644
--- a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs
@@ -36,6 +36,20 @@ public class DatWidgetFactoryTests
Assert.IsType(e);
}
+ [Fact]
+ public void Type2_Dragbar_IsWindowMoveHandle_AndClaimsThePointer()
+ {
+ // Retail UIElement_Dragbar (element class 2, Register @ 0x0046C840). It must
+ // opt back out of UiDatElement's decoration ClickThrough so the press reaches
+ // it and starts the window move.
+ var e = DatWidgetFactory.Create(
+ new ElementInfo { Type = 2, Width = 600, Height = 5 }, NoTex, null);
+
+ Assert.IsType(e);
+ Assert.True(e.WindowMoveHandle);
+ Assert.False(e.ClickThrough);
+ }
+
// ── Test 3: Type 12 → UiText (behavioral text widget) ────────────────────
[Fact]
diff --git a/tests/AcDream.App.Tests/UI/UiRootInputTests.cs b/tests/AcDream.App.Tests/UI/UiRootInputTests.cs
index ceca3432..498790ad 100644
--- a/tests/AcDream.App.Tests/UI/UiRootInputTests.cs
+++ b/tests/AcDream.App.Tests/UI/UiRootInputTests.cs
@@ -235,6 +235,80 @@ public class UiRootInputTests
Assert.Equal(80f, panel.Top);
}
+ [Fact]
+ public void DragHandle_MovesNonDraggableWindow_AndStopsOnRelease()
+ {
+ // Retail UIElement_Dragbar (element class 2, Register @ 0x0046C840): a press on
+ // the authored handle moves a window that is NOT whole-surface Draggable
+ // (StartMouseMoving @ 0x0046C760 → UIElement::StartMovement on the parent).
+ // The combat/spell bar (LayoutDesc 0x21000073) authors a 600x5 strip at Y=0 and
+ // mounts Draggable=false; before the fix the strip was inert and the bars
+ // could not be moved at all.
+ var root = new UiRoot { Width = 800, Height = 600 };
+ var window = new UiPanel { Left = 100, Top = 500, Width = 610, Height = 90 };
+ var handle = new UiPanel
+ {
+ Left = 5, Top = 0, Width = 600, Height = 5,
+ WindowMoveHandle = true,
+ };
+ window.AddChild(handle);
+ root.AddChild(window);
+
+ root.OnMouseDown(UiMouseButton.Left, 110, 502); // press inside the top strip
+ Assert.True(root.IsWindowMoveActive);
+ root.OnMouseMove(160, 452);
+ Assert.Equal(150f, window.Left);
+ Assert.Equal(450f, window.Top);
+
+ root.OnMouseUp(UiMouseButton.Left, 160, 452);
+ root.OnMouseMove(300, 300); // released — must not move
+ Assert.Equal(150f, window.Left);
+ Assert.Equal(450f, window.Top);
+ }
+
+ [Fact]
+ public void DragHandle_Hover_ShowsMoveCursor_WindowBodyDoesNot()
+ {
+ var root = new UiRoot { Width = 800, Height = 600 };
+ var window = new UiPanel { Left = 100, Top = 500, Width = 610, Height = 90 };
+ var handle = new UiPanel
+ {
+ Left = 5, Top = 0, Width = 600, Height = 5,
+ WindowMoveHandle = true,
+ };
+ window.AddChild(handle);
+ root.AddChild(window);
+
+ root.OnMouseMove(110, 502); // over the authored strip
+ Assert.True(root.HoverWindowMove);
+
+ root.OnMouseMove(110, 550); // over the body of the non-Draggable window
+ Assert.False(root.HoverWindowMove);
+ }
+
+ [Fact]
+ public void DragHandle_UiLocked_NeitherMovesNorShowsCursor()
+ {
+ var root = new UiRoot { Width = 800, Height = 600 };
+ var window = new UiPanel { Left = 100, Top = 500, Width = 610, Height = 90 };
+ var handle = new UiPanel
+ {
+ Left = 5, Top = 0, Width = 600, Height = 5,
+ WindowMoveHandle = true,
+ };
+ window.AddChild(handle);
+ root.AddChild(window);
+ root.UiLocked = true;
+
+ root.OnMouseMove(110, 502);
+ Assert.False(root.HoverWindowMove);
+
+ root.OnMouseDown(UiMouseButton.Left, 110, 502);
+ root.OnMouseMove(160, 452);
+ Assert.Equal(100f, window.Left);
+ Assert.Equal(500f, window.Top);
+ }
+
[Fact]
public void WindowDrag_ConstrainedPanel_StaysFullyInsideParent()
{