feat(ui): centralize retained window lifecycle

This commit is contained in:
Erik 2026-07-10 21:31:18 +02:00
parent 4bb37e302e
commit 6e9e10367f
12 changed files with 778 additions and 57 deletions

View file

@ -1,5 +1,4 @@
using System;
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.App.UI;
@ -25,6 +24,14 @@ public enum ResizeEdges { None = 0, Left = 1, Right = 2, Top = 4, Bottom = 8 }
/// </summary>
public sealed class UiRoot : UiElement
{
public UiRoot()
{
WindowManager = new RetailWindowManager(this);
}
/// <summary>Single owner for named retained-window lifecycle and raise policy.</summary>
public RetailWindowManager WindowManager { get; }
// ── Device-level state ───────────────────────────────────────────────
public int MouseX { get; private set; }
public int MouseY { get; private set; }
@ -61,7 +68,17 @@ public sealed class UiRoot : UiElement
/// <summary>Retail PlayerModule::LockUI gate. Blocks all retained-window
/// move/resize interactions without disabling their buttons or content.</summary>
public bool UiLocked { get; set; }
private bool _uiLocked;
public bool UiLocked
{
get => _uiLocked;
set
{
if (_uiLocked == value) return;
_uiLocked = value;
UiLockChanged?.Invoke(value);
}
}
/// <summary>Current drag source (set between drag-begin and drop/cancel).</summary>
public UiElement? DragSource { get; private set; }
@ -141,6 +158,21 @@ public sealed class UiRoot : UiElement
/// <summary>Raised after a registered top-level window finishes moving.</summary>
public event Action<string, UiElement>? WindowMoved;
/// <summary>Raised after a registered top-level window finishes resizing.</summary>
public event Action<string, UiElement>? WindowResized;
/// <summary>Raised after any attached element changes visibility.</summary>
public event Action<UiElement, bool>? ElementVisibilityChanged;
/// <summary>Raised after keyboard focus changes; arguments are old/new.</summary>
public event Action<UiElement?, UiElement?>? KeyboardFocusChanged;
/// <summary>Raised after pointer capture changes; arguments are old/new.</summary>
public event Action<UiElement?, UiElement?>? PointerCaptureChanged;
/// <summary>Raised after the global retained-UI lock changes.</summary>
public event Action<bool>? UiLockChanged;
private uint _nextEventId = 0x10000001u;
public override void AddChild(UiElement child)
@ -171,6 +203,22 @@ public sealed class UiRoot : UiElement
}
internal void OnSubtreeRemoving(UiElement subtree)
{
ClearSubtreeOwnership(subtree);
WindowManager.OnSubtreeRemoving(subtree);
}
internal void OnElementVisibilityChanging(UiElement element, bool visible)
{
if (visible) return;
WindowManager.PrepareToHide(element);
ClearSubtreeOwnership(element);
}
internal void OnElementVisibilityChanged(UiElement element, bool visible)
=> ElementVisibilityChanged?.Invoke(element, visible);
internal void ClearSubtreeOwnership(UiElement subtree)
{
if (IsWithinSubtree(KeyboardFocus, subtree))
SetKeyboardFocus(null);
@ -211,14 +259,6 @@ public sealed class UiRoot : UiElement
_resizeTarget = null;
_dragCandidate = false;
}
foreach (string name in _windows
.Where(pair => IsWithinSubtree(pair.Value, subtree))
.Select(pair => pair.Key)
.ToArray())
{
_windows.Remove(name);
}
}
private static bool IsWithinSubtree(UiElement? element, UiElement subtree)
@ -463,8 +503,10 @@ public sealed class UiRoot : UiElement
if (_resizeTarget is not null)
{
var resizedWindow = _resizeTarget;
_resizeTarget = null;
ReleaseCapture();
NotifyWindowResized(resizedWindow);
return;
}
@ -473,12 +515,7 @@ public sealed class UiRoot : UiElement
var movedWindow = _windowDragTarget;
_windowDragTarget = null;
ReleaseCapture();
foreach (var pair in _windows)
if (ReferenceEquals(pair.Value, movedWindow))
{
WindowMoved?.Invoke(pair.Key, movedWindow);
break;
}
NotifyWindowMoved(movedWindow);
return;
}
@ -621,10 +658,11 @@ public sealed class UiRoot : UiElement
public void SetKeyboardFocus(UiElement? e)
{
if (KeyboardFocus == e) return;
if (KeyboardFocus is not null)
UiElement? previous = KeyboardFocus;
if (previous is not null)
{
var lost = new UiEvent(KeyboardFocus.EventId, KeyboardFocus, UiEventType.FocusLost);
KeyboardFocus.OnEvent(in lost);
var lost = new UiEvent(previous.EventId, previous, UiEventType.FocusLost);
previous.OnEvent(in lost);
}
KeyboardFocus = e;
if (e is not null)
@ -632,68 +670,80 @@ public sealed class UiRoot : UiElement
var gained = new UiEvent(e.EventId, e, UiEventType.FocusGained);
e.OnEvent(in gained);
}
KeyboardFocusChanged?.Invoke(previous, e);
}
public void SetCapture(UiElement e)
{
if (ReferenceEquals(Captured, e)) return;
UiElement? previous = Captured;
Captured = e;
PointerCaptureChanged?.Invoke(previous, e);
}
public void SetCapture(UiElement e) => Captured = e;
public void ReleaseCapture()
{
UiElement? previous = Captured;
Captured = null;
// Retail restarts the tooltip idle deadline when capture is released.
_hoverStartedMs = _nowMs;
_tooltipFired = false;
if (previous is not null)
PointerCaptureChanged?.Invoke(previous, null);
}
// ── Window manager (named top-level windows: Show / Hide / Toggle) ───
private readonly Dictionary<string, UiElement> _windows = new();
// Registry state lives in RetailWindowManager; methods below are compatibility forwarders.
/// <summary>Register a top-level window under a name for Show/Hide/Toggle.
/// Does NOT add it to the tree — the caller mounts via AddChild and controls
/// initial Visible. Idempotent (re-register replaces). The dict is the source
/// of truth — independent of UiElement.Name (init-only, not set here).</summary>
public void RegisterWindow(string name, UiElement window) => _windows[name] = window;
/// initial Visible. Idempotent registration returns the existing typed handle;
/// replacement performs full lifecycle teardown of the prior registration.</summary>
public RetailWindowHandle RegisterWindow(
string name,
UiElement window,
UiElement? contentRoot = null,
IRetainedPanelController? controller = null)
=> WindowManager.Register(name, window, contentRoot, controller);
public bool UnregisterWindow(string name) => WindowManager.Unregister(name);
/// <summary>Make the named window visible. No-op (returns false) if unknown.</summary>
public bool ShowWindow(string name)
{
if (!_windows.TryGetValue(name, out var w)) return false;
w.Visible = true;
BringToFront(w);
return true;
}
=> WindowManager.Show(name);
/// <summary>Hide the named window. No-op (returns false) if unknown.</summary>
public bool HideWindow(string name)
{
if (!_windows.TryGetValue(name, out var w)) return false;
w.Visible = false;
return true;
}
=> WindowManager.Hide(name);
public bool CloseWindow(string name) => WindowManager.Close(name);
/// <summary>Return the current visibility of a registered window.</summary>
public bool IsWindowVisible(string name)
=> _windows.TryGetValue(name, out var w) && w.Visible;
=> WindowManager.IsVisible(name);
/// <summary>Flip the named window's visibility (Show if hidden, Hide if shown).
/// Returns the new IsVisible state (false for an unknown name).</summary>
public bool ToggleWindow(string name)
{
if (!_windows.TryGetValue(name, out var w)) return false;
if (w.Visible) { HideWindow(name); return false; }
ShowWindow(name);
return true;
}
=> WindowManager.Toggle(name);
/// <summary>Raise a top-level window above its siblings by setting its ZOrder
/// one past the current max among the OTHER top-level children. Used on Show
/// and on click. Leaves ZOrder unchanged if it is the only / already-topmost child.</summary>
public void BringToFront(UiElement window)
=> WindowManager.BringToFront(window);
internal void NotifyWindowMoved(UiElement window)
{
int top = window.ZOrder;
foreach (var c in Children)
if (!ReferenceEquals(c, window))
top = System.Math.Max(top, c.ZOrder + 1);
window.ZOrder = top;
if (WindowManager.TryGet(window, out var handle))
WindowMoved?.Invoke(handle.Name, window);
}
internal void NotifyWindowResized(UiElement window)
{
if (WindowManager.TryGet(window, out var handle))
WindowResized?.Invoke(handle.Name, window);
}
// ── Drag-drop (retail event chain 0x15 → 0x21 → 0x1C → 0x3E) ────────