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