refactor(ui): own retained controller lifetimes

This commit is contained in:
Erik 2026-07-10 23:35:26 +02:00
parent 921c388e2c
commit 5d9e98c118
21 changed files with 373 additions and 35 deletions

View file

@ -10,10 +10,10 @@ namespace AcDream.App.UI;
public interface IRetainedPanelController : IDisposable
{
/// <summary>Called once for each hidden-to-shown transition.</summary>
void OnShown();
void OnShown() { }
/// <summary>Called once for each shown-to-hidden transition.</summary>
void OnHidden();
void OnHidden() { }
/// <summary>
/// Called when keyboard focus enters, leaves, or moves within this window.

View file

@ -24,9 +24,10 @@ namespace AcDream.App.UI.Layout;
/// and bound in place.
/// </para>
/// </summary>
public sealed class ChatWindowController : IRetainedWindowStateController
public sealed class ChatWindowController : IRetainedWindowStateController, IRetainedPanelController
{
public const uint LayoutId = 0x21000006u;
private bool _disposed;
// Element ids from chat LayoutDesc 0x21000006 (confirmed in Task D/G1).
private const uint RootId = 0x1000000Eu;
@ -509,4 +510,10 @@ public sealed class ChatWindowController : IRetainedWindowStateController
ChatKind.Combat => new(0.96f, 0.459f, 0.447f, 1f), // colorLightRed
_ => new(0.824f, 0.824f, 0.784f, 1f), // colorGrey (fallback)
};
public void Dispose()
{
if (_disposed) return;
_disposed = true;
}
}

View file

