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>
This commit is contained in:
parent
049a099123
commit
e58be3f030
1 changed files with 181 additions and 0 deletions
181
docs/superpowers/specs/2026-06-20-d2b-toolbar-collapse-design.md
Normal file
181
docs/superpowers/specs/2026-06-20-d2b-toolbar-collapse-design.md
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
# 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 1–9) or
|
||||
expanded to show both rows (slots 1–18), 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue