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:
Erik 2026-06-20 21:21:29 +02:00
parent a391b86a2e
commit 8f30585cb0

View 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 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`.