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

10 KiB
Raw Blame History

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

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

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==0OnTick 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.