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>
10 KiB
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'sHeight, 122).CollapsedHeight=minRow2Top + 2·border, whereminRow2Top= the smallestTopamong 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:
/// <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.HitEdgesapplies the mask at the end:e &= w.ResizableEdges;(after the existingResizeX/ResizeYmasking). So a toolbar withResizableEdges = Bottomonly grips its bottom edge; a press near the top edge falls through to window-move, not resize.UiRoot.ResizeRectgains amaxHparameter 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(andfloat.MaxValuefor the width's maxW). This changesResizeRect's signature — update its existing callers + theUiRootInputTests.ResizeRect_*tests to pass the newmaxW/maxHargs (float.MaxValuewhere 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'sMinHeight/MaxHeight-clamped resize drag setHeightthat 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
UiCollapsibleFrameinstead ofUiNineSlicePanel(sameResolveChromector arg). - After the content (
toolbarRoot) is sized: computeexpandedH = toolbarContentH + 2·border,collapsedH = minRow2Top + 2·borderwhereminRow2Top=minof the nine row-2 lists'Top(resolve each viatoolbarLayout.FindElement(0x100006B7..BF); reuseToolbarController'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/ResizeYkeep defaults; theResizableEdges = Bottommask is the operative restriction.) - Change
toolbarRoot.Anchorsfrom all-four-edges toLeft | Top | Right(dropBottom) so the dat content keeps its full height and row 1 never reflows when the frame collapses; row 2 hides viaVisible. (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 →
OnTicksnapsHeighttoCollapsedHeightand 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/MaxHeightclamp 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/:
UiCollapsibleFrame.OnTicksnap: setCollapsedHeight=96,ExpandedHeight=128; setHeightjust below the midpoint (e.g. 100) + tick →Height == 96and everySecondRowelementVisible==false; setHeightjust above (e.g. 120) + tick →Height == 128andSecondRowVisible==true.UiCollapsibleFramenot-configured guard:ExpandedHeight==CollapsedHeight==0→OnTickis a no-op (no divide/no forced height).UiRoot.ResizeRectMaxHeight clamp: a Bottom-edge resize withdyhuge clampshtomaxH; a Top-edge resize likewise; min still honored. (Drive via the existingResizeRectstatic test pattern inUiRootInputTests; also update the two pre-existingResizeRect_*tests to the newmaxW/maxHsignature — passfloat.MaxValue, assertions unchanged.)UiRoot.HitEdgeshonorsResizableEdges: a panel withResizableEdges = ResizeEdges.Bottomreturns onlyBottomwhen pressed near its bottom edge, andNonenear its top edge (which would otherwise be a grip withResizeYtrue).
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 testgreen.- 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.