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