acdream/src/AcDream.App/UI/RetailWindowLayoutPersistence.cs
Erik 2153bee247 fix #390: retail display-change UI cascade — clamp + per-res reload
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>
2026-08-13 17:42:50 +02:00

327 lines
12 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.
/// </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();
}
}