feat(ui): persist retained window layouts

This commit is contained in:
Erik 2026-07-10 23:17:29 +02:00
parent a8e9503d2e
commit 921c388e2c
22 changed files with 705 additions and 141 deletions

View file

@ -764,7 +764,6 @@ public sealed class GameWindow : IDisposable
// Core + the retained controller; GameWindow supplies live state only.
private AcDream.App.UI.Layout.RadarController? _radarController;
private AcDream.App.UI.Layout.RadarSnapshotProvider? _radarSnapshotProvider;
private AcDream.App.UI.UiRadar? _radarRoot;
// Phase D.2b-B — inventory controller (backpack grid + pack-selector + burden meter).
private AcDream.App.UI.Layout.InventoryController? _inventoryController;
// Phase D.2b Sub-phase C — paperdoll equip-slot controller (wield drag handler).
@ -2036,7 +2035,6 @@ public sealed class GameWindow : IDisposable
sendRaiseSkill: (statId, cost) => _liveSession?.SendRaiseSkill(statId, cost),
sendTrainSkill: (statId, credits) => _liveSession?.SendTrainSkill(statId, credits));
_uiHost.Root.DragReleasedOutsideUi += OnUiDragReleasedOutside;
_uiHost.Root.WindowMoved += OnRetailWindowMoved;
// Feed Silk input to the UiRoot tree so windows drag / close / select.
// UiRoot consumes UI events; the game InputDispatcher (subscribed to the
@ -2130,7 +2128,6 @@ public sealed class GameWindow : IDisposable
ResolveChrome, vitalsDatFont, ResolveDatFont);
if (radarLayout is not null && radarLayout.Root is AcDream.App.UI.UiRadar radarRoot)
{
_radarRoot = radarRoot;
_radarSnapshotProvider = new AcDream.App.UI.Layout.RadarSnapshotProvider(
Objects,
_entitiesByServerGuid,
@ -2162,7 +2159,6 @@ public sealed class GameWindow : IDisposable
ConstrainDragToParent = true,
ContentClickThrough = false,
});
ApplySavedRadarPosition();
Console.WriteLine("[D.6] retail radar/compass from LayoutDesc 0x21000074.");
}
else
@ -2223,6 +2219,7 @@ public sealed class GameWindow : IDisposable
ResizeX = true,
ResizeY = true,
Opacity = 0.75f,
StateController = chatController,
});
chatController.AttachWindow(chatHandle);
// The LayoutDesc root is 800px wide because it shares the display
@ -2529,27 +2526,8 @@ public sealed class GameWindow : IDisposable
});
var inventoryFrame = (AcDream.App.UI.UiNineSlicePanel)inventoryHandle.OuterFrame;
// Stretch the contents region with the window: the gm3DItemsUI sub-window + its grid
// + scrollbar + the full-window backdrop grow vertically (Left|Top|Bottom); the
// paperdoll + side-bag column stay pinned at the top (Left|Top). The grid's ViewHeight
// grows → more rows + the scrollbar thumb ratio (view/content) reflects it.
void StretchV(uint id)
{
if (invLayout!.FindElement(id) is { } el)
el.Anchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top
| AcDream.App.UI.AnchorEdges.Bottom;
}
void PinTopLeft(uint id)
{
if (invLayout!.FindElement(id) is { } el)
el.Anchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top;
}
StretchV(0x100001D0u); // full-window backdrop
StretchV(0x100001CFu); // gm3DItemsUI sub-window (contents-grid container)
StretchV(0x100001C6u); // contents grid (UiItemList)
StretchV(0x100001C7u); // contents scrollbar
PinTopLeft(0x100001CDu); // paperdoll (fixed at top)
PinTopLeft(0x100001CEu); // side-bag column (fixed at top)
// InventoryController owns descendant resize/reflow policy and captures
// its hidden-window baselines when it binds below.
// Phase D.2b-B — populate the inventory from ClientObjectTable: the
// "Contents of Backpack" grid + the pack-selector strip + the burden meter +
@ -2616,6 +2594,15 @@ public sealed class GameWindow : IDisposable
toggleCharacter: () => ToggleRetailWindow(AcDream.App.UI.WindowNames.Character));
SyncToolbarWindowButtons();
if (_settingsStore is not null)
{
_windowLayoutPersistence = new AcDream.App.UI.RetailWindowLayoutPersistence(
_uiHost.WindowManager,
_settingsStore,
characterKey: () => _activeToonKey,
screenSize: () => (_window!.Size.X, _window.Size.Y));
}
if (_options.UiProbeEnabled)
{
void ProbeLog(string message) => Console.WriteLine("[UI-PROBE] " + message);
@ -2910,7 +2897,8 @@ public sealed class GameWindow : IDisposable
_liveSession.EnterWorld(user, characterIndex: 0);
_activeToonKey = chosen.Name;
ApplySavedRadarPosition();
_windowLayoutPersistence?.RestoreAll();
SyncToolbarWindowButtons();
if (_settingsStore is not null && _settingsVm is not null)
{
var toonBag = _settingsStore.LoadCharacter(_activeToonKey);
@ -11799,6 +11787,7 @@ public sealed class GameWindow : IDisposable
// "default" and gets swapped to the actual character name on the
// first EnterWorld.
private AcDream.UI.Abstractions.Panels.Settings.SettingsStore? _settingsStore;
private AcDream.App.UI.RetailWindowLayoutPersistence? _windowLayoutPersistence;
private string _activeToonKey = "default";
private bool ToggleRetailWindow(string name)
@ -11842,41 +11831,6 @@ public sealed class GameWindow : IDisposable
}
}
private void ApplySavedRadarPosition()
{
if (_radarRoot is null || _settingsStore is null || _window is null)
return;
var saved = _settingsStore.LoadWindowPosition(
_activeToonKey, AcDream.App.UI.WindowNames.Radar);
if (saved is not { } position)
return;
_radarRoot.Left = System.Math.Clamp(
position.X, 0f, System.Math.Max(0f, _window.Size.X - _radarRoot.Width));
_radarRoot.Top = System.Math.Clamp(
position.Y, 0f, System.Math.Max(0f, _window.Size.Y - _radarRoot.Height));
}
private void OnRetailWindowMoved(string name, AcDream.App.UI.UiElement window)
{
if (name != AcDream.App.UI.WindowNames.Radar || _settingsStore is null)
return;
try
{
_settingsStore.SaveWindowPosition(
_activeToonKey,
name,
new AcDream.UI.Abstractions.Panels.Settings.UiWindowPosition(
window.Left, window.Top));
}
catch (Exception ex)
{
Console.WriteLine($"settings: radar position save failed: {ex.Message}");
}
}
private void UpdateRetailCursorFeedback()
{
if (_uiHost is null || _cursorFeedbackController is null || _retailCursorManager is null || _input is null)
@ -14097,6 +14051,8 @@ public sealed class GameWindow : IDisposable
_paperdollViewportRenderer?.Dispose(); // Slice 2: the doll's off-screen FBO + textures
_particleRenderer?.Dispose();
_debugLines?.Dispose();
_windowLayoutPersistence?.Dispose();
_windowLayoutPersistence = null;
_uiHost?.Dispose();
_textRenderer?.Dispose();
_debugFont?.Dispose();

View file

@ -254,6 +254,7 @@ internal static class MockupDesktop
ResizeX = true,
ResizeY = true,
Opacity = 0.75f,
StateController = controller,
});
controller.AttachWindow(handle);

