acdream/src/AcDream.App/UI/RetailWindowLayoutPersistence.cs
Erik 05f22d46ff fix #490 (part 2): plugin panels adopt an authored size change instead of keeping a stale saved size forever
MountPlugins registered every plugin window with authoredGeometryRevision
hard-coded to 0 (RetailUiRuntime.cs), so RetailWindowLayoutPersistence's
MigrateAuthoredGeometry -- gated on "saved revision >= authored revision"
-- never migrated a plugin window's saved size: 0 >= 0 forever. MossTank's
panel went 856x236 -> 984x271 and every user with a stored layout stayed
stuck at 856x236 with no way to see the new default.

Two changes:

1. MountPlugins now passes RetailWindowManager.ComputeAuthoredGeometryRevision
   (added previous commit) as the plugin window's authoredGeometryRevision,
   derived from the panel's own authored width/height/minw/minh/resizable.

2. MigrateAuthoredGeometry now compares revisions for INEQUALITY
   (saved.Revision == authored.Revision) instead of ordering
   (saved.Revision >= authored.Revision). A hash is not an incrementing
   counter -- two different authored sizes can hash in either order -- so
   "the authored size changed" has to mean "the value differs", not "the
   value went up". Built-in windows' hand-picked incrementing literals
   (chat: authoredGeometryRevision = 1) are unaffected: no existing saved
   revision is ever equal to a later, different literal either way.

Mutation shown to fail first: the two new PluginMarkupPanel_AuthoredSizeChanged_*
tests in RetailWindowLayoutPersistenceTests.cs reproduce the exact bug with
concrete literals (856x236/400/150/true -> 984x271/... and
200x100/100/80/true -> 220x110/...) chosen so ComputeAuthoredGeometryRevision's
OLD hash is >= the NEW hash for each pair -- confirmed via a throwaway probe
before writing the assertions, so the pre-fix run fails deterministically
rather than by chance of hash ordering. Both failed before this commit
(size stayed at the old authored extent) and pass after
(PluginMarkupPanel_AuthoredSizeUnchanged_KeepsUserResizedSize, unaffected
either way, is a regression-safety companion). Full RetailWindow/Markup/
PluginSidePanel filter: 249 passed (was 246), 0 failed, 0 skipped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 17:51:46 +02:00

