diff --git a/docs/superpowers/specs/2026-06-20-d2b-window-manager-design.md b/docs/superpowers/specs/2026-06-20-d2b-window-manager-design.md new file mode 100644 index 00000000..201739a0 --- /dev/null +++ b/docs/superpowers/specs/2026-06-20-d2b-window-manager-design.md @@ -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 _windows` plus: + +```csharp +/// 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. +public void RegisterWindow(string name, UiElement window); + +/// Make the named window visible and raise it to the front. +/// No-op if unknown. Returns true if a window was shown. +public bool ShowWindow(string name); + +/// Hide the named window (Visible = false). No-op if unknown. +public bool HideWindow(string name); + +/// Flip the named window's visibility; Show (raise) if it was hidden, +/// Hide if it was visible. Returns the new IsVisible state. +public bool ToggleWindow(string name); + +/// Set element.ZOrder to one above the current max among top-level +/// children, so it paints + hit-tests above its peers. +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`.