View file

@ -0,0 +1,18 @@
namespace AcDream.App.UI;
/// <summary>Panel state that is not completely described by outer-frame bounds.</summary>
public readonly record struct RetainedWindowState(
bool Collapsed = false,
bool Maximized = false,
float? PersistedTop = null,
float? PersistedHeight = null);
/// <summary>
/// Optional state seam used by retained-window persistence. Bounds are restored
/// first; the controller then reapplies collapsed/maximized presentation.
/// </summary>
public interface IRetainedWindowStateController
{
RetainedWindowState CaptureWindowState();
void RestoreWindowState(RetainedWindowState state);
}

View file

@ -24,7 +24,7 @@ namespace AcDream.App.UI.Layout;
/// and bound in place.
/// </para>
/// </summary>
public sealed class ChatWindowController
public sealed class ChatWindowController : IRetainedWindowStateController
{
public const uint LayoutId = 0x21000006u;
@ -364,13 +364,16 @@ public sealed class ChatWindowController
_normalHeight = frame.Height;
float expansion = parentHeight / 2f;
float targetTop = frame.Top;
float targetHeight = frame.Height + expansion;
if (frame.Top + targetHeight > parentHeight || frame.Top >= parentHeight / 2f)
targetTop = MathF.Max(0f, frame.Top - expansion);
float targetHeight = Math.Clamp(
MathF.Min(frame.Height + expansion, parentHeight),
frame.MinHeight,
frame.MaxHeight);
bool growUp = frame.Top + targetHeight > parentHeight
|| frame.Top >= parentHeight / 2f;
float targetTop = growUp
? MathF.Max(0f, frame.Top - (targetHeight - frame.Height))
: frame.Top;
targetHeight = MathF.Min(targetHeight, parentHeight - targetTop);
targetHeight = Math.Clamp(targetHeight, frame.MinHeight, frame.MaxHeight);
_maximized = true;
_maxMinButton?.TrySetRetailState(RetailUiStateIds.Maximized);
@ -378,6 +381,21 @@ public sealed class ChatWindowController
handle.MoveTo(frame.Left, targetTop);
}
public RetainedWindowState CaptureWindowState()
=> new(
Maximized: _maximized,
PersistedTop: _maximized ? _normalTop : null,
PersistedHeight: _maximized ? _normalHeight : null);
public void RestoreWindowState(RetainedWindowState state)
{
if (state.Maximized != _maximized)
ToggleMaximize();
else
_maxMinButton?.TrySetRetailState(
_maximized ? RetailUiStateIds.Maximized : RetailUiStateIds.Minimized);
}
private static ElementInfo? FindInfo(ElementInfo node, uint id)
{
if (node.Id == id) return node;

View file

@ -24,6 +24,10 @@ public sealed class InventoryController : IItemListDragHandler
public const uint ContentsCaptionId = 0x100001C5u; // "Contents of Backpack"
public const uint TitleTextId = 0x100001D3u; // "Inventory of <name>"
public const uint ContentsScrollbarId = 0x100001C7u; // gm3DItemsUI gutter scrollbar (right of the grid)
private const uint BackdropId = 0x100001D0u;
private const uint ContentsWindowId = 0x100001CFu;
private const uint PaperdollWindowId = 0x100001CDu;
private const uint BackpackWindowId = 0x100001CEu;
// 3D-items grid: 192x96 → 6 cols x 3 rows of the 32x32 UIItem cell (template 0x21000037).
private const int ContentsColumns = 6;
@ -92,6 +96,8 @@ public sealed class InventoryController : IItemListDragHandler
_containerList = layout.FindElement(ContainerListId) as UiItemList;
_topContainer = layout.FindElement(TopContainerId) as UiItemList;
ConfigureResizeLayout(layout);
if (_contentsGrid is not null)
{
_contentsGrid.Columns = ContentsColumns;
@ -153,6 +159,24 @@ public sealed class InventoryController : IItemListDragHandler
Populate();
}
internal static void ConfigureResizeLayout(ImportedLayout layout)
{
static void Set(ImportedLayout imported, uint id, AnchorEdges anchors)
{
if (imported.FindElement(id) is not { } element) return;
element.Anchors = anchors;
element.CaptureCurrentAnchorBaseline();
}
AnchorEdges stretch = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom;
Set(layout, BackdropId, stretch);
Set(layout, ContentsWindowId, stretch);
Set(layout, ContentsGridId, stretch);
Set(layout, ContentsScrollbarId, stretch);
Set(layout, PaperdollWindowId, AnchorEdges.Left | AnchorEdges.Top);
Set(layout, BackpackWindowId, AnchorEdges.Left | AnchorEdges.Top);
}
public static InventoryController Bind(
ImportedLayout layout,
ClientObjectTable objects,

View file

@ -66,6 +66,7 @@ public static class RetailWindowFrame
AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom;
public bool? ContentClickThrough { get; init; }
public IRetainedPanelController? Controller { get; init; }
public IRetainedWindowStateController? StateController { get; init; }
}
public static RetailWindowHandle Mount(
@ -159,7 +160,8 @@ public static class RetailWindowFrame
options.WindowName,
outerFrame,
content,
options.Controller);
options.Controller,
options.StateController);
}
private static float ResolveConstraint(

View file

@ -38,6 +38,9 @@ synthesis + six deep-dive docs under
geometry events, lock propagation, and controller teardown.
- `IRetainedPanelController.cs` — disposable lifecycle contract for
panel-specific show/hide and descendant-focus behavior.
- `RetailWindowLayoutPersistence.cs` / `IRetainedWindowStateController.cs`
per-character/per-resolution bounds, visibility, collapse/maximize, legacy
migration, and safe resolution-clamped restore.
- `Layout/RetailWindowFrame.cs` — the single production/Studio mount
contract for imported-chrome and shared-wrapper windows.
- `UiHost.cs` — one-shot wrapper: owns the `UiRoot`, a `TextRenderer`,

View file

@ -19,13 +19,15 @@ public sealed class RetailWindowHandle
string name,
UiElement outerFrame,
UiElement contentRoot,
IRetainedPanelController? controller)
IRetainedPanelController? controller,
IRetainedWindowStateController? stateController)
{
_owner = owner;
Name = name;
OuterFrame = outerFrame;
ContentRoot = contentRoot;
Controller = controller;
StateController = stateController;
_notifiedVisible = outerFrame.Visible;
}
@ -33,6 +35,7 @@ public sealed class RetailWindowHandle
public UiElement OuterFrame { get; }
public UiElement ContentRoot { get; }
public IRetainedPanelController? Controller { get; }
public IRetainedWindowStateController? StateController { get; }
public bool IsRegistered => !_disposed;
public bool IsVisible => OuterFrame.Visible;
public bool IsLocked => _owner.IsLocked;