436 lines
18 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using AcDream.UI.Abstractions.Panels.Settings;
namespace AcDream.App.UI;
/// <summary>
/// Bridges typed retained-window lifecycle events to per-character,
/// per-resolution settings. It deliberately ignores the temporary pre-login
/// <c>default</c> character key so startup layout cannot overwrite a real
/// character's state.
///
/// <para>
/// <b>Authored-geometry revision (#490 part 2).</b> Every registered window
/// carries an <c>authoredGeometryRevision</c> (see
/// <see cref="RetailWindowHandle.AuthoredGeometryRevision"/>); a restore
/// whose saved revision differs from the handle's current one replaces only
/// the saved WIDTH/HEIGHT with the current authored size
/// (<see cref="MigrateAuthoredGeometry"/>) — position, visibility, and
/// collapsed/maximized state are untouched, and the clamp in
/// <see cref="Apply"/> 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 <c>Register</c> call site (chat windows:
/// <c>authoredGeometryRevision = 1</c>) — a deliberate author decision each
/// time their authored size changes. Plugin windows have no such call site
/// an author remembers to touch, so <c>MountPlugins</c> instead derives the
/// revision automatically from the authored geometry tuple itself via
/// <see cref="RetailWindowManager.ComputeAuthoredGeometryRevision"/>
/// (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
/// <see cref="MigrateAuthoredGeometry"/>'s own doc for why the original
/// "newer revision only" read was wrong for this case.
/// </para>
/// </summary>
public sealed class RetailWindowLayoutPersistence : IDisposable
{
private readonly RetailWindowManager _manager;
private readonly SettingsStore _store;
private readonly Func<string> _characterKey;
private readonly Func<(int Width, int Height)> _screenSize;
private readonly HashSet<string> _stateManagedVisibilityWindows;
private readonly List<RetailWindowHandle> _attached = new();
private bool _restoring;
private bool _disposed;
public RetailWindowLayoutPersistence(
RetailWindowManager manager,
SettingsStore store,
Func<string> characterKey,
Func<(int Width, int Height)> screenSize,
IEnumerable<string>? stateManagedVisibilityWindows = null)
{
_manager = manager ?? throw new ArgumentNullException(nameof(manager));
_store = store ?? throw new ArgumentNullException(nameof(store));
_characterKey = characterKey ?? throw new ArgumentNullException(nameof(characterKey));
_screenSize = screenSize ?? throw new ArgumentNullException(nameof(screenSize));
_stateManagedVisibilityWindows = stateManagedVisibilityWindows is null
? new HashSet<string>(StringComparer.Ordinal)
: new HashSet<string>(stateManagedVisibilityWindows, StringComparer.Ordinal);
// Review fix round finding 4: attach late registrations too. Before this
// fix, only windows already registered at CONSTRUCTION time ever got a
// save subscription — a plugin window (or the plugin shelf, on a path
// that somehow constructs persistence first) registered afterward was
// silently never persisted. WindowUnregistered detaches the mirror image
// so a stale handle is not held (and re-notified) forever.
_manager.WindowRegistered += OnWindowRegistered;
_manager.WindowUnregistered += OnWindowUnregistered;
foreach (RetailWindowHandle handle in manager.Windows)
Attach(handle);
}
private void OnWindowRegistered(RetailWindowHandle handle) => Attach(handle);
private void OnWindowUnregistered(RetailWindowHandle handle) => Detach(handle);
/// <summary>Restore all registered windows after character and screen are
/// known. <paramref name="saveBack"/> (#390): the login-time restore keeps
/// its lazy schema-migration save; the LIVE display-change reload passes
/// false — retail persists layouts only via <c>@saveui</c>/<c>@saveautoui</c>,
/// never as a side effect of a display change, and writing here from a
/// mid-drag reload would litter settings.json with intermediate-resolution
/// keys.</summary>
public void RestoreAll(bool saveBack = true)
=> RestoreAllCore(
saveBack,
restoreVisibility: true);
/// <summary>
/// Reloads the current resolution's saved geometry after a live display
/// change. Retail's <c>UI-&lt;char&gt;-&lt;world&gt;-&lt;W&gt;-&lt;H&gt;.txt</c>
/// records only X/Y/W/H; global message <c>0xE</c> therefore cannot hide
/// a window. Keeping live visibility is especially load-bearing for the Options window:
/// hiding Config while its Resolution row is still uncommitted invokes
/// <c>PlayerOptionPage::OnVisibilityChanged(false)</c> and restores the
/// previous resolution, producing a resize-out/resize-back blip.
/// </summary>
public void RestoreAfterDisplayChange()
=> RestoreAllCore(
saveBack: false,
restoreVisibility: false);
private void RestoreAllCore(
bool saveBack,
bool restoreVisibility)
{
ObjectDisposedException.ThrowIf(_disposed, this);
string character = _characterKey();
if (!CanPersist(character)) return;
var screen = ValidScreenSize();
string resolution = ResolutionKey(screen);
_restoring = true;
try
{
// NEW-4 (residual round, docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md
// Slice A): _attached is now mutated mid-session by WindowRegistered/
// WindowUnregistered (a plugin window or the shelf can register/
// unregister from inside a callback this very loop invokes — e.g.
// Apply -> Show()/Hide() -> a controller reacting by unregistering
// another window), so every loop over it snapshots first.
foreach (RetailWindowHandle handle in _attached.ToArray())
{
UiWindowLayout fallback = Capture(handle);
UiWindowLayout? saved = _store.LoadWindowLayout(
character, resolution, handle.Name, fallback);
if (saved is not { } layout) continue;
layout = MigrateAuthoredGeometry(
layout,
AuthoredGeometry(handle, fallback));
Apply(
handle,
layout,
screen,
restoreVisibility:
restoreVisibility
&& !_stateManagedVisibilityWindows.Contains(handle.Name));
// Lazily migrate legacy position-only entries into the complete schema.
if (saveBack)
_store.SaveWindowLayout(character, resolution, handle.Name, Capture(handle));
}
}
finally
{
_restoring = false;
}
}
/// <summary>
/// #390: retail's display-change anti-stranding rule. On every display
/// change, retail's UI cascade (<c>UIElementManager::RefreshEvent
/// @0x0045C530</c> → <c>UIElement::UpdateForParentSizeChange
/// @0x00462640</c>) unconditionally re-applies each floating window's
/// own <c>MoveTo</c> override, whose clamp is
/// <c>x = max(0, min(x, parentW selfW))</c> — top-left priority, so an
/// oversized window pins to 0 and its top-left chrome stays reachable.
/// Same math as <see cref="Apply"/>'s restore clamp, run against the live
/// screen with no store I/O — safe to call on every resize frame.
/// Deliberate deviation, register-rowed with #390: retail's floating
/// chats (<c>gmFloatyChatUI</c>) do NOT clamp and can strand; the block's
/// product requirement ("UI windows must stay reachable") clamps ALL
/// registered windows including them.
/// </summary>
public void ClampAllToScreen()
{
ObjectDisposedException.ThrowIf(_disposed, this);
var screen = ValidScreenSize();
// _restoring also suppresses OnChanged's per-move save: this runs on
// every resize FRAME during a live window drag, and each clamping
// MoveTo would otherwise write settings.json per frame under whatever
// intermediate resolution the drag is passing through. A USER drag of
// the window itself still saves (OnChanged, unguarded path) — retail
// deviation already carried by this class's save-on-move behavior.
_restoring = true;
try
{
// NEW-4: snapshot — see the RestoreAllCore loop's comment above.
foreach (RetailWindowHandle handle in _attached.ToArray())
{
float maxX = MathF.Max(0f, screen.Width - handle.Width);
float maxY = MathF.Max(0f, screen.Height - handle.Height);
float x = Math.Clamp(handle.Left, 0f, maxX);
float y = Math.Clamp(handle.Top, 0f, maxY);
if (x != handle.Left || y != handle.Top)
handle.MoveTo(x, y);
}
}
finally
{
_restoring = false;
}
}
/// <summary>Force-save every attached window to the current automatic profile.</summary>
public void SaveAll()
{
ObjectDisposedException.ThrowIf(_disposed, this);
string character = _characterKey();
if (!CanPersist(character)) return;
var screen = ValidScreenSize();
string resolution = ResolutionKey(screen);
// NEW-4: snapshot — see the RestoreAllCore loop's comment above.
foreach (RetailWindowHandle handle in _attached.ToArray())
_store.SaveWindowLayout(character, resolution, handle.Name, Capture(handle));
}
/// <summary>Save every attached window to a portable named retail UI profile.</summary>
public void SaveNamed(string profileName)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(profileName);
// NEW-4: snapshot — see the RestoreAllCore loop's comment above.
foreach (RetailWindowHandle handle in _attached.ToArray())
_store.SaveNamedWindowLayout(profileName, handle.Name, Capture(handle));
}
/// <summary>Restore every window found in a portable named retail UI profile.</summary>
public void RestoreNamed(string profileName)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(profileName);
var screen = ValidScreenSize();
_restoring = true;
try
{
// NEW-4: snapshot — see the RestoreAllCore loop's comment above.
foreach (RetailWindowHandle handle in _attached.ToArray())
{
UiWindowLayout? saved = _store.LoadNamedWindowLayout(
profileName, handle.Name, Capture(handle));
if (saved is not { } layout) continue;
UiWindowLayout fallback = Capture(handle);
layout = MigrateAuthoredGeometry(
layout,
AuthoredGeometry(handle, fallback));
Apply(
handle,
layout,
screen,
restoreVisibility: !_stateManagedVisibilityWindows.Contains(handle.Name));
}
}
finally
{
_restoring = false;
}
}
private void Attach(RetailWindowHandle handle)
{
if (_attached.Contains(handle))
return;
_attached.Add(handle);
handle.Moved += OnChanged;
handle.Resized += OnChanged;
handle.StateChanged += OnChanged;
if (!_stateManagedVisibilityWindows.Contains(handle.Name))
{
handle.Shown += OnChanged;
handle.Hidden += OnChanged;
}
}
private void Detach(RetailWindowHandle handle)
{
if (!_attached.Remove(handle))
return;
handle.Moved -= OnChanged;
handle.Resized -= OnChanged;
handle.StateChanged -= OnChanged;
if (!_stateManagedVisibilityWindows.Contains(handle.Name))
{
handle.Shown -= OnChanged;
handle.Hidden -= OnChanged;
}
}
private void OnChanged(RetailWindowHandle handle)
{
if (_restoring || _disposed) return;
string character = _characterKey();
if (!CanPersist(character)) return;
try
{
var screen = ValidScreenSize();
_store.SaveWindowLayout(
character,
ResolutionKey(screen),
handle.Name,
Capture(handle));
}
catch (Exception ex)
{
Console.WriteLine($"settings: window layout save failed [{handle.Name}]: {ex.Message}");
}
}
private static UiWindowLayout Capture(RetailWindowHandle handle)
{
RetainedWindowState state = handle.StateController?.CaptureWindowState() ?? default;
return new UiWindowLayout(
handle.Left,
state.PersistedTop ?? handle.Top,
handle.Width,
state.PersistedHeight ?? handle.Height,
// Review fix round finding 3: persist the controller's own show/hide
// INTENT when it reports one, never the outer frame's derived
// IsVisible — a controller (e.g. PluginSidePanel) may fold in an
// availability gate on top of the user's actual request, and an
// availability-driven hide must never be captured as a user hide.
state.RequestedVisible ?? handle.IsVisible,
state.Collapsed,
state.Maximized,
handle.AuthoredGeometryRevision);
}
/// <summary>
/// #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 &lt; authored" read fine for them. Plugin windows instead derive
/// their revision from a hash of the authored geometry itself
/// (<see cref="RetailWindowManager.ComputeAuthoredGeometryRevision"/>) 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 -&gt; 984x271 bump got
/// stuck at the old size for every user with a stored layout.
/// </summary>
private static UiWindowLayout MigrateAuthoredGeometry(
UiWindowLayout saved,
UiWindowLayout authored)
{
if (saved.AuthoredGeometryRevision == authored.AuthoredGeometryRevision)
return saved;
return saved with
{
Width = authored.Width,
Height = authored.Height,
AuthoredGeometryRevision = authored.AuthoredGeometryRevision,
};
}
private static UiWindowLayout AuthoredGeometry(
RetailWindowHandle handle,
UiWindowLayout current) => current with
{
Width = handle.AuthoredWidth,
Height = handle.AuthoredHeight,
AuthoredGeometryRevision = handle.AuthoredGeometryRevision,
};
private static void Apply(
RetailWindowHandle handle,
UiWindowLayout layout,
(int Width, int Height) screen,
bool restoreVisibility)
{
UiElement frame = handle.OuterFrame;
float width = ClampDimension(layout.Width, frame.Width, frame.MinWidth, frame.MaxWidth, screen.Width);
float height = ClampDimension(layout.Height, frame.Height, frame.MinHeight, frame.MaxHeight, screen.Height);
handle.ResizeTo(width, height);
float maxX = MathF.Max(0f, screen.Width - handle.Width);
float maxY = MathF.Max(0f, screen.Height - handle.Height);
float x = Math.Clamp(FiniteOr(layout.X, handle.Left), 0f, maxX);
float y = Math.Clamp(FiniteOr(layout.Y, handle.Top), 0f, maxY);
handle.MoveTo(x, y);
// Review fix round finding 3: always hand the saved intent to the
// controller (not only when restoreVisibility is true) — a
// state-managed window (restoreVisibility false) restores its own
// show/hide intent THIS way instead of through Show/Hide below, so
// login never fires an extra BringToFront or routes through the
// ordinary Shown/Hidden notification for a window whose visibility
// this persistence layer does not otherwise touch.
handle.StateController?.RestoreWindowState(new RetainedWindowState(
Collapsed: layout.Collapsed,
Maximized: layout.Maximized,
RequestedVisible: layout.Visible));
if (restoreVisibility)
{
if (layout.Visible) handle.Show();
else handle.Hide();
}
}
private (int Width, int Height) ValidScreenSize()
{
var screen = _screenSize();
return (Math.Max(1, screen.Width), Math.Max(1, screen.Height));
}
private static float ClampDimension(
float saved,
float current,
float minimum,
float maximum,
int screenExtent)
{
float value = FiniteOr(saved, current);
float upper = MathF.Max(minimum, MathF.Min(maximum, screenExtent));
return Math.Clamp(value, minimum, upper);
}
private static float FiniteOr(float value, float fallback)
=> float.IsFinite(value) ? value : fallback;
private static string ResolutionKey((int Width, int Height) screen)
=> $"{screen.Width}x{screen.Height}";
private static bool CanPersist(string key)
=> !string.IsNullOrWhiteSpace(key)
&& !string.Equals(key, "default", StringComparison.OrdinalIgnoreCase);
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_manager.WindowRegistered -= OnWindowRegistered;
_manager.WindowUnregistered -= OnWindowUnregistered;
foreach (RetailWindowHandle handle in _attached.ToArray())
Detach(handle);
_attached.Clear();
}
}