feat(engine): group HUD menu into Play/You/Community/System + Esc dismissal audit #165

Merged
funman300 merged 2 commits from feat/menu-grouping into master 2026-07-09 05:10:03 +00:00
10 changed files with 258 additions and 90 deletions
+5
View File
@@ -237,6 +237,11 @@ pub struct ToggleSettingsRequestEvent;
#[derive(Message, Debug, Clone, Copy, Default)] #[derive(Message, Debug, Clone, Copy, Default)]
pub struct ToggleLeaderboardRequestEvent; 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 /// Fired by `SyncPlugin` after a pull task resolves and the merged result has
/// been persisted to disk. `Ok(SyncResponse)` carries the merged payload plus /// been persisted to disk. `Ok(SyncResponse)` carries the merged payload plus
/// any `ConflictReport`s the merge produced. `Err(String)` carries a /// any `ConflictReport`s the merge produced. `Err(String)` carries a
+36 -2
View File
@@ -86,13 +86,18 @@ fn toggle_help_screen(
} }
/// Click handler for the modal's "Done" button. F1 toggles the overlay /// 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( fn handle_help_close_button(
mut commands: Commands, mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>,
close_buttons: Query<&Interaction, (With<HelpCloseButton>, Changed<Interaction>)>, close_buttons: Query<&Interaction, (With<HelpCloseButton>, Changed<Interaction>)>,
screens: Query<Entity, With<HelpScreen>>, screens: Query<Entity, With<HelpScreen>>,
) { ) {
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; return;
} }
for entity in &screens { for entity in &screens {
@@ -583,4 +588,33 @@ mod tests {
0 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::<ButtonInput<KeyCode>>()
.press(KeyCode::F1);
app.update();
{
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
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"
);
}
} }
+44 -2
View File
@@ -24,7 +24,8 @@ use crate::daily_challenge_plugin::DailyChallengeResource;
use crate::events::{ use crate::events::{
InfoToastEvent, NewGameRequestEvent, StartChallengeRequestEvent, InfoToastEvent, NewGameRequestEvent, StartChallengeRequestEvent,
StartDailyChallengeRequestEvent, StartDifficultyRequestEvent, StartPlayBySeedRequestEvent, StartDailyChallengeRequestEvent, StartDifficultyRequestEvent, StartPlayBySeedRequestEvent,
StartTimeAttackRequestEvent, StartZenRequestEvent, ToggleProfileRequestEvent, StartTimeAttackRequestEvent, StartZenRequestEvent, ToggleHomeRequestEvent,
ToggleProfileRequestEvent,
}; };
use crate::font_plugin::FontResource; use crate::font_plugin::FontResource;
use crate::progress_plugin::ProgressResource; use crate::progress_plugin::ProgressResource;
@@ -264,6 +265,7 @@ impl Plugin for HomePlugin {
.add_message::<StartPlayBySeedRequestEvent>() .add_message::<StartPlayBySeedRequestEvent>()
.add_message::<StartDifficultyRequestEvent>() .add_message::<StartDifficultyRequestEvent>()
.add_message::<InfoToastEvent>() .add_message::<InfoToastEvent>()
.add_message::<ToggleHomeRequestEvent>()
.add_message::<ToggleProfileRequestEvent>() .add_message::<ToggleProfileRequestEvent>()
.add_message::<SettingsChangedEvent>() .add_message::<SettingsChangedEvent>()
// Defensively register MouseWheel so `scroll_home_panel` // Defensively register MouseWheel so `scroll_home_panel`
@@ -371,6 +373,7 @@ fn spawn_home_on_launch(
fn toggle_home_screen( fn toggle_home_screen(
mut commands: Commands, mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,
mut requests: MessageReader<ToggleHomeRequestEvent>,
progress: Option<Res<ProgressResource>>, progress: Option<Res<ProgressResource>>,
stats: Option<Res<StatsResource>>, stats: Option<Res<StatsResource>>,
settings: Option<Res<SettingsResource>>, settings: Option<Res<SettingsResource>>,
@@ -380,7 +383,8 @@ fn toggle_home_screen(
other_modal_scrims: Query<(), (With<crate::ui_modal::ModalScrim>, Without<HomeScreen>)>, other_modal_scrims: Query<(), (With<crate::ui_modal::ModalScrim>, Without<HomeScreen>)>,
diff_expanded: Res<DifficultyExpanded>, diff_expanded: Res<DifficultyExpanded>,
) { ) {
if !keys.just_pressed(KeyCode::KeyM) { let button_clicked = requests.read().count() > 0;
if !keys.just_pressed(KeyCode::KeyM) && !button_clicked {
return; return;
} }
if let Ok(entity) = screens.single() { 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::<Messages<ToggleHomeRequestEvent>>()
.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::<Messages<ToggleHomeRequestEvent>>()
.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] #[test]
fn pressing_m_twice_closes_home_screen() { fn pressing_m_twice_closes_home_screen() {
let mut app = headless_app(); let mut app = headless_app();
+95 -64
View File
@@ -311,44 +311,65 @@ pub(super) fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&Font
..default() ..default()
}; };
// Each row carries a tooltip alongside its label so hover reveals // One popover row: destination, label, hover tooltip.
// a one-line description of what each overlay shows — mirroring type MenuRow = (MenuOption, &'static str, &'static str);
// the tooltips on the action-bar buttons that opened this popover. // Destinations grouped into labelled sections (Phase C of the menu
let rows: [(MenuOption, &'static str, &'static str); 7] = [ // 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, "Play",
"Help", &[(
"Show controls, rules, and keyboard shortcuts.", MenuOption::Home,
"Home",
"Pick a mode, continue, or start a new game.",
)],
), ),
( (
MenuOption::Modes, "You",
"Game Modes", &[
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack.", (
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, "Community",
"Stats", &[(
"Lifetime totals: wins, streaks, fastest time, best score.", MenuOption::Leaderboard,
"Leaderboard",
"Top players from your sync server. Opt in from Profile.",
)],
), ),
( (
MenuOption::Achievements, "System",
"Achievements", &[
"Browse unlocked achievements and the rewards still ahead.", (
), MenuOption::Settings,
( "Settings",
MenuOption::Profile, "Audio, animations, theme, draw mode, and sync.",
"Profile", ),
"Your level, XP progress, and sync status.", (
), MenuOption::Help,
( "Help",
MenuOption::Settings, "Show controls, rules, and keyboard shortcuts.",
"Settings", ),
"Audio, animations, theme, draw mode, and sync.", ],
),
(
MenuOption::Leaderboard,
"Leaderboard",
"Top players from your sync server. Opt in from Profile.",
), ),
]; ];
@@ -373,27 +394,48 @@ pub(super) fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&Font
ZIndex(Z_HUD_POPOVER), ZIndex(Z_HUD_POPOVER),
)) ))
.with_children(|panel| { .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 panel
.spawn(( .spawn(Node {
option, padding: UiRect::axes(VAL_SPACE_3, Val::Px(2.0)),
ActionButton, ..default()
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| { .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<Interaction>>, interaction_query: Query<(&Interaction, &MenuOption), Changed<Interaction>>,
popovers: Query<Entity, With<MenuPopover>>, popovers: Query<Entity, With<MenuPopover>>,
backdrops: Query<Entity, With<MenuPopoverBackdrop>>, backdrops: Query<Entity, With<MenuPopoverBackdrop>>,
mut home: MessageWriter<ToggleHomeRequestEvent>,
mut stats: MessageWriter<ToggleStatsRequestEvent>, mut stats: MessageWriter<ToggleStatsRequestEvent>,
mut achievements: MessageWriter<ToggleAchievementsRequestEvent>, mut achievements: MessageWriter<ToggleAchievementsRequestEvent>,
mut profile: MessageWriter<ToggleProfileRequestEvent>, mut profile: MessageWriter<ToggleProfileRequestEvent>,
mut settings: MessageWriter<ToggleSettingsRequestEvent>, mut settings: MessageWriter<ToggleSettingsRequestEvent>,
mut leaderboard: MessageWriter<ToggleLeaderboardRequestEvent>, mut leaderboard: MessageWriter<ToggleLeaderboardRequestEvent>,
mut help: MessageWriter<HelpRequestEvent>, mut help: MessageWriter<HelpRequestEvent>,
progress: Option<Res<ProgressResource>>,
daily: Option<Res<DailyChallengeResource>>,
font_res: Option<Res<FontResource>>,
mut commands: Commands, mut commands: Commands,
) { ) {
let mut clicked_any = false; let mut clicked_any = false;
let mut open_modes = false;
for (interaction, option) in &interaction_query { for (interaction, option) in &interaction_query {
if *interaction != Interaction::Pressed { if *interaction != Interaction::Pressed {
continue; continue;
} }
clicked_any = true; clicked_any = true;
match option { match option {
MenuOption::Home => {
home.write(ToggleHomeRequestEvent);
}
MenuOption::Help => { MenuOption::Help => {
help.write(HelpRequestEvent); help.write(HelpRequestEvent);
} }
MenuOption::Modes => {
open_modes = true;
}
MenuOption::Stats => { MenuOption::Stats => {
stats.write(ToggleStatsRequestEvent); stats.write(ToggleStatsRequestEvent);
} }
@@ -470,14 +509,6 @@ pub(super) fn handle_menu_option_click(
commands.entity(e).despawn(); 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 /// Despawns the [`ModesPopover`] and its backdrop when Escape / Android back
+14 -10
View File
@@ -30,9 +30,9 @@ use crate::daily_challenge_plugin::DailyChallengeResource;
use crate::events::{ use crate::events::{
HelpRequestEvent, InfoToastEvent, NewGameRequestEvent, PauseRequestEvent, HelpRequestEvent, InfoToastEvent, NewGameRequestEvent, PauseRequestEvent,
StartChallengeRequestEvent, StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent, StartChallengeRequestEvent, StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent,
StartZenRequestEvent, ToggleAchievementsRequestEvent, ToggleLeaderboardRequestEvent, StartZenRequestEvent, ToggleAchievementsRequestEvent, ToggleHomeRequestEvent,
ToggleProfileRequestEvent, ToggleSettingsRequestEvent, ToggleStatsRequestEvent, ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent, ToggleSettingsRequestEvent,
UndoRequestEvent, WinStreakMilestoneEvent, ToggleStatsRequestEvent, UndoRequestEvent, WinStreakMilestoneEvent,
}; };
use crate::font_plugin::FontResource; use crate::font_plugin::FontResource;
use crate::game_plugin::{GameMutation, NewGameRequestWriters}; use crate::game_plugin::{GameMutation, NewGameRequestWriters};
@@ -386,8 +386,9 @@ pub enum ModeOption {
} }
/// Marker on the "Menu" action button. Click toggles the [`MenuPopover`] /// Marker on the "Menu" action button. Click toggles the [`MenuPopover`]
/// which exposes the Stats / Achievements / Profile / Settings / /// which exposes the Home / Profile / Stats / Achievements /
/// Leaderboard overlays without needing the S/A/P/O/L hotkeys. /// Leaderboard / Settings / Help overlays without needing the
/// M/P/S/A/L/O/F1 hotkeys.
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct MenuButton; pub struct MenuButton;
@@ -413,16 +414,18 @@ struct MenuPopoverBackdrop;
struct ModesPopoverBackdrop; struct ModesPopoverBackdrop;
/// One row inside the [`MenuPopover`]. The variant selects which /// 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)] #[derive(Component, Debug, Clone, Copy)]
pub enum MenuOption { pub enum MenuOption {
Help, Home,
Modes, Profile,
Stats, Stats,
Achievements, Achievements,
Profile,
Settings,
Leaderboard, Leaderboard,
Settings,
Help,
} }
/// HUD Z-layer — above cards (which start at z=0) but below overlay screens. /// HUD Z-layer — above cards (which start at z=0) but below overlay screens.
@@ -459,6 +462,7 @@ impl Plugin for HudPlugin {
.add_message::<StartTimeAttackRequestEvent>() .add_message::<StartTimeAttackRequestEvent>()
.add_message::<StartDailyChallengeRequestEvent>() .add_message::<StartDailyChallengeRequestEvent>()
.add_message::<ToggleStatsRequestEvent>() .add_message::<ToggleStatsRequestEvent>()
.add_message::<ToggleHomeRequestEvent>()
.add_message::<ToggleAchievementsRequestEvent>() .add_message::<ToggleAchievementsRequestEvent>()
.add_message::<ToggleProfileRequestEvent>() .add_message::<ToggleProfileRequestEvent>()
.add_message::<ToggleSettingsRequestEvent>() .add_message::<ToggleSettingsRequestEvent>()
+1 -1
View File
@@ -786,8 +786,8 @@ fn popover_rows_carry_tooltip_strings() {
menu_tooltips.len() menu_tooltips.len()
); );
for expected in [ for expected in [
"Pick a mode, continue, or start a new game.",
"Show controls, rules, and keyboard shortcuts.", "Show controls, rules, and keyboard shortcuts.",
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack.",
"Lifetime totals: wins, streaks, fastest time, best score.", "Lifetime totals: wins, streaks, fastest time, best score.",
"Browse unlocked achievements and the rewards still ahead.", "Browse unlocked achievements and the rewards still ahead.",
"Your level, XP progress, and sync status.", "Your level, XP progress, and sync status.",
+16 -2
View File
@@ -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( fn handle_leaderboard_close_button(
mut commands: Commands, mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>,
close_buttons: Query<&Interaction, (With<LeaderboardCloseButton>, Changed<Interaction>)>, close_buttons: Query<&Interaction, (With<LeaderboardCloseButton>, Changed<Interaction>)>,
screens: Query<Entity, With<LeaderboardScreen>>, screens: Query<Entity, With<LeaderboardScreen>>,
other_modal_scrims: Query<(), (With<ModalScrim>, Without<LeaderboardScreen>)>,
mut closed_flag: ResMut<ClosedThisFrame>, mut closed_flag: ResMut<ClosedThisFrame>,
) { ) {
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; return;
} }
for entity in &screens { for entity in &screens {
@@ -888,12 +896,18 @@ fn handle_display_name_confirm(
} }
/// Discards any typed text and closes the display-name editor modal. /// 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( fn handle_display_name_cancel(
button_q: Query<&Interaction, (Changed<Interaction>, With<DisplayNameCancelButton>)>, button_q: Query<&Interaction, (Changed<Interaction>, With<DisplayNameCancelButton>)>,
keys: Res<ButtonInput<KeyCode>>,
screens: Query<Entity, With<DisplayNameModal>>, screens: Query<Entity, With<DisplayNameModal>>,
mut commands: Commands, 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; return;
} }
for entity in &screens { for entity in &screens {
@@ -50,15 +50,21 @@ pub(super) fn handle_volume_keys(
} }
/// Opens or closes the Settings panel — `O` keyboard accelerator or /// 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( pub(super) fn toggle_settings_screen(
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,
mut requests: MessageReader<ToggleSettingsRequestEvent>, mut requests: MessageReader<ToggleSettingsRequestEvent>,
mut screen: ResMut<SettingsScreen>, mut screen: ResMut<SettingsScreen>,
other_modal_scrims: Query<(), (With<ModalScrim>, Without<SettingsPanel>)>,
) { ) {
let button_clicked = requests.read().count() > 0; let button_clicked = requests.read().count() > 0;
if keys.just_pressed(KeyCode::KeyO) || button_clicked { if keys.just_pressed(KeyCode::KeyO) || button_clicked {
screen.0 = !screen.0; screen.0 = !screen.0;
} else if keys.just_pressed(KeyCode::Escape) && screen.0 && other_modal_scrims.is_empty() {
screen.0 = false;
} }
} }
@@ -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::<SettingsScreen>().0,
"Esc on a closed panel stays closed"
);
press(&mut app, KeyCode::KeyO);
app.update();
assert!(app.world().resource::<SettingsScreen>().0, "O opens");
press(&mut app, KeyCode::Escape);
app.update();
assert!(
!app.world().resource::<SettingsScreen>().0,
"Esc closes settings"
);
}
// cycle_unlocked pure-function tests // cycle_unlocked pure-function tests
#[test] #[test]
fn cycle_unlocked_wraps_at_end() { fn cycle_unlocked_wraps_at_end() {
+15 -8
View File
@@ -118,6 +118,9 @@ impl Plugin for ThemeStorePlugin {
.init_resource::<CatalogTask>() .init_resource::<CatalogTask>()
.init_resource::<InstallTask>() .init_resource::<InstallTask>()
.init_resource::<StoreBaseUrl>() .init_resource::<StoreBaseUrl>()
// Esc-close reads keyboard input; register defensively so
// the plugin works under MinimalPlugins in tests.
.init_resource::<ButtonInput<KeyCode>>()
.add_message::<ThemeStoreOpenRequestEvent>() .add_message::<ThemeStoreOpenRequestEvent>()
.add_message::<InfoToastEvent>() .add_message::<InfoToastEvent>()
.add_message::<WarningToastEvent>() .add_message::<WarningToastEvent>()
@@ -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( fn handle_close_button(
interactions: Query<&Interaction, (Changed<Interaction>, With<ThemeStoreCloseButton>)>, interactions: Query<&Interaction, (Changed<Interaction>, With<ThemeStoreCloseButton>)>,
keys: Res<ButtonInput<KeyCode>>,
screens: Query<Entity, With<ThemeStoreScreen>>, screens: Query<Entity, With<ThemeStoreScreen>>,
mut commands: Commands, mut commands: Commands,
) { ) {
for interaction in &interactions { let clicked = interactions.iter().any(|i| *i == Interaction::Pressed);
if *interaction != Interaction::Pressed { let esc = keys.just_pressed(KeyCode::Escape) && !screens.is_empty();
continue; if !clicked && !esc {
} return;
for entity in &screens { }
commands.entity(entity).despawn(); for entity in &screens {
} commands.entity(entity).despawn();
} }
} }