View file

@ -0,0 +1,176 @@
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 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)
{
_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));
foreach (RetailWindowHandle handle in manager.Windows)
Attach(handle);
}
/// <summary>Restore all registered windows after character and screen are known.</summary>
public void RestoreAll()
{
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;
Apply(handle, layout, screen);
// Lazily migrate legacy position-only entries into the complete schema.
_store.SaveWindowLayout(character, resolution, handle.Name, Capture(handle));
}
}
finally
{
_restoring = false;
}
}
private void Attach(RetailWindowHandle handle)
{
_attached.Add(handle);
handle.Moved += OnChanged;
handle.Resized += OnChanged;
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);
}
private static void Apply(
RetailWindowHandle handle,
UiWindowLayout layout,
(int Width, int Height) screen)
{
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 (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;
handle.Shown -= OnChanged;
handle.Hidden -= OnChanged;
}
_attached.Clear();
}
}

View file

@ -36,7 +36,8 @@ public sealed class RetailWindowManager : IDisposable
string name,
UiElement outerFrame,
UiElement? contentRoot = null,
IRetainedPanelController? controller = null)
IRetainedPanelController? controller = null,
IRetainedWindowStateController? stateController = null)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentException.ThrowIfNullOrWhiteSpace(name);
@ -46,11 +47,15 @@ public sealed class RetailWindowManager : IDisposable
throw new InvalidOperationException(
$"Retained window '{name}' must be mounted as a direct UiRoot child before registration.");
IRetainedWindowStateController? resolvedStateController =
stateController ?? outerFrame as IRetainedWindowStateController;
if (_byName.TryGetValue(name, out var sameName))
{
if (ReferenceEquals(sameName.OuterFrame, outerFrame)
&& ReferenceEquals(sameName.ContentRoot, contentRoot ?? outerFrame)
&& ReferenceEquals(sameName.Controller, controller))
&& ReferenceEquals(sameName.Controller, controller)
&& ReferenceEquals(sameName.StateController, resolvedStateController))
return sameName;
Unregister(name);
@ -64,7 +69,8 @@ public sealed class RetailWindowManager : IDisposable
name,
outerFrame,
contentRoot ?? outerFrame,
controller);
controller,
resolvedStateController);
_byName.Add(name, handle);
_byFrame.Add(outerFrame, handle);
handle.NotifyInitialState();

