From e4c99f54c07f0d6972de68b14360be2cbe7ae308 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 29 Jul 2026 21:04:33 +0200 Subject: [PATCH] feat(ui): port retail UIElement_Dragbar so authored drag strips move their windows The combat bar and spell bar could not be moved at all: their window mounts Draggable=false (correct - retail never whole-surface-drags them) and the authored move mechanism was missing. Retail registers element class 2 as UIElement_Dragbar (Register @ 0x0046C840); a press inside it calls UIElement::StartMovement on its parent window (StartMouseMoving @ 0x0046C760) and release calls StopMovement (@ 0x0046C7C0). The combat/spell bar layout (LayoutDesc 0x21000073) authors exactly one such element - a 600 x 5 strip along the top edge, which is where the user expects the move cursor. The powerbar, vitals, indicators, radar, and examination layouts author dragbars too, so they all gain their retail handles from this one port. Our importer knew Type 2 by name but built it as a generic UiDatElement - ClickThrough decoration, so the strip never even claimed the pointer. Now: - UiElement.WindowMoveHandle marks an authored handle; the DAT factory sets it for Type-2 elements and opts them out of ClickThrough. - A left-press inside a handle subtree moves the handle's top-level window (the outer frame directly under the root - the mounted analogue of retail's dragbar parent) even when that window is not whole-surface Draggable. Edge-resize still wins; UiLocked still gates, matching the retail locked/fixed parent-flag check. - HoverWindowMove reports the handle so the window-move cursor shows over the strip - and only there - on non-Draggable windows. Four new tests: handle press moves a non-Draggable window and stops on release, hover shows the move cursor over the strip but not the body, UiLocked suppresses both, and the factory builds Type 2 as a pointer-claiming move handle. App Release suite 3,966 / 3 skips. Co-Authored-By: Claude Fable 5 --- src/AcDream.App/UI/Layout/DatWidgetFactory.cs | 9 +++ src/AcDream.App/UI/UiElement.cs | 9 +++ src/AcDream.App/UI/UiRoot.cs | 52 +++++++++++-- .../UI/Layout/DatWidgetFactoryTests.cs | 14 ++++ .../AcDream.App.Tests/UI/UiRootInputTests.cs | 74 +++++++++++++++++++ 5 files changed, 151 insertions(+), 7 deletions(-) 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() {