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 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-06 13:11:43 +02:00
parent 0275b4ca5a
commit 01b98ca30c
3 changed files with 613 additions and 18 deletions

View file

@ -6,14 +6,59 @@ namespace AcDream.App.UI;
/// <summary>
/// Host-owned shelf for running gameplay plugins. A shelf button changes only
/// presentation visibility; it never touches plugin enable/session lifetime.
///
/// <para>
/// Slice A (2026-09-06, <c>docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md</c>):
/// the shelf is itself a retained window (<see cref="RetailUiRuntime.MountPlugins"/>
/// registers it as <c>"plugin-shelf"</c>) so it gets drag, the UI lock, and
/// <see cref="RetailWindowLayoutPersistence"/> position/visibility/collapsed
/// persistence for free, exactly like every other retained window. It implements
/// <see cref="IRetainedWindowStateController"/> for the collapse flag and
/// <see cref="IRetainedPanelController"/> to separate "has entries to show"
/// (availability) from the user's own show/hide choice — the same pattern
/// <c>PluginWindowVisibilityController</c> uses for individual plugin windows,
/// just applied synchronously (see <see cref="ApplyVisibility"/>) so unit tests
/// that never call <see cref="UiRoot.Tick"/> still observe <see cref="Visible"/>
/// update immediately after <see cref="Add"/>/window-unregister, matching the
/// pre-Slice-A behavior when the shelf is used unregistered.
/// </para>
///
/// <para>
/// The grip/collapse-toggle band across the top is drawn, not a child element:
/// <see cref="OnDraw"/> paints it and <see cref="HandlesClick"/>/<see cref="OnEvent"/>
/// 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 <see cref="UiElement.WindowMoveHandle"/> grip subtree
/// (nesting it inside would let the drag-handle promotion swallow its press
/// before the button ever sees a click; see <c>UiRoot.OnMouseDown</c>'s
/// <c>handleWindow is not null</c> priority over <c>HandlesClick</c>), and a
/// grip element as a plain sibling would change <see cref="UiElement.Children"/>'s
/// count/shape, which the existing single-button shelf tests assert directly.
/// Keeping the whole shelf <see cref="UiElement.Draggable"/> and excluding just
/// the toggle's pixel rect from <see cref="HandlesClick"/> gets the same
/// grip-drags/buttons-and-toggle-don't behavior without adding any child.
/// </para>
/// </summary>
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;
/// <summary>Height of the top drag-grip band. Also the collapsed shelf's
/// total height (only the grip remains when collapsed).</summary>
private const float GripHeight = 12f;
/// <summary>Width of the collapse-toggle glyph's clickable rect, anchored
/// to the grip's right end.</summary>
private const float ToggleWidth = 16f;
/// <summary>Collapsed shelf width — just enough for the toggle glyph.</summary>
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<uint, (uint tex, int width, int height)> _resolve;
private readonly UiDatFont? _font;
@ -21,6 +66,39 @@ public sealed class PluginSidePanel : UiPanel, IDisposable
private bool _disposed;
private float _lastLayoutHeight = -1f;
/// <summary>Collapsed presentation: only the grip band remains, button
/// entries hidden. Persisted through <see cref="CaptureWindowState"/>/
/// <see cref="RestoreWindowState"/> (<c>UiWindowLayout.Collapsed</c>).</summary>
private bool _collapsed;
/// <summary>The user's/persisted show-hide intent, independent of whether
/// there are currently any entries to show (see <see cref="ApplyVisibility"/>).
/// Shift+Ctrl+F1 (<c>InputAction.TogglePluginManager</c>) flips this via
/// <see cref="Show"/>/<see cref="Hide"/>.</summary>
private bool _requestedVisible = true;
/// <summary>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 <see cref="Reflow"/>
/// 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 <see cref="RetailWindowHandle.Moved"/> on the
/// shelf's own registration, so unregistered (legacy unit-test) use never
/// sets this and behaves like the shelf has always been docked.</summary>
private bool _userPositioned;
/// <summary>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 <see cref="Reflow"/> math (which needs a prior placed
/// position to preserve a corner of).</summary>
private bool _initialDockApplied;
/// <summary>The shelf's own retained-window handle, captured the moment
/// <c>RetailUiRuntime.MountPlugins</c> registers it as <c>"plugin-shelf"</c>
/// (via <see cref="OnWindowRegistered"/>). Null when the shelf is used
/// unregistered, e.g. by tests that predate Slice A.</summary>
private RetailWindowHandle? _handle;
public PluginSidePanel(
RetailWindowManager windows,
Func<uint, (uint tex, int width, int height)> 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;
}
/// <summary>Number of live plugin-window entries, exposed for gates.</summary>
@ -105,24 +185,72 @@ public sealed class PluginSidePanel : UiPanel, IDisposable
Reflow();
}
/// <summary>Show the shelf (expanding it first if collapsed). Called by
/// <c>InputAction.TogglePluginManager</c>'s handler when the shelf is
/// currently hidden. A no-op on the underlying <see cref="Visible"/> flag
/// when there are no entries — the availability gate in
/// <see cref="ApplyVisibility"/> still wins, matching "a hidden shelf
/// stays hidden [an availability-gated hide] is not a user hide".</summary>
public void Show()
{
_requestedVisible = true;
if (_collapsed)
_collapsed = false;
Reflow();
}
/// <summary>Hide the shelf. Preserves entries/positions; never disables a
/// plugin or touches any plugin window's own visibility.</summary>
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
}
}
/// <summary>
/// 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 <c>UiRoot.OnMouseDown</c>'s <c>target.HandlesClick</c> branch,
/// checked BEFORE its <c>window is {{ Draggable: true }}</c> 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 (<c>OnMouseDown</c>), which sets <c>UiRoot.MouseX/MouseY</c>
/// to the press coordinates immediately before reading it.
/// </summary>
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);
}
/// <summary>
/// Recomputes button positions and the shelf's own Width/Height for the
/// current entries + collapsed state, then (once the shelf has been
/// placed — <see cref="_initialDockApplied"/>) preserves the appropriate
/// corner: top-right while still docked (<see cref="_userPositioned"/> is
/// false), top-left once the user has moved it. Always reflows the full
/// button set (even while collapsed, with <c>Visible=false</c>) so
/// expanding is instant, per Slice A plan item 4.
/// </summary>
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();
}
/// <summary>Availability (has entries) AND the user's requested-visible
/// intent both have to hold. Applied synchronously (not via
/// <see cref="UiElement.VisibleSource"/>) 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.</summary>
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)
{

View file

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

View file

@ -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<UiSimpleButton>(
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<UiWindowLayout>(
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);
}
}
}