From a54201e97bc7b07b6c7d78c76210020a62907dd3 Mon Sep 17 00:00:00 2001 From: funman300 Date: Wed, 6 May 2026 00:32:58 +0000 Subject: [PATCH] feat(engine): click-outside-to-dismiss for read-only modals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a ScrimDismissible marker to ui_modal that opts a modal into the standard "click outside the card to close" gesture. The new dismiss_modal_on_scrim_click system fires on a left-mouse press whose cursor falls on the scrim and outside every ModalCard, then despawns the topmost dismissible scrim — Bevy's hierarchy despawn cascades to the card and its children. Marker design is opt-in per modal so destructive / state-mutating modals (Settings saves on close, Onboarding requires explicit acknowledgement, Pause / Forfeit / ConfirmNewGame need confirmed intent) don't lose work to an accidental scrim click. Three read-only modals opt in this round: - Stats — informational; press S or click outside to dismiss. - Achievements — read-only list. - Help — keyboard reference. Profile, Leaderboard, and Home will opt in the same way in a follow-up; they were left out to keep this commit's scope tight. The hit-test path uses each ModalCard's UiGlobalTransform + ComputedNode bounding box so stacked modals close cleanly: the topmost dismissible scrim is the only candidate per click. Tests spawn synthetic ComputedNodes (with bevy::sprite::BorderRect for the resolved-border slots Bevy's UI module re-exports) so the geometry hit-tests deterministically without running the full UI layout pipeline. Co-Authored-By: Claude Opus 4.7 (1M context) --- solitaire_engine/src/achievement_plugin.rs | 6 +- solitaire_engine/src/help_plugin.rs | 6 +- solitaire_engine/src/stats_plugin.rs | 5 +- solitaire_engine/src/ui_modal.rs | 323 +++++++++++++++++++++ 4 files changed, 337 insertions(+), 3 deletions(-) diff --git a/solitaire_engine/src/achievement_plugin.rs b/solitaire_engine/src/achievement_plugin.rs index a02858d..48d92d5 100644 --- a/solitaire_engine/src/achievement_plugin.rs +++ b/solitaire_engine/src/achievement_plugin.rs @@ -32,6 +32,7 @@ use crate::settings_plugin::{SettingsResource, SettingsStoragePath}; use crate::stats_plugin::{StatsResource, StatsUpdate}; use crate::ui_modal::{ spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant, + ScrimDismissible, }; use crate::ui_theme::{ ACCENT_PRIMARY, BORDER_SUBTLE, STATE_SUCCESS, TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY, @@ -473,7 +474,7 @@ fn spawn_achievements_screen( ..default() }; - spawn_modal(commands, AchievementsScreen, Z_MODAL_PANEL, |card| { + let scrim = spawn_modal(commands, AchievementsScreen, Z_MODAL_PANEL, |card| { spawn_modal_header(card, header, font_res); // Scrollable body — the achievements list grows to ~19 rows which @@ -577,6 +578,9 @@ fn spawn_achievements_screen( ); }); }); + // Achievements is a read-only list — clicking the scrim outside + // the card dismisses alongside the existing A / Done paths. + commands.entity(scrim).insert(ScrimDismissible); } fn format_reward(reward: Reward) -> String { diff --git a/solitaire_engine/src/help_plugin.rs b/solitaire_engine/src/help_plugin.rs index 0a98563..d6dedd4 100644 --- a/solitaire_engine/src/help_plugin.rs +++ b/solitaire_engine/src/help_plugin.rs @@ -11,6 +11,7 @@ use crate::events::HelpRequestEvent; use crate::font_plugin::FontResource; use crate::ui_modal::{ spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant, + ScrimDismissible, }; use crate::ui_theme::{ Z_MODAL_PANEL, BORDER_SUBTLE, RADIUS_SM, SPACE_2, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, @@ -209,7 +210,7 @@ fn spawn_help_screen(commands: &mut Commands, font_res: Option<&FontResource>) { ..default() }; - spawn_modal(commands, HelpScreen, Z_MODAL_PANEL, |card| { + let scrim = spawn_modal(commands, HelpScreen, Z_MODAL_PANEL, |card| { spawn_modal_header(card, "Controls", font_res); // Scrollable body — the controls reference is six sections totalling @@ -295,6 +296,9 @@ fn spawn_help_screen(commands: &mut Commands, font_res: Option<&FontResource>) { ); }); }); + // Help is read-only — clicking the scrim outside the card dismisses + // alongside the existing F1 / Esc / Done paths. + commands.entity(scrim).insert(ScrimDismissible); } #[cfg(test)] diff --git a/solitaire_engine/src/stats_plugin.rs b/solitaire_engine/src/stats_plugin.rs index e837815..ecb114f 100644 --- a/solitaire_engine/src/stats_plugin.rs +++ b/solitaire_engine/src/stats_plugin.rs @@ -29,6 +29,7 @@ use crate::resources::GameStateResource; use crate::time_attack_plugin::TimeAttackResource; use crate::ui_modal::{ spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant, + ScrimDismissible, }; use crate::ui_theme::{ ACCENT_PRIMARY, BORDER_SUBTLE, RADIUS_SM, STATE_INFO, STATE_WARNING, STREAK_MILESTONES, @@ -602,7 +603,7 @@ fn spawn_stats_screen( ..default() }; - spawn_modal(commands, StatsScreen, Z_MODAL_PANEL, |card| { + let scrim = spawn_modal(commands, StatsScreen, Z_MODAL_PANEL, |card| { spawn_modal_header(card, "Statistics", font_res); // Scrollable body — the Stats panel renders an 8-cell grid plus @@ -820,6 +821,8 @@ fn spawn_stats_screen( ); }); }); + // Stats is read-only — opt into click-outside-to-dismiss. + commands.entity(scrim).insert(ScrimDismissible); } /// Spawn one row of the "Per-mode bests" section: the mode label on the diff --git a/solitaire_engine/src/ui_modal.rs b/solitaire_engine/src/ui_modal.rs index 05ac682..a239e0d 100644 --- a/solitaire_engine/src/ui_modal.rs +++ b/solitaire_engine/src/ui_modal.rs @@ -49,6 +49,8 @@ //! ``` use bevy::prelude::*; +use bevy::ui::{ComputedNode, UiGlobalTransform}; +use bevy::window::PrimaryWindow; use solitaire_data::AnimSpeed; use crate::font_plugin::FontResource; @@ -74,6 +76,19 @@ pub struct ModalScrim; #[derive(Component, Debug)] pub struct ModalCard; +/// Marker on a [`ModalScrim`] entity opting that modal into the +/// click-outside-to-dismiss behaviour. +/// +/// When attached, [`dismiss_modal_on_scrim_click`] despawns the scrim +/// (and its hierarchy) on a left mouse press whose cursor falls on the +/// scrim and outside every [`ModalCard`]. Modals with destructive +/// actions or unsaved state (Settings, Onboarding, Pause, Forfeit +/// confirmation, Confirm New Game, etc.) intentionally do not opt in +/// — those require an explicit Cancel / Done / Confirm so an +/// accidental scrim click cannot lose work. +#[derive(Component, Debug, Clone, Copy)] +pub struct ScrimDismissible; + /// Marker on a header `Text` (`TYPE_HEADLINE` + `TEXT_PRIMARY`). #[derive(Component, Debug)] pub struct ModalHeader; @@ -474,6 +489,89 @@ pub fn advance_modal_enter( } } +// --------------------------------------------------------------------------- +// Click-outside-to-dismiss +// --------------------------------------------------------------------------- + +/// Returns `true` when the cursor at `cursor_logical` falls inside the +/// axis-aligned rectangle described by `centre_logical` (rectangle +/// centre, logical pixels) and `size_logical` (full width × height, +/// logical pixels). +/// +/// Pure helper extracted from [`dismiss_modal_on_scrim_click`] so the +/// hit-test decision can be tested without a real `Window` / +/// rendered UI tree. +#[inline] +fn cursor_is_inside_rect(cursor_logical: Vec2, centre_logical: Vec2, size_logical: Vec2) -> bool { + let half = size_logical * 0.5; + cursor_logical.x >= centre_logical.x - half.x + && cursor_logical.x <= centre_logical.x + half.x + && cursor_logical.y >= centre_logical.y - half.y + && cursor_logical.y <= centre_logical.y + half.y +} + +/// Despawns the topmost [`ScrimDismissible`] modal when the player +/// presses the left mouse button while the cursor is over the scrim +/// AND outside every [`ModalCard`]. Modals without the marker are +/// untouched, and existing dismiss paths (Cancel / Done / Esc / +/// dedicated buttons) keep working unchanged. +/// +/// **Topmost-only.** Stacked dismissible modals would otherwise all +/// dismiss together on a single click. The system processes at most +/// one entity per frame: the first match in the query is taken, +/// matching the click-handler convention used elsewhere in the engine. +/// Spawn order is the practical tiebreaker — dismissible modals are +/// rarely stacked, so picking any one is acceptable. +/// +/// **No same-frame dismissal.** `just_pressed` is true only on the +/// frame the button transitions to pressed. The press that *opens* a +/// modal happens on one frame; this system fires on a subsequent +/// press, so a modal can never be opened and dismissed in a single +/// click. +/// +/// `cards`/`scrims` queries read [`UiGlobalTransform`] (window-space +/// physical pixels) and [`ComputedNode`] (size in physical pixels); +/// both are converted to logical pixels via +/// `ComputedNode::inverse_scale_factor` so they can be compared with +/// the cursor position from `Window::cursor_position` (logical px). +#[allow(clippy::type_complexity)] +pub fn dismiss_modal_on_scrim_click( + mut commands: Commands, + mouse: Option>>, + windows: Query<&Window, With>, + scrims: Query, With)>, + cards: Query<(&UiGlobalTransform, &ComputedNode), With>, +) { + let Some(mouse) = mouse else { return }; + if !mouse.just_pressed(MouseButton::Left) { + return; + } + let Ok(window) = windows.single() else { + return; + }; + let Some(cursor) = window.cursor_position() else { + return; + }; + + // Topmost-only: bail after the first dismissible scrim. Stacked + // dismissible modals are not currently a real case, but this guard + // keeps the behaviour predictable if they ever arise. + let Some(scrim_entity) = scrims.iter().next() else { + return; + }; + + let cursor_over_card = cards.iter().any(|(transform, computed)| { + let inv = computed.inverse_scale_factor; + let size_logical = computed.size() * inv; + let centre_logical = transform.translation * inv; + cursor_is_inside_rect(cursor, centre_logical, size_logical) + }); + + if !cursor_over_card { + commands.entity(scrim_entity).despawn(); + } +} + /// Repaints every `ModalButton` on `Changed` so hover and /// press states are visible without each overlay registering its own /// paint system. @@ -515,6 +613,12 @@ impl Plugin for UiModalPlugin { Update, (apply_modal_enter_speed, advance_modal_enter, paint_modal_buttons).chain(), ); + // Click-outside-to-dismiss is independent of the open + // animation chain — it reads `just_pressed(Left)` and runs + // every tick. `just_pressed` is true only on the frame the + // button transitions to pressed, so the press that *opens* a + // modal cannot dismiss the same modal on the next frame. + app.add_systems(Update, dismiss_modal_on_scrim_click); } } @@ -668,5 +772,224 @@ mod tests { Duration::from_secs_f32(secs), )); } + + // ----------------------------------------------------------------------- + // Click-outside-to-dismiss + // ----------------------------------------------------------------------- + + /// Pure-helper hit-test: cursor inside the rectangle returns true. + #[test] + fn cursor_is_inside_rect_inside_returns_true() { + // 100×60 rectangle centred at (200, 150). + let centre = Vec2::new(200.0, 150.0); + let size = Vec2::new(100.0, 60.0); + // Centre + a few corners just inside. + assert!(cursor_is_inside_rect(centre, centre, size)); + assert!(cursor_is_inside_rect(Vec2::new(151.0, 121.0), centre, size)); + assert!(cursor_is_inside_rect(Vec2::new(249.0, 179.0), centre, size)); + } + + /// Pure-helper hit-test: cursor outside the rectangle returns false + /// on every side. + #[test] + fn cursor_is_inside_rect_outside_returns_false() { + let centre = Vec2::new(200.0, 150.0); + let size = Vec2::new(100.0, 60.0); + assert!(!cursor_is_inside_rect(Vec2::new(149.0, 150.0), centre, size)); // left + assert!(!cursor_is_inside_rect(Vec2::new(251.0, 150.0), centre, size)); // right + assert!(!cursor_is_inside_rect(Vec2::new(200.0, 119.0), centre, size)); // above + assert!(!cursor_is_inside_rect(Vec2::new(200.0, 181.0), centre, size)); // below + } + + /// Builds a headless app capable of running + /// `dismiss_modal_on_scrim_click`: registers the plugin, primes the + /// `ButtonInput` resource that `MinimalPlugins` + /// doesn't provide, and spawns a synthetic `PrimaryWindow`. + fn dismiss_test_app() -> App { + let mut app = App::new(); + app.add_plugins(MinimalPlugins).add_plugins(UiModalPlugin); + app.init_resource::>(); + // Synthetic primary window. `MinimalPlugins` doesn't ship + // `WindowPlugin`, so spawning the entity by hand is fine — + // `dismiss_modal_on_scrim_click` only reads `cursor_position` + // off it, not any platform-backed state. + app.world_mut().spawn(( + Window { + resolution: bevy::window::WindowResolution::new(800, 600), + ..default() + }, + PrimaryWindow, + )); + app + } + + /// Marker for synthetic-modal tests below. + #[derive(Component, Debug)] + struct DismissTestModal; + + /// Spawns a synthetic scrim + card pair pre-populated with + /// `ComputedNode` + `UiGlobalTransform` so the dismiss system has + /// real geometry to hit-test against without running the full UI + /// layout pipeline. `card_centre` and `card_size` are in physical + /// pixels (matching `ComputedNode.size`); the synthetic + /// `inverse_scale_factor` is 1.0 so logical == physical. + fn spawn_synthetic_modal( + app: &mut App, + dismissible: bool, + card_centre: Vec2, + card_size: Vec2, + ) -> Entity { + let world = app.world_mut(); + let mut scrim = world.spawn((DismissTestModal, ModalScrim)); + if dismissible { + scrim.insert(ScrimDismissible); + } + let scrim_entity = scrim.id(); + let card_entity = world + .spawn(( + ModalCard, + { + let mut node = ComputedNode { + stack_index: 0, + size: card_size, + content_size: card_size, + scrollbar_size: Vec2::ZERO, + scroll_position: Vec2::ZERO, + outline_width: 0.0, + outline_offset: 0.0, + unrounded_size: card_size, + border: bevy::sprite::BorderRect::default(), + border_radius: bevy::ui::ResolvedBorderRadius::default(), + padding: bevy::sprite::BorderRect::default(), + inverse_scale_factor: 1.0, + }; + // `is_empty` guard inside Bevy treats zero-size + // nodes as inert; we always pass a non-zero size. + node.size = card_size; + node + }, + UiGlobalTransform::from_translation(card_centre), + )) + .id(); + // Parent the card to the scrim so a `commands.entity(scrim).despawn()` + // also takes the card down — matching the real `spawn_modal` hierarchy. + world.entity_mut(scrim_entity).add_child(card_entity); + scrim_entity + } + + /// Sets the synthetic primary window's cursor position (logical px, + /// since we use `inverse_scale_factor = 1.0` everywhere in tests). + fn set_cursor(app: &mut App, position: Option) { + let world = app.world_mut(); + let mut q = world.query_filtered::<&mut Window, With>(); + let mut window = q.single_mut(world).expect("primary window"); + window.set_cursor_position(position); + } + + /// Drives a fresh `just_pressed(Left)` for the next `app.update()`. + /// `MinimalPlugins` doesn't run the input clear pass, so we mark + /// the clear by hand on the resource between presses. + fn press_left_mouse(app: &mut App) { + let mut input = app + .world_mut() + .resource_mut::>(); + input.clear(); + input.press(MouseButton::Left); + } + + /// Click outside the card on a dismissible modal despawns it. + #[test] + fn dismissible_scrim_despawns_on_scrim_click_outside_card() { + let mut app = dismiss_test_app(); + let scrim = spawn_synthetic_modal( + &mut app, + /* dismissible: */ true, + Vec2::new(400.0, 300.0), + Vec2::new(200.0, 100.0), + ); + // Cursor far outside the card — top-left corner of the window. + set_cursor(&mut app, Some(Vec2::new(50.0, 50.0))); + press_left_mouse(&mut app); + app.update(); + + assert!( + app.world().get_entity(scrim).is_err(), + "dismissible scrim should be despawned on a scrim-area click" + ); + } + + /// Click *inside* the card area must NOT dismiss the modal — the + /// player intends to interact with the card content. + #[test] + fn dismissible_scrim_does_not_despawn_on_card_click() { + let mut app = dismiss_test_app(); + let scrim = spawn_synthetic_modal( + &mut app, + /* dismissible: */ true, + Vec2::new(400.0, 300.0), + Vec2::new(200.0, 100.0), + ); + // Cursor at the card centre — definitely inside. + set_cursor(&mut app, Some(Vec2::new(400.0, 300.0))); + press_left_mouse(&mut app); + app.update(); + + assert!( + app.world().get_entity(scrim).is_ok(), + "click inside the card must not dismiss the modal" + ); + } + + /// Modals without `ScrimDismissible` ignore scrim clicks entirely. + /// Settings, Onboarding, Pause, etc. rely on this opt-out. + #[test] + fn non_dismissible_scrim_does_not_despawn_on_scrim_click() { + let mut app = dismiss_test_app(); + let scrim = spawn_synthetic_modal( + &mut app, + /* dismissible: */ false, + Vec2::new(400.0, 300.0), + Vec2::new(200.0, 100.0), + ); + set_cursor(&mut app, Some(Vec2::new(50.0, 50.0))); + press_left_mouse(&mut app); + app.update(); + + assert!( + app.world().get_entity(scrim).is_ok(), + "non-dismissible scrim must survive a scrim-area click" + ); + } + + /// Stacked dismissible modals: one click despawns at most one + /// modal per frame (the one the query yields first). The other + /// stays put until the next press. + #[test] + fn stacked_modals_dismiss_at_most_one_per_click() { + let mut app = dismiss_test_app(); + let a = spawn_synthetic_modal( + &mut app, + /* dismissible: */ true, + Vec2::new(400.0, 300.0), + Vec2::new(200.0, 100.0), + ); + let b = spawn_synthetic_modal( + &mut app, + /* dismissible: */ true, + Vec2::new(400.0, 300.0), + Vec2::new(200.0, 100.0), + ); + // Cursor outside both cards. + set_cursor(&mut app, Some(Vec2::new(50.0, 50.0))); + press_left_mouse(&mut app); + app.update(); + + let a_alive = app.world().get_entity(a).is_ok(); + let b_alive = app.world().get_entity(b).is_ok(); + assert!( + a_alive ^ b_alive, + "exactly one of the two stacked dismissible modals should remain" + ); + } }