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

19 KiB
Raw Blame History

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:

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

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

    public void SetCapture(UiElement e) => Captured = e;
    public void ReleaseCapture() => Captured = null;

Insert immediately after it:


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

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

        if (w.Visible) { HideWindow(name); return false; }
        ShowWindow(name);
        return true;
    }

Insert immediately after it:


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

        if (!_windows.TryGetValue(name, out var w)) return false;
        w.Visible = true;
        return true;

Replace with:

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

        var window = FindWindow(target);
        if (btn == UiMouseButton.Left && window is not null)

Replace with:

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

    public void Dispose()
    {
        TextRenderer.Dispose();
    }

Insert immediately before it:

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

                    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:

                    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:

            case AcDream.UI.Abstractions.Input.InputAction.AcdreamToggleDebugPanel:

Insert this case immediately before it:

            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
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
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. ✓