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:
parent
af74eac0c2
commit
f42c164b90
14 changed files with 1567 additions and 5 deletions
196
src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs
Normal file
196
src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue