feat(ui): #25 Phase K.3 — Settings panel + click-to-rebind + Phase K shipped

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>
This commit is contained in:
Erik 2026-04-26 09:44:56 +02:00
parent af74eac0c2
commit f42c164b90
14 changed files with 1567 additions and 5 deletions

View file

@ -789,8 +789,8 @@ public sealed class GameWindow : IDisposable
// bars surface only after the first PlayerDescription has
// populated LocalPlayer (Issue #5).
_vitalsVm = new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer);
_panelHost.Register(
new AcDream.UI.Abstractions.Panels.Vitals.VitalsPanel(_vitalsVm));
_vitalsPanel = new AcDream.UI.Abstractions.Panels.Vitals.VitalsPanel(_vitalsVm);
_panelHost.Register(_vitalsPanel);
// ChatPanel: reads the tail of the shared ChatLog. No GUID
// dependency — works pre-login (empty) and post-login (live
@ -863,7 +863,38 @@ public sealed class GameWindow : IDisposable
_debugPanel = new AcDream.UI.Abstractions.Panels.Debug.DebugPanel(_debugVm);
_panelHost.Register(_debugPanel);
Console.WriteLine("devtools: ImGui panel host ready (VitalsPanel + ChatPanel + DebugPanel registered)");
// Phase K.3 — Settings panel. SettingsVM owns a draft
// copy of the active KeyBindings. Save replaces the
// dispatcher's live table + writes JSON; Cancel reverts
// the draft. Construction is null-safe vs. the
// dispatcher because the dispatcher is built earlier in
// the same OnLoad path (see _inputDispatcher field).
if (_inputDispatcher is not null)
{
_settingsVm = new AcDream.UI.Abstractions.Panels.Settings.SettingsVM(
persisted: _keyBindings,
dispatcher: _inputDispatcher,
onSave: bindings =>
{
_inputDispatcher.SetBindings(bindings);
try
{
bindings.SaveToFile(
AcDream.UI.Abstractions.Input.KeyBindings.DefaultPath());
Console.WriteLine(
"keybinds: saved to "
+ AcDream.UI.Abstractions.Input.KeyBindings.DefaultPath());
}
catch (Exception ex)
{
Console.WriteLine($"keybinds: save failed: {ex.Message}");
}
});
_settingsPanel = new AcDream.UI.Abstractions.Panels.Settings.SettingsPanel(_settingsVm);
_panelHost.Register(_settingsPanel);
}
Console.WriteLine("devtools: ImGui panel host ready (VitalsPanel + ChatPanel + DebugPanel + SettingsPanel registered)");
}
catch (Exception ex)
{
@ -872,9 +903,12 @@ public sealed class GameWindow : IDisposable
_imguiBootstrap = null;
_panelHost = null;
_vitalsVm = null;
_vitalsPanel = null;
_debugVm = null;
_debugPanel = null;
_chatPanel = null;
_settingsVm = null;
_settingsPanel = null;
}
}
@ -4198,6 +4232,35 @@ public sealed class GameWindow : IDisposable
var ctx = new AcDream.UI.Abstractions.PanelContext(
(float)deltaSeconds,
bus);
// Phase K.3 — top-of-screen menu bar. Provides discoverable
// entries for users who don't memorize the F-keys (View →
// Settings / Vitals / Chat / Debug). Uses ImGuiNET directly
// here because the panel host doesn't own a menu-bar
// surface; the abstraction (BeginMainMenuBar / BeginMenu /
// MenuItem) exists for backend portability + tests but only
// gets exercised here once per frame.
if (ImGuiNET.ImGui.BeginMainMenuBar())
{
if (ImGuiNET.ImGui.BeginMenu("View"))
{
if (_settingsPanel is not null
&& ImGuiNET.ImGui.MenuItem("Settings", "F11"))
_settingsPanel.IsVisible = !_settingsPanel.IsVisible;
if (_vitalsPanel is not null
&& ImGuiNET.ImGui.MenuItem("Vitals"))
_vitalsPanel.IsVisible = !_vitalsPanel.IsVisible;
if (_chatPanel is not null
&& ImGuiNET.ImGui.MenuItem("Chat"))
_chatPanel.IsVisible = !_chatPanel.IsVisible;
if (_debugPanel is not null
&& ImGuiNET.ImGui.MenuItem("Debug", "F1"))
_debugPanel.IsVisible = !_debugPanel.IsVisible;
ImGuiNET.ImGui.EndMenu();
}
ImGuiNET.ImGui.EndMainMenuBar();
}
_panelHost.RenderAll(ctx);
_imguiBootstrap.Render();
}
@ -5048,6 +5111,13 @@ public sealed class GameWindow : IDisposable
// DevToolsEnabled construction block; null otherwise.
private AcDream.UI.Abstractions.Panels.Chat.ChatPanel? _chatPanel;
// Phase K.3 — Settings panel (click-to-rebind keymap UI). Hidden by
// default; F11 / View → Settings toggles. Null when devtools are off.
private AcDream.UI.Abstractions.Panels.Settings.SettingsPanel? _settingsPanel;
private AcDream.UI.Abstractions.Panels.Settings.SettingsVM? _settingsVm;
// Vitals panel reference cached for the View menu's toggle entry.
private AcDream.UI.Abstractions.Panels.Vitals.VitalsPanel? _vitalsPanel;
// ── K.1b: dispatcher action handler ──────────────────────────────────
//
// SINGLE place where every game-side keyboard/mouse-button reaction
@ -5175,6 +5245,14 @@ public sealed class GameWindow : IDisposable
_chatPanel?.FocusInput();
break;
case AcDream.UI.Abstractions.Input.InputAction.ToggleOptionsPanel:
// K.3: F11 toggles the Settings panel. Null-safe vs.
// devtools-off / panel-not-registered — the [input] log
// line above still records the press regardless.
if (_settingsPanel is not null)
_settingsPanel.IsVisible = !_settingsPanel.IsVisible;
break;
case AcDream.UI.Abstractions.Input.InputAction.EscapeKey:
if (_cameraController?.IsFlyMode == true)
_cameraController.ToggleFly(); // exit fly, release cursor

