fix(ui): interactive window moves must survive the per-frame anchor layout; lock the dragbar cursor
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
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 <noreply@anthropic.com>
This commit is contained in:
parent
e4c99f54c0
commit
bfba0ecf7f
5 changed files with 87 additions and 7 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, UiCursorMedia> { [""] = 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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue