//! Settings panel construction: the spawn function and row builders. use super::*; use std::path::PathBuf; use solitaire_data::Settings; use solitaire_data::settings::SyncBackend; #[cfg(not(target_arch = "wasm32"))] use crate::assets::user_theme_dir; use crate::events::InfoToastEvent; use crate::font_plugin::FontResource; use crate::theme::ThemeThumbnailPair; #[cfg(not(target_arch = "wasm32"))] use crate::theme::{ImportError, import_theme, refresh_registry}; use crate::ui_focus::FocusRow; use crate::ui_modal::{ ButtonVariant, spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, spawn_tab_chip, }; use crate::ui_theme::{ BG_BASE, BG_ELEVATED, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, STATE_SUCCESS, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL, }; use crate::ui_tooltip::Tooltip; /// Spawns the Settings modal. /// /// `theme_overrides_back` is `true` when the active card-art theme /// supplies its own back (`CardImageSet::theme_back == Some(_)`). The /// "Card Back" picker is rendered with a small caption and the /// swatches are hidden in this state — the theme's back wins /// regardless of which legacy back is selected, so the picker would /// be inert otherwise. #[allow(clippy::too_many_arguments)] pub(super) fn spawn_settings_panel( commands: &mut Commands, settings: &Settings, sync_status: &str, unlocked_card_backs: &[usize], unlocked_backgrounds: &[usize], themes: &[ThemePickerEntry], scroll_offset: f32, font_res: Option<&FontResource>, theme_overrides_back: bool, active_tab: SettingsTab, ) { spawn_modal(commands, SettingsPanel, Z_MODAL_PANEL, |card| { spawn_modal_header(card, "Settings", font_res); // Tab chips — one group of rows on screen at a time, no // scroll-hunting (Phase A, docs/ui-redesign-2026-07.md). card.spawn(( FocusRow, Node { flex_direction: FlexDirection::Row, flex_wrap: FlexWrap::Wrap, column_gap: VAL_SPACE_2, row_gap: VAL_SPACE_2, margin: UiRect::bottom(VAL_SPACE_3), ..default() }, )) .with_children(|row| { for tab in SettingsTab::ALL { tab_chip(row, tab, tab == active_tab, font_res); } }); // Scrollable body — most tabs fit without scrolling, but the // Appearance pickers can still overflow short windows. The Done // button below stays fixed outside the scroll so it's always one // click away. card.spawn(( SettingsPanelScrollable, SettingsScrollNode, ScrollPosition(Vec2::new(0.0, scroll_offset)), Node { flex_direction: FlexDirection::Column, row_gap: VAL_SPACE_3, max_height: Val::Vh(60.0), overflow: Overflow::scroll_y(), padding: UiRect::bottom(Val::Px(96.0)), ..default() }, )) .with_children(|body| match active_tab { SettingsTab::Audio => spawn_audio_tab(body, settings, font_res), SettingsTab::Gameplay => spawn_gameplay_tab(body, settings, font_res), SettingsTab::Appearance => spawn_appearance_tab( body, settings, unlocked_card_backs, unlocked_backgrounds, themes, theme_overrides_back, font_res, ), SettingsTab::Accessibility => spawn_accessibility_tab(body, settings, font_res), SettingsTab::Account => spawn_account_tab(body, settings, sync_status, font_res), }); // Done is the only action — primary so the player always knows // how to leave the modal. `O` toggles it the same way. spawn_modal_actions(card, |actions| { spawn_modal_button( actions, SettingsButton::Done, "Done", Some("O"), ButtonVariant::Primary, font_res, ); }); }); } /// One Settings tab chip — thin wrapper over the shared /// [`spawn_tab_chip`] widget so Settings and the You hub stay visually /// identical. fn tab_chip( parent: &mut ChildSpawnerCommands, tab: SettingsTab, active: bool, font_res: Option<&FontResource>, ) { spawn_tab_chip( parent, SettingsTabButton(tab), tab.label(), active, font_res, ); } /// Audio tab: the two volume rows. fn spawn_audio_tab( body: &mut ChildSpawnerCommands, settings: &Settings, font_res: Option<&FontResource>, ) { volume_row( body, "SFX Volume", settings.sfx_volume, SfxVolumeText, SettingsButton::SfxDown, SettingsButton::SfxUp, "Lower sound effects volume.", "Raise sound effects volume.", font_res, ); volume_row( body, "Music Volume", settings.music_volume, MusicVolumeText, SettingsButton::MusicDown, SettingsButton::MusicUp, "Lower music and ambience volume.", "Raise music and ambience volume.", font_res, ); } /// Gameplay tab: rules and pacing. fn spawn_gameplay_tab( body: &mut ChildSpawnerCommands, settings: &Settings, font_res: Option<&FontResource>, ) { toggle_row( body, "Draw Mode", DrawModeText, draw_mode_label(&settings.draw_mode), SettingsButton::ToggleDrawMode, "Switch between Draw 1 and Draw 3. Takes effect next deal.", font_res, ); toggle_row( body, "Winnable deals only", WinnableDealsOnlyText, winnable_deals_only_label(settings.winnable_deals_only), SettingsButton::ToggleWinnableDealsOnly, "When on, fresh Classic deals are filtered through a solver \ (may take a moment when on).", font_res, ); toggle_row( body, "Anim Speed", AnimSpeedText, anim_speed_label(&settings.animation_speed), SettingsButton::CycleAnimSpeed, "Cycle animation speed: Normal, Fast, Instant.", font_res, ); time_bonus_multiplier_row(body, settings.time_bonus_multiplier, font_res); replay_move_interval_row(body, settings.replay_move_interval_secs, font_res); toggle_row( body, "Smart window size", SmartDefaultSizeText, smart_default_size_label(!settings.disable_smart_default_size), SettingsButton::ToggleSmartDefaultSize, "When ON, fresh launches resize the window to ~70 % of the \ monitor. OFF pins the 1280\u{00D7}800 baseline. Saved \ window size always wins.", font_res, ); } /// Appearance tab: felt, deck art, backgrounds, and the theme system. #[allow(clippy::too_many_arguments)] fn spawn_appearance_tab( body: &mut ChildSpawnerCommands, settings: &Settings, unlocked_card_backs: &[usize], unlocked_backgrounds: &[usize], themes: &[ThemePickerEntry], theme_overrides_back: bool, font_res: Option<&FontResource>, ) { toggle_row( body, "Theme", ThemeText, theme_label(&settings.theme), SettingsButton::ToggleTheme, "Cycle felt color: Green, Blue, Dark.", font_res, ); if theme_overrides_back { // The active theme provides its own back; the legacy // picker has no visible effect, so we replace its // swatch row with an informational caption. The // player's `selected_card_back` value still // round-trips through `settings.json` — the moment // they switch to a theme without a back, the picker // re-appears with their previous choice intact. picker_row_overridden_by_theme(body, "Card Back", font_res); } else { picker_row( body, "Card Back", unlocked_card_backs, settings.selected_card_back, SettingsButton::SelectCardBack, "Choose your deck art. New backs unlock at higher levels.", font_res, ); } picker_row( body, "Background", unlocked_backgrounds, settings.selected_background, SettingsButton::SelectBackground, "Choose your felt art. New felts unlock at higher levels.", font_res, ); // Card-art theme picker — only renders when the registry has // entries (production: always; tests: only when // ThemeRegistryPlugin is installed). if !themes.is_empty() { theme_picker_row( body, "Card Theme", themes, &settings.selected_theme_id, "Choose card-face artwork. Imported themes appear here.", font_res, ); } #[cfg(not(target_arch = "wasm32"))] import_themes_row(body, font_res); #[cfg(not(target_arch = "wasm32"))] theme_store_row(body, font_res); } /// Accessibility tab: perception and input assists, gathered from the /// old Gameplay/Cosmetic sections so they're findable in one place. fn spawn_accessibility_tab( body: &mut ChildSpawnerCommands, settings: &Settings, font_res: Option<&FontResource>, ) { toggle_row( body, "Color-blind Mode", ColorBlindText, color_blind_label(settings.color_blind_mode), SettingsButton::ToggleColorBlind, "Show shape glyphs alongside suit colors. Suit-blind friendly.", font_res, ); toggle_row( body, "High Contrast", HighContrastText, on_off_label(settings.high_contrast_mode), SettingsButton::ToggleHighContrast, "Boosts foreground + suit-red glyphs to higher-luminance variants for low-vision readers and low-quality displays.", font_res, ); toggle_row( body, "Reduce Motion", ReduceMotionText, on_off_label(settings.reduce_motion_mode), SettingsButton::ToggleReduceMotion, "Skips card-slide animations and other non-essential motion. Cards snap instantly to their target.", font_res, ); toggle_row( body, "Touch Input Mode", TouchInputModeText, touch_input_mode_label(&settings.touch_input_mode), SettingsButton::ToggleTouchInputMode, "One-tap: tap a card to auto-move it. Tap to select: first tap selects a card, second tap on a pile moves it.", font_res, ); tooltip_delay_row(body, settings.tooltip_delay_secs, font_res); } /// Account tab: privacy opt-in (when analytics is configured) and the /// sync backend controls. fn spawn_account_tab( body: &mut ChildSpawnerCommands, settings: &Settings, sync_status: &str, font_res: Option<&FontResource>, ) { // --- Privacy (only shown when a Matomo URL is configured) --- if settings.matomo_url.is_some() { section_label(body, "Privacy", font_res); toggle_row( body, "Share usage data", AnalyticsEnabledText, on_off_label(settings.analytics_enabled), SettingsButton::ToggleAnalytics, "Sends anonymous game events to Matomo for aggregate analytics.", font_res, ); } // --- Sync --- section_label(body, "Sync", font_res); sync_row(body, sync_status, &settings.sync_backend, font_res); } /// Section divider — small lavender label inside the scrollable body. pub(super) fn section_label( parent: &mut ChildSpawnerCommands, title: &str, font_res: Option<&FontResource>, ) { let font = TextFont { font: font_res.map(|f| f.0.clone()).unwrap_or_default(), font_size: TYPE_BODY, ..default() }; parent.spawn((Text::new(title), font, TextColor(TEXT_SECONDARY))); } /// `Label 0.80 [−] [+]` — used for SFX and Music volume rows. /// /// `tooltip_down` / `tooltip_up` are attached to the `−` / `+` buttons /// respectively so each glyph carries a one-line reminder of which channel /// it adjusts. #[allow(clippy::too_many_arguments)] pub(super) fn volume_row( parent: &mut ChildSpawnerCommands, label: &str, value: f32, marker: Marker, btn_down: SettingsButton, btn_up: SettingsButton, tooltip_down: &'static str, tooltip_up: &'static str, font_res: Option<&FontResource>, ) { let label_font = label_text_font(font_res); let value_font = value_text_font(font_res); // Row spans the full body width with a flex-grow spacer between // the left-aligned label and the right-aligned controls cluster. // Without `width: 100%` + the spacer, the label / value / buttons // bunch against the left edge and a varying-length value (e.g. // "0.80" → "1.00") shifts the +/− buttons sideways frame to // frame, visually overlapping with adjacent UI on small windows. parent .spawn(Node { flex_direction: FlexDirection::Row, align_items: AlignItems::Center, column_gap: VAL_SPACE_2, width: Val::Percent(100.0), ..default() }) .with_children(|row| { row.spawn(( Text::new(label.to_string()), label_font, TextColor(TEXT_SECONDARY), )); // Spacer: takes up all remaining horizontal space so the // controls cluster sits flush against the right edge. row.spawn(Node { flex_grow: 1.0, ..default() }); // Controls cluster — value + decrement + increment held // together so the buttons stay in fixed positions even // as the value text width varies. row.spawn(Node { flex_direction: FlexDirection::Row, align_items: AlignItems::Center, column_gap: VAL_SPACE_2, ..default() }) .with_children(|cluster| { cluster.spawn(( marker, Text::new(format!("{value:.2}")), value_font, TextColor(TEXT_PRIMARY), )); icon_button(cluster, "−", btn_down, tooltip_down, font_res); icon_button(cluster, "+", btn_up, tooltip_up, font_res); }); }); } /// `Tooltip Delay 0.5 s [−] [+]` — slider row for the player-tunable /// tooltip-hover dwell. Mirrors [`volume_row`] (label, current value, /// decrement, increment) but formats the value via [`tooltip_delay_label`] /// so `0.0` reads as `"Instant"` and other values as `"{n:.1} s"`. pub(super) fn tooltip_delay_row( parent: &mut ChildSpawnerCommands, value_secs: f32, font_res: Option<&FontResource>, ) { let label_font = label_text_font(font_res); let value_font = value_text_font(font_res); parent .spawn(Node { flex_direction: FlexDirection::Row, align_items: AlignItems::Center, column_gap: VAL_SPACE_2, width: Val::Percent(100.0), ..default() }) .with_children(|row| { row.spawn(( Text::new("Tooltip Delay".to_string()), label_font, TextColor(TEXT_SECONDARY), )); row.spawn(Node { flex_grow: 1.0, ..default() }); row.spawn(Node { flex_direction: FlexDirection::Row, align_items: AlignItems::Center, column_gap: VAL_SPACE_2, ..default() }) .with_children(|cluster| { cluster.spawn(( TooltipDelayText, Text::new(tooltip_delay_label(value_secs)), value_font, TextColor(TEXT_PRIMARY), )); icon_button( cluster, "−", SettingsButton::TooltipDelayDown, "Shorten the hover delay before tooltips appear.", font_res, ); icon_button( cluster, "+", SettingsButton::TooltipDelayUp, "Lengthen the hover delay before tooltips appear.", font_res, ); }); }); } /// `Time bonus 1.0× [−] [+]` — slider row for the cosmetic /// `Settings::time_bonus_multiplier`. Mirrors [`tooltip_delay_row`] /// (label, current value, decrement, increment) but formats the value /// via [`time_bonus_label`] so `0.0` reads as `"Off"` and other values /// as `"{n:.1}×"`. The multiplier is **cosmetic** — adjusting it /// changes only the win-modal score breakdown, not the canonical /// scores recorded in stats / achievements / leaderboards. pub(super) fn time_bonus_multiplier_row( parent: &mut ChildSpawnerCommands, value: f32, font_res: Option<&FontResource>, ) { let label_font = label_text_font(font_res); let value_font = value_text_font(font_res); parent .spawn(Node { flex_direction: FlexDirection::Row, align_items: AlignItems::Center, column_gap: VAL_SPACE_2, width: Val::Percent(100.0), ..default() }) .with_children(|row| { row.spawn(( Text::new("Time bonus".to_string()), label_font, TextColor(TEXT_SECONDARY), )); row.spawn(Node { flex_grow: 1.0, ..default() }); row.spawn(Node { flex_direction: FlexDirection::Row, align_items: AlignItems::Center, column_gap: VAL_SPACE_2, ..default() }) .with_children(|cluster| { cluster.spawn(( TimeBonusMultiplierText, Text::new(time_bonus_label(value)), value_font, TextColor(TEXT_PRIMARY), )); icon_button( cluster, "−", SettingsButton::TimeBonusDown, "Shrink the time-bonus shown in the win modal. Cosmetic only.", font_res, ); icon_button( cluster, "+", SettingsButton::TimeBonusUp, "Boost the time-bonus shown in the win modal. Cosmetic only.", font_res, ); }); }); } /// `Replay speed 0.45 s/move [−] [+]` — slider row for the /// player-tunable replay-playback per-move interval. Mirrors /// [`tooltip_delay_row`] (label, current value, decrement, increment) /// but formats the value via [`replay_move_interval_label`] as /// `"{n:.2} s/move"`. The decrement button speeds playback up /// (smaller interval); the increment slows it down — same direction /// convention as the tooltip-delay slider. pub(super) fn replay_move_interval_row( parent: &mut ChildSpawnerCommands, value_secs: f32, font_res: Option<&FontResource>, ) { let label_font = label_text_font(font_res); let value_font = value_text_font(font_res); parent .spawn(Node { flex_direction: FlexDirection::Row, align_items: AlignItems::Center, column_gap: VAL_SPACE_2, width: Val::Percent(100.0), ..default() }) .with_children(|row| { row.spawn(( Text::new("Replay speed".to_string()), label_font, TextColor(TEXT_SECONDARY), )); row.spawn(Node { flex_grow: 1.0, ..default() }); row.spawn(Node { flex_direction: FlexDirection::Row, align_items: AlignItems::Center, column_gap: VAL_SPACE_2, ..default() }) .with_children(|cluster| { cluster.spawn(( ReplayMoveIntervalText, Text::new(replay_move_interval_label(value_secs)), value_font, TextColor(TEXT_PRIMARY), )); icon_button( cluster, "−", SettingsButton::ReplayMoveIntervalDown, "Speed up replay playback (shorter per-move interval).", font_res, ); icon_button( cluster, "+", SettingsButton::ReplayMoveIntervalUp, "Slow down replay playback (longer per-move interval).", font_res, ); }); }); } /// `Label Value [⇄]` — used for cycle/toggle rows (draw mode, theme, /// anim speed, colour-blind). /// /// `tooltip` is attached to the `⇄` button so the cycle glyph carries a /// one-line reminder of what it iterates through. #[allow(clippy::too_many_arguments)] pub(super) fn toggle_row( parent: &mut ChildSpawnerCommands, label: &str, marker: Marker, value: String, action: SettingsButton, tooltip: &'static str, font_res: Option<&FontResource>, ) { let label_font = label_text_font(font_res); let value_font = value_text_font(font_res); parent .spawn(Node { flex_direction: FlexDirection::Row, align_items: AlignItems::Center, column_gap: VAL_SPACE_2, width: Val::Percent(100.0), ..default() }) .with_children(|row| { row.spawn(( Text::new(label.to_string()), label_font, TextColor(TEXT_SECONDARY), )); row.spawn(Node { flex_grow: 1.0, ..default() }); row.spawn(Node { flex_direction: FlexDirection::Row, align_items: AlignItems::Center, column_gap: VAL_SPACE_2, ..default() }) .with_children(|cluster| { cluster.spawn(( marker, Text::new(value), value_font, TextColor(TEXT_PRIMARY), )); icon_button(cluster, "⇄", action, tooltip, font_res); }); }); } /// Wrapping row of indexed swatch buttons — used for card-back and /// background pickers. The currently-selected swatch is tinted with /// `STATE_SUCCESS` so the user can see it without reading a label. /// /// `tooltip` is attached to every swatch in the row so hovering any chip /// reveals what the picker controls and how new entries unlock. #[allow(clippy::too_many_arguments)] pub(super) fn picker_row( parent: &mut ChildSpawnerCommands, label: &str, unlocked: &[usize], selected: usize, make_button: impl Fn(usize) -> SettingsButton, tooltip: &'static str, font_res: Option<&FontResource>, ) { let label_font = label_text_font(font_res); let chip_font = TextFont { font: font_res.map(|f| f.0.clone()).unwrap_or_default(), font_size: TYPE_BODY, ..default() }; parent .spawn(( // The row container is a `FocusRow` so Left / Right arrow // keys cycle within its swatch children. Tab still escapes // the row to the next focusable in the modal. FocusRow, Node { flex_direction: FlexDirection::Row, align_items: AlignItems::Center, column_gap: VAL_SPACE_2, flex_wrap: FlexWrap::Wrap, ..default() }, )) .with_children(|row| { row.spawn(( Text::new(label.to_string()), label_font, TextColor(TEXT_SECONDARY), )); // Always show at least swatch 0 (default). let entries: &[usize] = if unlocked.is_empty() { &[0] } else { unlocked }; for &idx in entries { let is_selected = idx == selected; let bg = if is_selected { STATE_SUCCESS } else { BG_ELEVATED_HI }; row.spawn(( make_button(idx), Button, Tooltip::new(tooltip), Node { width: Val::Px(SWATCH_PX), height: Val::Px(SWATCH_PX), justify_content: JustifyContent::Center, align_items: AlignItems::Center, border: UiRect::all(Val::Px(1.0)), border_radius: BorderRadius::all(Val::Px(RADIUS_SM)), ..default() }, BackgroundColor(bg), BorderColor::all(BORDER_SUBTLE), HighContrastBorder::with_default(BORDER_SUBTLE), )) .with_children(|b| { let text_color = if is_selected { BG_BASE } else { TEXT_PRIMARY }; b.spawn(( Text::new(format!("{}", idx + 1)), chip_font.clone(), TextColor(text_color), )); }); } }); } /// Marker on the row spawned by [`picker_row_overridden_by_theme`] so /// tests can find the caption without depending on text-content /// matching. #[derive(Component, Debug)] pub(crate) struct CardBackPickerOverriddenByTheme; /// Marker placed on every preview-thumbnail [`ImageNode`] inside a /// theme picker chip. Lets tests assert that a chip's children include /// the rasterised preview pair, and lets a future system update or /// hot-swap thumbnails without scanning the whole UI tree. #[derive(Component, Debug)] pub(crate) struct ThemeThumbnailMarker; /// Renders the "Card Back" row in its overridden-by-theme state: a /// labelled caption explaining why the swatches are hidden, with no /// interactive children. This is what the player sees when the active /// card-art theme supplies its own `back.svg` — the theme's back wins /// over the legacy `selected_card_back` choice, so showing the /// swatches would only confuse the player into thinking they were /// changing something when they weren't. pub(super) fn picker_row_overridden_by_theme( parent: &mut ChildSpawnerCommands, label: &str, font_res: Option<&FontResource>, ) { let label_font = label_text_font(font_res); let caption_font = TextFont { font: font_res.map(|f| f.0.clone()).unwrap_or_default(), font_size: TYPE_CAPTION, ..default() }; parent .spawn(( CardBackPickerOverriddenByTheme, Node { flex_direction: FlexDirection::Row, align_items: AlignItems::Center, column_gap: VAL_SPACE_2, ..default() }, )) .with_children(|row| { row.spawn(( Text::new(label.to_string()), label_font, TextColor(TEXT_SECONDARY), )); row.spawn(( Text::new("Active theme provides its own back"), caption_font, TextColor(TEXT_SECONDARY), )); }); } /// Logical width (px) of one preview thumbnail inside a picker chip. /// Mirrors [`crate::theme::THEME_THUMBNAIL_WIDTH_PX`] but at the UI /// scale used by Bevy's flex layout. The rasterised image itself is /// 100×140 px; the chip displays it at the same logical size so /// scaling artifacts stay minimal. const THUMBNAIL_LOGICAL_WIDTH_PX: f32 = 50.0; /// Logical height counterpart to [`THUMBNAIL_LOGICAL_WIDTH_PX`] — /// preserves the 2:3 card aspect. const THUMBNAIL_LOGICAL_HEIGHT_PX: f32 = 70.0; /// Picker row for card-art themes. Distinct from [`picker_row`] /// because themes are identified by `String` ids (matching /// `ThemeMeta::id`) instead of dense indices, and each chip carries /// the theme's display name plus a small Ace + back preview pair /// (when available in [`ThemeThumbnailCache`]). pub(super) fn theme_picker_row( parent: &mut ChildSpawnerCommands, label: &str, themes: &[ThemePickerEntry], selected_id: &str, tooltip: &'static str, font_res: Option<&FontResource>, ) { let label_font = label_text_font(font_res); let chip_font = TextFont { font: font_res.map(|f| f.0.clone()).unwrap_or_default(), font_size: TYPE_BODY, ..default() }; parent .spawn(( FocusRow, Node { flex_direction: FlexDirection::Row, align_items: AlignItems::Center, column_gap: VAL_SPACE_2, flex_wrap: FlexWrap::Wrap, ..default() }, )) .with_children(|row| { row.spawn(( Text::new(label.to_string()), label_font, TextColor(TEXT_SECONDARY), )); for entry in themes { let is_selected = entry.id == selected_id; let bg = if is_selected { STATE_SUCCESS } else { BG_ELEVATED_HI }; row.spawn(( SettingsButton::SelectTheme(entry.id.clone()), Button, Tooltip::new(tooltip), Node { // Chips with thumbnails stack the preview pair // above the label so a glance reveals the // theme's art without hovering for the // tooltip. flex_direction: FlexDirection::Column, // Theme names are wider than numeric chips — // pad horizontally instead of using a fixed // square swatch. padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_2), min_height: Val::Px(SWATCH_PX), row_gap: VAL_SPACE_2, justify_content: JustifyContent::Center, align_items: AlignItems::Center, border: UiRect::all(Val::Px(1.0)), border_radius: BorderRadius::all(Val::Px(RADIUS_SM)), ..default() }, BackgroundColor(bg), BorderColor::all(BORDER_SUBTLE), HighContrastBorder::with_default(BORDER_SUBTLE), )) .with_children(|b| { spawn_thumbnail_pair(b, entry.thumbnails.as_ref()); let text_color = if is_selected { BG_BASE } else { TEXT_PRIMARY }; b.spawn(( Text::new(entry.display_name.clone()), chip_font.clone(), TextColor(text_color), )); }); } }); } /// Spawns the Ace + back preview pair for a theme picker chip. /// /// When `thumbnails` is `Some(_)` and both handles are non-default, /// renders two `ImageNode` siblings (Ace on the left, back on the /// right). When the thumbnails are missing or only partially loaded, /// renders two muted `BG_ELEVATED` placeholder rectangles at the same /// logical size — keeping the chip's overall footprint stable so the /// picker row layout doesn't reflow as the cache fills in. pub(super) fn spawn_thumbnail_pair( parent: &mut ChildSpawnerCommands, thumbnails: Option<&ThemeThumbnailPair>, ) { parent .spawn(Node { flex_direction: FlexDirection::Row, column_gap: VAL_SPACE_2, align_items: AlignItems::Center, ..default() }) .with_children(|pair| match thumbnails { Some(t) if t.is_fully_populated() => { spawn_thumbnail_image(pair, t.ace.clone()); spawn_thumbnail_image(pair, t.back.clone()); } _ => { spawn_thumbnail_placeholder(pair); spawn_thumbnail_placeholder(pair); } }); } /// Spawns one `ImageNode` thumbnail at the canonical preview size. /// Tagged with [`ThemeThumbnailMarker`] so tests can scan a chip's /// children for the rendered preview without crawling the whole UI. pub(super) fn spawn_thumbnail_image(parent: &mut ChildSpawnerCommands, image: Handle) { parent.spawn(( ThemeThumbnailMarker, ImageNode::new(image), Node { width: Val::Px(THUMBNAIL_LOGICAL_WIDTH_PX), height: Val::Px(THUMBNAIL_LOGICAL_HEIGHT_PX), ..default() }, )); } /// Spawns a muted placeholder rectangle for the case where the cache /// has not yet generated thumbnails for a theme — or when a user theme /// is missing one of its preview SVGs. Same logical size as /// [`spawn_thumbnail_image`] so chip layout stays stable. pub(super) fn spawn_thumbnail_placeholder(parent: &mut ChildSpawnerCommands) { parent.spawn(( Node { width: Val::Px(THUMBNAIL_LOGICAL_WIDTH_PX), height: Val::Px(THUMBNAIL_LOGICAL_HEIGHT_PX), border_radius: BorderRadius::all(Val::Px(RADIUS_SM)), ..default() }, BackgroundColor(BG_ELEVATED), )); } /// Sync section row — shows different controls depending on whether a server /// backend is configured. pub(super) fn sync_row( parent: &mut ChildSpawnerCommands, status_text: &str, backend: &SyncBackend, font_res: Option<&FontResource>, ) { let status_font = TextFont { font: font_res.map(|f| f.0.clone()).unwrap_or_default(), font_size: TYPE_BODY, ..default() }; let button_font = TextFont { font: font_res.map(|f| f.0.clone()).unwrap_or_default(), font_size: TYPE_CAPTION, ..default() }; // Helper closure to spawn a small settings-style pill button. let small_button = |row: &mut ChildSpawnerCommands, marker: SettingsButton, label: &str, tooltip: String, font: TextFont| { row.spawn(( marker, Button, Tooltip::new(tooltip), Node { padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2), justify_content: JustifyContent::Center, border: UiRect::all(Val::Px(1.0)), border_radius: BorderRadius::all(Val::Px(RADIUS_SM)), ..default() }, BackgroundColor(BG_ELEVATED_HI), BorderColor::all(BORDER_SUBTLE), HighContrastBorder::with_default(BORDER_SUBTLE), )) .with_children(|b| { b.spawn((Text::new(label.to_string()), font, TextColor(TEXT_PRIMARY))); }); }; parent .spawn(Node { flex_direction: FlexDirection::Column, row_gap: VAL_SPACE_2, ..default() }) .with_children(|col| { // Status line + inline action buttons. col.spawn(Node { flex_direction: FlexDirection::Row, align_items: AlignItems::Center, column_gap: VAL_SPACE_3, flex_wrap: FlexWrap::Wrap, row_gap: VAL_SPACE_2, ..default() }) .with_children(|row| { row.spawn(( SyncStatusText, Text::new(status_text.to_string()), status_font, TextColor(TEXT_SECONDARY), )); match backend { SyncBackend::Local => { small_button( row, SettingsButton::ConnectSync, "Connect", "Connect to a self-hosted Ferrous Solitaire sync server.".to_string(), button_font, ); } SyncBackend::SolitaireServer { username, .. } => { // Show the logged-in username as a secondary label. row.spawn(( Text::new(format!("({username})")), TextFont { font: font_res.map(|f| f.0.clone()).unwrap_or_default(), font_size: TYPE_CAPTION, ..default() }, TextColor(TEXT_SECONDARY), )); small_button( row, SettingsButton::SyncNow, "Sync Now", "Push and pull stats now. Runs automatically on launch and exit.".to_string(), button_font.clone(), ); small_button( row, SettingsButton::DisconnectSync, "Disconnect", "Unlink this device from the sync server.".to_string(), button_font.clone(), ); small_button( row, SettingsButton::DeleteAccount, "Delete Account", "Permanently delete your account and all server data. Cannot be undone.".to_string(), button_font, ); } } }); }); } pub(super) fn label_text_font(font_res: Option<&FontResource>) -> TextFont { TextFont { font: font_res.map(|f| f.0.clone()).unwrap_or_default(), font_size: TYPE_BODY_LG, ..default() } } pub(super) fn value_text_font(font_res: Option<&FontResource>) -> TextFont { TextFont { font: font_res.map(|f| f.0.clone()).unwrap_or_default(), font_size: TYPE_BODY_LG, ..default() } } /// Spawns a small square icon button (volume +/−, toggle, cycle). /// /// `tooltip` is the hover-reveal caption attached via [`Tooltip`]. Every /// Settings icon button ships with one because the glyph alone (`+`, `−`, /// `⇄`) does not name what it adjusts; the tooltip carries that meaning. /// Scans `user_theme_dir()` for `.zip` files and calls [`import_theme`] on /// each one. On success, [`ThemeRegistry`] is refreshed in place and an /// [`InfoToastEvent`] is fired per imported theme. `IdCollision` errors (theme /// already installed) are silently skipped; all other errors produce a warning /// toast. A final toast tells the player to reopen Settings to see new themes. #[cfg(not(target_arch = "wasm32"))] pub(super) fn handle_scan_themes( interaction_query: Query<(&Interaction, &SettingsButton), Changed>, mut toast: MessageWriter, mut registry: Option>, ) { for (interaction, button) in &interaction_query { if *interaction != Interaction::Pressed { continue; } if !matches!(button, SettingsButton::ScanThemes) { continue; } let themes_dir = user_theme_dir(); let zips: Vec = match std::fs::read_dir(&themes_dir) { Ok(entries) => entries .flatten() .map(|e| e.path()) .filter(|p| p.extension().is_some_and(|ext| ext == "zip")) .collect(), Err(_) => { toast.write(InfoToastEvent( "Themes folder not found — drop .zip files there first.".to_string(), )); return; } }; if zips.is_empty() { toast.write(InfoToastEvent( "No .zip files found in themes folder.".to_string(), )); return; } let mut imported = 0u32; let mut errors = 0u32; for zip_path in &zips { match import_theme(zip_path) { Ok(theme_id) => { toast.write(InfoToastEvent(format!( "Imported theme '{}'.", theme_id.as_str() ))); imported += 1; } Err(ImportError::IdCollision { .. }) => { // Already installed — silent skip. } Err(e) => { let name = zip_path .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_default(); toast.write(InfoToastEvent(format!("Import failed ({name}): {e}"))); errors += 1; } } } if imported == 0 && errors == 0 { toast.write(InfoToastEvent("All themes already installed.".to_string())); return; } if imported > 0 { if let Some(reg) = &mut registry { refresh_registry(reg, &themes_dir); } toast.write(InfoToastEvent( "Reopen Settings to see new themes in the picker.".to_string(), )); } } } #[cfg(not(target_arch = "wasm32"))] /// A small pill-shaped settings button, matching the style used in `sync_row`. pub(super) fn pill_button( parent: &mut ChildSpawnerCommands, marker: SettingsButton, label: &str, tooltip: &'static str, font_res: Option<&FontResource>, ) { let font = TextFont { font: font_res.map(|f| f.0.clone()).unwrap_or_default(), font_size: TYPE_CAPTION, ..default() }; parent .spawn(( marker, Button, Tooltip::new(tooltip), Node { padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2), justify_content: JustifyContent::Center, border: UiRect::all(Val::Px(1.0)), border_radius: BorderRadius::all(Val::Px(RADIUS_SM)), ..default() }, BackgroundColor(BG_ELEVATED_HI), BorderColor::all(BORDER_SUBTLE), HighContrastBorder::with_default(BORDER_SUBTLE), )) .with_children(|b| { b.spawn((Text::new(label.to_string()), font, TextColor(TEXT_PRIMARY))); }); } /// "Import Theme" row: folder-path label + "Scan for new themes" button. /// /// The player drops `.zip` theme archives into the themes folder shown here, /// then presses the button. [`handle_scan_themes`] picks them up, validates, /// and installs them. Reopen Settings to see newly imported themes in the /// card-theme picker. #[cfg(not(target_arch = "wasm32"))] pub(super) fn import_themes_row( parent: &mut ChildSpawnerCommands, font_res: Option<&FontResource>, ) { let caption_font = TextFont { font: font_res.map(|f| f.0.clone()).unwrap_or_default(), font_size: TYPE_CAPTION, ..default() }; parent .spawn(( FocusRow, Node { flex_direction: FlexDirection::Column, row_gap: VAL_SPACE_2, ..default() }, )) .with_children(|col| { // Folder path hint. let path_str = user_theme_dir().to_string_lossy().into_owned(); col.spawn(( Text::new(format!("Drop .zip files into: {path_str}")), caption_font, TextColor(TEXT_SECONDARY), )); // Scan button. col.spawn(Node { flex_direction: FlexDirection::Row, align_items: AlignItems::Center, ..default() }) .with_children(|row| { pill_button( row, SettingsButton::ScanThemes, "Scan for new themes", "Scan the themes folder for .zip archives and install any that are new.", font_res, ); }); }); } /// "Theme store" row: one pill button that closes Settings and opens /// the in-game theme-store modal (`theme_store_plugin`). Sits directly /// under the Import row so both install paths live together. #[cfg(not(target_arch = "wasm32"))] pub(super) fn theme_store_row(parent: &mut ChildSpawnerCommands, font_res: Option<&FontResource>) { parent .spawn(( FocusRow, Node { flex_direction: FlexDirection::Row, align_items: AlignItems::Center, ..default() }, )) .with_children(|row| { pill_button( row, SettingsButton::OpenThemeStore, "Browse theme store", "Download themes from your sync server without leaving the game.", font_res, ); }); } pub(super) fn icon_button( parent: &mut ChildSpawnerCommands, label: &str, action: SettingsButton, tooltip: &'static str, font_res: Option<&FontResource>, ) { let glyph_font = TextFont { font: font_res.map(|f| f.0.clone()).unwrap_or_default(), font_size: TYPE_BODY_LG, ..default() }; parent .spawn(( action, Button, Tooltip::new(tooltip), Node { width: Val::Px(ICON_BUTTON_PX), height: Val::Px(ICON_BUTTON_PX), justify_content: JustifyContent::Center, align_items: AlignItems::Center, border: UiRect::all(Val::Px(1.0)), border_radius: BorderRadius::all(Val::Px(RADIUS_SM)), ..default() }, BackgroundColor(BG_ELEVATED_HI), BorderColor::all(BORDER_SUBTLE), HighContrastBorder::with_default(BORDER_SUBTLE), )) .with_children(|b| { b.spawn(( Text::new(label.to_string()), glyph_font, TextColor(TEXT_PRIMARY), )); }); } // --------------------------------------------------------------------------- // Tests // ---------------------------------------------------------------------------