//! 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)" ); } }