refactor(ui): own retained controller lifetimes
This commit is contained in:
parent
921c388e2c
commit
5d9e98c118
21 changed files with 373 additions and 35 deletions
|
|
@ -104,6 +104,13 @@ Plugins register retained gameplay markup through the BCL-only
|
||||||
`AcDream.Plugin.Abstractions.IUiRegistry`; they do not import App or ImGui
|
`AcDream.Plugin.Abstractions.IUiRegistry`; they do not import App or ImGui
|
||||||
assemblies.
|
assemblies.
|
||||||
|
|
||||||
|
Every retained gameplay window has a typed `RetailWindowHandle` and at most one
|
||||||
|
`IRetainedPanelController` lifecycle owner. Multi-controller windows use
|
||||||
|
`RetainedPanelControllerGroup`; the manager disposes controllers exactly once in
|
||||||
|
reverse ownership order. `UiHost` removes Silk device subscriptions first, then
|
||||||
|
disposes the manager/controllers, then its GL renderer. `GameWindow.OnClosing`
|
||||||
|
tears this runtime down before session and game-state sources.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Project Structure (current + target)
|
## Project Structure (current + target)
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ accepted-divergence entries (#96, #49, #50).
|
||||||
| IA-12 | UI toolkit mirrors retail behavior from research docs, not a byte-port — keystone.dll is outside decomp coverage; observed constants embedded (drag 3 px, tooltip 1000 ms) | `src/AcDream.App/UI/README.md:3` (window mgr: `UiRoot.cs` RegisterWindow/Show/Hide/Toggle/BringToFront — F12 inventory toggle IS retail-faithful) | keystone.dll has no PDB/decomp; semantics reconstructed from the six `docs/research/retail-ui/` deep-dives, keeping retail's event-type constants so panel switch-cases transplant cleanly | Edge-case input semantics the research under-specified (drag threshold, tooltip timing, focus hand-off, capture corners) differ silently with no oracle to diff against | keystone.dll Device DAT_00837ff4; docs/research/retail-ui/04-input-events.md |
|
| IA-12 | UI toolkit mirrors retail behavior from research docs, not a byte-port — keystone.dll is outside decomp coverage; observed constants embedded (drag 3 px, tooltip 1000 ms) | `src/AcDream.App/UI/README.md:3` (window mgr: `UiRoot.cs` RegisterWindow/Show/Hide/Toggle/BringToFront — F12 inventory toggle IS retail-faithful) | keystone.dll has no PDB/decomp; semantics reconstructed from the six `docs/research/retail-ui/` deep-dives, keeping retail's event-type constants so panel switch-cases transplant cleanly | Edge-case input semantics the research under-specified (drag threshold, tooltip timing, focus hand-off, capture corners) differ silently with no oracle to diff against | keystone.dll Device DAT_00837ff4; docs/research/retail-ui/04-input-events.md |
|
||||||
| IA-13 | GameEventType registry deliberately omits event types retail ignores; unknown events fall through unhandled | `src/AcDream.Core.Net/Messages/GameEventType.cs:11` | Retail also ignores them — dropping matches retail by construction | If the "retail ignores X" judgment is wrong for any opcode (or a server mod uses one), the event is silently dropped with no diagnostic pointing at the omission | retail GameEvent dispatch (ignored-event set) |
|
| IA-13 | GameEventType registry deliberately omits event types retail ignores; unknown events fall through unhandled | `src/AcDream.Core.Net/Messages/GameEventType.cs:11` | Retail also ignores them — dropping matches retail by construction | If the "retail ignores X" judgment is wrong for any opcode (or a server mod uses one), the event is silently dropped with no diagnostic pointing at the omission | retail GameEvent dispatch (ignored-event set) |
|
||||||
| IA-14 | Rendering + dat-handling base is WorldBuilder's tested port, not a fresh retail-decomp port (Phase N.4/O design stance) | `docs/architecture/worldbuilder-inventory.md` (code at `src/AcDream.{Core,App}/Rendering/Wb/`) | WB visually verified on the AC world, MIT, same stack; known WB↔retail deltas resolved case-by-case — terrain split kept retail `FSplitNESW` (**#51**, pinned by `SplitFormulaDivergenceTest`), scenery drift accepted (AP-31) | A WB-upstream divergence not yet caught ships silently as "our" behavior; guard = the inventory doc's 🟢/🔴 split + per-formula divergence tests | retail decomp per algorithm; `tests/.../SplitFormulaDivergenceTest.cs` |
|
| IA-14 | Rendering + dat-handling base is WorldBuilder's tested port, not a fresh retail-decomp port (Phase N.4/O design stance) | `docs/architecture/worldbuilder-inventory.md` (code at `src/AcDream.{Core,App}/Rendering/Wb/`) | WB visually verified on the AC world, MIT, same stack; known WB↔retail deltas resolved case-by-case — terrain split kept retail `FSplitNESW` (**#51**, pinned by `SplitFormulaDivergenceTest`), scenery drift accepted (AP-31) | A WB-upstream divergence not yet caught ships silently as "our" behavior; guard = the inventory doc's 🟢/🔴 split + per-formula divergence tests | retail decomp per algorithm; `tests/.../SplitFormulaDivergenceTest.cs` |
|
||||||
| IA-15 | D.2b gameplay UI is our own `UiHost`/`UiRoot` retained tree, not a byte-port of Keystone. `RetailWindowManager`/typed handles centralize registry, raise, focus/capture cleanup, lifecycle events, controller teardown, and schema-v2 per-character/per-resolution layout persistence; `RetailWindowFrame` is the single production/Studio mount contract for imported-chrome and shared-wrapper windows. Production LayoutDesc imports include vitals `0x2100006C`, chat `0x21000006`, toolbar `0x21000016`, character `0x2100002E`, inventory `0x21000023` plus mounted `0x21000024/22/21`, and radar `0x21000074`. | `src/AcDream.App/UI/RetailWindowManager.cs`; `src/AcDream.App/UI/RetailWindowLayoutPersistence.cs`; `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; `src/AcDream.App/UI/Layout/LayoutImporter.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. | Persistence is behaviorally reconstructed from `saveui/loadui` semantics rather than Keystone internals; lifecycle edge cases remain constrained by conformance tests instead of a byte-port | 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 centralize registry, raise, focus/capture cleanup, lifecycle events, reverse-order grouped controller teardown, removable Silk input subscriptions, and schema-v2 per-character/per-resolution layout persistence; `RetailWindowFrame` is the single production/Studio mount contract for imported-chrome and shared-wrapper windows. Production LayoutDesc imports include vitals `0x2100006C`, chat `0x21000006`, toolbar `0x21000016`, character `0x2100002E`, inventory `0x21000023` plus mounted `0x21000024/22/21`, and radar `0x21000074`. | `src/AcDream.App/UI/RetailWindowManager.cs`; `src/AcDream.App/UI/RetainedPanelControllerGroup.cs`; `src/AcDream.App/UI/UiHost.cs`; `src/AcDream.App/UI/RetailWindowLayoutPersistence.cs`; `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; `src/AcDream.App/UI/Layout/LayoutImporter.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. | Persistence is behaviorally reconstructed from `saveui/loadui` semantics rather than Keystone internals; lifecycle edge cases remain constrained by conformance tests instead of a byte-port | Production LayoutDesc objects; `docs/research/2026-06-15-layoutdesc-format.md`; Keystone behavior notes in `docs/research/retail-ui/` |
|
||||||
| IA-17 | Toolbar chrome is toolkit-supplied through the central `RetailWindowFrame` mount (`UiCollapsibleFrame` 8-piece bevel) because LayoutDesc `0x21000016` carries no baked frame. 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/UI/Layout/RetailWindowFrame.cs`; `src/AcDream.App/UI/UiCollapsibleFrame.cs`; toolbar policy in `GameWindow.cs`; spec: `docs/superpowers/specs/2026-06-20-d2b-toolbar-collapse-design.md` | The central mount now owns wrapper geometry/registration uniformly; border-over-content prevents the row-2 right cap from poking through | The collapse stops remain a toolkit reconstruction rather than a byte-port of Keystone behavior | gmToolbarUI WM chrome (keystone.dll, no PDB); no bevel ids in LayoutDesc 0x21000016 (toolbar dump) |
|
| IA-17 | Toolbar chrome is toolkit-supplied through the central `RetailWindowFrame` mount (`UiCollapsibleFrame` 8-piece bevel) because LayoutDesc `0x21000016` carries no baked frame. 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/UI/Layout/RetailWindowFrame.cs`; `src/AcDream.App/UI/UiCollapsibleFrame.cs`; toolbar policy in `GameWindow.cs`; spec: `docs/superpowers/specs/2026-06-20-d2b-toolbar-collapse-design.md` | The central mount now owns wrapper geometry/registration uniformly; border-over-content prevents the row-2 right cap from poking through | The collapse stops remain a toolkit reconstruction rather than a byte-port of Keystone behavior | gmToolbarUI WM chrome (keystone.dll, no PDB); no bevel ids in LayoutDesc 0x21000016 (toolbar dump) |
|
||||||
| IA-18 | Effect overlay tile (enum 0x10000005) is a `ReplaceColor` SURFACE SOURCE — pure-white pixels in the composited drag icon are replaced PER-PIXEL with the same (x,y) pixel of the effect tile (the SURFACE overload `SurfaceWindow::ReplaceColor` 0x004415b0), preserving the tile's texture/gradient; the tile itself is NOT blitted as an additional layer. This IS faithful retail behavior. **Anti-regression: do NOT re-implement this as a blit layer NOR as a flat-color replace (it is a per-pixel surface copy).** | `src/AcDream.App/UI/IconComposer.cs` (`ReplaceWhiteFromSurface`) | Faithful port of `IconData::RenderIcons` @407614 → the SURFACE overload `ReplaceColor` 0x004415b0 (`dst[x,y]=src[x,y]` where `dst==white`); confirmed via clean Ghidra decompile + named decomp + visual (the Energy Crystal's blue is a gradient, 2026-06-17). | A blit-layer or flat-color re-implementation would show the wrong effect look (no gradient) — the visual-verification regression that retired the mean-color approximation | `IconData::RenderIcons` acclient_2013_pseudo_c.txt:407524; `ReplaceColor` SURFACE overload 0x004415b0:71656; `docs/research/2026-06-17-stateful-icon-RESOLVED.md` |
|
| IA-18 | Effect overlay tile (enum 0x10000005) is a `ReplaceColor` SURFACE SOURCE — pure-white pixels in the composited drag icon are replaced PER-PIXEL with the same (x,y) pixel of the effect tile (the SURFACE overload `SurfaceWindow::ReplaceColor` 0x004415b0), preserving the tile's texture/gradient; the tile itself is NOT blitted as an additional layer. This IS faithful retail behavior. **Anti-regression: do NOT re-implement this as a blit layer NOR as a flat-color replace (it is a per-pixel surface copy).** | `src/AcDream.App/UI/IconComposer.cs` (`ReplaceWhiteFromSurface`) | Faithful port of `IconData::RenderIcons` @407614 → the SURFACE overload `ReplaceColor` 0x004415b0 (`dst[x,y]=src[x,y]` where `dst==white`); confirmed via clean Ghidra decompile + named decomp + visual (the Energy Crystal's blue is a gradient, 2026-06-17). | A blit-layer or flat-color re-implementation would show the wrong effect look (no gradient) — the visual-verification regression that retired the mean-color approximation | `IconData::RenderIcons` acclient_2013_pseudo_c.txt:407524; `ReplaceColor` SURFACE overload 0x004415b0:71656; `docs/research/2026-06-17-stateful-icon-RESOLVED.md` |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -277,14 +277,20 @@ passed, 5 skipped.
|
||||||
|
|
||||||
### 2.4 Enforce controller teardown
|
### 2.4 Enforce controller teardown
|
||||||
|
|
||||||
- [ ] Apply the same `IRetainedPanelController : IDisposable` contract; allow plain
|
- [x] Apply the same `IRetainedPanelController : IDisposable` contract; allow plain
|
||||||
`IDisposable` only for controllers that genuinely have no visibility/focus reaction.
|
`IDisposable` only for controllers that genuinely have no visibility/focus reaction.
|
||||||
- [ ] Convert inline event lambdas to removable handlers/subscription tokens.
|
- [x] Convert inline event lambdas to removable handlers/subscription tokens.
|
||||||
- [ ] Dispose controllers on runtime shutdown/rebind and test that relog/recreate
|
- [x] Dispose controllers on runtime shutdown/rebind and test that relog/recreate
|
||||||
does not double-subscribe.
|
does not double-subscribe.
|
||||||
- [ ] Dispose controller subscriptions before session/data sources and before the
|
- [x] Dispose controller subscriptions before session/data sources and before the
|
||||||
UI host; make every disposal path idempotent.
|
UI host; make every disposal path idempotent.
|
||||||
|
|
||||||
|
**Acceptance (2026-07-10):** every subscribing retained controller is owned by its
|
||||||
|
typed window handle (toolbar/inventory use reverse-order controller groups), late
|
||||||
|
attachment is lifecycle-safe, Silk input handlers are removable, and shutdown
|
||||||
|
disposes the retained runtime before session sources. Recreate/disposal regression
|
||||||
|
tests pass; full suite green: 4,549 passed, 5 skipped.
|
||||||
|
|
||||||
### 2.5 Extract the composition block
|
### 2.5 Extract the composition block
|
||||||
|
|
||||||
- [ ] Create `RetailUiRuntime` and focused binding records.
|
- [ ] Create `RetailUiRuntime` and focused binding records.
|
||||||
|
|
|
||||||
|
|
@ -93,3 +93,19 @@ compass/coordinates, `RadarSnapshotProvider` joins the existing `WorldEntity` +
|
||||||
and `UiRadar` only draws/handles retained interaction. `GameWindow` contains mount
|
and `UiRadar` only draws/handles retained interaction. `GameWindow` contains mount
|
||||||
and delegate wiring, not the feature body. See
|
and delegate wiring, not the feature body. See
|
||||||
`docs/research/2026-07-10-retail-radar-pseudocode.md`.
|
`docs/research/2026-07-10-retail-radar-pseudocode.md`.
|
||||||
|
|
||||||
|
## 2026-07-10 retained window ownership
|
||||||
|
|
||||||
|
Every production retained window is registered through `RetailWindowManager` and
|
||||||
|
owned by a typed `RetailWindowHandle`. Controllers implement
|
||||||
|
`IRetainedPanelController`; windows with multiple focused controllers use
|
||||||
|
`RetainedPanelControllerGroup`, which forwards lifecycle in construction order and
|
||||||
|
disposes in reverse order exactly once. A controller created after a hidden mount
|
||||||
|
(inventory) is attached through `RetailWindowManager.AttachController`.
|
||||||
|
|
||||||
|
Controller event subscriptions must use removable named handlers or paired
|
||||||
|
subscribe/unsubscribe delegates. `UiHost.Dispose` first removes Silk mouse/keyboard
|
||||||
|
subscriptions, then disposes the manager/controllers, then the GL text renderer.
|
||||||
|
`GameWindow.OnClosing` disposes layout persistence and `UiHost` before live session
|
||||||
|
or game-state sources. This ordering is the recreate/relogin double-subscription
|
||||||
|
guard; do not reintroduce anonymous lifetime-bound event lambdas.
|
||||||
|
|
|
||||||
|
|
@ -2158,6 +2158,7 @@ public sealed class GameWindow : IDisposable
|
||||||
ResizeY = false,
|
ResizeY = false,
|
||||||
ConstrainDragToParent = true,
|
ConstrainDragToParent = true,
|
||||||
ContentClickThrough = false,
|
ContentClickThrough = false,
|
||||||
|
Controller = _radarController,
|
||||||
});
|
});
|
||||||
Console.WriteLine("[D.6] retail radar/compass from LayoutDesc 0x21000074.");
|
Console.WriteLine("[D.6] retail radar/compass from LayoutDesc 0x21000074.");
|
||||||
}
|
}
|
||||||
|
|
@ -2219,6 +2220,7 @@ public sealed class GameWindow : IDisposable
|
||||||
ResizeX = true,
|
ResizeX = true,
|
||||||
ResizeY = true,
|
ResizeY = true,
|
||||||
Opacity = 0.75f,
|
Opacity = 0.75f,
|
||||||
|
Controller = chatController,
|
||||||
StateController = chatController,
|
StateController = chatController,
|
||||||
});
|
});
|
||||||
chatController.AttachWindow(chatHandle);
|
chatController.AttachWindow(chatHandle);
|
||||||
|
|
@ -2346,7 +2348,9 @@ public sealed class GameWindow : IDisposable
|
||||||
_selectedObjectController = AcDream.App.UI.Layout.SelectedObjectController.Bind(
|
_selectedObjectController = AcDream.App.UI.Layout.SelectedObjectController.Bind(
|
||||||
toolbarLayout,
|
toolbarLayout,
|
||||||
subscribeSelectionChanged: h => SelectionChanged += h,
|
subscribeSelectionChanged: h => SelectionChanged += h,
|
||||||
|
unsubscribeSelectionChanged: h => SelectionChanged -= h,
|
||||||
subscribeHealthChanged: h => Combat.HealthChanged += h,
|
subscribeHealthChanged: h => Combat.HealthChanged += h,
|
||||||
|
unsubscribeHealthChanged: h => Combat.HealthChanged -= h,
|
||||||
isHealthTarget: IsHealthBarTarget,
|
isHealthTarget: IsHealthBarTarget,
|
||||||
name: g => Objects.Get(g)?.Name,
|
name: g => Objects.Get(g)?.Name,
|
||||||
healthPercent: g => Combat.GetHealthPercent(g),
|
healthPercent: g => Combat.GetHealthPercent(g),
|
||||||
|
|
@ -2380,6 +2384,9 @@ public sealed class GameWindow : IDisposable
|
||||||
| AcDream.App.UI.AnchorEdges.Top
|
| AcDream.App.UI.AnchorEdges.Top
|
||||||
| AcDream.App.UI.AnchorEdges.Right,
|
| AcDream.App.UI.AnchorEdges.Right,
|
||||||
ContentClickThrough = true,
|
ContentClickThrough = true,
|
||||||
|
Controller = new AcDream.App.UI.RetainedPanelControllerGroup(
|
||||||
|
_toolbarController,
|
||||||
|
_selectedObjectController),
|
||||||
});
|
});
|
||||||
var toolbarFrame = (AcDream.App.UI.UiCollapsibleFrame)toolbarHandle.OuterFrame;
|
var toolbarFrame = (AcDream.App.UI.UiCollapsibleFrame)toolbarHandle.OuterFrame;
|
||||||
|
|
||||||
|
|
@ -2578,6 +2585,12 @@ public sealed class GameWindow : IDisposable
|
||||||
datFont: vitalsDatFont,
|
datFont: vitalsDatFont,
|
||||||
itemInteraction: _itemInteractionController); // Slice 2: caption the "Slots" toggle button
|
itemInteraction: _itemInteractionController); // Slice 2: caption the "Slots" toggle button
|
||||||
|
|
||||||
|
_uiHost.WindowManager.AttachController(
|
||||||
|
AcDream.App.UI.WindowNames.Inventory,
|
||||||
|
new AcDream.App.UI.RetainedPanelControllerGroup(
|
||||||
|
_inventoryController,
|
||||||
|
_paperdollController));
|
||||||
|
|
||||||
// Slice 2: capture the doll viewport widget (Type 0xD, element 0x100001D5) + the inventory
|
// Slice 2: capture the doll viewport widget (Type 0xD, element 0x100001D5) + the inventory
|
||||||
// frame so the per-frame pre-UI hook can render the 3-D doll into the widget's texture only
|
// frame so the per-frame pre-UI hook can render the 3-D doll into the widget's texture only
|
||||||
// when the window is open and in doll-view. The renderer itself is built after the
|
// when the window is open and in doll-view. The renderer itself is built after the
|
||||||
|
|
@ -14024,6 +14037,14 @@ public sealed class GameWindow : IDisposable
|
||||||
|
|
||||||
private void OnClosing()
|
private void OnClosing()
|
||||||
{
|
{
|
||||||
|
// Retained controllers subscribe to session-owned object/selection/combat sources.
|
||||||
|
// Tear their window-manager ownership down first, while every source and GL-backed
|
||||||
|
// UI resource is still alive. UiHost disposes controllers before its renderer.
|
||||||
|
_windowLayoutPersistence?.Dispose();
|
||||||
|
_windowLayoutPersistence = null;
|
||||||
|
_uiHost?.Dispose();
|
||||||
|
_uiHost = null;
|
||||||
|
|
||||||
// Phase A.1: join the streamer worker thread before tearing down GL
|
// Phase A.1: join the streamer worker thread before tearing down GL
|
||||||
// state. The worker may still be processing a load job that references
|
// state. The worker may still be processing a load job that references
|
||||||
// _dats; Dispose cancels the token and waits up to 2s for the thread.
|
// _dats; Dispose cancels the token and waits up to 2s for the thread.
|
||||||
|
|
@ -14051,9 +14072,6 @@ public sealed class GameWindow : IDisposable
|
||||||
_paperdollViewportRenderer?.Dispose(); // Slice 2: the doll's off-screen FBO + textures
|
_paperdollViewportRenderer?.Dispose(); // Slice 2: the doll's off-screen FBO + textures
|
||||||
_particleRenderer?.Dispose();
|
_particleRenderer?.Dispose();
|
||||||
_debugLines?.Dispose();
|
_debugLines?.Dispose();
|
||||||
_windowLayoutPersistence?.Dispose();
|
|
||||||
_windowLayoutPersistence = null;
|
|
||||||
_uiHost?.Dispose();
|
|
||||||
_textRenderer?.Dispose();
|
_textRenderer?.Dispose();
|
||||||
_debugFont?.Dispose();
|
_debugFont?.Dispose();
|
||||||
_frameProfiler.Dispose(); // MP0: releases the GpuFrameTimer query ring
|
_frameProfiler.Dispose(); // MP0: releases the GpuFrameTimer query ring
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,10 @@ namespace AcDream.App.UI;
|
||||||
public interface IRetainedPanelController : IDisposable
|
public interface IRetainedPanelController : IDisposable
|
||||||
{
|
{
|
||||||
/// <summary>Called once for each hidden-to-shown transition.</summary>
|
/// <summary>Called once for each hidden-to-shown transition.</summary>
|
||||||
void OnShown();
|
void OnShown() { }
|
||||||
|
|
||||||
/// <summary>Called once for each shown-to-hidden transition.</summary>
|
/// <summary>Called once for each shown-to-hidden transition.</summary>
|
||||||
void OnHidden();
|
void OnHidden() { }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Called when keyboard focus enters, leaves, or moves within this window.
|
/// Called when keyboard focus enters, leaves, or moves within this window.
|
||||||
|
|
|
||||||
|
|
@ -24,9 +24,10 @@ namespace AcDream.App.UI.Layout;
|
||||||
/// and bound in place.
|
/// and bound in place.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ChatWindowController : IRetainedWindowStateController
|
public sealed class ChatWindowController : IRetainedWindowStateController, IRetainedPanelController
|
||||||
{
|
{
|
||||||
public const uint LayoutId = 0x21000006u;
|
public const uint LayoutId = 0x21000006u;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
// Element ids from chat LayoutDesc 0x21000006 (confirmed in Task D/G1).
|
// Element ids from chat LayoutDesc 0x21000006 (confirmed in Task D/G1).
|
||||||
private const uint RootId = 0x1000000Eu;
|
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
|
ChatKind.Combat => new(0.96f, 0.459f, 0.447f, 1f), // colorLightRed
|
||||||
_ => new(0.824f, 0.824f, 0.784f, 1f), // colorGrey (fallback)
|
_ => new(0.824f, 0.824f, 0.784f, 1f), // colorGrey (fallback)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
_disposed = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ namespace AcDream.App.UI.Layout;
|
||||||
/// Container-switching is live (click a side bag → Use 0x0036 → ViewContents 0x0196 full-replace);
|
/// Container-switching is live (click a side bag → Use 0x0036 → ViewContents 0x0196 full-replace);
|
||||||
/// drag-into-bag / wield-drop wire are later sub-phases.
|
/// drag-into-bag / wield-drop wire are later sub-phases.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class InventoryController : IItemListDragHandler
|
public sealed class InventoryController : IItemListDragHandler, IRetainedPanelController
|
||||||
{
|
{
|
||||||
// Element ids — spec §1 (dat dump of 0x21000022 / 0x21000021 + the *::PostInit binds).
|
// Element ids — spec §1 (dat dump of 0x21000022 / 0x21000021 + the *::PostInit binds).
|
||||||
public const uint ContentsGridId = 0x100001C6u; // gm3DItemsUI m_itemList ("Contents of Backpack")
|
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>? _sendNoLongerViewing;
|
||||||
private readonly Action<uint, uint, int>? _sendPutItemInContainer; // (item, container, placement)
|
private readonly Action<uint, uint, int>? _sendPutItemInContainer; // (item, container, placement)
|
||||||
private readonly ItemInteractionController? _itemInteraction;
|
private readonly ItemInteractionController? _itemInteraction;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
// ACE PropertyInt ids read by retail InqLoad (decomp 0x0058f130: InqInt(5)/InqInt(0xe6)).
|
// ACE PropertyInt ids read by retail InqLoad (decomp 0x0058f130: InqInt(5)/InqInt(0xe6)).
|
||||||
private const uint EncumbranceValProperty = 5u; // total carried burden
|
private const uint EncumbranceValProperty = 5u; // total carried burden
|
||||||
|
|
@ -528,6 +529,8 @@ public sealed class InventoryController : IItemListDragHandler
|
||||||
/// <summary>Detach event handlers (idempotent).</summary>
|
/// <summary>Detach event handlers (idempotent).</summary>
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
_disposed = true;
|
||||||
_objects.ObjectAdded -= OnObjectChanged;
|
_objects.ObjectAdded -= OnObjectChanged;
|
||||||
_objects.ObjectMoved -= OnObjectMoved;
|
_objects.ObjectMoved -= OnObjectMoved;
|
||||||
_objects.ObjectRemoved -= OnObjectChanged;
|
_objects.ObjectRemoved -= OnObjectChanged;
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ namespace AcDream.App.UI.Layout;
|
||||||
/// 175480 / 173620). Slice 1: equip slots only — no 3D doll viewport (that's Slice 2).
|
/// 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).
|
/// Unwield is handled by InventoryController (dragging an equipped item onto the pack grid).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class PaperdollController : IItemListDragHandler
|
public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelController
|
||||||
{
|
{
|
||||||
// ── Slots-toggle public surface ───────────────────────────────────────────────────────────────
|
// ── Slots-toggle public surface ───────────────────────────────────────────────────────────────
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -82,6 +82,7 @@ public sealed class PaperdollController : IItemListDragHandler
|
||||||
private readonly PaperdollViewState _viewState = new();
|
private readonly PaperdollViewState _viewState = new();
|
||||||
private readonly List<UiItemList> _armorSlots = new();
|
private readonly List<UiItemList> _armorSlots = new();
|
||||||
private UiElement? _dollViewport; // UiViewport wired in Slice 3; UiElement? keeps this task independent
|
private UiElement? _dollViewport; // UiViewport wired in Slice 3; UiElement? keeps this task independent
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
private PaperdollController(
|
private PaperdollController(
|
||||||
ImportedLayout layout, ClientObjectTable objects, Func<uint> playerGuid,
|
ImportedLayout layout, ClientObjectTable objects, Func<uint> playerGuid,
|
||||||
|
|
@ -244,6 +245,8 @@ public sealed class PaperdollController : IItemListDragHandler
|
||||||
/// <summary>Detach event handlers (idempotent).</summary>
|
/// <summary>Detach event handlers (idempotent).</summary>
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
_disposed = true;
|
||||||
_objects.ObjectAdded -= OnObjectChanged;
|
_objects.ObjectAdded -= OnObjectChanged;
|
||||||
_objects.ObjectMoved -= OnObjectMoved;
|
_objects.ObjectMoved -= OnObjectMoved;
|
||||||
_objects.ObjectRemoved -= OnObjectChanged;
|
_objects.ObjectRemoved -= OnObjectChanged;
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ namespace AcDream.App.UI.Layout;
|
||||||
/// LayoutDesc <c>0x21000074</c> tree. Like the other gm* controllers, this class only
|
/// 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.
|
/// finds children by retail id and attaches live providers; it does not recreate DAT chrome.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class RadarController
|
public sealed class RadarController : IRetainedPanelController
|
||||||
{
|
{
|
||||||
public const uint LayoutId = 0x21000074u;
|
public const uint LayoutId = 0x21000074u;
|
||||||
/// <summary>Production layout property 0x1000002D, recovered directly from the retail DAT.</summary>
|
/// <summary>Production layout property 0x1000002D, recovered directly from the retail DAT.</summary>
|
||||||
|
|
@ -39,6 +39,7 @@ public sealed class RadarController
|
||||||
private string? _lastCoordinates;
|
private string? _lastCoordinates;
|
||||||
private bool _coordinatesInitialized;
|
private bool _coordinatesInitialized;
|
||||||
private bool? _lastUiLocked;
|
private bool? _lastUiLocked;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
private RadarController(
|
private RadarController(
|
||||||
ImportedLayout layout,
|
ImportedLayout layout,
|
||||||
|
|
@ -235,6 +236,17 @@ public sealed class RadarController
|
||||||
? Array.Empty<UiText.Line>()
|
? Array.Empty<UiText.Line>()
|
||||||
: new[] { new UiText.Line(_lastCoordinates, CoordinateColor) };
|
: 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(
|
private readonly record struct CompassToken(
|
||||||
UiElement? Element,
|
UiElement? Element,
|
||||||
float Magnitude,
|
float Magnitude,
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ namespace AcDream.App.UI.Layout;
|
||||||
/// the gate only affects whether we proactively query; recorded in the divergence register.
|
/// the gate only affects whether we proactively query; recorded in the divergence register.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class SelectedObjectController
|
public sealed class SelectedObjectController : IRetainedPanelController
|
||||||
{
|
{
|
||||||
// ── Element ids (toolbar LayoutDesc 0x21000016) ─────────────────────────
|
// ── Element ids (toolbar LayoutDesc 0x21000016) ─────────────────────────
|
||||||
/// <summary>Selected-object container / field element id (retail m_pSelObjectField).</summary>
|
/// <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, bool> _hasHealth;
|
||||||
private readonly Func<uint, uint> _stackSize;
|
private readonly Func<uint, uint> _stackSize;
|
||||||
private readonly Action<uint> _sendQueryHealth;
|
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) ────────────
|
// ── Live state (read by closures on the per-frame draw path) ────────────
|
||||||
private uint? _current;
|
private uint? _current;
|
||||||
private string? _currentName;
|
private string? _currentName;
|
||||||
private double _flashRemaining; // > 0 while the selection overlay is flashing
|
private double _flashRemaining; // > 0 while the selection overlay is flashing
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
/// <summary>White label color for the name line.</summary>
|
/// <summary>White label color for the name line.</summary>
|
||||||
private static readonly Vector4 NameColor = new(1f, 1f, 1f, 1f);
|
private static readonly Vector4 NameColor = new(1f, 1f, 1f, 1f);
|
||||||
|
|
@ -94,7 +97,9 @@ public sealed class SelectedObjectController
|
||||||
private SelectedObjectController(
|
private SelectedObjectController(
|
||||||
ImportedLayout layout,
|
ImportedLayout layout,
|
||||||
Action<Action<uint?>> subscribeSelectionChanged,
|
Action<Action<uint?>> subscribeSelectionChanged,
|
||||||
|
Action<Action<uint?>> unsubscribeSelectionChanged,
|
||||||
Action<Action<uint, float>> subscribeHealthChanged,
|
Action<Action<uint, float>> subscribeHealthChanged,
|
||||||
|
Action<Action<uint, float>> unsubscribeHealthChanged,
|
||||||
Func<uint, bool> isHealthTarget,
|
Func<uint, bool> isHealthTarget,
|
||||||
Func<uint, string?> name,
|
Func<uint, string?> name,
|
||||||
Func<uint, float> healthPercent,
|
Func<uint, float> healthPercent,
|
||||||
|
|
@ -109,6 +114,8 @@ public sealed class SelectedObjectController
|
||||||
_hasHealth = hasHealth;
|
_hasHealth = hasHealth;
|
||||||
_stackSize = stackSize;
|
_stackSize = stackSize;
|
||||||
_sendQueryHealth = sendQueryHealth;
|
_sendQueryHealth = sendQueryHealth;
|
||||||
|
_unsubscribeSelectionChanged = unsubscribeSelectionChanged;
|
||||||
|
_unsubscribeHealthChanged = unsubscribeHealthChanged;
|
||||||
|
|
||||||
// Find elements — silently skip absent ones (partial/test layouts).
|
// Find elements — silently skip absent ones (partial/test layouts).
|
||||||
_name = layout.FindElement(NameId);
|
_name = layout.FindElement(NameId);
|
||||||
|
|
@ -189,7 +196,9 @@ public sealed class SelectedObjectController
|
||||||
public static SelectedObjectController Bind(
|
public static SelectedObjectController Bind(
|
||||||
ImportedLayout layout,
|
ImportedLayout layout,
|
||||||
Action<Action<uint?>> subscribeSelectionChanged,
|
Action<Action<uint?>> subscribeSelectionChanged,
|
||||||
|
Action<Action<uint?>> unsubscribeSelectionChanged,
|
||||||
Action<Action<uint, float>> subscribeHealthChanged,
|
Action<Action<uint, float>> subscribeHealthChanged,
|
||||||
|
Action<Action<uint, float>> unsubscribeHealthChanged,
|
||||||
Func<uint, bool> isHealthTarget,
|
Func<uint, bool> isHealthTarget,
|
||||||
Func<uint, string?> name,
|
Func<uint, string?> name,
|
||||||
Func<uint, float> healthPercent,
|
Func<uint, float> healthPercent,
|
||||||
|
|
@ -198,7 +207,8 @@ public sealed class SelectedObjectController
|
||||||
Action<uint> sendQueryHealth,
|
Action<uint> sendQueryHealth,
|
||||||
UiDatFont? datFont)
|
UiDatFont? datFont)
|
||||||
=> new SelectedObjectController(
|
=> new SelectedObjectController(
|
||||||
layout, subscribeSelectionChanged, subscribeHealthChanged,
|
layout, subscribeSelectionChanged, unsubscribeSelectionChanged,
|
||||||
|
subscribeHealthChanged, unsubscribeHealthChanged,
|
||||||
isHealthTarget, name, healthPercent, hasHealth, stackSize, sendQueryHealth, datFont);
|
isHealthTarget, name, healthPercent, hasHealth, stackSize, sendQueryHealth, datFont);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -268,4 +278,12 @@ public sealed class SelectedObjectController
|
||||||
{
|
{
|
||||||
_overlay?.TrySetRetailState(state);
|
_overlay?.TrySetRetailState(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
_disposed = true;
|
||||||
|
_unsubscribeSelectionChanged(OnSelectionChanged);
|
||||||
|
_unsubscribeHealthChanged(OnHealthChanged);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ namespace AcDream.App.UI.Layout;
|
||||||
/// <c>CreateObject</c> resolves a formerly-unknown guid.
|
/// <c>CreateObject</c> resolves a formerly-unknown guid.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ToolbarController : IItemListDragHandler
|
public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelController
|
||||||
{
|
{
|
||||||
// Slot element ids in slot-index order (toolbar LayoutDesc 0x21000016, pre-dump).
|
// 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).
|
// 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? _characterButton;
|
||||||
private readonly UiButton? _inventoryButton;
|
private readonly UiButton? _inventoryButton;
|
||||||
private readonly ClientObjectTable _repo;
|
private readonly ClientObjectTable _repo;
|
||||||
|
private readonly CombatState? _combatState;
|
||||||
private readonly Func<IReadOnlyList<PlayerDescriptionParser.ShortcutEntry>> _shortcuts;
|
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 Func<ItemType, uint, uint, uint, uint, uint> _iconIds; // (itemType, icon, underlay, overlay, effects) → GL tex
|
||||||
private readonly Action<uint> _useItem; // guid → fire UseObject
|
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, uint>? _sendAddShortcut; // (index, objectGuid)
|
||||||
private readonly Action<uint>? _sendRemoveShortcut; // (index)
|
private readonly Action<uint>? _sendRemoveShortcut; // (index)
|
||||||
private readonly ItemInteractionController? _itemInteraction;
|
private readonly ItemInteractionController? _itemInteraction;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
// Digit sprite DID arrays for slot labels (top row, numbers 1-9).
|
// Digit sprite DID arrays for slot labels (top row, numbers 1-9).
|
||||||
// Read from LayoutDesc 0x21000037, element 0x1000034A under composite 0x10000346.
|
// Read from LayoutDesc 0x21000037, element 0x1000034A under composite 0x10000346.
|
||||||
|
|
@ -98,6 +100,7 @@ public sealed class ToolbarController : IItemListDragHandler
|
||||||
Action<uint>? sendRemoveShortcut = null)
|
Action<uint>? sendRemoveShortcut = null)
|
||||||
{
|
{
|
||||||
_repo = repo;
|
_repo = repo;
|
||||||
|
_combatState = combatState;
|
||||||
_shortcuts = shortcuts;
|
_shortcuts = shortcuts;
|
||||||
_iconIds = iconIds;
|
_iconIds = iconIds;
|
||||||
_useItem = useItem;
|
_useItem = useItem;
|
||||||
|
|
@ -141,15 +144,21 @@ public sealed class ToolbarController : IItemListDragHandler
|
||||||
SetCombatMode(CombatMode.NonCombat);
|
SetCombatMode(CombatMode.NonCombat);
|
||||||
|
|
||||||
// Wire live combat-mode changes if a CombatState was provided.
|
// Wire live combat-mode changes if a CombatState was provided.
|
||||||
if (combatState is not null)
|
if (_combatState is not null)
|
||||||
combatState.CombatModeChanged += SetCombatMode;
|
_combatState.CombatModeChanged += SetCombatMode;
|
||||||
|
|
||||||
// D.5.4: the table now holds ALL objects (creatures, NPCs, etc.), so filter
|
// 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
|
// to our 18 shortcut guids — else every creature spawn in a busy zone
|
||||||
// needlessly re-populates the bar (gmToolbarUI::SetDelayedShortcutNum pattern).
|
// needlessly re-populates the bar (gmToolbarUI::SetDelayedShortcutNum pattern).
|
||||||
repo.ObjectAdded += o => { if (IsShortcutGuid(o.ObjectId)) Populate(); };
|
repo.ObjectAdded += OnRepositoryObjectChanged;
|
||||||
repo.ObjectUpdated += o => { if (IsShortcutGuid(o.ObjectId)) Populate(); };
|
repo.ObjectUpdated += OnRepositoryObjectChanged;
|
||||||
repo.ObjectRemoved += o => { if (IsShortcutGuid(o.ObjectId)) Populate(); };
|
repo.ObjectRemoved += OnRepositoryObjectChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnRepositoryObjectChanged(ClientObject obj)
|
||||||
|
{
|
||||||
|
if (IsShortcutGuid(obj.ObjectId))
|
||||||
|
Populate();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -409,4 +418,16 @@ public sealed class ToolbarController : IItemListDragHandler
|
||||||
}
|
}
|
||||||
Populate();
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,9 @@ synthesis + six deep-dive docs under
|
||||||
geometry events, lock propagation, and controller teardown.
|
geometry events, lock propagation, and controller teardown.
|
||||||
- `IRetainedPanelController.cs` — disposable lifecycle contract for
|
- `IRetainedPanelController.cs` — disposable lifecycle contract for
|
||||||
panel-specific show/hide and descendant-focus behavior.
|
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` —
|
- `RetailWindowLayoutPersistence.cs` / `IRetainedWindowStateController.cs` —
|
||||||
per-character/per-resolution bounds, visibility, collapse/maximize, legacy
|
per-character/per-resolution bounds, visibility, collapse/maximize, legacy
|
||||||
migration, and safe resolution-clamped restore.
|
migration, and safe resolution-clamped restore.
|
||||||
|
|
@ -45,7 +48,8 @@ synthesis + six deep-dive docs under
|
||||||
contract for imported-chrome and shared-wrapper windows.
|
contract for imported-chrome and shared-wrapper windows.
|
||||||
- `UiHost.cs` — one-shot wrapper: owns the `UiRoot`, a `TextRenderer`,
|
- `UiHost.cs` — one-shot wrapper: owns the `UiRoot`, a `TextRenderer`,
|
||||||
and a default `BitmapFont`. Provides `WireMouse` / `WireKeyboard`
|
and a default `BitmapFont`. Provides `WireMouse` / `WireKeyboard`
|
||||||
helpers for Silk.NET plumbing.
|
helpers for Silk.NET plumbing and removes those device subscriptions before
|
||||||
|
disposing the window manager/controllers and renderer.
|
||||||
|
|
||||||
## Integration pattern
|
## Integration pattern
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ public sealed class RetailWindowHandle
|
||||||
public string Name { get; }
|
public string Name { get; }
|
||||||
public UiElement OuterFrame { get; }
|
public UiElement OuterFrame { get; }
|
||||||
public UiElement ContentRoot { get; }
|
public UiElement ContentRoot { get; }
|
||||||
public IRetainedPanelController? Controller { get; }
|
public IRetainedPanelController? Controller { get; private set; }
|
||||||
public IRetainedWindowStateController? StateController { get; }
|
public IRetainedWindowStateController? StateController { get; }
|
||||||
public bool IsRegistered => !_disposed;
|
public bool IsRegistered => !_disposed;
|
||||||
public bool IsVisible => OuterFrame.Visible;
|
public bool IsVisible => OuterFrame.Visible;
|
||||||
|
|
@ -70,6 +70,19 @@ public sealed class RetailWindowHandle
|
||||||
Controller?.OnShown();
|
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)
|
internal void NotifyVisibility(bool visible)
|
||||||
{
|
{
|
||||||
if (_notifiedVisible == visible) return;
|
if (_notifiedVisible == visible) return;
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,16 @@ public sealed class RetailWindowManager : IDisposable
|
||||||
public bool TryGet(string name, out RetailWindowHandle handle)
|
public bool TryGet(string name, out RetailWindowHandle handle)
|
||||||
=> _byName.TryGetValue(name, out 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)
|
internal bool TryGet(UiElement outerFrame, out RetailWindowHandle handle)
|
||||||
=> _byFrame.TryGetValue(outerFrame, out handle!);
|
=> _byFrame.TryGetValue(outerFrame, out handle!);
|
||||||
|
|
||||||
|
|
|
||||||
52
src/AcDream.App/UI/RetainedPanelControllerGroup.cs
Normal file
52
src/AcDream.App/UI/RetainedPanelControllerGroup.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
using AcDream.App.Rendering;
|
using AcDream.App.Rendering;
|
||||||
using Silk.NET.Input;
|
using Silk.NET.Input;
|
||||||
|
|
@ -48,6 +49,8 @@ public sealed class UiHost : System.IDisposable
|
||||||
public IKeyboard? Keyboard { get; private set; }
|
public IKeyboard? Keyboard { get; private set; }
|
||||||
|
|
||||||
private long _startTicks = System.Environment.TickCount64;
|
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)
|
public UiHost(GL gl, string shaderDir, BitmapFont? defaultFont = null)
|
||||||
{
|
{
|
||||||
|
|
@ -78,22 +81,48 @@ public sealed class UiHost : System.IDisposable
|
||||||
|
|
||||||
public void WireMouse(IMouse mouse)
|
public void WireMouse(IMouse mouse)
|
||||||
{
|
{
|
||||||
mouse.MouseDown += (_, b) =>
|
System.ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
Root.OnMouseDown(MapButton(b), (int)mouse.Position.X, (int)mouse.Position.Y);
|
System.ArgumentNullException.ThrowIfNull(mouse);
|
||||||
mouse.MouseUp += (_, b) =>
|
|
||||||
Root.OnMouseUp(MapButton(b), (int)mouse.Position.X, (int)mouse.Position.Y);
|
void OnMouseDown(IMouse sender, MouseButton button) =>
|
||||||
mouse.MouseMove += (_, p) =>
|
Root.OnMouseDown(MapButton(button), (int)sender.Position.X, (int)sender.Position.Y);
|
||||||
Root.OnMouseMove((int)p.X, (int)p.Y);
|
void OnMouseUp(IMouse sender, MouseButton button) =>
|
||||||
mouse.Scroll += (_, s) =>
|
Root.OnMouseUp(MapButton(button), (int)sender.Position.X, (int)sender.Position.Y);
|
||||||
Root.OnScroll((int)s.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)
|
public void WireKeyboard(IKeyboard kb)
|
||||||
{
|
{
|
||||||
|
System.ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
|
System.ArgumentNullException.ThrowIfNull(kb);
|
||||||
Keyboard = kb; // last wired keyboard wins (one-keyboard desktop)
|
Keyboard = kb; // last wired keyboard wins (one-keyboard desktop)
|
||||||
kb.KeyDown += (_, k, _) => Root.OnKeyDown((int)k);
|
void OnKeyDown(IKeyboard sender, Key key, int scanCode) => Root.OnKeyDown((int)key);
|
||||||
kb.KeyUp += (_, k, _) => Root.OnKeyUp((int)k);
|
void OnKeyUp(IKeyboard sender, Key key, int scanCode) => Root.OnKeyUp((int)key);
|
||||||
kb.KeyChar += (_, c) => Root.OnChar(c);
|
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
|
private static UiMouseButton MapButton(MouseButton b) => b switch
|
||||||
|
|
@ -133,6 +162,12 @@ public sealed class UiHost : System.IDisposable
|
||||||
|
|
||||||
public void Dispose()
|
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();
|
WindowManager.Dispose();
|
||||||
TextRenderer.Dispose();
|
TextRenderer.Dispose();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,15 @@ public class SelectedObjectControllerTests
|
||||||
=> SelectedObjectController.Bind(
|
=> SelectedObjectController.Bind(
|
||||||
layout,
|
layout,
|
||||||
subscribeSelectionChanged: h => SelectionHandler = h,
|
subscribeSelectionChanged: h => SelectionHandler = h,
|
||||||
|
unsubscribeSelectionChanged: h =>
|
||||||
|
{
|
||||||
|
if (SelectionHandler == h) SelectionHandler = null;
|
||||||
|
},
|
||||||
subscribeHealthChanged: h => HealthHandler = h,
|
subscribeHealthChanged: h => HealthHandler = h,
|
||||||
|
unsubscribeHealthChanged: h =>
|
||||||
|
{
|
||||||
|
if (HealthHandler == h) HealthHandler = null;
|
||||||
|
},
|
||||||
isHealthTarget: g => HealthTargetMap.TryGetValue(g, out var v) && v,
|
isHealthTarget: g => HealthTargetMap.TryGetValue(g, out var v) && v,
|
||||||
name: g => NameMap.TryGetValue(g, out var v) ? v : null,
|
name: g => NameMap.TryGetValue(g, out var v) ? v : null,
|
||||||
healthPercent: g => HealthMap.TryGetValue(g, out var v) ? v : 1f,
|
healthPercent: g => HealthMap.TryGetValue(g, out var v) ? v : 1f,
|
||||||
|
|
@ -403,4 +411,23 @@ public class SelectedObjectControllerTests
|
||||||
h.FireSelection(null);
|
h.FireSelection(null);
|
||||||
Assert.Equal(0f, healthMeterEl.Fill() ?? 0f);
|
Assert.Equal(0f, healthMeterEl.Fill() ?? 0f);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Dispose_UnsubscribesBothHandlers_AndIsIdempotent()
|
||||||
|
{
|
||||||
|
var (layout, _, _, healthMeterEl) = FakeLayout();
|
||||||
|
var h = new Harness();
|
||||||
|
h.HealthTargetMap[0xAA09u] = true;
|
||||||
|
var controller = h.Bind(layout);
|
||||||
|
|
||||||
|
controller.Dispose();
|
||||||
|
controller.Dispose();
|
||||||
|
h.FireSelection(0xAA09u);
|
||||||
|
h.FireHealth(0xAA09u, 0.5f);
|
||||||
|
|
||||||
|
Assert.Null(h.SelectionHandler);
|
||||||
|
Assert.Null(h.HealthHandler);
|
||||||
|
Assert.False(healthMeterEl.Visible);
|
||||||
|
Assert.Empty(h.QueryHealthCalls);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,28 @@ public class ToolbarControllerTests
|
||||||
Assert.Equal(0x5002u, slots[Row1[2]].Cell.ItemId); // rebound on ItemAdded
|
Assert.Equal(0x5002u, slots[Row1[2]].Cell.ItemId); // rebound on ItemAdded
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Dispose_UnsubscribesDeferredRebind_AndIsIdempotent()
|
||||||
|
{
|
||||||
|
var (layout, slots, _) = FakeToolbar();
|
||||||
|
var repo = new ClientObjectTable();
|
||||||
|
var shortcuts = new List<PlayerDescriptionParser.ShortcutEntry>
|
||||||
|
{ new(Index: 2, ObjectGuid: 0x5002u, SpellId: 0, Layer: 0) };
|
||||||
|
var controller = ToolbarController.Bind(layout, repo, () => shortcuts,
|
||||||
|
iconIds: (_,_,_,_,_) => 0x88u, useItem: _ => { });
|
||||||
|
|
||||||
|
controller.Dispose();
|
||||||
|
controller.Dispose();
|
||||||
|
repo.AddOrUpdate(new ClientObject
|
||||||
|
{
|
||||||
|
ObjectId = 0x5002u,
|
||||||
|
WeenieClassId = 1u,
|
||||||
|
IconId = 0x06005678u,
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(0u, slots[Row1[2]].Cell.ItemId);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Click_emitsUseForBoundItem()
|
public void Click_emitsUseForBoundItem()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -179,6 +179,28 @@ public sealed class RetailWindowManagerTests
|
||||||
Assert.Equal(1, secondController.DisposeCount);
|
Assert.Equal(1, secondController.DisposeCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AttachController_AfterRegistration_DeliversVisibilityAndOwnsDisposal()
|
||||||
|
{
|
||||||
|
var root = new UiRoot { Width = 800, Height = 600 };
|
||||||
|
var frame = new UiPanel { Width = 100, Height = 100 };
|
||||||
|
root.AddChild(frame);
|
||||||
|
RetailWindowHandle handle = root.RegisterWindow("inventory", frame);
|
||||||
|
var controller = new RecordingController();
|
||||||
|
|
||||||
|
root.WindowManager.AttachController("inventory", controller);
|
||||||
|
|
||||||
|
Assert.Same(controller, handle.Controller);
|
||||||
|
Assert.Equal(1, controller.ShownCount);
|
||||||
|
Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
root.WindowManager.AttachController("inventory", new RecordingController()));
|
||||||
|
|
||||||
|
root.WindowManager.Dispose();
|
||||||
|
root.WindowManager.Dispose();
|
||||||
|
Assert.Equal(1, controller.HiddenCount);
|
||||||
|
Assert.Equal(1, controller.DisposeCount);
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class RecordingController : IRetainedPanelController
|
private sealed class RecordingController : IRetainedPanelController
|
||||||
{
|
{
|
||||||
public int ShownCount { get; private set; }
|
public int ShownCount { get; private set; }
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using AcDream.App.UI;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.UI;
|
||||||
|
|
||||||
|
public sealed class RetainedPanelControllerGroupTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Lifecycle_ForwardsInOwnershipOrder_AndDisposesInReverseExactlyOnce()
|
||||||
|
{
|
||||||
|
var calls = new List<string>();
|
||||||
|
var first = new RecordingController("first", calls);
|
||||||
|
var second = new RecordingController("second", calls);
|
||||||
|
var group = new RetainedPanelControllerGroup(first, second);
|
||||||
|
var focus = new UiPanel();
|
||||||
|
|
||||||
|
group.OnShown();
|
||||||
|
group.OnDescendantFocusChanged(focus);
|
||||||
|
group.OnHidden();
|
||||||
|
group.Dispose();
|
||||||
|
group.Dispose();
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
[
|
||||||
|
"first:shown", "second:shown",
|
||||||
|
"first:focus", "second:focus",
|
||||||
|
"second:hidden", "first:hidden",
|
||||||
|
"second:dispose", "first:dispose",
|
||||||
|
], calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RecordingController(
|
||||||
|
string name,
|
||||||
|
List<string> calls) : IRetainedPanelController
|
||||||
|
{
|
||||||
|
public void OnShown() => calls.Add($"{name}:shown");
|
||||||
|
public void OnHidden() => calls.Add($"{name}:hidden");
|
||||||
|
public void OnDescendantFocusChanged(UiElement? focusedDescendant)
|
||||||
|
=> calls.Add($"{name}:focus");
|
||||||
|
public void Dispose() => calls.Add($"{name}:dispose");
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue