From 36605751cd57538c11d70808812249838b0c08eb Mon Sep 17 00:00:00 2001 From: funman300 Date: Mon, 13 Jul 2026 18:57:43 -0700 Subject: [PATCH] feat(engine): hold-/ hotkey cheat sheet + unified binding table (Phase J) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- solitaire_engine/src/cheat_sheet_plugin.rs | 194 +++++++++++++++++++++ solitaire_engine/src/core_game_plugin.rs | 17 +- solitaire_engine/src/hotkeys.rs | 169 ++++++++++++++++++ solitaire_engine/src/lib.rs | 3 + solitaire_engine/src/onboarding_plugin.rs | 88 ++-------- 5 files changed, 389 insertions(+), 82 deletions(-) create mode 100644 solitaire_engine/src/cheat_sheet_plugin.rs create mode 100644 solitaire_engine/src/hotkeys.rs diff --git a/solitaire_engine/src/cheat_sheet_plugin.rs b/solitaire_engine/src/cheat_sheet_plugin.rs new file mode 100644 index 0000000..e39c5ce --- /dev/null +++ b/solitaire_engine/src/cheat_sheet_plugin.rs @@ -0,0 +1,194 @@ +//! Hold-`/` hotkey cheat sheet (Phase J). +//! +//! While `/` is held on a keyboard platform, a lightweight overlay +//! lists every binding from [`crate::hotkeys::HOTKEYS`] — the same +//! table the onboarding slide teaches from, so the two can never +//! disagree. Releasing the key hides it instantly; it never captures +//! input, never pauses the game, and never spawns while a modal owns +//! the screen (which also keeps it out of the seed-entry text field's +//! way). +//! +//! Not a `spawn_modal` modal on purpose: modals are sticky and guarded; +//! this is a momentary reference card, closer to a tooltip than a +//! dialog. + +use bevy::input::ButtonInput; +use bevy::prelude::*; + +use crate::font_plugin::FontResource; +use crate::hotkeys::HOTKEYS; +use crate::platform::SHOW_KEYBOARD_ACCELERATORS; +use crate::ui_modal::ModalScrim; +use crate::ui_theme::{ + ACCENT_PRIMARY, BG_ELEVATED, BORDER_STRONG, HighContrastBorder, RADIUS_MD, TEXT_PRIMARY, + TEXT_SECONDARY, TYPE_BODY, TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, + Z_TOOLTIP, +}; + +/// Marker on the cheat-sheet overlay root. +#[derive(Component, Debug)] +pub struct CheatSheetOverlay; + +/// Registers the hold-`/` driver. Inert on touch-first builds. +pub struct CheatSheetPlugin; + +impl Plugin for CheatSheetPlugin { + fn build(&self, app: &mut App) { + app.init_resource::>() + .add_systems(Update, drive_cheat_sheet); + } +} + +/// Shows the overlay while `/` is held (and no modal owns the screen); +/// hides it the frame the key releases. +fn drive_cheat_sheet( + keys: Res>, + scrims: Query<(), With>, + existing: Query>, + font_res: Option>, + mut commands: Commands, +) { + if !SHOW_KEYBOARD_ACCELERATORS { + return; + } + let held = keys.pressed(KeyCode::Slash); + if held && existing.is_empty() && scrims.is_empty() { + spawn_cheat_sheet(&mut commands, font_res.as_deref()); + } else if !held { + for entity in &existing { + commands.entity(entity).despawn(); + } + } +} + +fn spawn_cheat_sheet(commands: &mut Commands, font_res: Option<&FontResource>) { + let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default(); + let font_header = TextFont { + font: font_handle.clone(), + font_size: TYPE_BODY, + ..default() + }; + let font_keys = TextFont { + font: font_handle.clone(), + font_size: TYPE_CAPTION, + ..default() + }; + let font_desc = TextFont { + font: font_handle, + font_size: TYPE_CAPTION, + ..default() + }; + + commands + .spawn(( + CheatSheetOverlay, + Node { + position_type: PositionType::Absolute, + right: Val::Px(16.0), + top: Val::Percent(8.0), + flex_direction: FlexDirection::Column, + row_gap: VAL_SPACE_1, + padding: UiRect::all(VAL_SPACE_4), + border: UiRect::all(Val::Px(1.0)), + border_radius: BorderRadius::all(Val::Px(RADIUS_MD)), + max_height: Val::Percent(84.0), + overflow: Overflow::scroll_y(), + ..default() + }, + BackgroundColor(BG_ELEVATED), + BorderColor::all(BORDER_STRONG), + HighContrastBorder::with_default(BORDER_STRONG), + GlobalZIndex(Z_TOOLTIP), + )) + .with_children(|panel| { + panel.spawn(( + Text::new("Keyboard shortcuts"), + font_header.clone(), + TextColor(TEXT_PRIMARY), + Node { + margin: UiRect::bottom(VAL_SPACE_2), + ..default() + }, + )); + for row in HOTKEYS { + panel + .spawn(Node { + flex_direction: FlexDirection::Row, + column_gap: VAL_SPACE_3, + ..default() + }) + .with_children(|line| { + line.spawn(( + Text::new(row.keys), + font_keys.clone(), + TextColor(ACCENT_PRIMARY), + Node { + min_width: Val::Px(110.0), + ..default() + }, + )); + line.spawn(( + Text::new(row.description), + font_desc.clone(), + TextColor(TEXT_SECONDARY), + )); + }); + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn app() -> App { + let mut app = App::new(); + app.add_plugins(MinimalPlugins) + .add_plugins(CheatSheetPlugin); + app.update(); + app + } + + fn overlay_count(app: &mut App) -> usize { + app.world_mut() + .query::<&CheatSheetOverlay>() + .iter(app.world()) + .count() + } + + #[test] + fn holding_slash_shows_and_release_hides() { + let mut app = app(); + app.world_mut() + .resource_mut::>() + .press(KeyCode::Slash); + app.update(); + assert_eq!(overlay_count(&mut app), 1, "held / must show the sheet"); + // Still held on later frames: exactly one overlay, no stacking. + app.update(); + assert_eq!(overlay_count(&mut app), 1); + + app.world_mut() + .resource_mut::>() + .release(KeyCode::Slash); + app.update(); + app.update(); + assert_eq!(overlay_count(&mut app), 0, "release must hide the sheet"); + } + + #[test] + fn suppressed_while_a_modal_is_open() { + let mut app = app(); + app.world_mut().spawn(ModalScrim); + app.update(); + app.world_mut() + .resource_mut::>() + .press(KeyCode::Slash); + app.update(); + assert_eq!( + overlay_count(&mut app), + 0, + "the sheet must not spawn over a modal (or into a text field)" + ); + } +} diff --git a/solitaire_engine/src/core_game_plugin.rs b/solitaire_engine/src/core_game_plugin.rs index f4cf85c..cf88dcd 100644 --- a/solitaire_engine/src/core_game_plugin.rs +++ b/solitaire_engine/src/core_game_plugin.rs @@ -14,14 +14,14 @@ use crate::platform::{ }; use crate::{ AchievementPlugin, AnimationPlugin, AssetSourcesPlugin, AutoCompletePlugin, - CardAnimationPlugin, CardPlugin, ChallengePlugin, ContextualTipsPlugin, CursorPlugin, - DailyChallengePlugin, DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin, FontPlugin, - GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, OnboardingPlugin, PausePlugin, - PlayBySeedPlugin, ProfilePlugin, ProgressPlugin, RadialMenuPlugin, ReplayOverlayPlugin, - ReplayPlaybackPlugin, SafeAreaInsetsPlugin, SelectionPlugin, SettingsPlugin, - SolutionPlaybackPlugin, SplashPlugin, StatsPlugin, SyncProvider, TablePlugin, ThemePlugin, - ThemeRegistryPlugin, TimeAttackPlugin, TouchSelectionPlugin, UiFocusPlugin, UiModalPlugin, - UiTooltipPlugin, WeeklyGoalsPlugin, WhatsNewPlugin, WinSummaryPlugin, + CardAnimationPlugin, CardPlugin, ChallengePlugin, CheatSheetPlugin, ContextualTipsPlugin, + CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin, + FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, OnboardingPlugin, + PausePlugin, PlayBySeedPlugin, ProfilePlugin, ProgressPlugin, RadialMenuPlugin, + ReplayOverlayPlugin, ReplayPlaybackPlugin, SafeAreaInsetsPlugin, SelectionPlugin, + SettingsPlugin, SolutionPlaybackPlugin, SplashPlugin, StatsPlugin, SyncProvider, TablePlugin, + ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin, TouchSelectionPlugin, UiFocusPlugin, + UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WhatsNewPlugin, WinSummaryPlugin, }; #[cfg(not(target_arch = "wasm32"))] use crate::{ @@ -117,6 +117,7 @@ impl Plugin for CoreGamePlugin { .add_plugins(OnboardingPlugin) .add_plugins(WhatsNewPlugin) .add_plugins(ContextualTipsPlugin) + .add_plugins(CheatSheetPlugin) .add_plugins(WinSummaryPlugin) .add_plugins(UiModalPlugin) .add_plugins(UiFocusPlugin) diff --git a/solitaire_engine/src/hotkeys.rs b/solitaire_engine/src/hotkeys.rs new file mode 100644 index 0000000..ec0d65e --- /dev/null +++ b/solitaire_engine/src/hotkeys.rs @@ -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 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); + } + } +} diff --git a/solitaire_engine/src/lib.rs b/solitaire_engine/src/lib.rs index 6e446c0..35f7fd2 100644 --- a/solitaire_engine/src/lib.rs +++ b/solitaire_engine/src/lib.rs @@ -15,6 +15,7 @@ pub mod avatar_plugin; pub mod card_animation; pub mod card_plugin; pub mod challenge_plugin; +pub mod cheat_sheet_plugin; pub mod contextual_tips_plugin; pub mod core_game_plugin; pub mod cursor_plugin; @@ -27,6 +28,7 @@ pub mod font_plugin; pub mod game_plugin; pub mod help_plugin; pub mod home_plugin; +pub mod hotkeys; pub mod hud_plugin; pub mod input_plugin; pub mod layout; @@ -95,6 +97,7 @@ pub use card_plugin::{ pub use challenge_plugin::{ CHALLENGE_UNLOCK_LEVEL, ChallengeAdvancedEvent, ChallengePlugin, challenge_progress_label, }; +pub use cheat_sheet_plugin::{CheatSheetOverlay, CheatSheetPlugin}; pub use contextual_tips_plugin::ContextualTipsPlugin; pub use core_game_plugin::CoreGamePlugin; pub use cursor_plugin::CursorPlugin; diff --git a/solitaire_engine/src/onboarding_plugin.rs b/solitaire_engine/src/onboarding_plugin.rs index 471d0a3..c168d82 100644 --- a/solitaire_engine/src/onboarding_plugin.rs +++ b/solitaire_engine/src/onboarding_plugin.rs @@ -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 1–5 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" + ); } }