fix(engine): close Settings, Help, Leaderboard, and theme store on Esc
Test / test (pull_request) Successful in 10m16s

Phase C dismissal audit: Esc / scrim-tap / Done must behave the same
on every modal. Stragglers found and fixed — none of these had any
Esc path (pause's toggle guard swallowed the key while they were
open):

- Settings: Esc clears SettingsScreen, gated on being the topmost
  modal so a stacked sync-setup / theme-store dialog owns Esc
- Help: Esc closes alongside F1/Done (the code comment already
  claimed an Esc path existed — now it does)
- Leaderboard: Esc closes when topmost; the display-name dialog
  stacked above it now Esc-cancels like sync-setup's dialog
- Theme store: Esc closes (always topmost when open)

Scrim-tap opt-ins are unchanged — ui_modal documents which modals
deliberately stay non-dismissible on outside clicks.

Tests: escape_closes_help_screen, escape_closes_settings_screen_flag.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-07-08 20:31:55 -07:00
parent d4448bf0cd
commit 4700bd7912
5 changed files with 99 additions and 13 deletions
+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"
);
}
} }
+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();
} }
} }