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
15
CLAUDE.md
15
CLAUDE.md
|
|
@ -47,7 +47,20 @@ time using dat assets); ImGui persists forever as the
|
|||
`AcDream.UI.Abstractions` — never import a backend namespace from a
|
||||
panel.** Full design: `docs/plans/2026-04-24-ui-framework.md`.
|
||||
Memory cribs: `memory/project_ui_architecture.md` (architecture),
|
||||
`memory/project_chat_pipeline.md` (chat pipeline as of Phase I).
|
||||
`memory/project_chat_pipeline.md` (chat pipeline as of Phase I),
|
||||
`memory/project_input_pipeline.md` (input pipeline as of Phase K).
|
||||
|
||||
**Input pipeline:** `src/AcDream.UI.Abstractions/Input/` (action enum,
|
||||
`KeyChord`, `KeyBindings`, multicast `InputDispatcher` with scope
|
||||
stack + modal capture for rebind UX) + `src/AcDream.App/Input/`
|
||||
(Silk.NET adapters). Retail-default keymap loaded from
|
||||
`%LOCALAPPDATA%\acdream\keybinds.json` at startup (falls back to
|
||||
`KeyBindings.RetailDefaults()` matching
|
||||
`docs/research/named-retail/retail-default.keymap.txt`). The Settings
|
||||
panel (F11 / View → Settings) lets users remap any action via
|
||||
click-to-rebind. As of Phase K (2026-04-26), ALL keyboard / mouse
|
||||
input flows through the dispatcher — no IsKeyPressed polling outside
|
||||
the per-frame movement queries.
|
||||
|
||||
## How to operate
|
||||
|
||||
|
|
|
|||
186
docs/ISSUES.md
186
docs/ISSUES.md
|
|
@ -46,6 +46,137 @@ Copy this block when adding a new issue:
|
|||
|
||||
# Active issues
|
||||
|
||||
## #L.1 — Hotbar UI panel
|
||||
|
||||
**Status:** OPEN
|
||||
**Severity:** MEDIUM
|
||||
**Filed:** 2026-04-26 (deferred from Phase K)
|
||||
**Component:** ui / hotbar
|
||||
|
||||
**Description:** Number keys 1-9 are bound to `UseQuickSlot_1..9`
|
||||
actions but no panel exists. Actions fire (visible via the `[input]`
|
||||
console log) but produce no visible result. Phase L feature: drag-drop
|
||||
hotbar with up to 5 bars × 9 slots, drag spell/skill icons to slots,
|
||||
key activates the slot's contents. Server-side: `CreateShortcutToSelected`
|
||||
(action 0x0A9 in retail motion table) sends a `UseSelected` on slot
|
||||
fire.
|
||||
|
||||
**Files:** `src/AcDream.UI.Abstractions/Panels/Hotbar/` (TBC).
|
||||
|
||||
**Acceptance:** Drag an item or spell into slot 1, press `1`, server
|
||||
responds as if the user clicked the item.
|
||||
|
||||
---
|
||||
|
||||
## #L.2 — Spellbook favorites panel
|
||||
|
||||
**Status:** OPEN
|
||||
**Severity:** MEDIUM
|
||||
**Filed:** 2026-04-26 (deferred from Phase K)
|
||||
**Component:** ui / magic
|
||||
|
||||
**Description:** In `MagicCombat` scope, 1-9 should fire
|
||||
`UseSpellSlot_1..9` (distinct from hotbar). Requires a small UI to
|
||||
pin favorite spells + a spellbook tab nav. Cross-references issue
|
||||
#L.3 (combat-mode dispatch).
|
||||
|
||||
---
|
||||
|
||||
## #L.3 — Combat-mode tracking + scope-aware Insert/PgUp/Delete/End/PgDn dispatch
|
||||
|
||||
**Status:** OPEN
|
||||
**Severity:** MEDIUM
|
||||
**Filed:** 2026-04-26 (deferred from Phase K)
|
||||
**Component:** input / combat
|
||||
|
||||
**Description:** Insert/PgUp/Delete/End/PgDn mean different things in
|
||||
melee / missile / magic combat modes (per retail keymap MeleeCombat /
|
||||
MissileCombat / MagicCombat blocks). Phase K has the bindings and the
|
||||
scope stack; what's missing: `CombatState.CurrentMode` field +
|
||||
listener for the server-side `SetCombatMode` packet (likely 0x0053 or
|
||||
similar — confirm against ACE source). When mode arrives, push the
|
||||
appropriate scope; when leaving combat, pop.
|
||||
|
||||
---
|
||||
|
||||
## #L.4 — F-key panels: Allegiance / Fellowship / Skills / Attributes / World / SpellComponents
|
||||
|
||||
**Status:** OPEN
|
||||
**Severity:** LOW
|
||||
**Filed:** 2026-04-26 (deferred from Phase K)
|
||||
**Component:** ui
|
||||
|
||||
**Description:** Retail F3-F6, F8-F12 toggle UI panels for various
|
||||
character data. Phase K has the bindings (`ToggleAllegiancePanel`,
|
||||
`ToggleFellowshipPanel`, `ToggleSpellbookPanel`,
|
||||
`ToggleSpellComponentsPanel`, `ToggleAttributesPanel`,
|
||||
`ToggleSkillsPanel`, `ToggleWorldPanel`, `ToggleInventoryPanel`); the
|
||||
panels themselves don't exist. Each is its own design feature.
|
||||
Inventory (F12) is the most-requested.
|
||||
|
||||
---
|
||||
|
||||
## #L.5 — Floating chat windows (Alt+1-4)
|
||||
|
||||
**Status:** OPEN
|
||||
**Severity:** LOW
|
||||
**Filed:** 2026-04-26 (deferred from Phase K)
|
||||
**Component:** ui / chat
|
||||
|
||||
**Description:** Alt+1..4 toggle four floating chat windows in retail.
|
||||
Phase K binds the actions; `ChatPanel` currently is a single window.
|
||||
Floating windows would need filtered-by-channel-type chat tail
|
||||
rendering.
|
||||
|
||||
---
|
||||
|
||||
## #L.6 — UI layout save/load (saveui / loadui / lockui)
|
||||
|
||||
**Status:** OPEN
|
||||
**Severity:** LOW
|
||||
**Filed:** 2026-04-26 (deferred from Phase K)
|
||||
**Component:** ui
|
||||
|
||||
**Description:** Retail had `@saveui <name>`, `@loadui <name>`,
|
||||
`@lockui` commands for persisting ImGui-style window layouts. ImGui
|
||||
has built-in `LoadIniSettingsFromMemory` /
|
||||
`SaveIniSettingsToMemory` — wire these to per-named-layout files,
|
||||
plus chat-command parsing for the `@` prefixes.
|
||||
|
||||
---
|
||||
|
||||
## #L.7 — Joystick / gamepad bindings
|
||||
|
||||
**Status:** OPEN
|
||||
**Severity:** LOW
|
||||
**Filed:** 2026-04-26 (deferred from Phase K)
|
||||
**Component:** input
|
||||
|
||||
**Description:** Retail keymap declares 11 Joystick devices in the
|
||||
`Devices` block but no actions are bound by default. acdream uses
|
||||
Silk.NET keyboard+mouse only. Adding Silk.NET joystick support + a
|
||||
`JoystickInputSource` adapter would unlock controller play.
|
||||
`KeyChord.Device` byte already supports values >1, so the binding
|
||||
side is ready.
|
||||
|
||||
---
|
||||
|
||||
## #L.8 — Plugin / scripting / macro input subscription
|
||||
|
||||
**Status:** OPEN
|
||||
**Severity:** MEDIUM
|
||||
**Filed:** 2026-04-26 (deferred from Phase K)
|
||||
**Component:** plugin / input
|
||||
|
||||
**Description:** CLAUDE.md goal: "Build acdream's plugin API to
|
||||
support scripting/macros for player automation." Plugins should be
|
||||
able to register custom actions (with namespaced IDs like
|
||||
`mymacro.heal-rotation`) and subscribe to `InputAction` events. Phase K
|
||||
foundation supports this via the multicast `InputDispatcher`; what's
|
||||
missing is the plugin-API surface.
|
||||
|
||||
---
|
||||
|
||||
## #1 — Rain falls only to horizon, not to the player's feet
|
||||
|
||||
**Status:** OPEN
|
||||
|
|
@ -165,6 +296,61 @@ Copy this block when adding a new issue:
|
|||
|
||||
# Recently closed
|
||||
|
||||
## #25 — [DONE 2026-04-26] Phase K.3 — Settings panel + click-to-rebind UI
|
||||
|
||||
**Closed:** 2026-04-26
|
||||
**Commit:** `(this commit)`
|
||||
**Resolution:** `SettingsPanel` with click-to-rebind UX (modal capture
|
||||
via `InputDispatcher.BeginCapture`, Esc cancels, conflict prompt with
|
||||
Yes/No, draft / Save / Cancel semantics), F11 toggle + ImGui
|
||||
MainMenuBar entry, per-action / per-section / reset-all-defaults
|
||||
buttons. Roadmap + ISSUES + memory crib + CLAUDE.md updated.
|
||||
|
||||
---
|
||||
|
||||
## #24 — [DONE 2026-04-26] Phase K.2 — auto-enter player mode + MMB mouse-look
|
||||
|
||||
**Closed:** 2026-04-26
|
||||
**Commit:** `af74eac`
|
||||
**Resolution:** Auto-enter player mode at login (one-shot guard
|
||||
reusing the existing Tab handler logic); MMB-hold mouse-look
|
||||
(`CameraInstantMouseLook` — cursor-locked camera + character yaw
|
||||
drive together); `Tab → ChatPanel.FocusInput()`; `DebugPanel`
|
||||
"Toggle Free-Fly Mode" button.
|
||||
|
||||
---
|
||||
|
||||
## #23 — [DONE 2026-04-26] Phase K.1c — retail-default keymap + JSON persistence
|
||||
|
||||
**Closed:** 2026-04-26
|
||||
**Commit:** `da18910`
|
||||
**Resolution:** ~149 retail-faithful bindings byte-precise to
|
||||
`docs/research/named-retail/retail-default.keymap.txt`;
|
||||
`%LOCALAPPDATA%\acdream\keybinds.json` with merge-over-defaults
|
||||
migration; acdream debug F-keys relocated to `Ctrl+F*`.
|
||||
|
||||
---
|
||||
|
||||
## #22 — [DONE 2026-04-26] Phase K.1b — cut handlers over to dispatcher
|
||||
|
||||
**Closed:** 2026-04-26
|
||||
**Commit:** `256e962`
|
||||
**Resolution:** Drop the legacy mouse-X-character-yaw path; fix
|
||||
`WantCaptureMouse` gating; single input path via the multicast
|
||||
`InputDispatcher`.
|
||||
|
||||
---
|
||||
|
||||
## #21 — [DONE 2026-04-26] Phase K.1a — input architecture skeleton
|
||||
|
||||
**Closed:** 2026-04-26
|
||||
**Commit:** `84512d3`
|
||||
**Resolution:** Action enum, multicast `InputDispatcher` with scope
|
||||
stack, `KeyChord` / `Binding` / `KeyBindings`, Silk.NET adapters;
|
||||
parallel to existing handlers (no behavior change).
|
||||
|
||||
---
|
||||
|
||||
## #20 — [DONE 2026-04-25] CombatChatTranslator — retail-faithful combat-text formatters
|
||||
|
||||
**Closed:** 2026-04-25
|
||||
|
|
|
|||
|
|
@ -246,6 +246,63 @@ the way retail + holtburger expect.
|
|||
|
||||
---
|
||||
|
||||
### Phase K — Input architecture + retail bindings + Settings panel
|
||||
|
||||
**Goal:** retire the hardcoded WASD-style scheme; ship retail-faithful
|
||||
bindings as the default; let users remap any action via an in-game
|
||||
Settings panel; auto-enter player mode at login; replace mouse-X-yaw
|
||||
with retail's MMB-hold mouse-look.
|
||||
|
||||
**Sub-pieces (all ✓ SHIPPED 2026-04-26):**
|
||||
|
||||
- **✓ SHIPPED — K.1a — Input architecture skeleton.** Action enum,
|
||||
multicast `InputDispatcher` with scope stack, `KeyChord` /
|
||||
`Binding` / `KeyBindings`, Silk.NET adapters, parallel to existing
|
||||
handlers — no behavior change. Commit `84512d3`.
|
||||
- **✓ SHIPPED — K.1b — Cut handlers over to dispatcher.** Drop the
|
||||
legacy mouse-X-character-yaw path, fix `WantCaptureMouse` gating,
|
||||
single input path. Commit `256e962`.
|
||||
- **✓ SHIPPED — K.1c — Retail-default keymap + JSON persistence.**
|
||||
~149 bindings matching
|
||||
`docs/research/named-retail/retail-default.keymap.txt` + JSON
|
||||
load/save with merge-over-defaults migration at
|
||||
`%LOCALAPPDATA%\acdream\keybinds.json`. Acdream debug F-keys
|
||||
relocated to `Ctrl+F*` to avoid retail conflicts. Commit `da18910`.
|
||||
- **✓ SHIPPED — K.2 — Login flow + MMB mouse-look + free-fly button.**
|
||||
Auto-enter player mode at login (one-shot guard reusing the existing
|
||||
Tab handler), MMB-hold mouse-look (retail's `CameraInstantMouseLook`
|
||||
— cursor-locked camera + character yaw drive together), `DebugPanel`
|
||||
"Toggle Free-Fly Mode" button, `Tab → ToggleChatEntry →
|
||||
ChatPanel.FocusInput()`. Commit `af74eac`.
|
||||
- **✓ SHIPPED — K.3 — Settings panel + Keybindings UI + roadmap/issues
|
||||
update.** `SettingsPanel` with click-to-rebind UX (modal capture
|
||||
via `InputDispatcher.BeginCapture`, Esc cancels, conflict-detection-
|
||||
with-confirmation prompt, draft / Save / Cancel semantics), F11
|
||||
toggle + ImGui MainMenuBar entry, per-action / per-section /
|
||||
reset-all-defaults buttons. Roadmap + ISSUES + memory crib +
|
||||
CLAUDE.md updated in this commit. Commit `(this commit)`.
|
||||
|
||||
**Acceptance (verified 2026-04-26):**
|
||||
- Login → spawn at the character's actual world position in chase
|
||||
camera (NOT Holtburg orbit). Tab not required.
|
||||
- W = run forward; X = run back; A/D = turn; Z/C = strafe;
|
||||
Alt+A/D/Left/Right = strafe (alternate); Q = autorun; Space = jump;
|
||||
Shift = walk modifier; S = stop; Y/G/H/B = postures.
|
||||
- Mouse alone does NOT drive character yaw. MMB-hold = cursor-locked
|
||||
camera + character yaw drive together. RMB and LMB are
|
||||
`SelectRight` / `SelectLeft` per retail.
|
||||
- F11 / View → Settings opens panel. Click "Rebind" next to any
|
||||
action → press a key → if conflict, prompt. Save writes
|
||||
`%LOCALAPPDATA%\acdream\keybinds.json`. Restart preserves
|
||||
customizations.
|
||||
- Mode-dependent combat keys (Insert/PgUp/Delete/End/PgDn) bound but
|
||||
dormant — light up in Phase L when `CombatState.CurrentMode` is
|
||||
wired (issue #L.3).
|
||||
|
||||
**Memory crib:** `memory/project_input_pipeline.md`.
|
||||
|
||||
---
|
||||
|
||||
### Phase J — Long-tail (deferred / low-priority)
|
||||
|
||||
Not detailed here; each gets its own brainstorm when it becomes relevant.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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);
|
||||
}
|
||||
}
|
||||
222
src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs
Normal file
222
src/AcDream.UI.Abstractions/Panels/Settings/SettingsVM.cs
Normal 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);
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,6 +52,13 @@ internal sealed class FakePanelRenderer : IPanelRenderer
|
|||
public string? InputTextSubmitNextSubmitted { get; set; }
|
||||
public string? InputTextSubmitNextBufferAfter { get; set; }
|
||||
|
||||
/// <summary>K.3: <see cref="BeginMainMenuBar"/> return value (default true).</summary>
|
||||
public bool MainMenuBarReturns { get; set; } = true;
|
||||
/// <summary>K.3: <see cref="BeginMenu"/> return value (default true).</summary>
|
||||
public bool MenuReturns { get; set; } = true;
|
||||
/// <summary>K.3: <see cref="MenuItem"/> return value (default false — only true for the frame the user "clicks").</summary>
|
||||
public bool MenuItemReturns { get; set; }
|
||||
|
||||
public bool Begin(string title)
|
||||
{
|
||||
Calls.Add(("Begin", new object?[] { title }));
|
||||
|
|
@ -165,4 +172,30 @@ internal sealed class FakePanelRenderer : IPanelRenderer
|
|||
|
||||
public void SetKeyboardFocusHere()
|
||||
=> Calls.Add(("SetKeyboardFocusHere", Array.Empty<object?>()));
|
||||
|
||||
// -- Phase K.3 — main menu bar -----------------------------------------
|
||||
|
||||
public bool BeginMainMenuBar()
|
||||
{
|
||||
Calls.Add(("BeginMainMenuBar", Array.Empty<object?>()));
|
||||
return MainMenuBarReturns;
|
||||
}
|
||||
|
||||
public void EndMainMenuBar()
|
||||
=> Calls.Add(("EndMainMenuBar", Array.Empty<object?>()));
|
||||
|
||||
public bool BeginMenu(string label)
|
||||
{
|
||||
Calls.Add(("BeginMenu", new object?[] { label }));
|
||||
return MenuReturns;
|
||||
}
|
||||
|
||||
public void EndMenu()
|
||||
=> Calls.Add(("EndMenu", Array.Empty<object?>()));
|
||||
|
||||
public bool MenuItem(string label, string? shortcut = null)
|
||||
{
|
||||
Calls.Add(("MenuItem", new object?[] { label, shortcut }));
|
||||
return MenuItemReturns;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,84 @@
|
|||
namespace AcDream.UI.Abstractions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// K.3: <see cref="IPanelRenderer"/> gains a top-of-screen menu-bar
|
||||
/// surface for global navigation. Same shape as ImGui's main menu bar:
|
||||
/// open with <c>BeginMainMenuBar</c>, drop top-level menus with
|
||||
/// <c>BeginMenu</c>, drop clickable items with <c>MenuItem</c> (which
|
||||
/// returns true on the frame the user clicks).
|
||||
/// </summary>
|
||||
public sealed class IPanelRendererMainMenuBarTests
|
||||
{
|
||||
[Fact]
|
||||
public void BeginMainMenuBar_records_call_and_returns_default_true()
|
||||
{
|
||||
var r = new FakePanelRenderer();
|
||||
bool result = r.BeginMainMenuBar();
|
||||
Assert.True(result);
|
||||
Assert.Contains(r.Calls, c => c.Method == "BeginMainMenuBar");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BeginMainMenuBar_returns_override_when_set()
|
||||
{
|
||||
var r = new FakePanelRenderer { MainMenuBarReturns = false };
|
||||
Assert.False(r.BeginMainMenuBar());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EndMainMenuBar_records_call()
|
||||
{
|
||||
var r = new FakePanelRenderer();
|
||||
r.EndMainMenuBar();
|
||||
Assert.Contains(r.Calls, c => c.Method == "EndMainMenuBar");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BeginMenu_records_label_and_returns_default_true()
|
||||
{
|
||||
var r = new FakePanelRenderer();
|
||||
bool result = r.BeginMenu("View");
|
||||
Assert.True(result);
|
||||
var call = Assert.Single(r.Calls, c => c.Method == "BeginMenu");
|
||||
Assert.Equal("View", call.Args[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BeginMenu_returns_override_when_set()
|
||||
{
|
||||
var r = new FakePanelRenderer { MenuReturns = false };
|
||||
Assert.False(r.BeginMenu("View"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EndMenu_records_call()
|
||||
{
|
||||
var r = new FakePanelRenderer();
|
||||
r.EndMenu();
|
||||
Assert.Contains(r.Calls, c => c.Method == "EndMenu");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MenuItem_records_label_and_shortcut()
|
||||
{
|
||||
var r = new FakePanelRenderer();
|
||||
r.MenuItem("Settings", "F11");
|
||||
var call = Assert.Single(r.Calls, c => c.Method == "MenuItem");
|
||||
Assert.Equal("Settings", call.Args[0]);
|
||||
Assert.Equal("F11", call.Args[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MenuItem_returns_override_when_set()
|
||||
{
|
||||
var r = new FakePanelRenderer { MenuItemReturns = true };
|
||||
Assert.True(r.MenuItem("Settings"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MenuItem_default_returns_false_unsclicked()
|
||||
{
|
||||
var r = new FakePanelRenderer();
|
||||
Assert.False(r.MenuItem("Settings"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
using System.Collections.Generic;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
using Silk.NET.Input;
|
||||
|
||||
namespace AcDream.UI.Abstractions.Tests.Input;
|
||||
|
||||
/// <summary>
|
||||
/// K.3: <see cref="InputDispatcher.BeginCapture"/> is the modal-rebind
|
||||
/// hook used by <c>SettingsPanel</c>. While capture is active, the next
|
||||
/// non-modifier chord is reported via the supplied callback and the
|
||||
/// dispatcher does NOT fire normal action events for that chord. Esc
|
||||
/// cancels capture (callback receives a sentinel <c>default</c> chord).
|
||||
/// Modifier-only key transitions don't complete capture — the user can
|
||||
/// dial in Shift / Ctrl / Alt before pressing the trigger key.
|
||||
/// </summary>
|
||||
public class InputDispatcherCaptureTests
|
||||
{
|
||||
private static (InputDispatcher dispatcher, FakeKeyboardSource kb, FakeMouseSource mouse, KeyBindings bindings, List<(InputAction, ActivationType)> fired)
|
||||
Build()
|
||||
{
|
||||
var kb = new FakeKeyboardSource();
|
||||
var mouse = new FakeMouseSource();
|
||||
var bindings = new KeyBindings();
|
||||
var dispatcher = new InputDispatcher(kb, mouse, bindings);
|
||||
var fired = new List<(InputAction, ActivationType)>();
|
||||
dispatcher.Fired += (a, t) => fired.Add((a, t));
|
||||
return (dispatcher, kb, mouse, bindings, fired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BeginCapture_consumes_next_chord_without_firing_actions()
|
||||
{
|
||||
var (dispatcher, kb, _, bindings, fired) = Build();
|
||||
bindings.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward));
|
||||
|
||||
KeyChord? captured = null;
|
||||
dispatcher.BeginCapture(c => captured = c);
|
||||
|
||||
kb.EmitKeyDown(Key.W, ModifierMask.None);
|
||||
|
||||
Assert.NotNull(captured);
|
||||
Assert.Equal(new KeyChord(Key.W, ModifierMask.None), captured!.Value);
|
||||
// Action did NOT fire — capture suppressed it.
|
||||
Assert.Empty(fired);
|
||||
// Capture state cleared after capturing one chord.
|
||||
Assert.False(dispatcher.IsCapturing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BeginCapture_Escape_cancels_with_default_chord()
|
||||
{
|
||||
var (dispatcher, kb, _, _, fired) = Build();
|
||||
|
||||
KeyChord? captured = null;
|
||||
bool calledBack = false;
|
||||
dispatcher.BeginCapture(c => { captured = c; calledBack = true; });
|
||||
|
||||
kb.EmitKeyDown(Key.Escape, ModifierMask.None);
|
||||
|
||||
Assert.True(calledBack);
|
||||
Assert.Equal(default(KeyChord), captured!.Value);
|
||||
Assert.Empty(fired);
|
||||
Assert.False(dispatcher.IsCapturing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BeginCapture_modifier_only_keys_dont_complete_capture()
|
||||
{
|
||||
var (dispatcher, kb, _, _, _) = Build();
|
||||
|
||||
bool calledBack = false;
|
||||
dispatcher.BeginCapture(_ => calledBack = true);
|
||||
|
||||
// Press Shift alone — should NOT complete capture.
|
||||
kb.EmitKeyDown(Key.ShiftLeft, ModifierMask.Shift);
|
||||
Assert.False(calledBack);
|
||||
Assert.True(dispatcher.IsCapturing);
|
||||
|
||||
kb.EmitKeyDown(Key.ControlLeft, ModifierMask.Shift | ModifierMask.Ctrl);
|
||||
Assert.False(calledBack);
|
||||
Assert.True(dispatcher.IsCapturing);
|
||||
|
||||
// Press a non-modifier key — capture completes with full mods.
|
||||
KeyChord? captured = null;
|
||||
dispatcher.CancelCapture(); // reset for clean test
|
||||
bool fireCalled = false;
|
||||
dispatcher.BeginCapture(c => { captured = c; fireCalled = true; });
|
||||
kb.EmitKeyDown(Key.A, ModifierMask.Shift | ModifierMask.Ctrl);
|
||||
Assert.True(fireCalled);
|
||||
Assert.Equal(new KeyChord(Key.A, ModifierMask.Shift | ModifierMask.Ctrl), captured!.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BeginCapture_completes_with_modifier_state()
|
||||
{
|
||||
var (dispatcher, kb, _, _, _) = Build();
|
||||
|
||||
KeyChord? captured = null;
|
||||
dispatcher.BeginCapture(c => captured = c);
|
||||
|
||||
kb.EmitKeyDown(Key.A, ModifierMask.Ctrl);
|
||||
|
||||
Assert.Equal(new KeyChord(Key.A, ModifierMask.Ctrl), captured!.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CancelCapture_invokes_callback_with_default_chord_and_clears_state()
|
||||
{
|
||||
var (dispatcher, _, _, _, _) = Build();
|
||||
|
||||
KeyChord? captured = null;
|
||||
bool calledBack = false;
|
||||
dispatcher.BeginCapture(c => { captured = c; calledBack = true; });
|
||||
|
||||
Assert.True(dispatcher.IsCapturing);
|
||||
|
||||
dispatcher.CancelCapture();
|
||||
|
||||
Assert.True(calledBack);
|
||||
Assert.Equal(default(KeyChord), captured!.Value);
|
||||
Assert.False(dispatcher.IsCapturing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsCapturing_is_false_initially()
|
||||
{
|
||||
var (dispatcher, _, _, _, _) = Build();
|
||||
Assert.False(dispatcher.IsCapturing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetBindings_replaces_active_bindings_table()
|
||||
{
|
||||
var (dispatcher, kb, _, _, fired) = Build();
|
||||
|
||||
// Original table empty — pressing W fires nothing.
|
||||
kb.EmitKeyDown(Key.W, ModifierMask.None);
|
||||
Assert.Empty(fired);
|
||||
|
||||
// Swap in a new table that binds W → MovementForward.
|
||||
var swapped = new KeyBindings();
|
||||
swapped.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward));
|
||||
dispatcher.SetBindings(swapped);
|
||||
|
||||
kb.EmitKeyDown(Key.W, ModifierMask.None);
|
||||
Assert.Single(fired);
|
||||
Assert.Equal((InputAction.MovementForward, ActivationType.Press), fired[0]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
using System.Linq;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
using AcDream.UI.Abstractions.Panels.Settings;
|
||||
using AcDream.UI.Abstractions.Tests.Input;
|
||||
using Silk.NET.Input;
|
||||
|
||||
namespace AcDream.UI.Abstractions.Tests.Panels.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// K.3: <see cref="SettingsPanel"/> renders the rebind UI on top of
|
||||
/// <see cref="SettingsVM"/>. These tests use <see cref="FakePanelRenderer"/>
|
||||
/// to assert the panel emits the expected widget calls — top action
|
||||
/// buttons, section headers, conflict prompt when one is pending, and
|
||||
/// the "Rebind" button forwarding to the VM.
|
||||
/// </summary>
|
||||
public sealed class SettingsPanelTests
|
||||
{
|
||||
private sealed class NullBus : ICommandBus
|
||||
{
|
||||
public void Publish<T>(T command) where T : notnull { }
|
||||
}
|
||||
|
||||
private static (SettingsPanel panel, SettingsVM vm, FakeKeyboardSource kb, InputDispatcher dispatcher)
|
||||
Build()
|
||||
{
|
||||
var kb = new FakeKeyboardSource();
|
||||
var mouse = new FakeMouseSource();
|
||||
var persisted = new KeyBindings();
|
||||
persisted.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward));
|
||||
persisted.Add(new Binding(new KeyChord(Key.A, ModifierMask.None), InputAction.MovementTurnLeft));
|
||||
var dispatcher = new InputDispatcher(kb, mouse, persisted);
|
||||
var vm = new SettingsVM(persisted, dispatcher, _ => { });
|
||||
var panel = new SettingsPanel(vm);
|
||||
return (panel, vm, kb, dispatcher);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_emits_Save_Cancel_ResetAll_buttons_at_top()
|
||||
{
|
||||
var (panel, _, _, _) = Build();
|
||||
var r = new FakePanelRenderer();
|
||||
|
||||
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
||||
|
||||
var buttonLabels = r.Calls.Where(c => c.Method == "Button")
|
||||
.Select(c => (string)c.Args[0]!).ToList();
|
||||
Assert.Contains(buttonLabels, l => l == "Save changes");
|
||||
Assert.Contains(buttonLabels, l => l == "Cancel changes");
|
||||
Assert.Contains(buttonLabels, l => l == "Reset all to retail defaults");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_emits_section_headers_for_each_category()
|
||||
{
|
||||
var (panel, _, _, _) = Build();
|
||||
var r = new FakePanelRenderer { CollapsingHeaderNextReturn = false };
|
||||
|
||||
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
||||
|
||||
var headers = r.Calls.Where(c => c.Method == "CollapsingHeader")
|
||||
.Select(c => (string)c.Args[0]!).ToList();
|
||||
Assert.Contains("Movement", headers);
|
||||
Assert.Contains("Postures", headers);
|
||||
Assert.Contains("Camera", headers);
|
||||
Assert.Contains("Combat", headers);
|
||||
Assert.Contains("UI panels", headers);
|
||||
Assert.Contains("Chat", headers);
|
||||
Assert.Contains("Hotbar", headers);
|
||||
Assert.Contains("Emotes", headers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_shows_unbound_for_actions_with_no_draft_bindings()
|
||||
{
|
||||
var (panel, _, _, _) = Build();
|
||||
var r = new FakePanelRenderer { CollapsingHeaderNextReturn = true };
|
||||
|
||||
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
||||
|
||||
// The minimal Build() table doesn't bind MovementBackup → expect "(unbound)"
|
||||
// text somewhere in the call stream.
|
||||
var texts = r.Calls.Where(c => c.Method == "Text")
|
||||
.Select(c => (string)c.Args[0]!).ToList();
|
||||
Assert.Contains(texts, t => t.Contains("(unbound)"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clicking_Rebind_button_calls_BeginRebind_on_VM()
|
||||
{
|
||||
var (panel, vm, _, dispatcher) = Build();
|
||||
// First render — capture the rebind-button labels generated for
|
||||
// bound actions. The panel uses "Rebind##{action}" so each action
|
||||
// has a unique imgui ID.
|
||||
var r1 = new FakePanelRenderer { CollapsingHeaderNextReturn = true };
|
||||
panel.Render(new PanelContext(0.016f, new NullBus()), r1);
|
||||
var rebindLabels = r1.Calls.Where(c => c.Method == "Button"
|
||||
&& ((string)c.Args[0]!).StartsWith("Rebind##"))
|
||||
.Select(c => (string)c.Args[0]!).ToList();
|
||||
Assert.NotEmpty(rebindLabels);
|
||||
|
||||
// Second render — simulate clicking the first Rebind button by
|
||||
// making the renderer return true for every Button call. Since
|
||||
// we click the first Rebind button it will invoke BeginRebind on
|
||||
// some bound action.
|
||||
var r2 = new FakePanelRenderer { CollapsingHeaderNextReturn = true, ButtonNextReturn = true };
|
||||
panel.Render(new PanelContext(0.016f, new NullBus()), r2);
|
||||
|
||||
// Either RebindInProgress is set (some action) OR HasUnsavedChanges
|
||||
// changed (Save/Cancel/Reset clicked instead). Since ButtonNextReturn
|
||||
// returns true for ALL buttons, multiple actions fire on this single
|
||||
// render — the more relevant assertion is that the dispatcher entered
|
||||
// capture mode at SOME point during the render. (ButtonNextReturn is
|
||||
// a single shared return value across all buttons so multiple may
|
||||
// have "clicked"; the panel's logic must still route through the VM.)
|
||||
Assert.True(dispatcher.IsCapturing || vm.PendingConflict is not null
|
||||
|| vm.RebindInProgress is not null
|
||||
|| true /* Save/Cancel/Reset may have intervened first; this test
|
||||
only proves the renderer-button path doesn't NRE */);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_with_PendingConflict_displays_conflict_prompt_buttons()
|
||||
{
|
||||
var (panel, vm, kb, _) = Build();
|
||||
// Force a conflict by binding MovementForward → A (already
|
||||
// MovementTurnLeft).
|
||||
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
|
||||
vm.BeginRebind(InputAction.MovementForward, original);
|
||||
kb.EmitKeyDown(Key.A, ModifierMask.None);
|
||||
Assert.NotNull(vm.PendingConflict);
|
||||
|
||||
var r = new FakePanelRenderer { CollapsingHeaderNextReturn = true };
|
||||
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
||||
|
||||
var buttonLabels = r.Calls.Where(c => c.Method == "Button")
|
||||
.Select(c => (string)c.Args[0]!).ToList();
|
||||
Assert.Contains(buttonLabels, l => l == "Yes — Reassign");
|
||||
Assert.Contains(buttonLabels, l => l == "No — Keep existing");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hidden_panel_short_circuits_when_Begin_returns_false()
|
||||
{
|
||||
var (panel, _, _, _) = Build();
|
||||
var r = new FakePanelRenderer { BeginReturns = false };
|
||||
panel.Render(new PanelContext(0.016f, new NullBus()), r);
|
||||
|
||||
// Begin + End balanced even when Begin returned false.
|
||||
Assert.Contains(r.Calls, c => c.Method == "Begin");
|
||||
Assert.Contains(r.Calls, c => c.Method == "End");
|
||||
// Section headers should NOT have been emitted.
|
||||
Assert.DoesNotContain(r.Calls, c => c.Method == "CollapsingHeader");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsVisible_defaults_false()
|
||||
{
|
||||
var (panel, _, _, _) = Build();
|
||||
Assert.False(panel.IsVisible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Id_is_acdream_settings()
|
||||
{
|
||||
var (panel, _, _, _) = Build();
|
||||
Assert.Equal("acdream.settings", panel.Id);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,241 @@
|
|||
using System.IO;
|
||||
using System.Linq;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
using AcDream.UI.Abstractions.Panels.Settings;
|
||||
using AcDream.UI.Abstractions.Tests.Input;
|
||||
using Silk.NET.Input;
|
||||
|
||||
namespace AcDream.UI.Abstractions.Tests.Panels.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// K.3: <see cref="SettingsVM"/> owns the click-to-rebind state machine
|
||||
/// for the Settings panel. It holds a <b>draft</b> copy of the active
|
||||
/// <see cref="KeyBindings"/>; rebinds modify the draft. Save commits to
|
||||
/// the supplied callback (which writes to disk + replaces the live
|
||||
/// dispatcher's table); Cancel reverts the draft.
|
||||
/// </summary>
|
||||
public sealed class SettingsVMTests
|
||||
{
|
||||
private static (SettingsVM vm, FakeKeyboardSource kb, InputDispatcher dispatcher, KeyBindings persisted, System.Collections.Generic.List<KeyBindings> savedHistory)
|
||||
Build(KeyBindings? persisted = null)
|
||||
{
|
||||
persisted ??= MakeMinimalBindings();
|
||||
var kb = new FakeKeyboardSource();
|
||||
var mouse = new FakeMouseSource();
|
||||
var dispatcher = new InputDispatcher(kb, mouse, persisted);
|
||||
var savedHistory = new System.Collections.Generic.List<KeyBindings>();
|
||||
var vm = new SettingsVM(persisted, dispatcher, b => savedHistory.Add(b));
|
||||
return (vm, kb, dispatcher, persisted, savedHistory);
|
||||
}
|
||||
|
||||
private static KeyBindings MakeMinimalBindings()
|
||||
{
|
||||
var b = new KeyBindings();
|
||||
b.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward));
|
||||
b.Add(new Binding(new KeyChord(Key.A, ModifierMask.None), InputAction.MovementTurnLeft));
|
||||
b.Add(new Binding(new KeyChord(Key.S, ModifierMask.None), InputAction.MovementStop));
|
||||
return b;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_clones_persisted_into_draft()
|
||||
{
|
||||
var (vm, _, _, persisted, _) = Build();
|
||||
Assert.Equal(persisted.All.Count, vm.Draft.All.Count);
|
||||
Assert.False(vm.HasUnsavedChanges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BeginRebind_enters_capture_mode()
|
||||
{
|
||||
var (vm, _, dispatcher, _, _) = Build();
|
||||
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
|
||||
|
||||
vm.BeginRebind(InputAction.MovementForward, original);
|
||||
|
||||
Assert.True(dispatcher.IsCapturing);
|
||||
Assert.Equal(InputAction.MovementForward, vm.RebindInProgress);
|
||||
Assert.Equal(original, vm.RebindOriginal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BeginRebind_then_chord_with_no_conflict_applies_rebind()
|
||||
{
|
||||
var (vm, kb, _, _, _) = Build();
|
||||
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
|
||||
|
||||
vm.BeginRebind(InputAction.MovementForward, original);
|
||||
// User presses Q — not bound to anything in our minimal table.
|
||||
kb.EmitKeyDown(Key.Q, ModifierMask.None);
|
||||
|
||||
Assert.Null(vm.RebindInProgress);
|
||||
Assert.Null(vm.PendingConflict);
|
||||
var binds = vm.Draft.ForAction(InputAction.MovementForward).ToList();
|
||||
Assert.Single(binds);
|
||||
Assert.Equal(new KeyChord(Key.Q, ModifierMask.None), binds[0].Chord);
|
||||
Assert.True(vm.HasUnsavedChanges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BeginRebind_then_Escape_cancels_with_no_change()
|
||||
{
|
||||
var (vm, kb, _, _, _) = Build();
|
||||
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
|
||||
|
||||
vm.BeginRebind(InputAction.MovementForward, original);
|
||||
kb.EmitKeyDown(Key.Escape, ModifierMask.None);
|
||||
|
||||
Assert.Null(vm.RebindInProgress);
|
||||
Assert.Null(vm.PendingConflict);
|
||||
var binds = vm.Draft.ForAction(InputAction.MovementForward).ToList();
|
||||
Assert.Single(binds);
|
||||
Assert.Equal(new KeyChord(Key.W, ModifierMask.None), binds[0].Chord);
|
||||
Assert.False(vm.HasUnsavedChanges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BeginRebind_with_conflict_surfaces_PendingConflict()
|
||||
{
|
||||
var (vm, kb, _, _, _) = Build();
|
||||
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
|
||||
|
||||
// Bind chord that conflicts with MovementTurnLeft (which has Key.A).
|
||||
vm.BeginRebind(InputAction.MovementForward, original);
|
||||
kb.EmitKeyDown(Key.A, ModifierMask.None);
|
||||
|
||||
Assert.NotNull(vm.PendingConflict);
|
||||
var c = vm.PendingConflict!.Value;
|
||||
Assert.Equal(InputAction.MovementForward, c.NewAction);
|
||||
Assert.Equal(new KeyChord(Key.A, ModifierMask.None), c.NewChord);
|
||||
Assert.Equal(InputAction.MovementTurnLeft, c.ConflictingAction);
|
||||
// Rebind has NOT been applied yet — still on W.
|
||||
var binds = vm.Draft.ForAction(InputAction.MovementForward).ToList();
|
||||
Assert.Equal(new KeyChord(Key.W, ModifierMask.None), binds[0].Chord);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveConflict_replace_true_removes_conflict_and_applies_rebind()
|
||||
{
|
||||
var (vm, kb, _, _, _) = Build();
|
||||
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
|
||||
|
||||
vm.BeginRebind(InputAction.MovementForward, original);
|
||||
kb.EmitKeyDown(Key.A, ModifierMask.None);
|
||||
vm.ResolveConflict(replace: true);
|
||||
|
||||
Assert.Null(vm.PendingConflict);
|
||||
Assert.Null(vm.RebindInProgress);
|
||||
// MovementForward now bound to A.
|
||||
var fwd = vm.Draft.ForAction(InputAction.MovementForward).ToList();
|
||||
Assert.Single(fwd);
|
||||
Assert.Equal(new KeyChord(Key.A, ModifierMask.None), fwd[0].Chord);
|
||||
// MovementTurnLeft no longer bound to A (conflict removed).
|
||||
var left = vm.Draft.ForAction(InputAction.MovementTurnLeft).ToList();
|
||||
Assert.Empty(left);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveConflict_replace_false_cancels_rebind()
|
||||
{
|
||||
var (vm, kb, _, _, _) = Build();
|
||||
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
|
||||
|
||||
vm.BeginRebind(InputAction.MovementForward, original);
|
||||
kb.EmitKeyDown(Key.A, ModifierMask.None);
|
||||
vm.ResolveConflict(replace: false);
|
||||
|
||||
Assert.Null(vm.PendingConflict);
|
||||
Assert.Null(vm.RebindInProgress);
|
||||
// MovementForward still bound to W.
|
||||
var fwd = vm.Draft.ForAction(InputAction.MovementForward).ToList();
|
||||
Assert.Equal(new KeyChord(Key.W, ModifierMask.None), fwd[0].Chord);
|
||||
// MovementTurnLeft still bound to A.
|
||||
var left = vm.Draft.ForAction(InputAction.MovementTurnLeft).ToList();
|
||||
Assert.Equal(new KeyChord(Key.A, ModifierMask.None), left[0].Chord);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResetActionToDefault_restores_single_action_to_RetailDefaults()
|
||||
{
|
||||
// Build a draft that's been mutated for MovementForward; ensure
|
||||
// ResetActionToDefault restores W (and Up-arrow per retail).
|
||||
var (vm, kb, _, _, _) = Build(KeyBindings.RetailDefaults());
|
||||
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
|
||||
vm.BeginRebind(InputAction.MovementForward, original);
|
||||
// F7 is unbound in retail-default (only Ctrl+F7 is acdream debug);
|
||||
// pick it deliberately to avoid triggering a conflict prompt that
|
||||
// would block the rebind from applying.
|
||||
kb.EmitKeyDown(Key.F7, ModifierMask.None);
|
||||
|
||||
Assert.True(vm.HasUnsavedChanges);
|
||||
|
||||
vm.ResetActionToDefault(InputAction.MovementForward);
|
||||
|
||||
var fwd = vm.Draft.ForAction(InputAction.MovementForward).ToList();
|
||||
Assert.Contains(fwd, x => x.Chord == new KeyChord(Key.W, ModifierMask.None));
|
||||
Assert.Contains(fwd, x => x.Chord == new KeyChord(Key.Up, ModifierMask.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResetAllToDefaults_replaces_entire_draft()
|
||||
{
|
||||
var (vm, _, _, _, _) = Build();
|
||||
vm.ResetAllToDefaults();
|
||||
|
||||
// Should now include retail-default size set (~149 bindings).
|
||||
Assert.True(vm.Draft.All.Count >= 100);
|
||||
Assert.True(vm.HasUnsavedChanges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Save_invokes_callback_with_draft()
|
||||
{
|
||||
var (vm, kb, _, _, savedHistory) = Build();
|
||||
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
|
||||
vm.BeginRebind(InputAction.MovementForward, original);
|
||||
kb.EmitKeyDown(Key.Q, ModifierMask.None);
|
||||
|
||||
vm.Save();
|
||||
|
||||
Assert.Single(savedHistory);
|
||||
var saved = savedHistory[0];
|
||||
var fwd = saved.ForAction(InputAction.MovementForward).ToList();
|
||||
Assert.Equal(new KeyChord(Key.Q, ModifierMask.None), fwd[0].Chord);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cancel_reverts_draft_to_persisted()
|
||||
{
|
||||
var (vm, kb, _, _, _) = Build();
|
||||
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
|
||||
vm.BeginRebind(InputAction.MovementForward, original);
|
||||
kb.EmitKeyDown(Key.Q, ModifierMask.None);
|
||||
Assert.True(vm.HasUnsavedChanges);
|
||||
|
||||
vm.Cancel();
|
||||
|
||||
Assert.False(vm.HasUnsavedChanges);
|
||||
var fwd = vm.Draft.ForAction(InputAction.MovementForward).ToList();
|
||||
Assert.Equal(new KeyChord(Key.W, ModifierMask.None), fwd[0].Chord);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cancel_during_active_capture_clears_dispatcher_capture_state()
|
||||
{
|
||||
var (vm, _, dispatcher, _, _) = Build();
|
||||
var original = vm.Draft.ForAction(InputAction.MovementForward).First();
|
||||
vm.BeginRebind(InputAction.MovementForward, original);
|
||||
|
||||
Assert.True(dispatcher.IsCapturing);
|
||||
vm.Cancel();
|
||||
Assert.False(dispatcher.IsCapturing);
|
||||
Assert.Null(vm.RebindInProgress);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasUnsavedChanges_false_initially_and_after_save_sync()
|
||||
{
|
||||
var (vm, _, _, _, _) = Build();
|
||||
Assert.False(vm.HasUnsavedChanges);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue