Phase L.0 polish — the Display + Character tabs were persisting to disk
but didn't yet drive runtime behavior. This commit flips the live
switches.
DISPLAY ↔ GL window:
· FOV slider (degrees) → camera FovY (radians) on Orbit + Fly + Chase,
pushed every frame so dragging is visible immediately. Brainstorm
said FOV is a live-preview slider; this delivers it.
· VSync → _window.VSync, change-detected per-frame so flipping the
checkbox is instant. Applied at startup too so saved-VSync takes
effect before the first frame.
· Resolution → _window.Size on Save (TryParseResolution parses
"WIDTHxHEIGHT"). Live preview would be too jarring; resize is on
Save only.
· Fullscreen → _window.WindowState (Silk.NET borderless mode), also
on Save only.
· ShowFps → wraps the title-bar perf string. true → full perf line;
false → just "acdream" for a cleaner alt-tab. Default true matches
pre-L.0 behavior.
Defaults rebalanced — FieldOfView 75→60° (matches Orbit/Fly/Chase
FovY = π/3), VSync true→false (matches the previous WindowOptions),
ShowFps false→true (preserves the existing perf-in-title behavior).
Net effect: a user who never opens Display tab + later opens it +
Saves without touching anything sees ZERO visual change. Tests pinned
to the new defaults.
ApplyDisplayWindowState helper consolidates the window-side
mutations. Called from the SettingsVM construction site (apply
persisted at startup) and from the onSaveDisplay callback (apply
saved on demand). Malformed resolution strings are silently ignored
to avoid crashing mid-session if settings.json gets hand-edited.
CHARACTER ↔ active toon:
· _activeToonKey field replaces the hard-coded "default" — starts as
"default" (used for any pre-login Settings interaction), gets
swapped to the actual character.Name immediately after EnterWorld
in BeginLiveSessionAsync.
· onSaveCharacter callback closes over _activeToonKey by reference
(lambda captures `this`), so saves always write to the current
toon's slot without rebinding the lambda.
· After EnterWorld lands the chosen toon's name, the host loads
that toon's bag via SettingsStore.LoadCharacter and calls a new
SettingsVM.LoadCharacterContext to swap BOTH persisted snapshot
AND draft atomically — HasUnsavedChanges stays false on login so
the user doesn't see a "pending changes" indicator just because
they switched toons.
Per-toon storage already worked at the SettingsStore layer (commit
73749d1); this commit just plumbs the actual character name through
to the toonKey instead of always using "default".
2 new tests for LoadCharacterContext: atomic persisted+draft swap,
and pending edits getting wiped on swap (so pre-login bleed-through
can't write to the new toon's slot).
dotnet build green (0 warnings); dotnet test 1,309 / 1,309 green
(243 Core.Net + 393 UI.Abstractions + 673 Core).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
399 lines
16 KiB
C#
399 lines
16 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using AcDream.UI.Abstractions.Input;
|
|
|
|
namespace AcDream.UI.Abstractions.Panels.Settings;
|
|
|
|
/// <summary>
|
|
/// K.3 ViewModel for <see cref="SettingsPanel"/>. Owns a <b>draft</b>
|
|
/// copy of the current <see cref="KeyBindings"/>; rebinds modify the
|
|
/// draft. <see cref="Save"/> commits draft via the supplied callback
|
|
/// (which writes to disk + replaces the live dispatcher's table);
|
|
/// <see cref="Cancel"/> reverts the draft to the persisted state.
|
|
///
|
|
/// <para>
|
|
/// Click-to-rebind UX: caller invokes <see cref="BeginRebind"/> with the
|
|
/// action being rebound + the binding being replaced. The VM enters
|
|
/// modal capture on the dispatcher; when the user presses a chord (or
|
|
/// Esc), the dispatcher reports it via <see cref="OnChordCaptured"/>.
|
|
/// If the new chord conflicts with another action's binding (same
|
|
/// activation type), <see cref="PendingConflict"/> surfaces a prompt
|
|
/// the panel renders as Yes / No buttons; <see cref="ResolveConflict"/>
|
|
/// dispatches the user's choice.
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class SettingsVM
|
|
{
|
|
private KeyBindings _persisted;
|
|
private KeyBindings _draft;
|
|
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;
|
|
|
|
// L.0 — Audio tab. Same shape as Display.
|
|
private AudioSettings _audioPersisted;
|
|
private AudioSettings _audioDraft;
|
|
private readonly Action<AudioSettings> _onSaveAudio;
|
|
|
|
// L.0 — Gameplay tab (subset of retail CharacterOption flags).
|
|
private GameplaySettings _gameplayPersisted;
|
|
private GameplaySettings _gameplayDraft;
|
|
private readonly Action<GameplaySettings> _onSaveGameplay;
|
|
|
|
// L.0 — Chat tab (CharacterOptions2 channel filters + visual prefs).
|
|
private ChatSettings _chatPersisted;
|
|
private ChatSettings _chatDraft;
|
|
private readonly Action<ChatSettings> _onSaveChat;
|
|
|
|
// L.0 — Character tab (per-toon, host-keyed by toon name).
|
|
private CharacterSettings _characterPersisted;
|
|
private CharacterSettings _characterDraft;
|
|
private readonly Action<CharacterSettings> _onSaveCharacter;
|
|
|
|
/// <summary>The action currently being rebound, or null when idle.</summary>
|
|
public InputAction? RebindInProgress { get; private set; }
|
|
|
|
/// <summary>The original binding being replaced (so we can preserve
|
|
/// activation type on the new chord and roll back on cancel).</summary>
|
|
public Binding? RebindOriginal { get; private set; }
|
|
|
|
/// <summary>The action+chord conflict pending confirmation, or null.
|
|
/// Populated when <see cref="OnChordCaptured"/> finds the captured
|
|
/// chord already bound to another action; cleared by
|
|
/// <see cref="ResolveConflict"/>.</summary>
|
|
public ConflictPrompt? PendingConflict { get; private set; }
|
|
|
|
/// <summary>The current working draft. Panel renders bindings from
|
|
/// here; mutates via the rebind / reset methods.</summary>
|
|
public KeyBindings Draft => _draft;
|
|
|
|
/// <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)
|
|
|| _displayPersisted != _displayDraft
|
|
|| _audioPersisted != _audioDraft
|
|
|| _gameplayPersisted != _gameplayDraft
|
|
|| _chatPersisted != _chatDraft
|
|
|| _characterPersisted != _characterDraft;
|
|
|
|
/// <summary>The current Display draft. Panel reads from here;
|
|
/// mutation goes through <see cref="SetDisplay"/>.</summary>
|
|
public DisplaySettings DisplayDraft => _displayDraft;
|
|
|
|
/// <summary>The current Audio draft. Panel reads from here;
|
|
/// mutation goes through <see cref="SetAudio"/>.</summary>
|
|
public AudioSettings AudioDraft => _audioDraft;
|
|
|
|
/// <summary>The current Gameplay draft. Panel reads from here;
|
|
/// mutation goes through <see cref="SetGameplay"/>.</summary>
|
|
public GameplaySettings GameplayDraft => _gameplayDraft;
|
|
|
|
/// <summary>The current Chat draft. Panel reads from here;
|
|
/// mutation goes through <see cref="SetChat"/>.</summary>
|
|
public ChatSettings ChatDraft => _chatDraft;
|
|
|
|
/// <summary>The current Character draft (per-toon — host owns the
|
|
/// toon-name key). Panel reads from here; mutation goes through
|
|
/// <see cref="SetCharacter"/>.</summary>
|
|
public CharacterSettings CharacterDraft => _characterDraft;
|
|
|
|
public SettingsVM(
|
|
KeyBindings persisted,
|
|
InputDispatcher dispatcher,
|
|
Action<KeyBindings> onSave,
|
|
DisplaySettings persistedDisplay,
|
|
Action<DisplaySettings> onSaveDisplay,
|
|
AudioSettings persistedAudio,
|
|
Action<AudioSettings> onSaveAudio,
|
|
GameplaySettings persistedGameplay,
|
|
Action<GameplaySettings> onSaveGameplay,
|
|
ChatSettings persistedChat,
|
|
Action<ChatSettings> onSaveChat,
|
|
CharacterSettings persistedCharacter,
|
|
Action<CharacterSettings> onSaveCharacter)
|
|
{
|
|
_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));
|
|
_audioPersisted = persistedAudio ?? throw new ArgumentNullException(nameof(persistedAudio));
|
|
_onSaveAudio = onSaveAudio ?? throw new ArgumentNullException(nameof(onSaveAudio));
|
|
_gameplayPersisted = persistedGameplay ?? throw new ArgumentNullException(nameof(persistedGameplay));
|
|
_onSaveGameplay = onSaveGameplay ?? throw new ArgumentNullException(nameof(onSaveGameplay));
|
|
_chatPersisted = persistedChat ?? throw new ArgumentNullException(nameof(persistedChat));
|
|
_onSaveChat = onSaveChat ?? throw new ArgumentNullException(nameof(onSaveChat));
|
|
_characterPersisted = persistedCharacter ?? throw new ArgumentNullException(nameof(persistedCharacter));
|
|
_onSaveCharacter = onSaveCharacter ?? throw new ArgumentNullException(nameof(onSaveCharacter));
|
|
_draft = CloneBindings(persisted);
|
|
_displayDraft = persistedDisplay;
|
|
_audioDraft = persistedAudio;
|
|
_gameplayDraft = persistedGameplay;
|
|
_chatDraft = persistedChat;
|
|
_characterDraft = persistedCharacter;
|
|
}
|
|
|
|
/// <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>
|
|
/// Replace the entire Audio draft with <paramref name="value"/>.
|
|
/// Live audio preview is achieved at the host layer by pushing
|
|
/// <see cref="AudioDraft"/> into the running OpenAL engine each frame
|
|
/// — this method only mutates VM state. Cancel reverts the draft and
|
|
/// the host's next-frame push restores the pre-edit engine volumes.
|
|
/// </summary>
|
|
public void SetAudio(AudioSettings value)
|
|
{
|
|
_audioDraft = value ?? throw new ArgumentNullException(nameof(value));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Replace the entire Gameplay draft with <paramref name="value"/>.
|
|
/// Local-only this phase — values persist on Save but don't yet
|
|
/// flow to the server. When server-sync ships, the host's
|
|
/// <c>onSaveGameplay</c> callback will marshal the draft into the
|
|
/// retail <c>CharacterOption</c> wire bitmask.
|
|
/// </summary>
|
|
public void SetGameplay(GameplaySettings value)
|
|
{
|
|
_gameplayDraft = value ?? throw new ArgumentNullException(nameof(value));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Replace the entire Chat draft with <paramref name="value"/>.
|
|
/// Local-only this phase — values persist on Save but the Hear*Chat
|
|
/// flags affect client-side display filtering, not server-side
|
|
/// channel subscriptions.
|
|
/// </summary>
|
|
public void SetChat(ChatSettings value)
|
|
{
|
|
_chatDraft = value ?? throw new ArgumentNullException(nameof(value));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Replace the entire Character draft with <paramref name="value"/>.
|
|
/// Per-toon — the host knows which toon's bag we're editing because
|
|
/// it owned the toonKey when constructing the VM.
|
|
/// </summary>
|
|
public void SetCharacter(CharacterSettings value)
|
|
{
|
|
_characterDraft = value ?? throw new ArgumentNullException(nameof(value));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Replace BOTH the persisted snapshot and the live draft for the
|
|
/// Character bag. Used when the active toon changes (e.g. on
|
|
/// EnterWorld with a non-default character) — the host loads that
|
|
/// toon's settings from disk and pushes them into the VM here so
|
|
/// <see cref="HasUnsavedChanges"/> doesn't flag the swap as a
|
|
/// pending edit. Differs from <see cref="SetCharacter"/>, which
|
|
/// updates draft only.
|
|
/// </summary>
|
|
public void LoadCharacterContext(CharacterSettings persisted)
|
|
{
|
|
_characterPersisted = persisted ?? throw new ArgumentNullException(nameof(persisted));
|
|
_characterDraft = persisted;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Begin rebinding <paramref name="action"/>. The supplied
|
|
/// <paramref name="original"/> binding will be removed when the new
|
|
/// chord is applied. The dispatcher enters modal capture mode; the
|
|
/// next chord pressed (or Esc) feeds back into
|
|
/// <see cref="OnChordCaptured"/>.
|
|
/// </summary>
|
|
public void BeginRebind(InputAction action, Binding original)
|
|
{
|
|
RebindInProgress = action;
|
|
RebindOriginal = original;
|
|
_dispatcher.BeginCapture(OnChordCaptured);
|
|
}
|
|
|
|
private void OnChordCaptured(KeyChord chord)
|
|
{
|
|
// Sentinel: dispatcher reports default(KeyChord) on Esc cancel.
|
|
if (chord.Equals(default(KeyChord)))
|
|
{
|
|
RebindInProgress = null;
|
|
RebindOriginal = null;
|
|
return;
|
|
}
|
|
|
|
// Conflict check: scan the draft for a binding that matches the
|
|
// captured chord + same activation type, but on a DIFFERENT
|
|
// action. (Same-action bindings are fine — that's already in
|
|
// _draft for this action and gets removed when we apply.)
|
|
var existing = _draft.Find(chord, RebindOriginal!.Value.Activation);
|
|
if (existing is not null && existing.Value.Action != RebindInProgress!.Value)
|
|
{
|
|
PendingConflict = new ConflictPrompt(
|
|
NewAction: RebindInProgress.Value,
|
|
NewChord: chord,
|
|
OriginalBinding: RebindOriginal.Value,
|
|
ConflictingAction: existing.Value.Action,
|
|
ConflictingBinding: existing.Value);
|
|
return;
|
|
}
|
|
|
|
ApplyRebind(chord);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolve a <see cref="PendingConflict"/>: <paramref name="replace"/>=
|
|
/// true removes the conflicting binding and applies the new chord;
|
|
/// false cancels the rebind entirely (original binding intact).
|
|
/// </summary>
|
|
public void ResolveConflict(bool replace)
|
|
{
|
|
if (PendingConflict is null) return;
|
|
var c = PendingConflict.Value;
|
|
if (replace)
|
|
{
|
|
_draft.Remove(c.ConflictingBinding);
|
|
ApplyRebind(c.NewChord);
|
|
}
|
|
else
|
|
{
|
|
RebindInProgress = null;
|
|
RebindOriginal = null;
|
|
}
|
|
PendingConflict = null;
|
|
}
|
|
|
|
private void ApplyRebind(KeyChord chord)
|
|
{
|
|
_draft.Remove(RebindOriginal!.Value);
|
|
_draft.Add(new Binding(chord, RebindInProgress!.Value, RebindOriginal.Value.Activation));
|
|
RebindInProgress = null;
|
|
RebindOriginal = null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Cancel any in-progress rebind / pending conflict and clear the
|
|
/// dispatcher's capture state. Does NOT revert the draft — for that
|
|
/// see <see cref="Cancel"/>.
|
|
/// </summary>
|
|
public void CancelRebind()
|
|
{
|
|
if (_dispatcher.IsCapturing) _dispatcher.CancelCapture();
|
|
RebindInProgress = null;
|
|
RebindOriginal = null;
|
|
PendingConflict = null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Restore the draft's bindings for <paramref name="action"/> to the
|
|
/// retail defaults. Other actions' draft bindings are untouched.
|
|
/// </summary>
|
|
public void ResetActionToDefault(InputAction action)
|
|
{
|
|
var defaults = KeyBindings.RetailDefaults();
|
|
foreach (var b in _draft.ForAction(action).ToList())
|
|
_draft.Remove(b);
|
|
foreach (var b in defaults.ForAction(action))
|
|
_draft.Add(b);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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();
|
|
_displayDraft = DisplaySettings.Default;
|
|
_audioDraft = AudioSettings.Default;
|
|
_gameplayDraft = GameplaySettings.Default;
|
|
_chatDraft = ChatSettings.Default;
|
|
_characterDraft = CharacterSettings.Default;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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);
|
|
_onSaveDisplay(_displayDraft);
|
|
_onSaveAudio(_audioDraft);
|
|
_onSaveGameplay(_gameplayDraft);
|
|
_onSaveChat(_chatDraft);
|
|
_onSaveCharacter(_characterDraft);
|
|
_persisted = CloneBindings(_draft);
|
|
_displayPersisted = _displayDraft;
|
|
_audioPersisted = _audioDraft;
|
|
_gameplayPersisted = _gameplayDraft;
|
|
_chatPersisted = _chatDraft;
|
|
_characterPersisted = _characterDraft;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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);
|
|
_displayDraft = _displayPersisted;
|
|
_audioDraft = _audioPersisted;
|
|
_gameplayDraft = _gameplayPersisted;
|
|
_chatDraft = _chatPersisted;
|
|
_characterDraft = _characterPersisted;
|
|
CancelRebind();
|
|
}
|
|
|
|
// ── helpers ───────────────────────────────────────────────────────
|
|
|
|
private static KeyBindings CloneBindings(KeyBindings src)
|
|
{
|
|
var clone = new KeyBindings();
|
|
foreach (var b in src.All) clone.Add(b);
|
|
return clone;
|
|
}
|
|
|
|
private static bool KeyBindingsEqual(KeyBindings a, KeyBindings b)
|
|
{
|
|
if (a.All.Count != b.All.Count) return false;
|
|
for (int i = 0; i < a.All.Count; i++)
|
|
if (!a.All[i].Equals(b.All[i])) return false;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// K.3 conflict-prompt payload surfaced when the user binds a chord
|
|
/// already in use. The panel renders the <see cref="NewAction"/> +
|
|
/// <see cref="ConflictingAction"/> labels in a confirmation prompt;
|
|
/// <see cref="SettingsVM.ResolveConflict"/> dispatches the user's
|
|
/// answer.
|
|
/// </summary>
|
|
public readonly record struct ConflictPrompt(
|
|
InputAction NewAction,
|
|
KeyChord NewChord,
|
|
Binding OriginalBinding,
|
|
InputAction ConflictingAction,
|
|
Binding ConflictingBinding);
|