View file

@ -206,4 +206,33 @@ public interface IPanelRenderer
/// typing without clicking the field.
/// </summary>
void SetKeyboardFocusHere();
// -- Phase K.3 — top-of-screen main menu bar -------------------------
/// <summary>
/// Open the top-of-screen main menu bar. Returns true if the bar is
/// visible (top-level menus go inside that branch). Always pair with
/// <see cref="EndMainMenuBar"/> when the call returned true.
/// </summary>
bool BeginMainMenuBar();
/// <summary>Close the menu bar opened by <see cref="BeginMainMenuBar"/>.</summary>
void EndMainMenuBar();
/// <summary>
/// Open a top-level menu within a menu bar. Returns true if the menu
/// is open — the caller emits <see cref="MenuItem"/> entries inside
/// that branch, then calls <see cref="EndMenu"/>.
/// </summary>
bool BeginMenu(string label);
/// <summary>Close the menu opened by <see cref="BeginMenu"/>.</summary>
void EndMenu();
/// <summary>
/// A clickable menu item with optional shortcut hint (e.g.
/// <c>"F11"</c>) drawn right-aligned. Returns true on the single
/// frame the user clicks the item; false otherwise.
/// </summary>
bool MenuItem(string label, string? shortcut = null);
}

View file

@ -34,10 +34,15 @@ public sealed class InputDispatcher
{
private readonly IKeyboardSource _keyboard;
private readonly IMouseSource _mouse;
private readonly KeyBindings _bindings;
private KeyBindings _bindings;
private readonly Stack<InputScope> _scopes = new();
private readonly HashSet<KeyChord> _heldHoldChords = new();
/// <summary>K.3 modal-rebind hook: when non-null, the next non-modifier
/// chord is reported via this callback INSTEAD of firing actions. Esc
/// cancels (callback receives <c>default(KeyChord)</c>).</summary>
private Action<KeyChord>? _captureCallback;
/// <summary>Fires every time a binding matches a press / release / hold tick.
/// Multicast — every subscriber gets every event in subscription order.</summary>
public event Action<InputAction, ActivationType>? Fired;
@ -61,6 +66,51 @@ public sealed class InputDispatcher
/// <summary>Topmost scope on the stack — what the dispatcher looks up first.</summary>
public InputScope ActiveScope => _scopes.Peek();
/// <summary>True iff a <see cref="BeginCapture"/> is in progress.</summary>
public bool IsCapturing => _captureCallback is not null;
/// <summary>
/// Enter modal capture mode. The next non-modifier chord pressed
/// (with whatever modifiers are held at that moment) is reported
/// via <paramref name="onCaptured"/> and the dispatcher does NOT
/// fire normal action events for that chord. Esc cancels —
/// <paramref name="onCaptured"/> receives a sentinel
/// <c>default(KeyChord)</c>. Modifier-only key transitions
/// (Shift / Ctrl / Alt / Win held alone) are NOT captured; only a
/// non-modifier key down completes capture, so the user can dial
/// in modifier combinations before pressing the trigger key.
/// </summary>
public void BeginCapture(Action<KeyChord> onCaptured)
{
_captureCallback = onCaptured ?? throw new ArgumentNullException(nameof(onCaptured));
}
/// <summary>
/// Cancel an active capture. Invokes the callback with a sentinel
/// <c>default(KeyChord)</c> so the caller can treat it as a user
/// cancel. No-op if no capture is active.
/// </summary>
public void CancelCapture()
{
var cb = _captureCallback;
if (cb is null) return;
_captureCallback = null;
cb(default);
}
/// <summary>
/// Replace the active bindings table. Used by the Settings panel's
/// Save flow — the in-memory <see cref="InputDispatcher"/> picks up
/// the new bindings without a process restart. Held-Hold-chord
/// tracking is reset; any previously held chord that no longer maps
/// will simply stop firing on the next <see cref="Tick"/>.
/// </summary>
public void SetBindings(KeyBindings bindings)
{
_bindings = bindings ?? throw new ArgumentNullException(nameof(bindings));
_heldHoldChords.Clear();
}
/// <summary>
/// Per-frame "is this action's chord currently held" query. Walks every
/// binding for the given action; returns true if any of them has its
@ -165,6 +215,30 @@ public sealed class InputDispatcher
private void OnKeyDown(Key key, ModifierMask mods)
{
// K.3 modal capture (used by Settings panel's "Rebind" UX) takes
// precedence over both WantCaptureKeyboard gating AND normal
// binding lookup. Esc cancels capture; modifier-only keys don't
// complete it (so the user can dial in Shift/Ctrl/Alt before
// pressing the trigger key); every other key completes capture
// with the current modifier state.
if (_captureCallback is not null)
{
if (key == Key.Escape)
{
var cb = _captureCallback;
_captureCallback = null;
cb(default);
return;
}
if (IsModifierKey(key)) return; // dial more mods, don't complete
var captured = new KeyChord(key, mods, Device: 0);
var cb2 = _captureCallback;
_captureCallback = null;
cb2(captured);
return; // SUPPRESS the action — don't run binding lookup below
}
if (_mouse.WantCaptureKeyboard) return;
var chord = new KeyChord(key, mods, Device: 0);
@ -181,6 +255,18 @@ public sealed class InputDispatcher
}
}
/// <summary>True for Shift/Ctrl/Alt/Win left+right variants — keys
/// that don't complete a capture by themselves. The user holds them
/// to dial in modifier combinations before pressing the trigger key.</summary>
private static bool IsModifierKey(Key key) => key switch
{
Key.ShiftLeft or Key.ShiftRight => true,
Key.ControlLeft or Key.ControlRight => true,
Key.AltLeft or Key.AltRight => true,
Key.SuperLeft or Key.SuperRight => true,
_ => false,
};
private void OnKeyUp(Key key, ModifierMask mods)
{
// Release fires regardless of WantCaptureKeyboard so we don't

View file

@ -0,0 +1,196 @@
using System.Collections.Generic;
using System.Linq;
using AcDream.UI.Abstractions.Input;
namespace AcDream.UI.Abstractions.Panels.Settings;
/// <summary>
/// K.3: in-game Settings panel for click-to-rebind keymap editing.
/// Hidden by default; opens via <c>F11</c> (which fires the
/// <see cref="InputAction.ToggleOptionsPanel"/> action) or via the
/// View → Settings entry on the main menu bar.
///
/// <para>
/// Layout: top row of action buttons (Save / Cancel / Reset all), then
/// a sequence of <see cref="IPanelRenderer.CollapsingHeader"/> sections
/// matching the retail keymap categories (Movement / Postures / Camera /
/// Combat / UI panels / Chat / Hotbar / Emotes). Each row inside a
/// section: action name, current binding(s) summary, "Rebind" button,
/// per-action "Reset" button. When a rebind is in progress the Rebind
/// button label changes to "Press a key... (Esc to cancel)".
/// </para>
///
/// <para>
/// When <see cref="SettingsVM.PendingConflict"/> is non-null, a
/// confirmation prompt is rendered ABOVE the rest of the panel (Yes —
/// Reassign / No — Keep existing).
/// </para>
/// </summary>
public sealed class SettingsPanel : IPanel
{
private readonly SettingsVM _vm;
public SettingsPanel(SettingsVM vm)
{
_vm = vm ?? throw new System.ArgumentNullException(nameof(vm));
}
/// <inheritdoc />
public string Id => "acdream.settings";
/// <inheritdoc />
public string Title => "Settings";
/// <inheritdoc />
/// <remarks>K.3: hidden by default — opened via F11 / View menu.</remarks>
public bool IsVisible { get; set; } = false;
/// <inheritdoc />
public void Render(PanelContext ctx, IPanelRenderer renderer)
{
if (!renderer.Begin(Title))
{
renderer.End();
return;
}
// Conflict prompt — modal-ish row at top of the panel.
if (_vm.PendingConflict is { } conflict)
{
renderer.TextWrapped(
$"'{ChordLabel(conflict.NewChord)}' is already bound to "
+ $"{conflict.ConflictingAction}. Reassign it to "
+ $"{conflict.NewAction}?");
if (renderer.Button("Yes — Reassign")) _vm.ResolveConflict(replace: true);
renderer.SameLine();
if (renderer.Button("No — Keep existing")) _vm.ResolveConflict(replace: false);
renderer.Separator();
}
// Top action buttons.
if (renderer.Button("Save changes")) _vm.Save();
renderer.SameLine();
if (renderer.Button("Cancel changes")) _vm.Cancel();
renderer.SameLine();
if (renderer.Button("Reset all to retail defaults")) _vm.ResetAllToDefaults();
renderer.Separator();
// Sections (retail keymap categories).
RenderSection(renderer, "Movement", new[]
{
InputAction.MovementForward, InputAction.MovementBackup,
InputAction.MovementTurnLeft, InputAction.MovementTurnRight,
InputAction.MovementStrafeLeft, InputAction.MovementStrafeRight,
InputAction.MovementJump, InputAction.MovementStop,
InputAction.MovementWalkMode, InputAction.MovementRunLock,
});
RenderSection(renderer, "Postures", new[]
{
InputAction.Ready, InputAction.Sitting,
InputAction.Crouch, InputAction.Sleeping,
});
RenderSection(renderer, "Camera", new[]
{
InputAction.CameraActivateAlternateMode, InputAction.CameraInstantMouseLook,
InputAction.CameraRotateLeft, InputAction.CameraRotateRight,
InputAction.CameraRotateUp, InputAction.CameraRotateDown,
InputAction.CameraMoveToward, InputAction.CameraMoveAway,
InputAction.CameraViewDefault, InputAction.CameraViewFirstPerson,
InputAction.CameraViewLookDown, InputAction.CameraViewMapMode,
});
RenderSection(renderer, "Combat", new[]
{
InputAction.CombatToggleCombat,
InputAction.CombatDecreaseAttackPower, InputAction.CombatIncreaseAttackPower,
InputAction.CombatLowAttack, InputAction.CombatMediumAttack, InputAction.CombatHighAttack,
InputAction.CombatAimLow, InputAction.CombatAimMedium, InputAction.CombatAimHigh,
InputAction.CombatPrevSpellTab, InputAction.CombatNextSpellTab,
InputAction.CombatPrevSpell, InputAction.CombatNextSpell, InputAction.CombatCastCurrentSpell,
});
RenderSection(renderer, "UI panels", new[]
{
InputAction.ToggleHelp, InputAction.ToggleAllegiancePanel,
InputAction.ToggleFellowshipPanel, InputAction.ToggleSpellbookPanel,
InputAction.ToggleSpellComponentsPanel, InputAction.ToggleAttributesPanel,
InputAction.ToggleSkillsPanel, InputAction.ToggleWorldPanel,
InputAction.ToggleOptionsPanel, InputAction.ToggleInventoryPanel,
InputAction.SelectionExamine, InputAction.UseSelected,
InputAction.EscapeKey, InputAction.LOGOUT,
});
RenderSection(renderer, "Chat", new[]
{
InputAction.ToggleChatEntry, InputAction.EnterChatMode,
InputAction.ToggleFloatingChatWindow1, InputAction.ToggleFloatingChatWindow2,
InputAction.ToggleFloatingChatWindow3, InputAction.ToggleFloatingChatWindow4,
});
RenderSection(renderer, "Hotbar", new[]
{
InputAction.UseQuickSlot_1, InputAction.UseQuickSlot_2, InputAction.UseQuickSlot_3,
InputAction.UseQuickSlot_4, InputAction.UseQuickSlot_5, InputAction.UseQuickSlot_6,
InputAction.UseQuickSlot_7, InputAction.UseQuickSlot_8, InputAction.UseQuickSlot_9,
InputAction.CreateShortcut,
});
RenderSection(renderer, "Emotes", new[]
{
InputAction.Cry, InputAction.Laugh, InputAction.Wave,
InputAction.Cheer, InputAction.PointState,
});
renderer.End();
}
private void RenderSection(IPanelRenderer renderer, string label, InputAction[] actions)
{
// Movement defaults open; other sections collapsed for first-run UX.
bool defaultOpen = label == "Movement";
if (!renderer.CollapsingHeader(label, defaultOpen))
return;
foreach (var action in actions)
{
renderer.Text(action.ToString());
renderer.SameLine();
// Current binding(s) summary.
var binds = _vm.Draft.ForAction(action).ToList();
string summary = binds.Count == 0
? "(unbound)"
: string.Join(", ", binds.Select(b => ChordLabel(b.Chord)));
renderer.Text(summary);
renderer.SameLine();
// Rebind button — when a rebind is in progress for THIS
// action, the label changes to a "press a key..." prompt.
// The "##{action}" suffix gives ImGui a stable per-row id
// so multiple "Rebind" buttons don't collide.
string buttonLabel = (_vm.RebindInProgress == action)
? $"Press a key... (Esc to cancel)##{action}"
: $"Rebind##{action}";
if (renderer.Button(buttonLabel))
{
if (binds.Count > 0 && _vm.RebindInProgress is null)
_vm.BeginRebind(action, binds[0]);
}
renderer.SameLine();
if (renderer.Button($"Reset##{action}"))
_vm.ResetActionToDefault(action);
}
}
/// <summary>
/// Render a chord as <c>"Shift+Ctrl+A"</c> / <c>"W"</c> / etc. for the
/// row summary + conflict prompt. Joins held modifiers with <c>+</c>
/// then the trigger key name.
/// </summary>
private static string ChordLabel(KeyChord chord)
{
var parts = new List<string>();
if ((chord.Modifiers & ModifierMask.Shift) != 0) parts.Add("Shift");
if ((chord.Modifiers & ModifierMask.Ctrl) != 0) parts.Add("Ctrl");
if ((chord.Modifiers & ModifierMask.Alt) != 0) parts.Add("Alt");
if ((chord.Modifiers & ModifierMask.Win) != 0) parts.Add("Win");
parts.Add(chord.Key.ToString());
return string.Join("+", parts);
}
}

View file

@ -0,0 +1,222 @@
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);

View file

@ -173,4 +173,24 @@ public sealed class ImGuiPanelRenderer : IPanelRenderer
/// <inheritdoc />
public void SetKeyboardFocusHere() => ImGuiNET.ImGui.SetKeyboardFocusHere();
// -- Phase K.3 — main menu bar -----------------------------------------
/// <inheritdoc />
public bool BeginMainMenuBar() => ImGuiNET.ImGui.BeginMainMenuBar();
/// <inheritdoc />
public void EndMainMenuBar() => ImGuiNET.ImGui.EndMainMenuBar();
/// <inheritdoc />
public bool BeginMenu(string label) => ImGuiNET.ImGui.BeginMenu(label);
/// <inheritdoc />
public void EndMenu() => ImGuiNET.ImGui.EndMenu();
/// <inheritdoc />
public bool MenuItem(string label, string? shortcut = null)
=> shortcut is null
? ImGuiNET.ImGui.MenuItem(label)
: ImGuiNET.ImGui.MenuItem(label, shortcut);
}