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:
parent
0275b4ca5a
commit
01b98ca30c
3 changed files with 613 additions and 18 deletions
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue