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

11 KiB
Raw Blame History

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 APIUiRoot 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, InventoryControllerB.
  • Paperdoll / UiViewportC.
  • 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 UiHostUiHost 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:

/// <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

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):

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:

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 buttonHideWindow(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.