From 924a1e2af7927c09cbee63e3f49bb218ceb0ddb6 Mon Sep 17 00:00:00 2001 From: funman300 Date: Fri, 1 May 2026 16:24:24 +0000 Subject: [PATCH] =?UTF-8?q?feat(engine):=20card-theme=20picker=20in=20Sett?= =?UTF-8?q?ings=20=E2=86=92=20Cosmetic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the runtime theme system (CARD_PLAN.md phases 1–7) into the visible Settings UI so a player can switch between every theme discovered by `ThemeRegistry` without restarting. solitaire_data/src/settings.rs Settings gains `selected_theme_id: String` (default "default"), guarded by `#[serde(default = "default_theme_id")]` so existing settings.json files deserialize cleanly. solitaire_engine/src/settings_plugin.rs - SettingsButton::SelectTheme(String) variant + focus order 85. - sync_settings_panel_visibility now reads Option>, snapshots id+display_name pairs, and threads them into spawn_settings_panel. When the registry is absent (tests under MinimalPlugins) the picker silently skips — every existing test continues to pass unchanged. - theme_picker_row helper: like picker_row but keyed by String rather than usize, with chips wide enough for theme display names. Attaches the canonical tooltip ("Choose card-face artwork. Imported themes appear here.") and the FocusRow marker so Left/Right arrows cycle within the row. - Click handler updates settings.selected_theme_id, persists, and fires SettingsChangedEvent — same shape as every other picker. solitaire_engine/src/theme/plugin.rs - load_default_theme renamed to load_initial_theme; reads SettingsResource on Startup and seeds ActiveTheme from settings.selected_theme_id (falling back to embedded default). - react_to_settings_theme_change watches SettingsChangedEvent, no-ops when the active theme already matches, and otherwise swaps ActiveTheme — the existing sync_card_image_set_with_active_theme system then refreshes every card sprite on the next AssetEvent::LoadedWithDependencies. cargo build / clippy --workspace --all-targets -- -D warnings / test --workspace all green (960 passed, 0 failed, 9 ignored). --- solitaire_data/src/settings.rs | 14 +++ solitaire_engine/src/settings_plugin.rs | 110 ++++++++++++++++++++++++ solitaire_engine/src/theme/plugin.rs | 63 ++++++++++++-- 3 files changed, 181 insertions(+), 6 deletions(-) diff --git a/solitaire_data/src/settings.rs b/solitaire_data/src/settings.rs index 2ac329f..2cca090 100644 --- a/solitaire_data/src/settings.rs +++ b/solitaire_data/src/settings.rs @@ -124,6 +124,14 @@ pub struct Settings { /// `None` thanks to `#[serde(default)]`. #[serde(default)] pub window_geometry: Option, + /// Identifier of the active card-art theme. Matches `meta.id` from + /// the theme's `theme.ron` manifest. `"default"` is the bundled + /// theme and is always present in the registry; user-supplied + /// themes register under their own ids when they're imported. + /// Older `settings.json` files default cleanly to `"default"` via + /// `#[serde(default = ...)]`. + #[serde(default = "default_theme_id")] + pub selected_theme_id: String, } fn default_draw_mode() -> DrawMode { @@ -138,6 +146,10 @@ fn default_music_volume() -> f32 { 0.5 } +fn default_theme_id() -> String { + "default".to_string() +} + impl Default for Settings { fn default() -> Self { Self { @@ -152,6 +164,7 @@ impl Default for Settings { first_run_complete: false, color_blind_mode: false, window_geometry: None, + selected_theme_id: default_theme_id(), } } } @@ -304,6 +317,7 @@ mod tests { first_run_complete: true, color_blind_mode: false, window_geometry: None, + selected_theme_id: "default".to_string(), }; save_settings_to(&path, &s).expect("save"); let loaded = load_settings_from(&path); diff --git a/solitaire_engine/src/settings_plugin.rs b/solitaire_engine/src/settings_plugin.rs index 1a79bfc..aefc49d 100644 --- a/solitaire_engine/src/settings_plugin.rs +++ b/solitaire_engine/src/settings_plugin.rs @@ -147,6 +147,10 @@ enum SettingsButton { SelectCardBack(usize), /// Select a specific background by index from the picker row. SelectBackground(usize), + /// Select a specific card-art theme by `meta.id` from the + /// `ThemeRegistry`. The string is owned so the click handler can + /// hand it directly to `Settings::selected_theme_id`. + SelectTheme(String), } impl SettingsButton { @@ -172,6 +176,7 @@ impl SettingsButton { // priority so entity-index tiebreaking yields left → right. SettingsButton::SelectCardBack(_) => 70, SettingsButton::SelectBackground(_) => 80, + SettingsButton::SelectTheme(_) => 85, // Sync section SettingsButton::SyncNow => 90, // Done is tagged by `attach_focusable_to_modal_buttons` and @@ -353,6 +358,7 @@ fn sync_settings_panel_visibility( sync_status: Option>, progress: Option>, font_res: Option>, + theme_registry: Option>, ) { if !screen.is_changed() { return; @@ -367,12 +373,25 @@ fn sync_settings_panel_visibility( let unlocked_bgs = progress .as_ref() .map_or(&[0][..], |p| p.0.unlocked_backgrounds.as_slice()); + // Snapshot themes by id+display_name so spawn_settings_panel + // doesn't have to know about the registry shape. Empty when + // ThemeRegistryPlugin isn't installed (tests under + // MinimalPlugins) — the picker row simply won't render. + let themes: Vec<(String, String)> = theme_registry + .as_deref() + .map(|r| { + r.iter() + .map(|e| (e.id.clone(), e.display_name.clone())) + .collect() + }) + .unwrap_or_default(); spawn_settings_panel( &mut commands, &settings.0, &status_label, unlocked_backs, unlocked_bgs, + &themes, scroll_pos.0, font_res.as_deref(), ); @@ -617,6 +636,13 @@ fn handle_settings_buttons( persist(&path, &settings.0); changed.write(SettingsChangedEvent(settings.0.clone())); } + SettingsButton::SelectTheme(theme_id) => { + if settings.0.selected_theme_id != *theme_id { + settings.0.selected_theme_id = theme_id.clone(); + persist(&path, &settings.0); + changed.write(SettingsChangedEvent(settings.0.clone())); + } + } SettingsButton::SyncNow => { manual_sync.write(ManualSyncRequestEvent); } @@ -902,12 +928,14 @@ fn persist_window_geometry_after_debounce( // UI construction // --------------------------------------------------------------------------- +#[allow(clippy::too_many_arguments)] fn spawn_settings_panel( commands: &mut Commands, settings: &Settings, sync_status: &str, unlocked_card_backs: &[usize], unlocked_backgrounds: &[usize], + themes: &[(String, String)], scroll_offset: f32, font_res: Option<&FontResource>, ) { @@ -1014,6 +1042,19 @@ fn spawn_settings_panel( "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, + ); + } // --- Sync --- section_label(body, "Sync", font_res); @@ -1198,6 +1239,75 @@ fn picker_row( }); } +/// 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 rather than a numeric label. +fn theme_picker_row( + parent: &mut ChildSpawnerCommands, + label: &str, + themes: &[(String, String)], + 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 (id, display_name) in themes { + let is_selected = id == selected_id; + let bg = if is_selected { STATE_SUCCESS } else { BG_ELEVATED_HI }; + row.spawn(( + SettingsButton::SelectTheme(id.clone()), + Button, + Tooltip::new(tooltip), + Node { + // Theme names are wider than numeric chips — + // pad horizontally instead of using a fixed + // square swatch. + padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2), + min_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), + )) + .with_children(|b| { + let text_color = if is_selected { BG_BASE } else { TEXT_PRIMARY }; + b.spawn(( + Text::new(display_name.clone()), + chip_font.clone(), + TextColor(text_color), + )); + }); + } + }); +} + /// Status text + manual "Sync Now" button. fn sync_row(parent: &mut ChildSpawnerCommands, status_text: &str, font_res: Option<&FontResource>) { let status_font = TextFont { diff --git a/solitaire_engine/src/theme/plugin.rs b/solitaire_engine/src/theme/plugin.rs index 9029351..baa0375 100644 --- a/solitaire_engine/src/theme/plugin.rs +++ b/solitaire_engine/src/theme/plugin.rs @@ -47,17 +47,68 @@ impl Plugin for ThemePlugin { app.init_asset::() .register_asset_loader(crate::assets::SvgLoader) .register_asset_loader(CardThemeLoader) - .add_systems(Startup, load_default_theme) - .add_systems(Update, sync_card_image_set_with_active_theme); + .add_systems(Startup, load_initial_theme) + .add_systems( + Update, + ( + sync_card_image_set_with_active_theme, + react_to_settings_theme_change, + ), + ); } } -/// Kicks off the default-theme load and stashes the handle on -/// [`ActiveTheme`]. The actual rasterisation runs asynchronously on +/// Kicks off the initial theme load — the one named by +/// `Settings::selected_theme_id` if available, falling back to the +/// embedded default. The actual rasterisation runs asynchronously on /// the asset task pool; the sync system below picks up the /// `LoadedWithDependencies` event when every face + back is ready. -fn load_default_theme(asset_server: Res, mut commands: Commands) { - let handle: Handle = asset_server.load(DEFAULT_THEME_MANIFEST_URL); +fn load_initial_theme( + asset_server: Res, + settings: Option>, + mut commands: Commands, +) { + let url = match settings.as_deref() { + Some(s) if s.0.selected_theme_id != "default" => { + format!("themes://{}/theme.ron", s.0.selected_theme_id) + } + _ => DEFAULT_THEME_MANIFEST_URL.to_string(), + }; + let handle: Handle = asset_server.load(url); + commands.insert_resource(ActiveTheme(handle)); +} + +/// Watches [`crate::settings_plugin::SettingsChangedEvent`] and +/// triggers a fresh theme load whenever +/// `Settings::selected_theme_id` changes. The settings panel's theme +/// picker fires the event after persisting; this system is the bridge +/// that turns the persisted choice into a live `set_theme` call. +fn react_to_settings_theme_change( + mut events: MessageReader, + asset_server: Res, + active: Option>, + themes: Res>, + mut commands: Commands, +) { + let Some(latest) = events.read().last() else { + return; + }; + let new_id = latest.0.selected_theme_id.as_str(); + + // No-op if the active theme already matches the desired id. + if let Some(active) = active.as_deref() + && let Some(theme) = themes.get(&active.0) + && theme.meta.id == new_id + { + return; + } + + let url = if new_id == "default" { + DEFAULT_THEME_MANIFEST_URL.to_string() + } else { + format!("themes://{new_id}/theme.ron") + }; + let handle: Handle = asset_server.load(url); commands.insert_resource(ActiveTheme(handle)); }