feat(ui): centralize retained window lifecycle
This commit is contained in:
parent
4bb37e302e
commit
6e9e10367f
12 changed files with 778 additions and 57 deletions
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
24
src/AcDream.App/UI/IRetainedPanelController.cs
Normal file
24
src/AcDream.App/UI/IRetainedPanelController.cs
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
using System;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Lifecycle contract for a controller attached to a retained retail window.
|
||||
/// Implementations own panel-specific subscriptions and reactions; the window
|
||||
/// manager owns visibility, focus, capture, geometry, and teardown ordering.
|
||||
/// </summary>
|
||||
public interface IRetainedPanelController : IDisposable
|
||||
{
|
||||
/// <summary>Called once for each hidden-to-shown transition.</summary>
|
||||
void OnShown();
|
||||
|
||||
/// <summary>Called once for each shown-to-hidden transition.</summary>
|
||||
void OnHidden();
|
||||
|
||||
/// <summary>
|
||||
/// Called when keyboard focus enters, leaves, or moves within this window.
|
||||
/// <paramref name="focusedDescendant"/> is null when focus left the window.
|
||||
/// Controllers that do not react to focus may use this default no-op.
|
||||
/// </summary>
|
||||
void OnDescendantFocusChanged(UiElement? focusedDescendant) { }
|
||||
}
|
||||
|
|
@ -50,5 +50,5 @@ internal static class WindowChromeController
|
|||
}
|
||||
|
||||
private static bool IsCloseButtonId(uint id) =>
|
||||
id is MaxMinButtonId or InventoryCloseButtonId or CharacterCloseButtonId;
|
||||
id is InventoryCloseButtonId or CharacterCloseButtonId;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,11 @@ synthesis + six deep-dive docs under
|
|||
- `UiRoot.cs` — top-of-tree + "Device" responsibilities (mouse/keyboard
|
||||
state, focus, modal, capture, drag-drop, tooltip timer). Mirrors the
|
||||
retail `DAT_00837ff4` Device object's vtable.
|
||||
- `RetailWindowManager.cs` / `RetailWindowHandle.cs` — named top-level
|
||||
window registry, raise policy, visibility/focus/capture lifecycle,
|
||||
geometry events, lock propagation, and controller teardown.
|
||||
- `IRetainedPanelController.cs` — disposable lifecycle contract for
|
||||
panel-specific show/hide and descendant-focus behavior.
|
||||
- `UiHost.cs` — one-shot wrapper: owns the `UiRoot`, a `TextRenderer`,
|
||||
and a default `BitmapFont`. Provides `WireMouse` / `WireKeyboard`
|
||||
helpers for Silk.NET plumbing.
|
||||
|
|
|
|||
115
src/AcDream.App/UI/RetailWindowHandle.cs
Normal file
115
src/AcDream.App/UI/RetailWindowHandle.cs
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
using System;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Typed ownership handle for one named retained window. Geometry and opacity
|
||||
/// mutations flow through this handle so lifecycle observers see the same state
|
||||
/// regardless of whether a change came from input, restore, or controller logic.
|
||||
/// </summary>
|
||||
public sealed class RetailWindowHandle
|
||||
{
|
||||
private readonly RetailWindowManager _owner;
|
||||
private bool _notifiedVisible;
|
||||
private bool _closedSinceShown;
|
||||
private bool _disposed;
|
||||
|
||||
internal RetailWindowHandle(
|
||||
RetailWindowManager owner,
|
||||
string name,
|
||||
UiElement outerFrame,
|
||||
UiElement contentRoot,
|
||||
IRetainedPanelController? controller)
|
||||
{
|
||||
_owner = owner;
|
||||
Name = name;
|
||||
OuterFrame = outerFrame;
|
||||
ContentRoot = contentRoot;
|
||||
Controller = controller;
|
||||
_notifiedVisible = outerFrame.Visible;
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
public UiElement OuterFrame { get; }
|
||||
public UiElement ContentRoot { get; }
|
||||
public IRetainedPanelController? Controller { get; }
|
||||
public bool IsRegistered => !_disposed;
|
||||
public bool IsVisible => OuterFrame.Visible;
|
||||
public bool IsLocked => _owner.IsLocked;
|
||||
public float Left => OuterFrame.Left;
|
||||
public float Top => OuterFrame.Top;
|
||||
public float Width => OuterFrame.Width;
|
||||
public float Height => OuterFrame.Height;
|
||||
public float Opacity => OuterFrame.Opacity;
|
||||
|
||||
public event Action<RetailWindowHandle>? Shown;
|
||||
public event Action<RetailWindowHandle>? Hidden;
|
||||
public event Action<RetailWindowHandle>? Moved;
|
||||
public event Action<RetailWindowHandle>? Resized;
|
||||
public event Action<RetailWindowHandle>? Closed;
|
||||
public event Action<RetailWindowHandle, bool>? LockChanged;
|
||||
public event Action<RetailWindowHandle, UiElement?>? DescendantFocusChanged;
|
||||
public event Action<RetailWindowHandle, UiElement?>? DescendantCaptureChanged;
|
||||
|
||||
public bool Show() => IsRegistered && _owner.Show(Name);
|
||||
public bool Hide() => IsRegistered && _owner.Hide(Name);
|
||||
public bool Close() => IsRegistered && _owner.Close(Name);
|
||||
public bool MoveTo(float left, float top)
|
||||
=> IsRegistered && _owner.MoveTo(Name, left, top);
|
||||
public bool ResizeTo(float width, float height)
|
||||
=> IsRegistered && _owner.ResizeTo(Name, width, height);
|
||||
public bool SetOpacity(float opacity)
|
||||
=> IsRegistered && _owner.SetOpacity(Name, opacity);
|
||||
|
||||
internal void NotifyInitialState()
|
||||
{
|
||||
if (_notifiedVisible)
|
||||
Controller?.OnShown();
|
||||
}
|
||||
|
||||
internal void NotifyVisibility(bool visible)
|
||||
{
|
||||
if (_notifiedVisible == visible) return;
|
||||
_notifiedVisible = visible;
|
||||
|
||||
if (visible)
|
||||
{
|
||||
_closedSinceShown = false;
|
||||
Controller?.OnShown();
|
||||
Shown?.Invoke(this);
|
||||
}
|
||||
else
|
||||
{
|
||||
Controller?.OnHidden();
|
||||
Hidden?.Invoke(this);
|
||||
}
|
||||
}
|
||||
|
||||
internal void NotifyMoved() => Moved?.Invoke(this);
|
||||
internal void NotifyResized() => Resized?.Invoke(this);
|
||||
|
||||
internal void NotifyClosed()
|
||||
{
|
||||
if (_closedSinceShown) return;
|
||||
_closedSinceShown = true;
|
||||
Closed?.Invoke(this);
|
||||
}
|
||||
|
||||
internal void NotifyLockChanged(bool locked) => LockChanged?.Invoke(this, locked);
|
||||
|
||||
internal void NotifyDescendantFocusChanged(UiElement? focusedDescendant)
|
||||
{
|
||||
Controller?.OnDescendantFocusChanged(focusedDescendant);
|
||||
DescendantFocusChanged?.Invoke(this, focusedDescendant);
|
||||
}
|
||||
|
||||
internal void NotifyDescendantCaptureChanged(UiElement? capturedDescendant)
|
||||
=> DescendantCaptureChanged?.Invoke(this, capturedDescendant);
|
||||
|
||||
internal void DisposeController()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
Controller?.Dispose();
|
||||
}
|
||||
}
|
||||
289
src/AcDream.App/UI/RetailWindowManager.cs
Normal file
289
src/AcDream.App/UI/RetailWindowManager.cs
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Owns the named retained-window registry, raise policy, lifecycle delivery,
|
||||
/// and controller teardown. <see cref="UiRoot"/> remains responsible for raw
|
||||
/// input ownership and reports completed interactions to this manager.
|
||||
/// </summary>
|
||||
public sealed class RetailWindowManager : IDisposable
|
||||
{
|
||||
private readonly UiRoot _root;
|
||||
private readonly Dictionary<string, RetailWindowHandle> _byName =
|
||||
new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<UiElement, RetailWindowHandle> _byFrame = new();
|
||||
private readonly Dictionary<RetailWindowHandle, UiElement> _defaultInputs = new();
|
||||
private bool _disposed;
|
||||
|
||||
internal RetailWindowManager(UiRoot root)
|
||||
{
|
||||
_root = root;
|
||||
_root.ElementVisibilityChanged += OnElementVisibilityChanged;
|
||||
_root.KeyboardFocusChanged += OnKeyboardFocusChanged;
|
||||
_root.PointerCaptureChanged += OnPointerCaptureChanged;
|
||||
_root.WindowMoved += OnWindowMoved;
|
||||
_root.WindowResized += OnWindowResized;
|
||||
_root.UiLockChanged += OnUiLockChanged;
|
||||
}
|
||||
|
||||
public bool IsLocked => _root.UiLocked;
|
||||
public IReadOnlyCollection<RetailWindowHandle> Windows => _byName.Values;
|
||||
|
||||
public RetailWindowHandle Register(
|
||||
string name,
|
||||
UiElement outerFrame,
|
||||
UiElement? contentRoot = null,
|
||||
IRetainedPanelController? controller = null)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||
ArgumentNullException.ThrowIfNull(outerFrame);
|
||||
|
||||
if (!ReferenceEquals(outerFrame.Parent, _root))
|
||||
throw new InvalidOperationException(
|
||||
$"Retained window '{name}' must be mounted as a direct UiRoot child before registration.");
|
||||
|
||||
if (_byName.TryGetValue(name, out var sameName))
|
||||
{
|
||||
if (ReferenceEquals(sameName.OuterFrame, outerFrame)
|
||||
&& ReferenceEquals(sameName.ContentRoot, contentRoot ?? outerFrame)
|
||||
&& ReferenceEquals(sameName.Controller, controller))
|
||||
return sameName;
|
||||
|
||||
Unregister(name);
|
||||
}
|
||||
|
||||
if (_byFrame.TryGetValue(outerFrame, out var sameFrame))
|
||||
Unregister(sameFrame.Name);
|
||||
|
||||
var handle = new RetailWindowHandle(
|
||||
this,
|
||||
name,
|
||||
outerFrame,
|
||||
contentRoot ?? outerFrame,
|
||||
controller);
|
||||
_byName.Add(name, handle);
|
||||
_byFrame.Add(outerFrame, handle);
|
||||
handle.NotifyInitialState();
|
||||
return handle;
|
||||
}
|
||||
|
||||
public bool TryGet(string name, out RetailWindowHandle handle)
|
||||
=> _byName.TryGetValue(name, out handle!);
|
||||
|
||||
internal bool TryGet(UiElement outerFrame, out RetailWindowHandle handle)
|
||||
=> _byFrame.TryGetValue(outerFrame, out handle!);
|
||||
|
||||
public bool Show(string name)
|
||||
{
|
||||
if (!_byName.TryGetValue(name, out var handle)) return false;
|
||||
handle.OuterFrame.Visible = true;
|
||||
BringToFront(handle.OuterFrame);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool Hide(string name)
|
||||
{
|
||||
if (!_byName.TryGetValue(name, out var handle)) return false;
|
||||
handle.OuterFrame.Visible = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool Toggle(string name)
|
||||
{
|
||||
if (!_byName.TryGetValue(name, out var handle)) return false;
|
||||
if (handle.OuterFrame.Visible)
|
||||
{
|
||||
Hide(name);
|
||||
return false;
|
||||
}
|
||||
|
||||
Show(name);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool Close(string name)
|
||||
{
|
||||
if (!_byName.TryGetValue(name, out var handle)) return false;
|
||||
Hide(name);
|
||||
handle.NotifyClosed();
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool IsVisible(string name)
|
||||
=> _byName.TryGetValue(name, out var handle) && handle.OuterFrame.Visible;
|
||||
|
||||
public bool MoveTo(string name, float left, float top)
|
||||
{
|
||||
if (!_byName.TryGetValue(name, out var handle)) return false;
|
||||
var frame = handle.OuterFrame;
|
||||
if (frame.ConstrainDragToParent && frame.Parent is { } parent)
|
||||
{
|
||||
left = Math.Clamp(left, 0f, Math.Max(0f, parent.Width - frame.Width));
|
||||
top = Math.Clamp(top, 0f, Math.Max(0f, parent.Height - frame.Height));
|
||||
}
|
||||
if (frame.Left == left && frame.Top == top) return true;
|
||||
frame.Left = left;
|
||||
frame.Top = top;
|
||||
_root.NotifyWindowMoved(frame);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool ResizeTo(string name, float width, float height)
|
||||
{
|
||||
if (!_byName.TryGetValue(name, out var handle)) return false;
|
||||
var frame = handle.OuterFrame;
|
||||
if (!frame.ResizeX) width = frame.Width;
|
||||
if (!frame.ResizeY) height = frame.Height;
|
||||
width = Math.Clamp(width, frame.MinWidth, float.MaxValue);
|
||||
height = Math.Clamp(height, frame.MinHeight, frame.MaxHeight);
|
||||
if (frame.Width == width && frame.Height == height) return true;
|
||||
frame.Width = width;
|
||||
frame.Height = height;
|
||||
_root.NotifyWindowResized(frame);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool SetOpacity(string name, float opacity)
|
||||
{
|
||||
if (!_byName.TryGetValue(name, out var handle)) return false;
|
||||
handle.OuterFrame.Opacity = Math.Clamp(opacity, 0f, 1f);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void SetLocked(bool locked) => _root.UiLocked = locked;
|
||||
|
||||
public bool Unregister(string name)
|
||||
{
|
||||
if (!_byName.TryGetValue(name, out var handle)) return false;
|
||||
|
||||
if (handle.OuterFrame.Visible)
|
||||
handle.OuterFrame.Visible = false;
|
||||
else
|
||||
_root.ClearSubtreeOwnership(handle.OuterFrame);
|
||||
|
||||
_byName.Remove(name);
|
||||
_byFrame.Remove(handle.OuterFrame);
|
||||
_defaultInputs.Remove(handle);
|
||||
handle.NotifyClosed();
|
||||
handle.DisposeController();
|
||||
return true;
|
||||
}
|
||||
|
||||
public void BringToFront(UiElement window)
|
||||
{
|
||||
int top = window.ZOrder;
|
||||
foreach (var child in _root.Children)
|
||||
if (!ReferenceEquals(child, window))
|
||||
top = Math.Max(top, child.ZOrder + 1);
|
||||
window.ZOrder = top;
|
||||
}
|
||||
|
||||
internal void PrepareToHide(UiElement subtree)
|
||||
{
|
||||
if (!_byFrame.TryGetValue(subtree, out var handle)) return;
|
||||
if (_root.DefaultTextInput is { } input && IsWithin(input, subtree))
|
||||
_defaultInputs[handle] = input;
|
||||
}
|
||||
|
||||
internal void OnSubtreeRemoving(UiElement subtree)
|
||||
{
|
||||
foreach (var handle in _byName.Values
|
||||
.Where(handle => IsWithin(handle.OuterFrame, subtree))
|
||||
.ToArray())
|
||||
{
|
||||
handle.NotifyVisibility(false);
|
||||
_byName.Remove(handle.Name);
|
||||
_byFrame.Remove(handle.OuterFrame);
|
||||
_defaultInputs.Remove(handle);
|
||||
handle.NotifyClosed();
|
||||
handle.DisposeController();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnElementVisibilityChanged(UiElement element, bool visible)
|
||||
{
|
||||
if (!_byFrame.TryGetValue(element, out var handle)) return;
|
||||
|
||||
if (visible
|
||||
&& _root.DefaultTextInput is null
|
||||
&& _defaultInputs.TryGetValue(handle, out var input)
|
||||
&& IsWithin(input, handle.OuterFrame))
|
||||
{
|
||||
_root.DefaultTextInput = input;
|
||||
}
|
||||
|
||||
handle.NotifyVisibility(visible);
|
||||
}
|
||||
|
||||
private void OnKeyboardFocusChanged(UiElement? oldFocus, UiElement? newFocus)
|
||||
{
|
||||
foreach (var handle in _byName.Values.ToArray())
|
||||
{
|
||||
bool hadFocus = IsWithin(oldFocus, handle.OuterFrame);
|
||||
bool hasFocus = IsWithin(newFocus, handle.OuterFrame);
|
||||
if (hadFocus || hasFocus)
|
||||
handle.NotifyDescendantFocusChanged(hasFocus ? newFocus : null);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPointerCaptureChanged(UiElement? oldCapture, UiElement? newCapture)
|
||||
{
|
||||
foreach (var handle in _byName.Values.ToArray())
|
||||
{
|
||||
bool hadCapture = IsWithin(oldCapture, handle.OuterFrame);
|
||||
bool hasCapture = IsWithin(newCapture, handle.OuterFrame);
|
||||
if (hadCapture || hasCapture)
|
||||
handle.NotifyDescendantCaptureChanged(hasCapture ? newCapture : null);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnWindowMoved(string name, UiElement window)
|
||||
{
|
||||
if (_byName.TryGetValue(name, out var handle)
|
||||
&& ReferenceEquals(handle.OuterFrame, window))
|
||||
handle.NotifyMoved();
|
||||
}
|
||||
|
||||
private void OnWindowResized(string name, UiElement window)
|
||||
{
|
||||
if (_byName.TryGetValue(name, out var handle)
|
||||
&& ReferenceEquals(handle.OuterFrame, window))
|
||||
handle.NotifyResized();
|
||||
}
|
||||
|
||||
private void OnUiLockChanged(bool locked)
|
||||
{
|
||||
foreach (var handle in _byName.Values.ToArray())
|
||||
handle.NotifyLockChanged(locked);
|
||||
}
|
||||
|
||||
private static bool IsWithin(UiElement? element, UiElement subtree)
|
||||
{
|
||||
while (element is not null)
|
||||
{
|
||||
if (ReferenceEquals(element, subtree)) return true;
|
||||
element = element.Parent;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
foreach (string name in _byName.Keys.ToArray())
|
||||
Unregister(name);
|
||||
|
||||
_root.ElementVisibilityChanged -= OnElementVisibilityChanged;
|
||||
_root.KeyboardFocusChanged -= OnKeyboardFocusChanged;
|
||||
_root.PointerCaptureChanged -= OnPointerCaptureChanged;
|
||||
_root.WindowMoved -= OnWindowMoved;
|
||||
_root.WindowResized -= OnWindowResized;
|
||||
_root.UiLockChanged -= OnUiLockChanged;
|
||||
}
|
||||
}
|
||||
|
|
@ -120,7 +120,25 @@ public abstract class UiElement
|
|||
}
|
||||
|
||||
// ── State flags ─────────────────────────────────────────────────────
|
||||
public bool Visible { get; set; } = true;
|
||||
private bool _visible = true;
|
||||
public bool Visible
|
||||
{
|
||||
get => _visible;
|
||||
set
|
||||
{
|
||||
if (_visible == value) return;
|
||||
|
||||
// A top-level visibility transition is also an ownership boundary:
|
||||
// focus/capture/modal state must be released before the subtree becomes
|
||||
// unreachable to input. UiRoot filters these notifications through the
|
||||
// registered-window manager, while ordinary child visibility changes
|
||||
// remain cheap and behavior-neutral.
|
||||
UiRoot? root = FindRoot();
|
||||
root?.OnElementVisibilityChanging(this, value);
|
||||
_visible = value;
|
||||
root?.OnElementVisibilityChanged(this, value);
|
||||
}
|
||||
}
|
||||
private bool _enabled = true;
|
||||
public bool Enabled
|
||||
{
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ namespace AcDream.App.UI;
|
|||
public sealed class UiHost : System.IDisposable
|
||||
{
|
||||
public UiRoot Root { get; } = new();
|
||||
public RetailWindowManager WindowManager => Root.WindowManager;
|
||||
public TextRenderer TextRenderer { get; }
|
||||
public BitmapFont? DefaultFont { get; set; }
|
||||
|
||||
|
|
@ -106,7 +107,14 @@ public sealed class UiHost : System.IDisposable
|
|||
// ── Window manager forwarders (delegate to UiRoot) ─────────────────
|
||||
|
||||
/// <summary>Register a top-level window for Show/Hide/Toggle. See <see cref="UiRoot.RegisterWindow"/>.</summary>
|
||||
public void RegisterWindow(string name, UiElement window) => Root.RegisterWindow(name, window);
|
||||
public RetailWindowHandle RegisterWindow(
|
||||
string name,
|
||||
UiElement window,
|
||||
UiElement? contentRoot = null,
|
||||
IRetainedPanelController? controller = null)
|
||||
=> Root.RegisterWindow(name, window, contentRoot, controller);
|
||||
|
||||
public bool UnregisterWindow(string name) => Root.UnregisterWindow(name);
|
||||
|
||||
/// <summary>Show a registered window; returns false if the name is unknown.</summary>
|
||||
public bool ShowWindow(string name) => Root.ShowWindow(name);
|
||||
|
|
@ -114,6 +122,8 @@ public sealed class UiHost : System.IDisposable
|
|||
/// <summary>Hide a registered window; returns false if the name is unknown.</summary>
|
||||
public bool HideWindow(string name) => Root.HideWindow(name);
|
||||
|
||||
public bool CloseWindow(string name) => Root.CloseWindow(name);
|
||||
|
||||
/// <summary>Return the current visibility of a registered window.</summary>
|
||||
public bool IsWindowVisible(string name) => Root.IsWindowVisible(name);
|
||||
|
||||
|
|
@ -122,6 +132,7 @@ public sealed class UiHost : System.IDisposable
|
|||
|
||||
public void Dispose()
|
||||
{
|
||||
WindowManager.Dispose();
|
||||
TextRenderer.Dispose();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
|
@ -25,6 +24,14 @@ public enum ResizeEdges { None = 0, Left = 1, Right = 2, Top = 4, Bottom = 8 }
|
|||
/// </summary>
|
||||
public sealed class UiRoot : UiElement
|
||||
{
|
||||
public UiRoot()
|
||||
{
|
||||
WindowManager = new RetailWindowManager(this);
|
||||
}
|
||||
|
||||
/// <summary>Single owner for named retained-window lifecycle and raise policy.</summary>
|
||||
public RetailWindowManager WindowManager { get; }
|
||||
|
||||
// ── Device-level state ───────────────────────────────────────────────
|
||||
public int MouseX { get; private set; }
|
||||
public int MouseY { get; private set; }
|
||||
|
|
@ -61,7 +68,17 @@ public sealed class UiRoot : UiElement
|
|||
|
||||
/// <summary>Retail PlayerModule::LockUI gate. Blocks all retained-window
|
||||
/// move/resize interactions without disabling their buttons or content.</summary>
|
||||
public bool UiLocked { get; set; }
|
||||
private bool _uiLocked;
|
||||
public bool UiLocked
|
||||
{
|
||||
get => _uiLocked;
|
||||
set
|
||||
{
|
||||
if (_uiLocked == value) return;
|
||||
_uiLocked = value;
|
||||
UiLockChanged?.Invoke(value);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Current drag source (set between drag-begin and drop/cancel).</summary>
|
||||
public UiElement? DragSource { get; private set; }
|
||||
|
|
@ -141,6 +158,21 @@ public sealed class UiRoot : UiElement
|
|||
/// <summary>Raised after a registered top-level window finishes moving.</summary>
|
||||
public event Action<string, UiElement>? WindowMoved;
|
||||
|
||||
/// <summary>Raised after a registered top-level window finishes resizing.</summary>
|
||||
public event Action<string, UiElement>? WindowResized;
|
||||
|
||||
/// <summary>Raised after any attached element changes visibility.</summary>
|
||||
public event Action<UiElement, bool>? ElementVisibilityChanged;
|
||||
|
||||
/// <summary>Raised after keyboard focus changes; arguments are old/new.</summary>
|
||||
public event Action<UiElement?, UiElement?>? KeyboardFocusChanged;
|
||||
|
||||
/// <summary>Raised after pointer capture changes; arguments are old/new.</summary>
|
||||
public event Action<UiElement?, UiElement?>? PointerCaptureChanged;
|
||||
|
||||
/// <summary>Raised after the global retained-UI lock changes.</summary>
|
||||
public event Action<bool>? UiLockChanged;
|
||||
|
||||
private uint _nextEventId = 0x10000001u;
|
||||
|
||||
public override void AddChild(UiElement child)
|
||||
|
|
@ -171,6 +203,22 @@ public sealed class UiRoot : UiElement
|
|||
}
|
||||
|
||||
internal void OnSubtreeRemoving(UiElement subtree)
|
||||
{
|
||||
ClearSubtreeOwnership(subtree);
|
||||
WindowManager.OnSubtreeRemoving(subtree);
|
||||
}
|
||||
|
||||
internal void OnElementVisibilityChanging(UiElement element, bool visible)
|
||||
{
|
||||
if (visible) return;
|
||||
WindowManager.PrepareToHide(element);
|
||||
ClearSubtreeOwnership(element);
|
||||
}
|
||||
|
||||
internal void OnElementVisibilityChanged(UiElement element, bool visible)
|
||||
=> ElementVisibilityChanged?.Invoke(element, visible);
|
||||
|
||||
internal void ClearSubtreeOwnership(UiElement subtree)
|
||||
{
|
||||
if (IsWithinSubtree(KeyboardFocus, subtree))
|
||||
SetKeyboardFocus(null);
|
||||
|
|
@ -211,14 +259,6 @@ public sealed class UiRoot : UiElement
|
|||
_resizeTarget = null;
|
||||
_dragCandidate = false;
|
||||
}
|
||||
|
||||
foreach (string name in _windows
|
||||
.Where(pair => IsWithinSubtree(pair.Value, subtree))
|
||||
.Select(pair => pair.Key)
|
||||
.ToArray())
|
||||
{
|
||||
_windows.Remove(name);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsWithinSubtree(UiElement? element, UiElement subtree)
|
||||
|
|
@ -463,8 +503,10 @@ public sealed class UiRoot : UiElement
|
|||
|
||||
if (_resizeTarget is not null)
|
||||
{
|
||||
var resizedWindow = _resizeTarget;
|
||||
_resizeTarget = null;
|
||||
ReleaseCapture();
|
||||
NotifyWindowResized(resizedWindow);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -473,12 +515,7 @@ public sealed class UiRoot : UiElement
|
|||
var movedWindow = _windowDragTarget;
|
||||
_windowDragTarget = null;
|
||||
ReleaseCapture();
|
||||
foreach (var pair in _windows)
|
||||
if (ReferenceEquals(pair.Value, movedWindow))
|
||||
{
|
||||
WindowMoved?.Invoke(pair.Key, movedWindow);
|
||||
break;
|
||||
}
|
||||
NotifyWindowMoved(movedWindow);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -621,10 +658,11 @@ public sealed class UiRoot : UiElement
|
|||
public void SetKeyboardFocus(UiElement? e)
|
||||
{
|
||||
if (KeyboardFocus == e) return;
|
||||
if (KeyboardFocus is not null)
|
||||
UiElement? previous = KeyboardFocus;
|
||||
if (previous is not null)
|
||||
{
|
||||
var lost = new UiEvent(KeyboardFocus.EventId, KeyboardFocus, UiEventType.FocusLost);
|
||||
KeyboardFocus.OnEvent(in lost);
|
||||
var lost = new UiEvent(previous.EventId, previous, UiEventType.FocusLost);
|
||||
previous.OnEvent(in lost);
|
||||
}
|
||||
KeyboardFocus = e;
|
||||
if (e is not null)
|
||||
|
|
@ -632,68 +670,80 @@ public sealed class UiRoot : UiElement
|
|||
var gained = new UiEvent(e.EventId, e, UiEventType.FocusGained);
|
||||
e.OnEvent(in gained);
|
||||
}
|
||||
KeyboardFocusChanged?.Invoke(previous, e);
|
||||
}
|
||||
|
||||
public void SetCapture(UiElement e)
|
||||
{
|
||||
if (ReferenceEquals(Captured, e)) return;
|
||||
UiElement? previous = Captured;
|
||||
Captured = e;
|
||||
PointerCaptureChanged?.Invoke(previous, e);
|
||||
}
|
||||
|
||||
public void SetCapture(UiElement e) => Captured = e;
|
||||
public void ReleaseCapture()
|
||||
{
|
||||
UiElement? previous = Captured;
|
||||
Captured = null;
|
||||
// Retail restarts the tooltip idle deadline when capture is released.
|
||||
_hoverStartedMs = _nowMs;
|
||||
_tooltipFired = false;
|
||||
if (previous is not null)
|
||||
PointerCaptureChanged?.Invoke(previous, null);
|
||||
}
|
||||
|
||||
// ── Window manager (named top-level windows: Show / Hide / Toggle) ───
|
||||
|
||||
private readonly Dictionary<string, UiElement> _windows = new();
|
||||
// Registry state lives in RetailWindowManager; methods below are compatibility forwarders.
|
||||
|
||||
/// <summary>Register a top-level window under a name for Show/Hide/Toggle.
|
||||
/// Does NOT add it to the tree — the caller mounts via AddChild and controls
|
||||
/// initial Visible. Idempotent (re-register replaces). The dict is the source
|
||||
/// of truth — independent of UiElement.Name (init-only, not set here).</summary>
|
||||
public void RegisterWindow(string name, UiElement window) => _windows[name] = window;
|
||||
/// initial Visible. Idempotent registration returns the existing typed handle;
|
||||
/// replacement performs full lifecycle teardown of the prior registration.</summary>
|
||||
public RetailWindowHandle RegisterWindow(
|
||||
string name,
|
||||
UiElement window,
|
||||
UiElement? contentRoot = null,
|
||||
IRetainedPanelController? controller = null)
|
||||
=> WindowManager.Register(name, window, contentRoot, controller);
|
||||
|
||||
public bool UnregisterWindow(string name) => WindowManager.Unregister(name);
|
||||
|
||||
/// <summary>Make the named window visible. No-op (returns false) if unknown.</summary>
|
||||
public bool ShowWindow(string name)
|
||||
{
|
||||
if (!_windows.TryGetValue(name, out var w)) return false;
|
||||
w.Visible = true;
|
||||
BringToFront(w);
|
||||
return true;
|
||||
}
|
||||
=> WindowManager.Show(name);
|
||||
|
||||
/// <summary>Hide the named window. No-op (returns false) if unknown.</summary>
|
||||
public bool HideWindow(string name)
|
||||
{
|
||||
if (!_windows.TryGetValue(name, out var w)) return false;
|
||||
w.Visible = false;
|
||||
return true;
|
||||
}
|
||||
=> WindowManager.Hide(name);
|
||||
|
||||
public bool CloseWindow(string name) => WindowManager.Close(name);
|
||||
|
||||
/// <summary>Return the current visibility of a registered window.</summary>
|
||||
public bool IsWindowVisible(string name)
|
||||
=> _windows.TryGetValue(name, out var w) && w.Visible;
|
||||
=> WindowManager.IsVisible(name);
|
||||
|
||||
/// <summary>Flip the named window's visibility (Show if hidden, Hide if shown).
|
||||
/// Returns the new IsVisible state (false for an unknown name).</summary>
|
||||
public bool ToggleWindow(string name)
|
||||
{
|
||||
if (!_windows.TryGetValue(name, out var w)) return false;
|
||||
if (w.Visible) { HideWindow(name); return false; }
|
||||
ShowWindow(name);
|
||||
return true;
|
||||
}
|
||||
=> WindowManager.Toggle(name);
|
||||
|
||||
/// <summary>Raise a top-level window above its siblings by setting its ZOrder
|
||||
/// one past the current max among the OTHER top-level children. Used on Show
|
||||
/// and on click. Leaves ZOrder unchanged if it is the only / already-topmost child.</summary>
|
||||
public void BringToFront(UiElement window)
|
||||
=> WindowManager.BringToFront(window);
|
||||
|
||||
internal void NotifyWindowMoved(UiElement window)
|
||||
{
|
||||
int top = window.ZOrder;
|
||||
foreach (var c in Children)
|
||||
if (!ReferenceEquals(c, window))
|
||||
top = System.Math.Max(top, c.ZOrder + 1);
|
||||
window.ZOrder = top;
|
||||
if (WindowManager.TryGet(window, out var handle))
|
||||
WindowMoved?.Invoke(handle.Name, window);
|
||||
}
|
||||
|
||||
internal void NotifyWindowResized(UiElement window)
|
||||
{
|
||||
if (WindowManager.TryGet(window, out var handle))
|
||||
WindowResized?.Invoke(handle.Name, window);
|
||||
}
|
||||
|
||||
// ── Drag-drop (retail event chain 0x15 → 0x21 → 0x1C → 0x3E) ────────
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
195
tests/AcDream.App.Tests/UI/RetailWindowManagerTests.cs
Normal file
195
tests/AcDream.App.Tests/UI/RetailWindowManagerTests.cs
Normal 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++;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue