feat(engine): click-outside-to-dismiss for read-only modals
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) <noreply@anthropic.com>
This commit is contained in:
@@ -32,6 +32,7 @@ use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
|
|||||||
use crate::stats_plugin::{StatsResource, StatsUpdate};
|
use crate::stats_plugin::{StatsResource, StatsUpdate};
|
||||||
use crate::ui_modal::{
|
use crate::ui_modal::{
|
||||||
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
||||||
|
ScrimDismissible,
|
||||||
};
|
};
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
ACCENT_PRIMARY, BORDER_SUBTLE, STATE_SUCCESS, TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY,
|
ACCENT_PRIMARY, BORDER_SUBTLE, STATE_SUCCESS, TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY,
|
||||||
@@ -473,7 +474,7 @@ fn spawn_achievements_screen(
|
|||||||
..default()
|
..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);
|
spawn_modal_header(card, header, font_res);
|
||||||
|
|
||||||
// Scrollable body — the achievements list grows to ~19 rows which
|
// 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 {
|
fn format_reward(reward: Reward) -> String {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use crate::events::HelpRequestEvent;
|
|||||||
use crate::font_plugin::FontResource;
|
use crate::font_plugin::FontResource;
|
||||||
use crate::ui_modal::{
|
use crate::ui_modal::{
|
||||||
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
||||||
|
ScrimDismissible,
|
||||||
};
|
};
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
Z_MODAL_PANEL, BORDER_SUBTLE, RADIUS_SM, SPACE_2, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
|
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()
|
..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);
|
spawn_modal_header(card, "Controls", font_res);
|
||||||
|
|
||||||
// Scrollable body — the controls reference is six sections totalling
|
// 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)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ use crate::resources::GameStateResource;
|
|||||||
use crate::time_attack_plugin::TimeAttackResource;
|
use crate::time_attack_plugin::TimeAttackResource;
|
||||||
use crate::ui_modal::{
|
use crate::ui_modal::{
|
||||||
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
||||||
|
ScrimDismissible,
|
||||||
};
|
};
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
ACCENT_PRIMARY, BORDER_SUBTLE, RADIUS_SM, STATE_INFO, STATE_WARNING, STREAK_MILESTONES,
|
ACCENT_PRIMARY, BORDER_SUBTLE, RADIUS_SM, STATE_INFO, STATE_WARNING, STREAK_MILESTONES,
|
||||||
@@ -602,7 +603,7 @@ fn spawn_stats_screen(
|
|||||||
..default()
|
..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);
|
spawn_modal_header(card, "Statistics", font_res);
|
||||||
|
|
||||||
// Scrollable body — the Stats panel renders an 8-cell grid plus
|
// 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
|
/// Spawn one row of the "Per-mode bests" section: the mode label on the
|
||||||
|
|||||||
@@ -49,6 +49,8 @@
|
|||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
use bevy::ui::{ComputedNode, UiGlobalTransform};
|
||||||
|
use bevy::window::PrimaryWindow;
|
||||||
use solitaire_data::AnimSpeed;
|
use solitaire_data::AnimSpeed;
|
||||||
|
|
||||||
use crate::font_plugin::FontResource;
|
use crate::font_plugin::FontResource;
|
||||||
@@ -74,6 +76,19 @@ pub struct ModalScrim;
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct ModalCard;
|
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`).
|
/// Marker on a header `Text` (`TYPE_HEADLINE` + `TEXT_PRIMARY`).
|
||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct ModalHeader;
|
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<Res<ButtonInput<MouseButton>>>,
|
||||||
|
windows: Query<&Window, With<PrimaryWindow>>,
|
||||||
|
scrims: Query<Entity, (With<ModalScrim>, With<ScrimDismissible>)>,
|
||||||
|
cards: Query<(&UiGlobalTransform, &ComputedNode), With<ModalCard>>,
|
||||||
|
) {
|
||||||
|
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<Interaction>` so hover and
|
/// Repaints every `ModalButton` on `Changed<Interaction>` so hover and
|
||||||
/// press states are visible without each overlay registering its own
|
/// press states are visible without each overlay registering its own
|
||||||
/// paint system.
|
/// paint system.
|
||||||
@@ -515,6 +613,12 @@ impl Plugin for UiModalPlugin {
|
|||||||
Update,
|
Update,
|
||||||
(apply_modal_enter_speed, advance_modal_enter, paint_modal_buttons).chain(),
|
(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),
|
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<MouseButton>` 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::<ButtonInput<MouseButton>>();
|
||||||
|
// 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<Vec2>) {
|
||||||
|
let world = app.world_mut();
|
||||||
|
let mut q = world.query_filtered::<&mut Window, With<PrimaryWindow>>();
|
||||||
|
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::<ButtonInput<MouseButton>>();
|
||||||
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user