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; /// 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); public SettingsVM(KeyBindings persisted, InputDispatcher dispatcher, Action onSave) { _persisted = persisted ?? throw new ArgumentNullException(nameof(persisted)); _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); _onSave = onSave ?? throw new ArgumentNullException(nameof(onSave)); _draft = CloneBindings(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 entire draft with . /// public void ResetAllToDefaults() { _draft = KeyBindings.RetailDefaults(); } /// /// Commit the draft via the onSave callback supplied at /// construction. After save the draft becomes the new persisted /// snapshot — resets to false. /// public void Save() { _onSave(_draft); _persisted = CloneBindings(_draft); } /// /// Revert the draft to the persisted snapshot 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); 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);