feat(ui): persist retained window layouts
This commit is contained in:
parent
a8e9503d2e
commit
921c388e2c
22 changed files with 705 additions and 141 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
Loading…
Add table
Add a link
Reference in a new issue