//! HUD interaction: action-button handlers, Modes/Menu popovers, and //! the chrome tap-to-toggle gesture. use super::*; /// `Changed` filter ensures we only react on the frame the /// interaction state transitions, avoiding repeat events while the button /// is held down. Each click handler fires the corresponding request event, /// which `pause_plugin` / `help_plugin` / `game_plugin` consume alongside /// their existing keyboard handlers. pub(super) fn handle_new_game_button( interaction_query: Query<&Interaction, (With, Changed)>, mut new_game: MessageWriter, ) { for interaction in &interaction_query { if *interaction == Interaction::Pressed { new_game.write(NewGameRequestEvent::default()); } } } pub(super) fn handle_undo_button( interaction_query: Query<&Interaction, (With, Changed)>, mut undo: MessageWriter, ) { for interaction in &interaction_query { if *interaction == Interaction::Pressed { undo.write(UndoRequestEvent); } } } pub(super) fn handle_pause_button( interaction_query: Query<&Interaction, (With, Changed)>, mut pause: MessageWriter, ) { for interaction in &interaction_query { if *interaction == Interaction::Pressed { pause.write(PauseRequestEvent); } } } pub(super) fn handle_help_button( interaction_query: Query<&Interaction, (With, Changed)>, mut help: MessageWriter, ) { for interaction in &interaction_query { if *interaction == Interaction::Pressed { help.write(HelpRequestEvent); } } } pub(super) fn handle_hint_button( interaction_query: Query<&Interaction, (With, Changed)>, paused: Option>, game: Option>, solver_config: Option>, mut pending_hint: Option>, mut info_toast: MessageWriter, ) { for interaction in &interaction_query { if *interaction != Interaction::Pressed { continue; } if paused.as_ref().is_some_and(|p| p.0) { return; } let Some(ref g) = game else { return }; if g.0.is_won() { info_toast.write(InfoToastEvent(HINT_WON_MSG.to_string())); return; } if let (Some(cfg), Some(hint)) = (solver_config.as_ref(), pending_hint.as_mut()) { hint.spawn(g.0.clone(), cfg.moves_budget, cfg.states_budget); } } } /// Toggles the [`ModesPopover`]: spawns it on first click, despawns it on /// second click. Mode rows are populated per the player's current level so /// only unlocked options appear. pub(super) fn handle_modes_button( interaction_query: Query<&Interaction, (With, Changed)>, popovers: Query>, backdrops: Query>, progress: Option>, daily: Option>, font_res: Option>, mut commands: Commands, ) { let pressed = interaction_query.iter().any(|i| *i == Interaction::Pressed); if !pressed { return; } if let Ok(entity) = popovers.single() { commands.entity(entity).despawn(); for e in &backdrops { commands.entity(e).despawn(); } } else { spawn_modes_popover( &mut commands, progress.as_deref(), daily.as_deref(), font_res.as_deref(), ); } } /// Spawns the modes popover anchored just below the action bar's right /// edge. Always includes Classic; includes Daily Challenge when a daily /// resource is loaded; includes Zen / Challenge / Time Attack once the /// player reaches the challenge unlock level. pub(super) fn spawn_modes_popover( commands: &mut Commands, progress: Option<&ProgressResource>, daily: Option<&DailyChallengeResource>, font_res: Option<&FontResource>, ) { let level = progress.map_or(0, |p| p.0.level); let font = TextFont { font: font_res.map(|f| f.0.clone()).unwrap_or_default(), font_size: 15.0, ..default() }; // Each row carries a tooltip alongside its label so hover reveals // a one-line description of what the mode does — mirroring the // tooltips on the action-bar buttons that opened this popover. let mut rows: Vec<(ModeOption, &'static str, &'static str)> = vec![( ModeOption::Classic, "Classic", "Standard Klondike. Score, timer, and full progression.", )]; if daily.is_some() { rows.push(( ModeOption::DailyChallenge, "Daily Challenge", "Today's seeded deal. Same for every player worldwide.", )); } if level >= CHALLENGE_UNLOCK_LEVEL { rows.push(( ModeOption::Zen, "Zen", "No timer, no score, no penalties. Just play.", )); rows.push(( ModeOption::Challenge, "Challenge", "Hand-picked hard seeds. No undo allowed.", )); rows.push(( ModeOption::TimeAttack, "Time Attack", "Win as many games as you can in ten minutes.", )); } // Popover opens upward from just above the bottom action bar. // Use a platform-aware offset that clears the bar height + safe-area // gesture zone on Android, and the flat bar height on desktop. let popover_bottom = Val::Px(ACTION_POPOVER_BOTTOM_PX); commands .spawn(( ModesPopover, HudPopoverOpen, Node { position_type: PositionType::Absolute, right: VAL_SPACE_3, bottom: popover_bottom, flex_direction: FlexDirection::Column, row_gap: VAL_SPACE_1, padding: UiRect::all(VAL_SPACE_2), border_radius: BorderRadius::all(Val::Px(RADIUS_MD)), ..default() }, BackgroundColor(BG_ELEVATED), ZIndex(Z_HUD_POPOVER), )) .with_children(|panel| { 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))); }); } }); // Fullscreen transparent backdrop at Z_HUD_POPOVER_BACKDROP (below the // popover at Z_HUD_POPOVER) so tapping outside light-dismisses it. commands.spawn(( ModesPopoverBackdrop, Button, Node { position_type: PositionType::Absolute, left: Val::Px(0.0), top: Val::Px(0.0), width: Val::Percent(100.0), height: Val::Percent(100.0), ..default() }, BackgroundColor(Color::NONE), ZIndex(Z_HUD_POPOVER_BACKDROP), )); } /// Dispatches the click on a popover row to the matching request event, /// then despawns the popover. /// /// Classic uses [`NewGameRequestEvent`] directly; the other modes use /// their `Start*RequestEvent` so the existing keyboard handler runs /// (level gates, `TimeAttackResource` setup, daily seed lookup, etc.) — /// the popover stays a thin entry point and never duplicates that logic. #[allow(clippy::too_many_arguments)] pub(super) fn handle_mode_option_click( interaction_query: Query<(&Interaction, &ModeOption), Changed>, popovers: Query>, backdrops: Query>, mut new_game: MessageWriter, mut zen: MessageWriter, mut challenge: MessageWriter, mut time_attack: MessageWriter, mut daily: MessageWriter, mut commands: Commands, ) { let mut clicked_any = false; for (interaction, option) in &interaction_query { if *interaction != Interaction::Pressed { continue; } clicked_any = true; match option { ModeOption::Classic => { new_game.write(NewGameRequestEvent::default()); } ModeOption::DailyChallenge => { daily.write(StartDailyChallengeRequestEvent); } ModeOption::Zen => { zen.write(StartZenRequestEvent); } ModeOption::Challenge => { challenge.write(StartChallengeRequestEvent); } ModeOption::TimeAttack => { time_attack.write(StartTimeAttackRequestEvent); } } } if clicked_any && let Ok(entity) = popovers.single() { commands.entity(entity).despawn(); for e in &backdrops { commands.entity(e).despawn(); } } } /// Toggles the [`MenuPopover`]: spawns it on first click, despawns it on /// second click. The popover lists the five overlays previously only /// reachable via the S / A / P / O / L hotkeys. pub(super) fn handle_menu_button( interaction_query: Query<&Interaction, (With, Changed)>, popovers: Query>, backdrops: Query>, scrims: Query<(), With>, font_res: Option>, mut commands: Commands, ) { let pressed = interaction_query.iter().any(|i| *i == Interaction::Pressed); if !pressed { return; } if let Ok(entity) = popovers.single() { commands.entity(entity).despawn(); for e in &backdrops { commands.entity(e).despawn(); } } else if scrims.is_empty() { spawn_menu_popover(&mut commands, font_res.as_deref()); } } /// Spawns the menu popover anchored just below the action bar, with one /// row per overlay. Each row dispatches its corresponding /// `Toggle*RequestEvent` so the existing toggle handler runs (and the /// HUD never duplicates spawn / despawn / fetch logic). pub(super) fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>) { let font = TextFont { font: font_res.map(|f| f.0.clone()).unwrap_or_default(), font_size: 15.0, ..default() }; // 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] = [ ( "Play", &[( MenuOption::Home, "Home", "Pick a mode, continue, or start a new game.", )], ), ( "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.", ), ], ), ( "Community", &[( 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.", ), ], ), ]; // Same upward-opening placement as ModesPopover. let popover_bottom = Val::Px(ACTION_POPOVER_BOTTOM_PX); commands .spawn(( MenuPopover, HudPopoverOpen, Node { position_type: PositionType::Absolute, right: VAL_SPACE_3, bottom: popover_bottom, flex_direction: FlexDirection::Column, row_gap: VAL_SPACE_1, padding: UiRect::all(VAL_SPACE_2), border_radius: BorderRadius::all(Val::Px(RADIUS_MD)), ..default() }, BackgroundColor(BG_ELEVATED), ZIndex(Z_HUD_POPOVER), )) .with_children(|panel| { 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(Node { padding: UiRect::axes(VAL_SPACE_3, Val::Px(2.0)), ..default() }) .with_children(|b| { 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))); }); } } }); // Transparent fullscreen backdrop behind the popover — tapping anywhere // outside the panel light-dismisses it via handle_menu_backdrop_click. commands.spawn(( MenuPopoverBackdrop, Button, Node { position_type: PositionType::Absolute, left: Val::Px(0.0), top: Val::Px(0.0), width: Val::Percent(100.0), height: Val::Percent(100.0), ..default() }, BackgroundColor(Color::NONE), ZIndex(Z_HUD_POPOVER_BACKDROP), )); } /// Dispatches the click on a menu row to the matching toggle event, /// then despawns the popover. #[allow(clippy::too_many_arguments)] 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, mut commands: Commands, ) { let mut clicked_any = 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::Stats => { stats.write(ToggleStatsRequestEvent); } MenuOption::Achievements => { achievements.write(ToggleAchievementsRequestEvent); } MenuOption::Profile => { profile.write(ToggleProfileRequestEvent); } MenuOption::Settings => { settings.write(ToggleSettingsRequestEvent); } MenuOption::Leaderboard => { leaderboard.write(ToggleLeaderboardRequestEvent); } } } if clicked_any && let Ok(entity) = popovers.single() { commands.entity(entity).despawn(); for e in &backdrops { commands.entity(e).despawn(); } } } /// Despawns the [`ModesPopover`] and its backdrop when Escape / Android back /// is pressed while the popover is open. Runs so `PausePlugin`'s guard (which /// checks [`HudPopoverOpen`]) sees an empty world and stays idle. pub(super) fn close_modes_popover_on_escape( keys: Res>, popovers: Query>, backdrops: Query>, mut commands: Commands, ) { if !keys.just_pressed(KeyCode::Escape) || popovers.is_empty() { return; } for e in popovers.iter().chain(backdrops.iter()) { commands.entity(e).despawn(); } } /// Despawns the [`MenuPopover`] and its backdrop when Escape / Android back /// is pressed while the popover is open. pub(super) fn close_menu_popover_on_escape( keys: Res>, popovers: Query>, backdrops: Query>, mut commands: Commands, ) { if !keys.just_pressed(KeyCode::Escape) || popovers.is_empty() { return; } for e in popovers.iter().chain(backdrops.iter()) { commands.entity(e).despawn(); } } /// Despawns the [`ModesPopover`] and its backdrop when the player taps /// anywhere outside the panel. pub(super) fn handle_modes_backdrop_click( interaction_query: Query<&Interaction, (With, Changed)>, popovers: Query>, backdrops: Query>, mut commands: Commands, ) { let pressed = interaction_query.iter().any(|i| *i == Interaction::Pressed); if !pressed { return; } for e in popovers.iter().chain(backdrops.iter()) { commands.entity(e).despawn(); } } /// Despawns the [`MenuPopover`] and its backdrop when the player taps /// anywhere outside the panel (i.e. the transparent backdrop is pressed). pub(super) fn handle_menu_backdrop_click( interaction_query: Query<&Interaction, (With, Changed)>, popovers: Query>, backdrops: Query>, mut commands: Commands, ) { let pressed = interaction_query.iter().any(|i| *i == Interaction::Pressed); if !pressed { return; } for e in popovers.iter().chain(backdrops.iter()) { commands.entity(e).despawn(); } } #[cfg(target_os = "android")] #[allow(clippy::too_many_arguments)] pub(super) fn toggle_hud_on_tap( mut touch_events: MessageReader, drag: Res, scrims: Query<(), With>, paused: Option>, mut tracker: ResMut, mut hud_vis: ResMut, buttons: Query<&Interaction, With>, mut game_consumed: ResMut, ) { use bevy::input::touch::TouchPhase; if !scrims.is_empty() || paused.is_some_and(|p| p.0) { // Drain buffered events so they don't replay in the frame after // the scrim despawns, which would trigger a spurious visibility // toggle as the resume/close button tap's Started+Ended pair // replays in the now-scrim-free frame. for _ in touch_events.read() {} tracker.start_pos = None; tracker.started_on_button = false; game_consumed.0 = false; return; } for event in touch_events.read() { match event.phase { TouchPhase::Started => { tracker.start_pos = Some(event.position); // Record whether the finger-down landed on a button so // the finger-up doesn't double-fire (toggle bar + press // button at the same time). tracker.started_on_button = buttons.iter().any(|i| *i != Interaction::None); } TouchPhase::Ended if drag.is_idle() => { // Also treat taps where game logic consumed the touch (e.g. // drawing from stock) as "on button" so they don't toggle // the HUD. The flag is set on TouchPhase::Started by the // input system that consumed the tap and must be cleared here // regardless of whether we toggle. let on_button = tracker.started_on_button || game_consumed.0; game_consumed.0 = false; if let Some(start) = tracker.start_pos.take() && !on_button && (event.position - start).length() < HUD_TAP_SLOP_PX { *hud_vis = match *hud_vis { HudVisibility::Visible => HudVisibility::Hidden, HudVisibility::Hidden => HudVisibility::Visible, }; } tracker.started_on_button = false; } // Moved: don't clear start_pos — Android fires Moved for normal // tap jitter, and the distance check at Ended already rejects // real drags. Clearing here would silently swallow tap toggles. TouchPhase::Canceled => { tracker.start_pos = None; tracker.started_on_button = false; game_consumed.0 = false; } _ => {} } } }