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>
324 lines
11 KiB
C#
324 lines
11 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
|
|
namespace AcDream.App.UI;
|
|
|
|
/// <summary>
|
|
/// Owns the named retained-window registry, raise policy, lifecycle delivery,
|
|
/// and controller teardown. <see cref="UiRoot"/> remains responsible for raw
|
|
/// input ownership and reports completed interactions to this manager.
|
|
/// </summary>
|
|
public sealed class RetailWindowManager : IDisposable
|
|
{
|
|
private readonly UiRoot _root;
|
|
private readonly Dictionary<string, RetailWindowHandle> _byName =
|
|
new(StringComparer.Ordinal);
|
|
private readonly Dictionary<UiElement, RetailWindowHandle> _byFrame = new();
|
|
private readonly Dictionary<RetailWindowHandle, UiElement> _defaultInputs = new();
|
|
private bool _disposed;
|
|
|
|
internal RetailWindowManager(UiRoot root)
|
|
{
|
|
_root = root;
|
|
_root.ElementVisibilityChanged += OnElementVisibilityChanged;
|
|
_root.KeyboardFocusChanged += OnKeyboardFocusChanged;
|
|
_root.PointerCaptureChanged += OnPointerCaptureChanged;
|
|
_root.WindowMoved += OnWindowMoved;
|
|
_root.WindowResized += OnWindowResized;
|
|
_root.UiLockChanged += OnUiLockChanged;
|
|
}
|
|
|
|
public bool IsLocked => _root.UiLocked;
|
|
public IReadOnlyCollection<RetailWindowHandle> Windows => _byName.Values;
|
|
public event Action<string, bool>? WindowVisibilityChanged;
|
|
|
|
public RetailWindowHandle Register(
|
|
string name,
|
|
UiElement outerFrame,
|
|
UiElement? contentRoot = null,
|
|
IRetainedPanelController? controller = null,
|
|
IRetainedWindowStateController? stateController = null,
|
|
int authoredGeometryRevision = 0)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
|
ArgumentNullException.ThrowIfNull(outerFrame);
|
|
authoredGeometryRevision = Math.Max(0, authoredGeometryRevision);
|
|
|
|
if (!ReferenceEquals(outerFrame.Parent, _root))
|
|
throw new InvalidOperationException(
|
|
$"Retained window '{name}' must be mounted as a direct UiRoot child before registration.");
|
|
|
|
IRetainedWindowStateController? resolvedStateController =
|
|
stateController ?? outerFrame as IRetainedWindowStateController;
|
|
|
|
if (_byName.TryGetValue(name, out var sameName))
|
|
{
|
|
if (ReferenceEquals(sameName.OuterFrame, outerFrame)
|
|
&& ReferenceEquals(sameName.ContentRoot, contentRoot ?? outerFrame)
|
|
&& ReferenceEquals(sameName.Controller, controller)
|
|
&& ReferenceEquals(sameName.StateController, resolvedStateController)
|
|
&& sameName.AuthoredGeometryRevision == authoredGeometryRevision)
|
|
return sameName;
|
|
|
|
Unregister(name);
|
|
}
|
|
|
|
if (_byFrame.TryGetValue(outerFrame, out var sameFrame))
|
|
Unregister(sameFrame.Name);
|
|
|
|
var handle = new RetailWindowHandle(
|
|
this,
|
|
name,
|
|
outerFrame,
|
|
contentRoot ?? outerFrame,
|
|
controller,
|
|
resolvedStateController,
|
|
authoredGeometryRevision);
|
|
_byName.Add(name, handle);
|
|
_byFrame.Add(outerFrame, handle);
|
|
handle.NotifyInitialState();
|
|
return handle;
|
|
}
|
|
|
|
public bool TryGet(string name, out RetailWindowHandle handle)
|
|
=> _byName.TryGetValue(name, out handle!);
|
|
|
|
public void AttachController(string name, IRetainedPanelController controller)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
|
ArgumentNullException.ThrowIfNull(controller);
|
|
if (!_byName.TryGetValue(name, out var handle))
|
|
throw new KeyNotFoundException($"No retained window named '{name}' is registered.");
|
|
handle.AttachController(controller);
|
|
}
|
|
|
|
internal bool TryGet(UiElement outerFrame, out RetailWindowHandle handle)
|
|
=> _byFrame.TryGetValue(outerFrame, out handle!);
|
|
|
|
public bool Show(string name)
|
|
{
|
|
if (!_byName.TryGetValue(name, out var handle)) return false;
|
|
handle.OuterFrame.Visible = true;
|
|
BringToFront(handle.OuterFrame);
|
|
return true;
|
|
}
|
|
|
|
public bool Hide(string name)
|
|
{
|
|
if (!_byName.TryGetValue(name, out var handle)) return false;
|
|
handle.OuterFrame.Visible = false;
|
|
return true;
|
|
}
|
|
|
|
public bool Toggle(string name)
|
|
{
|
|
if (!_byName.TryGetValue(name, out var handle)) return false;
|
|
if (handle.OuterFrame.Visible)
|
|
{
|
|
Hide(name);
|
|
return false;
|
|
}
|
|
|
|
Show(name);
|
|
return true;
|
|
}
|
|
|
|
public bool Close(string name)
|
|
{
|
|
if (!_byName.TryGetValue(name, out var handle)) return false;
|
|
Hide(name);
|
|
handle.NotifyClosed();
|
|
return true;
|
|
}
|
|
|
|
public bool IsVisible(string name)
|
|
=> _byName.TryGetValue(name, out var handle) && handle.OuterFrame.Visible;
|
|
|
|
public bool MoveTo(string name, float left, float top)
|
|
{
|
|
if (!_byName.TryGetValue(name, out var handle)) return false;
|
|
var frame = handle.OuterFrame;
|
|
if (frame.ConstrainDragToParent && frame.Parent is { } parent)
|
|
{
|
|
left = Math.Clamp(left, 0f, Math.Max(0f, parent.Width - frame.Width));
|
|
top = Math.Clamp(top, 0f, Math.Max(0f, parent.Height - frame.Height));
|
|
}
|
|
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;
|
|
}
|
|
|
|
public bool ResizeTo(string name, float width, float height)
|
|
{
|
|
if (!_byName.TryGetValue(name, out var handle)) return false;
|
|
var frame = handle.OuterFrame;
|
|
if (!frame.ResizeX) width = frame.Width;
|
|
if (!frame.ResizeY) height = frame.Height;
|
|
float maxWidth = frame.MaxWidth;
|
|
float maxHeight = frame.MaxHeight;
|
|
if (frame.ConstrainResizeToParent && frame.Parent is { } parent)
|
|
{
|
|
maxWidth = MathF.Min(maxWidth, parent.Width - frame.Left);
|
|
maxHeight = MathF.Min(maxHeight, parent.Height - frame.Top);
|
|
}
|
|
width = Math.Clamp(width, frame.MinWidth, MathF.Max(frame.MinWidth, maxWidth));
|
|
height = Math.Clamp(height, frame.MinHeight, MathF.Max(frame.MinHeight, maxHeight));
|
|
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;
|
|
}
|
|
|
|
public bool SetOpacity(string name, float opacity)
|
|
{
|
|
if (!_byName.TryGetValue(name, out var handle)) return false;
|
|
handle.OuterFrame.Opacity = Math.Clamp(opacity, 0f, 1f);
|
|
return true;
|
|
}
|
|
|
|
public void SetLocked(bool locked) => _root.UiLocked = locked;
|
|
|
|
public bool Unregister(string name)
|
|
{
|
|
if (!_byName.TryGetValue(name, out var handle)) return false;
|
|
|
|
if (handle.OuterFrame.Visible)
|
|
handle.OuterFrame.Visible = false;
|
|
else
|
|
_root.ClearSubtreeOwnership(handle.OuterFrame);
|
|
|
|
_byName.Remove(name);
|
|
_byFrame.Remove(handle.OuterFrame);
|
|
_defaultInputs.Remove(handle);
|
|
handle.NotifyClosed();
|
|
handle.DisposeController();
|
|
return true;
|
|
}
|
|
|
|
public void BringToFront(UiElement window)
|
|
{
|
|
int top = window.ZOrder;
|
|
foreach (var child in _root.Children)
|
|
if (!ReferenceEquals(child, window))
|
|
top = Math.Max(top, child.ZOrder + 1);
|
|
window.ZOrder = top;
|
|
}
|
|
|
|
internal void PrepareToHide(UiElement subtree)
|
|
{
|
|
if (!_byFrame.TryGetValue(subtree, out var handle)) return;
|
|
if (_root.DefaultTextInput is { } input && IsWithin(input, subtree))
|
|
_defaultInputs[handle] = input;
|
|
}
|
|
|
|
internal void OnSubtreeRemoving(UiElement subtree)
|
|
{
|
|
foreach (var handle in _byName.Values
|
|
.Where(handle => IsWithin(handle.OuterFrame, subtree))
|
|
.ToArray())
|
|
{
|
|
handle.NotifyVisibility(false);
|
|
_byName.Remove(handle.Name);
|
|
_byFrame.Remove(handle.OuterFrame);
|
|
_defaultInputs.Remove(handle);
|
|
handle.NotifyClosed();
|
|
handle.DisposeController();
|
|
}
|
|
}
|
|
|
|
private void OnElementVisibilityChanged(UiElement element, bool visible)
|
|
{
|
|
if (!_byFrame.TryGetValue(element, out var handle)) return;
|
|
|
|
if (visible
|
|
&& _root.DefaultTextInput is null
|
|
&& _defaultInputs.TryGetValue(handle, out var input)
|
|
&& IsWithin(input, handle.OuterFrame))
|
|
{
|
|
_root.DefaultTextInput = input;
|
|
}
|
|
|
|
handle.NotifyVisibility(visible);
|
|
WindowVisibilityChanged?.Invoke(handle.Name, visible);
|
|
}
|
|
|
|
private void OnKeyboardFocusChanged(UiElement? oldFocus, UiElement? newFocus)
|
|
{
|
|
foreach (var handle in _byName.Values.ToArray())
|
|
{
|
|
bool hadFocus = IsWithin(oldFocus, handle.OuterFrame);
|
|
bool hasFocus = IsWithin(newFocus, handle.OuterFrame);
|
|
if (hadFocus || hasFocus)
|
|
handle.NotifyDescendantFocusChanged(hasFocus ? newFocus : null);
|
|
}
|
|
}
|
|
|
|
private void OnPointerCaptureChanged(UiElement? oldCapture, UiElement? newCapture)
|
|
{
|
|
foreach (var handle in _byName.Values.ToArray())
|
|
{
|
|
bool hadCapture = IsWithin(oldCapture, handle.OuterFrame);
|
|
bool hasCapture = IsWithin(newCapture, handle.OuterFrame);
|
|
if (hadCapture || hasCapture)
|
|
handle.NotifyDescendantCaptureChanged(hasCapture ? newCapture : null);
|
|
}
|
|
}
|
|
|
|
private void OnWindowMoved(string name, UiElement window)
|
|
{
|
|
if (_byName.TryGetValue(name, out var handle)
|
|
&& ReferenceEquals(handle.OuterFrame, window))
|
|
handle.NotifyMoved();
|
|
}
|
|
|
|
private void OnWindowResized(string name, UiElement window)
|
|
{
|
|
if (_byName.TryGetValue(name, out var handle)
|
|
&& ReferenceEquals(handle.OuterFrame, window))
|
|
handle.NotifyResized();
|
|
}
|
|
|
|
private void OnUiLockChanged(bool locked)
|
|
{
|
|
foreach (var handle in _byName.Values.ToArray())
|
|
handle.NotifyLockChanged(locked);
|
|
}
|
|
|
|
private static bool IsWithin(UiElement? element, UiElement subtree)
|
|
{
|
|
while (element is not null)
|
|
{
|
|
if (ReferenceEquals(element, subtree)) return true;
|
|
element = element.Parent;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed) return;
|
|
_disposed = true;
|
|
|
|
foreach (string name in _byName.Keys.ToArray())
|
|
Unregister(name);
|
|
|
|
_root.ElementVisibilityChanged -= OnElementVisibilityChanged;
|
|
_root.KeyboardFocusChanged -= OnKeyboardFocusChanged;
|
|
_root.PointerCaptureChanged -= OnPointerCaptureChanged;
|
|
_root.WindowMoved -= OnWindowMoved;
|
|
_root.WindowResized -= OnWindowResized;
|
|
_root.UiLockChanged -= OnUiLockChanged;
|
|
}
|
|
}
|