feat(ui): Display tab + settings.json persistence — first non-keybind tab lands
Phase L.0 (cont.) — first concrete tab on the new Settings shell, in the Easy-wins build order agreed in the brainstorm (Display → Audio → Gameplay → Chat → Character). DisplaySettings (immutable record): Resolution / Fullscreen / VSync / FieldOfView (30-120°) / Gamma (0.5-2.0) / ShowFps. Six common 16:9 resolutions in the dropdown. Defaults: 1920×1080, windowed, vsync on, 75° FOV, gamma 1.0, FPS off — matches the brainstorm UX agreement. SettingsStore: JSON persistence at %LOCALAPPDATA%\acdream\settings.json (coexists with keybinds.json — own load/save path stays put, no migration needed). LoadDisplay falls back per-field when keys are missing (partial-file tolerant) and falls back to defaults when the file is corrupt or the JSON is unparseable. SaveDisplay round-trips preserved — unknown top-level keys (e.g. an `audio` section written by a future client) are kept on save so older builds don't silently drop newer-tab data. SettingsVM gains a parallel display-state machine: persistedDisplay + draftDisplay, SetDisplay mutator, HasUnsavedChanges checks both keybinds and display deltas, Save/Cancel/ResetAll cover both atomically from the user's POV (one Save commits everything, one Cancel reverts everything). Constructor signature extends with two new params; existing keybinds-only callers updated. SettingsPanel.RenderDisplayTab replaces the L.0-shell placeholder — Combo for resolution, Checkboxes for fullscreen/vsync/show-fps, SliderFloat for FOV + gamma. Live-preview note in the panel body matches the agreed UX: FOV + gamma update visibly while the user drags; resolution / fullscreen / vsync apply on Save (live preview would be too jarring). GameWindow wires SettingsStore into the existing SettingsVM construct site — load on startup, save on each tab Save. Errors print to console and don't crash the panel. 19 new tests: · DisplaySettings record (4) — defaults pinned, value equality, with- expressions, AvailableResolutions sorted ascending · SettingsStore (6) — round trip, missing-file → defaults, corrupt- file → defaults, partial-file → per-field fallback, unknown-key preservation, DefaultPath shape · SettingsVM display (6) — initial draft tracks persisted, SetDisplay marks dirty, Save invokes display callback, Cancel reverts, ResetAllToDefaults covers display, Save-then-Cancel is no-op · SettingsPanel display tab (3) — widgets render only when active, resolution combo uses AvailableResolutions, no Combo emitted on inactive tabs dotnet build green (0 warnings); dotnet test 1,246 / 1,246 green (243 Core.Net + 330 UI.Abstractions + 673 Core). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7665cdf642
commit
382f0ad3fa
9 changed files with 653 additions and 33 deletions
|
|
@ -30,6 +30,12 @@ public sealed class SettingsVM
|
|||
private readonly InputDispatcher _dispatcher;
|
||||
private readonly Action<KeyBindings> _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<DisplaySettings> _onSaveDisplay;
|
||||
|
||||
/// <summary>The action currently being rebound, or null when idle.</summary>
|
||||
public InputAction? RebindInProgress { get; private set; }
|
||||
|
||||
|
|
@ -50,14 +56,38 @@ public sealed class SettingsVM
|
|||
/// <summary>True iff the draft differs structurally from the
|
||||
/// persisted snapshot. Used to grey out the Save button when no
|
||||
/// rebinds are pending.</summary>
|
||||
public bool HasUnsavedChanges => !KeyBindingsEqual(_persisted, _draft);
|
||||
public bool HasUnsavedChanges
|
||||
=> !KeyBindingsEqual(_persisted, _draft)
|
||||
|| _displayPersisted != _displayDraft;
|
||||
|
||||
public SettingsVM(KeyBindings persisted, InputDispatcher dispatcher, Action<KeyBindings> onSave)
|
||||
/// <summary>The current Display draft. Panel reads from here;
|
||||
/// mutation goes through <see cref="SetDisplay"/>.</summary>
|
||||
public DisplaySettings DisplayDraft => _displayDraft;
|
||||
|
||||
public SettingsVM(
|
||||
KeyBindings persisted,
|
||||
InputDispatcher dispatcher,
|
||||
Action<KeyBindings> onSave,
|
||||
DisplaySettings persistedDisplay,
|
||||
Action<DisplaySettings> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replace the entire Display draft with <paramref name="value"/>.
|
||||
/// Panel calls this with a <c>DisplayDraft with { Field = newValue }</c>
|
||||
/// so each widget edits exactly one field at a time.
|
||||
/// </summary>
|
||||
public void SetDisplay(DisplaySettings value)
|
||||
{
|
||||
_displayDraft = value ?? throw new ArgumentNullException(nameof(value));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -160,32 +190,42 @@ public sealed class SettingsVM
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replace the entire draft with <see cref="KeyBindings.RetailDefaults"/>.
|
||||
/// Replace the keybinds draft with <see cref="KeyBindings.RetailDefaults"/>
|
||||
/// AND the display draft with <see cref="DisplaySettings.Default"/>.
|
||||
/// "Reset all" applies to every tab — it's the user's escape hatch
|
||||
/// when they've gotten lost.
|
||||
/// </summary>
|
||||
public void ResetAllToDefaults()
|
||||
{
|
||||
_draft = KeyBindings.RetailDefaults();
|
||||
_draft = KeyBindings.RetailDefaults();
|
||||
_displayDraft = DisplaySettings.Default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Commit the draft via the onSave callback supplied at
|
||||
/// construction. After save the draft becomes the new persisted
|
||||
/// snapshot — <see cref="HasUnsavedChanges"/> resets to false.
|
||||
/// Commit both keybinds + display drafts via the onSave callbacks
|
||||
/// supplied at construction. After save the drafts become the new
|
||||
/// persisted snapshots — <see cref="HasUnsavedChanges"/> 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.
|
||||
/// </summary>
|
||||
public void Save()
|
||||
{
|
||||
_onSave(_draft);
|
||||
_persisted = CloneBindings(_draft);
|
||||
_onSaveDisplay(_displayDraft);
|
||||
_persisted = CloneBindings(_draft);
|
||||
_displayPersisted = _displayDraft;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public void Cancel()
|
||||
{
|
||||
_draft = CloneBindings(_persisted);
|
||||
_draft = CloneBindings(_persisted);
|
||||
_displayDraft = _displayPersisted;
|
||||
CancelRebind();
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue