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
+14 -74
View File
@@ -84,66 +84,11 @@ struct OnboardingSkipButton;
pub struct OnboardingSlideIndex(pub u8);
// ---------------------------------------------------------------------------
// Slide data — hotkey rows are taken verbatim from `help_plugin.rs` so the
// two screens stay in sync without a shared abstraction.
// Slide data — the hotkey slide renders the essential subset of the
// shared table in `crate::hotkeys` (Phase J unified the previously
// diverging copies here and in help_plugin).
// ---------------------------------------------------------------------------
/// A single `key — description` pair shown on slide 3.
#[cfg(not(target_os = "android"))]
struct HotkeyRow {
keys: &'static str,
description: &'static str,
}
/// Most-used shortcuts from the `help_plugin` canonical list.
///
/// Updating the list in `help_plugin.rs` should be mirrored here. The
/// ARCHITECTURE.md decision log calls out that we copy values rather than
/// refactor the help plugin.
#[cfg(not(target_os = "android"))]
const HOTKEYS: &[HotkeyRow] = &[
HotkeyRow {
keys: "D / Space",
description: "Draw from stock",
},
HotkeyRow {
keys: "U",
description: "Undo last move",
},
HotkeyRow {
keys: "Tab → Enter",
description: "Pick a card; arrows pick where; Enter to drop",
},
HotkeyRow {
keys: "N",
description: "New Classic game",
},
HotkeyRow {
keys: "M",
description: "Open Mode Launcher (then 15 to pick)",
},
HotkeyRow {
keys: "S",
description: "Stats & progression",
},
HotkeyRow {
keys: "A",
description: "Achievements",
},
HotkeyRow {
keys: "O",
description: "Settings",
},
HotkeyRow {
keys: "Esc",
description: "Pause / resume",
},
HotkeyRow {
keys: "F1",
description: "Help / controls",
},
];
// ---------------------------------------------------------------------------
// Plugin
// ---------------------------------------------------------------------------
@@ -446,8 +391,10 @@ fn spawn_slide_hotkeys(commands: &mut Commands, font_res: Option<&FontResource>)
spawn_modal(commands, OnboardingScreen, Z_ONBOARDING, |card| {
spawn_modal_header(card, "Keyboard shortcuts", font_res);
// Vertical list of `key — description` rows, same chip style as HelpScreen.
for row in HOTKEYS {
// Vertical list of `key — description` rows, same chip style as
// HelpScreen. Essential subset only — the full table lives on
// the hold-`/` cheat sheet.
for row in crate::hotkeys::HOTKEYS.iter().filter(|r| r.essential) {
card.spawn(Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
@@ -826,22 +773,15 @@ mod tests {
}
// -----------------------------------------------------------------------
// Hotkey list is non-empty (guards against accidental truncation)
// Hotkey slide renders a non-empty essential subset (the table's own
// integrity tests live in `crate::hotkeys`)
// -----------------------------------------------------------------------
#[test]
fn hotkey_list_is_non_empty() {
assert!(!HOTKEYS.is_empty(), "HOTKEYS must not be empty");
}
#[test]
fn all_hotkey_rows_have_non_empty_fields() {
for row in HOTKEYS {
assert!(!row.keys.is_empty(), "hotkey key field must not be empty");
assert!(
!row.description.is_empty(),
"hotkey description must not be empty"
);
}
fn hotkey_slide_subset_is_non_empty() {
assert!(
crate::hotkeys::HOTKEYS.iter().any(|r| r.essential),
"the onboarding slide needs at least one essential hotkey"
);
}
}