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>
436 lines
18 KiB
C#
436 lines
18 KiB
C#
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-<char>-<world>-<W>-<H>.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 < 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 -> 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();
|
||
}
|
||
}
|