36605751cd
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>
170 lines
4.5 KiB
Rust
170 lines
4.5 KiB
Rust
//! 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 1–6 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);
|
||
}
|
||
}
|
||
}
|