acdream/docs/superpowers/plans/2026-06-20-d2b-window-manager.md
Erik 8457bf0006 docs(D.2b-A): window manager implementation plan
4-task TDD plan for Sub-phase A: UiRoot named-window registry
(Show/Hide/Toggle/BringToFront) + raise-on-click + F12 wiring of the
existing ToggleInventoryPanel action to a throwaway placeholder inventory
window that Sub-phase B replaces. Divergence handled by extending IA-12.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 21:28:50 +02:00

472 lines
19 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.

# D.2b Window Manager Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add open/close/raise for top-level retail-UI windows and toggle a (placeholder) inventory window with F12.
**Architecture:** A named-window registry on `UiRoot` (`RegisterWindow` / `ShowWindow` / `HideWindow` / `ToggleWindow` / `BringToFront`) — `UiRoot` already owns the top-level children, `ZOrder`, hit-test, and focus, so window show/hide/raise is its job. `Show`/`Hide` flip `UiElement.Visible` (which already gates Draw/Tick/HitTest); `BringToFront` bumps `ZOrder` above peers. The existing F12-bound `InputAction.ToggleInventoryPanel` is wired through `OnInputAction` to toggle a throwaway placeholder window that Sub-phase B replaces with the real `gmInventoryUI`.
**Tech Stack:** C# .NET 10, xUnit, the in-tree `AcDream.App.UI` retained-mode toolkit (`UiRoot`/`UiElement`/`UiHost`/`UiNineSlicePanel`), the `InputDispatcher` input pipeline.
**Spec:** `docs/superpowers/specs/2026-06-20-d2b-window-manager-design.md`
---
## File Structure
- **Create** `src/AcDream.App/UI/WindowNames.cs` — canonical registry-name constants (one literal shared by mount, registry, toggle).
- **Modify** `src/AcDream.App/UI/UiRoot.cs` — add the window registry + `BringToFront`; add raise-on-click in `OnMouseDown`.
- **Modify** `src/AcDream.App/UI/UiHost.cs` — two forwarders (`RegisterWindow`, `ToggleWindow`) delegating to `Root`.
- **Modify** `src/AcDream.App/Rendering/GameWindow.cs` — mount the placeholder inventory window (default-hidden) in the `_options.RetailUi` block; add the `ToggleInventoryPanel` case in `OnInputAction`.
- **Modify** `tests/AcDream.App.Tests/UI/UiRootInputTests.cs` — registry + z-order + raise-on-click unit tests.
- **Modify** `docs/architecture/retail-divergence-register.md` — extend IA-12 "Where" to cite the window manager.
---
## Task 1: `WindowNames` + `UiRoot` window registry (visibility)
**Files:**
- Create: `src/AcDream.App/UI/WindowNames.cs`
- Modify: `src/AcDream.App/UI/UiRoot.cs` (insert after `ReleaseCapture`, ~line 475)
- Test: `tests/AcDream.App.Tests/UI/UiRootInputTests.cs`
- [ ] **Step 1: Create the `WindowNames` constant holder**
Create `src/AcDream.App/UI/WindowNames.cs`:
```csharp
namespace AcDream.App.UI;
/// <summary>Canonical registry names for top-level retail-UI windows, so the
/// mount, the window registry, and the toggle keybind all agree on one literal.</summary>
public static class WindowNames
{
public const string Inventory = "inventory";
}
```
- [ ] **Step 2: Write the failing tests**
Append to `tests/AcDream.App.Tests/UI/UiRootInputTests.cs`, inside the class (before the final closing `}`):
```csharp
[Fact]
public void ToggleWindow_FlipsVisible_AndReturnsNewState()
{
var root = new UiRoot { Width = 800, Height = 600 };
var win = new UiPanel { Width = 100, Height = 100, Visible = false };
root.AddChild(win);
root.RegisterWindow("inventory", win);
Assert.True(root.ToggleWindow("inventory")); // hidden -> shown
Assert.True(win.Visible);
Assert.False(root.ToggleWindow("inventory")); // shown -> hidden
Assert.False(win.Visible);
}
[Fact]
public void ShowHideWindow_SetVisibility_UnknownNameIsNoOp()
{
var root = new UiRoot { Width = 800, Height = 600 };
var win = new UiPanel { Width = 100, Height = 100, Visible = false };
root.AddChild(win);
root.RegisterWindow("inventory", win);
Assert.True(root.ShowWindow("inventory"));
Assert.True(win.Visible);
Assert.True(root.HideWindow("inventory"));
Assert.False(win.Visible);
Assert.False(root.ShowWindow("nope"));
Assert.False(root.HideWindow("nope"));
Assert.False(root.ToggleWindow("nope"));
}
```
- [ ] **Step 3: Run the tests to verify they fail**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~UiRootInputTests"`
Expected: FAIL — compile error, `'UiRoot' does not contain a definition for 'RegisterWindow'`.
- [ ] **Step 4: Implement the registry**
In `src/AcDream.App/UI/UiRoot.cs`, find:
```csharp
public void SetCapture(UiElement e) => Captured = e;
public void ReleaseCapture() => Captured = null;
```
Insert immediately after it:
```csharp
// ── Window manager (named top-level windows: Show / Hide / Toggle) ───
private readonly Dictionary<string, UiElement> _windows = new();
/// <summary>Register a top-level window under a name for Show/Hide/Toggle.
/// Does NOT add it to the tree — the caller mounts via AddChild and controls
/// initial Visible. Idempotent (re-register replaces). The dict is the source
/// of truth — independent of UiElement.Name (init-only, not set here).</summary>
public void RegisterWindow(string name, UiElement window) => _windows[name] = window;
/// <summary>Make the named window visible. No-op (returns false) if unknown.</summary>
public bool ShowWindow(string name)
{
if (!_windows.TryGetValue(name, out var w)) return false;
w.Visible = true;
return true;
}
/// <summary>Hide the named window. No-op (returns false) if unknown.</summary>
public bool HideWindow(string name)
{
if (!_windows.TryGetValue(name, out var w)) return false;
w.Visible = false;
return true;
}
/// <summary>Flip the named window's visibility (Show if hidden, Hide if shown).
/// Returns the new IsVisible state (false for an unknown name).</summary>
public bool ToggleWindow(string name)
{
if (!_windows.TryGetValue(name, out var w)) return false;
if (w.Visible) { HideWindow(name); return false; }
ShowWindow(name);
return true;
}
```
(`using System.Collections.Generic;` is already present at the top of `UiRoot.cs`.)
- [ ] **Step 5: Run the tests to verify they pass**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~UiRootInputTests"`
Expected: PASS (all `UiRootInputTests`, including the two new ones).
- [ ] **Step 6: Commit**
```bash
git add src/AcDream.App/UI/WindowNames.cs src/AcDream.App/UI/UiRoot.cs tests/AcDream.App.Tests/UI/UiRootInputTests.cs
git commit -m "feat(ui): D.2b-A — UiRoot named-window registry (Show/Hide/Toggle)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 2: `BringToFront` + raise-on-show + raise-on-click
**Files:**
- Modify: `src/AcDream.App/UI/UiRoot.cs` (add `BringToFront`; edit `ShowWindow` + `OnMouseDown`)
- Test: `tests/AcDream.App.Tests/UI/UiRootInputTests.cs`
- [ ] **Step 1: Write the failing tests**
Append to `tests/AcDream.App.Tests/UI/UiRootInputTests.cs`, inside the class:
```csharp
[Fact]
public void ShowWindow_RaisesAbovePeers()
{
var root = new UiRoot { Width = 800, Height = 600 };
var a = new UiPanel { Width = 100, Height = 100 };
var b = new UiPanel { Width = 100, Height = 100 };
var win = new UiPanel { Width = 100, Height = 100, Visible = false };
root.AddChild(a); root.AddChild(b); root.AddChild(win);
root.RegisterWindow("inventory", win);
root.ShowWindow("inventory");
Assert.True(win.ZOrder > a.ZOrder);
Assert.True(win.ZOrder > b.ZOrder);
}
[Fact]
public void BringToFront_SetsStrictlyGreatestZOrder()
{
var root = new UiRoot { Width = 800, Height = 600 };
var a = new UiPanel { Width = 100, Height = 100, ZOrder = 5 };
var b = new UiPanel { Width = 100, Height = 100, ZOrder = 9 };
root.AddChild(a); root.AddChild(b);
root.BringToFront(a);
Assert.True(a.ZOrder > b.ZOrder); // 10 > 9
}
[Fact]
public void MouseDown_OnWindow_RaisesItAbovePeers()
{
var root = new UiRoot { Width = 800, Height = 600 };
// 'peer' has a higher ZOrder but does NOT cover (50,50); 'clicked' does.
var peer = new UiPanel { Left = 300, Top = 0, Width = 100, Height = 100, ZOrder = 9 };
var clicked = new UiPanel { Left = 0, Top = 0, Width = 100, Height = 100, ZOrder = 0, Draggable = true };
root.AddChild(peer); root.AddChild(clicked);
root.OnMouseDown(UiMouseButton.Left, 50, 50); // only 'clicked' occupies (50,50)
Assert.True(clicked.ZOrder > peer.ZOrder);
root.OnMouseUp(UiMouseButton.Left, 50, 50);
}
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~UiRootInputTests"`
Expected: FAIL — compile error, `'UiRoot' does not contain a definition for 'BringToFront'`.
- [ ] **Step 3: Add `BringToFront`**
In `src/AcDream.App/UI/UiRoot.cs`, find the end of `ToggleWindow` (added in Task 1):
```csharp
if (w.Visible) { HideWindow(name); return false; }
ShowWindow(name);
return true;
}
```
Insert immediately after it:
```csharp
/// <summary>Raise a top-level window above its siblings by setting its ZOrder
/// one past the current max among the OTHER top-level children. Used on Show
/// and on click. Leaves ZOrder unchanged if it is the only / already-topmost child.</summary>
public void BringToFront(UiElement window)
{
int top = window.ZOrder;
foreach (var c in Children)
if (!ReferenceEquals(c, window))
top = System.Math.Max(top, c.ZOrder + 1);
window.ZOrder = top;
}
```
- [ ] **Step 4: Make `ShowWindow` raise**
In `src/AcDream.App/UI/UiRoot.cs`, find (inside `ShowWindow`):
```csharp
if (!_windows.TryGetValue(name, out var w)) return false;
w.Visible = true;
return true;
```
Replace with:
```csharp
if (!_windows.TryGetValue(name, out var w)) return false;
w.Visible = true;
BringToFront(w);
return true;
```
- [ ] **Step 5: Add raise-on-click in `OnMouseDown`**
In `src/AcDream.App/UI/UiRoot.cs`, find (in `OnMouseDown`):
```csharp
var window = FindWindow(target);
if (btn == UiMouseButton.Left && window is not null)
```
Replace with:
```csharp
var window = FindWindow(target);
// Retail-faithful: pressing on a window raises it above its peers.
if (window is not null) BringToFront(window);
if (btn == UiMouseButton.Left && window is not null)
```
- [ ] **Step 6: Run the tests to verify they pass**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~UiRootInputTests"`
Expected: PASS — all `UiRootInputTests` (the new three plus the existing window-drag/resize tests, which still pass: raise-on-click only changes `ZOrder`, not geometry).
- [ ] **Step 7: Commit**
```bash
git add src/AcDream.App/UI/UiRoot.cs tests/AcDream.App.Tests/UI/UiRootInputTests.cs
git commit -m "feat(ui): D.2b-A — BringToFront + raise on show/click
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 3: `UiHost` forwarders + `GameWindow` wiring (placeholder + F12)
No unit tests — these sites are GL-coupled (`UiHost` needs a `GL`) and only run with `ACDREAM_RETAIL_UI=1`; they are verified by build + the visual check in Task 4.
**Files:**
- Modify: `src/AcDream.App/UI/UiHost.cs` (add forwarders before `Dispose`)
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (placeholder mount in the `_options.RetailUi` block; `OnInputAction` case)
- [ ] **Step 1: Add `UiHost` forwarders**
In `src/AcDream.App/UI/UiHost.cs`, find:
```csharp
public void Dispose()
{
TextRenderer.Dispose();
}
```
Insert immediately before it:
```csharp
// ── Window manager forwarders (delegate to UiRoot) ─────────────────
/// <summary>Register a top-level window for Show/Hide/Toggle. See <see cref="UiRoot.RegisterWindow"/>.</summary>
public void RegisterWindow(string name, UiElement window) => Root.RegisterWindow(name, window);
/// <summary>Toggle a registered window's visibility; returns the new IsVisible.</summary>
public bool ToggleWindow(string name) => Root.ToggleWindow(name);
```
- [ ] **Step 2: Mount the placeholder inventory window**
In `src/AcDream.App/Rendering/GameWindow.cs`, find the END of the plugin-panel drain, which is the last block inside `if (_options.RetailUi)`:
```csharp
catch (Exception ex)
{
Console.WriteLine($"[D.2b] plugin UI panel '{p.MarkupPath}' failed to load: {ex.Message}");
}
}
}
}
```
Insert the placeholder mount between the inner `}` (closing `if (_uiRegistry is not null)`) and the outer `}` (closing `if (_options.RetailUi)`) — i.e. make it the last statement inside the `_options.RetailUi` block:
```csharp
catch (Exception ex)
{
Console.WriteLine($"[D.2b] plugin UI panel '{p.MarkupPath}' failed to load: {ex.Message}");
}
}
}
// Phase D.2b-A — placeholder inventory window. Starts HIDDEN; F12
// (InputAction.ToggleInventoryPanel) reveals it via the UiRoot window
// manager. Throwaway scaffolding: Sub-phase B replaces the body with the
// real gmInventoryUI (0x21000023) nested layout, keeping the same name.
var inventoryWindow = new AcDream.App.UI.UiNineSlicePanel(ResolveChrome)
{
Left = 220, Top = 120, Width = 320, Height = 400,
Visible = false,
};
_uiHost.Root.AddChild(inventoryWindow);
_uiHost.RegisterWindow(AcDream.App.UI.WindowNames.Inventory, inventoryWindow);
Console.WriteLine("[D.2b-A] placeholder inventory window registered (F12 toggles).");
}
```
(`ResolveChrome` and `_uiHost` are both in scope here — `ResolveChrome` is the local function defined earlier in the same block; `_uiHost` is the field assigned at the top of the block.)
- [ ] **Step 3: Wire F12 → toggle in `OnInputAction`**
In `src/AcDream.App/Rendering/GameWindow.cs`, find the first arm of the `switch (action)` in `OnInputAction`:
```csharp
case AcDream.UI.Abstractions.Input.InputAction.AcdreamToggleDebugPanel:
```
Insert this case immediately before it:
```csharp
case AcDream.UI.Abstractions.Input.InputAction.ToggleInventoryPanel:
// Retail F12 (rebindable). Gated upstream by WantsKeyboard, so it
// does not fire while the chat input holds focus. Null _uiHost =
// retail UI off (ACDREAM_RETAIL_UI unset) → no-op.
_uiHost?.ToggleWindow(AcDream.App.UI.WindowNames.Inventory);
break;
```
- [ ] **Step 4: Build to verify it compiles**
Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`
Expected: Build succeeded, 0 errors.
- [ ] **Step 5: Commit**
```bash
git add src/AcDream.App/UI/UiHost.cs src/AcDream.App/Rendering/GameWindow.cs
git commit -m "feat(ui): D.2b-A — F12 toggles placeholder inventory window
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 4: Divergence register + full verification
**Files:**
- Modify: `docs/architecture/retail-divergence-register.md` (extend IA-12 "Where")
- [ ] **Step 1: Extend IA-12 to cite the window manager**
In `docs/architecture/retail-divergence-register.md`, find the IA-12 row's "Where" cell:
```
| IA-12 | UI toolkit mirrors retail behavior from research docs, not a byte-port — keystone.dll is outside decomp coverage; observed constants embedded (drag 3 px, tooltip 1000 ms) | `src/AcDream.App/UI/README.md:3` | keystone.dll has no PDB/decomp; semantics reconstructed from the six `docs/research/retail-ui/` deep-dives, keeping retail's event-type constants so panel switch-cases transplant cleanly | Edge-case input semantics the research under-specified (drag threshold, tooltip timing, focus hand-off, capture corners) differ silently with no oracle to diff against | keystone.dll Device DAT_00837ff4; docs/research/retail-ui/04-input-events.md |
```
Replace the "Where" cell `` `src/AcDream.App/UI/README.md:3` `` with:
```
`src/AcDream.App/UI/README.md:3` (window mgr: `UiRoot.cs` RegisterWindow/Show/Hide/Toggle/BringToFront — F12 inventory toggle IS retail-faithful)
```
So the full row becomes:
```
| IA-12 | UI toolkit mirrors retail behavior from research docs, not a byte-port — keystone.dll is outside decomp coverage; observed constants embedded (drag 3 px, tooltip 1000 ms) | `src/AcDream.App/UI/README.md:3` (window mgr: `UiRoot.cs` RegisterWindow/Show/Hide/Toggle/BringToFront — F12 inventory toggle IS retail-faithful) | keystone.dll has no PDB/decomp; semantics reconstructed from the six `docs/research/retail-ui/` deep-dives, keeping retail's event-type constants so panel switch-cases transplant cleanly | Edge-case input semantics the research under-specified (drag threshold, tooltip timing, focus hand-off, capture corners) differ silently with no oracle to diff against | keystone.dll Device DAT_00837ff4; docs/research/retail-ui/04-input-events.md |
```
- [ ] **Step 2: Run the full test suite**
Run: `dotnet test`
Expected: PASS — full suite green (2752+ tests; the window-manager facts add ~5).
- [ ] **Step 3: Commit**
```bash
git add docs/architecture/retail-divergence-register.md
git commit -m "docs(register): D.2b-A — extend IA-12 to cite the window manager
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
- [ ] **Step 4: Hand off for visual verification**
Stop and ask the user to launch the client (`ACDREAM_RETAIL_UI=1`, the standard launch in CLAUDE.md) and confirm:
- F12 shows the placeholder inventory window above vitals/chat/toolbar; 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).
Do NOT mark Sub-phase A done until the user confirms the visual behavior.
---
## Self-Review
**Spec coverage:**
- Registry on `UiRoot` (§4.1) → Task 1. ✓
- `BringToFront` + raise-on-click (§4.2) → Task 2. ✓
- `UiHost` forwarders (§4.3) → Task 3 Step 1. ✓
- F12 wiring in `OnInputAction` (§4.4) → Task 3 Step 3. ✓
- Placeholder inventory window (§4.5) → Task 3 Step 2. ✓
- In-memory persistence (§5) → no code needed (Visible + hardcoded positions); covered by construction. ✓
- Tests (§6) → Tasks 12. ✓
- Divergence register (§7) → Task 4 Step 1 (extend IA-12, dedup convention). ✓
- Acceptance (§8): build+tests green → Tasks 14; visual → Task 4 Step 4. ✓
**Placeholder scan:** No TBD/TODO/"handle edge cases"/"similar to" — every code step shows full code. ✓
**Type consistency:** `RegisterWindow(string, UiElement)`, `ShowWindow/HideWindow/ToggleWindow(string)→bool`, `BringToFront(UiElement)`, `WindowNames.Inventory`, `InputAction.ToggleInventoryPanel`, `_uiHost.ToggleWindow` — names identical across Tasks 13 and the spec. ✓