From 6e9e10367ff7817f985e787d0e9f907454284e0d Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 10 Jul 2026 21:31:18 +0200 Subject: [PATCH] feat(ui): centralize retained window lifecycle --- .../retail-divergence-register.md | 4 +- ...026-07-10-retail-ui-fidelity-completion.md | 10 +- .../UI/IRetainedPanelController.cs | 24 ++ .../UI/Layout/WindowChromeController.cs | 2 +- src/AcDream.App/UI/README.md | 5 + src/AcDream.App/UI/RetailWindowHandle.cs | 115 +++++++ src/AcDream.App/UI/RetailWindowManager.cs | 289 ++++++++++++++++++ src/AcDream.App/UI/UiElement.cs | 20 +- src/AcDream.App/UI/UiHost.cs | 13 +- src/AcDream.App/UI/UiRoot.cs | 144 ++++++--- .../UI/Layout/InventoryControllerTests.cs | 14 + .../UI/RetailWindowManagerTests.cs | 195 ++++++++++++ 12 files changed, 778 insertions(+), 57 deletions(-) create mode 100644 src/AcDream.App/UI/IRetainedPanelController.cs create mode 100644 src/AcDream.App/UI/RetailWindowHandle.cs create mode 100644 src/AcDream.App/UI/RetailWindowManager.cs create mode 100644 tests/AcDream.App.Tests/UI/RetailWindowManagerTests.cs 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++; + } +}