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() {