diff --git a/solitaire_engine/src/events.rs b/solitaire_engine/src/events.rs index 79eb0ea..4e3678a 100644 --- a/solitaire_engine/src/events.rs +++ b/solitaire_engine/src/events.rs @@ -237,6 +237,11 @@ pub struct ToggleSettingsRequestEvent; #[derive(Message, Debug, Clone, Copy, Default)] pub struct ToggleLeaderboardRequestEvent; +/// Request to toggle the Home mode launcher. Fired by the HUD +/// Menu-popover "Home" row alongside the existing `M` accelerator. +#[derive(Message, Debug, Clone, Copy, Default)] +pub struct ToggleHomeRequestEvent; + /// Fired by `SyncPlugin` after a pull task resolves and the merged result has /// been persisted to disk. `Ok(SyncResponse)` carries the merged payload plus /// any `ConflictReport`s the merge produced. `Err(String)` carries a diff --git a/solitaire_engine/src/help_plugin.rs b/solitaire_engine/src/help_plugin.rs index a22950a..76199ab 100644 --- a/solitaire_engine/src/help_plugin.rs +++ b/solitaire_engine/src/help_plugin.rs @@ -86,13 +86,18 @@ fn toggle_help_screen( } /// Click handler for the modal's "Done" button. F1 toggles the overlay -/// the same way; this just exposes the close action to mouse / touch. +/// the same way; Esc closes too, so dismissal matches every other +/// modal (Phase C dismissal audit). Nothing ever stacks above Help, +/// so Esc needs no topmost gate. fn handle_help_close_button( mut commands: Commands, + keys: Res>, close_buttons: Query<&Interaction, (With, Changed)>, screens: Query>, ) { - if !close_buttons.iter().any(|i| *i == Interaction::Pressed) { + let clicked = close_buttons.iter().any(|i| *i == Interaction::Pressed); + let esc = keys.just_pressed(KeyCode::Escape) && !screens.is_empty(); + if !clicked && !esc { return; } for entity in &screens { @@ -583,4 +588,33 @@ mod tests { 0 ); } + + /// Esc must dismiss the Help modal like Done and F1 do (Phase C + /// dismissal audit). + #[test] + fn escape_closes_help_screen() { + let mut app = headless_app(); + app.world_mut() + .resource_mut::>() + .press(KeyCode::F1); + app.update(); + + { + let mut input = app.world_mut().resource_mut::>(); + input.release(KeyCode::F1); + input.clear(); + input.press(KeyCode::Escape); + } + app.update(); + app.update(); + + assert_eq!( + app.world_mut() + .query::<&HelpScreen>() + .iter(app.world()) + .count(), + 0, + "Esc must close the Help modal" + ); + } } diff --git a/solitaire_engine/src/home_plugin.rs b/solitaire_engine/src/home_plugin.rs index 01d2065..5193a13 100644 --- a/solitaire_engine/src/home_plugin.rs +++ b/solitaire_engine/src/home_plugin.rs @@ -24,7 +24,8 @@ use crate::daily_challenge_plugin::DailyChallengeResource; use crate::events::{ InfoToastEvent, NewGameRequestEvent, StartChallengeRequestEvent, StartDailyChallengeRequestEvent, StartDifficultyRequestEvent, StartPlayBySeedRequestEvent, - StartTimeAttackRequestEvent, StartZenRequestEvent, ToggleProfileRequestEvent, + StartTimeAttackRequestEvent, StartZenRequestEvent, ToggleHomeRequestEvent, + ToggleProfileRequestEvent, }; use crate::font_plugin::FontResource; use crate::progress_plugin::ProgressResource; @@ -264,6 +265,7 @@ impl Plugin for HomePlugin { .add_message::() .add_message::() .add_message::() + .add_message::() .add_message::() .add_message::() // Defensively register MouseWheel so `scroll_home_panel` @@ -371,6 +373,7 @@ fn spawn_home_on_launch( fn toggle_home_screen( mut commands: Commands, keys: Res>, + mut requests: MessageReader, progress: Option>, stats: Option>, settings: Option>, @@ -380,7 +383,8 @@ fn toggle_home_screen( other_modal_scrims: Query<(), (With, Without)>, diff_expanded: Res, ) { - if !keys.just_pressed(KeyCode::KeyM) { + let button_clicked = requests.read().count() > 0; + if !keys.just_pressed(KeyCode::KeyM) && !button_clicked { return; } if let Ok(entity) = screens.single() { @@ -1621,6 +1625,44 @@ mod tests { ); } + /// The HUD Menu popover's "Home" row fires + /// `ToggleHomeRequestEvent`; it must open Home exactly like the + /// `M` accelerator (Phase C: the popover's Play section replaces + /// the old Modes row). + #[test] + fn toggle_home_event_opens_home_screen() { + let mut app = headless_app(); + app.world_mut() + .resource_mut::>() + .write(ToggleHomeRequestEvent); + app.update(); + + assert_eq!( + app.world_mut() + .query::<&HomeScreen>() + .iter(app.world()) + .count(), + 1, + "ToggleHomeRequestEvent must open the Home modal" + ); + + // A second request toggles it closed, matching the M key. + app.world_mut() + .resource_mut::>() + .write(ToggleHomeRequestEvent); + app.update(); + app.update(); + + assert_eq!( + app.world_mut() + .query::<&HomeScreen>() + .iter(app.world()) + .count(), + 0, + "second ToggleHomeRequestEvent must close the Home modal" + ); + } + #[test] fn pressing_m_twice_closes_home_screen() { let mut app = headless_app(); diff --git a/solitaire_engine/src/hud_plugin/interaction.rs b/solitaire_engine/src/hud_plugin/interaction.rs index b7e5a19..128200c 100644 --- a/solitaire_engine/src/hud_plugin/interaction.rs +++ b/solitaire_engine/src/hud_plugin/interaction.rs @@ -311,44 +311,65 @@ pub(super) fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&Font ..default() }; - // Each row carries a tooltip alongside its label so hover reveals - // a one-line description of what each overlay shows — mirroring - // the tooltips on the action-bar buttons that opened this popover. - let rows: [(MenuOption, &'static str, &'static str); 7] = [ + // One popover row: destination, label, hover tooltip. + type MenuRow = (MenuOption, &'static str, &'static str); + // Destinations grouped into labelled sections (Phase C of the menu + // redesign): Play · You · Community · System. Each row carries a + // tooltip alongside its label so hover reveals a one-line + // description of what each overlay shows — mirroring the tooltips + // on the action-bar buttons that opened this popover. Mode + // selection lives on Home now, so there is no Modes row. + let sections: [(&'static str, &'static [MenuRow]); 4] = [ ( - MenuOption::Help, - "Help", - "Show controls, rules, and keyboard shortcuts.", + "Play", + &[( + MenuOption::Home, + "Home", + "Pick a mode, continue, or start a new game.", + )], ), ( - MenuOption::Modes, - "Game Modes", - "Switch modes: Classic, Daily, Zen, Challenge, Time Attack.", + "You", + &[ + ( + MenuOption::Profile, + "Profile", + "Your level, XP progress, and sync status.", + ), + ( + MenuOption::Stats, + "Stats", + "Lifetime totals: wins, streaks, fastest time, best score.", + ), + ( + MenuOption::Achievements, + "Achievements", + "Browse unlocked achievements and the rewards still ahead.", + ), + ], ), ( - MenuOption::Stats, - "Stats", - "Lifetime totals: wins, streaks, fastest time, best score.", + "Community", + &[( + MenuOption::Leaderboard, + "Leaderboard", + "Top players from your sync server. Opt in from Profile.", + )], ), ( - MenuOption::Achievements, - "Achievements", - "Browse unlocked achievements and the rewards still ahead.", - ), - ( - MenuOption::Profile, - "Profile", - "Your level, XP progress, and sync status.", - ), - ( - MenuOption::Settings, - "Settings", - "Audio, animations, theme, draw mode, and sync.", - ), - ( - MenuOption::Leaderboard, - "Leaderboard", - "Top players from your sync server. Opt in from Profile.", + "System", + &[ + ( + MenuOption::Settings, + "Settings", + "Audio, animations, theme, draw mode, and sync.", + ), + ( + MenuOption::Help, + "Help", + "Show controls, rules, and keyboard shortcuts.", + ), + ], ), ]; @@ -373,27 +394,48 @@ pub(super) fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&Font ZIndex(Z_HUD_POPOVER), )) .with_children(|panel| { - for (option, label, tooltip) in rows { + let section_font = TextFont { + font: font_res.map(|f| f.0.clone()).unwrap_or_default(), + font_size: TYPE_CAPTION, + ..default() + }; + for (section, rows) in sections { + // Non-interactive section header — a quiet divider + // inside the existing panel, not a new widget. panel - .spawn(( - option, - ActionButton, - PopoverRow, - Button, - Tooltip::new(tooltip), - Node { - padding: UiRect::axes(VAL_SPACE_3, Val::Px(6.0)), - justify_content: JustifyContent::FlexStart, - align_items: AlignItems::Center, - min_width: Val::Px(150.0), - border_radius: BorderRadius::all(Val::Px(RADIUS_SM)), - ..default() - }, - BackgroundColor(ACTION_BTN_IDLE), - )) + .spawn(Node { + padding: UiRect::axes(VAL_SPACE_3, Val::Px(2.0)), + ..default() + }) .with_children(|b| { - b.spawn((Text::new(label), font.clone(), TextColor(TEXT_PRIMARY))); + b.spawn(( + Text::new(section), + section_font.clone(), + TextColor(TEXT_SECONDARY), + )); }); + for &(option, label, tooltip) in rows { + panel + .spawn(( + option, + ActionButton, + PopoverRow, + Button, + Tooltip::new(tooltip), + Node { + padding: UiRect::axes(VAL_SPACE_3, Val::Px(6.0)), + justify_content: JustifyContent::FlexStart, + align_items: AlignItems::Center, + min_width: Val::Px(150.0), + border_radius: BorderRadius::all(Val::Px(RADIUS_SM)), + ..default() + }, + BackgroundColor(ACTION_BTN_IDLE), + )) + .with_children(|b| { + b.spawn((Text::new(label), font.clone(), TextColor(TEXT_PRIMARY))); + }); + } } }); @@ -422,31 +464,28 @@ pub(super) fn handle_menu_option_click( interaction_query: Query<(&Interaction, &MenuOption), Changed>, popovers: Query>, backdrops: Query>, + mut home: MessageWriter, mut stats: MessageWriter, mut achievements: MessageWriter, mut profile: MessageWriter, mut settings: MessageWriter, mut leaderboard: MessageWriter, mut help: MessageWriter, - progress: Option>, - daily: Option>, - font_res: Option>, mut commands: Commands, ) { let mut clicked_any = false; - let mut open_modes = false; for (interaction, option) in &interaction_query { if *interaction != Interaction::Pressed { continue; } clicked_any = true; match option { + MenuOption::Home => { + home.write(ToggleHomeRequestEvent); + } MenuOption::Help => { help.write(HelpRequestEvent); } - MenuOption::Modes => { - open_modes = true; - } MenuOption::Stats => { stats.write(ToggleStatsRequestEvent); } @@ -470,14 +509,6 @@ pub(super) fn handle_menu_option_click( commands.entity(e).despawn(); } } - if open_modes { - spawn_modes_popover( - &mut commands, - progress.as_deref(), - daily.as_deref(), - font_res.as_deref(), - ); - } } /// Despawns the [`ModesPopover`] and its backdrop when Escape / Android back diff --git a/solitaire_engine/src/hud_plugin/mod.rs b/solitaire_engine/src/hud_plugin/mod.rs index 8d5d7cd..62bcc45 100644 --- a/solitaire_engine/src/hud_plugin/mod.rs +++ b/solitaire_engine/src/hud_plugin/mod.rs @@ -30,9 +30,9 @@ use crate::daily_challenge_plugin::DailyChallengeResource; use crate::events::{ HelpRequestEvent, InfoToastEvent, NewGameRequestEvent, PauseRequestEvent, StartChallengeRequestEvent, StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent, - StartZenRequestEvent, ToggleAchievementsRequestEvent, ToggleLeaderboardRequestEvent, - ToggleProfileRequestEvent, ToggleSettingsRequestEvent, ToggleStatsRequestEvent, - UndoRequestEvent, WinStreakMilestoneEvent, + StartZenRequestEvent, ToggleAchievementsRequestEvent, ToggleHomeRequestEvent, + ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent, ToggleSettingsRequestEvent, + ToggleStatsRequestEvent, UndoRequestEvent, WinStreakMilestoneEvent, }; use crate::font_plugin::FontResource; use crate::game_plugin::{GameMutation, NewGameRequestWriters}; @@ -386,8 +386,9 @@ pub enum ModeOption { } /// Marker on the "Menu" action button. Click toggles the [`MenuPopover`] -/// which exposes the Stats / Achievements / Profile / Settings / -/// Leaderboard overlays without needing the S/A/P/O/L hotkeys. +/// which exposes the Home / Profile / Stats / Achievements / +/// Leaderboard / Settings / Help overlays without needing the +/// M/P/S/A/L/O/F1 hotkeys. #[derive(Component, Debug)] pub struct MenuButton; @@ -413,16 +414,18 @@ struct MenuPopoverBackdrop; struct ModesPopoverBackdrop; /// One row inside the [`MenuPopover`]. The variant selects which -/// `Toggle*RequestEvent` the click handler fires. +/// `Toggle*RequestEvent` the click handler fires. Rows render grouped +/// under section headers (Play · You · Community · System); mode +/// selection lives on Home, so there is no Modes row here. #[derive(Component, Debug, Clone, Copy)] pub enum MenuOption { - Help, - Modes, + Home, + Profile, Stats, Achievements, - Profile, - Settings, Leaderboard, + Settings, + Help, } /// HUD Z-layer — above cards (which start at z=0) but below overlay screens. @@ -459,6 +462,7 @@ impl Plugin for HudPlugin { .add_message::() .add_message::() .add_message::() + .add_message::() .add_message::() .add_message::() .add_message::() diff --git a/solitaire_engine/src/hud_plugin/tests.rs b/solitaire_engine/src/hud_plugin/tests.rs index 0b2c086..8a8d7db 100644 --- a/solitaire_engine/src/hud_plugin/tests.rs +++ b/solitaire_engine/src/hud_plugin/tests.rs @@ -786,8 +786,8 @@ fn popover_rows_carry_tooltip_strings() { menu_tooltips.len() ); for expected in [ + "Pick a mode, continue, or start a new game.", "Show controls, rules, and keyboard shortcuts.", - "Switch modes: Classic, Daily, Zen, Challenge, Time Attack.", "Lifetime totals: wins, streaks, fastest time, best score.", "Browse unlocked achievements and the rewards still ahead.", "Your level, XP progress, and sync status.", diff --git a/solitaire_engine/src/leaderboard_plugin.rs b/solitaire_engine/src/leaderboard_plugin.rs index 0803fd7..49df78a 100644 --- a/solitaire_engine/src/leaderboard_plugin.rs +++ b/solitaire_engine/src/leaderboard_plugin.rs @@ -343,13 +343,21 @@ fn scroll_leaderboard_panel( } } +/// Done click or Esc dismisses the leaderboard (Phase C dismissal +/// audit). Esc only fires when the leaderboard is the topmost modal — +/// with the display-name dialog stacked on top, that dialog owns Esc. fn handle_leaderboard_close_button( mut commands: Commands, + keys: Res>, close_buttons: Query<&Interaction, (With, Changed)>, screens: Query>, + other_modal_scrims: Query<(), (With, Without)>, mut closed_flag: ResMut, ) { - if !close_buttons.iter().any(|i| *i == Interaction::Pressed) { + let clicked = close_buttons.iter().any(|i| *i == Interaction::Pressed); + let esc = + keys.just_pressed(KeyCode::Escape) && !screens.is_empty() && other_modal_scrims.is_empty(); + if !clicked && !esc { return; } for entity in &screens { @@ -888,12 +896,18 @@ fn handle_display_name_confirm( } /// Discards any typed text and closes the display-name editor modal. +/// Cancel click or Esc dismisses the display-name dialog without +/// saving (Phase C dismissal audit — same contract as the sync-setup +/// dialog's Cancel/Esc pair). fn handle_display_name_cancel( button_q: Query<&Interaction, (Changed, With)>, + keys: Res>, screens: Query>, mut commands: Commands, ) { - if !button_q.iter().any(|i| *i == Interaction::Pressed) { + let clicked = button_q.iter().any(|i| *i == Interaction::Pressed); + let esc = keys.just_pressed(KeyCode::Escape) && !screens.is_empty(); + if !clicked && !esc { return; } for entity in &screens { diff --git a/solitaire_engine/src/settings_plugin/input.rs b/solitaire_engine/src/settings_plugin/input.rs index f69c679..72c3676 100644 --- a/solitaire_engine/src/settings_plugin/input.rs +++ b/solitaire_engine/src/settings_plugin/input.rs @@ -50,15 +50,21 @@ pub(super) fn handle_volume_keys( } /// Opens or closes the Settings panel — `O` keyboard accelerator or -/// `ToggleSettingsRequestEvent` from the HUD Menu popover. +/// `ToggleSettingsRequestEvent` from the HUD Menu popover. Esc closes +/// too (Phase C dismissal audit), but only when Settings is the +/// topmost modal — with sync-setup or the theme store stacked on top, +/// the stacked dialog owns Esc. pub(super) fn toggle_settings_screen( keys: Res>, mut requests: MessageReader, mut screen: ResMut, + other_modal_scrims: Query<(), (With, Without)>, ) { let button_clicked = requests.read().count() > 0; if keys.just_pressed(KeyCode::KeyO) || button_clicked { screen.0 = !screen.0; + } else if keys.just_pressed(KeyCode::Escape) && screen.0 && other_modal_scrims.is_empty() { + screen.0 = false; } } diff --git a/solitaire_engine/src/settings_plugin/tests.rs b/solitaire_engine/src/settings_plugin/tests.rs index e6b7173..9916dfd 100644 --- a/solitaire_engine/src/settings_plugin/tests.rs +++ b/solitaire_engine/src/settings_plugin/tests.rs @@ -140,6 +140,31 @@ fn pressing_o_toggles_settings_screen_flag() { ); } +/// Esc closes the Settings panel like O / Done do (Phase C dismissal +/// audit). Esc while the panel is closed must NOT open it. +#[test] +fn escape_closes_settings_screen_flag() { + let mut app = headless_app(); + + press(&mut app, KeyCode::Escape); + app.update(); + assert!( + !app.world().resource::().0, + "Esc on a closed panel stays closed" + ); + + press(&mut app, KeyCode::KeyO); + app.update(); + assert!(app.world().resource::().0, "O opens"); + + press(&mut app, KeyCode::Escape); + app.update(); + assert!( + !app.world().resource::().0, + "Esc closes settings" + ); +} + // cycle_unlocked pure-function tests #[test] fn cycle_unlocked_wraps_at_end() { diff --git a/solitaire_engine/src/theme_store_plugin.rs b/solitaire_engine/src/theme_store_plugin.rs index 4b4f325..49645c5 100644 --- a/solitaire_engine/src/theme_store_plugin.rs +++ b/solitaire_engine/src/theme_store_plugin.rs @@ -118,6 +118,9 @@ impl Plugin for ThemeStorePlugin { .init_resource::() .init_resource::() .init_resource::() + // Esc-close reads keyboard input; register defensively so + // the plugin works under MinimalPlugins in tests. + .init_resource::>() .add_message::() .add_message::() .add_message::() @@ -344,19 +347,23 @@ fn poll_install_task( ); } -/// Despawns the store modal when Close is pressed. +/// Despawns the store modal when Close is pressed or on Esc (Phase C +/// dismissal audit). The store only ever stacks over Settings and +/// nothing stacks over the store, so it owns Esc whenever it is open +/// (Settings' own Esc handler is gated on being topmost). fn handle_close_button( interactions: Query<&Interaction, (Changed, With)>, + keys: Res>, screens: Query>, mut commands: Commands, ) { - for interaction in &interactions { - if *interaction != Interaction::Pressed { - continue; - } - for entity in &screens { - commands.entity(entity).despawn(); - } + let clicked = interactions.iter().any(|i| *i == Interaction::Pressed); + let esc = keys.just_pressed(KeyCode::Escape) && !screens.is_empty(); + if !clicked && !esc { + return; + } + for entity in &screens { + commands.entity(entity).despawn(); } }