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

@ -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-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-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-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-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` | | 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-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-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-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-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-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` | | 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` |

View file

@ -225,17 +225,17 @@ text; full suite green; user visual gate.
### 2.1 Introduce `RetailWindowManager` and typed handles ### 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`. `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. 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` `OnHidden`, and optional descendant-focus notification, without coupling `UiRoot`
to game/network semantics. 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 the handle owns outer-frame geometry/opacity and the controller owns panel-specific
reactions. 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 state, and default text input. Remove the max/min button from any shared close-id
convention; each controller binds close and maximize explicitly. convention; each controller binds close and maximize explicitly.

View 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) { }
}

View file

@ -50,5 +50,5 @@ internal static class WindowChromeController
} }
private static bool IsCloseButtonId(uint id) => private static bool IsCloseButtonId(uint id) =>
id is MaxMinButtonId or InventoryCloseButtonId or CharacterCloseButtonId; id is InventoryCloseButtonId or CharacterCloseButtonId;
} }

View file

@ -33,6 +33,11 @@ synthesis + six deep-dive docs under
- `UiRoot.cs` — top-of-tree + "Device" responsibilities (mouse/keyboard - `UiRoot.cs` — top-of-tree + "Device" responsibilities (mouse/keyboard
state, focus, modal, capture, drag-drop, tooltip timer). Mirrors the state, focus, modal, capture, drag-drop, tooltip timer). Mirrors the
retail `DAT_00837ff4` Device object's vtable. 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`, - `UiHost.cs` — one-shot wrapper: owns the `UiRoot`, a `TextRenderer`,
and a default `BitmapFont`. Provides `WireMouse` / `WireKeyboard` and a default `BitmapFont`. Provides `WireMouse` / `WireKeyboard`
helpers for Silk.NET plumbing. helpers for Silk.NET plumbing.

View 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();
}
}

View 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;
}
}

View file

