Two independent Opus reviews of01b98ca30(Slice A - movable, collapsible plugin shelf) found blocking design and behavior issues plus several should-fix gaps. All addressed in this commit: 1. Grip/toggle are now REAL children instead of a drawn band + a cursor-position-dependent HandlesClick override + an OnEvent toggle hack: ShelfGripPanel (WindowMoveHandle=true) spans the top band minus the toggle width; a UiSimpleButton toggle sits beside it (HandlesClick already wins at UiRoot.OnMouseDown before the Draggable-window fallback). The shelf's own Draggable is now FALSE - verified against UiRoot.FindDragHandleWindow, which never reads a window's own Draggable flag at all (it walks for a WindowMoveHandle ancestor-or-self then climbs to the nearest child of UiRoot), so Draggable=true was never required for the grip to work and only armed the whole-window- drag fallback for clicks on the shelf's own padding - exactly the behavior the review asked NOT to have. The two Assert.Single(shelf. Children) test sites now filter by the (now internal) PluginShelfButton type instead of asserting child count. 2. Deleted the per-tick "always highest ZOrder" raise in OnTick. It fought RetailDialogFactory.Tick's own per-frame dialog re-raise, so a dialog opened while the shelf was visible could never end up on top of it. Registration's ordinary press-to-raise (a grip press calls BringToFront before the drag starts) remains. 3. Collapse and hide/show intent are now persisted through their own channel. RetainedWindowState gained a nullable RequestedVisible; RetailWindowHandle gained an internal StateChanged event that RetailWindowLayoutPersistence subscribes to (alongside Moved/Resized/ Shown/Hidden) and that the shelf raises after a collapse toggle or Show/Hide. Capture() now persists state.RequestedVisible (the panel's own intent) instead of the derived IsVisible, so an availability hide (last plugin window unregistered) is never mistaken for a user hide. WindowNames.PluginShelf is now one of RetailUiRuntime's state-managed visibility windows, so Apply() restores the intent through RestoreWindowState directly rather than via Show()/Hide(). 4. RetailWindowLayoutPersistence now subscribes to RetailWindowManager.WindowRegistered/WindowUnregistered so a window (a plugin window loaded after startup, or the shelf on any path that constructs persistence first) attaches even when it registers after persistence already exists. 5. Reflow()'s default height argument is now nullable and falls back to the last height OnTick actually measured (or unbounded if none yet), instead of always forcing a single-column layout - every call site OTHER than OnTick's own row-wrap (Add, unregister, Show/Hide, the collapse toggle, RestoreWindowState) used to collapse a wrapped multi-column layout to one column for a frame. 6. _userPositioned is now flipped only when the handle's position differs from the recorded dock placement, not on every RetailWindowHandle. Moved (which fires unconditionally on any completed window-drag release, including a zero-movement grip click, and on any ClampAllToScreen reachable-clamp). 7. New tests cover: a press+drag starting on an entry button does not move the shelf; the removed per-tick raise (a sibling with higher ZOrder keeps it after a tick); TogglePluginManager's hidden-and- collapsed -> shown-and-expanded / visible -> hidden transitions at the shelf API (no RetailUiRuntime construction harness exists in this test suite to exercise the action-routing switch itself - the "no plugin windows registered" message branch is therefore not covered here). 8. The hide branch of TogglePluginManager now displays "Plugin shelf hidden. Press Shift+Ctrl+F1 to show it again."; the show branch stays silent. 9. The collapse toggle now draws ASCII '<'/'>' instead of the DAT-font- dependent '«'/'»' glyphs (the only use of those code points in the App UI, silently dropped by UiDatFont when absent), and gets the same DatFont + bitmap fallback the shelf's entry buttons already have through UiSimpleButton. A new installed-DAT test pins that the default font actually carries both ASCII glyphs. 10. ResizeX/ResizeY are false on the shelf so a restored layout's saved dimensions can never stomp the derived Width/Height via ResizeTo. 11. WindowNames.PluginShelf replaces the "plugin-shelf" literal at every site (RetailUiRuntime, docs comments, tests). 12. The grip dims to half opacity while RetailWindowManager.IsLocked, the same visual cue every other retail window gets (the shelf's grip has no DatElementId, so RetailWindowLockPresentationController's authored- chrome dimming does not reach it on its own). Every new test was verified to fail against the pre-fix source: reverting src/AcDream.App/UI/PluginSidePanel.cs, IRetainedWindowStateController.cs, RetailWindowHandle.cs, WindowNames.cs, and RetailUiRuntime.cs to their01b98ca30state makes the whole PluginSidePanelTests.cs file fail to even compile (missing WindowNames.PluginShelf, the now-internal PluginShelfButton type, and RetainedWindowState.RequestedVisible); reverting RetailWindowLayoutPersistence.cs alone (fixed source elsewhere) makes WindowRegisteredAfterConstruction_StillRoundTrips fail at runtime with a null saved layout, confirming finding 4 in isolation. Verified: dotnet build src/AcDream.App (Release) green; dotnet test tests/AcDream.App.Tests (Release) targeted filter (PluginSidePanel|RetailWindowLayout|Markup|UiRootInput) 105/105 green; full suite 7303 passed / 97 skipped / 36 failed (identical failure set to the pre-fix-round baseline - installed-DAT live-mount probes, Linux-only pacing/credential tests, and alpha-flush COUNT-only conformance divergences, none touching plugin UI; +9 tests, all passing, over the prior 7294/97/36 baseline). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
756 lines
31 KiB
C#
756 lines
31 KiB
C#
using System.Numerics;
|
|
using AcDream.App.UI;
|
|
using AcDream.Plugin.Abstractions;
|
|
using AcDream.UI.Abstractions.Panels.Settings;
|
|
|
|
namespace AcDream.App.Tests.UI;
|
|
|
|
public sealed class PluginSidePanelTests
|
|
{
|
|
[Fact]
|
|
public void ManyPluginsWrapIntoReachableColumnsWithinTheLiveScreenHeight()
|
|
{
|
|
var root = new UiRoot { Width = 800f, Height = 260f };
|
|
using var shelf = new PluginSidePanel(
|
|
root.WindowManager,
|
|
_ => (0u, 0, 0),
|
|
font: null);
|
|
root.AddChild(shelf);
|
|
|
|
for (int i = 0; i < 12; 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);
|
|
|
|
Assert.Equal(12, shelf.EntryCount);
|
|
Assert.True(shelf.Width > 36f);
|
|
Assert.True(shelf.Top + shelf.Height <= root.Height);
|
|
Assert.All(
|
|
shelf.Children,
|
|
child => Assert.True(child.Top + child.Height <= shelf.Height));
|
|
Assert.Equal(root.Width - shelf.Width - 4f, shelf.Left);
|
|
}
|
|
|
|
[Fact]
|
|
public void ShelfAndMinimizeButtonsHideAndRestoreWithoutUnregisteringWindow()
|
|
{
|
|
var root = new UiRoot { Width = 1280f, Height = 720f };
|
|
var frame = new UiPanel
|
|
{
|
|
Width = 320f,
|
|
Height = 180f,
|
|
Visible = true,
|
|
};
|
|
root.AddChild(frame);
|
|
RetailWindowHandle handle = root.WindowManager.Register(
|
|
"plugin:acdream.test:main",
|
|
frame);
|
|
using var shelf = new PluginSidePanel(
|
|
root.WindowManager,
|
|
_ => (0u, 0, 0),
|
|
font: null);
|
|
root.AddChild(shelf);
|
|
|
|
shelf.Add(
|
|
new PluginUiOwner("acdream.test", "Test Plugin"),
|
|
new PluginPanelDescriptor("main", "Test Plugin")
|
|
{
|
|
IconText = "TP",
|
|
},
|
|
handle);
|
|
|
|
Assert.True(shelf.Visible);
|
|
Assert.Equal(1, shelf.EntryCount);
|
|
// Review fix round finding 1: the grip and collapse toggle are now
|
|
// real sibling children too, so shelf.Children is no longer just the
|
|
// one entry button — filter by the entry-button TYPE instead of
|
|
// asserting a raw child count.
|
|
PluginSidePanel.PluginShelfButton shelfButton = Assert.Single(
|
|
shelf.Children.OfType<PluginSidePanel.PluginShelfButton>());
|
|
|
|
shelfButton.OnEvent(new UiEvent { Type = UiEventType.Click });
|
|
Assert.False(handle.IsVisible);
|
|
Assert.True(handle.IsRegistered);
|
|
|
|
shelfButton.OnEvent(new UiEvent { Type = UiEventType.Click });
|
|
Assert.True(handle.IsVisible);
|
|
|
|
UiSimpleButton minimize = Assert.IsAssignableFrom<UiSimpleButton>(
|
|
Assert.Single(frame.Children));
|
|
minimize.OnEvent(new UiEvent { Type = UiEventType.Click });
|
|
Assert.False(handle.IsVisible);
|
|
Assert.True(handle.IsRegistered);
|
|
|
|
root.WindowManager.Unregister(handle.Name);
|
|
Assert.Equal(0, shelf.EntryCount);
|
|
Assert.False(shelf.Visible);
|
|
}
|
|
|
|
[Fact]
|
|
public void FullWidthPluginWindowStartsAndStaysReachableAtMinimumCanvas()
|
|
{
|
|
var root = new UiRoot { Width = 800f, Height = 600f };
|
|
var frame = new UiPanel
|
|
{
|
|
Left = 28f,
|
|
Top = 42f,
|
|
Width = 800f,
|
|
Height = 244f,
|
|
Visible = true,
|
|
};
|
|
root.AddChild(frame);
|
|
RetailWindowHandle handle = root.WindowManager.Register(
|
|
"plugin:acdream.mosstank:main",
|
|
frame);
|
|
using var shelf = new PluginSidePanel(
|
|
root.WindowManager,
|
|
_ => (0u, 0, 0),
|
|
font: null);
|
|
root.AddChild(shelf);
|
|
|
|
shelf.Add(
|
|
new PluginUiOwner("acdream.mosstank", "MossTank"),
|
|
new PluginPanelDescriptor("main", "MossTank"),
|
|
handle);
|
|
|
|
Assert.Equal(0f, handle.Left);
|
|
Assert.Equal(42f, handle.Top);
|
|
Assert.True(frame.ConstrainDragToParent);
|
|
Assert.True(frame.ConstrainResizeToParent);
|
|
|
|
frame.Left = 700f;
|
|
frame.Top = 590f;
|
|
root.Tick(0.016d, 16L);
|
|
|
|
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(WindowNames.PluginShelf, 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(WindowNames.PluginShelf, 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(WindowNames.PluginShelf, 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(WindowNames.PluginShelf, 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(WindowNames.PluginShelf, 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;
|
|
PluginSidePanel.PluginShelfButton button = Assert.Single(
|
|
shelf.Children.OfType<PluginSidePanel.PluginShelfButton>());
|
|
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(WindowNames.PluginShelf, 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(WindowNames.PluginShelf, 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(WindowNames.PluginShelf, 120f, 88f);
|
|
shelf.RestoreWindowState(new RetainedWindowState(Collapsed: true));
|
|
shelf.Hide();
|
|
|
|
UiWindowLayout saved = Assert.IsType<UiWindowLayout>(
|
|
store.LoadWindowLayout("Alice", "800x600", WindowNames.PluginShelf, 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(WindowNames.PluginShelf, 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);
|
|
}
|
|
}
|
|
|
|
// ── Review fix round (2026-09-06): grip/toggle real children, dialog
|
|
// z-order, persisted collapse/hide intent ──────────────────────────────
|
|
|
|
[Fact]
|
|
public void Drag_StartingOnAnEntryButton_DoesNotMoveTheShelf()
|
|
{
|
|
// Finding 1 / finding 7a: a press that lands on a real entry button
|
|
// (UiSimpleButton.HandlesClick => true) must be handled by the button,
|
|
// never promoted to a whole-shelf drag.
|
|
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(WindowNames.PluginShelf, 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);
|
|
|
|
PluginSidePanel.PluginShelfButton button = Assert.Single(
|
|
shelf.Children.OfType<PluginSidePanel.PluginShelfButton>());
|
|
Vector2 buttonScreen = button.ScreenPosition;
|
|
int pressX = (int)buttonScreen.X + 5;
|
|
int pressY = (int)buttonScreen.Y + 5;
|
|
float leftBefore = shelf.Left;
|
|
float topBefore = shelf.Top;
|
|
|
|
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 PerTickZOrderRaise_Removed_ADialogAddedAfterKeepsItsHigherZOrder()
|
|
{
|
|
// Finding 2: the OnTick block that used to bump the shelf's ZOrder
|
|
// above every sibling, every frame, is gone. Registration's own
|
|
// press-to-raise remains (a grip drag calls UiRoot.BringToFront before
|
|
// the drag starts) but ticking alone must never re-assert dominance —
|
|
// RetailDialogFactory.Tick re-raises open dialogs every frame the same
|
|
// way, and the two must not fight.
|
|
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(WindowNames.PluginShelf, 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);
|
|
|
|
int shelfZBefore = shelf.ZOrder;
|
|
var dialog = new UiPanel { Width = 100f, Height = 60f, ZOrder = shelfZBefore + 50 };
|
|
root.AddChild(dialog);
|
|
|
|
root.Tick(0.016d, 16L);
|
|
root.Tick(0.016d, 16L);
|
|
|
|
Assert.Equal(shelfZBefore, shelf.ZOrder);
|
|
Assert.True(dialog.ZOrder > shelf.ZOrder);
|
|
}
|
|
|
|
[Fact]
|
|
public void ToggleClick_SavesCollapsedOnly_WithNoSubsequentHideOrMove()
|
|
{
|
|
// Finding 3a.
|
|
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(WindowNames.PluginShelf, 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);
|
|
|
|
using var persistence = new RetailWindowLayoutPersistence(
|
|
root.WindowManager, store, () => "Alice", () => (800, 600),
|
|
stateManagedVisibilityWindows: [WindowNames.PluginShelf]);
|
|
|
|
float topBefore = shelf.Top;
|
|
float rightEdgeBefore = shelf.Left + shelf.Width;
|
|
|
|
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);
|
|
|
|
UiWindowLayout saved = Assert.IsType<UiWindowLayout>(
|
|
store.LoadWindowLayout("Alice", "800x600", WindowNames.PluginShelf, default));
|
|
Assert.True(saved.Collapsed);
|
|
Assert.True(saved.Visible); // no Hide happened
|
|
Assert.Equal(topBefore, saved.Y); // no vertical Move happened
|
|
// Collapsing shrinks Width; the shelf is still DOCKED, so Reflow's
|
|
// own anchor math shifts Left to keep the right edge fixed — that
|
|
// is not a "Move" (it never goes through handle.MoveTo/NotifyMoved),
|
|
// just the collapse's own geometry, captured in the same save.
|
|
Assert.Equal(shelf.Left, saved.X);
|
|
Assert.Equal(rightEdgeBefore, shelf.Left + shelf.Width, precision: 3);
|
|
}
|
|
finally
|
|
{
|
|
if (Directory.Exists(directory))
|
|
Directory.Delete(directory, recursive: true);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void UnregisteringTheLastPlugin_DoesNotPersistTheAvailabilityHideAsAUserHide()
|
|
{
|
|
// Finding 3b.
|
|
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(WindowNames.PluginShelf, 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);
|
|
|
|
using var persistence = new RetailWindowLayoutPersistence(
|
|
root.WindowManager, store, () => "Alice", () => (800, 600),
|
|
stateManagedVisibilityWindows: [WindowNames.PluginShelf]);
|
|
|
|
// Establish a genuine "the user wants this visible" save.
|
|
shelf.Show();
|
|
Assert.True(shelf.Visible);
|
|
|
|
root.WindowManager.Unregister(pluginHandle.Name);
|
|
Assert.Equal(0, shelf.EntryCount);
|
|
Assert.False(shelf.Visible); // derived Visible DOES flip (no entries)...
|
|
|
|
// ...but the persisted INTENT must still read visible=true: the
|
|
// availability-hide is not subscribed for a state-managed window.
|
|
UiWindowLayout saved = Assert.IsType<UiWindowLayout>(
|
|
store.LoadWindowLayout("Alice", "800x600", WindowNames.PluginShelf, default));
|
|
Assert.True(saved.Visible);
|
|
}
|
|
finally
|
|
{
|
|
if (Directory.Exists(directory))
|
|
Directory.Delete(directory, recursive: true);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void RestoreWindowState_HiddenAndCollapsedIntent_AppliesDirectly_ThenShowExpandsAndReveals()
|
|
{
|
|
// Finding 3c: restoring Collapsed=true/RequestedVisible=false onto a
|
|
// fresh shelf that already has entries must hide AND collapse it —
|
|
// without going through Show/Hide (RestoreWindowState sets the intent
|
|
// and re-derives Visible through the same synchronous ApplyVisibility
|
|
// path every other state change uses). Show() must then reveal AND
|
|
// expand it.
|
|
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(WindowNames.PluginShelf, 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.True(shelf.Visible);
|
|
|
|
shelf.RestoreWindowState(new RetainedWindowState(Collapsed: true, RequestedVisible: false));
|
|
|
|
Assert.False(shelf.Visible);
|
|
Assert.True(shelf.CaptureWindowState().Collapsed);
|
|
|
|
shelf.Show();
|
|
|
|
Assert.True(shelf.Visible);
|
|
Assert.False(shelf.CaptureWindowState().Collapsed);
|
|
}
|
|
|
|
[Fact]
|
|
public void CollapseExpandViaTheToggle_DoesNotCollapseTheColumnWrapToOneColumn()
|
|
{
|
|
// Finding 5: every Reflow() call site OTHER than OnTick's row-wrap
|
|
// used to default to an unbounded height, forcing a single-column
|
|
// layout for one frame after Add/remove/collapse/expand/restore —
|
|
// reusing _lastLayoutHeight keeps the column count OnTick already
|
|
// established. 12 entries at 800x260 forces a real multi-column wrap.
|
|
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(WindowNames.PluginShelf, shelf, shelf, controller: shelf);
|
|
|
|
for (int i = 0; i < 12; 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);
|
|
|
|
// Collapse via the real toggle click.
|
|
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);
|
|
|
|
// Expand via the real toggle click (coordinates recomputed: the
|
|
// shelf's collapsed Width moved the toggle).
|
|
toggleX = (int)shelf.Left + (int)shelf.Width - 8;
|
|
toggleY = (int)shelf.Top + 4;
|
|
root.OnMouseDown(UiMouseButton.Left, toggleX, toggleY);
|
|
root.OnMouseUp(UiMouseButton.Left, toggleX, toggleY);
|
|
|
|
root.Tick(0.016d, 16L);
|
|
|
|
Assert.True(shelf.Top + shelf.Height <= root.Height);
|
|
Assert.All(
|
|
shelf.Children.OfType<PluginSidePanel.PluginShelfButton>(),
|
|
button => Assert.True(button.Top + button.Height <= shelf.Height));
|
|
}
|
|
|
|
[Fact]
|
|
public void ZeroMovementGripReleaseDoesNotFlipAnchoring_ButARealDragDoes()
|
|
{
|
|
// Finding 6: RetailWindowHandle.Moved fires unconditionally on every
|
|
// completed window-drag gesture — including a press+release on the
|
|
// grip with NO intervening mouse move. (RetailWindowManager.MoveTo
|
|
// itself short-circuits an already-equal position before ever firing
|
|
// Moved, so the meaningful equivalent of "a MoveTo to the identical
|
|
// dock position" is this zero-pixel drag-release, not a same-value
|
|
// MoveTo call.) That must not be mistaken for a user drag; a REAL
|
|
// drag still must.
|
|
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(WindowNames.PluginShelf, 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
|
|
|
|
int pressX = (int)shelf.Left + 10;
|
|
int pressY = (int)shelf.Top + 5;
|
|
root.OnMouseDown(UiMouseButton.Left, pressX, pressY);
|
|
root.OnMouseUp(UiMouseButton.Left, pressX, pressY); // zero movement
|
|
|
|
float rightEdgeBeforeCollapse = shelf.Left + shelf.Width;
|
|
shelf.RestoreWindowState(new RetainedWindowState(Collapsed: true));
|
|
// Still docked: the RIGHT edge survived the width change.
|
|
Assert.Equal(rightEdgeBeforeCollapse, shelf.Left + shelf.Width, precision: 3);
|
|
shelf.RestoreWindowState(new RetainedWindowState(Collapsed: false));
|
|
|
|
// Now a REAL drag via the grip.
|
|
root.OnMouseDown(UiMouseButton.Left, pressX, pressY);
|
|
root.OnMouseMove(pressX + 40, pressY + 40);
|
|
root.OnMouseUp(UiMouseButton.Left, pressX + 40, pressY + 40);
|
|
|
|
float leftAfterDrag = shelf.Left;
|
|
shelf.RestoreWindowState(new RetainedWindowState(Collapsed: true));
|
|
// User-positioned now: the LEFT edge survives instead.
|
|
Assert.Equal(leftAfterDrag, shelf.Left);
|
|
}
|
|
}
|