@ -12,7 +12,7 @@ namespace AcDream.App.UI.Layout;
/// Container-switching is live (click a side bag → Use 0x0036 → ViewContents 0x0196 full-replace);
/// drag-into-bag / wield-drop wire are later sub-phases.
/// </summary>
public sealed class InventoryController : IItemListDragHandler
public sealed class InventoryController : IItemListDragHandler, IRetainedPanelController
{
// Element ids — spec §1 (dat dump of 0x21000022 / 0x21000021 + the *::PostInit binds).
public const uint ContentsGridId = 0x100001C6u; // gm3DItemsUI m_itemList ("Contents of Backpack")
@ -58,6 +58,7 @@ public sealed class InventoryController : IItemListDragHandler
private readonly Action<uint>? _sendNoLongerViewing;
private readonly Action<uint, uint, int>? _sendPutItemInContainer; // (item, container, placement)
private readonly ItemInteractionController? _itemInteraction;
private bool _disposed;
// ACE PropertyInt ids read by retail InqLoad (decomp 0x0058f130: InqInt(5)/InqInt(0xe6)).
private const uint EncumbranceValProperty = 5u; // total carried burden
@ -528,6 +529,8 @@ public sealed class InventoryController : IItemListDragHandler
/// <summary>Detach event handlers (idempotent).</summary>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_objects.ObjectAdded -= OnObjectChanged;
_objects.ObjectMoved -= OnObjectMoved;
_objects.ObjectRemoved -= OnObjectChanged;

View file

@ -12,7 +12,7 @@ namespace AcDream.App.UI.Layout;
/// 175480 / 173620). Slice 1: equip slots only — no 3D doll viewport (that's Slice 2).
/// Unwield is handled by InventoryController (dragging an equipped item onto the pack grid).
/// </summary>
public sealed class PaperdollController : IItemListDragHandler
public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelController
{
// ── Slots-toggle public surface ───────────────────────────────────────────────────────────────
/// <summary>
@ -82,6 +82,7 @@ public sealed class PaperdollController : IItemListDragHandler
private readonly PaperdollViewState _viewState = new();
private readonly List<UiItemList> _armorSlots = new();
private UiElement? _dollViewport; // UiViewport wired in Slice 3; UiElement? keeps this task independent
private bool _disposed;
private PaperdollController(
ImportedLayout layout, ClientObjectTable objects, Func<uint> playerGuid,
@ -244,6 +245,8 @@ public sealed class PaperdollController : IItemListDragHandler
/// <summary>Detach event handlers (idempotent).</summary>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_objects.ObjectAdded -= OnObjectChanged;
_objects.ObjectMoved -= OnObjectMoved;
_objects.ObjectRemoved -= OnObjectChanged;

View file

@ -11,7 +11,7 @@ namespace AcDream.App.UI.Layout;
/// LayoutDesc <c>0x21000074</c> tree. Like the other gm* controllers, this class only
/// finds children by retail id and attaches live providers; it does not recreate DAT chrome.
/// </summary>
public sealed class RadarController
public sealed class RadarController : IRetainedPanelController
{
public const uint LayoutId = 0x21000074u;
/// <summary>Production layout property 0x1000002D, recovered directly from the retail DAT.</summary>
@ -39,6 +39,7 @@ public sealed class RadarController
private string? _lastCoordinates;
private bool _coordinatesInitialized;
private bool? _lastUiLocked;
private bool _disposed;
private RadarController(
ImportedLayout layout,
@ -235,6 +236,17 @@ public sealed class RadarController
? Array.Empty<UiText.Line>()
: new[] { new UiText.Line(_lastCoordinates, CoordinateColor) };
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_radar.SnapshotProvider = null;
_radar.SelectObject = null;
_radar.HoveredObjectChanged = null;
if (_lockButton is not null)
_lockButton.OnClick = null;
}
private readonly record struct CompassToken(
UiElement? Element,
float Magnitude,

View file

@ -41,7 +41,7 @@ namespace AcDream.App.UI.Layout;
/// the gate only affects whether we proactively query; recorded in the divergence register.
/// </para>
/// </summary>
public sealed class SelectedObjectController
public sealed class SelectedObjectController : IRetainedPanelController
{
// ── Element ids (toolbar LayoutDesc 0x21000016) ─────────────────────────
/// <summary>Selected-object container / field element id (retail m_pSelObjectField).</summary>
@ -82,11 +82,14 @@ public sealed class SelectedObjectController
private readonly Func<uint, bool> _hasHealth;
private readonly Func<uint, uint> _stackSize;
private readonly Action<uint> _sendQueryHealth;
private readonly Action<Action<uint?>> _unsubscribeSelectionChanged;
private readonly Action<Action<uint, float>> _unsubscribeHealthChanged;
// ── Live state (read by closures on the per-frame draw path) ────────────
private uint? _current;
private string? _currentName;
private double _flashRemaining; // > 0 while the selection overlay is flashing
private bool _disposed;
/// <summary>White label color for the name line.</summary>
private static readonly Vector4 NameColor = new(1f, 1f, 1f, 1f);
@ -94,7 +97,9 @@ public sealed class SelectedObjectController
private SelectedObjectController(
ImportedLayout layout,
Action<Action<uint?>> subscribeSelectionChanged,
Action<Action<uint?>> unsubscribeSelectionChanged,
Action<Action<uint, float>> subscribeHealthChanged,
Action<Action<uint, float>> unsubscribeHealthChanged,
Func<uint, bool> isHealthTarget,
Func<uint, string?> name,
Func<uint, float> healthPercent,
@ -109,6 +114,8 @@ public sealed class SelectedObjectController
_hasHealth = hasHealth;
_stackSize = stackSize;
_sendQueryHealth = sendQueryHealth;
_unsubscribeSelectionChanged = unsubscribeSelectionChanged;
_unsubscribeHealthChanged = unsubscribeHealthChanged;
// Find elements — silently skip absent ones (partial/test layouts).
_name = layout.FindElement(NameId);
@ -189,7 +196,9 @@ public sealed class SelectedObjectController
public static SelectedObjectController Bind(
ImportedLayout layout,
Action<Action<uint?>> subscribeSelectionChanged,
Action<Action<uint?>> unsubscribeSelectionChanged,
Action<Action<uint, float>> subscribeHealthChanged,
Action<Action<uint, float>> unsubscribeHealthChanged,
Func<uint, bool> isHealthTarget,
Func<uint, string?> name,
Func<uint, float> healthPercent,
@ -198,7 +207,8 @@ public sealed class SelectedObjectController
Action<uint> sendQueryHealth,
UiDatFont? datFont)
=> new SelectedObjectController(
layout, subscribeSelectionChanged, subscribeHealthChanged,
layout, subscribeSelectionChanged, unsubscribeSelectionChanged,
subscribeHealthChanged, unsubscribeHealthChanged,
isHealthTarget, name, healthPercent, hasHealth, stackSize, sendQueryHealth, datFont);
/// <summary>
@ -268,4 +278,12 @@ public sealed class SelectedObjectController
{
_overlay?.TrySetRetailState(state);
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_unsubscribeSelectionChanged(OnSelectionChanged);
_unsubscribeHealthChanged(OnHealthChanged);
}
}

View file

@ -23,7 +23,7 @@ namespace AcDream.App.UI.Layout;
/// <c>CreateObject</c> resolves a formerly-unknown guid.
/// </para>
/// </summary>
public sealed class ToolbarController : IItemListDragHandler
public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelController
{
// Slot element ids in slot-index order (toolbar LayoutDesc 0x21000016, pre-dump).
// Row 1 = slots 0-8 (0x100001A7..0x100001AF), Row 2 = slots 9-17 (0x100006B7..0x100006BF).
@ -62,6 +62,7 @@ public sealed class ToolbarController : IItemListDragHandler
private readonly UiButton? _characterButton;
private readonly UiButton? _inventoryButton;
private readonly ClientObjectTable _repo;
private readonly CombatState? _combatState;
private readonly Func<IReadOnlyList<PlayerDescriptionParser.ShortcutEntry>> _shortcuts;
private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds; // (itemType, icon, underlay, overlay, effects) → GL tex
private readonly Action<uint> _useItem; // guid → fire UseObject
@ -70,6 +71,7 @@ public sealed class ToolbarController : IItemListDragHandler
private readonly Action<uint, uint>? _sendAddShortcut; // (index, objectGuid)
private readonly Action<uint>? _sendRemoveShortcut; // (index)
private readonly ItemInteractionController? _itemInteraction;
private bool _disposed;
// Digit sprite DID arrays for slot labels (top row, numbers 1-9).
// Read from LayoutDesc 0x21000037, element 0x1000034A under composite 0x10000346.
@ -98,6 +100,7 @@ public sealed class ToolbarController : IItemListDragHandler
Action<uint>? sendRemoveShortcut = null)
{
_repo = repo;
_combatState = combatState;
_shortcuts = shortcuts;
_iconIds = iconIds;
_useItem = useItem;
@ -141,15 +144,21 @@ public sealed class ToolbarController : IItemListDragHandler
SetCombatMode(CombatMode.NonCombat);
// Wire live combat-mode changes if a CombatState was provided.
if (combatState is not null)
combatState.CombatModeChanged += SetCombatMode;
if (_combatState is not null)
_combatState.CombatModeChanged += SetCombatMode;
// D.5.4: the table now holds ALL objects (creatures, NPCs, etc.), so filter
// to our 18 shortcut guids — else every creature spawn in a busy zone
// needlessly re-populates the bar (gmToolbarUI::SetDelayedShortcutNum pattern).
repo.ObjectAdded += o => { if (IsShortcutGuid(o.ObjectId)) Populate(); };
repo.ObjectUpdated += o => { if (IsShortcutGuid(o.ObjectId)) Populate(); };
repo.ObjectRemoved += o => { if (IsShortcutGuid(o.ObjectId)) Populate(); };
repo.ObjectAdded += OnRepositoryObjectChanged;
repo.ObjectUpdated += OnRepositoryObjectChanged;
repo.ObjectRemoved += OnRepositoryObjectChanged;
}
private void OnRepositoryObjectChanged(ClientObject obj)
{
if (IsShortcutGuid(obj.ObjectId))
Populate();
}
/// <summary>
@ -409,4 +418,16 @@ public sealed class ToolbarController : IItemListDragHandler
}
Populate();
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
if (_combatState is not null)
_combatState.CombatModeChanged -= SetCombatMode;
_repo.ObjectAdded -= OnRepositoryObjectChanged;
_repo.ObjectUpdated -= OnRepositoryObjectChanged;
_repo.ObjectRemoved -= OnRepositoryObjectChanged;
}
}

View file

@ -38,6 +38,9 @@ synthesis + six deep-dive docs under
geometry events, lock propagation, and controller teardown.
- `IRetainedPanelController.cs` — disposable lifecycle contract for
panel-specific show/hide and descendant-focus behavior.
- `RetainedPanelControllerGroup.cs` — one typed lifecycle owner for windows
whose imported subtree is bound by multiple focused controllers; teardown is
idempotent and runs in reverse construction order.
- `RetailWindowLayoutPersistence.cs` / `IRetainedWindowStateController.cs`
per-character/per-resolution bounds, visibility, collapse/maximize, legacy
migration, and safe resolution-clamped restore.
@ -45,7 +48,8 @@ synthesis + six deep-dive docs under
contract for imported-chrome and shared-wrapper windows.
- `UiHost.cs` — one-shot wrapper: owns the `UiRoot`, a `TextRenderer`,
and a default `BitmapFont`. Provides `WireMouse` / `WireKeyboard`
helpers for Silk.NET plumbing.
helpers for Silk.NET plumbing and removes those device subscriptions before
disposing the window manager/controllers and renderer.
## Integration pattern

View file

@ -34,7 +34,7 @@ public sealed class RetailWindowHandle
public string Name { get; }
public UiElement OuterFrame { get; }
public UiElement ContentRoot { get; }
public IRetainedPanelController? Controller { get; }
public IRetainedPanelController? Controller { get; private set; }
public IRetainedWindowStateController? StateController { get; }
public bool IsRegistered => !_disposed;
public bool IsVisible => OuterFrame.Visible;
@ -70,6 +70,19 @@ public sealed class RetailWindowHandle
Controller?.OnShown();
}
internal void AttachController(IRetainedPanelController controller)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(controller);
if (ReferenceEquals(Controller, controller)) return;
if (Controller is not null)
throw new InvalidOperationException($"Retained window '{Name}' already owns a controller.");
Controller = controller;
if (_notifiedVisible)
Controller.OnShown();
}
internal void NotifyVisibility(bool visible)
{
if (_notifiedVisible == visible) return;

View file

@ -80,6 +80,16 @@ public sealed class RetailWindowManager : IDisposable
public bool TryGet(string name, out RetailWindowHandle handle)
=> _byName.TryGetValue(name, out handle!);
public void AttachController(string name, IRetainedPanelController controller)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentException.ThrowIfNullOrWhiteSpace(name);
ArgumentNullException.ThrowIfNull(controller);
if (!_byName.TryGetValue(name, out var handle))
throw new KeyNotFoundException($"No retained window named '{name}' is registered.");
handle.AttachController(controller);
}
internal bool TryGet(UiElement outerFrame, out RetailWindowHandle handle)
=> _byFrame.TryGetValue(outerFrame, out handle!);

View file

@ -0,0 +1,52 @@
using System;
namespace AcDream.App.UI;
/// <summary>
/// Gives one retained window a single lifecycle owner when its imported subtree
/// is bound by multiple focused controllers (for example toolbar+selection or
/// inventory+paperdoll). Teardown runs in reverse construction order and is
/// idempotent.
/// </summary>
public sealed class RetainedPanelControllerGroup : IRetainedPanelController
{
private readonly IRetainedPanelController[] _controllers;
private bool _disposed;
public RetainedPanelControllerGroup(params IRetainedPanelController[] controllers)
{
ArgumentNullException.ThrowIfNull(controllers);
_controllers = (IRetainedPanelController[])controllers.Clone();
for (int i = 0; i < _controllers.Length; i++)
ArgumentNullException.ThrowIfNull(_controllers[i]);
}
public void OnShown()
{
if (_disposed) return;
for (int i = 0; i < _controllers.Length; i++)
_controllers[i].OnShown();
}
public void OnHidden()
{
if (_disposed) return;
for (int i = _controllers.Length - 1; i >= 0; i--)
_controllers[i].OnHidden();
}
public void OnDescendantFocusChanged(UiElement? focusedDescendant)
{
if (_disposed) return;
for (int i = 0; i < _controllers.Length; i++)
_controllers[i].OnDescendantFocusChanged(focusedDescendant);
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
for (int i = _controllers.Length - 1; i >= 0; i--)
_controllers[i].Dispose();
}
}

View file

@ -1,3 +1,4 @@
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering;
using Silk.NET.Input;
@ -48,6 +49,8 @@ public sealed class UiHost : System.IDisposable
public IKeyboard? Keyboard { get; private set; }
private long _startTicks = System.Environment.TickCount64;
private readonly List<System.Action> _inputUnsubscribers = new();
private bool _disposed;
public UiHost(GL gl, string shaderDir, BitmapFont? defaultFont = null)
{
@ -78,22 +81,48 @@ public sealed class UiHost : System.IDisposable
public void WireMouse(IMouse mouse)
{
mouse.MouseDown += (_, b) =>
Root.OnMouseDown(MapButton(b), (int)mouse.Position.X, (int)mouse.Position.Y);
mouse.MouseUp += (_, b) =>
Root.OnMouseUp(MapButton(b), (int)mouse.Position.X, (int)mouse.Position.Y);
mouse.MouseMove += (_, p) =>
Root.OnMouseMove((int)p.X, (int)p.Y);
mouse.Scroll += (_, s) =>
Root.OnScroll((int)s.Y);
System.ObjectDisposedException.ThrowIf(_disposed, this);
System.ArgumentNullException.ThrowIfNull(mouse);
void OnMouseDown(IMouse sender, MouseButton button) =>
Root.OnMouseDown(MapButton(button), (int)sender.Position.X, (int)sender.Position.Y);
void OnMouseUp(IMouse sender, MouseButton button) =>
Root.OnMouseUp(MapButton(button), (int)sender.Position.X, (int)sender.Position.Y);
void OnMouseMove(IMouse sender, Vector2 position) =>
Root.OnMouseMove((int)position.X, (int)position.Y);
void OnScroll(IMouse sender, ScrollWheel scroll) => Root.OnScroll((int)scroll.Y);
mouse.MouseDown += OnMouseDown;
mouse.MouseUp += OnMouseUp;
mouse.MouseMove += OnMouseMove;
mouse.Scroll += OnScroll;
_inputUnsubscribers.Add(() =>
{
mouse.MouseDown -= OnMouseDown;
mouse.MouseUp -= OnMouseUp;
mouse.MouseMove -= OnMouseMove;
mouse.Scroll -= OnScroll;
});
}
public void WireKeyboard(IKeyboard kb)
{
System.ObjectDisposedException.ThrowIf(_disposed, this);
System.ArgumentNullException.ThrowIfNull(kb);
Keyboard = kb; // last wired keyboard wins (one-keyboard desktop)
kb.KeyDown += (_, k, _) => Root.OnKeyDown((int)k);
kb.KeyUp += (_, k, _) => Root.OnKeyUp((int)k);
kb.KeyChar += (_, c) => Root.OnChar(c);
void OnKeyDown(IKeyboard sender, Key key, int scanCode) => Root.OnKeyDown((int)key);
void OnKeyUp(IKeyboard sender, Key key, int scanCode) => Root.OnKeyUp((int)key);
void OnKeyChar(IKeyboard sender, char value) => Root.OnChar(value);
kb.KeyDown += OnKeyDown;
kb.KeyUp += OnKeyUp;
kb.KeyChar += OnKeyChar;
_inputUnsubscribers.Add(() =>
{
kb.KeyDown -= OnKeyDown;
kb.KeyUp -= OnKeyUp;
kb.KeyChar -= OnKeyChar;
});
}
private static UiMouseButton MapButton(MouseButton b) => b switch
@ -133,6 +162,12 @@ public sealed class UiHost : System.IDisposable
public void Dispose()
{
if (_disposed) return;
_disposed = true;
for (int i = _inputUnsubscribers.Count - 1; i >= 0; i--)
_inputUnsubscribers[i]();
_inputUnsubscribers.Clear();
Keyboard = null;
WindowManager.Dispose();
TextRenderer.Dispose();
}