diff --git a/solitaire_engine/src/profile_plugin.rs b/solitaire_engine/src/profile_plugin.rs index 73e73ac..549dbbf 100644 --- a/solitaire_engine/src/profile_plugin.rs +++ b/solitaire_engine/src/profile_plugin.rs @@ -4,11 +4,11 @@ //! summary in a single scrollable panel. Spawned on the first `P` keypress and //! despawned on the second. -use bevy::input::mouse::{MouseScrollUnit, MouseWheel}; use bevy::input::ButtonInput; +use bevy::input::mouse::{MouseScrollUnit, MouseWheel}; use bevy::prelude::*; use chrono::{Duration, Local, NaiveDate}; -use solitaire_core::achievement::{achievement_by_id, ALL_ACHIEVEMENTS}; +use solitaire_core::achievement::{ALL_ACHIEVEMENTS, achievement_by_id}; use solitaire_data::SyncBackend; use crate::achievement_plugin::AchievementsResource; @@ -18,10 +18,10 @@ use crate::font_plugin::FontResource; use crate::progress_plugin::ProgressResource; use crate::resources::{SyncStatus, SyncStatusResource}; use crate::settings_plugin::SettingsResource; -use crate::stats_plugin::{format_fastest_win, format_win_rate, StatsResource}; +use crate::stats_plugin::{StatsResource, format_fastest_win, format_win_rate}; use crate::ui_modal::{ - spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant, - ScrimDismissible, + ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions, + spawn_modal_button, spawn_modal_header, }; use crate::ui_theme::{ ACCENT_PRIMARY, BG_ELEVATED, BORDER_STRONG, SPACE_1, STATE_INFO, STATE_SUCCESS, TEXT_PRIMARY, @@ -31,8 +31,8 @@ use crate::ui_theme::{ /// Number of days surfaced in the daily-challenge calendar row. /// /// 14 = trailing two weeks ending today. At ~12 px per dot with a 6 px gap -/// the row is ~246 px wide — well inside the 360 px minimum modal width on -/// the smallest supported window (800 px). +/// the row is ~246 px wide — comfortably inside the responsive modal card +/// even on narrow phone layouts. const CALENDAR_DAYS: usize = 14; /// Diameter of each calendar dot, in pixels. @@ -146,6 +146,7 @@ fn toggle_profile_screen( font_res: Option>, avatar: Option>, screens: Query>, + scrims: Query<(), With>, ) { let button_clicked = requests.read().count() > 0; let p_pressed = keys.just_pressed(KeyCode::KeyP); @@ -161,6 +162,9 @@ fn toggle_profile_screen( if !want_open && !want_close { return; } + if want_open && !scrims.is_empty() { + return; + } if let Ok(entity) = screens.single() { commands.entity(entity).despawn(); } else { @@ -257,7 +261,10 @@ fn spawn_profile_screen( flex_direction: FlexDirection::Row, align_items: AlignItems::Center, column_gap: Val::Px(10.0), - margin: UiRect { bottom: Val::Px(4.0), ..default() }, + margin: UiRect { + bottom: Val::Px(4.0), + ..default() + }, ..default() }) .with_children(|row| { @@ -275,7 +282,13 @@ fn spawn_profile_screen( )); } else { // Initials fallback: coloured disc with the first letter. - let initial = username.chars().next().unwrap_or('?').to_uppercase().next().unwrap_or('?'); + let initial = username + .chars() + .next() + .unwrap_or('?') + .to_uppercase() + .next() + .unwrap_or('?'); row.spawn(( Node { width: Val::Px(SIZE), @@ -335,7 +348,10 @@ fn spawn_profile_screen( let pct = if xp_span == 0 { 100u64 } else { - xp_done.saturating_mul(100).checked_div(xp_span).unwrap_or(100) + xp_done + .saturating_mul(100) + .checked_div(xp_span) + .unwrap_or(100) }; body.spawn(( Text::new(format!( @@ -378,7 +394,10 @@ fn spawn_profile_screen( let records = &ar.0; let unlocked_count = records.iter().filter(|r| r.unlocked).count(); body.spawn(( - Text::new(format!("{unlocked_count} / {} unlocked", ALL_ACHIEVEMENTS.len())), + Text::new(format!( + "{unlocked_count} / {} unlocked", + ALL_ACHIEVEMENTS.len() + )), font_row.clone(), TextColor(ACCENT_PRIMARY), )); @@ -533,7 +552,11 @@ fn spawn_daily_calendar( // accent border) regardless of completion; past days use a // subtle border so the row reads as a row of pills, not a // strip of bare squares. - let border_color = if is_today { ACCENT_PRIMARY } else { BORDER_STRONG }; + let border_color = if is_today { + ACCENT_PRIMARY + } else { + BORDER_STRONG + }; let border_width = if is_today { 2.0 } else { 0.0 }; row.spawn(( DailyCalendarDot { @@ -569,9 +592,7 @@ fn calendar_dot_color(completed: bool) -> Color { fn sync_info(backend: &SyncBackend) -> (&'static str, String) { match backend { SyncBackend::Local => ("Local", "—".to_string()), - SyncBackend::SolitaireServer { username, .. } => { - ("Solitaire Server", username.clone()) - } + SyncBackend::SolitaireServer { username, .. } => ("Solitaire Server", username.clone()), } } @@ -641,6 +662,25 @@ mod tests { ); } + #[test] + fn pressing_p_does_not_stack_profile_over_existing_modal_scrim() { + let mut app = headless_app(); + app.world_mut().spawn(ModalScrim); + app.world_mut() + .resource_mut::>() + .press(KeyCode::KeyP); + app.update(); + + assert_eq!( + app.world_mut() + .query::<&ProfileScreen>() + .iter(app.world()) + .count(), + 0, + "Profile should not open when another modal scrim already exists" + ); + } + #[test] fn profile_modal_body_is_scrollable() { let mut app = headless_app(); diff --git a/solitaire_engine/src/ui_modal.rs b/solitaire_engine/src/ui_modal.rs index 3ed1796..91c9d3c 100644 --- a/solitaire_engine/src/ui_modal.rs +++ b/solitaire_engine/src/ui_modal.rs @@ -58,11 +58,11 @@ use crate::font_plugin::FontResource; use crate::platform::SHOW_KEYBOARD_ACCELERATORS; 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, + 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, + scaled_duration, }; // --------------------------------------------------------------------------- @@ -193,7 +193,10 @@ where .spawn(( plugin_marker, ModalScrim, - ModalEntering { elapsed: 0.0, duration }, + ModalEntering { + elapsed: 0.0, + duration, + }, Node { position_type: PositionType::Absolute, left: Val::Px(0.0), @@ -227,11 +230,11 @@ where Node { flex_direction: FlexDirection::Column, row_gap: VAL_SPACE_4, + width: Val::Percent(90.0), 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() }, @@ -295,12 +298,7 @@ pub fn spawn_modal_body_text( font_size: TYPE_BODY_LG, ..default() }; - parent.spawn(( - ModalBody, - Text::new(text.into()), - font, - TextColor(color), - )); + parent.spawn((ModalBody, Text::new(text.into()), font, TextColor(color))); } /// Spawns the bottom actions row — flex-row with primary right-aligned. @@ -343,7 +341,11 @@ pub fn spawn_modal_button( variant: ButtonVariant, font_res: Option<&FontResource>, ) { - let hotkey = if SHOW_KEYBOARD_ACCELERATORS { hotkey } else { None }; + let hotkey = if SHOW_KEYBOARD_ACCELERATORS { + hotkey + } else { + None + }; let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default(); let font_label = TextFont { font: font_handle.clone(), @@ -517,7 +519,10 @@ pub fn apply_modal_enter_speed( pub fn advance_modal_enter( time: Res