using System; using System.Collections.Generic; using System.Linq; using AcDream.UI.Abstractions.Input; namespace AcDream.UI.Abstractions.Panels.Settings; /// /// K.3 ViewModel for . Owns a draft /// copy of the current ; rebinds modify the /// draft. commits draft via the supplied callback /// (which writes to disk + replaces the live dispatcher's table); /// reverts the draft to the persisted state. /// /// /// Click-to-rebind UX: caller invokes 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 . /// If the new chord conflicts with another action's binding (same /// activation type), surfaces a prompt /// the panel renders as Yes / No buttons; /// dispatches the user's choice. /// /// public sealed class SettingsVM { private KeyBindings _persisted; private KeyBindings _draft; 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; // L.0 — Audio tab. Same shape as Display. private AudioSettings _audioPersisted; private AudioSettings _audioDraft; private readonly Action _onSaveAudio; // L.0 — Gameplay tab (subset of retail CharacterOption flags). private GameplaySettings _gameplayPersisted; private GameplaySettings _gameplayDraft; private readonly Action _onSaveGameplay; // L.0 — Chat tab (CharacterOptions2 channel filters + visual prefs). private ChatSettings _chatPersisted; private ChatSettings _chatDraft; private readonly Action _onSaveChat; // L.0 — Character tab (per-toon, host-keyed by toon name). private CharacterSettings _characterPersisted; private CharacterSettings _characterDraft; private readonly Action _onSaveCharacter; /// The action currently being rebound, or null when idle. public InputAction? RebindInProgress { get; private set; } /// The original binding being replaced (so we can preserve /// activation type on the new chord and roll back on cancel). public Binding? RebindOriginal { get; private set; } /// The action+chord conflict pending confirmation, or null. /// Populated when finds the captured /// chord already bound to another action; cleared by /// . public ConflictPrompt? PendingConflict { get; private set; } /// The current working draft. Panel renders bindings from /// here; mutates via the rebind / reset methods. public KeyBindings Draft => _draft; /// 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) || _displayPersisted != _displayDraft || _audioPersisted != _audioDraft || _gameplayPersisted != _gameplayDraft || _chatPersisted != _chatDraft || _characterPersisted != _characterDraft; /// The current Display draft. Panel reads from here; /// mutation goes through . public DisplaySettings DisplayDraft => _displayDraft; /// The current Audio draft. Panel reads from here; /// mutation goes through . public AudioSettings AudioDraft => _audioDraft; /// The current Gameplay draft. Panel reads from here; /// mutation goes through . public GameplaySettings GameplayDraft => _gameplayDraft; /// The current Chat draft. Panel reads from here; /// mutation goes through . public ChatSettings ChatDraft => _chatDraft; /// The current Character draft (per-toon — host owns the /// toon-name key). Panel reads from here; mutation goes through /// . public CharacterSettings CharacterDraft => _characterDraft; public SettingsVM( KeyBindings persisted, InputDispatcher dispatcher, Action onSave, DisplaySettings persistedDisplay, Action onSaveDisplay, AudioSettings persistedAudio, Action onSaveAudio, GameplaySettings persistedGameplay, Action onSaveGameplay, ChatSettings persistedChat, Action onSaveChat, CharacterSettings persistedCharacter, Action 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; } /// /// 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)); } /// /// Replace the entire Audio draft with . /// Live audio preview is achieved at the host layer by pushing /// 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. /// public void SetAudio(AudioSettings value) { _audioDraft = value ?? throw new ArgumentNullException(nameof(value)); } /// /// Replace the entire Gameplay draft with . /// Local-only this phase — values persist on Save but don't yet /// flow to the server. When server-sync ships, the host's /// onSaveGameplay callback will marshal the draft into the /// retail CharacterOption wire bitmask. /// public void SetGameplay(GameplaySettings value) { _gameplayDraft = value ?? throw new ArgumentNullException(nameof(value)); } /// /// Replace the entire Chat draft with . /// Local-only this phase — values persist on Save but the Hear*Chat /// flags affect client-side display filtering, not server-side /// channel subscriptions. /// public void SetChat(ChatSettings value) { _chatDraft = value ?? throw new ArgumentNullException(nameof(value)); } /// /// Replace the entire Character draft with . /// Per-toon — the host knows which toon's bag we're editing because /// it owned the toonKey when constructing the VM. /// public void SetCharacter(CharacterSettings value) { _characterDraft = value ?? throw new ArgumentNullException(nameof(value)); } /// /// 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 /// doesn't flag the swap as a /// pending edit. Differs from , which /// updates draft only. /// public void LoadCharacterContext(CharacterSettings persisted) { _characterPersisted = persisted ?? throw new ArgumentNullException(nameof(persisted)); _characterDraft = persisted; } /// /// Begin rebinding . The supplied /// 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 /// . /// 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); } /// /// Resolve a : = /// true removes the conflicting binding and applies the new chord; /// false cancels the rebind entirely (original binding intact). /// 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; } /// /// Cancel any in-progress rebind / pending conflict and clear the /// dispatcher's capture state. Does NOT revert the draft — for that /// see . /// public void CancelRebind() { if (_dispatcher.IsCapturing) _dispatcher.CancelCapture(); RebindInProgress = null; RebindOriginal = null; PendingConflict = null; } /// /// Restore the draft's bindings for to the /// retail defaults. Other actions' draft bindings are untouched. /// 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); } /// /// 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(); _displayDraft = DisplaySettings.Default; _audioDraft = AudioSettings.Default; _gameplayDraft = GameplaySettings.Default; _chatDraft = ChatSettings.Default; _characterDraft = CharacterSettings.Default; } /// /// 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); _onSaveDisplay(_displayDraft); _onSaveAudio(_audioDraft); _onSaveGameplay(_gameplayDraft); _onSaveChat(_chatDraft); _onSaveCharacter(_characterDraft); _persisted = CloneBindings(_draft); _displayPersisted = _displayDraft; _audioPersisted = _audioDraft; _gameplayPersisted = _gameplayDraft; _chatPersisted = _chatDraft; _characterPersisted = _characterDraft; } /// /// 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); _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; } } /// /// K.3 conflict-prompt payload surfaced when the user binds a chord /// already in use. The panel renders the + /// labels in a confirmation prompt; /// dispatches the user's /// answer. /// public readonly record struct ConflictPrompt( InputAction NewAction, KeyChord NewChord, Binding OriginalBinding, InputAction ConflictingAction, Binding ConflictingBinding);