View file

@ -10,7 +10,7 @@ namespace AcDream.App.UI;
/// the dragged height to the nearer stop so the frame always rests collapsed or expanded — never a
/// half-row. Toolkit UX (keystone.dll has no decomp; the dat stacks both rows always) — see IA-17.
/// </summary>
public sealed class UiCollapsibleFrame : UiNineSlicePanel
public sealed class UiCollapsibleFrame : UiNineSlicePanel, IRetainedWindowStateController
{
public UiCollapsibleFrame(Func<uint, (uint, int, int)> resolve) : base(resolve) { }
@ -33,4 +33,15 @@ public sealed class UiCollapsibleFrame : UiNineSlicePanel
/// <summary>Test hook — OnTick is protected. Drives one snap+visibility reconcile.</summary>
internal void TickForTest(double dt) => OnTick(dt);
public RetainedWindowState CaptureWindowState()
=> new(Collapsed: !IsExpanded);
public void RestoreWindowState(RetainedWindowState state)
{
if (ExpandedHeight <= CollapsedHeight) return;
Height = state.Collapsed ? CollapsedHeight : ExpandedHeight;
for (int i = 0; i < SecondRow.Count; i++)
SecondRow[i].Visible = !state.Collapsed;
}
}

View file

@ -526,6 +526,18 @@ public abstract class UiElement
(int)Parent.Height));
}
/// <summary>
/// Capture the current mounted rect as the baseline for a compatibility
/// anchor policy immediately. Hidden trees do not receive a layout traversal,
/// so lazy capture after a persistence resize would use the wrong parent size.
/// </summary>
internal void CaptureCurrentAnchorBaseline()
{
if (Parent is null || Anchors == AnchorEdges.None) return;
_anchorCaptured = false;
ApplyAnchor(Parent.Width, Parent.Height);
}
/// <summary>
/// Rebase each immediate child after a host deliberately changes this element's
/// design extent. This is used when a production DAT root contains auxiliary

