//! Per-frame value-text updater systems and their label helpers. use super::*; use solitaire_core::DrawStockConfig; use solitaire_data::{AnimSpeed, settings::Theme}; use crate::font_plugin::FontResource; use crate::progress_plugin::ProgressResource; use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource}; use crate::theme::ThemeThumbnailCache; use crate::ui_modal::ModalScrim; use crate::ui_theme::{BORDER_SUBTLE_HC, HighContrastBackground, HighContrastBorder}; /// Spawns the Settings panel when `SettingsScreen` becomes `true`; /// despawns it when it becomes `false`. #[allow(clippy::too_many_arguments)] pub(super) fn sync_settings_panel_visibility( screen: Res, active_tab: Res, panels: Query>, other_modal_scrims: Query<(), (With, Without)>, scroll_nodes: Query<&ScrollPosition, With>, mut scroll_pos: ResMut, mut commands: Commands, settings: Res, sync_status: Option>, progress: Option>, font_res: Option>, theme_registry: Option>, theme_thumbs: Option>, card_images: Option>, ) { if !screen.is_changed() { return; } if screen.0 { if panels.is_empty() && other_modal_scrims.is_empty() { build_panel( &mut commands, &settings.0, sync_status.as_deref(), progress.as_deref(), font_res.as_deref(), theme_registry.as_deref(), theme_thumbs.as_deref(), card_images.as_deref(), scroll_pos.0, active_tab.0, ); } } else { // Save the current scroll offset before despawning the panel. if let Ok(sp) = scroll_nodes.single() { scroll_pos.0 = sp.0.y; } for entity in &panels { commands.entity(entity).despawn(); } } } /// Gathers the spawn context (sync label, unlocks, theme snapshot, /// back-override flag) and spawns the panel showing `active_tab`. /// Shared by [`sync_settings_panel_visibility`] (open) and /// [`rebuild_panel_on_tab_change`] (tab switch). #[allow(clippy::too_many_arguments)] fn build_panel( commands: &mut Commands, settings: &Settings, sync_status: Option<&SyncStatusResource>, progress: Option<&ProgressResource>, font_res: Option<&FontResource>, theme_registry: Option<&crate::theme::ThemeRegistry>, theme_thumbs: Option<&ThemeThumbnailCache>, card_images: Option<&crate::card_plugin::CardImageSet>, scroll_offset: f32, active_tab: SettingsTab, ) { let status_label = sync_status.map_or_else( || "Status: local only".to_string(), |s| sync_status_label(&s.0), ); let unlocked_backs = progress.map_or(&[0][..], |p| p.0.unlocked_card_backs.as_slice()); let unlocked_bgs = progress.map_or(&[0][..], |p| p.0.unlocked_backgrounds.as_slice()); // Snapshot themes by id, display_name and (optional) // thumbnail pair so spawn_settings_panel doesn't have to // know about the registry / cache shapes. Empty when // ThemeRegistryPlugin isn't installed (tests under // MinimalPlugins) — the picker row simply won't render. // Missing thumbnails (cache not ready, or partial user // theme) leave `thumbnails: None` so the chip renders its // plain-text fallback instead of a broken sprite. let themes: Vec = theme_registry .map(|r| { r.iter() .map(|e| ThemePickerEntry { id: e.id.clone(), display_name: e.display_name.clone(), thumbnails: theme_thumbs .and_then(|c| c.get(&e.id)) .filter(|p| p.is_fully_populated()) .cloned(), }) .collect() }) .unwrap_or_default(); // The active card-art theme can supply its own back image — // see `card_plugin::CardImageSet::theme_back`. When that is // populated the legacy "Card Back" picker has no visible // effect, so we render it muted with an explanatory caption // rather than letting the player click swatches that do // nothing. Absent under `MinimalPlugins`; treated as // "no override" in that case. let theme_overrides_back = card_images.is_some_and(|cs| cs.theme_back.is_some()); spawn_settings_panel( commands, settings, &status_label, unlocked_backs, unlocked_bgs, &themes, scroll_offset, font_res, theme_overrides_back, active_tab, ); } /// Rebuilds the open panel when [`ActiveSettingsTab`] changes (tab chip /// clicked). No-op while the panel is closed — the next open reads the /// resource directly. #[allow(clippy::too_many_arguments)] pub(super) fn rebuild_panel_on_tab_change( active_tab: Res, panels: Query>, mut scroll_pos: ResMut, mut commands: Commands, settings: Res, sync_status: Option>, progress: Option>, font_res: Option>, theme_registry: Option>, theme_thumbs: Option>, card_images: Option>, ) { if !active_tab.is_changed() || active_tab.is_added() { return; } if panels.is_empty() { return; } for entity in &panels { commands.entity(entity).despawn(); } // A fresh tab starts at the top; per-tab scroll memory isn't worth // the bookkeeping now that no tab needs much scrolling. scroll_pos.0 = 0.0; build_panel( &mut commands, &settings.0, sync_status.as_deref(), progress.as_deref(), font_res.as_deref(), theme_registry.as_deref(), theme_thumbs.as_deref(), card_images.as_deref(), 0.0, active_tab.0, ); } /// Keeps the sync-status text node current while the panel is open. pub(super) fn update_sync_status_text( sync_status: Option>, mut text_nodes: Query<&mut Text, With>, ) { let Some(status) = sync_status else { return; }; if !status.is_changed() { return; } let label = sync_status_label(&status.0); for mut text in &mut text_nodes { **text = label.clone(); } } pub(super) fn update_card_back_text( settings: Res, mut text_nodes: Query<&mut Text, With>, ) { if !settings.is_changed() { return; } for mut text in &mut text_nodes { **text = card_back_label(settings.0.selected_card_back); } } pub(super) fn update_background_text( settings: Res, mut text_nodes: Query<&mut Text, With>, ) { if !settings.is_changed() { return; } for mut text in &mut text_nodes { **text = background_label(settings.0.selected_background); } } pub(super) fn update_anim_speed_text( settings: Res, mut text_nodes: Query<&mut Text, With>, ) { if !settings.is_changed() { return; } for mut text in &mut text_nodes { **text = anim_speed_label(&settings.0.animation_speed); } } pub(super) fn update_color_blind_text( settings: Res, mut text_nodes: Query<&mut Text, With>, ) { if !settings.is_changed() { return; } for mut text in &mut text_nodes { **text = color_blind_label(settings.0.color_blind_mode); } } pub(super) fn update_high_contrast_text( settings: Res, mut text_nodes: Query<&mut Text, With>, ) { if !settings.is_changed() { return; } for mut text in &mut text_nodes { **text = on_off_label(settings.0.high_contrast_mode); } } /// Repaints `BorderColor` on every entity tagged with /// [`HighContrastBorder`] based on `Settings::high_contrast_mode`. /// Off → the marker's `default_color`; on → `BORDER_SUBTLE_HC` /// (`#a0a0a0`). Compares against the current border colour and /// only mutates when different so Bevy's change-detection /// doesn't trigger repaints every frame. /// /// Spec at `design-system.md` §Accessibility (#2): under HC, /// outlines boost from `#505050` (BORDER_STRONG) to `#a0a0a0` so /// modal panels, popover edges, and focus-ring carriers stay /// legible on low-quality displays / for low-vision users. /// /// Tagged sites in v0.21.x: the modal scaffold's card border /// (`ui_modal::spawn_modal`). More sites can be tagged in /// follow-ups by adding `HighContrastBorder::with_default(...)` /// to their spawn tuple. pub(super) fn update_high_contrast_borders( settings: Res, mut borders: Query<(&HighContrastBorder, &mut BorderColor)>, ) { let high_contrast = settings.0.high_contrast_mode; for (marker, mut border) in borders.iter_mut() { let target = if high_contrast { BORDER_SUBTLE_HC } else { marker.default_color }; // Only mutate when actually different — avoids per-frame // change-detection churn. `border.left` is representative // because every tagged site uses `BorderColor::all(...)`. if border.left != target { *border = BorderColor::all(target); } } } /// Repaints `BackgroundColor` on every entity tagged with /// [`HighContrastBackground`] based on `Settings::high_contrast_mode`. /// Off → the marker's `default_color`; on → `BORDER_SUBTLE_HC` /// (`#a0a0a0`). Compares against the current background and only /// mutates when different so Bevy's change-detection doesn't trigger /// repaints every frame. /// /// Parallel to [`update_high_contrast_borders`]. Same on/off rule, /// same change-suppression idiom, different colour channel — /// `BackgroundColor` for tick marks, decorative strips, fine /// separators that paint their shape directly rather than via a /// `BorderColor` on a wider Node. /// /// Tagged sites in v0.21.x: the replay overlay's 1 px scrub track /// + 5 quarter-mark notch ticks (`replay_overlay::spawn_overlay`). /// /// More sites can be tagged in follow-ups by adding /// `HighContrastBackground::with_default(...)` to their spawn tuple. pub(super) fn update_high_contrast_backgrounds( settings: Res, mut backgrounds: Query<(&HighContrastBackground, &mut BackgroundColor)>, ) { let high_contrast = settings.0.high_contrast_mode; for (marker, mut bg) in backgrounds.iter_mut() { let target = if high_contrast { marker.hc_color } else { marker.default_color }; if bg.0 != target { *bg = BackgroundColor(target); } } } pub(super) fn update_reduce_motion_text( settings: Res, mut text_nodes: Query<&mut Text, With>, ) { if !settings.is_changed() { return; } for mut text in &mut text_nodes { **text = on_off_label(settings.0.reduce_motion_mode); } } pub(super) fn update_touch_input_mode_text( settings: Res, mut text_nodes: Query<&mut Text, With>, ) { if !settings.is_changed() { return; } for mut text in &mut text_nodes { **text = touch_input_mode_label(&settings.0.touch_input_mode); } } /// Refreshes the live "Winnable deals only" toggle value in the /// Gameplay section whenever `SettingsResource` changes (button click, /// hand-edited `settings.json` reload, etc.). pub(super) fn update_winnable_deals_only_text( settings: Res, mut text_nodes: Query<&mut Text, With>, ) { if !settings.is_changed() { return; } for mut text in &mut text_nodes { **text = winnable_deals_only_label(settings.0.winnable_deals_only); } } /// Refreshes the live "Share usage data" toggle value in the Privacy section /// whenever `SettingsResource` changes. pub(super) fn update_analytics_enabled_text( settings: Res, mut text_nodes: Query<&mut Text, With>, ) { if !settings.is_changed() { return; } for mut text in &mut text_nodes { **text = on_off_label(settings.0.analytics_enabled); } } /// Refreshes the live "Smart window size" toggle value whenever /// `SettingsResource` changes. The flag is stored negatively as /// `disable_smart_default_size`, so the label inverts. pub(super) fn update_smart_default_size_text( settings: Res, mut text_nodes: Query<&mut Text, With>, ) { if !settings.is_changed() { return; } for mut text in &mut text_nodes { **text = smart_default_size_label(!settings.0.disable_smart_default_size); } } /// Refreshes the live tooltip-delay value in the Gameplay section /// whenever `SettingsResource` changes (slider buttons, hand-edited /// settings.json reload, etc.). pub(super) fn update_tooltip_delay_text( settings: Res, mut text_nodes: Query<&mut Text, With>, ) { if !settings.is_changed() { return; } for mut text in &mut text_nodes { **text = tooltip_delay_label(settings.0.tooltip_delay_secs); } } /// Refreshes the live time-bonus-multiplier value in the Gameplay /// section whenever `SettingsResource` changes. pub(super) fn update_time_bonus_multiplier_text( settings: Res, mut text_nodes: Query<&mut Text, With>, ) { if !settings.is_changed() { return; } for mut text in &mut text_nodes { **text = time_bonus_label(settings.0.time_bonus_multiplier); } } /// Refreshes the live replay-playback per-move-interval value in the /// Gameplay section whenever `SettingsResource` changes (slider buttons, /// hand-edited settings.json reload, etc.). pub(super) fn update_replay_move_interval_text( settings: Res, mut text_nodes: Query<&mut Text, With>, ) { if !settings.is_changed() { return; } for mut text in &mut text_nodes { **text = replay_move_interval_label(settings.0.replay_move_interval_secs); } } pub(super) fn card_back_label(idx: usize) -> String { if idx == 0 { "Default".to_string() } else { format!("Style {idx}") } } pub(super) fn background_label(idx: usize) -> String { if idx == 0 { "Default".to_string() } else { format!("Style {idx}") } } pub(super) fn sync_status_label(status: &SyncStatus) -> String { match status { SyncStatus::Idle => "Status: idle".to_string(), SyncStatus::Syncing => "Status: syncing…".to_string(), SyncStatus::LastSynced(t) => { let secs = chrono::Utc::now() .signed_duration_since(*t) .num_seconds() .max(0); if secs < 60 { format!("Last synced: {secs}s ago") } else { format!("Last synced: {}m ago", secs / 60) } } SyncStatus::Error(e) => format!("Sync error: {e}"), } } pub(super) fn draw_mode_label(mode: &DrawStockConfig) -> String { match mode { DrawStockConfig::DrawOne => "Draw 1".into(), DrawStockConfig::DrawThree => "Draw 3".into(), } } pub(super) fn anim_speed_label(speed: &AnimSpeed) -> String { match speed { AnimSpeed::Normal => "Normal".into(), AnimSpeed::Fast => "Fast".into(), AnimSpeed::Instant => "Instant".into(), } } pub(super) fn theme_label(theme: &Theme) -> String { match theme { Theme::Green => "Green".into(), Theme::Blue => "Blue".into(), Theme::Dark => "Dark".into(), } } pub(super) fn color_blind_label(enabled: bool) -> String { if enabled { "ON".into() } else { "OFF".into() } } /// Generic ON/OFF label shared by the high-contrast and reduce- /// motion accessibility toggles. Same format as /// [`color_blind_label`] / [`winnable_deals_only_label`] — /// keeping all simple boolean toggle rows visually uniform. pub(super) fn on_off_label(enabled: bool) -> String { if enabled { "ON".into() } else { "OFF".into() } } /// Display string for the "Winnable deals only" toggle. Mirrors /// [`color_blind_label`] — "ON" / "OFF" — so the layout is uniform /// with the rest of the Gameplay-section toggles. pub(super) fn winnable_deals_only_label(enabled: bool) -> String { if enabled { "ON".into() } else { "OFF".into() } } pub(super) fn touch_input_mode_label(mode: &solitaire_data::settings::TouchInputMode) -> String { use solitaire_data::settings::TouchInputMode; match mode { TouchInputMode::OneTap => "One-tap".into(), TouchInputMode::TapToSelect => "Tap to select".into(), } } /// Display string for the "Smart window size" toggle. The argument /// is the *enabled* state (i.e. the inverse of the underlying /// `disable_smart_default_size` field) so reading the label gives /// the player intuitive ON/OFF semantics. pub(super) fn smart_default_size_label(enabled: bool) -> String { if enabled { "ON".into() } else { "OFF".into() } } /// Formats the tooltip-hover delay for display in the Settings panel. /// `0.0` reads as `"Instant"` so the zero-delay case has a name; any /// other value prints as `"{n:.1} s"` (e.g. `"0.5 s"`, `"1.2 s"`). pub(super) fn tooltip_delay_label(secs: f32) -> String { if secs <= 0.0 { "Instant".into() } else { format!("{secs:.1} s") } } /// Formats the cosmetic time-bonus multiplier for display in the /// Settings panel. `0.0` reads as `"Off"` so the player understands the /// time-bonus row will be hidden; any other value prints as /// `"{n:.1}×"` (e.g. `"1.0×"`, `"1.5×"`). pub(super) fn time_bonus_label(value: f32) -> String { if value <= 0.0 { "Off".into() } else { format!("{value:.1}×") } } /// Formats the replay-playback per-move interval for display in the /// Settings panel. Mirrors [`tooltip_delay_label`] for parity — the /// readout is `"{n:.2} s/move"` (e.g. `"0.45 s/move"`, `"0.10 s/move"`), /// using two decimal places because the step is 0.05 s. pub(super) fn replay_move_interval_label(secs: f32) -> String { format!("{secs:.2} s/move") }