diff --git a/solitaire_engine/src/achievement_plugin.rs b/solitaire_engine/src/achievement_plugin.rs index bf5150f..a02858d 100644 --- a/solitaire_engine/src/achievement_plugin.rs +++ b/solitaire_engine/src/achievement_plugin.rs @@ -7,6 +7,7 @@ use std::path::PathBuf; +use bevy::input::mouse::{MouseScrollUnit, MouseWheel}; use bevy::prelude::*; use chrono::{Local, Timelike, Utc}; use solitaire_core::achievement::{ @@ -48,6 +49,19 @@ pub struct AchievementsScreen; #[derive(Component, Debug)] pub struct AchievementRow; +/// Marker on the scrollable body Node inside the Achievements modal. +/// +/// The Achievements list can grow to ~19 rows which overflows the modal at +/// the 800x600 minimum window. This marker tags the inner container that +/// carries `Overflow::scroll_y()` plus a `max_height` constraint so the +/// content scrolls instead of clipping. Mirrors the +/// `SettingsPanelScrollable` pattern in `settings_plugin`. +/// +/// `scroll_achievements_panel` reads this marker to route mouse-wheel +/// events into the body's `ScrollPosition`. +#[derive(Component, Debug)] +pub struct AchievementsScrollable; + /// All per-player achievement records (one per known achievement). #[derive(Resource, Debug, Clone)] pub struct AchievementsResource(pub Vec); @@ -96,6 +110,11 @@ impl Plugin for AchievementPlugin { .add_message::() .add_message::() .add_message::() + // `MouseWheel` is emitted by Bevy's input plugin under + // `DefaultPlugins`; register it explicitly so the + // achievements-scroll system also runs cleanly under + // `MinimalPlugins` in tests. + .add_message::() // Run after GameMutation (so GameWonEvent is available), after // StatsUpdate (so stats reflect this win), and after ProgressUpdate // (so daily_challenge_streak is up to date for daily_devotee). @@ -118,6 +137,7 @@ impl Plugin for AchievementPlugin { ) .add_systems(Update, toggle_achievements_screen) .add_systems(Update, handle_achievements_close_button) + .add_systems(Update, scroll_achievements_panel) // Event-driven unlock: observe `ReplayPlaybackState` and unlock // `cinephile` the first time playback runs to natural completion. // Reads the resource via `Option>` so headless tests that @@ -395,6 +415,38 @@ fn handle_achievements_close_button( } } +/// Routes mouse-wheel events into the Achievements modal's scrollable body +/// while the panel is open. +/// +/// `offset_y` increases downward (0 = top). Scrolling down (`ev.y < 0`) adds +/// to the offset; scrolling up subtracts. Clamped to >= 0 so the viewport +/// never scrolls past the top. Mirrors `scroll_settings_panel` in +/// `settings_plugin`. The query is empty when no `AchievementsScrollable` +/// is in the world (modal closed) so this is a no-op outside the open +/// state without an explicit gate resource. +fn scroll_achievements_panel( + mut scroll_evr: MessageReader, + mut scrollables: Query<&mut ScrollPosition, With>, +) { + if scrollables.is_empty() { + scroll_evr.clear(); + return; + } + let delta_y: f32 = scroll_evr + .read() + .map(|ev| match ev.unit { + MouseScrollUnit::Line => ev.y * 50.0, + MouseScrollUnit::Pixel => ev.y, + }) + .sum(); + if delta_y == 0.0 { + return; + } + for mut sp in scrollables.iter_mut() { + sp.0.y = (sp.0.y - delta_y).max(0.0); + } +} + fn spawn_achievements_screen( commands: &mut Commands, records: &[AchievementRecord], @@ -424,75 +476,95 @@ fn spawn_achievements_screen( spawn_modal(commands, AchievementsScreen, Z_MODAL_PANEL, |card| { spawn_modal_header(card, header, font_res); - // Achievement rows — unlocked first, then locked alphabetical. - let mut sorted: Vec<_> = records.iter().collect(); - sorted.sort_by_key(|r| (!r.unlocked, r.id.clone())); + // Scrollable body — the achievements list grows to ~19 rows which + // overflows the modal on the 800x600 minimum window. Wrapping the + // row list in an `Overflow::scroll_y()` Node with a constrained + // `max_height` keeps every row reachable. The Done button below + // sits outside the scroll so it's always one click away. Mirrors + // the `SettingsPanelScrollable` pattern. + card.spawn(( + AchievementsScrollable, + ScrollPosition::default(), + Node { + flex_direction: FlexDirection::Column, + row_gap: VAL_SPACE_1, + max_height: Val::Vh(70.0), + overflow: Overflow::scroll_y(), + ..default() + }, + )) + .with_children(|body| { + // Achievement rows — unlocked first, then locked alphabetical. + let mut sorted: Vec<_> = records.iter().collect(); + sorted.sort_by_key(|r| (!r.unlocked, r.id.clone())); - for record in &sorted { - let def = achievement_by_id(&record.id); - let (name, description) = def.map_or((record.id.as_str(), ""), |d| (d.name, d.description)); + for record in &sorted { + let def = achievement_by_id(&record.id); + let (name, description) = + def.map_or((record.id.as_str(), ""), |d| (d.name, d.description)); - // Hide secret locked achievements so they remain a surprise. - let is_secret = def.is_some_and(|d| d.secret); - if is_secret && !record.unlocked { - continue; - } + // Hide secret locked achievements so they remain a surprise. + let is_secret = def.is_some_and(|d| d.secret); + if is_secret && !record.unlocked { + continue; + } - let (name_color, desc_color, prefix) = if record.unlocked { - (ACCENT_PRIMARY, TEXT_PRIMARY, "\u{2713} ") - } else { - (TEXT_DISABLED, TEXT_DISABLED, "\u{25CB} ") - }; + let (name_color, desc_color, prefix) = if record.unlocked { + (ACCENT_PRIMARY, TEXT_PRIMARY, "\u{2713} ") + } else { + (TEXT_DISABLED, TEXT_DISABLED, "\u{25CB} ") + }; - let tooltip_text = tooltip_for_row(record.unlocked, def); + let tooltip_text = tooltip_for_row(record.unlocked, def); - card.spawn(( - Node { - flex_direction: FlexDirection::Column, - row_gap: VAL_SPACE_1, - ..default() - }, - AchievementRow, - Tooltip::new(tooltip_text), - )) - .with_children(|row| { - row.spawn(( - Text::new(format!("{prefix}{name}")), - font_name.clone(), - TextColor(name_color), + body.spawn(( + Node { + flex_direction: FlexDirection::Column, + row_gap: VAL_SPACE_1, + ..default() + }, + AchievementRow, + Tooltip::new(tooltip_text), + )) + .with_children(|row| { + row.spawn(( + Text::new(format!("{prefix}{name}")), + font_name.clone(), + TextColor(name_color), + )); + if !description.is_empty() { + row.spawn(( + Text::new(format!(" {description}")), + font_desc.clone(), + TextColor(desc_color), + )); + } + if let Some(reward_str) = def.and_then(|d| d.reward).map(format_reward) { + row.spawn(( + Text::new(format!(" Reward: {reward_str}")), + font_meta.clone(), + TextColor(STATE_SUCCESS), + )); + } + if let Some(date) = record.unlock_date { + row.spawn(( + Text::new(format!(" Unlocked {}", date.format("%Y-%m-%d"))), + font_meta.clone(), + TextColor(TEXT_SECONDARY), + )); + } + }); + + // Subtle row separator — keeps the long list scannable. + body.spawn(( + Node { + height: Val::Px(1.0), + ..default() + }, + BackgroundColor(BORDER_SUBTLE), )); - if !description.is_empty() { - row.spawn(( - Text::new(format!(" {description}")), - font_desc.clone(), - TextColor(desc_color), - )); - } - if let Some(reward_str) = def.and_then(|d| d.reward).map(format_reward) { - row.spawn(( - Text::new(format!(" Reward: {reward_str}")), - font_meta.clone(), - TextColor(STATE_SUCCESS), - )); - } - if let Some(date) = record.unlock_date { - row.spawn(( - Text::new(format!(" Unlocked {}", date.format("%Y-%m-%d"))), - font_meta.clone(), - TextColor(TEXT_SECONDARY), - )); - } - }); - - // Subtle row separator — keeps the long list scannable. - card.spawn(( - Node { - height: Val::Px(1.0), - ..default() - }, - BackgroundColor(BORDER_SUBTLE), - )); - } + } + }); spawn_modal_actions(card, |actions| { spawn_modal_button( @@ -895,6 +967,64 @@ mod tests { assert_eq!(count, 0); } + // ----------------------------------------------------------------------- + // Scrollable body + // ----------------------------------------------------------------------- + + /// Spawning the modal must place exactly one `AchievementsScrollable` + /// marker in the world so the row list scrolls instead of clipping at + /// the 800x600 minimum window. + #[test] + fn achievements_modal_body_is_scrollable() { + let mut app = headless_app(); + press(&mut app, KeyCode::KeyA); + app.update(); + + let count = app + .world_mut() + .query::<&AchievementsScrollable>() + .iter(app.world()) + .count(); + assert_eq!( + count, 1, + "Achievements modal must spawn exactly one AchievementsScrollable body" + ); + } + + /// The scrollable body must constrain its `max_height` so the modal + /// actually engages scrolling on tall content. Without this the inner + /// flex column would expand to fit every row and `Overflow::scroll_y` + /// would have nothing to clip. + #[test] + fn achievements_modal_body_has_max_height() { + let mut app = headless_app(); + press(&mut app, KeyCode::KeyA); + app.update(); + + let mut q = app + .world_mut() + .query_filtered::<&Node, With>(); + let nodes: Vec<&Node> = q.iter(app.world()).collect(); + assert_eq!(nodes.len(), 1, "expected exactly one scrollable body"); + let node = nodes[0]; + + // `Val::Auto` is the default; assert the body's `max_height` was + // explicitly set to something else so scroll engages. + assert_ne!( + node.max_height, + Val::Auto, + "scrollable body must set a non-default max_height; got {:?}", + node.max_height + ); + // And the overflow axis must be y-scroll. + assert_eq!( + node.overflow, + Overflow::scroll_y(), + "scrollable body must use Overflow::scroll_y(); got {:?}", + node.overflow + ); + } + // ----------------------------------------------------------------------- // format_reward // ----------------------------------------------------------------------- diff --git a/solitaire_engine/src/help_plugin.rs b/solitaire_engine/src/help_plugin.rs index cd005d9..0a98563 100644 --- a/solitaire_engine/src/help_plugin.rs +++ b/solitaire_engine/src/help_plugin.rs @@ -4,6 +4,7 @@ //! is an optional accelerator. Listed shortcuts are grouped by intent — //! gameplay, modes, and overlays. +use bevy::input::mouse::{MouseScrollUnit, MouseWheel}; use bevy::prelude::*; use crate::events::HelpRequestEvent; @@ -24,6 +25,16 @@ pub struct HelpScreen; #[derive(Component, Debug)] pub struct HelpCloseButton; +/// Marker on the scrollable body Node inside the Help modal. +/// +/// The controls reference is six sections totalling ~28 rows, which +/// overflows the modal on the 800x600 minimum window. This marker tags +/// the inner container that carries `Overflow::scroll_y()` plus a +/// `max_height` constraint so every row stays reachable. Mirrors the +/// `SettingsPanelScrollable` pattern. +#[derive(Component, Debug)] +pub struct HelpScrollable; + /// Spawns and despawns the help / controls overlay shown when the player /// clicks the "Help" HUD button or presses `F1`. All hotkeys and gesture /// guides live here. @@ -32,7 +43,14 @@ pub struct HelpPlugin; impl Plugin for HelpPlugin { fn build(&self, app: &mut App) { app.add_message::() - .add_systems(Update, (toggle_help_screen, handle_help_close_button)); + // `MouseWheel` is emitted by Bevy's input plugin under + // `DefaultPlugins`; register it explicitly so the help-scroll + // system also runs cleanly under `MinimalPlugins` in tests. + .add_message::() + .add_systems( + Update, + (toggle_help_screen, handle_help_close_button, scroll_help_panel), + ); } } @@ -71,6 +89,32 @@ fn handle_help_close_button( } } +/// Routes mouse-wheel events into the Help modal's scrollable body while +/// the panel is open. No-op when no `HelpScrollable` exists in the world +/// (modal closed). Mirrors `scroll_settings_panel`. +fn scroll_help_panel( + mut scroll_evr: MessageReader, + mut scrollables: Query<&mut ScrollPosition, With>, +) { + if scrollables.is_empty() { + scroll_evr.clear(); + return; + } + let delta_y: f32 = scroll_evr + .read() + .map(|ev| match ev.unit { + MouseScrollUnit::Line => ev.y * 50.0, + MouseScrollUnit::Pixel => ev.y, + }) + .sum(); + if delta_y == 0.0 { + return; + } + for mut sp in scrollables.iter_mut() { + sp.0.y = (sp.0.y - delta_y).max(0.0); + } +} + /// Each entry in the controls reference table. struct ControlRow { keys: &'static str, @@ -168,59 +212,77 @@ fn spawn_help_screen(commands: &mut Commands, font_res: Option<&FontResource>) { spawn_modal(commands, HelpScreen, Z_MODAL_PANEL, |card| { spawn_modal_header(card, "Controls", font_res); - for section in CONTROL_SECTIONS { - // Section title in muted text — distinguishes from row content. - card.spawn(( - Text::new(section.title), - font_section.clone(), - TextColor(TEXT_SECONDARY), - )); + // Scrollable body — the controls reference is six sections totalling + // ~28 rows, which overflows the modal on the 800x600 minimum + // window. Wrapping in an `Overflow::scroll_y()` Node with a + // constrained `max_height` keeps every row reachable; the Done + // button below stays fixed outside the scroll. + card.spawn(( + HelpScrollable, + ScrollPosition::default(), + Node { + flex_direction: FlexDirection::Column, + row_gap: VAL_SPACE_2, + max_height: Val::Vh(70.0), + overflow: Overflow::scroll_y(), + ..default() + }, + )) + .with_children(|body| { + for section in CONTROL_SECTIONS { + // Section title in muted text — distinguishes from row content. + body.spawn(( + Text::new(section.title), + font_section.clone(), + TextColor(TEXT_SECONDARY), + )); - // Each row is a flex-row: kbd-style chip + description. - for row in section.rows { - card.spawn(Node { - flex_direction: FlexDirection::Row, - align_items: AlignItems::Center, - column_gap: VAL_SPACE_3, - ..default() - }) - .with_children(|line| { - // The hotkey rendered as a small chip with a border — - // visual cue that it's a key reference, not part of - // the description text. - line.spawn(( - Node { - padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1), - min_width: Val::Px(64.0), - justify_content: JustifyContent::Center, - border: UiRect::all(Val::Px(1.0)), - border_radius: BorderRadius::all(Val::Px(RADIUS_SM)), - ..default() - }, - BorderColor::all(BORDER_SUBTLE), - )) - .with_children(|chip| { - chip.spawn(( - Text::new(row.keys), - font_kbd.clone(), + // Each row is a flex-row: kbd-style chip + description. + for row in section.rows { + body.spawn(Node { + flex_direction: FlexDirection::Row, + align_items: AlignItems::Center, + column_gap: VAL_SPACE_3, + ..default() + }) + .with_children(|line| { + // The hotkey rendered as a small chip with a border — + // visual cue that it's a key reference, not part of + // the description text. + line.spawn(( + Node { + padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1), + min_width: Val::Px(64.0), + justify_content: JustifyContent::Center, + border: UiRect::all(Val::Px(1.0)), + border_radius: BorderRadius::all(Val::Px(RADIUS_SM)), + ..default() + }, + BorderColor::all(BORDER_SUBTLE), + )) + .with_children(|chip| { + chip.spawn(( + Text::new(row.keys), + font_kbd.clone(), + TextColor(TEXT_PRIMARY), + )); + }); + line.spawn(( + Text::new(row.description), + font_row.clone(), TextColor(TEXT_PRIMARY), )); }); - line.spawn(( - Text::new(row.description), - font_row.clone(), - TextColor(TEXT_PRIMARY), - )); + } + + // Section spacer — small empty box. Keeps each section + // visually grouped. + body.spawn(Node { + height: Val::Px(SPACE_2), + ..default() }); } - - // Section spacer — small empty box. Keeps each section - // visually grouped. - card.spawn(Node { - height: Val::Px(SPACE_2), - ..default() - }); - } + }); spawn_modal_actions(card, |actions| { spawn_modal_button( @@ -264,6 +326,36 @@ mod tests { ); } + #[test] + fn help_modal_body_is_scrollable() { + let mut app = headless_app(); + app.world_mut() + .resource_mut::>() + .press(KeyCode::F1); + app.update(); + + let count = app + .world_mut() + .query::<&HelpScrollable>() + .iter(app.world()) + .count(); + assert_eq!( + count, 1, + "Help modal must spawn exactly one HelpScrollable body" + ); + + let mut q = app + .world_mut() + .query_filtered::<&Node, With>(); + let nodes: Vec<&Node> = q.iter(app.world()).collect(); + assert_ne!( + nodes[0].max_height, + Val::Auto, + "scrollable body must set a non-default max_height" + ); + assert_eq!(nodes[0].overflow, Overflow::scroll_y()); + } + #[test] fn pressing_f1_twice_closes_help_screen() { let mut app = headless_app(); diff --git a/solitaire_engine/src/leaderboard_plugin.rs b/solitaire_engine/src/leaderboard_plugin.rs index 9542dff..200e377 100644 --- a/solitaire_engine/src/leaderboard_plugin.rs +++ b/solitaire_engine/src/leaderboard_plugin.rs @@ -9,6 +9,7 @@ //! When the provider does not support leaderboards (e.g. `LocalOnlyProvider`) //! the panel shows "Not available" immediately. +use bevy::input::mouse::{MouseScrollUnit, MouseWheel}; use bevy::prelude::*; use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task}; use solitaire_data::settings::SyncBackend; @@ -23,7 +24,7 @@ use crate::ui_modal::{ }; use crate::ui_theme::{ ACCENT_PRIMARY, BORDER_SUBTLE, STATE_INFO, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, - TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_4, Z_MODAL_PANEL, + TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_4, Z_MODAL_PANEL, }; // --------------------------------------------------------------------------- @@ -66,6 +67,18 @@ struct LeaderboardFetchTask(Option, String>>>) #[derive(Component, Debug)] pub struct LeaderboardScreen; +/// Marker on the scrollable body Node inside the Leaderboard modal. +/// +/// The leaderboard caps at the top 10 entries today, but rendering the +/// caption + opt-in/opt-out row + 10 data rows on the 800x600 minimum +/// window is right at the edge of overflowing — long display names or +/// future row-count expansion would cut off entries below the fold. +/// Wrapping the data section in an `Overflow::scroll_y()` Node with a +/// constrained `max_height` keeps every row reachable. Mirrors the +/// `SettingsPanelScrollable` pattern. +#[derive(Component, Debug)] +pub struct LeaderboardScrollable; + /// Marker on the "Opt In" button inside the leaderboard panel. #[derive(Component, Debug)] struct LeaderboardOptInButton; @@ -98,6 +111,11 @@ impl Plugin for LeaderboardPlugin { .init_resource::() .init_resource::() .add_message::() + // `MouseWheel` is emitted by Bevy's input plugin under + // `DefaultPlugins`; register it explicitly so the + // leaderboard-scroll system also runs cleanly under + // `MinimalPlugins` in tests. + .add_message::() .add_systems( Update, ( @@ -112,7 +130,8 @@ impl Plugin for LeaderboardPlugin { poll_opt_out_task, ) .chain(), - ); + ) + .add_systems(Update, scroll_leaderboard_panel); } } @@ -222,6 +241,33 @@ fn update_leaderboard_panel( } /// Click handler for the modal's "Done" button — despawns the overlay. +/// Routes mouse-wheel events into the Leaderboard modal's scrollable +/// data body while the panel is open. No-op when no +/// `LeaderboardScrollable` exists in the world (modal closed). Mirrors +/// `scroll_settings_panel`. +fn scroll_leaderboard_panel( + mut scroll_evr: MessageReader, + mut scrollables: Query<&mut ScrollPosition, With>, +) { + if scrollables.is_empty() { + scroll_evr.clear(); + return; + } + let delta_y: f32 = scroll_evr + .read() + .map(|ev| match ev.unit { + MouseScrollUnit::Line => ev.y * 50.0, + MouseScrollUnit::Pixel => ev.y, + }) + .sum(); + if delta_y == 0.0 { + return; + } + for mut sp in scrollables.iter_mut() { + sp.0.y = (sp.0.y - delta_y).max(0.0); + } +} + fn handle_leaderboard_close_button( mut commands: Commands, close_buttons: Query<&Interaction, (With, Changed)>, @@ -420,76 +466,94 @@ fn spawn_leaderboard_screen( BackgroundColor(BORDER_SUBTLE), )); - match data { - LeaderboardResource::Idle => { - card.spawn(( - Text::new("Fetching\u{2026}"), - font_status.clone(), - TextColor(STATE_INFO), - )); - } - LeaderboardResource::Error(_) => { - card.spawn(( - Text::new("Couldn't reach the leaderboard. Try again later."), - font_status.clone(), - TextColor(TEXT_SECONDARY), - )); - } - LeaderboardResource::Loaded(rows) if rows.is_empty() => { - card.spawn(( - Text::new("No entries yet \u{2014} sync and opt in to appear here."), - font_row.clone(), - TextColor(TEXT_SECONDARY), - )); - } - LeaderboardResource::Loaded(rows) => { - // Column headers - card.spawn(Node { - flex_direction: FlexDirection::Row, - column_gap: VAL_SPACE_4, - ..default() - }) - .with_children(|row| { - header_cell(row, "#", 30.0, &font_header); - header_cell(row, "Player", 160.0, &font_header); - header_cell(row, "Best Score", 100.0, &font_header); - header_cell(row, "Fastest Win", 110.0, &font_header); - }); - - let mut sorted = rows.to_vec(); - sorted.sort_by_key(|e| std::cmp::Reverse(e.best_score.unwrap_or(0))); - - for (i, entry) in sorted.iter().take(10).enumerate() { - // Top three get accent treatments to highlight the - // podium without leaning on hand-picked metallic - // colours that sit outside the token system. - let rank_color = match i { - 0 => ACCENT_PRIMARY, // Balatro yellow for #1 - 1 | 2 => TEXT_PRIMARY, - _ => TEXT_SECONDARY, - }; - - let time_str = entry - .best_time_secs - .map_or_else(|| "-".to_string(), format_secs); - let score_str = entry - .best_score - .map_or_else(|| "-".to_string(), |s| s.to_string()); - - card.spawn(Node { + // Scrollable data section — caps at top 10 rows today, but on the + // 800x600 minimum window the header + caption + opt-in row + 10 + // entries crowds the modal. Wrapping in `Overflow::scroll_y()` + // with a `max_height` keeps every entry reachable and survives + // any future expansion of the row cap. + card.spawn(( + LeaderboardScrollable, + ScrollPosition::default(), + Node { + flex_direction: FlexDirection::Column, + row_gap: VAL_SPACE_2, + max_height: Val::Vh(50.0), + overflow: Overflow::scroll_y(), + ..default() + }, + )) + .with_children(|body| { + match data { + LeaderboardResource::Idle => { + body.spawn(( + Text::new("Fetching\u{2026}"), + font_status.clone(), + TextColor(STATE_INFO), + )); + } + LeaderboardResource::Error(_) => { + body.spawn(( + Text::new("Couldn't reach the leaderboard. Try again later."), + font_status.clone(), + TextColor(TEXT_SECONDARY), + )); + } + LeaderboardResource::Loaded(rows) if rows.is_empty() => { + body.spawn(( + Text::new("No entries yet \u{2014} sync and opt in to appear here."), + font_row.clone(), + TextColor(TEXT_SECONDARY), + )); + } + LeaderboardResource::Loaded(rows) => { + // Column headers + body.spawn(Node { flex_direction: FlexDirection::Row, column_gap: VAL_SPACE_4, ..default() }) .with_children(|row| { - data_cell(row, &format!("{}", i + 1), 30.0, rank_color, &font_row); - data_cell(row, &entry.display_name, 160.0, TEXT_PRIMARY, &font_row); - data_cell(row, &score_str, 100.0, TEXT_PRIMARY, &font_row); - data_cell(row, &time_str, 110.0, TEXT_PRIMARY, &font_row); + header_cell(row, "#", 30.0, &font_header); + header_cell(row, "Player", 160.0, &font_header); + header_cell(row, "Best Score", 100.0, &font_header); + header_cell(row, "Fastest Win", 110.0, &font_header); }); + + let mut sorted = rows.to_vec(); + sorted.sort_by_key(|e| std::cmp::Reverse(e.best_score.unwrap_or(0))); + + for (i, entry) in sorted.iter().take(10).enumerate() { + // Top three get accent treatments to highlight the + // podium without leaning on hand-picked metallic + // colours that sit outside the token system. + let rank_color = match i { + 0 => ACCENT_PRIMARY, // Balatro yellow for #1 + 1 | 2 => TEXT_PRIMARY, + _ => TEXT_SECONDARY, + }; + + let time_str = entry + .best_time_secs + .map_or_else(|| "-".to_string(), format_secs); + let score_str = entry + .best_score + .map_or_else(|| "-".to_string(), |s| s.to_string()); + + body.spawn(Node { + flex_direction: FlexDirection::Row, + column_gap: VAL_SPACE_4, + ..default() + }) + .with_children(|row| { + data_cell(row, &format!("{}", i + 1), 30.0, rank_color, &font_row); + data_cell(row, &entry.display_name, 160.0, TEXT_PRIMARY, &font_row); + data_cell(row, &score_str, 100.0, TEXT_PRIMARY, &font_row); + data_cell(row, &time_str, 110.0, TEXT_PRIMARY, &font_row); + }); + } } } - } + }); spawn_modal_actions(card, |actions| { spawn_modal_button( @@ -646,6 +710,34 @@ mod tests { assert_eq!(count, 1); } + #[test] + fn leaderboard_modal_body_is_scrollable() { + let mut app = headless_app(); + press(&mut app, KeyCode::KeyL); + app.update(); + + let count = app + .world_mut() + .query::<&LeaderboardScrollable>() + .iter(app.world()) + .count(); + assert_eq!( + count, 1, + "Leaderboard modal must spawn exactly one LeaderboardScrollable body" + ); + + let mut q = app + .world_mut() + .query_filtered::<&Node, With>(); + let nodes: Vec<&Node> = q.iter(app.world()).collect(); + assert_ne!( + nodes[0].max_height, + Val::Auto, + "scrollable body must set a non-default max_height" + ); + assert_eq!(nodes[0].overflow, Overflow::scroll_y()); + } + #[test] fn pressing_l_twice_dismisses_screen() { let mut app = headless_app(); diff --git a/solitaire_engine/src/profile_plugin.rs b/solitaire_engine/src/profile_plugin.rs index 9ec8013..4548edd 100644 --- a/solitaire_engine/src/profile_plugin.rs +++ b/solitaire_engine/src/profile_plugin.rs @@ -4,6 +4,7 @@ //! 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::prelude::*; use chrono::{Duration, Local, NaiveDate}; @@ -60,10 +61,60 @@ pub struct ProfilePlugin; #[derive(Component, Debug)] pub struct ProfileCloseButton; +/// Marker on the scrollable body Node inside the Profile modal. +/// +/// The Profile panel renders sync info, progression (incl. 14-day +/// calendar), every unlocked achievement (up to ~18), and a stats +/// summary, which can overflow the modal on the 800x600 minimum window +/// once a player has unlocked several achievements. This marker tags +/// the inner container that carries `Overflow::scroll_y()` plus a +/// `max_height` constraint. Mirrors the `SettingsPanelScrollable` +/// pattern. +#[derive(Component, Debug)] +pub struct ProfileScrollable; + impl Plugin for ProfilePlugin { fn build(&self, app: &mut App) { app.add_message::() - .add_systems(Update, (toggle_profile_screen, handle_profile_close_button)); + // `MouseWheel` is emitted by Bevy's input plugin under + // `DefaultPlugins`; register it explicitly so the + // profile-scroll system also runs cleanly under + // `MinimalPlugins` in tests. + .add_message::() + .add_systems( + Update, + ( + toggle_profile_screen, + handle_profile_close_button, + scroll_profile_panel, + ), + ); + } +} + +/// Routes mouse-wheel events into the Profile modal's scrollable body +/// while the panel is open. No-op when no `ProfileScrollable` exists in +/// the world (modal closed). Mirrors `scroll_settings_panel`. +fn scroll_profile_panel( + mut scroll_evr: MessageReader, + mut scrollables: Query<&mut ScrollPosition, With>, +) { + if scrollables.is_empty() { + scroll_evr.clear(); + return; + } + let delta_y: f32 = scroll_evr + .read() + .map(|ev| match ev.unit { + MouseScrollUnit::Line => ev.y * 50.0, + MouseScrollUnit::Pixel => ev.y, + }) + .sum(); + if delta_y == 0.0 { + return; + } + for mut sp in scrollables.iter_mut() { + sp.0.y = (sp.0.y - delta_y).max(0.0); } } @@ -136,183 +187,202 @@ fn spawn_profile_screen( spawn_modal(commands, ProfileScreen, Z_MODAL_PANEL, |card| { spawn_modal_header(card, "Profile", font_res); - // First-launch welcome — only when the player has zero XP and - // zero daily streak, so the profile doesn't read as a wall of - // zeros to a brand-new player. - if let Some(p) = progress - && p.0.total_xp == 0 - && p.0.daily_challenge_streak == 0 - { - card.spawn(( - Text::new("Welcome! Play games to earn XP and unlock achievements."), - font_section.clone(), - TextColor(ACCENT_PRIMARY), - Node { - margin: UiRect { - bottom: VAL_SPACE_2, + // Scrollable body — the Profile panel renders sync info, + // progression (incl. a 14-day calendar), every unlocked + // achievement (up to ~18), and a stats summary, which can + // overflow the modal on the 800x600 minimum window once the + // player has unlocked several achievements. The Done action + // stays fixed outside the scroll. + card.spawn(( + ProfileScrollable, + ScrollPosition::default(), + Node { + flex_direction: FlexDirection::Column, + row_gap: VAL_SPACE_1, + max_height: Val::Vh(70.0), + overflow: Overflow::scroll_y(), + ..default() + }, + )) + .with_children(|body| { + // First-launch welcome — only when the player has zero XP and + // zero daily streak, so the profile doesn't read as a wall of + // zeros to a brand-new player. + if let Some(p) = progress + && p.0.total_xp == 0 + && p.0.daily_challenge_streak == 0 + { + body.spawn(( + Text::new("Welcome! Play games to earn XP and unlock achievements."), + font_section.clone(), + TextColor(ACCENT_PRIMARY), + Node { + margin: UiRect { + bottom: VAL_SPACE_2, + ..default() + }, ..default() }, - ..default() - }, - )); - } - - // ── Sync section ──────────────────────────────────────────── - card.spawn(( - Text::new("Sync"), - font_section.clone(), - TextColor(STATE_INFO), - )); - if let Some(s) = settings { - let (backend_name, username) = sync_info(&s.0.sync_backend); - card.spawn(( - Text::new(format!("Account: {username} | Backend: {backend_name}")), - font_row.clone(), - TextColor(TEXT_PRIMARY), - )); - } - if let Some(ss) = sync_status { - let status_text = match &ss.0 { - SyncStatus::Idle => "Sync: idle".to_string(), - SyncStatus::Syncing => "Sync: syncing\u{2026}".to_string(), - SyncStatus::LastSynced(dt) => { - format!("Last synced: {}", dt.format("%Y-%m-%d %H:%M")) - } - SyncStatus::Error(e) => format!("Sync error: {e}"), - }; - card.spawn(( - Text::new(status_text), - font_row.clone(), - TextColor(TEXT_SECONDARY), - )); - } - - // ── Progression section ───────────────────────────────────── - spawn_spacer(card, VAL_SPACE_2); - card.spawn(( - Text::new("Progression"), - font_section.clone(), - TextColor(STATE_INFO), - )); - if let Some(p) = progress { - let prog = &p.0; - let (xp_span, xp_done) = xp_progress(prog.total_xp, prog.level); - let pct = if xp_span == 0 { - 100u64 - } else { - xp_done.saturating_mul(100).checked_div(xp_span).unwrap_or(100) - }; - card.spawn(( - Text::new(format!( - "Level {} \u{2014} {} XP ({}/{} to next, {}%)", - prog.level, prog.total_xp, xp_done, xp_span, pct - )), - font_row.clone(), - TextColor(TEXT_PRIMARY), - )); - card.spawn(( - Text::new(format!( - "Daily streak: {} | Card backs: {} | Backgrounds: {}", - prog.daily_challenge_streak, - prog.unlocked_card_backs.len(), - prog.unlocked_backgrounds.len(), - )), - font_row.clone(), - TextColor(TEXT_PRIMARY), - )); - - // 14-day daily-challenge calendar row. - spawn_daily_calendar( - card, - &prog.daily_challenge_history, - prog.daily_challenge_streak, - prog.daily_challenge_longest_streak, - Local::now().date_naive(), - font_res, - ); - } - - // ── Achievements section ──────────────────────────────────── - spawn_spacer(card, VAL_SPACE_2); - card.spawn(( - Text::new("Achievements"), - font_section.clone(), - TextColor(STATE_INFO), - )); - if let Some(ar) = achievements { - let records = &ar.0; - let unlocked_count = records.iter().filter(|r| r.unlocked).count(); - card.spawn(( - Text::new(format!("{unlocked_count} / 18 unlocked")), - font_row.clone(), - TextColor(ACCENT_PRIMARY), - )); - - let mut any_unlocked = false; - for record in records { - let def = achievement_by_id(record.id.as_str()); - let is_secret = def.is_some_and(|d| d.secret); - if is_secret && !record.unlocked { - continue; - } - if !record.unlocked { - continue; - } - any_unlocked = true; - let name = def.map_or(record.id.as_str(), |d| d.name); - let date_str = match record.unlock_date { - Some(dt) => format!(" ({})", dt.format("%Y-%m-%d")), - None => String::new(), - }; - card.spawn(( - Text::new(format!(" [x] {name}{date_str}")), - font_row.clone(), - TextColor(STATE_SUCCESS), )); } - if !any_unlocked { - card.spawn(( - Text::new(" No achievements unlocked yet."), + + // ── Sync section ──────────────────────────────────────────── + body.spawn(( + Text::new("Sync"), + font_section.clone(), + TextColor(STATE_INFO), + )); + if let Some(s) = settings { + let (backend_name, username) = sync_info(&s.0.sync_backend); + body.spawn(( + Text::new(format!("Account: {username} | Backend: {backend_name}")), + font_row.clone(), + TextColor(TEXT_PRIMARY), + )); + } + if let Some(ss) = sync_status { + let status_text = match &ss.0 { + SyncStatus::Idle => "Sync: idle".to_string(), + SyncStatus::Syncing => "Sync: syncing\u{2026}".to_string(), + SyncStatus::LastSynced(dt) => { + format!("Last synced: {}", dt.format("%Y-%m-%d %H:%M")) + } + SyncStatus::Error(e) => format!("Sync error: {e}"), + }; + body.spawn(( + Text::new(status_text), font_row.clone(), TextColor(TEXT_SECONDARY), )); } - } - // ── Statistics summary section ────────────────────────────── - spawn_spacer(card, VAL_SPACE_2); - card.spawn(( - Text::new("Statistics Summary"), - font_section.clone(), - TextColor(STATE_INFO), - )); - if let Some(sr) = stats { - let s = &sr.0; - let best_score_str = if s.best_single_score == 0 { - "\u{2014}".to_string() - } else { - s.best_single_score.to_string() - }; - card.spawn(( - Text::new(format!( - "Played: {} | Won: {} | Win rate: {} | Best time: {}", - s.games_played, - s.games_won, - format_win_rate(s), - format_fastest_win(s.fastest_win_seconds), - )), - font_row.clone(), - TextColor(TEXT_PRIMARY), + // ── Progression section ───────────────────────────────────── + spawn_spacer(body, VAL_SPACE_2); + body.spawn(( + Text::new("Progression"), + font_section.clone(), + TextColor(STATE_INFO), )); - card.spawn(( - Text::new(format!( - "Win streak: {} current, {} best | Best score: {}", - s.win_streak_current, s.win_streak_best, best_score_str, - )), - font_row.clone(), - TextColor(TEXT_PRIMARY), + if let Some(p) = progress { + let prog = &p.0; + let (xp_span, xp_done) = xp_progress(prog.total_xp, prog.level); + let pct = if xp_span == 0 { + 100u64 + } else { + xp_done.saturating_mul(100).checked_div(xp_span).unwrap_or(100) + }; + body.spawn(( + Text::new(format!( + "Level {} \u{2014} {} XP ({}/{} to next, {}%)", + prog.level, prog.total_xp, xp_done, xp_span, pct + )), + font_row.clone(), + TextColor(TEXT_PRIMARY), + )); + body.spawn(( + Text::new(format!( + "Daily streak: {} | Card backs: {} | Backgrounds: {}", + prog.daily_challenge_streak, + prog.unlocked_card_backs.len(), + prog.unlocked_backgrounds.len(), + )), + font_row.clone(), + TextColor(TEXT_PRIMARY), + )); + + // 14-day daily-challenge calendar row. + spawn_daily_calendar( + body, + &prog.daily_challenge_history, + prog.daily_challenge_streak, + prog.daily_challenge_longest_streak, + Local::now().date_naive(), + font_res, + ); + } + + // ── Achievements section ──────────────────────────────────── + spawn_spacer(body, VAL_SPACE_2); + body.spawn(( + Text::new("Achievements"), + font_section.clone(), + TextColor(STATE_INFO), )); - } + if let Some(ar) = achievements { + let records = &ar.0; + let unlocked_count = records.iter().filter(|r| r.unlocked).count(); + body.spawn(( + Text::new(format!("{unlocked_count} / 18 unlocked")), + font_row.clone(), + TextColor(ACCENT_PRIMARY), + )); + + let mut any_unlocked = false; + for record in records { + let def = achievement_by_id(record.id.as_str()); + let is_secret = def.is_some_and(|d| d.secret); + if is_secret && !record.unlocked { + continue; + } + if !record.unlocked { + continue; + } + any_unlocked = true; + let name = def.map_or(record.id.as_str(), |d| d.name); + let date_str = match record.unlock_date { + Some(dt) => format!(" ({})", dt.format("%Y-%m-%d")), + None => String::new(), + }; + body.spawn(( + Text::new(format!(" [x] {name}{date_str}")), + font_row.clone(), + TextColor(STATE_SUCCESS), + )); + } + if !any_unlocked { + body.spawn(( + Text::new(" No achievements unlocked yet."), + font_row.clone(), + TextColor(TEXT_SECONDARY), + )); + } + } + + // ── Statistics summary section ────────────────────────────── + spawn_spacer(body, VAL_SPACE_2); + body.spawn(( + Text::new("Statistics Summary"), + font_section.clone(), + TextColor(STATE_INFO), + )); + if let Some(sr) = stats { + let s = &sr.0; + let best_score_str = if s.best_single_score == 0 { + "\u{2014}".to_string() + } else { + s.best_single_score.to_string() + }; + body.spawn(( + Text::new(format!( + "Played: {} | Won: {} | Win rate: {} | Best time: {}", + s.games_played, + s.games_won, + format_win_rate(s), + format_fastest_win(s.fastest_win_seconds), + )), + font_row.clone(), + TextColor(TEXT_PRIMARY), + )); + body.spawn(( + Text::new(format!( + "Win streak: {} current, {} best | Best score: {}", + s.win_streak_current, s.win_streak_best, best_score_str, + )), + font_row.clone(), + TextColor(TEXT_PRIMARY), + )); + } + }); spawn_modal_actions(card, |actions| { spawn_modal_button( @@ -503,6 +573,36 @@ mod tests { ); } + #[test] + fn profile_modal_body_is_scrollable() { + let mut app = headless_app(); + app.world_mut() + .resource_mut::>() + .press(KeyCode::KeyP); + app.update(); + + let count = app + .world_mut() + .query::<&ProfileScrollable>() + .iter(app.world()) + .count(); + assert_eq!( + count, 1, + "Profile modal must spawn exactly one ProfileScrollable body" + ); + + let mut q = app + .world_mut() + .query_filtered::<&Node, With>(); + let nodes: Vec<&Node> = q.iter(app.world()).collect(); + assert_ne!( + nodes[0].max_height, + Val::Auto, + "scrollable body must set a non-default max_height" + ); + assert_eq!(nodes[0].overflow, Overflow::scroll_y()); + } + #[test] fn pressing_p_twice_closes_profile_screen() { let mut app = headless_app(); diff --git a/solitaire_engine/src/stats_plugin.rs b/solitaire_engine/src/stats_plugin.rs index 9e221e5..e837815 100644 --- a/solitaire_engine/src/stats_plugin.rs +++ b/solitaire_engine/src/stats_plugin.rs @@ -8,6 +8,7 @@ use std::path::PathBuf; +use bevy::input::mouse::{MouseScrollUnit, MouseWheel}; use bevy::input::ButtonInput; use bevy::prelude::*; use solitaire_data::{ @@ -118,6 +119,18 @@ pub struct ReplaySelectorCaption; #[derive(Component, Debug)] pub struct PerModeBestsRow; +/// Marker on the scrollable body Node inside the Stats modal. +/// +/// The Stats panel renders an 8-cell primary grid, three per-mode bests +/// rows, a five-cell progression grid, weekly goals, an unlocks line, +/// optional Time Attack readout, and the latest replay caption — enough +/// content to overflow the modal on the 800x600 minimum window. This +/// marker tags the inner container that carries `Overflow::scroll_y()` +/// plus a `max_height` constraint. Mirrors the `SettingsPanelScrollable` +/// pattern. +#[derive(Component, Debug)] +pub struct StatsScrollable; + /// Registers stats resources, update systems, and the UI toggle. pub struct StatsPlugin { /// Where to persist stats. `None` disables all file I/O (for tests). @@ -167,6 +180,10 @@ impl Plugin for StatsPlugin { .add_message::() .add_message::() .add_message::() + // `MouseWheel` is emitted by Bevy's input plugin under + // `DefaultPlugins`; register it explicitly so the stats-scroll + // system also runs cleanly under `MinimalPlugins` in tests. + .add_message::() // record_abandoned must read `move_count` BEFORE handle_new_game // clobbers it with a fresh game. These are NOT in StatsUpdate because // StatsUpdate (as a set) is ordered after GameMutation by external @@ -195,7 +212,34 @@ impl Plugin for StatsPlugin { .add_systems( Update, (handle_replay_selector_buttons, repaint_replay_selector_caption).chain(), - ); + ) + .add_systems(Update, scroll_stats_panel); + } +} + +/// Routes mouse-wheel events into the Stats modal's scrollable body +/// while the panel is open. No-op when no `StatsScrollable` exists in +/// the world (modal closed). Mirrors `scroll_settings_panel`. +fn scroll_stats_panel( + mut scroll_evr: MessageReader, + mut scrollables: Query<&mut ScrollPosition, With>, +) { + if scrollables.is_empty() { + scroll_evr.clear(); + return; + } + let delta_y: f32 = scroll_evr + .read() + .map(|ev| match ev.unit { + MouseScrollUnit::Line => ev.y * 50.0, + MouseScrollUnit::Pixel => ev.y, + }) + .sum(); + if delta_y == 0.0 { + return; + } + for mut sp in scrollables.iter_mut() { + sp.0.y = (sp.0.y - delta_y).max(0.0); } } @@ -561,104 +605,48 @@ fn spawn_stats_screen( spawn_modal(commands, StatsScreen, Z_MODAL_PANEL, |card| { spawn_modal_header(card, "Statistics", font_res); - // First-launch caption — sits above the grid as gentle nudge so - // the wall of em-dashes reads as "nothing to track yet" rather - // than as broken state. - if is_first_launch { - card.spawn(( - Text::new("Play a game to start tracking stats."), - TextFont { - font_size: TYPE_CAPTION, - ..default() - }, - TextColor(TEXT_SECONDARY), - Node { - margin: UiRect { - bottom: VAL_SPACE_2, + // Scrollable body — the Stats panel renders an 8-cell grid plus + // multiple sections (per-mode bests, progression, weekly goals, + // unlocks, optional Time Attack, latest replay caption) and + // overflows the modal on the 800x600 minimum window. Wrapping + // in an `Overflow::scroll_y()` Node with a constrained + // `max_height` keeps every cell reachable; the Watch Replay / + // Done action row stays fixed outside the scroll. + card.spawn(( + StatsScrollable, + ScrollPosition::default(), + Node { + flex_direction: FlexDirection::Column, + row_gap: VAL_SPACE_3, + max_height: Val::Vh(70.0), + overflow: Overflow::scroll_y(), + ..default() + }, + )) + .with_children(|body| { + // First-launch caption — sits above the grid as gentle nudge so + // the wall of em-dashes reads as "nothing to track yet" rather + // than as broken state. + if is_first_launch { + body.spawn(( + Text::new("Play a game to start tracking stats."), + TextFont { + font_size: TYPE_CAPTION, ..default() }, - ..default() - }, - )); - } + TextColor(TEXT_SECONDARY), + Node { + margin: UiRect { + bottom: VAL_SPACE_2, + ..default() + }, + ..default() + }, + )); + } - // --- primary stat cells grid --- - card.spawn(Node { - flex_direction: FlexDirection::Row, - flex_wrap: FlexWrap::Wrap, - justify_content: JustifyContent::Center, - align_items: AlignItems::FlexStart, - column_gap: VAL_SPACE_4, - row_gap: VAL_SPACE_3, - width: Val::Percent(100.0), - ..default() - }) - .with_children(|grid| { - spawn_stat_cell(grid, &win_rate_str, "Win Rate"); - spawn_stat_cell(grid, &played_str, "Games Played"); - spawn_stat_cell(grid, &won_str, "Games Won"); - spawn_stat_cell(grid, &lost_str, "Games Lost"); - spawn_stat_cell(grid, &fastest_str, "Fastest Win"); - spawn_stat_cell(grid, &avg_time_str, "Avg Time"); - spawn_stat_cell(grid, &best_score_str, "Best Score"); - spawn_stat_cell(grid, &best_streak_str, "Best Streak"); - }); - - // --- per-mode bests section --- - // Three rows, one per supported mode. Time Attack uses session-level - // scoring (count of wins inside a 10-minute window) so a per-game - // best wouldn't compose; Daily uses Classic scoring and so already - // contributes to the Classic row. - card.spawn(( - Text::new("Per-mode bests"), - font_section.clone(), - TextColor(STATE_INFO), - )); - card.spawn(Node { - flex_direction: FlexDirection::Column, - width: Val::Percent(100.0), - row_gap: VAL_SPACE_2, - ..default() - }) - .with_children(|column| { - spawn_per_mode_bests_row( - column, - "Classic", - stats.classic_best_score, - stats.classic_fastest_win_seconds, - &font_row, - ); - spawn_per_mode_bests_row( - column, - "Zen", - stats.zen_best_score, - stats.zen_fastest_win_seconds, - &font_row, - ); - spawn_per_mode_bests_row( - column, - "Challenge", - stats.challenge_best_score, - stats.challenge_fastest_win_seconds, - &font_row, - ); - }); - - // --- progression section --- - if let Some(p) = progress { - card.spawn(( - Text::new("Progression"), - font_section.clone(), - TextColor(STATE_INFO), - )); - - let level_str = format_stat_value(p.level); - let xp_str = format_stat_value(p.total_xp as u32); - let next_label = xp_to_next_level_label(p.total_xp, p.level); - let daily_str = format_stat_value(p.daily_challenge_streak); - let challenge_str = challenge_progress_label(p.challenge_index); - - card.spawn(Node { + // --- primary stat cells grid --- + body.spawn(Node { flex_direction: FlexDirection::Row, flex_wrap: FlexWrap::Wrap, justify_content: JustifyContent::Center, @@ -669,68 +657,144 @@ fn spawn_stats_screen( ..default() }) .with_children(|grid| { - spawn_stat_cell(grid, &level_str, "Level"); - spawn_stat_cell(grid, &xp_str, "Total XP"); - spawn_stat_cell(grid, &next_label, "Next Level"); - spawn_stat_cell(grid, &daily_str, "Daily Streak"); - spawn_stat_cell(grid, &challenge_str, "Challenge"); + spawn_stat_cell(grid, &win_rate_str, "Win Rate"); + spawn_stat_cell(grid, &played_str, "Games Played"); + spawn_stat_cell(grid, &won_str, "Games Won"); + spawn_stat_cell(grid, &lost_str, "Games Lost"); + spawn_stat_cell(grid, &fastest_str, "Fastest Win"); + spawn_stat_cell(grid, &avg_time_str, "Avg Time"); + spawn_stat_cell(grid, &best_score_str, "Best Score"); + spawn_stat_cell(grid, &best_streak_str, "Best Streak"); }); - // Weekly goals - card.spawn(( - Text::new("Weekly Goals"), + // --- per-mode bests section --- + // Three rows, one per supported mode. Time Attack uses session-level + // scoring (count of wins inside a 10-minute window) so a per-game + // best wouldn't compose; Daily uses Classic scoring and so already + // contributes to the Classic row. + body.spawn(( + Text::new("Per-mode bests"), font_section.clone(), - TextColor(TEXT_SECONDARY), + TextColor(STATE_INFO), )); - for goal in WEEKLY_GOALS { - let pv = p.weekly_goal_progress.get(goal.id).copied().unwrap_or(0); - card.spawn(( - Text::new(format!(" {}: {}/{}", goal.description, pv, goal.target)), + body.spawn(Node { + flex_direction: FlexDirection::Column, + width: Val::Percent(100.0), + row_gap: VAL_SPACE_2, + ..default() + }) + .with_children(|column| { + spawn_per_mode_bests_row( + column, + "Classic", + stats.classic_best_score, + stats.classic_fastest_win_seconds, + &font_row, + ); + spawn_per_mode_bests_row( + column, + "Zen", + stats.zen_best_score, + stats.zen_fastest_win_seconds, + &font_row, + ); + spawn_per_mode_bests_row( + column, + "Challenge", + stats.challenge_best_score, + stats.challenge_fastest_win_seconds, + &font_row, + ); + }); + + // --- progression section --- + if let Some(p) = progress { + body.spawn(( + Text::new("Progression"), + font_section.clone(), + TextColor(STATE_INFO), + )); + + let level_str = format_stat_value(p.level); + let xp_str = format_stat_value(p.total_xp as u32); + let next_label = xp_to_next_level_label(p.total_xp, p.level); + let daily_str = format_stat_value(p.daily_challenge_streak); + let challenge_str = challenge_progress_label(p.challenge_index); + + body.spawn(Node { + flex_direction: FlexDirection::Row, + flex_wrap: FlexWrap::Wrap, + justify_content: JustifyContent::Center, + align_items: AlignItems::FlexStart, + column_gap: VAL_SPACE_4, + row_gap: VAL_SPACE_3, + width: Val::Percent(100.0), + ..default() + }) + .with_children(|grid| { + spawn_stat_cell(grid, &level_str, "Level"); + spawn_stat_cell(grid, &xp_str, "Total XP"); + spawn_stat_cell(grid, &next_label, "Next Level"); + spawn_stat_cell(grid, &daily_str, "Daily Streak"); + spawn_stat_cell(grid, &challenge_str, "Challenge"); + }); + + // Weekly goals + body.spawn(( + Text::new("Weekly Goals"), + font_section.clone(), + TextColor(TEXT_SECONDARY), + )); + for goal in WEEKLY_GOALS { + let pv = p.weekly_goal_progress.get(goal.id).copied().unwrap_or(0); + body.spawn(( + Text::new(format!(" {}: {}/{}", goal.description, pv, goal.target)), + font_row.clone(), + TextColor(TEXT_PRIMARY), + )); + } + + // Unlocks line + body.spawn(( + Text::new(format!( + "Card Backs: {} | Backgrounds: {}", + format_id_list(&p.unlocked_card_backs), + format_id_list(&p.unlocked_backgrounds), + )), font_row.clone(), - TextColor(TEXT_PRIMARY), + TextColor(TEXT_SECONDARY), )); } - // Unlocks line - card.spawn(( - Text::new(format!( - "Card Backs: {} | Backgrounds: {}", - format_id_list(&p.unlocked_card_backs), - format_id_list(&p.unlocked_backgrounds), - )), + // --- Time Attack section --- + if let Some(ta) = time_attack + && ta.active { + let mins = (ta.remaining_secs / 60.0).floor() as u64; + let secs = (ta.remaining_secs % 60.0).floor() as u64; + body.spawn(( + Text::new(format!( + "Time Attack \u{2014} {mins}m {secs:02}s left | Wins: {}", + ta.wins + )), + font_section.clone(), + TextColor(STATE_WARNING), + )); + } + + // --- Latest replay caption --- + // Surfaces the most recent winning game so the player can spot + // whether their last victory has been recorded. The Watch + // Replay action below is what the player clicks to revisit it. + let replay_caption = match latest_replay { + Some(r) => format!("Latest win: {}", format_replay_caption(r)), + None => "No replay recorded yet \u{2014} win a game first.".to_string(), + }; + body.spawn(( + Text::new(replay_caption), font_row.clone(), TextColor(TEXT_SECONDARY), )); - } - - // --- Time Attack section --- - if let Some(ta) = time_attack - && ta.active { - let mins = (ta.remaining_secs / 60.0).floor() as u64; - let secs = (ta.remaining_secs % 60.0).floor() as u64; - card.spawn(( - Text::new(format!( - "Time Attack \u{2014} {mins}m {secs:02}s left | Wins: {}", - ta.wins - )), - font_section.clone(), - TextColor(STATE_WARNING), - )); - } - - // --- Latest replay caption --- - // Surfaces the most recent winning game so the player can spot - // whether their last victory has been recorded. The Watch - // Replay action below is what the player clicks to revisit it. - let replay_caption = match latest_replay { - Some(r) => format!("Latest win: {}", format_replay_caption(r)), - None => "No replay recorded yet \u{2014} win a game first.".to_string(), - }; - card.spawn(( - Text::new(replay_caption), - font_row.clone(), - TextColor(TEXT_SECONDARY), - )); + }); spawn_modal_actions(card, |actions| { // The Watch Replay button is always rendered so the @@ -1088,6 +1152,36 @@ mod tests { ); } + #[test] + fn stats_modal_body_is_scrollable() { + let mut app = headless_app(); + app.world_mut() + .resource_mut::>() + .press(KeyCode::KeyS); + app.update(); + + let count = app + .world_mut() + .query::<&StatsScrollable>() + .iter(app.world()) + .count(); + assert_eq!( + count, 1, + "Stats modal must spawn exactly one StatsScrollable body" + ); + + let mut q = app + .world_mut() + .query_filtered::<&Node, With>(); + let nodes: Vec<&Node> = q.iter(app.world()).collect(); + assert_ne!( + nodes[0].max_height, + Val::Auto, + "scrollable body must set a non-default max_height" + ); + assert_eq!(nodes[0].overflow, Overflow::scroll_y()); + } + #[test] fn stats_screen_renders_three_per_mode_bests_rows() { // Open the Stats overlay and assert three [`PerModeBestsRow`]