diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 02600df3..061a839d 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -4767,11 +4767,20 @@ public sealed class RetailUiRuntime : IDisposable // later registration/sidepanel failure then rolls the mounted // subtree back through FailMount instead of leaking it. _bindings.Plugins.CompleteMount(panel, Host.Root, element); + // #490 part 2: derive the authored-geometry revision from + // the panel's own authored extent instead of a hard-coded 0 + // — see RetailWindowLayoutPersistence's class doc and + // RetailWindowManager.ComputeAuthoredGeometryRevision's own + // doc for why a plugin window can't use the built-in + // windows' manual-literal scheme. + int authoredGeometryRevision = RetailWindowManager.ComputeAuthoredGeometryRevision( + element.Width, element.Height, element.MinWidth, element.MinHeight, element.Resizable); RetailWindowHandle handle = Host.WindowManager.Register( panel.WindowName, element, element, - visibility); + visibility, + authoredGeometryRevision: authoredGeometryRevision); _bindings.Plugins.CompleteWindowMount( panel, () => Host.WindowManager.Unregister(panel.WindowName)); diff --git a/src/AcDream.App/UI/RetailWindowLayoutPersistence.cs b/src/AcDream.App/UI/RetailWindowLayoutPersistence.cs index ca1bf655..ee2baf95 100644 --- a/src/AcDream.App/UI/RetailWindowLayoutPersistence.cs +++ b/src/AcDream.App/UI/RetailWindowLayoutPersistence.cs @@ -9,6 +9,31 @@ namespace AcDream.App.UI; /// per-resolution settings. It deliberately ignores the temporary pre-login /// default character key so startup layout cannot overwrite a real /// character's state. +/// +/// +/// Authored-geometry revision (#490 part 2). Every registered window +/// carries an authoredGeometryRevision (see +/// ); a restore +/// whose saved revision differs from the handle's current one replaces only +/// the saved WIDTH/HEIGHT with the current authored size +/// () — position, visibility, and +/// collapsed/maximized state are untouched, and the clamp in +/// still re-fits the kept position to the live screen. +/// Built-in retail-imported windows hand-pick that revision as a small +/// incrementing literal at their Register call site (chat windows: +/// authoredGeometryRevision = 1) — a deliberate author decision each +/// time their authored size changes. Plugin windows have no such call site +/// an author remembers to touch, so MountPlugins instead derives the +/// revision automatically from the authored geometry tuple itself via +/// +/// (width, height, min width, min height, resizable): unchanged authored +/// geometry hashes to the same revision (a user's own resize survives +/// restore), and ANY authored geometry change hashes to a different one +/// (the stored size resets to the new default exactly once). Because a hash +/// is not an ordered counter, the comparison is for INEQUALITY — see +/// 's own doc for why the original +/// "newer revision only" read was wrong for this case. +/// /// public sealed class RetailWindowLayoutPersistence : IDisposable { @@ -295,11 +320,26 @@ public sealed class RetailWindowLayoutPersistence : IDisposable handle.AuthoredGeometryRevision); } + /// + /// #490 part 2: compares revisions for INEQUALITY, not ordering. Built-in + /// retail-imported windows hand-pick a small incrementing literal + /// (0, 1, 2…) that only ever grows, so the original "migrate only if + /// saved < authored" read fine for them. Plugin windows instead derive + /// their revision from a hash of the authored geometry itself + /// () so + /// their author never has to remember to bump a literal — but a hash is + /// not a counter, and two different authored sizes can hash in either + /// order. "The authored size changed" therefore means "the value + /// differs", not "the value went up"; treating it as ordered silently + /// dropped every size-decreasing (by hash value, not by pixels) plugin + /// update, which is exactly how MossTank's 856x236 -> 984x271 bump got + /// stuck at the old size for every user with a stored layout. + /// private static UiWindowLayout MigrateAuthoredGeometry( UiWindowLayout saved, UiWindowLayout authored) { - if (saved.AuthoredGeometryRevision >= authored.AuthoredGeometryRevision) + if (saved.AuthoredGeometryRevision == authored.AuthoredGeometryRevision) return saved; return saved with diff --git a/tests/AcDream.App.Tests/UI/RetailWindowLayoutPersistenceTests.cs b/tests/AcDream.App.Tests/UI/RetailWindowLayoutPersistenceTests.cs index 67c0abe1..d82cf0ab 100644 --- a/tests/AcDream.App.Tests/UI/RetailWindowLayoutPersistenceTests.cs +++ b/tests/AcDream.App.Tests/UI/RetailWindowLayoutPersistenceTests.cs @@ -499,6 +499,143 @@ public sealed class RetailWindowLayoutPersistenceTests : IDisposable Assert.Equal((200f, 150f), (panel.Width, panel.Height)); } + // ── #490 part 2: a plugin's authored panel size changes across a plugin + // update (MossTank went 856x236 -> 984x271) and every user's stored + // layout must adopt the new authored size rather than keep the old one + // forever. MountPlugins derives authoredGeometryRevision from the + // authored geometry tuple via RetailWindowManager.ComputeAuthoredGeometryRevision + // (#490 part 2) instead of a manual literal, so these tests register the + // way MountPlugins does: pass the SAME computed hash to RegisterWindow. ── + + [Fact] + public void PluginMarkupPanel_AuthoredSizeChanged_ReplacesStoredSizeButKeepsPosition() + { + const string oldXml = + ""; + const string newXml = + ""; + var store = new SettingsStore(PathName); + + // "Old session": the plugin's previous authored size registers and + // the user drags the window. + var oldPanel = MarkupDocument.Build(oldXml, new object(), _ => (1u, 32, 32)); + var oldRoot = new UiRoot { Width = 1280, Height = 720 }; + oldRoot.AddChild(oldPanel); + RetailWindowHandle oldHandle = oldRoot.RegisterWindow( + "moss-tank", + oldPanel, + authoredGeometryRevision: RetailWindowManager.ComputeAuthoredGeometryRevision( + oldPanel.Width, oldPanel.Height, oldPanel.MinWidth, oldPanel.MinHeight, oldPanel.Resizable)); + using (var oldPersistence = new RetailWindowLayoutPersistence( + oldRoot.WindowManager, store, () => "Alice", () => (1280, 720))) + { + oldHandle.MoveTo(120f, 90f); + } + + // "New session": the plugin ships its new authored 984x271 size. + var newPanel = MarkupDocument.Build(newXml, new object(), _ => (1u, 32, 32)); + var newRoot = new UiRoot { Width = 1280, Height = 720 }; + newRoot.AddChild(newPanel); + newRoot.RegisterWindow( + "moss-tank", + newPanel, + authoredGeometryRevision: RetailWindowManager.ComputeAuthoredGeometryRevision( + newPanel.Width, newPanel.Height, newPanel.MinWidth, newPanel.MinHeight, newPanel.Resizable)); + using var persistence = new RetailWindowLayoutPersistence( + newRoot.WindowManager, store, () => "Alice", () => (1280, 720)); + + persistence.RestoreAll(); + + Assert.Equal((984f, 271f), (newPanel.Width, newPanel.Height)); + Assert.Equal((120f, 90f), (newPanel.Left, newPanel.Top)); + } + + [Fact] + public void PluginMarkupPanel_AuthoredSizeUnchanged_KeepsUserResizedSize() + { + const string xml = + ""; + var store = new SettingsStore(PathName); + + var panel = MarkupDocument.Build(xml, new object(), _ => (1u, 32, 32)); + var root = new UiRoot { Width = 1280, Height = 720 }; + root.AddChild(panel); + RetailWindowHandle handle = root.RegisterWindow( + "moss-tank", + panel, + authoredGeometryRevision: RetailWindowManager.ComputeAuthoredGeometryRevision( + panel.Width, panel.Height, panel.MinWidth, panel.MinHeight, panel.Resizable)); + using (var persistence = new RetailWindowLayoutPersistence( + root.WindowManager, store, () => "Alice", () => (1280, 720))) + { + handle.MoveTo(50f, 50f); + handle.ResizeTo(900f, 300f); + } + + // Fresh session: the SAME authored geometry (same markup) registers + // again — the derived revision is unchanged, so the user's own + // resize must survive. + var freshPanel = MarkupDocument.Build(xml, new object(), _ => (1u, 32, 32)); + var freshRoot = new UiRoot { Width = 1280, Height = 720 }; + freshRoot.AddChild(freshPanel); + freshRoot.RegisterWindow( + "moss-tank", + freshPanel, + authoredGeometryRevision: RetailWindowManager.ComputeAuthoredGeometryRevision( + freshPanel.Width, freshPanel.Height, freshPanel.MinWidth, freshPanel.MinHeight, freshPanel.Resizable)); + using var freshPersistence = new RetailWindowLayoutPersistence( + freshRoot.WindowManager, store, () => "Alice", () => (1280, 720)); + + freshPersistence.RestoreAll(); + + Assert.Equal((900f, 300f), (freshPanel.Width, freshPanel.Height)); + } + + [Fact] + public void PluginMarkupPanel_AuthoredSizeChanged_ClampsPositionToNewScreenBounds() + { + // The authored-size migration keeps the saved POSITION, but that + // position still runs through the ordinary restore clamp (Apply's + // MoveTo clamp) — a larger authored size can push a near-edge saved + // X/Y off the live screen. + const string oldXml = + ""; + const string newXml = + ""; + var store = new SettingsStore(PathName); + + var oldPanel = MarkupDocument.Build(oldXml, new object(), _ => (1u, 32, 32)); + var oldRoot = new UiRoot { Width = 800, Height = 600 }; + oldRoot.AddChild(oldPanel); + RetailWindowHandle oldHandle = oldRoot.RegisterWindow( + "moss-tank", + oldPanel, + authoredGeometryRevision: RetailWindowManager.ComputeAuthoredGeometryRevision( + oldPanel.Width, oldPanel.Height, oldPanel.MinWidth, oldPanel.MinHeight, oldPanel.Resizable)); + using (var oldPersistence = new RetailWindowLayoutPersistence( + oldRoot.WindowManager, store, () => "Alice", () => (800, 600))) + { + oldHandle.MoveTo(590f, 490f); // fits the OLD 200x100 size exactly + } + + var newPanel = MarkupDocument.Build(newXml, new object(), _ => (1u, 32, 32)); + var newRoot = new UiRoot { Width = 800, Height = 600 }; + newRoot.AddChild(newPanel); + newRoot.RegisterWindow( + "moss-tank", + newPanel, + authoredGeometryRevision: RetailWindowManager.ComputeAuthoredGeometryRevision( + newPanel.Width, newPanel.Height, newPanel.MinWidth, newPanel.MinHeight, newPanel.Resizable)); + using var persistence = new RetailWindowLayoutPersistence( + newRoot.WindowManager, store, () => "Alice", () => (800, 600)); + + persistence.RestoreAll(); + + Assert.Equal((220f, 110f), (newPanel.Width, newPanel.Height)); + // maxX = 800 - 220 = 580 (590 clamps down); maxY = 600 - 110 = 490 (already in bounds). + Assert.Equal((580f, 490f), (newPanel.Left, newPanel.Top)); + } + private static RetailWindowHandle Mount( UiRoot root, string name,