acdream/docs/superpowers/specs/2026-06-20-d2b-toolbar-collapse-design.md
Erik e58be3f030 docs(D.2b): toolbar collapse-to-one-row design spec
User request: the toolbar frame resizes vertically between one row (row 2
hidden, minimum) and two rows (shown), SNAPPING between the two stops; default
expanded. Small toolkit feature: UiElement.MaxHeight + a ResizableEdges mask
(bottom-edge-only) + a UiCollapsibleFrame (snaps height to the nearer stop and
ties row-2 visibility to it in OnTick) + the GameWindow mount (compute the two
heights from the layout, top-anchor the content so row 1 never reflows). Retail's
real mechanism is keystone.dll (no decomp) + the dat stacks both rows always —
so this is a toolkit UX from the user's retail observation; amends IA-17.

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

181 lines
10 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 toolbar collapse-to-one-row — design
**Date:** 2026-06-20
**Phase:** D.2b retail-UI, toolbar polish (follows D.5.3 B.1/B.2, both visually confirmed this session).
**Branch:** `claude/hopeful-maxwell-214a12`.
**Driver:** user request — the toolbar frame should resize vertically between **one row** (row 2 hidden,
the minimum) and **two rows** (row 2 shown), **snapping** between the two stops.
---
## 1. Goal & non-goals
**Goal.** The toolbar window can be collapsed to show only the top quickslot row (slots 19) or
expanded to show both rows (slots 118), by dragging its **bottom edge**. The drag **snaps** to the
nearer of two height stops — collapsed (row 2 hidden) or expanded (row 2 shown). Default = expanded
(today's look). Horizontal size stays fixed; the window still moves by grabbing empty cells / chrome
(IA-12). This is a toolkit UX defined from the user's retail observation — the real mechanism lives in
`keystone.dll` (no decomp); our research notes the dat just stacks two always-present rows, so the dat
encodes no collapse. Recorded as an amendment to **IA-17** (toolbar frame is toolkit-supplied).
**Non-goals:** a collapse/expand BUTTON (it's a bottom-edge resize); horizontal resize; persisting the
collapsed state across sessions (it resets to expanded each launch — persistence is the deferred
window-manager Plan-2); animating the snap.
---
## 2. Geometry (from the layout, not hardcoded)
The toolbar `LayoutDesc 0x21000016` root is **300×122**; the two rows are top `0x100001A7..AF` and
bottom `0x100006B7..BF`, with the bottom row's slots at content-y ≈ 90 (deep-dive §2a table, slot 9 at
`6,90`). Heights are computed at mount time from the actual layout, so there is no magic constant:
- `border` = `RetailChromeSprites.Border` (5 px).
- `ExpandedHeight` = `contentHeight + 2·border` (today's frame height; `contentHeight` = the imported
root's `Height`, 122).
- `CollapsedHeight` = `minRow2Top + 2·border`, where `minRow2Top` = the smallest `Top` among the nine
resolved row-2 slot elements (`0x100006B7..BF`). That cuts the frame just above row 2.
- `snapMidpoint` = `(CollapsedHeight + ExpandedHeight) / 2`.
---
## 3. Components
### 3.1 `UiElement` — `MaxHeight` + a `ResizableEdges` mask — `src/AcDream.App/UI/UiElement.cs`
Today resize clamps to a minimum only and (with `ResizeY`) treats BOTH vertical edges as grips. Add two
small generic members:
```csharp
/// <summary>Maximum height enforced while resizing (default unbounded). Pairs with MinHeight.</summary>
public float MaxHeight { get; set; } = float.MaxValue;
/// <summary>Which edges may start a resize, beyond the ResizeX/ResizeY axis gates. Default: all.
/// Set to e.g. ResizeEdges.Bottom to allow only a bottom-edge drag (the collapse toolbar).</summary>
public ResizeEdges ResizableEdges { get; set; } =
ResizeEdges.Left | ResizeEdges.Right | ResizeEdges.Top | ResizeEdges.Bottom;
```
(`MaxWidth` is YAGNI — only height needs it here.)
- **`UiRoot.HitEdges`** applies the mask at the end: `e &= w.ResizableEdges;` (after the existing
`ResizeX`/`ResizeY` masking). So a toolbar with `ResizableEdges = Bottom` only grips its bottom edge;
a press near the top edge falls through to window-move, not resize.
- **`UiRoot.ResizeRect`** gains a `maxH` parameter and clamps the Bottom/Top height branches:
`h = Math.Clamp(startH + dy, minH, maxH)` (Bottom) and the Top branch likewise. `OnMouseMove`'s resize
call passes `_resizeTarget.MaxHeight` (and `float.MaxValue` for the width's maxW). **This changes
`ResizeRect`'s signature — update its existing callers + the `UiRootInputTests.ResizeRect_*` tests to
pass the new `maxW`/`maxH` args** (`float.MaxValue` where unbounded, preserving their current
assertions).
### 3.2 `UiCollapsibleFrame : UiNineSlicePanel` (new) — `src/AcDream.App/UI/UiCollapsibleFrame.cs`
A toolbar-frame variant that snaps between two heights and toggles a set of "second-row" elements.
One clear responsibility: reconcile its height to a stop and the rows to that stop, every tick.
```csharp
public sealed class UiCollapsibleFrame : UiNineSlicePanel
{
public UiCollapsibleFrame(Func<uint,(uint,int,int)> resolveChrome) : base(resolveChrome) { }
public float CollapsedHeight { get; set; }
public float ExpandedHeight { get; set; }
/// <summary>Elements shown only when expanded (the row-2 slot lists). Hidden when collapsed.</summary>
public IReadOnlyList<UiElement> SecondRow { get; set; } = System.Array.Empty<UiElement>();
/// <summary>True when the frame is currently at (or nearer) the expanded stop.</summary>
public bool IsExpanded => Height >= (CollapsedHeight + ExpandedHeight) * 0.5f;
protected override void OnTick(double dt)
{
base.OnTick(dt);
if (ExpandedHeight <= CollapsedHeight) return; // not configured yet
// Snap to the nearer stop (the resize drag sets Height live; we resolve it to a stop so the
// frame always rests collapsed or expanded — never a half-row).
bool expanded = IsExpanded;
Height = expanded ? ExpandedHeight : CollapsedHeight;
// Row 2 is shown only when expanded. (No clipping needed — the dat content is top-anchored,
// so row-2 slots simply stop drawing when hidden; row 1 never moves.)
for (int i = 0; i < SecondRow.Count; i++) SecondRow[i].Visible = expanded;
}
}
```
Notes:
- The snap runs in `OnTick` (after the frame's `MinHeight`/`MaxHeight`-clamped resize drag set `Height`
that frame), so the rendered height is always a stop. With only two stops one row apart, this reads
as: drag the bottom edge past the midpoint → it jumps to the other stop + row 2 appears/hides.
- `IsExpanded`/the snap use the midpoint; `MinHeight`/`MaxHeight` (set by the mount) keep the drag
within `[Collapsed, Expanded]` so the midpoint test is well-defined.
### 3.3 GameWindow toolbar mount — `src/AcDream.App/Rendering/GameWindow.cs` (~line 2045)
- Build the frame as `UiCollapsibleFrame` instead of `UiNineSlicePanel` (same `ResolveChrome` ctor arg).
- After the content (`toolbarRoot`) is sized: compute `expandedH = toolbarContentH + 2·border`,
`collapsedH = minRow2Top + 2·border` where `minRow2Top` = `min` of the nine row-2 lists' `Top`
(resolve each via `toolbarLayout.FindElement(0x100006B7..BF)`; reuse `ToolbarController`'s row-2 id
list or inline the nine ids).
- Set on the frame: `Resizable = true; ResizableEdges = ResizeEdges.Bottom` (bottom-edge only — top
edge stays a move grip); `MinHeight = collapsedH; MaxHeight = expandedH; Height = expandedH` (default
expanded); `CollapsedHeight = collapsedH; ExpandedHeight = expandedH; SecondRow = <the nine row-2
elements>`. (`ResizeX`/`ResizeY` keep defaults; the `ResizableEdges = Bottom` mask is the operative
restriction.)
- Change `toolbarRoot.Anchors` from all-four-edges to **`Left | Top | Right`** (drop `Bottom`) so the
dat content keeps its full height and row 1 never reflows when the frame collapses; row 2 hides via
`Visible`. (Width is fixed — `ResizeX=false` — so the horizontal anchors are inert but harmless.)
---
## 4. Behavior walk-through
- **Launch:** frame at `ExpandedHeight`, both rows visible (unchanged from today).
- **Collapse:** grab the bottom edge, drag up past the midpoint → `OnTick` snaps `Height` to
`CollapsedHeight` and hides the nine row-2 slots. The frame is now a single-row bar; row 1 unchanged.
- **Expand:** drag the bottom edge down past the midpoint → snaps to `ExpandedHeight`, row 2 reappears.
- **Move:** unchanged — drag an empty cell / chrome to reposition (IA-12); occupied cells drag items
(B.1/B.2).
- **Edge cases:** `MinHeight`/`MaxHeight` clamp the drag to `[Collapsed, Expanded]`; the snap is
idempotent when not dragging (Height already at a stop). The collapsed state is per-session (resets
to expanded on relaunch).
---
## 5. Divergence register
**Amend IA-17** (toolbar window FRAME is toolkit-supplied): add that the frame also supports a
toolkit-defined **collapse-to-one-row** (bottom-edge resize snapping between a row-1-only and a
two-row height, row-2 visibility tied to the stop). Retail's real collapse mechanism is keystone.dll
(no decomp) and the dat encodes no collapse (both rows always present) — so this is our toolkit UX from
the user's retail observation, same justification class as the rest of IA-17. No new row; extend IA-17's
text + cite this spec.
---
## 6. Testing
`tests/AcDream.App.Tests/UI/`:
1. `UiCollapsibleFrame.OnTick` snap: set `CollapsedHeight=96`, `ExpandedHeight=128`; set `Height` just
below the midpoint (e.g. 100) + tick → `Height == 96` and every `SecondRow` element `Visible==false`;
set `Height` just above (e.g. 120) + tick → `Height == 128` and `SecondRow` `Visible==true`.
2. `UiCollapsibleFrame` not-configured guard: `ExpandedHeight==CollapsedHeight==0``OnTick` is a
no-op (no divide/no forced height).
3. `UiRoot.ResizeRect` MaxHeight clamp: a Bottom-edge resize with `dy` huge clamps `h` to `maxH`;
a Top-edge resize likewise; min still honored. (Drive via the existing `ResizeRect` static test
pattern in `UiRootInputTests`; also update the two pre-existing `ResizeRect_*` tests to the new
`maxW`/`maxH` signature — pass `float.MaxValue`, assertions unchanged.)
4. `UiRoot.HitEdges` honors `ResizableEdges`: a panel with `ResizableEdges = ResizeEdges.Bottom` returns
only `Bottom` when pressed near its bottom edge, and `None` near its top edge (which would otherwise
be a grip with `ResizeY` true).
A `UiCollapsibleFrame` needs a chrome resolver in tests — pass `_ => (1u,1,1)` (the existing
`UiNineSlicePanel` test pattern); `OnTick` doesn't draw, so no GL.
---
## 7. Acceptance
- [ ] `dotnet build` + `dotnet test` green.
- [ ] IA-17 amended.
- [ ] **Visual (user):** default shows both rows; dragging the toolbar's **bottom edge up** snaps it to
a single row (row 2 gone); dragging **down** snaps back to two rows; row 1 never moves/squishes;
the window still moves by dragging empty cells/chrome; item drag (B.1/B.2) still works.
---
## 8. Plan size
One small task (TDD): `UiElement.MaxHeight` + `ResizeRect` clamp → `UiCollapsibleFrame` + its tests →
the GameWindow mount swap + IA-17 amend. ~3 files + 1 test file. Suitable for a single implementer pass.