From bfba0ecf7fc91a90146eab53b04a9feedbe673e7 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 29 Jul 2026 21:24:49 +0200 Subject: [PATCH] fix(ui): interactive window moves must survive the per-frame anchor layout; lock the dragbar cursor The dragbar port (e4c99f54) armed the press path but the combat/spell bar still would not move in the live client, and the move cursor kept showing with the UI locked. Two distinct causes, both reported from the user's connected session: 1. Snap-back: the combat/spell bar mounts ANCHORED (Left|Bottom), and ApplyAnchor runs every frame before drawing children, recomputing Left/Top from margins captured at mount. The drag wrote Left/Top and the very next layout pass wrote them back - the window never visibly moved. (The unit harness runs no per-frame layout, which is why the original tests passed; unanchored windows like inventory never hit this.) Interactive window moves AND resizes now re-baseline the anchor capture on every applied change, and RetailWindowManager.MoveTo/ResizeTo get the same rebase so programmatic moves of anchored windows cannot be silently undone either. ResetAnchorCapture is exactly the documented tool for this ("make the current geometry the new layout baseline after an intentional change"). 2. Locked cursor: the cursor the user saw was never the window-move feedback path (which is lock-gated) - it was the dragbar's own authored MD_Data_Cursor, revealed the moment the element began claiming the pointer. Authored cursor resolution now suppresses a WindowMoveHandle element's cursor while the UI is locked, matching the radar's existing locked behavior of hiding its authored drag affordance; movement itself was already gated. Two inversion-sensitive regression tests: an anchored window dragged by its handle must hold its position ACROSS an ApplyAnchor pass, and the authored handle cursor must disappear when UiLocked flips on. App Release suite 3,968 / 3 skips. Co-Authored-By: Claude Fable 5 --- .../UI/CursorFeedbackController.cs | 19 +++++++---- src/AcDream.App/UI/RetailWindowManager.cs | 6 ++++ src/AcDream.App/UI/UiRoot.cs | 9 +++++ .../UI/CursorFeedbackControllerTests.cs | 27 +++++++++++++++ .../AcDream.App.Tests/UI/UiRootInputTests.cs | 33 +++++++++++++++++++ 5 files changed, 87 insertions(+), 7 deletions(-) diff --git a/src/AcDream.App/UI/CursorFeedbackController.cs b/src/AcDream.App/UI/CursorFeedbackController.cs index babab5d6..46b04511 100644 --- a/src/AcDream.App/UI/CursorFeedbackController.cs +++ b/src/AcDream.App/UI/CursorFeedbackController.cs @@ -141,7 +141,7 @@ public sealed class CursorFeedbackController CombatMode: _combatModeProvider()); var kind = ResolveKind(snapshot); - UiCursorMedia authoredCursor = ResolveCursor(root.Captured, hover); + UiCursorMedia authoredCursor = ResolveCursor(root.Captured, hover, root.UiLocked); UiCursorMedia cursor = ResolveEffectiveCursor(kind, snapshot, authoredCursor); Current = new CursorFeedback(kind, cursor, ResolveGlobalKind(snapshot)); return Current; @@ -303,20 +303,25 @@ public sealed class CursorFeedbackController return CursorFeedbackKind.Default; } - private static UiCursorMedia ResolveCursor(UiElement? captured, UiElement? hover) + private static UiCursorMedia ResolveCursor(UiElement? captured, UiElement? hover, bool uiLocked) { // UIElementManager::CheckCursor @ 0x0045ABF0: captured element first, // then the last-entered element, then the ClientUISystem default. It // does not search ancestors or synthesize a different UI state. - var capturedCursor = captured?.ActiveCursor() ?? default; + // One lock-state exception: a window-move handle (retail UIElement_Dragbar) + // whose movement is disabled by the UI lock must not advertise its authored + // move cursor — the same suppression the radar applies by hiding its + // authored drag button while locked. + var capturedCursor = AuthoredCursor(captured, uiLocked); if (capturedCursor.IsValid) return capturedCursor; - var hoverCursor = hover?.ActiveCursor() ?? default; - if (hoverCursor.IsValid) - return hoverCursor; + return AuthoredCursor(hover, uiLocked); - return default; + static UiCursorMedia AuthoredCursor(UiElement? element, bool locked) + => element is null || (locked && element.WindowMoveHandle) + ? default + : element.ActiveCursor(); } private static UiCursorMedia ResolveEffectiveCursor( diff --git a/src/AcDream.App/UI/RetailWindowManager.cs b/src/AcDream.App/UI/RetailWindowManager.cs index 8dd9d228..0b2d32ce 100644 --- a/src/AcDream.App/UI/RetailWindowManager.cs +++ b/src/AcDream.App/UI/RetailWindowManager.cs @@ -149,6 +149,9 @@ public sealed class RetailWindowManager : IDisposable if (frame.Left == left && frame.Top == top) return true; frame.Left = left; frame.Top = top; + // Anchored windows re-derive Left/Top from captured margins every frame + // (ApplyAnchor); rebase so the intentional move survives the next layout. + frame.ResetAnchorCapture(); _root.NotifyWindowMoved(frame); return true; } @@ -171,6 +174,9 @@ public sealed class RetailWindowManager : IDisposable if (frame.Width == width && frame.Height == height) return true; frame.Width = width; frame.Height = height; + // Same rebase as MoveTo: keep the intentional resize from being undone + // by the per-frame anchor layout. + frame.ResetAnchorCapture(); _root.NotifyWindowResized(frame); return true; } diff --git a/src/AcDream.App/UI/UiRoot.cs b/src/AcDream.App/UI/UiRoot.cs index c6eccb43..79954ada 100644 --- a/src/AcDream.App/UI/UiRoot.cs +++ b/src/AcDream.App/UI/UiRoot.cs @@ -374,6 +374,11 @@ public sealed class UiRoot : UiElement MathF.Max(_resizeTarget.MinHeight, maxHeight)); _resizeTarget.Left = nx; _resizeTarget.Top = ny; _resizeTarget.Width = nw; _resizeTarget.Height = nh; + // Re-baseline the anchor layout: ApplyAnchor runs every frame before + // drawing children and would otherwise snap the window back to its + // captured margins, silently undoing the interactive resize on any + // anchored window. + _resizeTarget.ResetAnchorCapture(); return; } @@ -390,6 +395,10 @@ public sealed class UiRoot : UiElement } _windowDragTarget.Left = left; _windowDragTarget.Top = top; + // Same re-baseline as the resize path: without it an anchored window + // (e.g. the combat/spell bar, mounted Left|Bottom) never visibly moves — + // the next frame's ApplyAnchor restores the captured margins. + _windowDragTarget.ResetAnchorCapture(); return; } diff --git a/tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs b/tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs index 6aee28dc..6540bf4d 100644 --- a/tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs @@ -126,6 +126,33 @@ public sealed class CursorFeedbackControllerTests Assert.Equal(new UiCursorMedia(0x06006119u, 16, 16), controller.Update(root).Cursor); } + [Fact] + public void UiLock_suppressesAuthoredMoveHandleCursor() + { + // The dragbar (retail UIElement_Dragbar) carries an authored MD_Data_Cursor + // — the four-arrow move cursor. With the UI locked the handle cannot move + // its window, so it must not advertise the move affordance either (the + // radar's authored drag button hides the same way when locked). + 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, + }; + var authored = new UiCursorMedia(0x06007777u, 2, 2); + handle.SetStateCursors(new Dictionary { [""] = authored }); + window.AddChild(handle); + root.AddChild(window); + var controller = new CursorFeedbackController(); + + root.OnMouseMove(110, 502); + Assert.Equal(authored, controller.Update(root).Cursor); + + root.UiLocked = true; + Assert.NotEqual(authored, controller.Update(root).Cursor); + } + [Fact] public void UiLock_suppressesSyntheticWindowControlCursors() { diff --git a/tests/AcDream.App.Tests/UI/UiRootInputTests.cs b/tests/AcDream.App.Tests/UI/UiRootInputTests.cs index 498790ad..96450179 100644 --- a/tests/AcDream.App.Tests/UI/UiRootInputTests.cs +++ b/tests/AcDream.App.Tests/UI/UiRootInputTests.cs @@ -266,6 +266,39 @@ public class UiRootInputTests Assert.Equal(450f, window.Top); } + [Fact] + public void DragHandle_MovedAnchoredWindow_SurvivesTheNextLayoutPass() + { + // The live-client regression the first dragbar port missed: the combat/spell + // bar mounts ANCHORED (Left|Bottom), and ApplyAnchor runs every frame before + // drawing children — recomputing Left/Top from the margins captured at mount. + // A drag that only writes Left/Top is snapped back by the very next layout + // pass, so the window never visibly moves (the unit harness runs no layout, + // which is why the original tests passed). The drag must re-baseline the + // anchor capture on every move. + var root = new UiRoot { Width = 800, Height = 600 }; + var window = new UiPanel + { + Left = 0, Top = 510, Width = 610, Height = 90, + Anchors = AnchorEdges.Left | AnchorEdges.Bottom, + }; + var handle = new UiPanel + { + Left = 5, Top = 0, Width = 600, Height = 5, + WindowMoveHandle = true, + }; + window.AddChild(handle); + root.AddChild(window); + window.ApplyAnchor(root.Width, root.Height); // mount-time layout captures margins + + root.OnMouseDown(UiMouseButton.Left, 10, 512); // press inside the top strip + root.OnMouseMove(210, 312); + window.ApplyAnchor(root.Width, root.Height); // the next frame's layout pass + + Assert.Equal(200f, window.Left); + Assert.Equal(310f, window.Top); + } + [Fact] public void DragHandle_Hover_ShowsMoveCursor_WindowBodyDoesNot() {