diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 48c0326..b892658 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -901,6 +901,13 @@ public sealed class GameWindow : IDisposable
// the same OnLoad path (see _inputDispatcher field).
if (_inputDispatcher is not null)
{
+ // L.0 — settings.json (display + future audio / gameplay /
+ // chat / character tabs). Coexists with keybinds.json,
+ // which keeps its own load/save path.
+ var settingsStore = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore(
+ AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath());
+ var persistedDisplay = settingsStore.LoadDisplay();
+
_settingsVm = new AcDream.UI.Abstractions.Panels.Settings.SettingsVM(
persisted: _keyBindings,
dispatcher: _inputDispatcher,
@@ -919,6 +926,21 @@ public sealed class GameWindow : IDisposable
{
Console.WriteLine($"keybinds: save failed: {ex.Message}");
}
+ },
+ persistedDisplay: persistedDisplay,
+ onSaveDisplay: display =>
+ {
+ try
+ {
+ settingsStore.SaveDisplay(display);
+ Console.WriteLine(
+ "settings: display saved to "
+ + AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath());
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"settings: display save failed: {ex.Message}");
+ }
});
_settingsPanel = new AcDream.UI.Abstractions.Panels.Settings.SettingsPanel(_settingsVm);
_panelHost.Register(_settingsPanel);
diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs b/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs
new file mode 100644
index 0000000..dd89b6c
--- /dev/null
+++ b/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs
@@ -0,0 +1,44 @@
+using System.Collections.Generic;
+
+namespace AcDream.UI.Abstractions.Panels.Settings;
+
+///
+/// Display-related preferences persisted to settings.json.
+/// Modern addition (no retail equivalent for FOV / vsync etc) — replaces
+/// the various ACDREAM_* environment variables for resolution +
+/// windowed mode with an in-game UI.
+///
+///
+/// Records are immutable; mutation goes through
+/// which assigns a new instance via
+/// with-expressions.
+///
+///
+public sealed record DisplaySettings(
+ string Resolution,
+ bool Fullscreen,
+ bool VSync,
+ float FieldOfView,
+ float Gamma,
+ bool ShowFps)
+{
+ /// Values used on first launch / when settings.json is absent.
+ public static DisplaySettings Default { get; } = new(
+ Resolution: "1920x1080",
+ Fullscreen: false,
+ VSync: true,
+ FieldOfView: 75f,
+ Gamma: 1.0f,
+ ShowFps: false);
+
+ /// 16:9 resolution presets offered in the dropdown.
+ public static IReadOnlyList AvailableResolutions { get; } = new[]
+ {
+ "1280x720",
+ "1366x768",
+ "1600x900",
+ "1920x1080",
+ "2560x1440",
+ "3840x2160",
+ };
+}
diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs
index 7def104..7af322e 100644
--- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs
+++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs
@@ -83,7 +83,7 @@ public sealed class SettingsPanel : IPanel
}
if (renderer.BeginTabItem("Display"))
{
- RenderPlaceholder(renderer, "Display");
+ RenderDisplayTab(renderer);
renderer.EndTabItem();
}
if (renderer.BeginTabItem("Audio"))
@@ -194,6 +194,51 @@ public sealed class SettingsPanel : IPanel
+ "Build order: Display → Audio → Gameplay → Chat → Character.");
}
+ ///
+ /// Render the Display tab — resolution / fullscreen / vsync /
+ /// FOV / gamma / show-FPS. FOV + Gamma are live-preview sliders;
+ /// the others apply on Save (matches the brainstorm UX agreement —
+ /// resolution change live would be too jarring).
+ ///
+ private void RenderDisplayTab(IPanelRenderer renderer)
+ {
+ var d = _vm.DisplayDraft;
+
+ // Resolution dropdown. Index falls back to the highest available
+ // option when the persisted resolution isn't one of the presets
+ // (e.g. user hand-edited settings.json with a non-standard size).
+ var resolutions = DisplaySettings.AvailableResolutions.ToArray();
+ int idx = System.Array.IndexOf(resolutions, d.Resolution);
+ if (idx < 0) idx = resolutions.Length - 1;
+ if (renderer.Combo("Resolution", ref idx, resolutions))
+ _vm.SetDisplay(d with { Resolution = resolutions[idx] });
+
+ bool fullscreen = d.Fullscreen;
+ if (renderer.Checkbox("Fullscreen", ref fullscreen))
+ _vm.SetDisplay(d with { Fullscreen = fullscreen });
+
+ bool vsync = d.VSync;
+ if (renderer.Checkbox("V-Sync", ref vsync))
+ _vm.SetDisplay(d with { VSync = vsync });
+
+ float fov = d.FieldOfView;
+ if (renderer.SliderFloat("Field of View", ref fov, 30f, 120f))
+ _vm.SetDisplay(d with { FieldOfView = fov });
+
+ float gamma = d.Gamma;
+ if (renderer.SliderFloat("Gamma", ref gamma, 0.5f, 2.0f))
+ _vm.SetDisplay(d with { Gamma = gamma });
+
+ bool showFps = d.ShowFps;
+ if (renderer.Checkbox("Show FPS", ref showFps))
+ _vm.SetDisplay(d with { ShowFps = showFps });
+
+ renderer.Spacing();
+ renderer.TextWrapped(
+ "Resolution / Fullscreen / V-Sync apply on Save. FOV + Gamma "
+ + "preview live as you drag; Cancel reverts to the saved value.");
+ }
+
private void RenderSection(IPanelRenderer renderer, string label, InputAction[] actions)
{
// Movement defaults open; other sections collapsed for first-run UX.
diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs
new file mode 100644
index 0000000..0e961a9
--- /dev/null
+++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs
@@ -0,0 +1,151 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text.Json;
+
+namespace AcDream.UI.Abstractions.Panels.Settings;
+
+///
+/// JSON-backed persistence for non-keybind settings (Display today; future
+/// tabs Audio / Gameplay / Chat / Character will be added to the same
+/// file). Path: %LOCALAPPDATA%\acdream\settings.json. Coexists
+/// with keybinds.json, which retains its own
+/// path.
+///
+///
+/// Schema (current version 1):
+///
+/// {
+/// "version": 1,
+/// "display": { "resolution": "1920x1080", "fullscreen": false, ... }
+/// }
+///
+/// Unknown top-level keys are preserved on save so future tab additions
+/// from a newer client don't get clobbered by an older client writing
+/// out only the sections it knows about.
+///
+///
+public sealed class SettingsStore
+{
+ private const int CurrentSchemaVersion = 1;
+ private readonly string _path;
+
+ public SettingsStore(string path)
+ {
+ _path = path ?? throw new ArgumentNullException(nameof(path));
+ }
+
+ /// Default path: %LOCALAPPDATA%\acdream\settings.json.
+ public static string DefaultPath() => Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "acdream",
+ "settings.json");
+
+ ///
+ /// Load Display settings. Missing file → .
+ /// Missing individual keys fall back to the corresponding default
+ /// field, so a partial file (e.g. only resolution is set) is
+ /// non-fatal.
+ ///
+ public DisplaySettings LoadDisplay()
+ {
+ if (!File.Exists(_path)) return DisplaySettings.Default;
+ try
+ {
+ using var stream = File.OpenRead(_path);
+ var doc = JsonDocument.Parse(stream);
+ var root = doc.RootElement;
+ if (!root.TryGetProperty("display", out var disp)
+ || disp.ValueKind != JsonValueKind.Object)
+ return DisplaySettings.Default;
+
+ var d = DisplaySettings.Default;
+ return new DisplaySettings(
+ Resolution: ReadString (disp, "resolution", d.Resolution),
+ Fullscreen: ReadBool (disp, "fullscreen", d.Fullscreen),
+ VSync: ReadBool (disp, "vsync", d.VSync),
+ FieldOfView: ReadFloat (disp, "fieldOfView", d.FieldOfView),
+ Gamma: ReadFloat (disp, "gamma", d.Gamma),
+ ShowFps: ReadBool (disp, "showFps", d.ShowFps));
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"settings: failed to load {_path}: {ex.Message} — using defaults");
+ return DisplaySettings.Default;
+ }
+ }
+
+ ///
+ /// Save Display settings, preserving any other top-level keys the file
+ /// already contains (e.g. an audio section written by a newer
+ /// client). Unknown keys are round-tripped via raw JSON text so older
+ /// builds don't silently drop them.
+ ///
+ public void SaveDisplay(DisplaySettings display)
+ {
+ var dir = Path.GetDirectoryName(_path);
+ if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
+
+ // Preserve any non-display top-level keys from the existing file.
+ var preservedKeys = new SortedDictionary(StringComparer.Ordinal);
+ if (File.Exists(_path))
+ {
+ try
+ {
+ using var stream = File.OpenRead(_path);
+ var doc = JsonDocument.Parse(stream);
+ foreach (var prop in doc.RootElement.EnumerateObject())
+ {
+ if (prop.Name == "display" || prop.Name == "version") continue;
+ preservedKeys[prop.Name] = prop.Value.GetRawText();
+ }
+ }
+ catch
+ {
+ // Corrupt file → fully overwrite; previous content is lost
+ // but the user's session continues with the new save.
+ preservedKeys.Clear();
+ }
+ }
+
+ var displayObj = new SortedDictionary(StringComparer.Ordinal)
+ {
+ ["fieldOfView"] = display.FieldOfView,
+ ["fullscreen"] = display.Fullscreen,
+ ["gamma"] = display.Gamma,
+ ["resolution"] = display.Resolution,
+ ["showFps"] = display.ShowFps,
+ ["vsync"] = display.VSync,
+ };
+
+ // Build the output by hand so preserved-keys keep their raw JSON.
+ var sb = new System.Text.StringBuilder();
+ sb.Append('{').AppendLine();
+ sb.Append(" \"display\": ")
+ .Append(JsonSerializer.Serialize(displayObj, new JsonSerializerOptions { WriteIndented = true })
+ .Replace("\n", "\n "))
+ .Append(',').AppendLine();
+ foreach (var kv in preservedKeys)
+ {
+ sb.Append(" \"").Append(kv.Key).Append("\": ")
+ .Append(kv.Value).Append(',').AppendLine();
+ }
+ sb.Append(" \"version\": ").Append(CurrentSchemaVersion).AppendLine();
+ sb.Append('}').AppendLine();
+
+ File.WriteAllText(_path, sb.ToString());
+ }
+
+ private static string ReadString(JsonElement obj, string name, string fallback)
+ => obj.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.String
+ ? (el.GetString() ?? fallback) : fallback;
+
+ private static bool ReadBool(JsonElement obj, string name, bool fallback)
+ => obj.TryGetProperty(name, out var el)
+ && (el.ValueKind == JsonValueKind.True || el.ValueKind == JsonValueKind.False)
+ ? el.GetBoolean() : fallback;
+
+ private static float ReadFloat(JsonElement obj, string name, float fallback)
+ => obj.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.Number
+ ? el.GetSingle() : fallback;
+}
diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs
index 5d33480..2cf233b 100644
--- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs
+++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs
@@ -30,6 +30,12 @@ public sealed class SettingsVM
private readonly InputDispatcher _dispatcher;
private readonly Action _onSave;
+ // L.0 — Display tab. Treated as a single immutable record; mutation
+ // through SetDisplay clones via with-expressions on the panel side.
+ private DisplaySettings _displayPersisted;
+ private DisplaySettings _displayDraft;
+ private readonly Action _onSaveDisplay;
+
/// The action currently being rebound, or null when idle.
public InputAction? RebindInProgress { get; private set; }
@@ -50,14 +56,38 @@ public sealed class SettingsVM
/// True iff the draft differs structurally from the
/// persisted snapshot. Used to grey out the Save button when no
/// rebinds are pending.
- public bool HasUnsavedChanges => !KeyBindingsEqual(_persisted, _draft);
+ public bool HasUnsavedChanges
+ => !KeyBindingsEqual(_persisted, _draft)
+ || _displayPersisted != _displayDraft;
- public SettingsVM(KeyBindings persisted, InputDispatcher dispatcher, Action onSave)
+ /// The current Display draft. Panel reads from here;
+ /// mutation goes through .
+ public DisplaySettings DisplayDraft => _displayDraft;
+
+ public SettingsVM(
+ KeyBindings persisted,
+ InputDispatcher dispatcher,
+ Action onSave,
+ DisplaySettings persistedDisplay,
+ Action onSaveDisplay)
{
- _persisted = persisted ?? throw new ArgumentNullException(nameof(persisted));
- _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
- _onSave = onSave ?? throw new ArgumentNullException(nameof(onSave));
- _draft = CloneBindings(persisted);
+ _persisted = persisted ?? throw new ArgumentNullException(nameof(persisted));
+ _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
+ _onSave = onSave ?? throw new ArgumentNullException(nameof(onSave));
+ _displayPersisted = persistedDisplay ?? throw new ArgumentNullException(nameof(persistedDisplay));
+ _onSaveDisplay = onSaveDisplay ?? throw new ArgumentNullException(nameof(onSaveDisplay));
+ _draft = CloneBindings(persisted);
+ _displayDraft = persistedDisplay;
+ }
+
+ ///
+ /// Replace the entire Display draft with .
+ /// Panel calls this with a DisplayDraft with { Field = newValue }
+ /// so each widget edits exactly one field at a time.
+ ///
+ public void SetDisplay(DisplaySettings value)
+ {
+ _displayDraft = value ?? throw new ArgumentNullException(nameof(value));
}
///
@@ -160,32 +190,42 @@ public sealed class SettingsVM
}
///
- /// Replace the entire draft with .
+ /// Replace the keybinds draft with
+ /// AND the display draft with .
+ /// "Reset all" applies to every tab — it's the user's escape hatch
+ /// when they've gotten lost.
///
public void ResetAllToDefaults()
{
- _draft = KeyBindings.RetailDefaults();
+ _draft = KeyBindings.RetailDefaults();
+ _displayDraft = DisplaySettings.Default;
}
///
- /// Commit the draft via the onSave callback supplied at
- /// construction. After save the draft becomes the new persisted
- /// snapshot — resets to false.
+ /// Commit both keybinds + display drafts via the onSave callbacks
+ /// supplied at construction. After save the drafts become the new
+ /// persisted snapshots — resets to
+ /// false. Each callback is invoked exactly once per Save; if the
+ /// caller wants atomicity across both files it has to handle it
+ /// outside the VM.
///
public void Save()
{
_onSave(_draft);
- _persisted = CloneBindings(_draft);
+ _onSaveDisplay(_displayDraft);
+ _persisted = CloneBindings(_draft);
+ _displayPersisted = _displayDraft;
}
///
- /// Revert the draft to the persisted snapshot and clear any
+ /// Revert all drafts to their persisted snapshots and clear any
/// in-flight rebind state. Used by the panel's "Cancel" button and
/// when the user closes the settings window without saving.
///
public void Cancel()
{
- _draft = CloneBindings(_persisted);
+ _draft = CloneBindings(_persisted);
+ _displayDraft = _displayPersisted;
CancelRebind();
}
diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/DisplaySettingsTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/DisplaySettingsTests.cs
new file mode 100644
index 0000000..f73db09
--- /dev/null
+++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/DisplaySettingsTests.cs
@@ -0,0 +1,67 @@
+using AcDream.UI.Abstractions.Panels.Settings;
+
+namespace AcDream.UI.Abstractions.Tests.Panels.Settings;
+
+///
+/// L.0: is the immutable record of
+/// display-tab preferences. Defaults are pinned here so a regression
+/// (e.g. someone changing the default FOV out from under users)
+/// surfaces immediately.
+///
+public sealed class DisplaySettingsTests
+{
+ [Fact]
+ public void Default_values_match_brainstorm_agreement()
+ {
+ var d = DisplaySettings.Default;
+ Assert.Equal("1920x1080", d.Resolution);
+ Assert.False(d.Fullscreen);
+ Assert.True(d.VSync);
+ Assert.Equal(75f, d.FieldOfView);
+ Assert.Equal(1.0f, d.Gamma);
+ Assert.False(d.ShowFps);
+ }
+
+ [Fact]
+ public void AvailableResolutions_includes_common_16_9_options()
+ {
+ var list = DisplaySettings.AvailableResolutions;
+ Assert.Contains("1280x720", list);
+ Assert.Contains("1920x1080", list);
+ Assert.Contains("2560x1440", list);
+ Assert.Contains("3840x2160", list);
+ // List should be ascending so the dropdown reads naturally.
+ for (int i = 1; i < list.Count; i++)
+ {
+ int prevW = ParseWidth(list[i - 1]);
+ int curW = ParseWidth(list[i]);
+ Assert.True(curW >= prevW, $"Resolutions not sorted: {list[i - 1]} >= {list[i]}");
+ }
+ }
+
+ [Fact]
+ public void Equality_is_value_based()
+ {
+ var a = DisplaySettings.Default;
+ var b = DisplaySettings.Default with { ShowFps = true };
+ var c = DisplaySettings.Default with { ShowFps = true };
+ Assert.NotEqual(a, b);
+ Assert.Equal(b, c);
+ }
+
+ [Fact]
+ public void With_expression_clones_one_field()
+ {
+ var d = DisplaySettings.Default with { FieldOfView = 90f };
+ Assert.Equal(90f, d.FieldOfView);
+ // Other fields untouched.
+ Assert.Equal("1920x1080", d.Resolution);
+ Assert.True(d.VSync);
+ }
+
+ private static int ParseWidth(string res)
+ {
+ int x = res.IndexOf('x');
+ return int.Parse(res.AsSpan(0, x));
+ }
+}
diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs
index 2d894ac..ec05c4c 100644
--- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs
+++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsPanelTests.cs
@@ -29,7 +29,9 @@ public sealed class SettingsPanelTests
persisted.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward));
persisted.Add(new Binding(new KeyChord(Key.A, ModifierMask.None), InputAction.MovementTurnLeft));
var dispatcher = new InputDispatcher(kb, mouse, persisted);
- var vm = new SettingsVM(persisted, dispatcher, _ => { });
+ var vm = new SettingsVM(
+ persisted, dispatcher, _ => { },
+ DisplaySettings.Default, _ => { });
var panel = new SettingsPanel(vm);
return (panel, vm, kb, dispatcher);
}
@@ -234,6 +236,54 @@ public sealed class SettingsPanelTests
Assert.Contains(wrapped, t => t.Contains("Audio settings coming soon"));
}
+ // -- Display tab content ---------------------------------------------
+
+ [Fact]
+ public void Display_tab_when_active_renders_resolution_combo_plus_sliders()
+ {
+ var (panel, _, _, _) = Build();
+ var r = new FakePanelRenderer { ActiveTabLabel = "Display" };
+
+ panel.Render(new PanelContext(0.016f, new NullBus()), r);
+
+ var combos = r.Calls.Where(c => c.Method == "Combo").Select(c => (string)c.Args[0]!).ToList();
+ var checks = r.Calls.Where(c => c.Method == "Checkbox").Select(c => (string)c.Args[0]!).ToList();
+ var sliders = r.Calls.Where(c => c.Method == "SliderFloat").Select(c => (string)c.Args[0]!).ToList();
+
+ Assert.Contains("Resolution", combos);
+ Assert.Contains("Fullscreen", checks);
+ Assert.Contains("V-Sync", checks);
+ Assert.Contains("Show FPS", checks);
+ Assert.Contains("Field of View", sliders);
+ Assert.Contains("Gamma", sliders);
+ }
+
+ [Fact]
+ public void Display_tab_does_not_render_when_a_different_tab_is_active()
+ {
+ var (panel, _, _, _) = Build();
+ var r = new FakePanelRenderer { ActiveTabLabel = "Audio" };
+
+ panel.Render(new PanelContext(0.016f, new NullBus()), r);
+
+ var combos = r.Calls.Where(c => c.Method == "Combo").Select(c => (string)c.Args[0]!).ToList();
+ Assert.DoesNotContain("Resolution", combos);
+ }
+
+ [Fact]
+ public void Display_tab_resolution_combo_uses_AvailableResolutions_list()
+ {
+ var (panel, _, _, _) = Build();
+ var r = new FakePanelRenderer { ActiveTabLabel = "Display" };
+
+ panel.Render(new PanelContext(0.016f, new NullBus()), r);
+
+ var resCall = r.Calls.First(c => c.Method == "Combo" && (string)c.Args[0]! == "Resolution");
+ var items = (string[])resCall.Args[2]!;
+ Assert.Contains("1920x1080", items);
+ Assert.Contains("3840x2160", items);
+ }
+
[Fact]
public void Save_Cancel_buttons_render_outside_the_tab_bar()
{
diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs
new file mode 100644
index 0000000..226454c
--- /dev/null
+++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsStoreTests.cs
@@ -0,0 +1,119 @@
+using System.IO;
+using AcDream.UI.Abstractions.Panels.Settings;
+
+namespace AcDream.UI.Abstractions.Tests.Panels.Settings;
+
+///
+/// L.0: reads / writes settings.json.
+/// Tests use a temp-file path so they don't touch the user's
+/// %LOCALAPPDATA% file.
+///
+public sealed class SettingsStoreTests : System.IDisposable
+{
+ private readonly string _tempPath;
+
+ public SettingsStoreTests()
+ {
+ // Unique per-test file under the system temp dir so parallel test
+ // runners don't clobber each other.
+ _tempPath = Path.Combine(
+ Path.GetTempPath(),
+ $"acdream-settings-test-{System.Guid.NewGuid():N}.json");
+ }
+
+ public void Dispose()
+ {
+ if (File.Exists(_tempPath)) File.Delete(_tempPath);
+ }
+
+ [Fact]
+ public void LoadDisplay_returns_defaults_when_file_is_missing()
+ {
+ var store = new SettingsStore(_tempPath);
+ var loaded = store.LoadDisplay();
+ Assert.Equal(DisplaySettings.Default, loaded);
+ }
+
+ [Fact]
+ public void SaveDisplay_then_LoadDisplay_round_trips_all_fields()
+ {
+ var store = new SettingsStore(_tempPath);
+ var original = new DisplaySettings(
+ Resolution: "2560x1440",
+ Fullscreen: true,
+ VSync: false,
+ FieldOfView: 100f,
+ Gamma: 1.4f,
+ ShowFps: true);
+
+ store.SaveDisplay(original);
+ var loaded = store.LoadDisplay();
+
+ Assert.Equal(original, loaded);
+ }
+
+ [Fact]
+ public void LoadDisplay_falls_back_to_defaults_when_file_is_corrupt()
+ {
+ File.WriteAllText(_tempPath, "{ this is not valid json");
+ var store = new SettingsStore(_tempPath);
+
+ var loaded = store.LoadDisplay();
+
+ Assert.Equal(DisplaySettings.Default, loaded);
+ }
+
+ [Fact]
+ public void LoadDisplay_falls_back_per_field_when_keys_missing()
+ {
+ // Partial file — only resolution set; everything else should
+ // pick up DisplaySettings.Default values.
+ File.WriteAllText(_tempPath, """
+ {
+ "version": 1,
+ "display": { "resolution": "1366x768" }
+ }
+ """);
+ var store = new SettingsStore(_tempPath);
+
+ var loaded = store.LoadDisplay();
+
+ Assert.Equal("1366x768", loaded.Resolution);
+ Assert.Equal(DisplaySettings.Default.Fullscreen, loaded.Fullscreen);
+ Assert.Equal(DisplaySettings.Default.VSync, loaded.VSync);
+ Assert.Equal(DisplaySettings.Default.FieldOfView, loaded.FieldOfView);
+ }
+
+ [Fact]
+ public void SaveDisplay_preserves_unknown_top_level_keys()
+ {
+ // Forward-compat: a newer client may have written sections we
+ // don't know about (audio, gameplay). Saving display must not
+ // delete those, otherwise running an older client would silently
+ // drop the user's other-tab preferences.
+ File.WriteAllText(_tempPath, """
+ {
+ "version": 1,
+ "display": { "resolution": "1280x720" },
+ "audio": { "master": 0.5, "music": 0.7 }
+ }
+ """);
+ var store = new SettingsStore(_tempPath);
+
+ store.SaveDisplay(DisplaySettings.Default with { Resolution = "1920x1080" });
+
+ var raw = File.ReadAllText(_tempPath);
+ Assert.Contains("\"audio\"", raw);
+ Assert.Contains("\"master\"", raw);
+ Assert.Contains("0.5", raw);
+ // And the new display value did get written.
+ Assert.Contains("1920x1080", raw);
+ }
+
+ [Fact]
+ public void DefaultPath_is_under_LocalAppData_acdream()
+ {
+ var path = SettingsStore.DefaultPath();
+ Assert.EndsWith("acdream" + Path.DirectorySeparatorChar + "settings.json", path);
+ }
+}
diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs
index f347190..595ff38 100644
--- a/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs
+++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Settings/SettingsVMTests.cs
@@ -16,16 +16,21 @@ namespace AcDream.UI.Abstractions.Tests.Panels.Settings;
///
public sealed class SettingsVMTests
{
- private static (SettingsVM vm, FakeKeyboardSource kb, InputDispatcher dispatcher, KeyBindings persisted, System.Collections.Generic.List savedHistory)
- Build(KeyBindings? persisted = null)
+ private static (SettingsVM vm, FakeKeyboardSource kb, InputDispatcher dispatcher, KeyBindings persisted, System.Collections.Generic.List savedHistory, System.Collections.Generic.List savedDisplayHistory)
+ Build(KeyBindings? persisted = null, DisplaySettings? persistedDisplay = null)
{
persisted ??= MakeMinimalBindings();
var kb = new FakeKeyboardSource();
var mouse = new FakeMouseSource();
var dispatcher = new InputDispatcher(kb, mouse, persisted);
var savedHistory = new System.Collections.Generic.List();
- var vm = new SettingsVM(persisted, dispatcher, b => savedHistory.Add(b));
- return (vm, kb, dispatcher, persisted, savedHistory);
+ var savedDisplayHistory = new System.Collections.Generic.List();
+ var vm = new SettingsVM(
+ persisted, dispatcher,
+ b => savedHistory.Add(b),
+ persistedDisplay ?? DisplaySettings.Default,
+ d => savedDisplayHistory.Add(d));
+ return (vm, kb, dispatcher, persisted, savedHistory, savedDisplayHistory);
}
private static KeyBindings MakeMinimalBindings()
@@ -40,7 +45,7 @@ public sealed class SettingsVMTests
[Fact]
public void Constructor_clones_persisted_into_draft()
{
- var (vm, _, _, persisted, _) = Build();
+ var (vm, _, _, persisted, _, _) = Build();
Assert.Equal(persisted.All.Count, vm.Draft.All.Count);
Assert.False(vm.HasUnsavedChanges);
}
@@ -48,7 +53,7 @@ public sealed class SettingsVMTests
[Fact]
public void BeginRebind_enters_capture_mode()
{
- var (vm, _, dispatcher, _, _) = Build();
+ var (vm, _, dispatcher, _, _, _) = Build();
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
vm.BeginRebind(InputAction.MovementForward, original);
@@ -61,7 +66,7 @@ public sealed class SettingsVMTests
[Fact]
public void BeginRebind_then_chord_with_no_conflict_applies_rebind()
{
- var (vm, kb, _, _, _) = Build();
+ var (vm, kb, _, _, _, _) = Build();
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
vm.BeginRebind(InputAction.MovementForward, original);
@@ -79,7 +84,7 @@ public sealed class SettingsVMTests
[Fact]
public void BeginRebind_then_Escape_cancels_with_no_change()
{
- var (vm, kb, _, _, _) = Build();
+ var (vm, kb, _, _, _, _) = Build();
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
vm.BeginRebind(InputAction.MovementForward, original);
@@ -96,7 +101,7 @@ public sealed class SettingsVMTests
[Fact]
public void BeginRebind_with_conflict_surfaces_PendingConflict()
{
- var (vm, kb, _, _, _) = Build();
+ var (vm, kb, _, _, _, _) = Build();
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
// Bind chord that conflicts with MovementTurnLeft (which has Key.A).
@@ -116,7 +121,7 @@ public sealed class SettingsVMTests
[Fact]
public void ResolveConflict_replace_true_removes_conflict_and_applies_rebind()
{
- var (vm, kb, _, _, _) = Build();
+ var (vm, kb, _, _, _, _) = Build();
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
vm.BeginRebind(InputAction.MovementForward, original);
@@ -137,7 +142,7 @@ public sealed class SettingsVMTests
[Fact]
public void ResolveConflict_replace_false_cancels_rebind()
{
- var (vm, kb, _, _, _) = Build();
+ var (vm, kb, _, _, _, _) = Build();
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
vm.BeginRebind(InputAction.MovementForward, original);
@@ -159,7 +164,7 @@ public sealed class SettingsVMTests
{
// Build a draft that's been mutated for MovementForward; ensure
// ResetActionToDefault restores W (and Up-arrow per retail).
- var (vm, kb, _, _, _) = Build(KeyBindings.RetailDefaults());
+ var (vm, kb, _, _, _, _) = Build(KeyBindings.RetailDefaults());
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
vm.BeginRebind(InputAction.MovementForward, original);
// F7 is unbound in retail-default (only Ctrl+F7 is acdream debug);
@@ -179,7 +184,7 @@ public sealed class SettingsVMTests
[Fact]
public void ResetAllToDefaults_replaces_entire_draft()
{
- var (vm, _, _, _, _) = Build();
+ var (vm, _, _, _, _, _) = Build();
vm.ResetAllToDefaults();
// Should now include retail-default size set (~149 bindings).
@@ -190,7 +195,7 @@ public sealed class SettingsVMTests
[Fact]
public void Save_invokes_callback_with_draft()
{
- var (vm, kb, _, _, savedHistory) = Build();
+ var (vm, kb, _, _, savedHistory, _) = Build();
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
vm.BeginRebind(InputAction.MovementForward, original);
kb.EmitKeyDown(Key.Q, ModifierMask.None);
@@ -206,7 +211,7 @@ public sealed class SettingsVMTests
[Fact]
public void Cancel_reverts_draft_to_persisted()
{
- var (vm, kb, _, _, _) = Build();
+ var (vm, kb, _, _, _, _) = Build();
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
vm.BeginRebind(InputAction.MovementForward, original);
kb.EmitKeyDown(Key.Q, ModifierMask.None);
@@ -222,7 +227,7 @@ public sealed class SettingsVMTests
[Fact]
public void Cancel_during_active_capture_clears_dispatcher_capture_state()
{
- var (vm, _, dispatcher, _, _) = Build();
+ var (vm, _, dispatcher, _, _, _) = Build();
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
vm.BeginRebind(InputAction.MovementForward, original);
@@ -235,7 +240,84 @@ public sealed class SettingsVMTests
[Fact]
public void HasUnsavedChanges_false_initially_and_after_save_sync()
{
- var (vm, _, _, _, _) = Build();
+ var (vm, _, _, _, _, _) = Build();
+ Assert.False(vm.HasUnsavedChanges);
+ }
+
+ // -- Display tab state ------------------------------------------------
+
+ [Fact]
+ public void DisplayDraft_initial_value_matches_persisted()
+ {
+ var custom = DisplaySettings.Default with { FieldOfView = 90f, ShowFps = true };
+ var (vm, _, _, _, _, _) = Build(persistedDisplay: custom);
+ Assert.Equal(custom, vm.DisplayDraft);
+ Assert.False(vm.HasUnsavedChanges);
+ }
+
+ [Fact]
+ public void SetDisplay_marks_unsaved_changes()
+ {
+ var (vm, _, _, _, _, _) = Build();
+ vm.SetDisplay(vm.DisplayDraft with { ShowFps = true });
+ Assert.True(vm.HasUnsavedChanges);
+ }
+
+ [Fact]
+ public void Save_invokes_display_callback_with_draft()
+ {
+ var (vm, _, _, _, _, savedDisplayHistory) = Build();
+ vm.SetDisplay(vm.DisplayDraft with { Resolution = "2560x1440", FieldOfView = 100f });
+
+ vm.Save();
+
+ Assert.Single(savedDisplayHistory);
+ Assert.Equal("2560x1440", savedDisplayHistory[0].Resolution);
+ Assert.Equal(100f, savedDisplayHistory[0].FieldOfView);
+ Assert.False(vm.HasUnsavedChanges);
+ }
+
+ [Fact]
+ public void Cancel_reverts_display_draft_to_persisted()
+ {
+ var custom = DisplaySettings.Default with { FieldOfView = 90f };
+ var (vm, _, _, _, _, _) = Build(persistedDisplay: custom);
+ vm.SetDisplay(vm.DisplayDraft with { FieldOfView = 30f, ShowFps = true });
+ Assert.True(vm.HasUnsavedChanges);
+
+ vm.Cancel();
+
+ Assert.Equal(custom, vm.DisplayDraft);
+ Assert.False(vm.HasUnsavedChanges);
+ }
+
+ [Fact]
+ public void ResetAllToDefaults_resets_display_to_default()
+ {
+ var custom = DisplaySettings.Default with { FieldOfView = 30f, ShowFps = true };
+ var (vm, _, _, _, _, _) = Build(persistedDisplay: custom);
+ Assert.NotEqual(DisplaySettings.Default, vm.DisplayDraft);
+
+ vm.ResetAllToDefaults();
+
+ Assert.Equal(DisplaySettings.Default, vm.DisplayDraft);
+ Assert.True(vm.HasUnsavedChanges);
+ }
+
+ [Fact]
+ public void Save_then_Cancel_does_not_revert()
+ {
+ // After Save the persisted snapshot equals the draft, so Cancel
+ // is a no-op. This guards the Save/Cancel ordering — a regression
+ // would surface as Cancel reverting to pre-Save values.
+ var (vm, _, _, _, _, _) = Build();
+ vm.SetDisplay(vm.DisplayDraft with { ShowFps = true });
+ vm.Save();
+ Assert.False(vm.HasUnsavedChanges);
+
+ vm.Cancel();
+
+ Assert.True(vm.DisplayDraft.ShowFps);
Assert.False(vm.HasUnsavedChanges);
}
}