acdream/src/AcDream.App/UI/RetailWindowLayoutPersistence.cs
Erik 1bd2b30291
All checks were successful
CI / linux-portable (push) Successful in 3m16s
CI / windows-gate (push) Successful in 5m41s
CI / release (push) Successful in 2m5s
fix(ui): restore retail vitals and window interactions
2026-08-20 13:26:35 +02:00

350 lines
13 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)
=> 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
{
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:
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();
}
}