@ -120,7 +120,25 @@ public abstract class UiElement
} }
// ── State flags ───────────────────────────────────────────────────── // ── 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; private bool _enabled = true;
public bool Enabled public bool Enabled
{ {

View file

@ -37,6 +37,7 @@ namespace AcDream.App.UI;
public sealed class UiHost : System.IDisposable public sealed class UiHost : System.IDisposable
{ {
public UiRoot Root { get; } = new(); public UiRoot Root { get; } = new();
public RetailWindowManager WindowManager => Root.WindowManager;
public TextRenderer TextRenderer { get; } public TextRenderer TextRenderer { get; }
public BitmapFont? DefaultFont { get; set; } public BitmapFont? DefaultFont { get; set; }
@ -106,7 +107,14 @@ public sealed class UiHost : System.IDisposable
// ── Window manager forwarders (delegate to UiRoot) ───────────────── // ── Window manager forwarders (delegate to UiRoot) ─────────────────
/// <summary>Register a top-level window for Show/Hide/Toggle. See <see cref="UiRoot.RegisterWindow"/>.</summary> /// <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> /// <summary>Show a registered window; returns false if the name is unknown.</summary>
public bool ShowWindow(string name) => Root.ShowWindow(name); 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> /// <summary>Hide a registered window; returns false if the name is unknown.</summary>
public bool HideWindow(string name) => Root.HideWindow(name); 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> /// <summary>Return the current visibility of a registered window.</summary>
public bool IsWindowVisible(string name) => Root.IsWindowVisible(name); public bool IsWindowVisible(string name) => Root.IsWindowVisible(name);
@ -122,6 +132,7 @@ public sealed class UiHost : System.IDisposable
public void Dispose() public void Dispose()
{ {
WindowManager.Dispose();
TextRenderer.Dispose(); TextRenderer.Dispose();
} }
} }

View file

@ -1,5 +1,4 @@
using System; using System;
using System.Collections.Generic;
using System.Numerics; using System.Numerics;
namespace AcDream.App.UI; namespace AcDream.App.UI;
@ -25,6 +24,14 @@ public enum ResizeEdges { None = 0, Left = 1, Right = 2, Top = 4, Bottom = 8 }
/// </summary> /// </summary>
public sealed class UiRoot : UiElement 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 ─────────────────────────────────────────────── // ── Device-level state ───────────────────────────────────────────────
public int MouseX { get; private set; } public int MouseX { get; private set; }
public int MouseY { 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 /// <summary>Retail PlayerModule::LockUI gate. Blocks all retained-window
/// move/resize interactions without disabling their buttons or content.</summary> /// 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> /// <summary>Current drag source (set between drag-begin and drop/cancel).</summary>
public UiElement? DragSource { get; private set; } 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> /// <summary>Raised after a registered top-level window finishes moving.</summary>
public event Action<string, UiElement>? WindowMoved; 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; private uint _nextEventId = 0x10000001u;
public override void AddChild(UiElement child) public override void AddChild(UiElement child)
@ -171,6 +203,22 @@ public sealed class UiRoot : UiElement
} }
internal void OnSubtreeRemoving(UiElement subtree) 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)) if (IsWithinSubtree(KeyboardFocus, subtree))
SetKeyboardFocus(null); SetKeyboardFocus(null);
@ -211,14 +259,6 @@ public sealed class UiRoot : UiElement
_resizeTarget = null; _resizeTarget = null;
_dragCandidate = false; _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) private static bool IsWithinSubtree(UiElement? element, UiElement subtree)
@ -463,8 +503,10 @@ public sealed class UiRoot : UiElement
if (_resizeTarget is not null) if (_resizeTarget is not null)
{ {
var resizedWindow = _resizeTarget;
_resizeTarget = null; _resizeTarget = null;
ReleaseCapture(); ReleaseCapture();
NotifyWindowResized(resizedWindow);
return; return;
} }
@ -473,12 +515,7 @@ public sealed class UiRoot : UiElement
var movedWindow = _windowDragTarget; var movedWindow = _windowDragTarget;
_windowDragTarget = null; _windowDragTarget = null;
ReleaseCapture(); ReleaseCapture();
foreach (var pair in _windows) NotifyWindowMoved(movedWindow);
if (ReferenceEquals(pair.Value, movedWindow))
{
WindowMoved?.Invoke(pair.Key, movedWindow);
break;
}
return; return;
} }
@ -621,10 +658,11 @@ public sealed class UiRoot : UiElement
public void SetKeyboardFocus(UiElement? e) public void SetKeyboardFocus(UiElement? e)
{ {
if (KeyboardFocus == e) return; 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); var lost = new UiEvent(previous.EventId, previous, UiEventType.FocusLost);
KeyboardFocus.OnEvent(in lost); previous.OnEvent(in lost);
} }
KeyboardFocus = e; KeyboardFocus = e;
if (e is not null) if (e is not null)
@ -632,68 +670,80 @@ public sealed class UiRoot : UiElement
var gained = new UiEvent(e.EventId, e, UiEventType.FocusGained); var gained = new UiEvent(e.EventId, e, UiEventType.FocusGained);
e.OnEvent(in gained); 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() public void ReleaseCapture()
{ {
UiElement? previous = Captured;
Captured = null; Captured = null;
// Retail restarts the tooltip idle deadline when capture is released. // Retail restarts the tooltip idle deadline when capture is released.
_hoverStartedMs = _nowMs; _hoverStartedMs = _nowMs;
_tooltipFired = false; _tooltipFired = false;
if (previous is not null)
PointerCaptureChanged?.Invoke(previous, null);
} }
// ── Window manager (named top-level windows: Show / Hide / Toggle) ─── // ── 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. /// <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 /// 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 /// initial Visible. Idempotent registration returns the existing typed handle;
/// of truth — independent of UiElement.Name (init-only, not set here).</summary> /// replacement performs full lifecycle teardown of the prior registration.</summary>
public void RegisterWindow(string name, UiElement window) => _windows[name] = window; 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> /// <summary>Make the named window visible. No-op (returns false) if unknown.</summary>
public bool ShowWindow(string name) public bool ShowWindow(string name)
{ => WindowManager.Show(name);
if (!_windows.TryGetValue(name, out var w)) return false;
w.Visible = true;
BringToFront(w);
return true;
}
/// <summary>Hide the named window. No-op (returns false) if unknown.</summary> /// <summary>Hide the named window. No-op (returns false) if unknown.</summary>
public bool HideWindow(string name) public bool HideWindow(string name)
{ => WindowManager.Hide(name);
if (!_windows.TryGetValue(name, out var w)) return false;
w.Visible = false; public bool CloseWindow(string name) => WindowManager.Close(name);
return true;
}
/// <summary>Return the current visibility of a registered window.</summary> /// <summary>Return the current visibility of a registered window.</summary>
public bool IsWindowVisible(string name) 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). /// <summary>Flip the named window's visibility (Show if hidden, Hide if shown).
/// Returns the new IsVisible state (false for an unknown name).</summary> /// Returns the new IsVisible state (false for an unknown name).</summary>
public bool ToggleWindow(string name) public bool ToggleWindow(string name)
{ => WindowManager.Toggle(name);
if (!_windows.TryGetValue(name, out var w)) return false;
if (w.Visible) { HideWindow(name); return false; }
ShowWindow(name);
return true;
}
/// <summary>Raise a top-level window above its siblings by setting its ZOrder /// <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 /// 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> /// and on click. Leaves ZOrder unchanged if it is the only / already-topmost child.</summary>
public void BringToFront(UiElement window) public void BringToFront(UiElement window)
=> WindowManager.BringToFront(window);
internal void NotifyWindowMoved(UiElement window)
{ {
int top = window.ZOrder; if (WindowManager.TryGet(window, out var handle))
foreach (var c in Children) WindowMoved?.Invoke(handle.Name, window);
if (!ReferenceEquals(c, window)) }
top = System.Math.Max(top, c.ZOrder + 1);
window.ZOrder = top; 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) ──────── // ── Drag-drop (retail event chain 0x15 → 0x21 → 0x1C → 0x3E) ────────

View file

@ -215,6 +215,20 @@ public class InventoryControllerTests
Assert.Equal(1, closes); 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] [Fact]
public void Burden_reads_wire_EncumbranceVal_over_carried_sum() public void Burden_reads_wire_EncumbranceVal_over_carried_sum()
{ {

View file

@ -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<string>();
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<bool>();
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<UiElement?>();
var secondCaptures = new List<UiElement?>();
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<UiElement?> FocusChanges { get; } = new();
public void OnShown() => ShownCount++;
public void OnHidden() => HiddenCount++;
public void OnDescendantFocusChanged(UiElement? focusedDescendant)
=> FocusChanges.Add(focusedDescendant);
public void Dispose() => DisposeCount++;
}
}