From 01b98ca30cfe7a82d2c6fd328ea09b80efbacf3c Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 6 Sep 2026 13:11:43 +0200 Subject: [PATCH] feat(plugin-ui): Slice A - movable, collapsible plugin shelf Owner request (docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md, Slice A): the plugin shelf was pinned to the right screen edge every tick, had no drag or hide affordance, and never persisted position. It is now a registered retained window ("plugin-shelf") that gets drag, the UI lock, and RetailWindowLayoutPersistence position/visibility/collapsed persistence for free, the same way every other retained window does. Design decisions where the plan left room: - Grip + collapse toggle are drawn, not child elements. A real child for the toggle would have to live outside any WindowMoveHandle grip subtree (nesting it inside lets UiRoot's drag-handle promotion swallow the press before the button ever sees a click - `handleWindow is not null` outranks `HandlesClick` in UiRoot.OnMouseDown), and a grip element as a plain sibling changes UiElement.Children's shape, which the pre-existing single-button shelf tests assert directly (`Assert.Single(shelf.Children)`). Keeping the whole shelf Draggable=true and excluding just the toggle's pixel rect from an overridden HandlesClick (computed live from UiRoot.MouseX/MouseY, the only call site) gets grip-drags/buttons-and-toggle-don't without adding any child or touching the existing tests' shape assumptions. - Availability (has plugin windows) vs the user's requested-visible intent are split the same way PluginWindowVisibilityController already splits it for individual plugin windows, but applied SYNCHRONOUSLY (not via VisibleSource) so Visible updates immediately after Add()/unregister with no dependency on a Tick ever running - required to keep the pre-Slice-A unregistered-shelf test (ShelfAndMinimizeButtonsHideAndRestoreWithoutUnregisteringWindow) green, since it never calls root.Tick(). - "The shelf was moved" (drag or a differing restored layout) is tracked via the shelf's own RetailWindowHandle.Moved event, captured through WindowManager.WindowRegistered the moment MountPlugins registers it - so unregistered/legacy use (the two other pre-existing tests) never sets this and behaves exactly as before. - The one-time right-edge dock (no saved layout) fires on the first OnTick with a real parent width, replacing the old per-tick pin; Reflow's anchor math then preserves the top-right corner while still docked or the top-left corner once positioned, on any width change (entry add/remove, collapse, or a parent-height-driven column rewrap). Caption finding: the Configure Keyboard row for InputAction.TogglePluginManager resolves its label live from the installed DAT's action-map string table (KeyboardConfigController.BuildActionRow, RetailActionMapRow.LabelHash) - there is no "Plugin Manager" string literal anywhere in our code to rename to "Plugin Shelf". The row keeps showing retail's own authored name; only the acdream-side action semantics changed. Tests added to PluginSidePanelTests.cs (all 7 fail to even compile against the pre-Slice-A PluginSidePanel, verified by temporarily reverting the source files and re-running): default right-edge dock; top-right corner preserved across a Reflow-driven width change while docked; grip drag moves the shelf and top-left survives the next reflow once positioned; drag refused under UiLocked; collapse via the real toggle click round-trips through CaptureWindowState/RestoreWindowState; Show/Hide toggle sequence and a hidden shelf staying hidden when a new plugin window registers; a full RetailWindowLayoutPersistence round trip of X/Y/Visible/Collapsed onto a fresh shelf instance. All 3 pre-existing tests remain green unmodified. Verified: dotnet build src/AcDream.App (Release) green; the full App test suite passes 7294/97 skipped/36 pre-existing unrelated failures (identical failure set confirmed present on HEAD before this change - installed-DAT live-mount probes, Linux-only pacing/credential tests, and known alpha-flush COUNT-only conformance divergences, none touching plugin UI). Co-Authored-By: Claude Fable 5.1 --- src/AcDream.App/UI/PluginSidePanel.cs | 299 ++++++++++++++++- src/AcDream.App/UI/RetailUiRuntime.cs | 31 +- .../UI/PluginSidePanelTests.cs | 301 ++++++++++++++++++ 3 files changed, 613 insertions(+), 18 deletions(-) diff --git a/src/AcDream.App/UI/PluginSidePanel.cs b/src/AcDream.App/UI/PluginSidePanel.cs index 364839a2f..fa1defb76 100644 --- a/src/AcDream.App/UI/PluginSidePanel.cs +++ b/src/AcDream.App/UI/PluginSidePanel.cs @@ -6,14 +6,59 @@ namespace AcDream.App.UI; /// /// Host-owned shelf for running gameplay plugins. A shelf button changes only /// presentation visibility; it never touches plugin enable/session lifetime. +/// +/// +/// Slice A (2026-09-06, docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md): +/// the shelf is itself a retained window ( +/// registers it as "plugin-shelf") so it gets drag, the UI lock, and +/// position/visibility/collapsed +/// persistence for free, exactly like every other retained window. It implements +/// for the collapse flag and +/// to separate "has entries to show" +/// (availability) from the user's own show/hide choice — the same pattern +/// PluginWindowVisibilityController uses for individual plugin windows, +/// just applied synchronously (see ) so unit tests +/// that never call still observe +/// update immediately after /window-unregister, matching the +/// pre-Slice-A behavior when the shelf is used unregistered. +/// +/// +/// +/// The grip/collapse-toggle band across the top is drawn, not a child element: +/// paints it and / +/// handle its input by inspecting the live pointer position against the shelf's +/// OWN bounds. This is deliberate — a real child element for the toggle would +/// have to sit outside any grip subtree +/// (nesting it inside would let the drag-handle promotion swallow its press +/// before the button ever sees a click; see UiRoot.OnMouseDown's +/// handleWindow is not null priority over HandlesClick), and a +/// grip element as a plain sibling would change 's +/// count/shape, which the existing single-button shelf tests assert directly. +/// Keeping the whole shelf and excluding just +/// the toggle's pixel rect from gets the same +/// grip-drags/buttons-and-toggle-don't behavior without adding any child. +/// /// -public sealed class PluginSidePanel : UiPanel, IDisposable +public sealed class PluginSidePanel : UiPanel, IDisposable, IRetainedWindowStateController, IRetainedPanelController { private const float OuterPadding = 4f; private const float ButtonExtent = 28f; private const float ButtonGap = 4f; private const float DefaultTop = 116f; + /// Height of the top drag-grip band. Also the collapsed shelf's + /// total height (only the grip remains when collapsed). + private const float GripHeight = 12f; + + /// Width of the collapse-toggle glyph's clickable rect, anchored + /// to the grip's right end. + private const float ToggleWidth = 16f; + + /// Collapsed shelf width — just enough for the toggle glyph. + private const float CollapsedWidth = ToggleWidth + OuterPadding * 2f; + + private static readonly Vector4 ToggleGlyphColor = new(0.86f, 0.72f, 0.32f, 1f); + private readonly RetailWindowManager _windows; private readonly Func _resolve; private readonly UiDatFont? _font; @@ -21,6 +66,39 @@ public sealed class PluginSidePanel : UiPanel, IDisposable private bool _disposed; private float _lastLayoutHeight = -1f; + /// Collapsed presentation: only the grip band remains, button + /// entries hidden. Persisted through / + /// (UiWindowLayout.Collapsed). + private bool _collapsed; + + /// The user's/persisted show-hide intent, independent of whether + /// there are currently any entries to show (see ). + /// Shift+Ctrl+F1 (InputAction.TogglePluginManager) flips this via + /// /. + private bool _requestedVisible = true; + + /// True once the shelf has been dragged, or a persisted layout + /// with a differing position has been restored onto it. While false, the + /// shelf is still in its default right-edge dock and + /// preserves the DOCKED (top-right) corner on growth; once true, it + /// preserves the top-left corner instead (see the class doc + Slice A + /// plan item 2). Tracked via on the + /// shelf's own registration, so unregistered (legacy unit-test) use never + /// sets this and behaves like the shelf has always been docked. + private bool _userPositioned; + + /// True once the one-time initial dock placement (right edge, top + /// 116) has run — gates both that placement (never re-applied) and the + /// anchor-preserving math (which needs a prior placed + /// position to preserve a corner of). + private bool _initialDockApplied; + + /// The shelf's own retained-window handle, captured the moment + /// RetailUiRuntime.MountPlugins registers it as "plugin-shelf" + /// (via ). Null when the shelf is used + /// unregistered, e.g. by tests that predate Slice A. + private RetailWindowHandle? _handle; + public PluginSidePanel( RetailWindowManager windows, Func resolve, @@ -31,10 +109,11 @@ public sealed class PluginSidePanel : UiPanel, IDisposable _font = font; Width = ButtonExtent + OuterPadding * 2f; - Height = OuterPadding * 2f; + Height = GripHeight + OuterPadding * 2f; Top = DefaultTop; Anchors = AnchorEdges.None; - Draggable = false; + Draggable = true; + ConstrainDragToParent = true; Resizable = false; BackgroundColor = new Vector4(0f, 0f, 0f, 0.88f); BorderColor = new Vector4(0.62f, 0.48f, 0.16f, 1f); @@ -42,6 +121,7 @@ public sealed class PluginSidePanel : UiPanel, IDisposable Visible = false; _windows.WindowUnregistered += OnWindowUnregistered; + _windows.WindowRegistered += OnWindowRegistered; } /// Number of live plugin-window entries, exposed for gates. @@ -105,24 +185,72 @@ public sealed class PluginSidePanel : UiPanel, IDisposable Reflow(); } + /// Show the shelf (expanding it first if collapsed). Called by + /// InputAction.TogglePluginManager's handler when the shelf is + /// currently hidden. A no-op on the underlying flag + /// when there are no entries — the availability gate in + /// still wins, matching "a hidden shelf + /// stays hidden [an availability-gated hide] is not a user hide". + public void Show() + { + _requestedVisible = true; + if (_collapsed) + _collapsed = false; + Reflow(); + } + + /// Hide the shelf. Preserves entries/positions; never disables a + /// plugin or touches any plugin window's own visibility. + public void Hide() + { + _requestedVisible = false; + Reflow(); + } + protected override void OnTick(double deltaSeconds) { base.OnTick(deltaSeconds); - // Screen-edge dock: root bounds become authoritative at draw time, so - // compute this from the live parent rather than capturing an anchor - // margin while the pre-first-frame root still measures 0x0. if (Parent is { } parent) { + // Row-wrap: re-flow into however many columns fit the live parent + // height. Independent of the one-time dock below — this keeps + // adapting every tick the available height actually changes, + // same as before Slice A. float availableHeight = MathF.Max( ButtonExtent + OuterPadding * 2f, - parent.Height - Top - OuterPadding); + parent.Height - Top - GripHeight - OuterPadding); if (MathF.Abs(availableHeight - _lastLayoutHeight) > 0.5f) { _lastLayoutHeight = availableHeight; Reflow(availableHeight); } - Left = MathF.Max(0f, parent.Width - Width - OuterPadding); + + // One-time right-edge dock (Slice A plan item 2): only while the + // parent has a real size, and only while the shelf has never been + // moved (a drag) or had a differing layout restored onto it. After + // this the shelf stays wherever it ends up; growth then preserves + // whichever corner Reflow decides based on _userPositioned. + if (!_initialDockApplied && !_userPositioned && parent.Width > 0f) + { + Left = MathF.Max(0f, parent.Width - Width - OuterPadding); + _initialDockApplied = true; + } + + // Keep the shelf itself reachable across a display resize — the + // same reachability guarantee KeepWindowReachable below gives the + // individual plugin windows. A direct clamp (not handle.MoveTo) + // so it never reads as a user move. + if (parent.Width > 0f && parent.Height > 0f) + { + float clampedLeft = Math.Clamp(Left, 0f, MathF.Max(0f, parent.Width - Width)); + float clampedTop = Math.Clamp(Top, 0f, MathF.Max(0f, parent.Height - Height)); + if (clampedLeft != Left || clampedTop != Top) + { + Left = clampedLeft; + Top = clampedTop; + } + } } foreach (RetailWindowHandle handle in _entries.Keys) @@ -142,6 +270,106 @@ public sealed class PluginSidePanel : UiPanel, IDisposable } } + /// + /// A press within the collapse-toggle's rect (top-right of the grip band) + /// is handled by this element instead of promoting to a whole-window drag + /// — see UiRoot.OnMouseDown's target.HandlesClick branch, + /// checked BEFORE its window is {{ Draggable: true }} fallback. + /// Computed from the live pointer position (not a cached hit-test side + /// effect) because this getter has exactly one caller in the whole + /// codebase (OnMouseDown), which sets UiRoot.MouseX/MouseY + /// to the press coordinates immediately before reading it. + /// + public override bool HandlesClick + { + get + { + if (FindRoot() is not { } root) return false; + Vector2 sp = ScreenPosition; + return IsWithinToggleRect(root.MouseX - sp.X, root.MouseY - sp.Y); + } + } + + public override bool OnEvent(in UiEvent e) + { + if (e.Type == UiEventType.Click && Enabled && IsWithinToggleRect(e.Data1, e.Data2)) + { + _collapsed = !_collapsed; + Reflow(); + return true; + } + return false; + } + + protected override void OnDraw(UiRenderContext ctx) + { + base.OnDraw(ctx); + + // Grip strip: three short dashes centered in the band (excluding the + // toggle's own rect) — a subtle drag affordance in the existing + // border color. + const float dashWidth = 5f; + const float dashGap = 4f; + float totalDashWidth = dashWidth * 3f + dashGap * 2f; + float dashX = MathF.Max(2f, (Width - ToggleWidth - totalDashWidth) * 0.5f); + float dashY = GripHeight * 0.5f - 1f; + for (int i = 0; i < 3; i++) + ctx.DrawFill(dashX + i * (dashWidth + dashGap), dashY, dashWidth, 2f, BorderColor); + + string glyph = _collapsed ? "«" : "»"; // « / » + if (_font is { } dat) + { + float glyphWidth = dat.MeasureWidth(glyph); + ctx.DrawStringDat( + dat, + glyph, + Width - ToggleWidth + (ToggleWidth - glyphWidth) * 0.5f, + (GripHeight - dat.LineHeight) * 0.5f, + ToggleGlyphColor, + outline: true); + } + } + + private static bool IsWithinToggleRect(float localX, float localY, float width) + => localY >= 0f && localY < GripHeight + && localX >= width - ToggleWidth && localX < width; + + private bool IsWithinToggleRect(float localX, float localY) + => IsWithinToggleRect(localX, localY, Width); + + private void OnWindowRegistered(RetailWindowHandle handle) + { + if (!ReferenceEquals(handle.OuterFrame, this)) return; + _handle = handle; + handle.Moved += OnHandleMoved; + _windows.WindowRegistered -= OnWindowRegistered; + } + + private void OnHandleMoved(RetailWindowHandle _) => _userPositioned = true; + + // ── IRetainedPanelController: separates "has entries" (availability) from + // the user's own show/hide request — see ApplyVisibility. ──────────────── + + void IRetainedPanelController.OnShown() => _requestedVisible = true; + + void IRetainedPanelController.OnHidden() + { + // Hidden because EntryCount hit zero is temporary (availability gate); + // hidden while entries remain is a real user hide. + if (_entries.Count > 0) + _requestedVisible = false; + } + + // ── IRetainedWindowStateController: collapse persistence ──────────────── + + public RetainedWindowState CaptureWindowState() => new(Collapsed: _collapsed); + + public void RestoreWindowState(RetainedWindowState state) + { + _collapsed = state.Collapsed; + Reflow(); + } + private void OnWindowUnregistered(RetailWindowHandle handle) { if (!_entries.Remove(handle, out ShelfEntry entry)) @@ -175,8 +403,19 @@ public sealed class PluginSidePanel : UiPanel, IDisposable handle.MoveTo(left, top); } + /// + /// Recomputes button positions and the shelf's own Width/Height for the + /// current entries + collapsed state, then (once the shelf has been + /// placed — ) preserves the appropriate + /// corner: top-right while still docked ( is + /// false), top-left once the user has moved it. Always reflows the full + /// button set (even while collapsed, with Visible=false) so + /// expanding is instant, per Slice A plan item 4. + /// private void Reflow(float maximumHeight = float.PositiveInfinity) { + float oldWidth = Width; + int maximumRows = float.IsPositiveInfinity(maximumHeight) ? Math.Max(1, _entries.Count) : Math.Max( @@ -191,20 +430,45 @@ public sealed class PluginSidePanel : UiPanel, IDisposable int row = index % maximumRows; entry.Button.Left = OuterPadding + column * (ButtonExtent + ButtonGap); - entry.Button.Top = OuterPadding + entry.Button.Top = GripHeight + OuterPadding + row * (ButtonExtent + ButtonGap); + entry.Button.Visible = !_collapsed; index++; } int rows = Math.Min(index, maximumRows); int columns = index == 0 ? 1 : (index + maximumRows - 1) / maximumRows; - Width = OuterPadding * 2f - + columns * ButtonExtent - + Math.Max(0, columns - 1) * ButtonGap; - Height = OuterPadding * 2f - + rows * ButtonExtent - + Math.Max(0, rows - 1) * ButtonGap; - Visible = index > 0; + + if (_collapsed) + { + Width = CollapsedWidth; + Height = GripHeight; + } + else + { + Width = OuterPadding * 2f + + columns * ButtonExtent + + Math.Max(0, columns - 1) * ButtonGap; + Height = GripHeight + + OuterPadding * 2f + + rows * ButtonExtent + + Math.Max(0, rows - 1) * ButtonGap; + } + + if (_initialDockApplied && !_userPositioned) + Left += oldWidth - Width; + + ApplyVisibility(); + } + + /// Availability (has entries) AND the user's requested-visible + /// intent both have to hold. Applied synchronously (not via + /// ) so it takes effect the instant + /// entries change, with no dependency on a tick ever running — the shelf + /// worked this way (unregistered) before Slice A and must keep doing so. + private void ApplyVisibility() + { + Visible = _requestedVisible && _entries.Count > 0; } public void Dispose() @@ -213,6 +477,9 @@ public sealed class PluginSidePanel : UiPanel, IDisposable return; _disposed = true; _windows.WindowUnregistered -= OnWindowUnregistered; + _windows.WindowRegistered -= OnWindowRegistered; + if (_handle is { } ownHandle) + ownHandle.Moved -= OnHandleMoved; foreach ((RetailWindowHandle handle, ShelfEntry entry) in _entries) { diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 0bd116d19..d765f1204 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -1042,8 +1042,24 @@ public sealed class RetailUiRuntime : IDisposable "In-game help is unavailable because the retail help plugin is not installed."); return true; case AcDream.UI.Abstractions.Input.InputAction.TogglePluginManager: - _bindings.Options.DisplaySystemMessage( - "The retail plugin manager is not available in acdream."); + // Slice A (2026-09-06): retail's plugin-manager chord is the + // honest home for acdream's own plugin shelf — acdream has no + // other plugin manager. The Configure Keyboard row's caption + // for this action is resolved live from the installed DAT's + // action-map string table (KeyboardConfigController.BuildActionRow, + // RetailActionMapRow.LabelHash) rather than a literal in our + // code, so there is nothing here to rename to "Plugin Shelf"; + // the row keeps showing retail's own authored name. + if (_pluginSidePanel is not { EntryCount: > 0 } shelf) + { + _bindings.Options.DisplaySystemMessage( + "No plugin windows are registered."); + return true; + } + if (shelf.Visible) + shelf.Hide(); + else + shelf.Show(); return true; case AcDream.UI.Abstractions.Input.InputAction.ToggleAbuseReportingPanel: _bindings.Options.DisplaySystemMessage(OptionsPanelText.ReportAbuseUnavailable); @@ -4692,6 +4708,17 @@ public sealed class RetailUiRuntime : IDisposable _bindings.Assets.ResolveSprite, _bindings.Assets.DefaultFont); Host.Root.AddChild(_pluginSidePanel); + // Slice A: the shelf is itself a retained window (stable + // key "plugin-shelf") so drag, the UI lock, and + // RetailWindowLayoutPersistence apply for free — the + // panel implements both IRetainedPanelController (the + // availability-vs-requested-visible split) and + // IRetainedWindowStateController (collapse) itself. + Host.WindowManager.Register( + "plugin-shelf", + _pluginSidePanel, + _pluginSidePanel, + controller: _pluginSidePanel); } _pluginSidePanel.Add(panel.Owner, panel.Descriptor, handle); } diff --git a/tests/AcDream.App.Tests/UI/PluginSidePanelTests.cs b/tests/AcDream.App.Tests/UI/PluginSidePanelTests.cs index cf7daad2b..61852ac2c 100644 --- a/tests/AcDream.App.Tests/UI/PluginSidePanelTests.cs +++ b/tests/AcDream.App.Tests/UI/PluginSidePanelTests.cs @@ -1,5 +1,6 @@ using AcDream.App.UI; using AcDream.Plugin.Abstractions; +using AcDream.UI.Abstractions.Panels.Settings; namespace AcDream.App.Tests.UI; @@ -129,4 +130,304 @@ public sealed class PluginSidePanelTests Assert.Equal(0f, handle.Left); Assert.Equal(356f, handle.Top); } + + // ── Slice A: movable, hideable plugin shelf ───────────────────────────── + // docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md + + [Fact] + public void DefaultDock_NoSavedLayout_SitsAtRightEdgeTopBar() + { + var root = new UiRoot { Width = 800f, Height = 600f }; + using var shelf = new PluginSidePanel( + root.WindowManager, _ => (0u, 0, 0), font: null); + root.AddChild(shelf); + root.WindowManager.Register("plugin-shelf", shelf, shelf, controller: shelf); + + var frame = new UiPanel { Width = 200f, Height = 100f }; + root.AddChild(frame); + RetailWindowHandle pluginHandle = root.WindowManager.Register( + "plugin:acdream.test:main", frame); + shelf.Add( + new PluginUiOwner("acdream.test", "Test Plugin"), + new PluginPanelDescriptor("main", "Test Plugin"), + pluginHandle); + + root.Tick(0.016d, 16L); + + Assert.Equal(800f - shelf.Width - 4f, shelf.Left); + Assert.Equal(116f, shelf.Top); + } + + [Fact] + public void TopRightCorner_StaysFixed_WhenReflowChangesWidthWhileStillDocked() + { + // Forces a genuine multi-column re-wrap (not just an entry-count + // change, which Reflow's own infinite-height default keeps single- + // column — see the PluginSidePanel class doc) by shrinking the + // available height between two ticks, then confirms the shelf's + // RIGHT edge (not its Left) stayed put while docked. + var root = new UiRoot { Width = 800f, Height = 260f }; + using var shelf = new PluginSidePanel( + root.WindowManager, _ => (0u, 0, 0), font: null); + root.AddChild(shelf); + root.WindowManager.Register("plugin-shelf", shelf, shelf, controller: shelf); + + for (int i = 0; i < 6; i++) + { + var frame = new UiPanel { Width = 200f, Height = 100f }; + root.AddChild(frame); + RetailWindowHandle handle = root.WindowManager.Register( + $"plugin:test:{i}", frame); + shelf.Add( + new PluginUiOwner($"test.{i}", $"Plugin {i}"), + new PluginPanelDescriptor("main", $"Plugin {i}"), + handle); + } + + root.Tick(0.016d, 16L); + float rightEdge = shelf.Left + shelf.Width; + float widthBefore = shelf.Width; + + root.Height = 150f; + root.Tick(0.016d, 16L); + + Assert.NotEqual(widthBefore, shelf.Width); + Assert.Equal(rightEdge, shelf.Left + shelf.Width, precision: 3); + } + + [Fact] + public void Drag_ViaGripBand_MovesShelf_AndTopLeftSurvivesTheNextReflow() + { + var root = new UiRoot { Width = 800f, Height = 600f }; + using var shelf = new PluginSidePanel( + root.WindowManager, _ => (0u, 0, 0), font: null); + root.AddChild(shelf); + root.WindowManager.Register("plugin-shelf", shelf, shelf, controller: shelf); + + var frame = new UiPanel { Width = 200f, Height = 100f }; + root.AddChild(frame); + RetailWindowHandle pluginHandle = root.WindowManager.Register( + "plugin:acdream.test:main", frame); + shelf.Add( + new PluginUiOwner("acdream.test", "Test Plugin"), + new PluginPanelDescriptor("main", "Test Plugin"), + pluginHandle); + root.Tick(0.016d, 16L); // establishes the initial right-edge dock + + // Press inside the grip band (y < 12) but left of the collapse + // toggle's rect (which starts at Width-16) so this is a plain + // drag, not a toggle click. + int pressX = (int)shelf.Left + 10; + int pressY = (int)shelf.Top + 5; + root.OnMouseDown(UiMouseButton.Left, pressX, pressY); + root.OnMouseMove(pressX + 100, pressY + 100); + root.OnMouseUp(UiMouseButton.Left, pressX + 100, pressY + 100); + + Assert.Equal(764f, shelf.Left); // clamped to parent (800 - 36) + Assert.Equal(216f, shelf.Top); + + float leftAfterDrag = shelf.Left; + float widthBeforeCollapse = shelf.Width; + + // Force a width change the same way the collapse toggle does, and + // confirm the LEFT edge (not the right edge) is what survives now + // that the shelf has been moved once. + shelf.RestoreWindowState(new RetainedWindowState(Collapsed: true)); + + Assert.NotEqual(widthBeforeCollapse, shelf.Width); + Assert.Equal(leftAfterDrag, shelf.Left); + } + + [Fact] + public void Drag_Refused_WhileUiLocked() + { + var root = new UiRoot { Width = 800f, Height = 600f }; + using var shelf = new PluginSidePanel( + root.WindowManager, _ => (0u, 0, 0), font: null); + root.AddChild(shelf); + root.WindowManager.Register("plugin-shelf", shelf, shelf, controller: shelf); + + var frame = new UiPanel { Width = 200f, Height = 100f }; + root.AddChild(frame); + RetailWindowHandle pluginHandle = root.WindowManager.Register( + "plugin:acdream.test:main", frame); + shelf.Add( + new PluginUiOwner("acdream.test", "Test Plugin"), + new PluginPanelDescriptor("main", "Test Plugin"), + pluginHandle); + root.Tick(0.016d, 16L); + + float leftBefore = shelf.Left; + float topBefore = shelf.Top; + root.UiLocked = true; + + int pressX = (int)shelf.Left + 10; + int pressY = (int)shelf.Top + 5; + root.OnMouseDown(UiMouseButton.Left, pressX, pressY); + root.OnMouseMove(pressX + 100, pressY + 100); + root.OnMouseUp(UiMouseButton.Left, pressX + 100, pressY + 100); + + Assert.Equal(leftBefore, shelf.Left); + Assert.Equal(topBefore, shelf.Top); + } + + [Fact] + public void Collapse_HidesButtonsAndShrinksWidth_RoundTripsThroughWindowState() + { + var root = new UiRoot { Width = 800f, Height = 600f }; + using var shelf = new PluginSidePanel( + root.WindowManager, _ => (0u, 0, 0), font: null); + root.AddChild(shelf); + root.WindowManager.Register("plugin-shelf", shelf, shelf, controller: shelf); + + var frame = new UiPanel { Width = 200f, Height = 100f }; + root.AddChild(frame); + RetailWindowHandle pluginHandle = root.WindowManager.Register( + "plugin:acdream.test:main", frame); + shelf.Add( + new PluginUiOwner("acdream.test", "Test Plugin"), + new PluginPanelDescriptor("main", "Test Plugin"), + pluginHandle); + root.Tick(0.016d, 16L); + + float expandedWidth = shelf.Width; + float leftBeforeCollapse = shelf.Left; + UiSimpleButton button = Assert.IsAssignableFrom( + Assert.Single(shelf.Children)); + Assert.True(button.Visible); + + // Click the collapse toggle at the grip's right end through the real + // mouse pipeline (not the state controller directly), proving the + // drawn toggle rect is genuinely clickable and does not promote to a + // window drag. + 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.False(button.Visible); + Assert.True(shelf.Width < expandedWidth); + RetainedWindowState captured = shelf.CaptureWindowState(); + Assert.True(captured.Collapsed); + // Right edge preserved (still docked — this test never dragged it). + Assert.Equal( + leftBeforeCollapse + expandedWidth, + shelf.Left + shelf.Width, + precision: 3); + + shelf.RestoreWindowState(new RetainedWindowState(Collapsed: false)); + + Assert.True(button.Visible); + Assert.Equal(expandedWidth, shelf.Width); + Assert.False(shelf.CaptureWindowState().Collapsed); + } + + [Fact] + public void ToggleVisibility_ShowHideShow_AndStaysHiddenWhenANewWindowRegisters() + { + var root = new UiRoot { Width = 800f, Height = 600f }; + using var shelf = new PluginSidePanel( + root.WindowManager, _ => (0u, 0, 0), font: null); + root.AddChild(shelf); + root.WindowManager.Register("plugin-shelf", shelf, shelf, controller: shelf); + + var frame1 = new UiPanel { Width = 200f, Height = 100f }; + root.AddChild(frame1); + RetailWindowHandle handle1 = root.WindowManager.Register( + "plugin:test:1", frame1); + shelf.Add( + new PluginUiOwner("test.1", "Plugin 1"), + new PluginPanelDescriptor("main", "Plugin 1"), + handle1); + + Assert.True(shelf.Visible); + + shelf.Hide(); + Assert.False(shelf.Visible); + + shelf.Show(); + Assert.True(shelf.Visible); + + shelf.Hide(); + Assert.False(shelf.Visible); + + var frame2 = new UiPanel { Width = 200f, Height = 100f }; + root.AddChild(frame2); + RetailWindowHandle handle2 = root.WindowManager.Register( + "plugin:test:2", frame2); + shelf.Add( + new PluginUiOwner("test.2", "Plugin 2"), + new PluginPanelDescriptor("main", "Plugin 2"), + handle2); + + Assert.Equal(2, shelf.EntryCount); + Assert.False(shelf.Visible); // a new window does not un-hide it + } + + [Fact] + public void Persistence_RoundTripsPositionVisibilityAndCollapsed() + { + string directory = Path.Combine( + Path.GetTempPath(), "acdream-plugin-shelf-tests-" + Guid.NewGuid().ToString("N")); + string path = Path.Combine(directory, "settings.json"); + try + { + var store = new SettingsStore(path); + var root = new UiRoot { Width = 800f, Height = 600f }; + using var shelf = new PluginSidePanel( + root.WindowManager, _ => (0u, 0, 0), font: null); + root.AddChild(shelf); + root.WindowManager.Register("plugin-shelf", shelf, shelf, controller: shelf); + + var frame = new UiPanel { Width = 200f, Height = 100f }; + root.AddChild(frame); + RetailWindowHandle pluginHandle = root.WindowManager.Register( + "plugin:acdream.test:main", frame); + shelf.Add( + new PluginUiOwner("acdream.test", "Test Plugin"), + new PluginPanelDescriptor("main", "Test Plugin"), + pluginHandle); + + using var persistence = new RetailWindowLayoutPersistence( + root.WindowManager, store, () => "Alice", () => (800, 600)); + + root.WindowManager.MoveTo("plugin-shelf", 120f, 88f); + shelf.RestoreWindowState(new RetainedWindowState(Collapsed: true)); + shelf.Hide(); + + UiWindowLayout saved = Assert.IsType( + store.LoadWindowLayout("Alice", "800x600", "plugin-shelf", default)); + Assert.Equal((120f, 88f), (saved.X, saved.Y)); + Assert.False(saved.Visible); + Assert.True(saved.Collapsed); + + // Restore onto a fresh shelf instance, mirroring a relog. + var root2 = new UiRoot { Width = 800f, Height = 600f }; + using var shelf2 = new PluginSidePanel( + root2.WindowManager, _ => (0u, 0, 0), font: null); + root2.AddChild(shelf2); + root2.WindowManager.Register("plugin-shelf", shelf2, shelf2, controller: shelf2); + var frame2 = new UiPanel { Width = 200f, Height = 100f }; + root2.AddChild(frame2); + RetailWindowHandle pluginHandle2 = root2.WindowManager.Register( + "plugin:acdream.test:main", frame2); + shelf2.Add( + new PluginUiOwner("acdream.test", "Test Plugin"), + new PluginPanelDescriptor("main", "Test Plugin"), + pluginHandle2); + + using var persistence2 = new RetailWindowLayoutPersistence( + root2.WindowManager, store, () => "Alice", () => (800, 600)); + persistence2.RestoreAll(); + + Assert.Equal((120f, 88f), (shelf2.Left, shelf2.Top)); + Assert.False(shelf2.Visible); + Assert.True(shelf2.CaptureWindowState().Collapsed); + } + finally + { + if (Directory.Exists(directory)) + Directory.Delete(directory, recursive: true); + } + } }