feat(engine): group HUD menu into Play/You/Community/System + Esc dismissal audit #165
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>()
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user