diff --git a/src/AcDream.App/UI/PluginSidePanel.cs b/src/AcDream.App/UI/PluginSidePanel.cs index b43f8b48e..2e91bc2c8 100644 --- a/src/AcDream.App/UI/PluginSidePanel.cs +++ b/src/AcDream.App/UI/PluginSidePanel.cs @@ -224,11 +224,29 @@ public sealed class PluginSidePanel : UiPanel, IDisposable, IRetainedWindowState BorderThickness = 1f; Visible = false; + // Anchors = None on both: the shelf is the SOLE layout owner of these + // children (LayoutChrome, called from Reflow, repositions them every + // time the shelf's own Width/Height changes — collapse/expand, + // entries added/removed, row-wrap). UiElement.ApplyAnchor is called + // for every child on every draw; for any child whose Anchors is not + // AnchorEdges.None it captures the Left/Top/Width/Height margins + // ONCE on the FIRST draw and re-applies that frozen snapshot on + // every subsequent draw, silently overwriting whatever LayoutChrome + // had just written. Left at the default Left|Top, the grip/toggle + // therefore stayed pinned at their EXPANDED geometry after a + // collapse (owner report 2026-09-06: the toggle ended up outside + // the collapsed shelf and the ancestor clip removed it — "I dont + // see the < after I minimize the window"). Anchoring is simply the + // wrong mechanism for a child whose layout owner already re-derives + // its full geometry every reflow; ResetAnchorCapture() would only + // patch this one call site; the actual fix is to never engage the + // capture-and-freeze machinery for these children at all. _grip = new ShelfGripPanel { WindowMoveHandle = true, BackgroundColor = Vector4.Zero, BorderColor = Vector4.Zero, + Anchors = AnchorEdges.None, }; _toggle = new UiSimpleButton { @@ -238,6 +256,7 @@ public sealed class PluginSidePanel : UiPanel, IDisposable, IRetainedWindowState DatFont = _font, Outline = true, TextSource = () => _collapsed ? "<" : ">", + Anchors = AnchorEdges.None, }; _toggle.Click += ToggleCollapsed; AddChild(_grip); @@ -285,6 +304,13 @@ public sealed class PluginSidePanel : UiPanel, IDisposable, IRetainedWindowState { Width = ButtonExtent, Height = ButtonExtent, + // Anchors = None for the same reason as _grip/_toggle above: + // Reflow() is the sole owner of each entry button's Left/Top + // (row-wrap column/row placement), rewritten on every add/ + // remove/collapse. Left at the default anchor, ApplyAnchor would + // freeze a button at whatever column/row it first drew in and + // never let a later removal's row-wrap actually move it. + Anchors = AnchorEdges.None, }; button.Click += () => { diff --git a/tests/AcDream.App.Tests/UI/PluginSidePanelToggleGlyphClipTests.cs b/tests/AcDream.App.Tests/UI/PluginSidePanelToggleGlyphClipTests.cs index fee36108a..34b74ed0b 100644 --- a/tests/AcDream.App.Tests/UI/PluginSidePanelToggleGlyphClipTests.cs +++ b/tests/AcDream.App.Tests/UI/PluginSidePanelToggleGlyphClipTests.cs @@ -266,4 +266,236 @@ public sealed class PluginSidePanelToggleGlyphClipTests Assert.True(shelf.Height > 20f, $"collapsed shelf height ({shelf.Height}) is not button-sized"); Assert.NotEqual(expandedHeight, shelf.Height); } + + /// + /// Owner-reported defect (2026-09-06 live probe): after collapsing the + /// shelf, the grip and toggle stayed pinned at the EXPANDED geometry — + /// the toggle ended up outside the 24px collapsed shelf and the ancestor + /// clip removed it, so the owner saw the tab with no <. + /// + /// + /// Root cause: is called for every + /// child on every draw. For any child whose + /// is not , it captures the Left/Top/Width/ + /// Height margins ONCE on the FIRST draw and re-applies that snapshot on + /// every subsequent draw — overwriting whatever + /// /Reflow set in between. + /// never draws, so a test that only ticks (as + /// every prior collapse test in this tree does) never exercises this path + /// at all — this test drives two REAL passes, + /// with a real toggle click in between, to reproduce it. + /// + /// + /// + /// Uses a bitmap-fallback font (font: null) deliberately, per the + /// investigation's direction, so this reproduction runs in every CI lane + /// without an installed retail DAT — unlike the Lane=InstalledDat + /// tests above, which require one. + /// + /// + [Fact] + public void Collapse_AfterADraw_RepositionsGripAndToggle_NotFrozenAtExpandedGeometry() + { + var root = new UiRoot { Width = 800f, Height = 600f }; + using var shelf = new PluginSidePanel(root.WindowManager, _ => (0u, 0, 0), font: null); + root.AddChild(shelf); + + for (int i = 0; i < 2; i++) + { + var frame = new UiPanel { Width = 200f, Height = 100f }; + root.AddChild(frame); + RetailWindowHandle handle = root.WindowManager.Register( + $"plugin:acdream.test:{i}", frame); + shelf.Add( + new PluginUiOwner($"acdream.test.{i}", $"Test Plugin {i}"), + new PluginPanelDescriptor("main", $"Test Plugin {i}"), + handle); + } + + root.Tick(0.016d, 16L); + + var device = new RecordingGpuDevice(); + var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused"); + var ctx = new UiRenderContext(renderer, new Vector2(800f, 600f)); + + // ── First draw: this is what CAPTURES the anchor snapshot (at the + // expanded geometry) — nothing is wrong yet, since the snapshot + // matches the current (correct) layout. ────────────────────────── + renderer.Begin(new Vector2(800f, 600f)); + root.Draw(ctx); + + UiSimpleButton toggle = Assert.Single( + shelf.Children, + c => c is UiSimpleButton && c is not PluginSidePanel.PluginShelfButton) as UiSimpleButton + ?? throw new InvalidOperationException("toggle button not found"); + UiElement grip = Assert.Single( + shelf.Children, + c => c is UiPanel && c is not UiSimpleButton); + + float expandedShelfHeight = shelf.Height; + float expandedBandHeight = shelf.ExpandedGripBandHeight; + float toggleWidth = toggle.Width; + + Assert.Equal(0f, grip.Left); + Assert.Equal(0f, grip.Top); + Assert.Equal(shelf.Width - toggleWidth, grip.Width, 3); + Assert.Equal(expandedBandHeight, grip.Height, 3); + Assert.Equal(shelf.Width - toggleWidth, toggle.Left, 3); + Assert.Equal(0f, toggle.Top); + Assert.Equal(expandedBandHeight, toggle.Height, 3); + + // ── Collapse via a REAL press/release through UiRoot (the production + // input path), not the state controller directly. ──────────────── + int toggleX = (int)shelf.Left + (int)shelf.Width - 8; + int toggleY = (int)shelf.Top + 4; + root.OnMouseDown(UiMouseButton.Left, toggleX, toggleY); + root.OnMouseUp(UiMouseButton.Left, toggleX, toggleY); + Assert.True(shelf.CaptureWindowState().Collapsed); + + // ── Second draw: must reflect the COLLAPSED geometry. Pre-fix, the + // anchor snapshot captured above wins instead: grip/toggle stay at + // their EXPANDED Left/Width/Height regardless of what LayoutChrome + // just wrote via Reflow. ─────────────────────────────────────────── + renderer.Begin(new Vector2(800f, 600f)); + root.Draw(ctx); + + Assert.Equal(0f, grip.Left); + Assert.Equal(0f, grip.Top); + Assert.Equal(shelf.Width - toggleWidth, grip.Width, 3); + Assert.Equal(shelf.Height, grip.Height, 3); + Assert.Equal(shelf.Width - toggleWidth, toggle.Left, 3); + Assert.Equal(0f, toggle.Top); + Assert.Equal(shelf.Height, toggle.Height, 3); + Assert.True( + toggle.Left + toggle.Width <= shelf.Width + 0.01f, + $"toggle (Left={toggle.Left}, Width={toggle.Width}) lies outside " + + $"the collapsed shelf (Width={shelf.Width}) — the ancestor clip " + + "would remove it, matching the owner's \"no <\" report."); + + // ── Expand again: the geometry must be restored too, via another + // real click. ────────────────────────────────────────────────────── + int toggleX2 = (int)shelf.Left + (int)shelf.Width - 8; + int toggleY2 = (int)shelf.Top + 4; + root.OnMouseDown(UiMouseButton.Left, toggleX2, toggleY2); + root.OnMouseUp(UiMouseButton.Left, toggleX2, toggleY2); + Assert.False(shelf.CaptureWindowState().Collapsed); + + renderer.Begin(new Vector2(800f, 600f)); + root.Draw(ctx); + + Assert.Equal(expandedShelfHeight, shelf.Height, 3); + Assert.Equal(0f, grip.Left); + Assert.Equal(0f, grip.Top); + Assert.Equal(shelf.Width - toggleWidth, grip.Width, 3); + Assert.Equal(expandedBandHeight, grip.Height, 3); + Assert.Equal(shelf.Width - toggleWidth, toggle.Left, 3); + Assert.Equal(0f, toggle.Top); + Assert.Equal(expandedBandHeight, toggle.Height, 3); + } + + /// + /// Same double-draw pin as + /// , + /// but for the ENTRIES + /// rather than the grip/toggle: a multi-column row-wrapped layout, draw, + /// remove one entry (a real window unregister — the same path a plugin + /// unload takes), draw again. Every remaining button's Left/Top must + /// match what a FRESH single-pass layout of the same final entry set + /// would produce — not a stale position captured from the 12-entry + /// layout on the first draw. + /// + /// + /// Compares against an independent "reference" shelf built directly with + /// the final (post-removal) entry set and drawn exactly once, so the + /// expected geometry is derived from the real layout math rather than + /// re-hard-coded private constants (OuterPadding/ButtonExtent/ + /// ButtonGap are private to ). + /// + /// + [Fact] + public void MultiColumnReflow_AfterADraw_EveryRemainingButtonMatchesAFreshSinglePassLayout() + { + const int entryCount = 12; + + var root = new UiRoot { Width = 800f, Height = 260f }; + using var shelf = new PluginSidePanel(root.WindowManager, _ => (0u, 0, 0), font: null); + root.AddChild(shelf); + + var handles = new RetailWindowHandle[entryCount]; + for (int i = 0; i < entryCount; i++) + { + var frame = new UiPanel { Width = 200f, Height = 100f }; + root.AddChild(frame); + handles[i] = root.WindowManager.Register($"plugin:acdream.test:{i}", frame); + shelf.Add( + new PluginUiOwner($"acdream.test.{i}", $"Test Plugin {i}"), + new PluginPanelDescriptor("main", $"Test Plugin {i}"), + handles[i]); + } + + root.Tick(0.016d, 16L); + + var device = new RecordingGpuDevice(); + var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused"); + var ctx = new UiRenderContext(renderer, new Vector2(800f, 600f)); + + // ── First draw: captures anchors at the full 12-entry layout. ───── + renderer.Begin(new Vector2(800f, 600f)); + root.Draw(ctx); + + // ── Remove the FIRST entry — every remaining button shifts down one + // slot, so this exercises the widest possible reflow. ───────────── + root.WindowManager.Unregister(handles[0].Name); + Assert.Equal(entryCount - 1, shelf.EntryCount); + + // ── Second draw: must reflect the fresh 11-entry reflow. Pre-fix, + // each surviving button stays pinned at its ORIGINAL 12-entry + // position/size instead. ─────────────────────────────────────────── + renderer.Begin(new Vector2(800f, 600f)); + root.Draw(ctx); + + List afterRemoval = + shelf.Children.OfType().ToList(); + Assert.Equal(entryCount - 1, afterRemoval.Count); + + // ── Reference: an independent shelf built directly with the SAME + // final 11-entry set (never having been through a 12-entry layout), + // ticked and drawn exactly once. ─────────────────────────────────── + var referenceRoot = new UiRoot { Width = 800f, Height = 260f }; + using var referenceShelf = new PluginSidePanel( + referenceRoot.WindowManager, _ => (0u, 0, 0), font: null); + referenceRoot.AddChild(referenceShelf); + for (int i = 1; i < entryCount; i++) + { + var frame = new UiPanel { Width = 200f, Height = 100f }; + referenceRoot.AddChild(frame); + RetailWindowHandle handle = referenceRoot.WindowManager.Register( + $"plugin:acdream.reference:{i}", frame); + referenceShelf.Add( + new PluginUiOwner($"acdream.reference.{i}", $"Test Plugin {i}"), + new PluginPanelDescriptor("main", $"Test Plugin {i}"), + handle); + } + referenceRoot.Tick(0.016d, 16L); + var referenceDevice = new RecordingGpuDevice(); + var referenceRenderer = new TextRenderer(referenceDevice, new NullGpuFrameSource(), "unused"); + var referenceCtx = new UiRenderContext(referenceRenderer, new Vector2(800f, 600f)); + referenceRenderer.Begin(new Vector2(800f, 600f)); + referenceRoot.Draw(referenceCtx); + + List reference = + referenceShelf.Children.OfType().ToList(); + Assert.Equal(reference.Count, afterRemoval.Count); + + for (int i = 0; i < afterRemoval.Count; i++) + { + Assert.Equal(reference[i].Left, afterRemoval[i].Left, 3); + Assert.Equal(reference[i].Top, afterRemoval[i].Top, 3); + Assert.Equal(reference[i].Width, afterRemoval[i].Width, 3); + Assert.Equal(reference[i].Height, afterRemoval[i].Height, 3); + } + + Assert.Equal(shelf.Width, referenceShelf.Width, 3); + Assert.Equal(shelf.Height, referenceShelf.Height, 3); + } }