Decomp-first per the block's rule: the research doc
(docs/research/2026-08-13-retail-ui-display-change.md, committed here)
pulled retail's actual mechanism before any code. A display change runs
UIElementManager::RefreshEvent @0x0045C530 ->
UIElement::UpdateForParentSizeChange @0x00462640, which unconditionally
re-applies every floating window's own clamping MoveTo override
(x = max(0, min(x, parentW - selfW)) - top-left priority, oversized
windows pin to 0), then broadcasts global message 0xE whose sole
listener reloads the per-resolution auto layout. No proportional moves,
no resets; retail saves layouts only via @saveui.
Port: RetailWindowLayoutPersistence.ClampAllToScreen() is the cascade
clamp (no store I/O; _restoring suppresses the per-move save so a live
drag-resize cannot write settings.json per frame), and
RetailUiRuntime.Draw carries a two-step screen-size edge detector:
change frame -> clamp; first stable frame -> one
RestoreAll(saveBack:false) per-resolution reload (the 0xE analog; no
lazy save-back, matching retail's save-only-on-command). The login
restore path already used retail's exact clamp math (Apply) - the live
trigger was the missing half, which is precisely the stranding the user
reported.
Deliberate deviation, register AD-91: retail's gmFloatyChatUI windows
have NO clamp and can strand; the block's requirement ("UI windows must
stay reachable") clamps every registered window uniformly.
Tests: 5 new persistence facts (clamp/top-left-pin/no-move/no-save-on-
clamp/no-save-on-live-reload). App suite 4,967/3 skips. Gate script
section D3 filled in.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
327 lines
12 KiB
C#
327 lines
12 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.
|
||
/// </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);
|
||
|
||
foreach (RetailWindowHandle handle in manager.Windows)
|
||
Attach(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)
|
||
{
|
||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||
string character = _characterKey();
|
||
if (!CanPersist(character)) return;
|
||
var screen = ValidScreenSize();
|
||
string resolution = ResolutionKey(screen);
|
||
|
||
_restoring = true;
|
||
try
|
||
{
|
||
foreach (RetailWindowHandle handle in _attached)
|
||
{
|
||
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: !_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
|
||
{
|
||
foreach (RetailWindowHandle handle in _attached)
|
||
{
|
||
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);
|
||
foreach (RetailWindowHandle handle in _attached)
|
||
_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);
|
||
foreach (RetailWindowHandle handle in _attached)
|
||
_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
|
||
{
|
||
foreach (RetailWindowHandle handle in _attached)
|
||
{
|
||
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)
|
||
{
|
||
_attached.Add(handle);
|
||
handle.Moved += OnChanged;
|
||
handle.Resized += 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,
|
||
handle.IsVisible,
|
||
state.Collapsed,
|
||
state.Maximized,
|
||
handle.AuthoredGeometryRevision);
|
||
}
|
||
|
||
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);
|
||
|
||
handle.StateController?.RestoreWindowState(new RetainedWindowState(
|
||
Collapsed: layout.Collapsed,
|
||
Maximized: layout.Maximized));
|
||
|
||
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;
|
||
foreach (RetailWindowHandle handle in _attached)
|
||
{
|
||
handle.Moved -= OnChanged;
|
||
handle.Resized -= OnChanged;
|
||
if (!_stateManagedVisibilityWindows.Contains(handle.Name))
|
||
{
|
||
handle.Shown -= OnChanged;
|
||
handle.Hidden -= OnChanged;
|
||
}
|
||
}
|
||
_attached.Clear();
|
||
}
|
||
}
|