feat(ui): D.2b — toolbar collapse-to-one-row (bottom-edge snap resize hides/shows row 2)
UiElement.MaxHeight + ResizableEdges mask; UiCollapsibleFrame snaps height to the nearer
of {collapsed,expanded} and toggles row-2 visibility; GameWindow computes the two heights
from the layout + top-anchors the content. Amends IA-17. UiNineSlicePanel unsealed to
allow subclassing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e58be3f030
commit
f74e017509
8 changed files with 159 additions and 15 deletions
|
|
@ -56,7 +56,7 @@ accepted-divergence entries (#96, #49, #50).
|
|||
| IA-13 | GameEventType registry deliberately omits event types retail ignores; unknown events fall through unhandled | `src/AcDream.Core.Net/Messages/GameEventType.cs:11` | Retail also ignores them — dropping matches retail by construction | If the "retail ignores X" judgment is wrong for any opcode (or a server mod uses one), the event is silently dropped with no diagnostic pointing at the omission | retail GameEvent dispatch (ignored-event set) |
|
||||
| IA-14 | Rendering + dat-handling base is WorldBuilder's tested port, not a fresh retail-decomp port (Phase N.4/O design stance) | `docs/architecture/worldbuilder-inventory.md` (code at `src/AcDream.{Core,App}/Rendering/Wb/`) | WB visually verified on the AC world, MIT, same stack; known WB↔retail deltas resolved case-by-case — terrain split kept retail `FSplitNESW` (**#51**, pinned by `SplitFormulaDivergenceTest`), scenery drift accepted (AP-31) | A WB-upstream divergence not yet caught ships silently as "our" behavior; guard = the inventory doc's 🟢/🔴 split + per-formula divergence tests | retail decomp per algorithm; `tests/.../SplitFormulaDivergenceTest.cs` |
|
||||
| IA-15 | D.2b retail UI is our own UiHost/UiElement retained-mode tree drawing dat-sprite window frames, not a byte-port of keystone.dll's LayoutDesc binary tree. Both the vitals window (`LayoutDesc 0x2100006C`) and the chat window (`LayoutDesc 0x21000006`) are rendered by the LayoutDesc importer; `UiNineSlicePanel`/`RetailChromeSprites` now back only plugin panels | `src/AcDream.App/UI/Layout/LayoutImporter.cs` (vitals + chat) + `src/AcDream.App/UI/Layout/ChatWindowController.cs` | keystone.dll has no PDB/decomp so a byte-port is impossible by definition; we mirror retail's ElementDesc field model + controls.ini tokens, and the chrome sprites ARE the real dat RenderSurfaces (Step-0 prove-out 2026-06-14 confirmed 0x06004CC2 center + 0x060074BF..C6 bevel). The 8-piece edge/corner→position mapping is DATA-DRIVEN from the dat: the `LayoutImporter` reads `LayoutDesc 0x2100006C`/`0x21000006` and resolves chrome element positions + sprite ids directly from parsed dat fields; vitals locked by the conformance fixture `tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json` | Remaining residual risk: anchor resolution at non-800×600 and the controls.ini cascade still lack an oracle — layout scaling at non-reference resolution and stylesheet token inheritance differ silently | `LayoutDesc 0x2100006C`/`0x21000006` (SHIPPED); `docs/research/2026-06-15-layoutdesc-format.md`; controls.ini tokens; keystone.dll layout eval (no PDB) |
|
||||
| IA-17 | Toolbar window FRAME is toolkit-supplied (per-window UiNineSlicePanel 8-piece bevel, drawn over content via UiElement.OnDrawAfterChildren) rather than the window-manager-owned chrome retail paints uniformly around every window | `src/AcDream.App/Rendering/GameWindow.cs` (toolbar mount) + `src/AcDream.App/UI/UiNineSlicePanel.cs` | LayoutDesc 0x21000016 has NO baked frame; retail's toolbar frame is window-manager chrome (keystone.dll). We draw the same reusable 8-piece bevel chat/vitals use; border drawn over content so the toolbar's 2px-wide row-2 right cap (W=8) can't poke through. Same pattern as the chat window. | Until a central window manager owns chrome uniformly, per-window wraps can drift (size/offset/z-order) from each other and from retail; the border-over-content rule is the toolkit's, not the WM's | gmToolbarUI WM chrome (keystone.dll, no PDB); no bevel ids in LayoutDesc 0x21000016 (toolbar dump) |
|
||||
| IA-17 | Toolbar window FRAME is toolkit-supplied (per-window UiNineSlicePanel 8-piece bevel, drawn over content via UiElement.OnDrawAfterChildren) rather than the window-manager-owned chrome retail paints uniformly around every window. It 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 is keystone.dll (no decomp) and the dat stacks both rows always. | `src/AcDream.App/Rendering/GameWindow.cs` (toolbar mount) + `src/AcDream.App/UI/UiNineSlicePanel.cs` + `src/AcDream.App/UI/UiCollapsibleFrame.cs`; spec: `docs/superpowers/specs/2026-06-20-d2b-toolbar-collapse-design.md` | LayoutDesc 0x21000016 has NO baked frame; retail's toolbar frame is window-manager chrome (keystone.dll). We draw the same reusable 8-piece bevel chat/vitals use; border drawn over content so the toolbar's 2px-wide row-2 right cap (W=8) can't poke through. Same pattern as the chat window. | Until a central window manager owns chrome uniformly, per-window wraps can drift (size/offset/z-order) from each other and from retail; the border-over-content rule is the toolkit's, not the WM's | gmToolbarUI WM chrome (keystone.dll, no PDB); no bevel ids in LayoutDesc 0x21000016 (toolbar dump) |
|
||||
| IA-18 | Effect overlay tile (enum 0x10000005) is a `ReplaceColor` SURFACE SOURCE — pure-white pixels in the composited drag icon are replaced PER-PIXEL with the same (x,y) pixel of the effect tile (the SURFACE overload `SurfaceWindow::ReplaceColor` 0x004415b0), preserving the tile's texture/gradient; the tile itself is NOT blitted as an additional layer. This IS faithful retail behavior. **Anti-regression: do NOT re-implement this as a blit layer NOR as a flat-color replace (it is a per-pixel surface copy).** | `src/AcDream.App/UI/IconComposer.cs` (`ReplaceWhiteFromSurface`) | Faithful port of `IconData::RenderIcons` @407614 → the SURFACE overload `ReplaceColor` 0x004415b0 (`dst[x,y]=src[x,y]` where `dst==white`); confirmed via clean Ghidra decompile + named decomp + visual (the Energy Crystal's blue is a gradient, 2026-06-17). | A blit-layer or flat-color re-implementation would show the wrong effect look (no gradient) — the visual-verification regression that retired the mean-color approximation | `IconData::RenderIcons` acclient_2013_pseudo_c.txt:407524; `ReplaceColor` SURFACE overload 0x004415b0:71656; `docs/research/2026-06-17-stateful-icon-RESOLVED.md` |
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -2044,7 +2044,7 @@ public sealed class GameWindow : IDisposable
|
|||
// thickness on every side, giving an outer window of 310×132.
|
||||
const int toolbarBorder = AcDream.App.UI.RetailChromeSprites.Border;
|
||||
float toolbarContentW = 300f, toolbarContentH = toolbarRoot.Height;
|
||||
var toolbarFrame = new AcDream.App.UI.UiNineSlicePanel(ResolveChrome)
|
||||
var toolbarFrame = new AcDream.App.UI.UiCollapsibleFrame(ResolveChrome)
|
||||
{
|
||||
Left = 10, Top = 300,
|
||||
Width = toolbarContentW + 2 * toolbarBorder,
|
||||
|
|
@ -2057,14 +2057,48 @@ public sealed class GameWindow : IDisposable
|
|||
toolbarRoot.Top = toolbarBorder;
|
||||
toolbarRoot.Width = toolbarContentW;
|
||||
toolbarRoot.Height = toolbarContentH;
|
||||
// Anchor content to all four edges so it reflows if the frame is resized.
|
||||
// Anchor content to Left|Top|Right only — drop Bottom so the dat content
|
||||
// keeps its full height when the frame collapses; row 2 hides via Visible,
|
||||
// not reflow. (Width is fixed so the horizontal anchors are inert but harmless.)
|
||||
toolbarRoot.Anchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top
|
||||
| AcDream.App.UI.AnchorEdges.Right | AcDream.App.UI.AnchorEdges.Bottom;
|
||||
| AcDream.App.UI.AnchorEdges.Right;
|
||||
// The frame is the draggable window; the content itself is not.
|
||||
toolbarRoot.ClickThrough = false;
|
||||
toolbarRoot.Draggable = false;
|
||||
toolbarRoot.Resizable = false;
|
||||
toolbarFrame.AddChild(toolbarRoot);
|
||||
|
||||
// Collapse-to-one-row: the frame's bottom edge snaps between a one-row (row 2 hidden)
|
||||
// and two-row height. CollapsedHeight is computed from the layout (just above row 2),
|
||||
// so there's no magic constant. Bottom-edge only; default expanded.
|
||||
uint[] row2Ids =
|
||||
{
|
||||
0x100006B7u, 0x100006B8u, 0x100006B9u, 0x100006BAu, 0x100006BBu,
|
||||
0x100006BCu, 0x100006BDu, 0x100006BEu, 0x100006BFu,
|
||||
};
|
||||
var toolbarRow2 = new System.Collections.Generic.List<AcDream.App.UI.UiElement>();
|
||||
float minRow2Top = float.MaxValue;
|
||||
foreach (var id in row2Ids)
|
||||
if (toolbarLayout.FindElement(id) is { } e2)
|
||||
{
|
||||
toolbarRow2.Add(e2);
|
||||
if (e2.Top < minRow2Top) minRow2Top = e2.Top;
|
||||
}
|
||||
if (toolbarRow2.Count > 0)
|
||||
{
|
||||
float expandedH = toolbarContentH + 2 * toolbarBorder; // today's full height
|
||||
float collapsedH = minRow2Top + 2 * toolbarBorder; // just above row 2
|
||||
toolbarFrame.CollapsedHeight = collapsedH;
|
||||
toolbarFrame.ExpandedHeight = expandedH;
|
||||
toolbarFrame.SecondRow = toolbarRow2;
|
||||
toolbarFrame.Resizable = true;
|
||||
toolbarFrame.ResizableEdges = AcDream.App.UI.ResizeEdges.Bottom; // bottom edge only
|
||||
toolbarFrame.MinHeight = collapsedH;
|
||||
toolbarFrame.MaxHeight = expandedH;
|
||||
// Height stays expandedH (already set at construction) = default expanded.
|
||||
Console.WriteLine($"[D.2b] toolbar collapse: collapsed={collapsedH:0} expanded={expandedH:0} (row2Top={minRow2Top:0}).");
|
||||
}
|
||||
|
||||
_uiHost.Root.AddChild(toolbarFrame);
|
||||
|
||||
Console.WriteLine("[D.5.1] retail toolbar window from LayoutDesc importer (0x21000016).");
|
||||
|
|
|
|||
36
src/AcDream.App/UI/UiCollapsibleFrame.cs
Normal file
36
src/AcDream.App/UI/UiCollapsibleFrame.cs
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// A toolbar-frame variant that snaps its height between two stops — collapsed (row 2 hidden) and
|
||||
/// expanded (row 2 shown) — and toggles a set of "second-row" elements to match. Resized via the
|
||||
/// bottom edge (the mount sets <see cref="UiElement.ResizableEdges"/> = Bottom); each tick it resolves
|
||||
/// the dragged height to the nearer stop so the frame always rests collapsed or expanded — never a
|
||||
/// half-row. Toolkit UX (keystone.dll has no decomp; the dat stacks both rows always) — see IA-17.
|
||||
/// </summary>
|
||||
public sealed class UiCollapsibleFrame : UiNineSlicePanel
|
||||
{
|
||||
public UiCollapsibleFrame(Func<uint, (uint, int, int)> resolve) : base(resolve) { }
|
||||
|
||||
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; } = Array.Empty<UiElement>();
|
||||
|
||||
/// <summary>True when the frame is at (or nearer) the expanded stop.</summary>
|
||||
public bool IsExpanded => Height >= (CollapsedHeight + ExpandedHeight) * 0.5f;
|
||||
|
||||
protected override void OnTick(double deltaSeconds)
|
||||
{
|
||||
base.OnTick(deltaSeconds);
|
||||
if (ExpandedHeight <= CollapsedHeight) return; // not configured yet — no snap
|
||||
bool expanded = IsExpanded;
|
||||
Height = expanded ? ExpandedHeight : CollapsedHeight; // snap the dragged height to a stop
|
||||
for (int i = 0; i < SecondRow.Count; i++) SecondRow[i].Visible = expanded;
|
||||
}
|
||||
|
||||
/// <summary>Test hook — OnTick is protected. Drives one snap+visibility reconcile.</summary>
|
||||
internal void TickForTest(double dt) => OnTick(dt);
|
||||
}
|
||||
|
|
@ -125,11 +125,19 @@ public abstract class UiElement
|
|||
public float MinWidth { get; set; } = 40f;
|
||||
public float MinHeight { get; set; } = 40f;
|
||||
|
||||
/// <summary>Maximum height enforced while resizing (default unbounded). Pairs with MinHeight.</summary>
|
||||
public float MaxHeight { get; set; } = float.MaxValue;
|
||||
|
||||
/// <summary>Allow horizontal (width) resize. Ignored unless <see cref="Resizable"/>.</summary>
|
||||
public bool ResizeX { get; set; } = true;
|
||||
/// <summary>Allow vertical (height) resize. Ignored unless <see cref="Resizable"/>.</summary>
|
||||
public bool ResizeY { get; set; } = true;
|
||||
|
||||
/// <summary>Which edges may start a resize, beyond the ResizeX/ResizeY axis gates. Default: all.
|
||||
/// Set to e.g. <see cref="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;
|
||||
|
||||
/// <summary>Edges this element anchors to in its parent. Default Left|Top
|
||||
/// (pinned top-left, fixed size — no reflow). Left|Right stretches width.</summary>
|
||||
public AnchorEdges Anchors { get; set; } = AnchorEdges.Left | AnchorEdges.Top;
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ namespace AcDream.App.UI;
|
|||
/// the widget is testable without GL. In production:
|
||||
/// <c>id => { var t = cache.GetOrUploadRenderSurface(id, out var w, out var h); return (t, w, h); }</c>.
|
||||
/// </summary>
|
||||
public sealed class UiNineSlicePanel : UiPanel
|
||||
public class UiNineSlicePanel : UiPanel
|
||||
{
|
||||
/// <summary>A placed chrome piece: destination rect in local pixel space.</summary>
|
||||
public readonly record struct Rect(float X, float Y, float W, float H);
|
||||
|
|
|
|||
|
|
@ -176,7 +176,8 @@ public sealed class UiRoot : UiElement
|
|||
var (nx, ny, nw, nh) = ResizeRect(
|
||||
_resizeStartX, _resizeStartY, _resizeStartW, _resizeStartH,
|
||||
_resizeEdges, x - _resizeMouseX, y - _resizeMouseY,
|
||||
_resizeTarget.MinWidth, _resizeTarget.MinHeight);
|
||||
_resizeTarget.MinWidth, _resizeTarget.MinHeight,
|
||||
float.MaxValue, _resizeTarget.MaxHeight);
|
||||
_resizeTarget.Left = nx; _resizeTarget.Top = ny;
|
||||
_resizeTarget.Width = nw; _resizeTarget.Height = nh;
|
||||
return;
|
||||
|
|
@ -626,21 +627,23 @@ public sealed class UiRoot : UiElement
|
|||
if (System.Math.Abs(y - b) <= grip) e |= ResizeEdges.Bottom;
|
||||
if (!w.ResizeX) e &= ~(ResizeEdges.Left | ResizeEdges.Right);
|
||||
if (!w.ResizeY) e &= ~(ResizeEdges.Top | ResizeEdges.Bottom);
|
||||
e &= w.ResizableEdges;
|
||||
return e;
|
||||
}
|
||||
|
||||
/// <summary>Compute a resized rect from a start rect + drag delta + which edges,
|
||||
/// clamping to (<paramref name="minW"/>,<paramref name="minH"/>). Left/Top edges
|
||||
/// move the origin so the opposite edge stays put.</summary>
|
||||
/// clamping to (<paramref name="minW"/>,<paramref name="minH"/>) and
|
||||
/// (<paramref name="maxW"/>,<paramref name="maxH"/>). Left/Top edges move the
|
||||
/// origin so the opposite edge stays put.</summary>
|
||||
public static (float x, float y, float w, float h) ResizeRect(
|
||||
float startX, float startY, float startW, float startH,
|
||||
ResizeEdges edges, float dx, float dy, float minW, float minH)
|
||||
ResizeEdges edges, float dx, float dy, float minW, float minH, float maxW, float maxH)
|
||||
{
|
||||
float x = startX, y = startY, w = startW, h = startH;
|
||||
if ((edges & ResizeEdges.Right) != 0) w = System.Math.Max(minW, startW + dx);
|
||||
if ((edges & ResizeEdges.Bottom) != 0) h = System.Math.Max(minH, startH + dy);
|
||||
if ((edges & ResizeEdges.Left) != 0) { float nw = System.Math.Max(minW, startW - dx); x = startX + (startW - nw); w = nw; }
|
||||
if ((edges & ResizeEdges.Top) != 0) { float nh = System.Math.Max(minH, startH - dy); y = startY + (startH - nh); h = nh; }
|
||||
if ((edges & ResizeEdges.Right) != 0) w = System.Math.Clamp(startW + dx, minW, maxW);
|
||||
if ((edges & ResizeEdges.Bottom) != 0) h = System.Math.Clamp(startH + dy, minH, maxH);
|
||||
if ((edges & ResizeEdges.Left) != 0) { float nw = System.Math.Clamp(startW - dx, minW, maxW); x = startX + (startW - nw); w = nw; }
|
||||
if ((edges & ResizeEdges.Top) != 0) { float nh = System.Math.Clamp(startH - dy, minH, maxH); y = startY + (startH - nh); h = nh; }
|
||||
return (x, y, w, h);
|
||||
}
|
||||
|
||||
|
|
|
|||
63
tests/AcDream.App.Tests/UI/UiCollapsibleFrameTests.cs
Normal file
63
tests/AcDream.App.Tests/UI/UiCollapsibleFrameTests.cs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
using AcDream.App.UI;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
public class UiCollapsibleFrameTests
|
||||
{
|
||||
private static UiCollapsibleFrame MakeFrame(out UiPanel row2a, out UiPanel row2b)
|
||||
{
|
||||
var f = new UiCollapsibleFrame(_ => (1u, 1, 1))
|
||||
{
|
||||
CollapsedHeight = 96f,
|
||||
ExpandedHeight = 128f,
|
||||
};
|
||||
row2a = new UiPanel(); row2b = new UiPanel();
|
||||
f.SecondRow = new UiElement[] { row2a, row2b };
|
||||
return f;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_belowMidpoint_snapsCollapsed_hidesSecondRow()
|
||||
{
|
||||
var f = MakeFrame(out var a, out var b);
|
||||
f.Height = 100f; // nearer the collapsed stop (midpoint 112)
|
||||
f.TickForTest(0.016);
|
||||
Assert.Equal(96f, f.Height);
|
||||
Assert.False(a.Visible);
|
||||
Assert.False(b.Visible);
|
||||
Assert.False(f.IsExpanded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_aboveMidpoint_snapsExpanded_showsSecondRow()
|
||||
{
|
||||
var f = MakeFrame(out var a, out var b);
|
||||
f.Height = 120f; // nearer the expanded stop
|
||||
f.TickForTest(0.016);
|
||||
Assert.Equal(128f, f.Height);
|
||||
Assert.True(a.Visible);
|
||||
Assert.True(b.Visible);
|
||||
Assert.True(f.IsExpanded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_notConfigured_isNoOp()
|
||||
{
|
||||
var f = new UiCollapsibleFrame(_ => (1u, 1, 1)); // Collapsed==Expanded==0
|
||||
f.Height = 50f;
|
||||
f.TickForTest(0.016);
|
||||
Assert.Equal(50f, f.Height); // unchanged, no divide/no forced height
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HitEdges_respectsResizableEdgesMask_bottomOnly()
|
||||
{
|
||||
var panel = new UiPanel { Left = 100, Top = 100, Width = 200, Height = 100,
|
||||
Resizable = true, ResizableEdges = ResizeEdges.Bottom };
|
||||
// bottom edge (y=200) → Bottom only
|
||||
Assert.Equal(ResizeEdges.Bottom, UiRoot.HitEdges(panel, 200, 200, 5));
|
||||
// top edge (y=100) → masked out → None
|
||||
Assert.Equal(ResizeEdges.None, UiRoot.HitEdges(panel, 200, 100, 5));
|
||||
}
|
||||
}
|
||||
|
|
@ -157,7 +157,7 @@ public class UiRootInputTests
|
|||
public void ResizeRect_RightBottom_GrowsSizeOnly()
|
||||
{
|
||||
var (x, y, w, h) = UiRoot.ResizeRect(10, 20, 100, 50,
|
||||
ResizeEdges.Right | ResizeEdges.Bottom, dx: 30, dy: 15, minW: 40, minH: 40);
|
||||
ResizeEdges.Right | ResizeEdges.Bottom, dx: 30, dy: 15, minW: 40, minH: 40, maxW: float.MaxValue, maxH: float.MaxValue);
|
||||
Assert.Equal(10f, x); Assert.Equal(20f, y);
|
||||
Assert.Equal(130f, w); Assert.Equal(65f, h);
|
||||
}
|
||||
|
|
@ -168,7 +168,7 @@ public class UiRootInputTests
|
|||
// Drag left edge right by 80 on a 100-wide / min-40 window: width clamps to 40,
|
||||
// origin shifts so the RIGHT edge (110) stays put → x = 70.
|
||||
var (x, _, w, _) = UiRoot.ResizeRect(10, 20, 100, 50,
|
||||
ResizeEdges.Left, dx: 80, dy: 0, minW: 40, minH: 40);
|
||||
ResizeEdges.Left, dx: 80, dy: 0, minW: 40, minH: 40, maxW: float.MaxValue, maxH: float.MaxValue);
|
||||
Assert.Equal(40f, w);
|
||||
Assert.Equal(70f, x);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue