//! Reusable modal-overlay primitive: a uniform scrim + centred card with //! header / body / actions slots, plus a button variant system that maps //! to the design tokens in [`crate::ui_theme`]. //! //! The audit found that the 11 existing overlay screens used three //! different visual styles (card-centred dialog, bare full-screen, and //! one outlier) with scrim alpha drift between 0.60 and 0.92. Every //! overlay built its own root `Node` and its own colour decisions. //! //! This module collapses all of that into a single helper. Each //! conversion commit replaces an overlay's bespoke spawn function with //! a call to [`spawn_modal`] plus body content built in a closure. //! //! # Example //! //! ```ignore //! spawn_modal( //! &mut commands, //! ConfirmNewGameScreen, //! ui_theme::Z_MODAL_PANEL, //! |card| { //! spawn_modal_header(card, "Abandon current game?", font_res); //! spawn_modal_body_text( //! card, //! "Your progress will be lost.", //! ui_theme::TEXT_SECONDARY, //! font_res, //! ); //! spawn_modal_actions(card, |actions| { //! spawn_modal_button( //! actions, //! CancelButton, //! "Cancel", //! Some("Esc"), //! ButtonVariant::Secondary, //! font_res, //! ); //! spawn_modal_button( //! actions, //! ConfirmButton, //! "New game", //! Some("Y"), //! ButtonVariant::Primary, //! font_res, //! ); //! }); //! }, //! ); //! ``` use bevy::prelude::*; use bevy::ui::{ComputedNode, UiGlobalTransform}; use bevy::window::PrimaryWindow; use solitaire_data::AnimSpeed; use crate::font_plugin::FontResource; use crate::settings_plugin::SettingsResource; use crate::ui_theme::{ scaled_duration, ACCENT_PRIMARY, ACCENT_PRIMARY_HOVER, ACCENT_SECONDARY, BG_BASE, BG_ELEVATED, BG_ELEVATED_HI, BG_ELEVATED_PRESSED, BG_ELEVATED_TOP, BORDER_STRONG, BORDER_SUBTLE, HighContrastBorder, MOTION_MODAL_SECS, RADIUS_LG, RADIUS_MD, SCRIM, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, VAL_SPACE_5, }; // --------------------------------------------------------------------------- // Marker components — let click handlers query / paint systems target / // despawn helpers find every part of a standard modal. // --------------------------------------------------------------------------- /// Marker on the full-screen scrim entity. Carries `BackgroundColor` /// `SCRIM` and the modal's z-index. #[derive(Component, Debug)] pub struct ModalScrim; /// Marker on the centred card entity. Child of the scrim. #[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; /// Marker on a body paragraph `Text`. #[derive(Component, Debug)] pub struct ModalBody; /// Marker on the actions row (flex-row, justify-end). #[derive(Component, Debug)] pub struct ModalActions; /// Marker on a button inside a modal. Carries its variant so the paint /// system can recolour it on hover / press. #[derive(Component, Debug, Clone, Copy)] pub struct ModalButton(pub ButtonVariant); /// Drives the modal open animation. Inserted on the scrim entity by /// [`spawn_modal`]; advanced and removed by [`advance_modal_enter`] once /// `elapsed >= duration`. /// /// During the animation the scrim's `BackgroundColor` alpha lerps from /// 0 → `SCRIM`'s native alpha and the card's `Transform` scale lerps from /// `MODAL_ENTER_START_SCALE` → 1.0. Under `AnimSpeed::Instant`, /// `duration == 0.0` and the system snaps everything to the final state on /// the first tick so no half-state is ever shown. #[derive(Component, Debug, Clone, Copy)] pub struct ModalEntering { /// Seconds elapsed since the animation started. pub elapsed: f32, /// Total duration in seconds. May be zero (`AnimSpeed::Instant`). pub duration: f32, } /// Initial card scale at `t = 0` for the modal open animation. The card /// grows from this value to `1.0` over `MOTION_MODAL_SECS`. pub const MODAL_ENTER_START_SCALE: f32 = 0.96; // --------------------------------------------------------------------------- // Button variants — three rungs of emphasis. A single overlay should have // at most one Primary; Secondary and Tertiary fill out the rest. // --------------------------------------------------------------------------- /// Visual emphasis tier applied to a [`ModalButton`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ButtonVariant { /// Cyan CTA (`ACCENT_PRIMARY`) — Confirm, Play Again, Resume. One per /// modal; right-aligned in the actions row. Primary, /// Mid-emphasis — Cancel, Close, Done. Secondary, /// Low-emphasis — Quit, secondary navigation. Tertiary, } // --------------------------------------------------------------------------- // Spawn helpers // --------------------------------------------------------------------------- /// Spawns a full-screen scrim and a centred card. The closure populates /// the card's children — typically `spawn_modal_header`, /// `spawn_modal_body_text`, and `spawn_modal_actions`. /// /// Returns the scrim entity so callers can despawn the whole modal with /// a single `commands.entity(scrim).despawn()` call (Bevy's hierarchy /// despawn cascades to the card and its descendants). /// /// `plugin_marker` is the overlay's plugin-specific marker /// (`ConfirmNewGameScreen`, `HelpScreen`, etc.) so plugin click handlers /// can find their own modal. /// /// **Open animation.** The scrim is spawned with alpha 0 and the card /// with `Transform::scale = MODAL_ENTER_START_SCALE`; a [`ModalEntering`] /// component on the scrim drives the scrim alpha → `SCRIM`'s native /// alpha and the card scale → 1.0 lerps via [`advance_modal_enter`]. The /// duration is `scaled_duration(MOTION_MODAL_SECS, settings.animation_speed)` /// so the open animation respects the player's `AnimSpeed` preference; /// under `AnimSpeed::Instant` the duration is zero and the very first /// tick snaps to the final state. The animate-OUT path is intentionally /// out of scope — modals despawn instantly. pub fn spawn_modal( commands: &mut Commands, plugin_marker: M, z_panel: i32, build_card: F, ) -> Entity where F: FnOnce(&mut ChildSpawnerCommands), { // The duration here is the `AnimSpeed::Normal` baseline; the // `apply_modal_enter_speed` system rescales it (or zeroes it for // `AnimSpeed::Instant`) on the first frame after spawn by reading // `SettingsResource`. Doing it that way keeps `spawn_modal` a free // function with no resource dependencies — every existing call site // (~11 plugins) continues to work without a signature change. let duration = MOTION_MODAL_SECS; let initial_scrim = scrim_with_alpha(0.0); commands .spawn(( plugin_marker, ModalScrim, ModalEntering { elapsed: 0.0, duration }, 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), justify_content: JustifyContent::Center, align_items: AlignItems::Center, ..default() }, BackgroundColor(initial_scrim), // GlobalZIndex pins this root modal at `z_panel` regardless // of any sibling stacking-context quirks in Bevy 0.18 — the // ordinary `ZIndex` is preserved as a fallback for nested // contexts. Without GlobalZIndex, a confirmation modal at // `Z_PAUSE_DIALOG` (225) was rendering *behind* the pause // modal at `Z_PAUSE` (220) in some scenes. GlobalZIndex(z_panel), ZIndex(z_panel), )) .with_children(|root| { root.spawn(( ModalCard, Node { flex_direction: FlexDirection::Column, row_gap: VAL_SPACE_4, padding: UiRect::all(VAL_SPACE_5), border: UiRect::all(Val::Px(1.0)), border_radius: BorderRadius::all(Val::Px(RADIUS_LG)), max_width: Val::Px(720.0), min_width: Val::Px(360.0), align_items: AlignItems::Stretch, ..default() }, // Card UI nodes carry a Transform; the open animation // lerps `scale` from MODAL_ENTER_START_SCALE → 1.0. Transform::from_scale(Vec3::splat(MODAL_ENTER_START_SCALE)), BackgroundColor(BG_ELEVATED), BorderColor::all(BORDER_STRONG), // Honour `Settings::high_contrast_mode`: under HC the // border boosts from `BORDER_STRONG` (#505050) to // `BORDER_SUBTLE_HC` (#a0a0a0) so the modal panel // edge stays clearly visible against the scrim and // surface beneath. `update_high_contrast_borders` in // `settings_plugin` does the per-frame swap. HighContrastBorder::with_default(BORDER_STRONG), )) .with_children(build_card); }) .id() } /// Returns `SCRIM` with its alpha multiplied by `factor` (0.0–1.0). The /// open animation lerps `factor` from 0 → 1 over the modal-enter /// duration so the scrim fades in instead of popping. fn scrim_with_alpha(factor: f32) -> Color { let mut c = SCRIM; let target = SCRIM.alpha(); c.set_alpha(target * factor.clamp(0.0, 1.0)); c } /// Spawns the standard modal header — `TYPE_HEADLINE` + `TEXT_PRIMARY`. pub fn spawn_modal_header( parent: &mut ChildSpawnerCommands, title: impl Into, font_res: Option<&FontResource>, ) { let font = TextFont { font: font_res.map(|f| f.0.clone()).unwrap_or_default(), font_size: TYPE_HEADLINE, ..default() }; parent.spawn(( ModalHeader, Text::new(title.into()), font, TextColor(TEXT_PRIMARY), )); } /// Spawns a body paragraph at `TYPE_BODY_LG`. Pass `TEXT_PRIMARY` for /// primary copy, `TEXT_SECONDARY` for caption-style supporting copy. pub fn spawn_modal_body_text( parent: &mut ChildSpawnerCommands, text: impl Into, color: Color, font_res: Option<&FontResource>, ) { let font = TextFont { font: font_res.map(|f| f.0.clone()).unwrap_or_default(), font_size: TYPE_BODY_LG, ..default() }; parent.spawn(( ModalBody, Text::new(text.into()), font, TextColor(color), )); } /// Spawns the bottom actions row — flex-row with primary right-aligned. /// The closure populates the row's buttons via `spawn_modal_button`. pub fn spawn_modal_actions(parent: &mut ChildSpawnerCommands, build_buttons: F) where F: FnOnce(&mut ChildSpawnerCommands), { parent .spawn(( ModalActions, Node { flex_direction: FlexDirection::Row, column_gap: VAL_SPACE_3, justify_content: JustifyContent::FlexEnd, margin: UiRect::top(VAL_SPACE_2), ..default() }, )) .with_children(build_buttons); } /// Spawns a real `Button` entity with consistent geometry, colours, and /// optional hotkey-hint chip. /// /// `marker` is the click-handler-targeting component (e.g. /// `ConfirmYesButton`); plugin systems query for it on /// `Changed` to detect clicks. pub fn spawn_modal_button( parent: &mut ChildSpawnerCommands, marker: M, label: impl Into, hotkey: Option<&'static str>, variant: ButtonVariant, font_res: Option<&FontResource>, ) { let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default(); let font_label = TextFont { font: font_handle.clone(), font_size: TYPE_BODY_LG, ..default() }; let font_caption = TextFont { font: font_handle, font_size: TYPE_CAPTION, ..default() }; let label_color = match variant { // Primary buttons sit on the brick-red accent — `BG_BASE` text on // top reads well and passes AAA contrast against `#a54242`. ButtonVariant::Primary => BG_BASE, ButtonVariant::Secondary | ButtonVariant::Tertiary => TEXT_PRIMARY, }; let caption_color = match variant { // Muted near-black on the red Primary so the hotkey chip reads // as a secondary detail without disappearing. Deliberately a // pure-black-at-alpha rather than `BG_BASE.with_alpha(...)`: // `BG_BASE` is `#151515` (not 0,0,0), so the alpha-on-accent // composite would tint slightly off from intended here. ButtonVariant::Primary => Color::srgba(0.0, 0.0, 0.0, 0.55), ButtonVariant::Secondary | ButtonVariant::Tertiary => TEXT_SECONDARY, }; parent .spawn(( marker, ModalButton(variant), Button, Node { padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_3), justify_content: JustifyContent::Center, align_items: AlignItems::Center, column_gap: VAL_SPACE_2, border: UiRect::all(Val::Px(1.0)), border_radius: BorderRadius::all(Val::Px(RADIUS_MD)), ..default() }, BackgroundColor(idle_bg(variant)), BorderColor::all(BORDER_SUBTLE), HighContrastBorder::with_default(BORDER_SUBTLE), )) .with_children(|b| { b.spawn((Text::new(label.into()), font_label, TextColor(label_color))); if let Some(key) = hotkey { b.spawn((Text::new(key), font_caption, TextColor(caption_color))); } }); } // --------------------------------------------------------------------------- // Helpers + paint system // --------------------------------------------------------------------------- /// Idle-state background colour for a button variant. fn idle_bg(variant: ButtonVariant) -> Color { match variant { ButtonVariant::Primary => ACCENT_PRIMARY, // Secondary sits at a higher elevation than Tertiary at idle so // the hierarchy reads even before hover; the paint system then // bumps each variant one rung on hover. ButtonVariant::Secondary => BG_ELEVATED_HI, ButtonVariant::Tertiary => BG_ELEVATED, } } /// Hover-state background colour. Each variant steps up one rung from /// its idle colour so idle / hover / pressed are visually distinct. fn hover_bg(variant: ButtonVariant) -> Color { match variant { ButtonVariant::Primary => ACCENT_PRIMARY_HOVER, ButtonVariant::Secondary => BG_ELEVATED_TOP, ButtonVariant::Tertiary => BG_ELEVATED_HI, } } /// Pressed-state background colour. Primary swaps to the lavender /// secondary accent (`ACCENT_SECONDARY`, `#e1a3ee`) for a moment of /// celebration; Secondary darkens to the base elevation; Tertiary /// darkens further to `BG_ELEVATED_PRESSED`. fn pressed_bg(variant: ButtonVariant) -> Color { match variant { ButtonVariant::Primary => ACCENT_SECONDARY, ButtonVariant::Secondary => BG_ELEVATED, ButtonVariant::Tertiary => BG_ELEVATED_PRESSED, } } // --------------------------------------------------------------------------- // Modal open animation // --------------------------------------------------------------------------- /// Patches the `ModalEntering::duration` of newly-spawned modals against /// the player's `AnimSpeed` setting. Runs on `Added` so it /// only fires once per modal, immediately after [`spawn_modal`] inserts /// the component. /// /// Under `AnimSpeed::Instant` this drops the duration to 0; the next /// frame [`advance_modal_enter`] sees `t >= 1.0` and snaps the modal to /// its final state, so no half-state is ever shown. pub fn apply_modal_enter_speed( settings: Option>, mut q: Query<&mut ModalEntering, Added>, ) { let speed = settings .as_ref() .map_or(AnimSpeed::Normal, |s| s.0.animation_speed); for mut entering in &mut q { entering.duration = scaled_duration(MOTION_MODAL_SECS, speed); } } /// Drives the modal open animation. For each scrim entity carrying /// [`ModalEntering`] this system increments `elapsed`, computes /// `t = (elapsed / duration).clamp(0, 1)`, applies an ease-out /// (`t * (2 - t)`) curve to both the scrim alpha and the card scale, /// and removes the component plus any leftover transform offset once /// `t >= 1.0`. /// /// The card scale is patched on the modal's `ModalCard` child rather /// than on the scrim — the scrim is full-window and any scale on it /// would visibly squash the layout. The card carries its own /// `Transform`, started at `Vec3::splat(MODAL_ENTER_START_SCALE)` by /// [`spawn_modal`]. pub fn advance_modal_enter( time: Res