docs(D.2b-A): window manager + F12 inventory toggle design spec
Sub-phase A of the window-manager → inventory → paperdoll arc. A named- window registry on UiRoot (Show/Hide/Toggle/BringToFront) + raise-on-click + wiring the EXISTING InputAction.ToggleInventoryPanel (already F12-bound, retail-faithful) through OnInputAction, toggling a throwaway placeholder inventory window that Sub-phase B replaces with the real gmInventoryUI. Cross-check correction vs the handoff: retail's inventory key is F12, not I (I = Laugh emote per retail-default.keymap.txt:246). Reuses the existing action, so no new keybind — rebindable via the Settings panel. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a391b86a2e
commit
8f30585cb0
1 changed files with 245 additions and 0 deletions
245
docs/superpowers/specs/2026-06-20-d2b-window-manager-design.md
Normal file
245
docs/superpowers/specs/2026-06-20-d2b-window-manager-design.md
Normal file
|
|
@ -0,0 +1,245 @@
|
||||||
|
# Design — D.2b window manager (open/close + F12 inventory toggle)
|
||||||
|
|
||||||
|
**Date:** 2026-06-20
|
||||||
|
**Phase:** D.2b retail-UI arc, Sub-phase **A** (window manager) — the first of three
|
||||||
|
(A window manager → B inventory window → C paperdoll). Handoff:
|
||||||
|
`docs/research/2026-06-20-window-manager-inventory-handoff.md` §3.
|
||||||
|
**Status:** approved design, pre-implementation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Context & goal
|
||||||
|
|
||||||
|
Every retail-UI window today is **always-on at a hardcoded position**: vitals, chat, and
|
||||||
|
the toolbar are added to `_uiHost.Root` unconditionally at startup in the `_options.RetailUi`
|
||||||
|
block of `GameWindow.OnLoad` (grep `_uiHost.Root.AddChild`). `UiHost`/`UiRoot` have **no
|
||||||
|
open/close API** — `UiRoot` exposes the widget tree, input routing, focus, capture, modal,
|
||||||
|
and drag-drop, but nothing to show/hide/raise a named top-level window.
|
||||||
|
|
||||||
|
**Goal:** the minimal shared infrastructure to **open/close a top-level window** and a
|
||||||
|
keybind to **toggle the inventory window**. This is the prerequisite for Sub-phase B
|
||||||
|
(the inventory window) and C (paperdoll) — both need a window that starts hidden and is
|
||||||
|
summoned on demand.
|
||||||
|
|
||||||
|
**Non-goal:** the inventory window's *contents*. Sub-phase A toggles a throwaway
|
||||||
|
**placeholder** window; Sub-phase B replaces its body with the real `gmInventoryUI`
|
||||||
|
layout. See §9.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Scope
|
||||||
|
|
||||||
|
**In scope (A):**
|
||||||
|
- A named-window registry on `UiRoot` with `Show` / `Hide` / `Toggle` / `BringToFront`.
|
||||||
|
- Raise-on-click for top-level windows (retail-faithful window stacking).
|
||||||
|
- F12 → toggle the inventory window, via the **existing** `InputAction.ToggleInventoryPanel`.
|
||||||
|
- A minimal placeholder inventory window (default-hidden) so A is visually verifiable and
|
||||||
|
gives B a concrete mount point.
|
||||||
|
- Unit tests for the registry logic; divergence-register bookkeeping.
|
||||||
|
|
||||||
|
**Out of scope (deferred):**
|
||||||
|
- Inventory window contents, grid, sub-window mount, wire gaps, `InventoryController` → **B**.
|
||||||
|
- Paperdoll / `UiViewport` → **C**.
|
||||||
|
- Disk persistence of open-state + window positions → handoff "Plan-2".
|
||||||
|
- A toolbar inventory *button* (radar/menu buttons) — F12 only for now; the button can be a
|
||||||
|
later cheap addition once the inventory window exists.
|
||||||
|
- Spell bar — explicitly deferred by the handoff.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Architecture decision — registry on `UiRoot`
|
||||||
|
|
||||||
|
`UiRoot` already owns exactly the state a window manager manipulates: the top-level
|
||||||
|
`Children`, `ZOrder` (drives both paint order in `DrawSelfAndChildren` and hit-test order in
|
||||||
|
`HitTestTopDown`), keyboard focus, mouse capture, and the modal overlay. "Show / hide / raise
|
||||||
|
a top-level window" is the job it already does — so the registry lives there.
|
||||||
|
|
||||||
|
This mirrors retail, where the Keystone root (`DAT_00870c2c`) owns the widget tree and window
|
||||||
|
stacking. (Keystone is an external DLL with no decomp, so the manager is **toolkit-defined**,
|
||||||
|
not a byte-faithful port — see §7.)
|
||||||
|
|
||||||
|
**Alternatives considered and rejected:**
|
||||||
|
- **Dedicated `WindowManager` class wrapping `UiRoot`** — cleaner single-responsibility on
|
||||||
|
paper, but it must reach into `UiRoot.Children` / `ZOrder` to mount + raise, so it just
|
||||||
|
borrows `UiRoot`'s internals through a new seam. Ceremony for ~30 lines of logic.
|
||||||
|
- **Thin API on `UiHost`** — `UiHost` is the facade `GameWindow` talks to, but the tree and
|
||||||
|
`ZOrder` live in `UiRoot`; this would be a pure forwarding layer with no added value.
|
||||||
|
|
||||||
|
`UiHost` gets **two convenience forwarders** (`ToggleWindow`, `RegisterWindow`) that delegate
|
||||||
|
to `Root`, because `GameWindow` holds a `UiHost`, not a `UiRoot`, at the call sites.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Components & interfaces
|
||||||
|
|
||||||
|
### 4.1 `UiRoot` window registry
|
||||||
|
|
||||||
|
A private `Dictionary<string, UiElement> _windows` plus:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
/// <summary>Register a top-level window under a name for Show/Hide/Toggle.
|
||||||
|
/// Idempotent (re-register replaces). The dict is the source of truth —
|
||||||
|
/// independent of UiElement.Name, which is init-only and not reassigned here.</summary>
|
||||||
|
public void RegisterWindow(string name, UiElement window);
|
||||||
|
|
||||||
|
/// <summary>Make the named window visible and raise it to the front.
|
||||||
|
/// No-op if unknown. Returns true if a window was shown.</summary>
|
||||||
|
public bool ShowWindow(string name);
|
||||||
|
|
||||||
|
/// <summary>Hide the named window (Visible = false). No-op if unknown.</summary>
|
||||||
|
public bool HideWindow(string name);
|
||||||
|
|
||||||
|
/// <summary>Flip the named window's visibility; Show (raise) if it was hidden,
|
||||||
|
/// Hide if it was visible. Returns the new IsVisible state.</summary>
|
||||||
|
public bool ToggleWindow(string name);
|
||||||
|
|
||||||
|
/// <summary>Set element.ZOrder to one above the current max among top-level
|
||||||
|
/// children, so it paints + hit-tests above its peers.</summary>
|
||||||
|
public void BringToFront(UiElement window);
|
||||||
|
```
|
||||||
|
|
||||||
|
- `Show`/`Hide` only flip `UiElement.Visible`; `Visible` already gates Draw, Tick, and
|
||||||
|
HitTest (all early-return when false), so a hidden window is fully inert — no draw, no
|
||||||
|
input, no tooltip timer.
|
||||||
|
- `RegisterWindow` does **not** add the element to the tree — the caller mounts it with
|
||||||
|
`AddChild` (so registration and tree-membership stay independent and the caller controls
|
||||||
|
initial `Visible`). Registration only records the name→element mapping.
|
||||||
|
- `BringToFront`: `window.ZOrder = 1 + max(child.ZOrder for child in Children)`. The existing
|
||||||
|
default windows mount at `ZOrder = 0`, so an opened/clicked window rises above them.
|
||||||
|
|
||||||
|
### 4.2 Raise-on-click
|
||||||
|
|
||||||
|
In `UiRoot.OnMouseDown`, after the existing `FindWindow(target)` resolves the top-level
|
||||||
|
window under the press, call `BringToFront(window)` so the clicked window comes to the top of
|
||||||
|
the stack (retail raises clicked windows). This is the existing `FindWindow` result reused —
|
||||||
|
~3 lines, no new traversal. Applies to **any** top-level window (vitals, chat, toolbar,
|
||||||
|
inventory), not just registered ones; the registry is only for named Show/Hide/Toggle.
|
||||||
|
|
||||||
|
### 4.3 `UiHost` forwarders
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public void RegisterWindow(string name, UiElement window) => Root.RegisterWindow(name, window);
|
||||||
|
public bool ToggleWindow(string name) => Root.ToggleWindow(name);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 F12 wiring — `OnInputAction`
|
||||||
|
|
||||||
|
`InputAction.ToggleInventoryPanel` already exists, is already bound to **F12** in
|
||||||
|
`KeyBindings.RetailDefaults()` (matching `docs/research/named-retail/retail-default.keymap.txt`
|
||||||
|
line 149: `ToggleInventoryPanel [ "" [ 0 DIK_F12 ] ]`), and is already listed in the Settings
|
||||||
|
rebind UI — so it's user-rebindable with no new wiring. Today **nothing consumes it.**
|
||||||
|
|
||||||
|
Add one arm to the `switch (action)` in `GameWindow.OnInputAction` (the `InputDispatcher.Fired`
|
||||||
|
multicast subscriber; the switch begins at GameWindow.cs:11421):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
case AcDream.UI.Abstractions.Input.InputAction.ToggleInventoryPanel:
|
||||||
|
_uiHost?.ToggleWindow(WindowNames.Inventory);
|
||||||
|
break;
|
||||||
|
```
|
||||||
|
|
||||||
|
- Gating is already correct: the dispatcher is suppressed by `WantsKeyboard` when the chat
|
||||||
|
input holds focus, so F12 cannot toggle inventory while you're typing.
|
||||||
|
- `WindowNames.Inventory` is a `const string "inventory"` (a small `WindowNames` holder in
|
||||||
|
`AcDream.App.UI`), so the mount, the registry, and the toggle agree on one literal.
|
||||||
|
|
||||||
|
### 4.5 Placeholder inventory window mount
|
||||||
|
|
||||||
|
In the `_options.RetailUi` block of `GameWindow.OnLoad`, alongside the vitals/chat/toolbar
|
||||||
|
mounts, build a minimal framed window:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var inventoryWindow = new AcDream.App.UI.UiNineSlicePanel(ResolveChrome)
|
||||||
|
{
|
||||||
|
Left = 200, Top = 120, Width = 320, Height = 400,
|
||||||
|
Visible = false, // starts hidden; F12 reveals it
|
||||||
|
Draggable = true,
|
||||||
|
Anchors = AcDream.App.UI.AnchorEdges.None,
|
||||||
|
};
|
||||||
|
_uiHost.Root.AddChild(inventoryWindow);
|
||||||
|
_uiHost.RegisterWindow(AcDream.App.UI.WindowNames.Inventory, inventoryWindow);
|
||||||
|
```
|
||||||
|
|
||||||
|
This uses the same `UiNineSlicePanel(ResolveChrome)` + `RetailChromeSprites.Border` chrome
|
||||||
|
the vitals/chat windows use, so the placeholder already has the retail beveled frame. It is
|
||||||
|
**throwaway scaffolding**: Sub-phase B replaces the body (and likely the whole construction)
|
||||||
|
with the real `gmInventoryUI 0x21000023` nested layout, keeping the same registry name so the
|
||||||
|
F12 wiring is untouched.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Persistence
|
||||||
|
|
||||||
|
In-memory for the session only. Open/closed state lives in `UiElement.Visible`; window
|
||||||
|
positions are the hardcoded mount positions (already reset each session). No disk persistence
|
||||||
|
— deferred to the handoff's Plan-2.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Testing
|
||||||
|
|
||||||
|
Unit tests in `tests/AcDream.App.Tests/UI/UiRootInputTests.cs` (or a sibling
|
||||||
|
`UiRootWindowManagerTests.cs`) — the registry logic is pure (no GL), so it tests directly:
|
||||||
|
|
||||||
|
- `RegisterWindow` + `ToggleWindow` flips `Visible` false→true→false and returns the new state.
|
||||||
|
- `ShowWindow` raises `ZOrder` above all peers; `HideWindow` sets `Visible=false` without
|
||||||
|
touching `ZOrder`.
|
||||||
|
- `BringToFront` on the lowest window makes its `ZOrder` strictly greatest among siblings.
|
||||||
|
- `ToggleWindow`/`ShowWindow`/`HideWindow` on an unknown name are no-ops returning false.
|
||||||
|
- Raise-on-click: an `OnMouseDown` on a registered window with a peer in front leaves the
|
||||||
|
clicked window with the greatest `ZOrder` (reuses the existing `UiRootInputTests` harness
|
||||||
|
pattern for synthesizing mouse events).
|
||||||
|
|
||||||
|
`dotnet build` + `dotnet test` green (full suite, currently 2752+).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Divergence register
|
||||||
|
|
||||||
|
The window manager is **toolkit-defined** — retail's window stacking lives in `keystone.dll`
|
||||||
|
(no decomp), so this is the existing IA-12 / IA-15 toolkit-adaptation umbrella, not a
|
||||||
|
byte-faithful port. Confirm whether an existing register row covers
|
||||||
|
"retail-UI windows are toolkit-managed, not Keystone-ported"; if not, add a one-line row in
|
||||||
|
`docs/architecture/retail-divergence-register.md` in the implementation commit. **F12 itself
|
||||||
|
is retail-faithful** (matches the retail default keymap) — no divergence for the keybind.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Acceptance criteria
|
||||||
|
|
||||||
|
- `dotnet build` green; `dotnet test` green.
|
||||||
|
- With `ACDREAM_RETAIL_UI=1`, pressing **F12** shows the placeholder inventory window above
|
||||||
|
vitals/chat/toolbar; pressing **F12** again hides it.
|
||||||
|
- Clicking any window (vitals, chat, toolbar, inventory) raises it above the others.
|
||||||
|
- F12 does **nothing** while the chat input is focused (write mode).
|
||||||
|
- Window manager logic is unit-tested; divergence-register row confirmed/added.
|
||||||
|
- Visual verification by the user (the toggle + raise behavior).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Seam for Sub-phase B
|
||||||
|
|
||||||
|
A leaves B a clean handoff:
|
||||||
|
- The registry name `WindowNames.Inventory` and the F12 → `ToggleWindow` wiring are stable;
|
||||||
|
B keeps both.
|
||||||
|
- B replaces the placeholder `UiNineSlicePanel` with the real `gmInventoryUI 0x21000023`
|
||||||
|
nested layout (paperdoll + backpack + 3D-items), re-registering it under the same name.
|
||||||
|
- B adds the dat **close button** → `HideWindow(WindowNames.Inventory)` wiring (the manager
|
||||||
|
already exposes `HideWindow`; A leaves the placeholder closable only via F12).
|
||||||
|
- B wires the inventory cell as a drag **source**, completing B.2's drag-from-inventory via
|
||||||
|
the `SourceKind == Inventory` branch in `ToolbarController.HandleDropRelease`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. References
|
||||||
|
|
||||||
|
- Handoff: `docs/research/2026-06-20-window-manager-inventory-handoff.md` §3 (Sub-phase A).
|
||||||
|
- Retail keymap: `docs/research/named-retail/retail-default.keymap.txt` lines 139–153
|
||||||
|
(panel-toggle commands; inventory = F12) + line 246 (`Laugh` = `DIK_I`).
|
||||||
|
- Toolkit: `src/AcDream.App/UI/UiRoot.cs`, `UiElement.cs`, `UiHost.cs`,
|
||||||
|
`UiNineSlicePanel.cs`; `claude-memory/project_d2b_retail_ui.md`.
|
||||||
|
- Input pipeline: `OnInputAction` switch at `src/AcDream.App/Rendering/GameWindow.cs:11421`;
|
||||||
|
`InputAction.ToggleInventoryPanel`; `KeyBindings.cs:205`;
|
||||||
|
`claude-memory/project_input_pipeline.md`.
|
||||||
|
- Object model (for B): `claude-memory/project_object_item_model.md`,
|
||||||
|
`src/AcDream.Core/Items/ClientObjectTable.cs`.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue