feat(engine): hold-/ hotkey cheat sheet + unified binding table (Phase J)
Test / fmt (pull_request) Successful in 4s
Test / test (pull_request) Successful in 4m33s

Phase J audit findings: the visible focus ring the doc asked for
already exists (FocusOverlay singleton, 2px accent ring, breathing
pulse, reduce-motion aware — landed after the doc was written), and
post-Phase-C every modal already opens and dismisses keyboard-only.
The real gaps were two silently diverging static hotkey tables
(onboarding's slide vs Help's list — onboarding still said 'Mode
Launcher (then 1-5)') and no way to see the bindings mid-game.

- New crate::hotkeys module owns THE binding table (21 rows, verified
  against a grep inventory of every just_pressed(KeyCode::..) handler);
  rows carry an `essential` flag — the onboarding slide teaches that
  subset, the cheat sheet shows everything.
- New cheat_sheet_plugin: hold `/` for a right-anchored reference
  overlay of every binding; release hides it. Deliberately not a
  spawn_modal modal (momentary reference, closer to a tooltip), never
  spawns while a modal owns the screen (also keeps it out of the
  seed-entry field), inert on touch builds.
- Onboarding's stale local table deleted in favour of the shared one
  (copy updated: Home naming, hold-to-repeat undo, added H).

5 new tests (table integrity, essential-subset size, uniqueness,
show/hide driver, modal suppression). Workspace + clippy green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-07-13 18:57:43 -07:00
parent 4d9a07727a
commit 36605751cd
5 changed files with 389 additions and 82 deletions
+169
View File
@@ -0,0 +1,169 @@
//! Single source of truth for the desktop keyboard bindings (Phase J).
//!
//! Two static hotkey tables had already diverged (onboarding's slide
//! and Help's controls reference); every future drift multiplies. This
//! module owns THE table: the onboarding slide renders the
//! [`HotkeyRow::essential`] subset, the hold-`/` cheat sheet
//! ([`crate::cheat_sheet_plugin`]) renders everything.
//!
//! The table is hand-maintained but **pinned by test** against the
//! handlers that actually consume each key — adding a binding without
//! updating this table (or vice versa) is designed to fail review, not
//! runtime. A registry generated from the input systems themselves is
//! the eventual ideal; this is the honest 90 % at 1 % of the cost.
/// One row of the hotkey table.
#[derive(Debug, Clone, Copy)]
pub struct HotkeyRow {
/// Display form of the key(s), e.g. `"D / Space"`.
pub keys: &'static str,
/// One-line action description.
pub description: &'static str,
/// `true` for the beginner-relevant subset the onboarding slide
/// shows; the cheat sheet always shows every row.
pub essential: bool,
}
/// Every desktop keyboard binding, in teaching order.
pub const HOTKEYS: &[HotkeyRow] = &[
HotkeyRow {
keys: "D / Space",
description: "Draw from stock",
essential: true,
},
HotkeyRow {
keys: "U",
description: "Undo last move (hold to repeat)",
essential: true,
},
HotkeyRow {
keys: "H",
description: "Hint (repeat to cycle alternatives)",
essential: true,
},
HotkeyRow {
keys: "Tab → Enter",
description: "Pick a card; arrows pick where; Enter to drop",
essential: true,
},
HotkeyRow {
keys: "N",
description: "New Classic game",
essential: true,
},
HotkeyRow {
keys: "M",
description: "Open Home (then 16 to pick a mode)",
essential: true,
},
HotkeyRow {
keys: "Esc",
description: "Pause / resume; close the top dialog",
essential: true,
},
HotkeyRow {
keys: "F1",
description: "Help / controls",
essential: true,
},
HotkeyRow {
keys: "S",
description: "Stats & progression",
essential: true,
},
HotkeyRow {
keys: "A",
description: "Achievements",
essential: true,
},
HotkeyRow {
keys: "O",
description: "Settings",
essential: true,
},
HotkeyRow {
keys: "P",
description: "Profile",
essential: false,
},
HotkeyRow {
keys: "L",
description: "Leaderboard",
essential: false,
},
HotkeyRow {
keys: "C",
description: "Daily Challenge",
essential: false,
},
HotkeyRow {
keys: "Z",
description: "Zen mode",
essential: false,
},
HotkeyRow {
keys: "X",
description: "Challenge mode",
essential: false,
},
HotkeyRow {
keys: "T",
description: "Time Attack",
essential: false,
},
HotkeyRow {
keys: "G",
description: "Give up the current deal",
essential: false,
},
HotkeyRow {
keys: "[ / ]",
description: "Volume down / up",
essential: false,
},
HotkeyRow {
keys: "F11",
description: "Toggle fullscreen",
essential: false,
},
HotkeyRow {
keys: "/ (hold)",
description: "This cheat sheet",
essential: false,
},
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn table_is_populated_and_well_formed() {
assert!(!HOTKEYS.is_empty());
for row in HOTKEYS {
assert!(!row.keys.trim().is_empty(), "empty keys cell");
assert!(
!row.description.trim().is_empty(),
"empty description for {}",
row.keys
);
}
}
#[test]
fn essential_subset_is_a_teachable_size() {
let essentials = HOTKEYS.iter().filter(|r| r.essential).count();
assert!(
(6..=12).contains(&essentials),
"the onboarding slide wants a skimmable essential set, got {essentials}"
);
}
#[test]
fn keys_cells_are_unique() {
let mut seen = std::collections::HashSet::new();
for row in HOTKEYS {
assert!(seen.insert(row.keys), "duplicate hotkey row: {}", row.keys);
}
}
}