View file

@ -111,8 +111,9 @@ public sealed class UiHost : System.IDisposable
string name,
UiElement window,
UiElement? contentRoot = null,
IRetainedPanelController? controller = null)
=> Root.RegisterWindow(name, window, contentRoot, controller);
IRetainedPanelController? controller = null,
IRetainedWindowStateController? stateController = null)
=> Root.RegisterWindow(name, window, contentRoot, controller, stateController);
public bool UnregisterWindow(string name) => Root.UnregisterWindow(name);

View file

@ -704,8 +704,9 @@ public sealed class UiRoot : UiElement
string name,
UiElement window,
UiElement? contentRoot = null,
IRetainedPanelController? controller = null)
=> WindowManager.Register(name, window, contentRoot, controller);
IRetainedPanelController? controller = null,
IRetainedWindowStateController? stateController = null)
=> WindowManager.Register(name, window, contentRoot, controller, stateController);
public bool UnregisterWindow(string name) => WindowManager.Unregister(name);

View file

@ -18,11 +18,12 @@ public readonly record struct UiWindowPosition(float X, float Y);
/// <see cref="Input.KeyBindings.LoadOrDefault"/> path.
///
/// <para>
/// Schema (current version 1):
/// Schema (current version 2):
/// <code>
/// {
/// "version": 1,
/// "version": 2,
/// "display": { "resolution": "1920x1080", "fullscreen": false, ... }
/// "windowLayouts": { "Character": { "1920x1080": { "chat": { ... } } } }
/// }
/// </code>
/// Unknown top-level keys are preserved on save so future tab additions
@ -32,7 +33,7 @@ public readonly record struct UiWindowPosition(float X, float Y);
/// </summary>
public sealed class SettingsStore
{
private const int CurrentSchemaVersion = 1;
private const int CurrentSchemaVersion = 2;
private readonly string _path;
public SettingsStore(string path)
@ -250,26 +251,7 @@ public sealed class SettingsStore
if (toonKey is null) throw new ArgumentNullException(nameof(toonKey));
if (settings is null) throw new ArgumentNullException(nameof(settings));
var dir = Path.GetDirectoryName(_path);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
// Read existing file as a mutable JsonObject (or start fresh).
JsonObject root;
if (File.Exists(_path))
{
try
{
root = JsonNode.Parse(File.ReadAllText(_path)) as JsonObject ?? new JsonObject();
}
catch
{
root = new JsonObject();
}
}
else
{
root = new JsonObject();
}
JsonObject root = LoadMutableRoot();
// Build the toon's payload.
var toonObj = new JsonObject
@ -282,15 +264,10 @@ public sealed class SettingsStore
// Slot it under character[toonKey], creating the character map if
// necessary. Other toons in the map are preserved.
if (root["character"] is not JsonObject characterMap)
{
characterMap = new JsonObject();
root["character"] = characterMap;
}
JsonObject characterMap = GetOrCreateObject(root, "character");
characterMap[toonKey] = toonObj;
root["version"] = CurrentSchemaVersion;
File.WriteAllText(_path, root.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
WriteMutableRoot(root);
}
/// <summary>Load a named retained-window position for one character.</summary>
@ -324,37 +301,165 @@ public sealed class SettingsStore
ArgumentException.ThrowIfNullOrWhiteSpace(toonKey);
ArgumentException.ThrowIfNullOrWhiteSpace(windowName);
var dir = Path.GetDirectoryName(_path);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
JsonObject root;
if (File.Exists(_path))
{
try { root = JsonNode.Parse(File.ReadAllText(_path)) as JsonObject ?? new JsonObject(); }
catch { root = new JsonObject(); }
}
else
{
root = new JsonObject();
}
if (root["windowPositions"] is not JsonObject positions)
{
positions = new JsonObject();
root["windowPositions"] = positions;
}
if (positions[toonKey] is not JsonObject toon)
{
toon = new JsonObject();
positions[toonKey] = toon;
}
JsonObject root = LoadMutableRoot();
JsonObject positions = GetOrCreateObject(root, "windowPositions");
JsonObject toon = GetOrCreateObject(positions, toonKey);
toon[windowName] = new JsonObject
{
["x"] = position.X,
["y"] = position.Y,
};
root["version"] = CurrentSchemaVersion;
File.WriteAllText(_path, root.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
WriteMutableRoot(root);
}
/// <summary>
/// Load complete retained-window state for one character and screen resolution.
/// Missing fields in an older partial layout inherit from
/// <paramref name="fallback"/>. When no modern entry exists, the legacy
/// <c>windowPositions[toonKey][windowName]</c> radar entry is returned as a
/// position-only migration layered over the same fallback.
/// </summary>
public UiWindowLayout? LoadWindowLayout(
string toonKey,
string resolutionKey,
string windowName,
UiWindowLayout fallback)
{
ArgumentException.ThrowIfNullOrWhiteSpace(toonKey);
ArgumentException.ThrowIfNullOrWhiteSpace(resolutionKey);
ArgumentException.ThrowIfNullOrWhiteSpace(windowName);
if (!File.Exists(_path)) return null;
try
{
var root = JsonNode.Parse(File.ReadAllText(_path)) as JsonObject;
JsonObject? node = root?["windowLayouts"]?[toonKey]?[resolutionKey]?[windowName] as JsonObject;
if (node is null
&& root?["windowLayouts"]?[toonKey] is JsonObject resolutions
&& TryParseResolution(resolutionKey, out int requestedWidth, out int requestedHeight))
{
long bestDistance = long.MaxValue;
foreach ((string key, JsonNode? value) in resolutions)
{
if (value?[windowName] is not JsonObject candidate
|| !TryParseResolution(key, out int width, out int height))
continue;
long dx = width - requestedWidth;
long dy = height - requestedHeight;
long distance = dx * dx + dy * dy;
if (distance >= bestDistance) continue;
bestDistance = distance;
node = candidate;
}
}
if (node is not null)
{
return new UiWindowLayout(
ReadNodeFloat(node, "x", fallback.X),
ReadNodeFloat(node, "y", fallback.Y),
ReadNodeFloat(node, "width", fallback.Width),
ReadNodeFloat(node, "height", fallback.Height),
ReadNodeBool(node, "visible", fallback.Visible),
ReadNodeBool(node, "collapsed", fallback.Collapsed),
ReadNodeBool(node, "maximized", fallback.Maximized));
}
// Version-1 migration: radar stored only X/Y and had no resolution key.
if (root?["windowPositions"]?[toonKey]?[windowName] is JsonObject legacy)
{
return fallback with
{
X = ReadNodeFloat(legacy, "x", fallback.X),
Y = ReadNodeFloat(legacy, "y", fallback.Y),
};
}
return null;
}
catch (Exception ex)
{
Console.WriteLine($"settings: failed to load window layout from {_path}: {ex.Message}");
return null;
}
}
/// <summary>
/// Save complete retained-window state under
/// <c>windowLayouts[toonKey][resolutionKey][windowName]</c>, preserving every
/// other character, resolution, window, and settings section.
/// </summary>
public void SaveWindowLayout(
string toonKey,
string resolutionKey,
string windowName,
UiWindowLayout layout)
{
ArgumentException.ThrowIfNullOrWhiteSpace(toonKey);
ArgumentException.ThrowIfNullOrWhiteSpace(resolutionKey);
ArgumentException.ThrowIfNullOrWhiteSpace(windowName);
JsonObject root = LoadMutableRoot();
JsonObject layouts = GetOrCreateObject(root, "windowLayouts");
JsonObject toon = GetOrCreateObject(layouts, toonKey);
JsonObject resolution = GetOrCreateObject(toon, resolutionKey);
resolution[windowName] = new JsonObject
{
["x"] = layout.X,
["y"] = layout.Y,
["width"] = layout.Width,
["height"] = layout.Height,
["visible"] = layout.Visible,
["collapsed"] = layout.Collapsed,
["maximized"] = layout.Maximized,
};
root["version"] = CurrentSchemaVersion;
WriteMutableRoot(root);
}
private JsonObject LoadMutableRoot()
{
var dir = Path.GetDirectoryName(_path);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
if (!File.Exists(_path)) return new JsonObject();
try { return JsonNode.Parse(File.ReadAllText(_path)) as JsonObject ?? new JsonObject(); }
catch { return new JsonObject(); }
}
private void WriteMutableRoot(JsonObject root)
=> File.WriteAllText(_path, root.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
private static JsonObject GetOrCreateObject(JsonObject parent, string key)
{
if (parent[key] is JsonObject existing) return existing;
var created = new JsonObject();
parent[key] = created;
return created;
}
private static float ReadNodeFloat(JsonObject node, string key, float fallback)
{
try { return node[key]?.GetValue<float>() ?? fallback; }
catch { return fallback; }
}
private static bool ReadNodeBool(JsonObject node, string key, bool fallback)
{
try { return node[key]?.GetValue<bool>() ?? fallback; }
catch { return fallback; }
}
private static bool TryParseResolution(string key, out int width, out int height)
{
width = 0;
height = 0;
int separator = key.IndexOf('x', StringComparison.OrdinalIgnoreCase);
return separator > 0
&& int.TryParse(key.AsSpan(0, separator), out width)
&& int.TryParse(key.AsSpan(separator + 1), out height)
&& width > 0
&& height > 0;
}
private static SortedDictionary<string, object> BuildChatObject(ChatSettings c)

View file

@ -0,0 +1,15 @@
namespace AcDream.UI.Abstractions.Panels.Settings;
/// <summary>
/// Complete persisted state for one retained window at one screen resolution.
/// Position and size are outer-frame pixels; panel flags retain the small amount
/// of state that geometry alone cannot reproduce reliably.
/// </summary>
public readonly record struct UiWindowLayout(
float X,
float Y,
float Width,
float Height,
bool Visible,
bool Collapsed,
bool Maximized);