From f74e01750950954fdb309a7468734f5bfac0f73c Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 20 Jun 2026 18:33:35 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20D.2b=20=E2=80=94=20toolbar=20collap?= =?UTF-8?q?se-to-one-row=20(bottom-edge=20snap=20resize=20hides/shows=20ro?= =?UTF-8?q?w=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../retail-divergence-register.md | 2 +- src/AcDream.App/Rendering/GameWindow.cs | 40 +++++++++++- src/AcDream.App/UI/UiCollapsibleFrame.cs | 36 +++++++++++ src/AcDream.App/UI/UiElement.cs | 8 +++ src/AcDream.App/UI/UiNineSlicePanel.cs | 2 +- src/AcDream.App/UI/UiRoot.cs | 19 +++--- .../UI/UiCollapsibleFrameTests.cs | 63 +++++++++++++++++++ .../AcDream.App.Tests/UI/UiRootInputTests.cs | 4 +- 8 files changed, 159 insertions(+), 15 deletions(-) create mode 100644 src/AcDream.App/UI/UiCollapsibleFrame.cs create mode 100644 tests/AcDream.App.Tests/UI/UiCollapsibleFrameTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index a78ef507..48d38e34 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -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` | --- diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 197bdd07..1eaf9497 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -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(); + 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)."); diff --git a/src/AcDream.App/UI/UiCollapsibleFrame.cs b/src/AcDream.App/UI/UiCollapsibleFrame.cs new file mode 100644 index 00000000..7f1fc340 --- /dev/null +++ b/src/AcDream.App/UI/UiCollapsibleFrame.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; + +namespace AcDream.App.UI; + +/// +/// 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 = 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. +/// +public sealed class UiCollapsibleFrame : UiNineSlicePanel +{ + public UiCollapsibleFrame(Func resolve) : base(resolve) { } + + public float CollapsedHeight { get; set; } + public float ExpandedHeight { get; set; } + /// Elements shown only when expanded (the row-2 slot lists). Hidden when collapsed. + public IReadOnlyList SecondRow { get; set; } = Array.Empty(); + + /// True when the frame is at (or nearer) the expanded stop. + 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; + } + + /// Test hook β€” OnTick is protected. Drives one snap+visibility reconcile. + internal void TickForTest(double dt) => OnTick(dt); +} diff --git a/src/AcDream.App/UI/UiElement.cs b/src/AcDream.App/UI/UiElement.cs index 84e5f8bd..72a49d63 100644 --- a/src/AcDream.App/UI/UiElement.cs +++ b/src/AcDream.App/UI/UiElement.cs @@ -125,11 +125,19 @@ public abstract class UiElement public float MinWidth { get; set; } = 40f; public float MinHeight { get; set; } = 40f; + /// Maximum height enforced while resizing (default unbounded). Pairs with MinHeight. + public float MaxHeight { get; set; } = float.MaxValue; + /// Allow horizontal (width) resize. Ignored unless . public bool ResizeX { get; set; } = true; /// Allow vertical (height) resize. Ignored unless . public bool ResizeY { get; set; } = true; + /// Which edges may start a resize, beyond the ResizeX/ResizeY axis gates. Default: all. + /// Set to e.g. to allow only a bottom-edge drag (the collapse toolbar). + public ResizeEdges ResizableEdges { get; set; } = + ResizeEdges.Left | ResizeEdges.Right | ResizeEdges.Top | ResizeEdges.Bottom; + /// Edges this element anchors to in its parent. Default Left|Top /// (pinned top-left, fixed size β€” no reflow). Left|Right stretches width. public AnchorEdges Anchors { get; set; } = AnchorEdges.Left | AnchorEdges.Top; diff --git a/src/AcDream.App/UI/UiNineSlicePanel.cs b/src/AcDream.App/UI/UiNineSlicePanel.cs index f407f07b..1c4c207b 100644 --- a/src/AcDream.App/UI/UiNineSlicePanel.cs +++ b/src/AcDream.App/UI/UiNineSlicePanel.cs @@ -10,7 +10,7 @@ namespace AcDream.App.UI; /// the widget is testable without GL. In production: /// id => { var t = cache.GetOrUploadRenderSurface(id, out var w, out var h); return (t, w, h); }. /// -public sealed class UiNineSlicePanel : UiPanel +public class UiNineSlicePanel : UiPanel { /// A placed chrome piece: destination rect in local pixel space. public readonly record struct Rect(float X, float Y, float W, float H); diff --git a/src/AcDream.App/UI/UiRoot.cs b/src/AcDream.App/UI/UiRoot.cs index 4bb20f5d..b43201dc 100644 --- a/src/AcDream.App/UI/UiRoot.cs +++ b/src/AcDream.App/UI/UiRoot.cs @@ -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; } /// Compute a resized rect from a start rect + drag delta + which edges, - /// clamping to (,). Left/Top edges - /// move the origin so the opposite edge stays put. + /// clamping to (,) and + /// (,). Left/Top edges move the + /// origin so the opposite edge stays put. 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); } diff --git a/tests/AcDream.App.Tests/UI/UiCollapsibleFrameTests.cs b/tests/AcDream.App.Tests/UI/UiCollapsibleFrameTests.cs new file mode 100644 index 00000000..bc7abf5b --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiCollapsibleFrameTests.cs @@ -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)); + } +} diff --git a/tests/AcDream.App.Tests/UI/UiRootInputTests.cs b/tests/AcDream.App.Tests/UI/UiRootInputTests.cs index c3160e66..89123682 100644 --- a/tests/AcDream.App.Tests/UI/UiRootInputTests.cs +++ b/tests/AcDream.App.Tests/UI/UiRootInputTests.cs @@ -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); }