acdream/docs/superpowers/specs/2026-06-20-d2b-window-manager-design.md
Erik 8f30585cb0 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>
2026-06-20 21:21:29 +02:00

245 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 139153
(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`.