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:
Erik 2026-06-20 18:33:35 +02:00
parent e58be3f030
commit f74e017509
8 changed files with 159 additions and 15 deletions

View file

@ -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-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-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-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` | | 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` |
--- ---

View file

@ -2044,7 +2044,7 @@ public sealed class GameWindow : IDisposable
// thickness on every side, giving an outer window of 310×132. // thickness on every side, giving an outer window of 310×132.
const int toolbarBorder = AcDream.App.UI.RetailChromeSprites.Border; const int toolbarBorder = AcDream.App.UI.RetailChromeSprites.Border;
float toolbarContentW = 300f, toolbarContentH = toolbarRoot.Height; 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, Left = 10, Top = 300,
Width = toolbarContentW + 2 * toolbarBorder, Width = toolbarContentW + 2 * toolbarBorder,
@ -2057,14 +2057,48 @@ public sealed class GameWindow : IDisposable
toolbarRoot.Top = toolbarBorder; toolbarRoot.Top = toolbarBorder;
toolbarRoot.Width = toolbarContentW; toolbarRoot.Width = toolbarContentW;
toolbarRoot.Height = toolbarContentH; 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 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. // The frame is the draggable window; the content itself is not.
toolbarRoot.ClickThrough = false; toolbarRoot.ClickThrough = false;
toolbarRoot.Draggable = false; toolbarRoot.Draggable = false;
toolbarRoot.Resizable = false; toolbarRoot.Resizable = false;
toolbarFrame.AddChild(toolbarRoot); 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); _uiHost.Root.AddChild(toolbarFrame);
Console.WriteLine("[D.5.1] retail toolbar window from LayoutDesc importer (0x21000016)."); Console.WriteLine("[D.5.1] retail toolbar window from LayoutDesc importer (0x21000016).");

View 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);
}

View file

@ -125,11 +125,19 @@ public abstract class UiElement
public float MinWidth { get; set; } = 40f; public float MinWidth { get; set; } = 40f;
public float MinHeight { 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> /// <summary>Allow horizontal (width) resize. Ignored unless <see cref="Resizable"/>.</summary>
public bool ResizeX { get; set; } = true; public bool ResizeX { get; set; } = true;
/// <summary>Allow vertical (height) resize. Ignored unless <see cref="Resizable"/>.</summary> /// <summary>Allow vertical (height) resize. Ignored unless <see cref="Resizable"/>.</summary>
public bool ResizeY { get; set; } = true; 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 /// <summary>Edges this element anchors to in its parent. Default Left|Top
/// (pinned top-left, fixed size — no reflow). Left|Right stretches width.</summary> /// (pinned top-left, fixed size — no reflow). Left|Right stretches width.</summary>
public AnchorEdges Anchors { get; set; } = AnchorEdges.Left | AnchorEdges.Top; public AnchorEdges Anchors { get; set; } = AnchorEdges.Left | AnchorEdges.Top;

View file

@ -10,7 +10,7 @@ namespace AcDream.App.UI;
/// the widget is testable without GL. In production: /// 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>. /// <c>id => { var t = cache.GetOrUploadRenderSurface(id, out var w, out var h); return (t, w, h); }</c>.
/// </summary> /// </summary>
public sealed class UiNineSlicePanel : UiPanel public class UiNineSlicePanel : UiPanel
{ {
/// <summary>A placed chrome piece: destination rect in local pixel space.</summary> /// <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); public readonly record struct Rect(float X, float Y, float W, float H);

View file

@ -176,7 +176,8 @@ public sealed class UiRoot : UiElement
var (nx, ny, nw, nh) = ResizeRect( var (nx, ny, nw, nh) = ResizeRect(
_resizeStartX, _resizeStartY, _resizeStartW, _resizeStartH, _resizeStartX, _resizeStartY, _resizeStartW, _resizeStartH,
_resizeEdges, x - _resizeMouseX, y - _resizeMouseY, _resizeEdges, x - _resizeMouseX, y - _resizeMouseY,
_resizeTarget.MinWidth, _resizeTarget.MinHeight); _resizeTarget.MinWidth, _resizeTarget.MinHeight,
float.MaxValue, _resizeTarget.MaxHeight);
_resizeTarget.Left = nx; _resizeTarget.Top = ny; _resizeTarget.Left = nx; _resizeTarget.Top = ny;
_resizeTarget.Width = nw; _resizeTarget.Height = nh; _resizeTarget.Width = nw; _resizeTarget.Height = nh;
return; return;
@ -626,21 +627,23 @@ public sealed class UiRoot : UiElement
if (System.Math.Abs(y - b) <= grip) e |= ResizeEdges.Bottom; if (System.Math.Abs(y - b) <= grip) e |= ResizeEdges.Bottom;
if (!w.ResizeX) e &= ~(ResizeEdges.Left | ResizeEdges.Right); if (!w.ResizeX) e &= ~(ResizeEdges.Left | ResizeEdges.Right);
if (!w.ResizeY) e &= ~(ResizeEdges.Top | ResizeEdges.Bottom); if (!w.ResizeY) e &= ~(ResizeEdges.Top | ResizeEdges.Bottom);
e &= w.ResizableEdges;
return e; return e;
} }
/// <summary>Compute a resized rect from a start rect + drag delta + which edges, /// <summary>Compute a resized rect from a start rect + drag delta + which edges,
/// clamping to (<paramref name="minW"/>,<paramref name="minH"/>). Left/Top edges /// clamping to (<paramref name="minW"/>,<paramref name="minH"/>) and
/// move the origin so the opposite edge stays put.</summary> /// (<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( public static (float x, float y, float w, float h) ResizeRect(
float startX, float startY, float startW, float startH, 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; float x = startX, y = startY, w = startW, h = startH;
if ((edges & ResizeEdges.Right) != 0) w = System.Math.Max(minW, startW + dx); if ((edges & ResizeEdges.Right) != 0) w = System.Math.Clamp(startW + dx, minW, maxW);
if ((edges & ResizeEdges.Bottom) != 0) h = System.Math.Max(minH, startH + dy); if ((edges & ResizeEdges.Bottom) != 0) h = System.Math.Clamp(startH + dy, minH, maxH);
if ((edges & ResizeEdges.Left) != 0) { float nw = System.Math.Max(minW, startW - dx); x = startX + (startW - nw); w = nw; } 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.Max(minH, startH - dy); y = startY + (startH - nh); h = nh; } 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); return (x, y, w, h);
} }

View 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));
}
}

View file

@ -157,7 +157,7 @@ public class UiRootInputTests
public void ResizeRect_RightBottom_GrowsSizeOnly() public void ResizeRect_RightBottom_GrowsSizeOnly()
{ {
var (x, y, w, h) = UiRoot.ResizeRect(10, 20, 100, 50, 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(10f, x); Assert.Equal(20f, y);
Assert.Equal(130f, w); Assert.Equal(65f, h); 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, // 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. // origin shifts so the RIGHT edge (110) stays put → x = 70.
var (x, _, w, _) = UiRoot.ResizeRect(10, 20, 100, 50, 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(40f, w);
Assert.Equal(70f, x); Assert.Equal(70f, x);
} }