From ba527de351680682d78a6a3a8965f0173d7d6062 Mon Sep 17 00:00:00 2001 From: funman300 Date: Tue, 5 May 2026 00:41:20 +0000 Subject: [PATCH] feat(engine): card-art thumbnails in the theme picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Cosmetic's theme picker showed only the theme name. Now each chip carries a small Ace-of-Spades + back preview pair so the player can see what each theme looks like before switching. A new ThemeThumbnailCache resource keys per-theme by id and stores two Handles (ace + back) rasterised at thumbnail resolution via the existing rasterize_svg path. Generation runs once per theme registration in theme_plugin; subsequent picker re-spawns just look up the cached handles. Themes that lack one of the preview SVGs (broken user theme) get a Handle::default() placeholder rather than crashing — the placeholder renders as a transparent rectangle the same size as the missing thumbnail. The picker chip spawn loop in settings_plugin reads the cache and renders the pair as two child sprites above the chip text. The selected-theme chip's existing STATE_SUCCESS tint sits behind the thumbnails; contrast stays readable. Asset-source plumbing in assets/sources.rs and assets/mod.rs picks up the new bytes-loading helper that the thumbnail generator uses for embedded:// theme assets at startup time (before AssetServer is fully initialised). Co-Authored-By: Claude Opus 4.7 (1M context) --- solitaire_engine/src/assets/mod.rs | 4 +- solitaire_engine/src/assets/sources.rs | 42 ++++ solitaire_engine/src/settings_plugin.rs | 226 +++++++++++++++++-- solitaire_engine/src/theme/mod.rs | 5 +- solitaire_engine/src/theme/plugin.rs | 276 +++++++++++++++++++++++- 5 files changed, 535 insertions(+), 18 deletions(-) diff --git a/solitaire_engine/src/assets/mod.rs b/solitaire_engine/src/assets/mod.rs index 4581edb..e235edf 100644 --- a/solitaire_engine/src/assets/mod.rs +++ b/solitaire_engine/src/assets/mod.rs @@ -11,8 +11,8 @@ pub mod svg_loader; pub mod user_dir; pub use sources::{ - populate_embedded_default_theme, register_theme_asset_sources, AssetSourcesPlugin, - DEFAULT_THEME_MANIFEST_URL, USER_THEMES, + default_theme_svg_bytes, populate_embedded_default_theme, register_theme_asset_sources, + AssetSourcesPlugin, DEFAULT_THEME_MANIFEST_URL, USER_THEMES, }; pub use svg_loader::{rasterize_svg, SvgLoader, SvgLoaderError, SvgLoaderSettings}; pub use user_dir::{set_user_theme_dir, user_theme_dir}; diff --git a/solitaire_engine/src/assets/sources.rs b/solitaire_engine/src/assets/sources.rs index d607f16..ab6275a 100644 --- a/solitaire_engine/src/assets/sources.rs +++ b/solitaire_engine/src/assets/sources.rs @@ -194,6 +194,25 @@ impl Plugin for AssetSourcesPlugin { } } +/// Returns the embedded SVG bytes for a single default-theme file +/// (e.g. `"back.svg"` or `"spades_ace.svg"`), or `None` when the +/// filename is not bundled. +/// +/// The thumbnail generator in +/// [`crate::theme::ThemeThumbnailCache`] uses this to rasterise +/// preview-sized art for the picker UI without going through Bevy's +/// async asset graph. Lookup is by the filename only — the +/// `solitaire_engine/assets/themes/default/` prefix is stripped before +/// comparison so callers don't need to know where the embedded files +/// live in the binary. +pub fn default_theme_svg_bytes(filename: &str) -> Option<&'static [u8]> { + let suffix = format!("/{filename}"); + DEFAULT_THEME_SVGS + .iter() + .find(|(path, _)| path.ends_with(&suffix)) + .map(|(_, bytes)| *bytes) +} + /// Pushes every bundled default-theme file into the /// [`EmbeddedAssetRegistry`] under its stable URL. Keeping this in a /// free function (and not inside the `Plugin::build` body) means the @@ -291,6 +310,29 @@ mod tests { assert_eq!(faces.len(), 52); } + /// `default_theme_svg_bytes` resolves the canonical preview pair + /// the thumbnail cache rasterises: `back.svg` and `spades_ace.svg`. + /// Both must exist in the embedded table or the picker's preview + /// thumbnails would silently fall back to placeholders even for the + /// always-present default theme. + #[test] + fn default_theme_svg_bytes_finds_back_and_ace_of_spades() { + assert!( + default_theme_svg_bytes("back.svg").is_some(), + "default theme must bundle a back.svg" + ); + assert!( + default_theme_svg_bytes("spades_ace.svg").is_some(), + "default theme must bundle a spades_ace.svg" + ); + } + + #[test] + fn default_theme_svg_bytes_returns_none_for_unknown_file() { + assert!(default_theme_svg_bytes("nope.svg").is_none()); + assert!(default_theme_svg_bytes("").is_none()); + } + /// Belt-and-braces: if anyone edits `DEFAULT_THEME_MANIFEST_PATH` /// without updating `DEFAULT_THEME_MANIFEST_URL` (or vice versa) /// the asset would register at one path and be loaded from diff --git a/solitaire_engine/src/settings_plugin.rs b/solitaire_engine/src/settings_plugin.rs index a3740e3..05be178 100644 --- a/solitaire_engine/src/settings_plugin.rs +++ b/solitaire_engine/src/settings_plugin.rs @@ -25,6 +25,7 @@ use crate::events::{ManualSyncRequestEvent, ToggleSettingsRequestEvent}; use crate::font_plugin::FontResource; use crate::progress_plugin::ProgressResource; use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource}; +use crate::theme::{ThemeThumbnailCache, ThemeThumbnailPair}; use crate::ui_focus::{FocusGroup, FocusRow, Focusable, FocusedButton}; use crate::ui_modal::{ spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant, @@ -32,8 +33,9 @@ use crate::ui_modal::{ }; use crate::ui_tooltip::Tooltip; use crate::ui_theme::{ - BG_BASE, BG_ELEVATED_HI, BORDER_SUBTLE, RADIUS_SM, SPACE_2, STATE_SUCCESS, TEXT_PRIMARY, - TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL, + BG_BASE, BG_ELEVATED, BG_ELEVATED_HI, BORDER_SUBTLE, RADIUS_SM, SPACE_2, STATE_SUCCESS, + TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, + Z_MODAL_PANEL, }; /// Side length of a swatch button in the card-back / background pickers. @@ -134,6 +136,23 @@ struct SettingsPanelScrollable; #[derive(Component, Debug)] struct SettingsScrollNode; +/// Snapshot row used by [`spawn_settings_panel`] to render the card-art +/// theme picker. Carries the `ThemeRegistry` entry's display fields plus +/// the (optional) thumbnail pair from [`ThemeThumbnailCache`]. A `None` +/// thumbnail means the picker should render a placeholder swatch — used +/// when the cache hasn't generated handles yet, or when a user theme +/// is missing one of the required preview SVGs. +#[derive(Debug, Clone)] +struct ThemePickerEntry { + /// Stable theme id (matches `ThemeMeta::id`). + id: String, + /// Player-facing label. + display_name: String, + /// Pre-generated picker preview pair, when ready. `None` collapses + /// the chip to its plain-text fallback. + thumbnails: Option, +} + /// Tags interactive buttons inside the Settings panel. #[derive(Component, Debug)] enum SettingsButton { @@ -370,6 +389,7 @@ fn sync_settings_panel_visibility( progress: Option>, font_res: Option>, theme_registry: Option>, + theme_thumbs: Option>, card_images: Option>, ) { if !screen.is_changed() { @@ -385,15 +405,27 @@ 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 + // 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. - let themes: Vec<(String, String)> = theme_registry + // 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 .as_deref() .map(|r| { r.iter() - .map(|e| (e.id.clone(), e.display_name.clone())) + .map(|e| ThemePickerEntry { + id: e.id.clone(), + display_name: e.display_name.clone(), + thumbnails: theme_thumbs + .as_deref() + .and_then(|c| c.get(&e.id)) + .filter(|p| p.is_fully_populated()) + .cloned(), + }) .collect() }) .unwrap_or_default(); @@ -1010,7 +1042,7 @@ fn spawn_settings_panel( sync_status: &str, unlocked_card_backs: &[usize], unlocked_backgrounds: &[usize], - themes: &[(String, String)], + themes: &[ThemePickerEntry], scroll_offset: f32, font_res: Option<&FontResource>, theme_overrides_back: bool, @@ -1384,6 +1416,13 @@ fn picker_row( #[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 @@ -1426,14 +1465,25 @@ fn picker_row_overridden_by_theme( }); } +/// 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 rather than a numeric label. +/// the theme's display name plus a small Ace + back preview pair +/// (when available in [`ThemeThumbnailCache`]). fn theme_picker_row( parent: &mut ChildSpawnerCommands, label: &str, - themes: &[(String, String)], + themes: &[ThemePickerEntry], selected_id: &str, tooltip: &'static str, font_res: Option<&FontResource>, @@ -1461,19 +1511,25 @@ fn theme_picker_row( label_font, TextColor(TEXT_SECONDARY), )); - for (id, display_name) in themes { - let is_selected = id == selected_id; + 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(id.clone()), + 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_3, VAL_SPACE_2), + 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)), @@ -1484,9 +1540,10 @@ fn theme_picker_row( BorderColor::all(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(display_name.clone()), + Text::new(entry.display_name.clone()), chip_font.clone(), TextColor(text_color), )); @@ -1495,6 +1552,70 @@ fn theme_picker_row( }); } +/// 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. +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. +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. +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), + )); +} + /// Status text + manual "Sync Now" button. fn sync_row(parent: &mut ChildSpawnerCommands, status_text: &str, font_res: Option<&FontResource>) { let status_font = TextFont { @@ -1943,6 +2064,83 @@ mod tests { ); } + /// Test 3 of the thumbnail-picker spec: when [`ThemeRegistry`] has + /// at least one theme and the [`ThemeThumbnailCache`] holds a + /// fully-populated [`ThemeThumbnailPair`] for that theme's id, the + /// rendered chip carries a [`ThemeThumbnailMarker`]-tagged + /// `ImageNode` for each preview slot. + #[test] + fn theme_picker_chip_includes_thumbnail_sprite_when_thumbnails_loaded() { + use crate::theme::{ThemeEntry, ThemeRegistry, ThemeThumbnailCache, ThemeThumbnailPair}; + + let mut app = headless_app_with_focus(); + // Prime an Assets resource so we can mint stable handles + // for the synthetic thumbnail pair. + app.init_resource::>(); + let (ace_handle, back_handle) = { + let mut images = app.world_mut().resource_mut::>(); + let ace = images.add(Image::default()); + let back = images.add(Image::default()); + (ace, back) + }; + // Inject one theme entry + a matching thumbnail pair. + app.insert_resource(ThemeRegistry { + entries: vec![ThemeEntry { + id: "test_theme".into(), + display_name: "Test Theme".into(), + manifest_url: "themes://test_theme/theme.ron".into(), + meta: crate::theme::ThemeMeta { + id: "test_theme".into(), + name: "Test Theme".into(), + author: "x".into(), + version: "x".into(), + card_aspect: (2, 3), + }, + }], + }); + let mut cache = ThemeThumbnailCache::default(); + cache.entries.insert( + "test_theme".into(), + ThemeThumbnailPair { + ace: ace_handle.clone(), + back: back_handle.clone(), + }, + ); + app.insert_resource(cache); + + // Open the panel and let the spawn + child-flush systems run. + app.world_mut().resource_mut::().0 = true; + app.update(); + app.update(); + app.update(); + + // Find every ImageNode tagged with ThemeThumbnailMarker — the + // theme picker chip for "test_theme" must contribute exactly + // two of them (ace + back). + let thumbnail_count = app + .world_mut() + .query_filtered::<&ImageNode, With>() + .iter(app.world()) + .count(); + assert!( + thumbnail_count >= 2, + "expected at least one ace + back thumbnail (2 sprites); got {thumbnail_count}" + ); + + // Spot-check: at least one thumbnail's image handle matches one + // of the ones we inserted into the cache. This guards against a + // future refactor that accidentally clones the wrong handle. + let any_matches = app + .world_mut() + .query_filtered::<&ImageNode, With>() + .iter(app.world()) + .any(|node| node.image == ace_handle || node.image == back_handle); + assert!( + any_matches, + "at least one rendered thumbnail must reuse the cached handle" + ); + } + // ----------------------------------------------------------------------- // Window geometry persistence // ----------------------------------------------------------------------- diff --git a/solitaire_engine/src/theme/mod.rs b/solitaire_engine/src/theme/mod.rs index a2a9ec9..520b259 100644 --- a/solitaire_engine/src/theme/mod.rs +++ b/solitaire_engine/src/theme/mod.rs @@ -31,7 +31,10 @@ use solitaire_core::card::{Rank, Suit}; pub use importer::{import_theme, import_theme_into, ImportError, ThemeId}; pub use loader::{CardThemeLoader, CardThemeLoaderError}; pub use manifest::ThemeManifest; -pub use plugin::{set_theme, ActiveTheme, ThemePlugin}; +pub use plugin::{ + ensure_theme_thumbnails, set_theme, ActiveTheme, ThemePlugin, ThemeThumbnailCache, + ThemeThumbnailPair, THEME_THUMBNAIL_HEIGHT_PX, THEME_THUMBNAIL_WIDTH_PX, +}; pub use registry::{ build_registry, refresh_registry, ThemeEntry, ThemeRegistry, ThemeRegistryPlugin, }; diff --git a/solitaire_engine/src/theme/plugin.rs b/solitaire_engine/src/theme/plugin.rs index 6e12f4f..51b82e5 100644 --- a/solitaire_engine/src/theme/plugin.rs +++ b/solitaire_engine/src/theme/plugin.rs @@ -8,24 +8,82 @@ //! exposed for tests and for any embedder that wants to load an //! alternative theme manually. +use std::collections::HashMap; + use bevy::asset::AssetEvent; use bevy::ecs::message::MessageReader; +use bevy::math::UVec2; use bevy::prelude::*; use solitaire_core::card::{Rank, Suit}; -use crate::assets::DEFAULT_THEME_MANIFEST_URL; +use crate::assets::{ + default_theme_svg_bytes, rasterize_svg, user_theme_dir, DEFAULT_THEME_MANIFEST_URL, +}; use crate::card_plugin::CardImageSet; use crate::events::StateChangedEvent; use super::loader::CardThemeLoader; +use super::registry::ThemeRegistry; use super::{CardKey, CardTheme}; +/// Width (logical px) of one Settings → Cosmetic theme-picker +/// thumbnail. A 2:3 card aspect at 100×140 keeps each chip a small +/// glanceable preview without bloating the picker row. +pub const THEME_THUMBNAIL_WIDTH_PX: u32 = 100; +/// Height counterpart to [`THEME_THUMBNAIL_WIDTH_PX`]. +pub const THEME_THUMBNAIL_HEIGHT_PX: u32 = 140; + /// Resource pointing at the currently-active card theme. Populated on /// startup with the bundled default theme and replaced by [`set_theme`] /// when the player switches. #[derive(Resource, Debug)] pub struct ActiveTheme(pub Handle); +/// One pair of preview-sized `Handle` for the Settings picker: +/// the theme's Ace of Spades and its card back. +/// +/// Either handle may be [`Handle::default`] when the underlying SVG +/// could not be located (e.g. a user theme that ships only a partial +/// set of files). The picker UI treats the default-handle case as +/// "render a placeholder swatch instead of an image" so a broken +/// theme can never crash the panel. +#[derive(Debug, Clone, Default)] +pub struct ThemeThumbnailPair { + /// Rasterised `spades_ace.svg` of the theme. + pub ace: Handle, + /// Rasterised `back.svg` of the theme. + pub back: Handle, +} + +impl ThemeThumbnailPair { + /// Returns `true` only when *both* preview slots resolve to a + /// non-default handle — a theme with at least one missing SVG is + /// considered incomplete and renders the placeholder for the + /// missing slot. + pub fn is_fully_populated(&self) -> bool { + self.ace != Handle::default() && self.back != Handle::default() + } +} + +/// Resource caching one [`ThemeThumbnailPair`] per registered theme, +/// keyed by `ThemeMeta::id`. +/// +/// Populated lazily by [`ensure_theme_thumbnails`] whenever the +/// [`ThemeRegistry`] grows or changes. The Settings panel reads from +/// this cache by id and falls back to the placeholder rendering path +/// when an entry is missing. +#[derive(Resource, Debug, Default)] +pub struct ThemeThumbnailCache { + pub entries: HashMap, +} + +impl ThemeThumbnailCache { + /// Returns the cached pair for `theme_id`, if any. + pub fn get(&self, theme_id: &str) -> Option<&ThemeThumbnailPair> { + self.entries.get(theme_id) + } +} + /// Bevy plugin that loads the default theme and keeps `CardImageSet` /// in sync with `Assets`. /// @@ -45,6 +103,7 @@ pub struct ThemePlugin; impl Plugin for ThemePlugin { fn build(&self, app: &mut App) { app.init_asset::() + .init_resource::() .register_asset_loader(crate::assets::SvgLoader) .register_asset_loader(CardThemeLoader) .add_systems(Startup, load_initial_theme) @@ -53,6 +112,7 @@ impl Plugin for ThemePlugin { ( sync_card_image_set_with_active_theme, react_to_settings_theme_change, + ensure_theme_thumbnails, ), ); } @@ -231,6 +291,104 @@ pub fn set_theme( handle } +// --------------------------------------------------------------------------- +// Picker-thumbnail generation +// --------------------------------------------------------------------------- + +/// Filename of the canonical "preview face" SVG inside a theme — the +/// Ace of Spades. Matches `CardKey::manifest_name(Spades, Ace)` so the +/// path resolves the same way whether we're reading from disk or from +/// the bundled-default lookup table. +const PREVIEW_FACE_FILENAME: &str = "spades_ace.svg"; + +/// Filename of the back SVG inside a theme. +const PREVIEW_BACK_FILENAME: &str = "back.svg"; + +/// Resolves the SVG bytes for one preview file (`back.svg` or +/// `spades_ace.svg`) belonging to the named theme. +/// +/// - For the bundled `default` theme, reads from the embedded +/// `DEFAULT_THEME_SVGS` table via [`default_theme_svg_bytes`]. No +/// filesystem I/O. +/// - For any user theme, reads from `//`. +/// Returns `None` for any I/O failure (file missing, permission +/// denied, etc.) — the caller treats `None` as "render placeholder". +fn read_theme_preview_svg_bytes(theme_id: &str, filename: &str) -> Option> { + if theme_id == "default" { + return default_theme_svg_bytes(filename).map(|b| b.to_vec()); + } + let path = user_theme_dir().join(theme_id).join(filename); + std::fs::read(&path).ok() +} + +/// Pure helper: rasterises one SVG preview byte slice at the picker's +/// thumbnail dimensions, inserts the resulting `Image` into +/// `Assets`, and returns the new handle. Returns +/// [`Handle::default`] if rasterisation fails (malformed SVG, etc.) so +/// the picker can render a placeholder for broken themes without +/// crashing. +fn rasterize_preview_to_handle( + svg_bytes: &[u8], + images: &mut Assets, +) -> Handle { + let target = UVec2::new(THEME_THUMBNAIL_WIDTH_PX, THEME_THUMBNAIL_HEIGHT_PX); + match rasterize_svg(svg_bytes, target) { + Ok(image) => images.add(image), + Err(err) => { + warn!("theme thumbnail rasterise failed: {err}"); + Handle::default() + } + } +} + +/// Builds a [`ThemeThumbnailPair`] for a single theme. Either handle +/// is [`Handle::default`] when the matching SVG could not be located +/// or rasterised. +fn generate_thumbnail_pair_for( + theme_id: &str, + images: &mut Assets, +) -> ThemeThumbnailPair { + let ace = read_theme_preview_svg_bytes(theme_id, PREVIEW_FACE_FILENAME) + .map(|b| rasterize_preview_to_handle(&b, images)) + .unwrap_or_default(); + let back = read_theme_preview_svg_bytes(theme_id, PREVIEW_BACK_FILENAME) + .map(|b| rasterize_preview_to_handle(&b, images)) + .unwrap_or_default(); + ThemeThumbnailPair { ace, back } +} + +/// System that generates a [`ThemeThumbnailPair`] for every registered +/// theme that doesn't yet have one in [`ThemeThumbnailCache`]. +/// +/// Runs each frame but the early-exit check (`already cached?`) keeps +/// the steady-state cost to a single hash lookup per theme. Generation +/// itself only happens once per theme — the SVGs are rasterised and +/// inserted into `Assets` and the handles cached forever. +/// +/// Lazy-on-first-pass beats Startup-only for two reasons: +/// +/// - The `ThemeRegistry` is built by a different `Startup` system, and +/// Bevy doesn't guarantee inter-system Startup ordering without +/// explicit `.after()` chaining. Polling each Update tick removes +/// the dependency. +/// - The future `refresh_registry` path (used after a successful +/// theme import in Phase 7) adds entries mid-session — this system +/// picks them up automatically without any extra wiring. +pub fn ensure_theme_thumbnails( + registry: Option>, + mut cache: ResMut, + mut images: ResMut>, +) { + let Some(registry) = registry else { return }; + for entry in registry.iter() { + if cache.entries.contains_key(&entry.id) { + continue; + } + let pair = generate_thumbnail_pair_for(&entry.id, &mut images); + cache.entries.insert(entry.id.clone(), pair); + } +} + #[cfg(test)] mod tests { use super::*; @@ -352,4 +510,120 @@ mod tests { let url2 = format!("themes://{}/theme.ron", "user_uploaded"); assert_eq!(url2, "themes://user_uploaded/theme.ron"); } + + /// Test 1: the bundled default theme always has embedded SVG bytes + /// available, so calling `generate_thumbnail_pair_for("default", …)` + /// must produce two non-default `Handle` slots. + #[test] + fn theme_thumbnails_generated_for_default_theme() { + let mut images = Assets::::default(); + let pair = generate_thumbnail_pair_for("default", &mut images); + assert!( + pair.is_fully_populated(), + "default theme must yield both ace + back thumbnail handles" + ); + // And the underlying images must actually exist in the assets + // collection — the handles are real, not dangling. + assert!(images.get(&pair.ace).is_some(), "ace image must be inserted"); + assert!(images.get(&pair.back).is_some(), "back image must be inserted"); + } + + /// Test 2: when a theme is registered but its preview SVGs are not + /// available on disk (a broken user-supplied theme), thumbnail + /// generation must NOT panic and must leave the missing slots as + /// the default handle so the picker UI can render its placeholder. + #[test] + fn theme_thumbnails_handle_missing_svg_gracefully() { + let mut images = Assets::::default(); + // A theme id that definitely has no files on disk under the + // user_theme_dir (the directory may not even exist on a + // fresh test machine). The function reads the filesystem + // lazily and silently returns None on I/O failures — no + // panic, no rasterise attempt. + let pair = generate_thumbnail_pair_for( + "this-theme-does-not-exist-on-disk-for-testing", + &mut images, + ); + assert_eq!( + pair.ace, + Handle::default(), + "missing ace.svg must yield Handle::default placeholder" + ); + assert_eq!( + pair.back, + Handle::default(), + "missing back.svg must yield Handle::default placeholder" + ); + assert!( + !pair.is_fully_populated(), + "incomplete pair must report not-fully-populated" + ); + } + + /// `read_theme_preview_svg_bytes` for the default theme always + /// returns embedded bytes for the canonical preview pair — + /// covering the happy-path branch of the helper. + #[test] + fn read_default_theme_preview_returns_some_for_canonical_files() { + assert!( + read_theme_preview_svg_bytes("default", PREVIEW_BACK_FILENAME).is_some(), + "default theme back.svg must be embedded" + ); + assert!( + read_theme_preview_svg_bytes("default", PREVIEW_FACE_FILENAME).is_some(), + "default theme spades_ace.svg must be embedded" + ); + } + + /// `ensure_theme_thumbnails` is idempotent: calling it twice with + /// the same registry must not regenerate or replace already-cached + /// entries. This guards against the per-frame Update tick churning + /// new `Handle` allocations and growing `Assets` + /// without bound. + #[test] + fn ensure_theme_thumbnails_caches_after_first_run() { + let mut app = App::new(); + app.add_plugins(MinimalPlugins); + app.init_resource::>(); + app.init_resource::(); + app.insert_resource(ThemeRegistry { + entries: vec![crate::theme::ThemeEntry { + id: "default".into(), + display_name: "Default".into(), + manifest_url: crate::assets::DEFAULT_THEME_MANIFEST_URL.into(), + meta: ThemeMeta { + id: "default".into(), + name: "Default".into(), + author: "x".into(), + version: "x".into(), + card_aspect: (2, 3), + }, + }], + }); + app.add_systems(Update, ensure_theme_thumbnails); + + // First tick generates the entry. + app.update(); + let first_ace = app + .world() + .resource::() + .get("default") + .map(|p| p.ace.clone()) + .expect("default theme thumbnail must exist after one tick"); + + // Second tick must NOT replace the cached handle. + app.update(); + let second_ace = app + .world() + .resource::() + .get("default") + .map(|p| p.ace.clone()) + .expect("default theme thumbnail must still exist"); + + assert_eq!( + first_ace.id(), + second_ace.id(), + "cached thumbnail handle must be stable across ticks" + ); + } }