diff --git a/solitaire_engine/src/leaderboard_plugin.rs b/solitaire_engine/src/leaderboard_plugin.rs index 49df78a..edd3c84 100644 --- a/solitaire_engine/src/leaderboard_plugin.rs +++ b/solitaire_engine/src/leaderboard_plugin.rs @@ -24,13 +24,13 @@ use crate::font_plugin::FontResource; use crate::settings_plugin::{SettingsResource, SettingsStoragePath}; use crate::sync_plugin::SyncProviderResource; use crate::ui_modal::{ - ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions, - spawn_modal_button, spawn_modal_header, + ButtonVariant, ModalScrim, ScrimDismissible, spawn_empty_state, spawn_modal, + spawn_modal_actions, spawn_modal_button, spawn_modal_header, }; use crate::ui_theme::{ - ACCENT_PRIMARY, BG_ELEVATED, BORDER_SUBTLE, RADIUS_SM, STATE_INFO, TEXT_DISABLED, TEXT_PRIMARY, - TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, - Z_MODAL_PANEL, Z_PAUSE_DIALOG, + ACCENT_PRIMARY, BG_ELEVATED, BORDER_SUBTLE, RADIUS_SM, TEXT_DISABLED, TEXT_PRIMARY, + TEXT_SECONDARY, TYPE_BODY, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, Z_MODAL_PANEL, + Z_PAUSE_DIALOG, }; // --------------------------------------------------------------------------- @@ -536,11 +536,6 @@ fn spawn_leaderboard_screen( font_size: TYPE_CAPTION, ..default() }; - let font_status = TextFont { - font: font_handle.clone(), - font_size: TYPE_BODY_LG, - ..default() - }; let font_row = TextFont { font: font_handle.clone(), font_size: TYPE_BODY, @@ -647,30 +642,31 @@ fn spawn_leaderboard_screen( .with_children(|body| { match data { LeaderboardResource::Idle => { - body.spawn(( - Text::new("Fetching\u{2026}"), - font_status.clone(), - TextColor(STATE_INFO), - )); + spawn_empty_state( + body, + "\u{21BB}", + "Fetching the leaderboard\u{2026}", + None, + font_res, + ); } LeaderboardResource::Error(_) => { - body.spawn(( - Text::new("Couldn't reach the leaderboard. Try again later."), - font_status.clone(), - TextColor(TEXT_SECONDARY), - )); + spawn_empty_state( + body, + "!", + "Couldn't reach the leaderboard.", + Some("Try again later."), + font_res, + ); } LeaderboardResource::Loaded(rows) if rows.is_empty() => { - body.spawn(( - Text::new("Be the first on the leaderboard."), - font_status.clone(), - TextColor(TEXT_PRIMARY), - )); - body.spawn(( - Text::new("Win a game and opt in to appear here."), - font_row.clone(), - TextColor(TEXT_SECONDARY), - )); + spawn_empty_state( + body, + "\u{2660}", + "Be the first on the leaderboard.", + Some("Win a game and opt in to appear here."), + font_res, + ); } LeaderboardResource::Loaded(rows) => { // Column headers diff --git a/solitaire_engine/src/stats_plugin.rs b/solitaire_engine/src/stats_plugin.rs index f84ed43..4cf6f0c 100644 --- a/solitaire_engine/src/stats_plugin.rs +++ b/solitaire_engine/src/stats_plugin.rs @@ -30,7 +30,7 @@ use crate::ui_modal::{ButtonVariant, ModalButton, spawn_modal_button}; use crate::ui_theme::{ ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, STATE_INFO, STATE_WARNING, STREAK_MILESTONES, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, - TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, + TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, }; /// Bevy resource wrapping the current stats. @@ -719,25 +719,17 @@ pub(crate) fn spawn_stats_body( }, )) .with_children(|body| { - // First-launch caption — sits above the grid as gentle nudge so + // First-launch empty state (Phase L) — sits above the grid 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() - }, - TextColor(TEXT_SECONDARY), - Node { - margin: UiRect { - bottom: VAL_SPACE_2, - ..default() - }, - ..default() - }, - )); + crate::ui_modal::spawn_empty_state( + body, + "\u{2663}", + "Play a game to start tracking stats.", + None, + font_res, + ); } // --- primary stat cells grid --- @@ -893,6 +885,20 @@ pub(crate) fn spawn_replays_body( selected_index: usize, font_res: Option<&FontResource>, ) { + // Standard empty state (Phase L) — the selector / Watch / Copy + // controls only spawn when there is something to select, so their + // handlers all no-op via their existing empty-query paths. + if replays.is_empty() { + crate::ui_modal::spawn_empty_state( + card, + "\u{2666}", + "No replays yet.", + Some("Every win records a replay here automatically."), + font_res, + ); + return; + } + let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default(); let font_row = TextFont { font: font_handle, diff --git a/solitaire_engine/src/theme_store_plugin.rs b/solitaire_engine/src/theme_store_plugin.rs index a635854..3c64389 100644 --- a/solitaire_engine/src/theme_store_plugin.rs +++ b/solitaire_engine/src/theme_store_plugin.rs @@ -32,8 +32,8 @@ use crate::resources::TokioRuntimeResource; use crate::settings_plugin::{SettingsPanel, SettingsResource}; use crate::theme::{ImportError, ThemeRegistry, import_theme, refresh_registry}; use crate::ui_modal::{ - ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions, - spawn_modal_button, spawn_modal_header, + ButtonVariant, ModalScrim, ScrimDismissible, spawn_empty_state, spawn_modal, + spawn_modal_actions, spawn_modal_button, spawn_modal_header, }; use crate::ui_theme::{ BORDER_SUBTLE, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, @@ -528,25 +528,31 @@ fn spawn_store_modal( match catalog_state { CatalogState::Idle | CatalogState::Loading => { - card.spawn(( - Text::new("Loading catalog…"), - body_font.clone(), - TextColor(TEXT_SECONDARY), - )); + spawn_empty_state( + card, + "\u{21BB}", + "Loading the catalog\u{2026}", + None, + font_res, + ); } CatalogState::Error(msg) => { - card.spawn(( - Text::new(format!("Could not load the catalog: {msg}")), - body_font.clone(), - TextColor(TEXT_SECONDARY), - )); + spawn_empty_state( + card, + "!", + "Couldn't load the catalog.", + Some(msg.as_str()), + font_res, + ); } CatalogState::Loaded(entries) if entries.is_empty() => { - card.spawn(( - Text::new("The server has no themes yet."), - body_font.clone(), - TextColor(TEXT_SECONDARY), - )); + spawn_empty_state( + card, + "#", + "The server has no themes yet.", + Some("Themes dropped into the server's theme_store folder appear here."), + font_res, + ); } CatalogState::Loaded(entries) => { for entry in entries { diff --git a/solitaire_engine/src/ui_modal.rs b/solitaire_engine/src/ui_modal.rs index 9973da1..adc8287 100644 --- a/solitaire_engine/src/ui_modal.rs +++ b/solitaire_engine/src/ui_modal.rs @@ -60,9 +60,9 @@ use crate::settings_plugin::SettingsResource; use crate::ui_theme::{ ACCENT_PRIMARY, ACCENT_PRIMARY_HOVER, ACCENT_SECONDARY, BG_BASE, BG_ELEVATED, BG_ELEVATED_HI, BG_ELEVATED_PRESSED, BG_ELEVATED_TOP, BORDER_STRONG, BORDER_SUBTLE, HighContrastBorder, - MOTION_MODAL_SECS, RADIUS_LG, RADIUS_MD, RADIUS_SM, SCRIM, STATE_SUCCESS, TEXT_PRIMARY, - TEXT_SECONDARY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3, - VAL_SPACE_4, VAL_SPACE_5, scaled_duration, + MOTION_MODAL_SECS, RADIUS_LG, RADIUS_MD, RADIUS_SM, SCRIM, STATE_SUCCESS, TEXT_DISABLED, + TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_DISPLAY, + TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, VAL_SPACE_5, scaled_duration, }; // --------------------------------------------------------------------------- @@ -328,6 +328,80 @@ pub fn spawn_modal_body_text( parent.spawn((ModalBody, Text::new(text.into()), font, TextColor(color))); } +/// Standard empty / loading / error surface (Phase L): a large glyph, +/// a headline, and an optional secondary detail line, centred in the +/// available space. Every async or possibly-empty surface (leaderboard, +/// replays, theme store, first-launch stats) renders through this so +/// "nothing here yet" reads identically everywhere. +/// +/// A next-step action is composition, not configuration: spawn a +/// [`spawn_modal_button`] (or any control) in the same parent right +/// after this call. +/// +/// `glyph` MUST come from FiraMono-covered ranges — card suits +/// (U+2660–2666), Arrows (U+2190–21FF), or ASCII. The Geometric Shapes +/// block renders as tofu on Android (§10 / §11). +pub fn spawn_empty_state( + parent: &mut ChildSpawnerCommands, + glyph: &str, + headline: &str, + detail: Option<&str>, + font_res: Option<&FontResource>, +) { + let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default(); + let font_glyph = TextFont { + font: font_handle.clone(), + font_size: TYPE_DISPLAY, + ..default() + }; + let font_headline = TextFont { + font: font_handle.clone(), + font_size: TYPE_BODY_LG, + ..default() + }; + let font_detail = TextFont { + font: font_handle, + font_size: TYPE_BODY, + ..default() + }; + + parent + .spawn(( + EmptyStateBlock, + Node { + flex_direction: FlexDirection::Column, + align_items: AlignItems::Center, + row_gap: VAL_SPACE_2, + padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_5), + width: Val::Percent(100.0), + ..default() + }, + )) + .with_children(|block| { + block.spawn(( + Text::new(glyph.to_string()), + font_glyph, + TextColor(TEXT_DISABLED), + )); + block.spawn(( + Text::new(headline.to_string()), + font_headline, + TextColor(TEXT_PRIMARY), + )); + if let Some(detail) = detail { + block.spawn(( + Text::new(detail.to_string()), + font_detail, + TextColor(TEXT_SECONDARY), + )); + } + }); +} + +/// Marker on every [`spawn_empty_state`] block, mostly for tests. +#[derive(Component, Debug)] +pub struct EmptyStateBlock; + /// Spawns the bottom actions row — flex-row with primary right-aligned. /// The closure populates the row's buttons via `spawn_modal_button`. /// @@ -1184,4 +1258,62 @@ mod tests { "exactly one of the two stacked dismissible modals should remain" ); } + + // ----------------------------------------------------------------------- + // Phase L: standard empty state + // ----------------------------------------------------------------------- + + /// The helper spawns glyph + headline (+ detail when given) under one + /// marked block, so every empty surface renders identically. + #[test] + fn empty_state_spawns_expected_structure() { + let mut app = App::new(); + app.add_plugins(MinimalPlugins); + { + let world = app.world_mut(); + let mut commands = world.commands(); + commands.spawn(Node::default()).with_children(|parent| { + spawn_empty_state( + parent, + "\u{2660}", + "Nothing here.", + Some("Do the thing."), + None, + ); + spawn_empty_state(parent, "!", "No detail variant.", None, None); + }); + } + app.update(); + + let blocks = app + .world_mut() + .query_filtered::>() + .iter(app.world()) + .count(); + assert_eq!(blocks, 2, "each call spawns exactly one marked block"); + + let texts: Vec = app + .world_mut() + .query::<&Text>() + .iter(app.world()) + .map(|t| t.0.clone()) + .collect(); + for expected in [ + "\u{2660}", + "Nothing here.", + "Do the thing.", + "!", + "No detail variant.", + ] { + assert!( + texts.iter().any(|t| t == expected), + "missing text node {expected:?}; got {texts:?}" + ); + } + assert_eq!( + texts.len(), + 5, + "the detail-less variant must not spawn a detail node" + ); + } }