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>
195 lines
6.2 KiB
Rust
195 lines
6.2 KiB
Rust
//! 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::<ButtonInput<KeyCode>>()
|
|
.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<ButtonInput<KeyCode>>,
|
|
scrims: Query<(), With<ModalScrim>>,
|
|
existing: Query<Entity, With<CheatSheetOverlay>>,
|
|
font_res: Option<Res<FontResource>>,
|
|
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::<ButtonInput<KeyCode>>()
|
|
.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::<ButtonInput<KeyCode>>()
|
|
.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::<ButtonInput<KeyCode>>()
|
|
.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)"
|
|
);
|
|
}
|
|
}
|