feat(ui): centralize retained window lifecycle
This commit is contained in:
parent
4bb37e302e
commit
6e9e10367f
12 changed files with 778 additions and 57 deletions
24
src/AcDream.App/UI/IRetainedPanelController.cs
Normal file
24
src/AcDream.App/UI/IRetainedPanelController.cs
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
using System;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Lifecycle contract for a controller attached to a retained retail window.
|
||||
/// Implementations own panel-specific subscriptions and reactions; the window
|
||||
/// manager owns visibility, focus, capture, geometry, and teardown ordering.
|
||||
/// </summary>
|
||||
public interface IRetainedPanelController : IDisposable
|
||||
{
|
||||
/// <summary>Called once for each hidden-to-shown transition.</summary>
|
||||
void OnShown();
|
||||
|
||||
/// <summary>Called once for each shown-to-hidden transition.</summary>
|
||||
void OnHidden();
|
||||
|
||||
/// <summary>
|
||||
/// Called when keyboard focus enters, leaves, or moves within this window.
|
||||
/// <paramref name="focusedDescendant"/> is null when focus left the window.
|
||||
/// Controllers that do not react to focus may use this default no-op.
|
||||
/// </summary>
|
||||
void OnDescendantFocusChanged(UiElement? focusedDescendant) { }
|
||||
}
|
||||
|
|
@ -50,5 +50,5 @@ internal static class WindowChromeController
|
|||
}
|
||||
|
||||
private static bool IsCloseButtonId(uint id) =>
|
||||
id is MaxMinButtonId or InventoryCloseButtonId or CharacterCloseButtonId;
|
||||
id is InventoryCloseButtonId or CharacterCloseButtonId;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,11 @@ synthesis + six deep-dive docs under
|
|||
- `UiRoot.cs` — top-of-tree + "Device" responsibilities (mouse/keyboard
|
||||
state, focus, modal, capture, drag-drop, tooltip timer). Mirrors the
|
||||
retail `DAT_00837ff4` Device object's vtable.
|
||||
- `RetailWindowManager.cs` / `RetailWindowHandle.cs` — named top-level
|
||||
window registry, raise policy, visibility/focus/capture lifecycle,
|
||||
geometry events, lock propagation, and controller teardown.
|
||||
- `IRetainedPanelController.cs` — disposable lifecycle contract for
|
||||
panel-specific show/hide and descendant-focus behavior.
|
||||
- `UiHost.cs` — one-shot wrapper: owns the `UiRoot`, a `TextRenderer`,
|
||||
and a default `BitmapFont`. Provides `WireMouse` / `WireKeyboard`
|
||||
helpers for Silk.NET plumbing.
|
||||
|
|
|
|||
115
src/AcDream.App/UI/RetailWindowHandle.cs
Normal file
115
src/AcDream.App/UI/RetailWindowHandle.cs
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
using System;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Typed ownership handle for one named retained window. Geometry and opacity
|
||||
/// mutations flow through this handle so lifecycle observers see the same state
|
||||
/// regardless of whether a change came from input, restore, or controller logic.
|
||||
/// </summary>
|
||||
public sealed class RetailWindowHandle
|
||||
{
|
||||
private readonly RetailWindowManager _owner;
|
||||
private bool _notifiedVisible;
|
||||
private bool _closedSinceShown;
|
||||
private bool _disposed;
|
||||
|
||||
internal RetailWindowHandle(
|
||||
RetailWindowManager owner,
|
||||
string name,
|
||||
UiElement outerFrame,
|
||||
UiElement contentRoot,
|
||||
IRetainedPanelController? controller)
|
||||
{
|
||||
_owner = owner;
|
||||
Name = name;
|
||||
OuterFrame = outerFrame;
|
||||
ContentRoot = contentRoot;
|
||||
Controller = controller;
|
||||
_notifiedVisible = outerFrame.Visible;
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
public UiElement OuterFrame { get; }
|
||||
public UiElement ContentRoot { get; }
|
||||
public IRetainedPanelController? Controller { get; }
|
||||
public bool IsRegistered => !_disposed;
|
||||
public bool IsVisible => OuterFrame.Visible;
|
||||
public bool IsLocked => _owner.IsLocked;
|
||||
public float Left => OuterFrame.Left;
|
||||
public float Top => OuterFrame.Top;
|
||||
public float Width => OuterFrame.Width;
|
||||
public float Height => OuterFrame.Height;
|
||||
public float Opacity => OuterFrame.Opacity;
|
||||
|
||||
public event Action<RetailWindowHandle>? Shown;
|
||||
public event Action<RetailWindowHandle>? Hidden;
|
||||
public event Action<RetailWindowHandle>? Moved;
|
||||
public event Action<RetailWindowHandle>? Resized;
|
||||
public event Action<RetailWindowHandle>? Closed;
|
||||
public event Action<RetailWindowHandle, bool>? LockChanged;
|
||||
public event Action<RetailWindowHandle, UiElement?>? DescendantFocusChanged;
|
||||
public event Action<RetailWindowHandle, UiElement?>? DescendantCaptureChanged;
|
||||
|
||||
public bool Show() => IsRegistered && _owner.Show(Name);
|
||||
public bool Hide() => IsRegistered && _owner.Hide(Name);
|
||||
public bool Close() => IsRegistered && _owner.Close(Name);
|
||||
public bool MoveTo(float left, float top)
|
||||
=> IsRegistered && _owner.MoveTo(Name, left, top);
|
||||
public bool ResizeTo(float width, float height)
|
||||
=> IsRegistered && _owner.ResizeTo(Name, width, height);
|
||||
public bool SetOpacity(float opacity)
|
||||
=> IsRegistered && _owner.SetOpacity(Name, opacity);
|
||||
|
||||
internal void NotifyInitialState()
|
||||
{
|
||||
if (_notifiedVisible)
|
||||
Controller?.OnShown();
|
||||
}
|
||||
|
||||
internal void NotifyVisibility(bool visible)
|
||||
{
|
||||
if (_notifiedVisible == visible) return;
|
||||
_notifiedVisible = visible;
|
||||
|
||||
if (visible)
|
||||
{
|
||||
_closedSinceShown = false;
|
||||
Controller?.OnShown();
|
||||
Shown?.Invoke(this);
|
||||
}
|
||||
else
|
||||
{
|
||||
Controller?.OnHidden();
|
||||
Hidden?.Invoke(this);
|
||||
}
|
||||
}
|
||||
|
||||
internal void NotifyMoved() => Moved?.Invoke(this);
|
||||
internal void NotifyResized() => Resized?.Invoke(this);
|
||||
|
||||
internal void NotifyClosed()
|
||||
{
|
||||
if (_closedSinceShown) return;
|
||||
_closedSinceShown = true;
|
||||
Closed?.Invoke(this);
|
||||
}
|
||||
|
||||
internal void NotifyLockChanged(bool locked) => LockChanged?.Invoke(this, locked);
|
||||
|
||||
internal void NotifyDescendantFocusChanged(UiElement? focusedDescendant)
|
||||
{
|
||||
Controller?.OnDescendantFocusChanged(focusedDescendant);
|
||||
DescendantFocusChanged?.Invoke(this, focusedDescendant);
|
||||
}
|
||||
|
||||
internal void NotifyDescendantCaptureChanged(UiElement? capturedDescendant)
|
||||
=> DescendantCaptureChanged?.Invoke(this, capturedDescendant);
|
||||
|
||||
internal void DisposeController()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
Controller?.Dispose();
|
||||
}
|
||||
}
|
||||
289
src/AcDream.App/UI/RetailWindowManager.cs
Normal file
289
src/AcDream.App/UI/RetailWindowManager.cs
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
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 RetailWindowHandle Register(
|
||||
string name,
|
||||
UiElement outerFrame,
|
||||
UiElement? contentRoot = null,
|
||||
IRetainedPanelController? controller = null)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||
ArgumentNullException.ThrowIfNull(outerFrame);
|
||||
|
||||
if (!ReferenceEquals(outerFrame.Parent, _root))
|
||||
throw new InvalidOperationException(
|
||||
$"Retained window '{name}' must be mounted as a direct UiRoot child before registration.");
|
||||
|
||||
if (_byName.TryGetValue(name, out var sameName))
|
||||
{
|
||||
if (ReferenceEquals(sameName.OuterFrame, outerFrame)
|
||||
&& ReferenceEquals(sameName.ContentRoot, contentRoot ?? outerFrame)
|
||||
&& ReferenceEquals(sameName.Controller, controller))
|
||||
return sameName;
|
||||
|
||||
Unregister(name);
|
||||
}
|
||||
|
||||
if (_byFrame.TryGetValue(outerFrame, out var sameFrame))
|
||||
Unregister(sameFrame.Name);
|
||||
|
||||
var handle = new RetailWindowHandle(
|
||||
this,
|
||||
name,
|
||||
outerFrame,
|
||||
contentRoot ?? outerFrame,
|
||||
controller);
|
||||
_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!);
|
||||
|
||||
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;
|
||||
_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;
|
||||
width = Math.Clamp(width, frame.MinWidth, float.MaxValue);
|
||||
height = Math.Clamp(height, frame.MinHeight, frame.MaxHeight);
|
||||
if (frame.Width == width && frame.Height == height) return true;
|
||||
frame.Width = width;
|
||||
frame.Height = height;
|
||||
_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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -120,7 +120,25 @@ public abstract class UiElement
|
|||
}
|
||||
|
||||
// ── State flags ─────────────────────────────────────────────────────
|
||||
public bool Visible { get; set; } = true;
|
||||
private bool _visible = true;
|
||||
public bool Visible
|
||||
{
|
||||
get => _visible;
|
||||
set
|
||||
{
|
||||
if (_visible == value) return;
|
||||
|
||||
// A top-level visibility transition is also an ownership boundary:
|
||||
// focus/capture/modal state must be released before the subtree becomes
|
||||
// unreachable to input. UiRoot filters these notifications through the
|
||||
// registered-window manager, while ordinary child visibility changes
|
||||
// remain cheap and behavior-neutral.
|
||||
UiRoot? root = FindRoot();
|
||||
root?.OnElementVisibilityChanging(this, value);
|
||||
_visible = value;
|
||||
root?.OnElementVisibilityChanged(this, value);
|
||||
}
|
||||
}
|
||||
private bool _enabled = true;
|
||||
public bool Enabled
|
||||
{
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ namespace AcDream.App.UI;
|
|||
public sealed class UiHost : System.IDisposable
|
||||
{
|
||||
public UiRoot Root { get; } = new();
|
||||
public RetailWindowManager WindowManager => Root.WindowManager;
|
||||
public TextRenderer TextRenderer { get; }
|
||||
public BitmapFont? DefaultFont { get; set; }
|
||||
|
||||
|
|
@ -106,7 +107,14 @@ public sealed class UiHost : System.IDisposable
|
|||
// ── Window manager forwarders (delegate to UiRoot) ─────────────────
|
||||
|
||||
/// <summary>Register a top-level window for Show/Hide/Toggle. See <see cref="UiRoot.RegisterWindow"/>.</summary>
|
||||
public void RegisterWindow(string name, UiElement window) => Root.RegisterWindow(name, window);
|
||||
public RetailWindowHandle RegisterWindow(
|
||||
string name,
|
||||
UiElement window,
|
||||
UiElement? contentRoot = null,
|
||||
IRetainedPanelController? controller = null)
|
||||
=> Root.RegisterWindow(name, window, contentRoot, controller);
|
||||
|
||||
public bool UnregisterWindow(string name) => Root.UnregisterWindow(name);
|
||||
|
||||
/// <summary>Show a registered window; returns false if the name is unknown.</summary>
|
||||
public bool ShowWindow(string name) => Root.ShowWindow(name);
|
||||
|
|
@ -114,6 +122,8 @@ public sealed class UiHost : System.IDisposable
|
|||
/// <summary>Hide a registered window; returns false if the name is unknown.</summary>
|
||||
public bool HideWindow(string name) => Root.HideWindow(name);
|
||||
|
||||
public bool CloseWindow(string name) => Root.CloseWindow(name);
|
||||
|
||||
/// <summary>Return the current visibility of a registered window.</summary>
|
||||
public bool IsWindowVisible(string name) => Root.IsWindowVisible(name);
|
||||
|
||||
|
|
@ -122,6 +132,7 @@ public sealed class UiHost : System.IDisposable
|
|||
|
||||
public void Dispose()
|
||||
{
|
||||
WindowManager.Dispose();
|
||||
TextRenderer.Dispose();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) ────────
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue