fix(engine): scroll the modals whose content overflows the viewport
Smoke-test report: the Achievements list isn't scrollable. With 19 achievements the panel overflows the modal at the 800x600 minimum window and the bottom rows are clipped. The same problem applies to several other modals whose content has grown over the v0.13–v0.15 rounds. Mirrors the existing SettingsPanelScrollable pattern from settings_plugin: each modal's body Node gets Overflow::scroll_y() plus a max_height (Val::Vh(70.0) for most, Val::Vh(50.0) for the leaderboard's variable-length ranking section), a marker component so the scroll system can find it, and a sibling system that routes MouseWheel events into the body's ScrollPosition. Five modals fixed: - Achievements: 19 rows clearly overflow; AchievementsScrollable + scroll_achievements_panel. - Help: ~28 reference rows overflow at 800x600; HelpScrollable + scroll_help_panel. - Stats: 8-cell primary grid + per-mode bests + progression + weekly goals + unlocks + Time Attack readout + replay caption is enough content to overflow once the player has any progress; StatsScrollable + scroll_stats_panel. - Profile: Sync + Progression + 14-day calendar + up to 18 unlocked achievements + Stats summary overflows once a few achievements unlock; ProfileScrollable + scroll_profile_panel. - Leaderboard: 10-row cap is at the edge of overflow on 800x600 with long display names; LeaderboardScrollable + scroll_leaderboard_panel (max_height = 50vh — the ranking section is the only variable-length part). Home modal NOT scrolled — five mode cards plus a Cancel button were sized to fit at 800x600 by design and adding scroll there would clutter the launcher. Five new tests pin the contract: each modal's body has the scrollable marker, a non-default max_height, and Overflow::scroll_y. Defer-list (small UX nits surfaced during the sweep, not fixed here): - Modal close-on-click-outside is missing across the board; would need Interaction on ModalScrim in ui_modal. - ModalButton hover doesn't set a pointer cursor. - Tab focus on modal open is initialised on the next frame instead of the same frame; first Tab press selects rather than focus already being on the primary. These are bigger touches than the scroll fix and don't fit a 30-LOC budget; surfacing for a follow-up round. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use chrono::{Local, Timelike, Utc};
|
use chrono::{Local, Timelike, Utc};
|
||||||
use solitaire_core::achievement::{
|
use solitaire_core::achievement::{
|
||||||
@@ -48,6 +49,19 @@ pub struct AchievementsScreen;
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct AchievementRow;
|
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).
|
/// All per-player achievement records (one per known achievement).
|
||||||
#[derive(Resource, Debug, Clone)]
|
#[derive(Resource, Debug, Clone)]
|
||||||
pub struct AchievementsResource(pub Vec<AchievementRecord>);
|
pub struct AchievementsResource(pub Vec<AchievementRecord>);
|
||||||
@@ -96,6 +110,11 @@ impl Plugin for AchievementPlugin {
|
|||||||
.add_message::<XpAwardedEvent>()
|
.add_message::<XpAwardedEvent>()
|
||||||
.add_message::<InfoToastEvent>()
|
.add_message::<InfoToastEvent>()
|
||||||
.add_message::<ToggleAchievementsRequestEvent>()
|
.add_message::<ToggleAchievementsRequestEvent>()
|
||||||
|
// `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::<MouseWheel>()
|
||||||
// Run after GameMutation (so GameWonEvent is available), after
|
// Run after GameMutation (so GameWonEvent is available), after
|
||||||
// StatsUpdate (so stats reflect this win), and after ProgressUpdate
|
// StatsUpdate (so stats reflect this win), and after ProgressUpdate
|
||||||
// (so daily_challenge_streak is up to date for daily_devotee).
|
// (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, toggle_achievements_screen)
|
||||||
.add_systems(Update, handle_achievements_close_button)
|
.add_systems(Update, handle_achievements_close_button)
|
||||||
|
.add_systems(Update, scroll_achievements_panel)
|
||||||
// Event-driven unlock: observe `ReplayPlaybackState` and unlock
|
// Event-driven unlock: observe `ReplayPlaybackState` and unlock
|
||||||
// `cinephile` the first time playback runs to natural completion.
|
// `cinephile` the first time playback runs to natural completion.
|
||||||
// Reads the resource via `Option<Res<_>>` so headless tests that
|
// Reads the resource via `Option<Res<_>>` 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<MouseWheel>,
|
||||||
|
mut scrollables: Query<&mut ScrollPosition, With<AchievementsScrollable>>,
|
||||||
|
) {
|
||||||
|
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(
|
fn spawn_achievements_screen(
|
||||||
commands: &mut Commands,
|
commands: &mut Commands,
|
||||||
records: &[AchievementRecord],
|
records: &[AchievementRecord],
|
||||||
@@ -424,75 +476,95 @@ fn spawn_achievements_screen(
|
|||||||
spawn_modal(commands, AchievementsScreen, Z_MODAL_PANEL, |card| {
|
spawn_modal(commands, AchievementsScreen, Z_MODAL_PANEL, |card| {
|
||||||
spawn_modal_header(card, header, font_res);
|
spawn_modal_header(card, header, font_res);
|
||||||
|
|
||||||
// Achievement rows — unlocked first, then locked alphabetical.
|
// Scrollable body — the achievements list grows to ~19 rows which
|
||||||
let mut sorted: Vec<_> = records.iter().collect();
|
// overflows the modal on the 800x600 minimum window. Wrapping the
|
||||||
sorted.sort_by_key(|r| (!r.unlocked, r.id.clone()));
|
// 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 {
|
for record in &sorted {
|
||||||
let def = achievement_by_id(&record.id);
|
let def = achievement_by_id(&record.id);
|
||||||
let (name, description) = def.map_or((record.id.as_str(), ""), |d| (d.name, d.description));
|
let (name, description) =
|
||||||
|
def.map_or((record.id.as_str(), ""), |d| (d.name, d.description));
|
||||||
|
|
||||||
// Hide secret locked achievements so they remain a surprise.
|
// Hide secret locked achievements so they remain a surprise.
|
||||||
let is_secret = def.is_some_and(|d| d.secret);
|
let is_secret = def.is_some_and(|d| d.secret);
|
||||||
if is_secret && !record.unlocked {
|
if is_secret && !record.unlocked {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let (name_color, desc_color, prefix) = if record.unlocked {
|
let (name_color, desc_color, prefix) = if record.unlocked {
|
||||||
(ACCENT_PRIMARY, TEXT_PRIMARY, "\u{2713} ")
|
(ACCENT_PRIMARY, TEXT_PRIMARY, "\u{2713} ")
|
||||||
} else {
|
} else {
|
||||||
(TEXT_DISABLED, TEXT_DISABLED, "\u{25CB} ")
|
(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((
|
body.spawn((
|
||||||
Node {
|
Node {
|
||||||
flex_direction: FlexDirection::Column,
|
flex_direction: FlexDirection::Column,
|
||||||
row_gap: VAL_SPACE_1,
|
row_gap: VAL_SPACE_1,
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
AchievementRow,
|
AchievementRow,
|
||||||
Tooltip::new(tooltip_text),
|
Tooltip::new(tooltip_text),
|
||||||
))
|
))
|
||||||
.with_children(|row| {
|
.with_children(|row| {
|
||||||
row.spawn((
|
row.spawn((
|
||||||
Text::new(format!("{prefix}{name}")),
|
Text::new(format!("{prefix}{name}")),
|
||||||
font_name.clone(),
|
font_name.clone(),
|
||||||
TextColor(name_color),
|
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_actions(card, |actions| {
|
||||||
spawn_modal_button(
|
spawn_modal_button(
|
||||||
@@ -895,6 +967,64 @@ mod tests {
|
|||||||
assert_eq!(count, 0);
|
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<AchievementsScrollable>>();
|
||||||
|
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
|
// format_reward
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
//! is an optional accelerator. Listed shortcuts are grouped by intent —
|
//! is an optional accelerator. Listed shortcuts are grouped by intent —
|
||||||
//! gameplay, modes, and overlays.
|
//! gameplay, modes, and overlays.
|
||||||
|
|
||||||
|
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
|
||||||
use crate::events::HelpRequestEvent;
|
use crate::events::HelpRequestEvent;
|
||||||
@@ -24,6 +25,16 @@ pub struct HelpScreen;
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct HelpCloseButton;
|
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
|
/// Spawns and despawns the help / controls overlay shown when the player
|
||||||
/// clicks the "Help" HUD button or presses `F1`. All hotkeys and gesture
|
/// clicks the "Help" HUD button or presses `F1`. All hotkeys and gesture
|
||||||
/// guides live here.
|
/// guides live here.
|
||||||
@@ -32,7 +43,14 @@ pub struct HelpPlugin;
|
|||||||
impl Plugin for HelpPlugin {
|
impl Plugin for HelpPlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.add_message::<HelpRequestEvent>()
|
app.add_message::<HelpRequestEvent>()
|
||||||
.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::<MouseWheel>()
|
||||||
|
.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<MouseWheel>,
|
||||||
|
mut scrollables: Query<&mut ScrollPosition, With<HelpScrollable>>,
|
||||||
|
) {
|
||||||
|
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.
|
/// Each entry in the controls reference table.
|
||||||
struct ControlRow {
|
struct ControlRow {
|
||||||
keys: &'static str,
|
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(commands, HelpScreen, Z_MODAL_PANEL, |card| {
|
||||||
spawn_modal_header(card, "Controls", font_res);
|
spawn_modal_header(card, "Controls", font_res);
|
||||||
|
|
||||||
for section in CONTROL_SECTIONS {
|
// Scrollable body — the controls reference is six sections totalling
|
||||||
// Section title in muted text — distinguishes from row content.
|
// ~28 rows, which overflows the modal on the 800x600 minimum
|
||||||
card.spawn((
|
// window. Wrapping in an `Overflow::scroll_y()` Node with a
|
||||||
Text::new(section.title),
|
// constrained `max_height` keeps every row reachable; the Done
|
||||||
font_section.clone(),
|
// button below stays fixed outside the scroll.
|
||||||
TextColor(TEXT_SECONDARY),
|
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.
|
// Each row is a flex-row: kbd-style chip + description.
|
||||||
for row in section.rows {
|
for row in section.rows {
|
||||||
card.spawn(Node {
|
body.spawn(Node {
|
||||||
flex_direction: FlexDirection::Row,
|
flex_direction: FlexDirection::Row,
|
||||||
align_items: AlignItems::Center,
|
align_items: AlignItems::Center,
|
||||||
column_gap: VAL_SPACE_3,
|
column_gap: VAL_SPACE_3,
|
||||||
..default()
|
..default()
|
||||||
})
|
})
|
||||||
.with_children(|line| {
|
.with_children(|line| {
|
||||||
// The hotkey rendered as a small chip with a border —
|
// The hotkey rendered as a small chip with a border —
|
||||||
// visual cue that it's a key reference, not part of
|
// visual cue that it's a key reference, not part of
|
||||||
// the description text.
|
// the description text.
|
||||||
line.spawn((
|
line.spawn((
|
||||||
Node {
|
Node {
|
||||||
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
|
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
|
||||||
min_width: Val::Px(64.0),
|
min_width: Val::Px(64.0),
|
||||||
justify_content: JustifyContent::Center,
|
justify_content: JustifyContent::Center,
|
||||||
border: UiRect::all(Val::Px(1.0)),
|
border: UiRect::all(Val::Px(1.0)),
|
||||||
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
|
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
BorderColor::all(BORDER_SUBTLE),
|
BorderColor::all(BORDER_SUBTLE),
|
||||||
))
|
))
|
||||||
.with_children(|chip| {
|
.with_children(|chip| {
|
||||||
chip.spawn((
|
chip.spawn((
|
||||||
Text::new(row.keys),
|
Text::new(row.keys),
|
||||||
font_kbd.clone(),
|
font_kbd.clone(),
|
||||||
|
TextColor(TEXT_PRIMARY),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
line.spawn((
|
||||||
|
Text::new(row.description),
|
||||||
|
font_row.clone(),
|
||||||
TextColor(TEXT_PRIMARY),
|
TextColor(TEXT_PRIMARY),
|
||||||
));
|
));
|
||||||
});
|
});
|
||||||
line.spawn((
|
}
|
||||||
Text::new(row.description),
|
|
||||||
font_row.clone(),
|
// Section spacer — small empty box. Keeps each section
|
||||||
TextColor(TEXT_PRIMARY),
|
// 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_actions(card, |actions| {
|
||||||
spawn_modal_button(
|
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::<ButtonInput<KeyCode>>()
|
||||||
|
.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<HelpScrollable>>();
|
||||||
|
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]
|
#[test]
|
||||||
fn pressing_f1_twice_closes_help_screen() {
|
fn pressing_f1_twice_closes_help_screen() {
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
//! When the provider does not support leaderboards (e.g. `LocalOnlyProvider`)
|
//! When the provider does not support leaderboards (e.g. `LocalOnlyProvider`)
|
||||||
//! the panel shows "Not available" immediately.
|
//! the panel shows "Not available" immediately.
|
||||||
|
|
||||||
|
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
|
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
|
||||||
use solitaire_data::settings::SyncBackend;
|
use solitaire_data::settings::SyncBackend;
|
||||||
@@ -23,7 +24,7 @@ use crate::ui_modal::{
|
|||||||
};
|
};
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
ACCENT_PRIMARY, BORDER_SUBTLE, STATE_INFO, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
|
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<Task<Result<Vec<LeaderboardEntry>, String>>>)
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct LeaderboardScreen;
|
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.
|
/// Marker on the "Opt In" button inside the leaderboard panel.
|
||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
struct LeaderboardOptInButton;
|
struct LeaderboardOptInButton;
|
||||||
@@ -98,6 +111,11 @@ impl Plugin for LeaderboardPlugin {
|
|||||||
.init_resource::<OptInTask>()
|
.init_resource::<OptInTask>()
|
||||||
.init_resource::<OptOutTask>()
|
.init_resource::<OptOutTask>()
|
||||||
.add_message::<ToggleLeaderboardRequestEvent>()
|
.add_message::<ToggleLeaderboardRequestEvent>()
|
||||||
|
// `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::<MouseWheel>()
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
@@ -112,7 +130,8 @@ impl Plugin for LeaderboardPlugin {
|
|||||||
poll_opt_out_task,
|
poll_opt_out_task,
|
||||||
)
|
)
|
||||||
.chain(),
|
.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.
|
/// 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<MouseWheel>,
|
||||||
|
mut scrollables: Query<&mut ScrollPosition, With<LeaderboardScrollable>>,
|
||||||
|
) {
|
||||||
|
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(
|
fn handle_leaderboard_close_button(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
close_buttons: Query<&Interaction, (With<LeaderboardCloseButton>, Changed<Interaction>)>,
|
close_buttons: Query<&Interaction, (With<LeaderboardCloseButton>, Changed<Interaction>)>,
|
||||||
@@ -420,76 +466,94 @@ fn spawn_leaderboard_screen(
|
|||||||
BackgroundColor(BORDER_SUBTLE),
|
BackgroundColor(BORDER_SUBTLE),
|
||||||
));
|
));
|
||||||
|
|
||||||
match data {
|
// Scrollable data section — caps at top 10 rows today, but on the
|
||||||
LeaderboardResource::Idle => {
|
// 800x600 minimum window the header + caption + opt-in row + 10
|
||||||
card.spawn((
|
// entries crowds the modal. Wrapping in `Overflow::scroll_y()`
|
||||||
Text::new("Fetching\u{2026}"),
|
// with a `max_height` keeps every entry reachable and survives
|
||||||
font_status.clone(),
|
// any future expansion of the row cap.
|
||||||
TextColor(STATE_INFO),
|
card.spawn((
|
||||||
));
|
LeaderboardScrollable,
|
||||||
}
|
ScrollPosition::default(),
|
||||||
LeaderboardResource::Error(_) => {
|
Node {
|
||||||
card.spawn((
|
flex_direction: FlexDirection::Column,
|
||||||
Text::new("Couldn't reach the leaderboard. Try again later."),
|
row_gap: VAL_SPACE_2,
|
||||||
font_status.clone(),
|
max_height: Val::Vh(50.0),
|
||||||
TextColor(TEXT_SECONDARY),
|
overflow: Overflow::scroll_y(),
|
||||||
));
|
..default()
|
||||||
}
|
},
|
||||||
LeaderboardResource::Loaded(rows) if rows.is_empty() => {
|
))
|
||||||
card.spawn((
|
.with_children(|body| {
|
||||||
Text::new("No entries yet \u{2014} sync and opt in to appear here."),
|
match data {
|
||||||
font_row.clone(),
|
LeaderboardResource::Idle => {
|
||||||
TextColor(TEXT_SECONDARY),
|
body.spawn((
|
||||||
));
|
Text::new("Fetching\u{2026}"),
|
||||||
}
|
font_status.clone(),
|
||||||
LeaderboardResource::Loaded(rows) => {
|
TextColor(STATE_INFO),
|
||||||
// Column headers
|
));
|
||||||
card.spawn(Node {
|
}
|
||||||
flex_direction: FlexDirection::Row,
|
LeaderboardResource::Error(_) => {
|
||||||
column_gap: VAL_SPACE_4,
|
body.spawn((
|
||||||
..default()
|
Text::new("Couldn't reach the leaderboard. Try again later."),
|
||||||
})
|
font_status.clone(),
|
||||||
.with_children(|row| {
|
TextColor(TEXT_SECONDARY),
|
||||||
header_cell(row, "#", 30.0, &font_header);
|
));
|
||||||
header_cell(row, "Player", 160.0, &font_header);
|
}
|
||||||
header_cell(row, "Best Score", 100.0, &font_header);
|
LeaderboardResource::Loaded(rows) if rows.is_empty() => {
|
||||||
header_cell(row, "Fastest Win", 110.0, &font_header);
|
body.spawn((
|
||||||
});
|
Text::new("No entries yet \u{2014} sync and opt in to appear here."),
|
||||||
|
font_row.clone(),
|
||||||
let mut sorted = rows.to_vec();
|
TextColor(TEXT_SECONDARY),
|
||||||
sorted.sort_by_key(|e| std::cmp::Reverse(e.best_score.unwrap_or(0)));
|
));
|
||||||
|
}
|
||||||
for (i, entry) in sorted.iter().take(10).enumerate() {
|
LeaderboardResource::Loaded(rows) => {
|
||||||
// Top three get accent treatments to highlight the
|
// Column headers
|
||||||
// podium without leaning on hand-picked metallic
|
body.spawn(Node {
|
||||||
// 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 {
|
|
||||||
flex_direction: FlexDirection::Row,
|
flex_direction: FlexDirection::Row,
|
||||||
column_gap: VAL_SPACE_4,
|
column_gap: VAL_SPACE_4,
|
||||||
..default()
|
..default()
|
||||||
})
|
})
|
||||||
.with_children(|row| {
|
.with_children(|row| {
|
||||||
data_cell(row, &format!("{}", i + 1), 30.0, rank_color, &font_row);
|
header_cell(row, "#", 30.0, &font_header);
|
||||||
data_cell(row, &entry.display_name, 160.0, TEXT_PRIMARY, &font_row);
|
header_cell(row, "Player", 160.0, &font_header);
|
||||||
data_cell(row, &score_str, 100.0, TEXT_PRIMARY, &font_row);
|
header_cell(row, "Best Score", 100.0, &font_header);
|
||||||
data_cell(row, &time_str, 110.0, TEXT_PRIMARY, &font_row);
|
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_actions(card, |actions| {
|
||||||
spawn_modal_button(
|
spawn_modal_button(
|
||||||
@@ -646,6 +710,34 @@ mod tests {
|
|||||||
assert_eq!(count, 1);
|
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<LeaderboardScrollable>>();
|
||||||
|
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]
|
#[test]
|
||||||
fn pressing_l_twice_dismisses_screen() {
|
fn pressing_l_twice_dismisses_screen() {
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
//! summary in a single scrollable panel. Spawned on the first `P` keypress and
|
//! summary in a single scrollable panel. Spawned on the first `P` keypress and
|
||||||
//! despawned on the second.
|
//! despawned on the second.
|
||||||
|
|
||||||
|
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||||
use bevy::input::ButtonInput;
|
use bevy::input::ButtonInput;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use chrono::{Duration, Local, NaiveDate};
|
use chrono::{Duration, Local, NaiveDate};
|
||||||
@@ -60,10 +61,60 @@ pub struct ProfilePlugin;
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct ProfileCloseButton;
|
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 {
|
impl Plugin for ProfilePlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.add_message::<ToggleProfileRequestEvent>()
|
app.add_message::<ToggleProfileRequestEvent>()
|
||||||
.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::<MouseWheel>()
|
||||||
|
.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<MouseWheel>,
|
||||||
|
mut scrollables: Query<&mut ScrollPosition, With<ProfileScrollable>>,
|
||||||
|
) {
|
||||||
|
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(commands, ProfileScreen, Z_MODAL_PANEL, |card| {
|
||||||
spawn_modal_header(card, "Profile", font_res);
|
spawn_modal_header(card, "Profile", font_res);
|
||||||
|
|
||||||
// First-launch welcome — only when the player has zero XP and
|
// Scrollable body — the Profile panel renders sync info,
|
||||||
// zero daily streak, so the profile doesn't read as a wall of
|
// progression (incl. a 14-day calendar), every unlocked
|
||||||
// zeros to a brand-new player.
|
// achievement (up to ~18), and a stats summary, which can
|
||||||
if let Some(p) = progress
|
// overflow the modal on the 800x600 minimum window once the
|
||||||
&& p.0.total_xp == 0
|
// player has unlocked several achievements. The Done action
|
||||||
&& p.0.daily_challenge_streak == 0
|
// stays fixed outside the scroll.
|
||||||
{
|
card.spawn((
|
||||||
card.spawn((
|
ProfileScrollable,
|
||||||
Text::new("Welcome! Play games to earn XP and unlock achievements."),
|
ScrollPosition::default(),
|
||||||
font_section.clone(),
|
Node {
|
||||||
TextColor(ACCENT_PRIMARY),
|
flex_direction: FlexDirection::Column,
|
||||||
Node {
|
row_gap: VAL_SPACE_1,
|
||||||
margin: UiRect {
|
max_height: Val::Vh(70.0),
|
||||||
bottom: VAL_SPACE_2,
|
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()
|
||||||
},
|
},
|
||||||
..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((
|
// ── Sync section ────────────────────────────────────────────
|
||||||
Text::new(" No achievements unlocked yet."),
|
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(),
|
font_row.clone(),
|
||||||
TextColor(TEXT_SECONDARY),
|
TextColor(TEXT_SECONDARY),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// ── Statistics summary section ──────────────────────────────
|
// ── Progression section ─────────────────────────────────────
|
||||||
spawn_spacer(card, VAL_SPACE_2);
|
spawn_spacer(body, VAL_SPACE_2);
|
||||||
card.spawn((
|
body.spawn((
|
||||||
Text::new("Statistics Summary"),
|
Text::new("Progression"),
|
||||||
font_section.clone(),
|
font_section.clone(),
|
||||||
TextColor(STATE_INFO),
|
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),
|
|
||||||
));
|
));
|
||||||
card.spawn((
|
if let Some(p) = progress {
|
||||||
Text::new(format!(
|
let prog = &p.0;
|
||||||
"Win streak: {} current, {} best | Best score: {}",
|
let (xp_span, xp_done) = xp_progress(prog.total_xp, prog.level);
|
||||||
s.win_streak_current, s.win_streak_best, best_score_str,
|
let pct = if xp_span == 0 {
|
||||||
)),
|
100u64
|
||||||
font_row.clone(),
|
} else {
|
||||||
TextColor(TEXT_PRIMARY),
|
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_actions(card, |actions| {
|
||||||
spawn_modal_button(
|
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::<ButtonInput<KeyCode>>()
|
||||||
|
.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<ProfileScrollable>>();
|
||||||
|
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]
|
#[test]
|
||||||
fn pressing_p_twice_closes_profile_screen() {
|
fn pressing_p_twice_closes_profile_screen() {
|
||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||||
use bevy::input::ButtonInput;
|
use bevy::input::ButtonInput;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use solitaire_data::{
|
use solitaire_data::{
|
||||||
@@ -118,6 +119,18 @@ pub struct ReplaySelectorCaption;
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct PerModeBestsRow;
|
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.
|
/// Registers stats resources, update systems, and the UI toggle.
|
||||||
pub struct StatsPlugin {
|
pub struct StatsPlugin {
|
||||||
/// Where to persist stats. `None` disables all file I/O (for tests).
|
/// Where to persist stats. `None` disables all file I/O (for tests).
|
||||||
@@ -167,6 +180,10 @@ impl Plugin for StatsPlugin {
|
|||||||
.add_message::<InfoToastEvent>()
|
.add_message::<InfoToastEvent>()
|
||||||
.add_message::<ToggleStatsRequestEvent>()
|
.add_message::<ToggleStatsRequestEvent>()
|
||||||
.add_message::<WinStreakMilestoneEvent>()
|
.add_message::<WinStreakMilestoneEvent>()
|
||||||
|
// `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::<MouseWheel>()
|
||||||
// record_abandoned must read `move_count` BEFORE handle_new_game
|
// record_abandoned must read `move_count` BEFORE handle_new_game
|
||||||
// clobbers it with a fresh game. These are NOT in StatsUpdate because
|
// clobbers it with a fresh game. These are NOT in StatsUpdate because
|
||||||
// StatsUpdate (as a set) is ordered after GameMutation by external
|
// StatsUpdate (as a set) is ordered after GameMutation by external
|
||||||
@@ -195,7 +212,34 @@ impl Plugin for StatsPlugin {
|
|||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(handle_replay_selector_buttons, repaint_replay_selector_caption).chain(),
|
(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<MouseWheel>,
|
||||||
|
mut scrollables: Query<&mut ScrollPosition, With<StatsScrollable>>,
|
||||||
|
) {
|
||||||
|
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(commands, StatsScreen, Z_MODAL_PANEL, |card| {
|
||||||
spawn_modal_header(card, "Statistics", font_res);
|
spawn_modal_header(card, "Statistics", font_res);
|
||||||
|
|
||||||
// First-launch caption — sits above the grid as gentle nudge so
|
// Scrollable body — the Stats panel renders an 8-cell grid plus
|
||||||
// the wall of em-dashes reads as "nothing to track yet" rather
|
// multiple sections (per-mode bests, progression, weekly goals,
|
||||||
// than as broken state.
|
// unlocks, optional Time Attack, latest replay caption) and
|
||||||
if is_first_launch {
|
// overflows the modal on the 800x600 minimum window. Wrapping
|
||||||
card.spawn((
|
// in an `Overflow::scroll_y()` Node with a constrained
|
||||||
Text::new("Play a game to start tracking stats."),
|
// `max_height` keeps every cell reachable; the Watch Replay /
|
||||||
TextFont {
|
// Done action row stays fixed outside the scroll.
|
||||||
font_size: TYPE_CAPTION,
|
card.spawn((
|
||||||
..default()
|
StatsScrollable,
|
||||||
},
|
ScrollPosition::default(),
|
||||||
TextColor(TEXT_SECONDARY),
|
Node {
|
||||||
Node {
|
flex_direction: FlexDirection::Column,
|
||||||
margin: UiRect {
|
row_gap: VAL_SPACE_3,
|
||||||
bottom: VAL_SPACE_2,
|
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()
|
||||||
},
|
},
|
||||||
..default()
|
TextColor(TEXT_SECONDARY),
|
||||||
},
|
Node {
|
||||||
));
|
margin: UiRect {
|
||||||
}
|
bottom: VAL_SPACE_2,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
// --- primary stat cells grid ---
|
// --- primary stat cells grid ---
|
||||||
card.spawn(Node {
|
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, &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 {
|
|
||||||
flex_direction: FlexDirection::Row,
|
flex_direction: FlexDirection::Row,
|
||||||
flex_wrap: FlexWrap::Wrap,
|
flex_wrap: FlexWrap::Wrap,
|
||||||
justify_content: JustifyContent::Center,
|
justify_content: JustifyContent::Center,
|
||||||
@@ -669,68 +657,144 @@ fn spawn_stats_screen(
|
|||||||
..default()
|
..default()
|
||||||
})
|
})
|
||||||
.with_children(|grid| {
|
.with_children(|grid| {
|
||||||
spawn_stat_cell(grid, &level_str, "Level");
|
spawn_stat_cell(grid, &win_rate_str, "Win Rate");
|
||||||
spawn_stat_cell(grid, &xp_str, "Total XP");
|
spawn_stat_cell(grid, &played_str, "Games Played");
|
||||||
spawn_stat_cell(grid, &next_label, "Next Level");
|
spawn_stat_cell(grid, &won_str, "Games Won");
|
||||||
spawn_stat_cell(grid, &daily_str, "Daily Streak");
|
spawn_stat_cell(grid, &lost_str, "Games Lost");
|
||||||
spawn_stat_cell(grid, &challenge_str, "Challenge");
|
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
|
// --- per-mode bests section ---
|
||||||
card.spawn((
|
// Three rows, one per supported mode. Time Attack uses session-level
|
||||||
Text::new("Weekly Goals"),
|
// 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(),
|
font_section.clone(),
|
||||||
TextColor(TEXT_SECONDARY),
|
TextColor(STATE_INFO),
|
||||||
));
|
));
|
||||||
for goal in WEEKLY_GOALS {
|
body.spawn(Node {
|
||||||
let pv = p.weekly_goal_progress.get(goal.id).copied().unwrap_or(0);
|
flex_direction: FlexDirection::Column,
|
||||||
card.spawn((
|
width: Val::Percent(100.0),
|
||||||
Text::new(format!(" {}: {}/{}", goal.description, pv, goal.target)),
|
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(),
|
font_row.clone(),
|
||||||
TextColor(TEXT_PRIMARY),
|
TextColor(TEXT_SECONDARY),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unlocks line
|
// --- Time Attack section ---
|
||||||
card.spawn((
|
if let Some(ta) = time_attack
|
||||||
Text::new(format!(
|
&& ta.active {
|
||||||
"Card Backs: {} | Backgrounds: {}",
|
let mins = (ta.remaining_secs / 60.0).floor() as u64;
|
||||||
format_id_list(&p.unlocked_card_backs),
|
let secs = (ta.remaining_secs % 60.0).floor() as u64;
|
||||||
format_id_list(&p.unlocked_backgrounds),
|
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(),
|
font_row.clone(),
|
||||||
TextColor(TEXT_SECONDARY),
|
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| {
|
spawn_modal_actions(card, |actions| {
|
||||||
// The Watch Replay button is always rendered so the
|
// 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::<ButtonInput<KeyCode>>()
|
||||||
|
.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<StatsScrollable>>();
|
||||||
|
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]
|
#[test]
|
||||||
fn stats_screen_renders_three_per_mode_bests_rows() {
|
fn stats_screen_renders_three_per_mode_bests_rows() {
|
||||||
// Open the Stats overlay and assert three [`PerModeBestsRow`]
|
// Open the Stats overlay and assert three [`PerModeBestsRow`]
|
||||||
|
|||||||
Reference in New Issue
Block a user