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);