Phase K final commit. Settings panel with click-to-rebind UX on top of the K.1+K.2 input architecture, plus the roadmap / ISSUES / memory updates that retire Phase K. InputDispatcher gains BeginCapture / CancelCapture / IsCapturing / SetBindings — modal capture suppresses normal action firing for the next chord. Esc cancels (returns sentinel default chord); modifier-only keys don't complete capture; non-modifier key down with current modifier mask completes. IPanelRenderer + ImGuiPanelRenderer + FakePanelRenderer gain BeginMainMenuBar / EndMainMenuBar / BeginMenu / EndMenu / MenuItem primitives. SettingsVM owns a draft copy of KeyBindings with explicit Save / Cancel / Reset semantics. Click-to-rebind enters dispatcher capture mode; on chord captured, conflict-detect against draft (excluding the action being rebound itself); surface a ConflictPrompt when the chord collides; ResolveConflict(replace=true|false) commits or reverts. ResetActionToDefault restores a single action to RetailDefaults(); ResetAllToDefaults rebuilds the entire draft. Save invokes the onSave callback (which writes JSON + swaps the live dispatcher's bindings). SettingsPanel renders 8 retail-keymap-categorized CollapsingHeader sections (Movement, Postures, Camera, Combat, UI panels, Chat, Hotbar, Emotes). Per action: name + current binding(s) summary + "Rebind"/"Reset" buttons. Conflict prompt at the top when pending. Save / Cancel / "Reset all to retail defaults" at the top. GameWindow registers SettingsPanel + wires F11 → ToggleOptionsPanel → IsVisible toggle, plus a top-of-frame ImGui MainMenuBar with View → Settings/Vitals/Chat/Debug entries (calls ImGui directly — the abstraction methods exist for backend portability but the host doesn't own a menu-bar surface). Tests: +37 across InputDispatcherCaptureTests (7), IPanelRendererMainMenuBarTests (9), SettingsVMTests (13), SettingsPanelTests (8). Solution total 1220 green. Roadmap (docs/plans/2026-04-11-roadmap.md) appends Phase K shipped section after Phase J with K.1a–K.3 commit SHAs. ISSUES.md files Phase L deferred work as #L.1–#L.8 (hotbar UI, spellbook favorites, combat-mode dispatch, F-key panels, floating chat windows, UI layout save/load, joystick bindings, plugin input subscription) and adds #21–#25 to Recently closed. project_input_pipeline.md updated to shipped state. CLAUDE.md gets an input-pipeline reference. Closes Phase K. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
222 lines
8.1 KiB
C#
222 lines
8.1 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;
|
|
|
|
/// <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);
|
|
|
|
public SettingsVM(KeyBindings persisted, InputDispatcher dispatcher, Action<KeyBindings> 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);
|
|
}
|
|
|
|
/// <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 entire draft with <see cref="KeyBindings.RetailDefaults"/>.
|
|
/// </summary>
|
|
public void ResetAllToDefaults()
|
|
{
|
|
_draft = KeyBindings.RetailDefaults();
|
|
}
|
|
|
|
/// <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.
|
|
/// </summary>
|
|
public void Save()
|
|
{
|
|
_onSave(_draft);
|
|
_persisted = CloneBindings(_draft);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <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);
|