diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index a39d4157..6add193c 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -54,7 +54,7 @@ accepted-divergence entries (#96, #49, #50).
| IA-12 | UI toolkit mirrors retail behavior from research docs, not a byte-port — keystone.dll is outside decomp coverage; observed constants embedded (drag 3 px, tooltip 1000 ms) | `src/AcDream.App/UI/README.md:3` (window mgr: `UiRoot.cs` RegisterWindow/Show/Hide/Toggle/BringToFront — F12 inventory toggle IS retail-faithful) | keystone.dll has no PDB/decomp; semantics reconstructed from the six `docs/research/retail-ui/` deep-dives, keeping retail's event-type constants so panel switch-cases transplant cleanly | Edge-case input semantics the research under-specified (drag threshold, tooltip timing, focus hand-off, capture corners) differ silently with no oracle to diff against | keystone.dll Device DAT_00837ff4; docs/research/retail-ui/04-input-events.md |
| IA-13 | GameEventType registry deliberately omits event types retail ignores; unknown events fall through unhandled | `src/AcDream.Core.Net/Messages/GameEventType.cs:11` | Retail also ignores them — dropping matches retail by construction | If the "retail ignores X" judgment is wrong for any opcode (or a server mod uses one), the event is silently dropped with no diagnostic pointing at the omission | retail GameEvent dispatch (ignored-event set) |
| IA-14 | Rendering + dat-handling base is WorldBuilder's tested port, not a fresh retail-decomp port (Phase N.4/O design stance) | `docs/architecture/worldbuilder-inventory.md` (code at `src/AcDream.{Core,App}/Rendering/Wb/`) | WB visually verified on the AC world, MIT, same stack; known WB↔retail deltas resolved case-by-case — terrain split kept retail `FSplitNESW` (**#51**, pinned by `SplitFormulaDivergenceTest`), scenery drift accepted (AP-31) | A WB-upstream divergence not yet caught ships silently as "our" behavior; guard = the inventory doc's 🟢/🔴 split + per-formula divergence tests | retail decomp per algorithm; `tests/.../SplitFormulaDivergenceTest.cs` |
-| IA-15 | D.2b gameplay UI is our own `UiHost`/`UiRoot` retained tree, not a byte-port of Keystone. Production LayoutDesc imports now include vitals `0x2100006C`, chat `0x21000006`, toolbar `0x21000016`, character `0x2100002E`, inventory `0x21000023` plus mounted `0x21000024/22/21`, and radar `0x21000074`. Chrome is still mixed across DAT-owned roots, `UiNineSlicePanel`, `UiCollapsibleFrame`, and `RetailWindowFrame`. | `src/AcDream.App/UI/Layout/LayoutImporter.cs`; `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; production mounts in `GameWindow.cs` | Keystone has no matching PDB/decomp, so we preserve its observable ElementDesc/state/input behavior from DAT, named client call sites, and live evidence while using modern retained ownership. Real RenderSurfaces and imported element geometry remain the visual oracle. | Mixed mount/chrome paths, lossy edge/property import, or unported lifecycle semantics can make windows drift from one another at non-reference sizes or during focus/show/hide transitions | Production LayoutDesc objects; `docs/research/2026-06-15-layoutdesc-format.md`; Keystone behavior notes in `docs/research/retail-ui/` |
+| IA-15 | D.2b gameplay UI is our own `UiHost`/`UiRoot` retained tree, not a byte-port of Keystone. `RetailWindowManager`/typed handles now centralize registry, raise, focus/capture cleanup, lifecycle events, and controller teardown. Production LayoutDesc imports include vitals `0x2100006C`, chat `0x21000006`, toolbar `0x21000016`, character `0x2100002E`, inventory `0x21000023` plus mounted `0x21000024/22/21`, and radar `0x21000074`; chrome/mount paths remain mixed pending Wave 2.2. | `src/AcDream.App/UI/RetailWindowManager.cs`; `src/AcDream.App/UI/Layout/LayoutImporter.cs`; `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; production mounts in `GameWindow.cs` | Keystone has no matching PDB/decomp, so we preserve its observable ElementDesc/state/input behavior from DAT, named client call sites, and live evidence while using modern retained ownership. Real RenderSurfaces and imported element geometry remain the visual oracle. | Mixed mount/chrome paths or unported persistence semantics can make windows drift from one another at non-reference sizes or across restart; lifecycle edge cases remain constrained by conformance tests rather than a Keystone byte-port | Production LayoutDesc objects; `docs/research/2026-06-15-layoutdesc-format.md`; Keystone behavior notes in `docs/research/retail-ui/` |
| IA-17 | Toolbar window FRAME is toolkit-supplied (per-window UiNineSlicePanel 8-piece bevel, drawn over content via UiElement.OnDrawAfterChildren) rather than the window-manager-owned chrome retail paints uniformly around every window. It also supports a toolkit-defined collapse-to-one-row (bottom-edge resize snapping between a row-1-only and a two-row height, row-2 visibility tied to the stop) — retail's real collapse is keystone.dll (no decomp) and the dat stacks both rows always. | `src/AcDream.App/Rendering/GameWindow.cs` (toolbar mount) + `src/AcDream.App/UI/UiNineSlicePanel.cs` + `src/AcDream.App/UI/UiCollapsibleFrame.cs`; spec: `docs/superpowers/specs/2026-06-20-d2b-toolbar-collapse-design.md` | LayoutDesc 0x21000016 has NO baked frame; retail's toolbar frame is window-manager chrome (keystone.dll). We draw the same reusable 8-piece bevel chat/vitals use; border drawn over content so the toolbar's 2px-wide row-2 right cap (W=8) can't poke through. Same pattern as the chat window. | Until a central window manager owns chrome uniformly, per-window wraps can drift (size/offset/z-order) from each other and from retail; the border-over-content rule is the toolkit's, not the WM's | gmToolbarUI WM chrome (keystone.dll, no PDB); no bevel ids in LayoutDesc 0x21000016 (toolbar dump) |
| IA-18 | Effect overlay tile (enum 0x10000005) is a `ReplaceColor` SURFACE SOURCE — pure-white pixels in the composited drag icon are replaced PER-PIXEL with the same (x,y) pixel of the effect tile (the SURFACE overload `SurfaceWindow::ReplaceColor` 0x004415b0), preserving the tile's texture/gradient; the tile itself is NOT blitted as an additional layer. This IS faithful retail behavior. **Anti-regression: do NOT re-implement this as a blit layer NOR as a flat-color replace (it is a per-pixel surface copy).** | `src/AcDream.App/UI/IconComposer.cs` (`ReplaceWhiteFromSurface`) | Faithful port of `IconData::RenderIcons` @407614 → the SURFACE overload `ReplaceColor` 0x004415b0 (`dst[x,y]=src[x,y]` where `dst==white`); confirmed via clean Ghidra decompile + named decomp + visual (the Energy Crystal's blue is a gradient, 2026-06-17). | A blit-layer or flat-color re-implementation would show the wrong effect look (no gradient) — the visual-verification regression that retired the mean-color approximation | `IconData::RenderIcons` acclient_2013_pseudo_c.txt:407524; `ReplaceColor` SURFACE overload 0x004415b0:71656; `docs/research/2026-06-17-stateful-icon-RESOLVED.md` |
@@ -203,7 +203,7 @@ AP-94..AP-110 for the confirmed retail-UI completion gaps.
| AP-95 | `UiButton` dispatches Click only; it lacks retail hover, pressed, released-outside, disabled, selected/toggle, and missing-state fallback transitions | `src/AcDream.App/UI/UiButton.cs` | Controllers manually set a few semantic states; generic pointer visuals remain incomplete | Buttons look inert or overwrite custom states, and disabled/toggle behavior can fire incorrectly | `UIElement_Button::UpdateState_ @ 0x00471CF0`; mouse handlers `0x00471FF0..0x004721F0` |
| AP-96 | Layout/property import loses raw edge-mode semantics (especially 3/4) and treats default scalar values as absent, so explicit `false`/`0` cannot override inheritance | `src/AcDream.App/UI/Layout/ElementReader.cs`; `LayoutImporter.cs` | Existing shipped layouts are hand-gated; Wave 1 adds presence-aware properties and the exact solver | Non-reference resizing recenters/stretches incorrectly; derived DAT properties silently inherit the wrong base value | `UIElement::UpdateForParentSizeChange @ 0x00462640`; production LayoutDesc inheritance |
| ~~AP-97~~ | **RETIRED 2026-07-10 (Wave 1 retained-widget foundation)** — the prior generic Device-timer premise was a decomp misread. Named retail polls the hovered element's tooltip deadline, then broadcasts global UI time message `3`; it does not expose the assumed arbitrary timer queue. `UiRoot.Tick` now preserves that order and subtree removal clears input/time ownership before another pulse. | `src/AcDream.App/UI/UiRoot.cs`; `IUiGlobalTimeListener.cs` | — | — | `UIElementManager::UseTime` and hover/focus paths, pinned in `docs/research/2026-07-10-retained-widget-foundations-pseudocode.md` |
-| AP-98 | Window registry lacks complete lifecycle/focus/persistence ownership; only radar position persists, and chat maximizes its imported content to hardcoded 320 px rather than resizing the outer frame | `src/AcDream.App/UI/UiRoot.cs`; `ChatWindowController.cs`; production mounts in `GameWindow.cs` | Windows remain usable through manual show/hide and local mount recipes; Wave 2 centralizes the contract | Close cleanup is skipped, layouts reset/drift, and chat chrome/content disagree after maximize | `gmMainChatUI::HandleMaximizeButton @ 0x004CCE50`; retail saveui/loadui behavior |
+| AP-98 | **PARTIAL 2026-07-10 (Wave 2.1)** — typed handles and `RetailWindowManager` now own registry, raise, lifecycle, focus/capture cleanup, and controller disposal. Complete layout persistence is still absent, and chat still maximizes imported content to hardcoded 320 px instead of resizing the outer frame. | `src/AcDream.App/UI/RetailWindowManager.cs`; `RetailWindowHandle.cs`; `ChatWindowController.cs`; production mounts in `GameWindow.cs` | Window lifecycle now has one tested owner; Wave 2.2/2.3 migrate mounts and persistence | Layouts still reset/drift across restart, and chat chrome/content disagree after maximize | `gmMainChatUI::HandleMaximizeButton @ 0x004CCE50`; retail saveui/loadui behavior |
| AP-99 | Shared item activation implements only a subset of `ItemHolder::UseObject`/placement legality and confirmation policy | `src/AcDream.App/UI/ItemInteractionController.cs` | Current PvE use/equip/container flows cover the M2 loop; Wave 3 ports the complete pure decision matrix | Vendor/trade/busy/confirmation/target edge cases send the wrong action or permit an illegal use | `ItemHolder::DetermineUseResult @ 0x00588460`; `UseObject @ 0x00588A80`; `AttemptPlaceIn3D @ 0x00588600` |
| AP-100 | Cursor semantics cover only a reachable subset of retail states, and widget cursor media can outrank authoritative global target mode | `src/AcDream.App/UI/CursorFeedbackController.cs`; `src/AcDream.App/Rendering/RetailCursorManager.cs` | Target-use art works for current flows; Wave 3 ports the full table and precedence | Hovering UI can replace the use/examine/busy/combat cursor; unsupported branches fall back to OS art | `ClientUISystem::UpdateCursorState @ 0x00564630` |
| AP-101 | Toolbar quick-slot actions and most panel/Use/Examine/combat controls are not fully dispatched; selected-object mana and stack entry/slider are absent | `src/AcDream.App/UI/Layout/ToolbarController.cs`; `GameWindow.OnInputAction` | Item click/use, stance indicators, health/name, and some toggles are present | Bound retail keys/buttons do nothing and item/stack selections show incomplete state | `gmToolbarUI @ 0x004BD0C0..0x004BF380`; LayoutDesc `0x21000016` |
diff --git a/docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md b/docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md
index 0158a40d..aedb01b6 100644
--- a/docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md
+++ b/docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md
@@ -225,17 +225,17 @@ text; full suite green; user visual gate.
### 2.1 Introduce `RetailWindowManager` and typed handles
-- [ ] Add `RetailWindowHandle` and lifecycle events: `Shown`, `Hidden`, `Moved`,
+- [x] Add `RetailWindowHandle` and lifecycle events: `Shown`, `Hidden`, `Moved`,
`Resized`, `Closed`, `LockChanged`.
-- [ ] Move named-window registry/raise policy behind `RetailWindowManager`; retain
+- [x] Move named-window registry/raise policy behind `RetailWindowManager`; retain
thin `UiHost` forwarding APIs for callers.
-- [ ] Define `IRetainedPanelController : IDisposable` with idempotent `OnShown`,
+- [x] Define `IRetainedPanelController : IDisposable` with idempotent `OnShown`,
`OnHidden`, and optional descendant-focus notification, without coupling `UiRoot`
to game/network semantics.
-- [ ] Make `UiRoot` emit visibility, descendant-focus, and pointer-capture changes;
+- [x] Make `UiRoot` emit visibility, descendant-focus, and pointer-capture changes;
the handle owns outer-frame geometry/opacity and the controller owns panel-specific
reactions.
-- [ ] On hide/unregister, clear descendant focus, capture, modal ownership, drag
+- [x] On hide/unregister, clear descendant focus, capture, modal ownership, drag
state, and default text input. Remove the max/min button from any shared close-id
convention; each controller binds close and maximize explicitly.
diff --git a/src/AcDream.App/UI/IRetainedPanelController.cs b/src/AcDream.App/UI/IRetainedPanelController.cs
new file mode 100644
index 00000000..34d9ff2b
--- /dev/null
+++ b/src/AcDream.App/UI/IRetainedPanelController.cs
@@ -0,0 +1,24 @@
+using System;
+
+namespace AcDream.App.UI;
+
+///
+/// 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.
+///
+public interface IRetainedPanelController : IDisposable
+{
+ /// Called once for each hidden-to-shown transition.
+ void OnShown();
+
+ /// Called once for each shown-to-hidden transition.
+ void OnHidden();
+
+ ///
+ /// Called when keyboard focus enters, leaves, or moves within this window.
+ /// is null when focus left the window.
+ /// Controllers that do not react to focus may use this default no-op.
+ ///
+ void OnDescendantFocusChanged(UiElement? focusedDescendant) { }
+}
diff --git a/src/AcDream.App/UI/Layout/WindowChromeController.cs b/src/AcDream.App/UI/Layout/WindowChromeController.cs
index a07eb9ec..3484e78f 100644
--- a/src/AcDream.App/UI/Layout/WindowChromeController.cs
+++ b/src/AcDream.App/UI/Layout/WindowChromeController.cs
@@ -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;
}
diff --git a/src/AcDream.App/UI/README.md b/src/AcDream.App/UI/README.md
index 8dacd46e..94f98a9b 100644
--- a/src/AcDream.App/UI/README.md
+++ b/src/AcDream.App/UI/README.md
@@ -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.
diff --git a/src/AcDream.App/UI/RetailWindowHandle.cs b/src/AcDream.App/UI/RetailWindowHandle.cs
new file mode 100644
index 00000000..da96ffcd
--- /dev/null
+++ b/src/AcDream.App/UI/RetailWindowHandle.cs
@@ -0,0 +1,115 @@
+using System;
+
+namespace AcDream.App.UI;
+
+///
+/// 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.
+///
+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? Shown;
+ public event Action? Hidden;
+ public event Action? Moved;
+ public event Action? Resized;
+ public event Action? Closed;
+ public event Action? LockChanged;
+ public event Action? DescendantFocusChanged;
+ public event Action? 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();
+ }
+}
diff --git a/src/AcDream.App/UI/RetailWindowManager.cs b/src/AcDream.App/UI/RetailWindowManager.cs
new file mode 100644
index 00000000..c19b644a
--- /dev/null
+++ b/src/AcDream.App/UI/RetailWindowManager.cs
@@ -0,0 +1,289 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace AcDream.App.UI;
+
+///
+/// Owns the named retained-window registry, raise policy, lifecycle delivery,
+/// and controller teardown. remains responsible for raw
+/// input ownership and reports completed interactions to this manager.
+///
+public sealed class RetailWindowManager : IDisposable
+{
+ private readonly UiRoot _root;
+ private readonly Dictionary _byName =
+ new(StringComparer.Ordinal);
+ private readonly Dictionary _byFrame = new();
+ private readonly Dictionary _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 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;
+ }
+}
diff --git a/src/AcDream.App/UI/UiElement.cs b/src/AcDream.App/UI/UiElement.cs
index f02e9d71..fc583585 100644
--- a/src/AcDream.App/UI/UiElement.cs
+++ b/src/AcDream.App/UI/UiElement.cs
@@ -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
{
diff --git a/src/AcDream.App/UI/UiHost.cs b/src/AcDream.App/UI/UiHost.cs
index 19c5dd52..9f6bb3a9 100644
--- a/src/AcDream.App/UI/UiHost.cs
+++ b/src/AcDream.App/UI/UiHost.cs
@@ -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) ─────────────────
/// Register a top-level window for Show/Hide/Toggle. See .
- 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);
/// Show a registered window; returns false if the name is unknown.
public bool ShowWindow(string name) => Root.ShowWindow(name);
@@ -114,6 +122,8 @@ public sealed class UiHost : System.IDisposable
/// Hide a registered window; returns false if the name is unknown.
public bool HideWindow(string name) => Root.HideWindow(name);
+ public bool CloseWindow(string name) => Root.CloseWindow(name);
+
/// Return the current visibility of a registered window.
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();
}
}
diff --git a/src/AcDream.App/UI/UiRoot.cs b/src/AcDream.App/UI/UiRoot.cs
index ec44ab02..dc497dc2 100644
--- a/src/AcDream.App/UI/UiRoot.cs
+++ b/src/AcDream.App/UI/UiRoot.cs
@@ -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 }
///
public sealed class UiRoot : UiElement
{
+ public UiRoot()
+ {
+ WindowManager = new RetailWindowManager(this);
+ }
+
+ /// Single owner for named retained-window lifecycle and raise policy.
+ 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
/// Retail PlayerModule::LockUI gate. Blocks all retained-window
/// move/resize interactions without disabling their buttons or content.
- public bool UiLocked { get; set; }
+ private bool _uiLocked;
+ public bool UiLocked
+ {
+ get => _uiLocked;
+ set
+ {
+ if (_uiLocked == value) return;
+ _uiLocked = value;
+ UiLockChanged?.Invoke(value);
+ }
+ }
/// Current drag source (set between drag-begin and drop/cancel).
public UiElement? DragSource { get; private set; }
@@ -141,6 +158,21 @@ public sealed class UiRoot : UiElement
/// Raised after a registered top-level window finishes moving.
public event Action? WindowMoved;
+ /// Raised after a registered top-level window finishes resizing.
+ public event Action? WindowResized;
+
+ /// Raised after any attached element changes visibility.
+ public event Action? ElementVisibilityChanged;
+
+ /// Raised after keyboard focus changes; arguments are old/new.
+ public event Action? KeyboardFocusChanged;
+
+ /// Raised after pointer capture changes; arguments are old/new.
+ public event Action? PointerCaptureChanged;
+
+ /// Raised after the global retained-UI lock changes.
+ public event Action? 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 _windows = new();
+ // Registry state lives in RetailWindowManager; methods below are compatibility forwarders.
/// 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).
- 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.
+ 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);
/// Make the named window visible. No-op (returns false) if unknown.
public bool ShowWindow(string name)
- {
- if (!_windows.TryGetValue(name, out var w)) return false;
- w.Visible = true;
- BringToFront(w);
- return true;
- }
+ => WindowManager.Show(name);
/// Hide the named window. No-op (returns false) if unknown.
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);
/// Return the current visibility of a registered window.
public bool IsWindowVisible(string name)
- => _windows.TryGetValue(name, out var w) && w.Visible;
+ => WindowManager.IsVisible(name);
/// Flip the named window's visibility (Show if hidden, Hide if shown).
/// Returns the new IsVisible state (false for an unknown name).
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);
/// 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.
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) ────────
diff --git a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs
index 8357f975..18806123 100644
--- a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs
@@ -215,6 +215,20 @@ public class InventoryControllerTests
Assert.Equal(1, closes);
}
+ [Fact]
+ public void Window_maximize_button_is_not_bound_as_close()
+ {
+ var (layout, _, _, _, _, _, _, _) = BuildLayout();
+ var maximize = MakeButton(WindowChromeController.MaxMinButtonId);
+ layout.Root.AddChild(maximize);
+ int closes = 0;
+
+ Bind(layout, new ClientObjectTable(), onClose: () => closes++);
+ maximize.OnEvent(new UiEvent(0u, maximize, UiEventType.Click));
+
+ Assert.Equal(0, closes);
+ }
+
[Fact]
public void Burden_reads_wire_EncumbranceVal_over_carried_sum()
{
diff --git a/tests/AcDream.App.Tests/UI/RetailWindowManagerTests.cs b/tests/AcDream.App.Tests/UI/RetailWindowManagerTests.cs
new file mode 100644
index 00000000..57118b59
--- /dev/null
+++ b/tests/AcDream.App.Tests/UI/RetailWindowManagerTests.cs
@@ -0,0 +1,195 @@
+using System.Collections.Generic;
+using AcDream.App.UI;
+
+namespace AcDream.App.Tests.UI;
+
+public sealed class RetailWindowManagerTests
+{
+ [Fact]
+ public void Hide_ClearsRootOwnership_AndShowRestoresDefaultInput()
+ {
+ var root = new UiRoot { Width = 800, Height = 600 };
+ var frame = new UiPanel { Width = 300, Height = 150 };
+ var input = new UiField { Width = 200, Height = 20 };
+ frame.AddChild(input);
+ root.AddChild(frame);
+ root.DefaultTextInput = input;
+ root.Modal = frame;
+ root.SetKeyboardFocus(input);
+ root.SetCapture(input);
+
+ var controller = new RecordingController();
+ RetailWindowHandle handle = root.RegisterWindow("chat", frame, frame, controller);
+ var events = new List();
+ handle.Hidden += _ => events.Add("hidden");
+ handle.Shown += _ => events.Add("shown");
+
+ frame.Visible = false;
+
+ Assert.False(frame.Visible);
+ Assert.Null(root.KeyboardFocus);
+ Assert.Null(root.Captured);
+ Assert.Null(root.Modal);
+ Assert.Null(root.DefaultTextInput);
+ Assert.Equal(1, controller.ShownCount);
+ Assert.Equal(1, controller.HiddenCount);
+ Assert.Equal(new[] { "hidden" }, events);
+
+ Assert.True(handle.Show());
+
+ Assert.True(frame.Visible);
+ Assert.Same(input, root.DefaultTextInput);
+ Assert.Equal(2, controller.ShownCount);
+ Assert.Equal(new[] { "hidden", "shown" }, events);
+ }
+
+ [Fact]
+ public void Handle_ReportsGeometryCloseAndLockLifecycle()
+ {
+ var root = new UiRoot { Width = 800, Height = 600 };
+ var frame = new UiPanel
+ {
+ Left = 10,
+ Top = 20,
+ Width = 100,
+ Height = 80,
+ MinWidth = 50,
+ MinHeight = 40,
+ };
+ root.AddChild(frame);
+ RetailWindowHandle handle = root.RegisterWindow("inventory", frame);
+ int moved = 0, resized = 0, hidden = 0, shown = 0, closed = 0;
+ var locks = new List();
+ handle.Moved += _ => moved++;
+ handle.Resized += _ => resized++;
+ handle.Hidden += _ => hidden++;
+ handle.Shown += _ => shown++;
+ handle.Closed += _ => closed++;
+ handle.LockChanged += (_, locked) => locks.Add(locked);
+
+ Assert.True(handle.MoveTo(30, 45));
+ Assert.Equal((30f, 45f), (frame.Left, frame.Top));
+ Assert.Equal(1, moved);
+
+ Assert.True(handle.ResizeTo(160, 120));
+ Assert.Equal((160f, 120f), (frame.Width, frame.Height));
+ Assert.Equal(1, resized);
+
+ root.WindowManager.SetLocked(true);
+ root.WindowManager.SetLocked(true);
+ root.WindowManager.SetLocked(false);
+ Assert.Equal(new[] { true, false }, locks);
+
+ Assert.True(handle.Close());
+ Assert.True(handle.Close());
+ Assert.False(handle.IsVisible);
+ Assert.Equal(1, hidden);
+ Assert.Equal(1, closed);
+
+ Assert.True(handle.Show());
+ Assert.True(handle.Close());
+ Assert.Equal(1, shown);
+ Assert.Equal(2, hidden);
+ Assert.Equal(2, closed);
+ }
+
+ [Fact]
+ public void PointerResize_ReportsCompletionThroughTypedHandle()
+ {
+ var root = new UiRoot { Width = 800, Height = 600 };
+ var frame = new UiPanel
+ {
+ Left = 10,
+ Top = 20,
+ Width = 100,
+ Height = 80,
+ Resizable = true,
+ };
+ root.AddChild(frame);
+ RetailWindowHandle handle = root.RegisterWindow("chat", frame);
+ int resized = 0;
+ handle.Resized += _ => resized++;
+
+ root.OnMouseDown(UiMouseButton.Left, 109, 60);
+ root.OnMouseMove(149, 60);
+ root.OnMouseUp(UiMouseButton.Left, 149, 60);
+
+ Assert.Equal(140f, frame.Width);
+ Assert.Equal(1, resized);
+ }
+
+ [Fact]
+ public void FocusAndCaptureTransitions_AreScopedToOwningHandle()
+ {
+ var root = new UiRoot { Width = 800, Height = 600 };
+ var first = new UiPanel { Width = 200, Height = 100 };
+ var second = new UiPanel { Left = 250, Width = 200, Height = 100 };
+ var firstField = new UiField { Width = 100, Height = 20 };
+ var secondField = new UiField { Width = 100, Height = 20 };
+ first.AddChild(firstField);
+ second.AddChild(secondField);
+ root.AddChild(first);
+ root.AddChild(second);
+ var firstController = new RecordingController();
+ var secondController = new RecordingController();
+ RetailWindowHandle firstHandle = root.RegisterWindow("first", first, first, firstController);
+ RetailWindowHandle secondHandle = root.RegisterWindow("second", second, second, secondController);
+ var firstCaptures = new List();
+ var secondCaptures = new List();
+ firstHandle.DescendantCaptureChanged += (_, element) => firstCaptures.Add(element);
+ secondHandle.DescendantCaptureChanged += (_, element) => secondCaptures.Add(element);
+
+ root.SetKeyboardFocus(firstField);
+ root.SetKeyboardFocus(secondField);
+ root.SetCapture(firstField);
+ root.SetCapture(secondField);
+ root.ReleaseCapture();
+
+ Assert.Equal(new UiElement?[] { firstField, null }, firstController.FocusChanges);
+ Assert.Equal(new UiElement?[] { secondField }, secondController.FocusChanges);
+ Assert.Equal(new UiElement?[] { firstField, null }, firstCaptures);
+ Assert.Equal(new UiElement?[] { secondField, null }, secondCaptures);
+ }
+
+ [Fact]
+ public void UnregisterAndTreeRemoval_DisposeControllersExactlyOnce()
+ {
+ var root = new UiRoot { Width = 800, Height = 600 };
+ var first = new UiPanel { Width = 100, Height = 100 };
+ var second = new UiPanel { Width = 100, Height = 100 };
+ root.AddChild(first);
+ root.AddChild(second);
+ var firstController = new RecordingController();
+ var secondController = new RecordingController();
+ RetailWindowHandle firstHandle = root.RegisterWindow("first", first, first, firstController);
+ RetailWindowHandle secondHandle = root.RegisterWindow("second", second, second, secondController);
+
+ Assert.True(root.UnregisterWindow("first"));
+ Assert.False(firstHandle.IsRegistered);
+ Assert.Equal(1, firstController.DisposeCount);
+ Assert.False(root.ShowWindow("first"));
+
+ Assert.True(root.RemoveChild(second));
+ Assert.False(secondHandle.IsRegistered);
+ Assert.Equal(1, secondController.DisposeCount);
+ Assert.False(root.ShowWindow("second"));
+
+ root.WindowManager.Dispose();
+ Assert.Equal(1, firstController.DisposeCount);
+ Assert.Equal(1, secondController.DisposeCount);
+ }
+
+ private sealed class RecordingController : IRetainedPanelController
+ {
+ public int ShownCount { get; private set; }
+ public int HiddenCount { get; private set; }
+ public int DisposeCount { get; private set; }
+ public List FocusChanges { get; } = new();
+
+ public void OnShown() => ShownCount++;
+ public void OnHidden() => HiddenCount++;
+ public void OnDescendantFocusChanged(UiElement? focusedDescendant)
+ => FocusChanges.Add(focusedDescendant);
+ public void Dispose() => DisposeCount++;
+ }
+}