feat(engine): card-art thumbnails in the theme picker

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 Handle<Image>s (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) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-05 00:41:20 +00:00
parent fe41b502ac
commit ba527de351
5 changed files with 535 additions and 18 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ pub mod svg_loader;
pub mod user_dir; pub mod user_dir;
pub use sources::{ pub use sources::{
populate_embedded_default_theme, register_theme_asset_sources, AssetSourcesPlugin, default_theme_svg_bytes, populate_embedded_default_theme, register_theme_asset_sources,
DEFAULT_THEME_MANIFEST_URL, USER_THEMES, AssetSourcesPlugin, DEFAULT_THEME_MANIFEST_URL, USER_THEMES,
}; };
pub use svg_loader::{rasterize_svg, SvgLoader, SvgLoaderError, SvgLoaderSettings}; pub use svg_loader::{rasterize_svg, SvgLoader, SvgLoaderError, SvgLoaderSettings};
pub use user_dir::{set_user_theme_dir, user_theme_dir}; pub use user_dir::{set_user_theme_dir, user_theme_dir};
+42
View File
@@ -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 /// Pushes every bundled default-theme file into the
/// [`EmbeddedAssetRegistry`] under its stable URL. Keeping this in a /// [`EmbeddedAssetRegistry`] under its stable URL. Keeping this in a
/// free function (and not inside the `Plugin::build` body) means the /// free function (and not inside the `Plugin::build` body) means the
@@ -291,6 +310,29 @@ mod tests {
assert_eq!(faces.len(), 52); 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` /// Belt-and-braces: if anyone edits `DEFAULT_THEME_MANIFEST_PATH`
/// without updating `DEFAULT_THEME_MANIFEST_URL` (or vice versa) /// without updating `DEFAULT_THEME_MANIFEST_URL` (or vice versa)
/// the asset would register at one path and be loaded from /// the asset would register at one path and be loaded from
+212 -14
View File
@@ -25,6 +25,7 @@ use crate::events::{ManualSyncRequestEvent, ToggleSettingsRequestEvent};
use crate::font_plugin::FontResource; use crate::font_plugin::FontResource;
use crate::progress_plugin::ProgressResource; use crate::progress_plugin::ProgressResource;
use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource}; use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource};
use crate::theme::{ThemeThumbnailCache, ThemeThumbnailPair};
use crate::ui_focus::{FocusGroup, FocusRow, Focusable, FocusedButton}; use crate::ui_focus::{FocusGroup, FocusRow, Focusable, FocusedButton};
use crate::ui_modal::{ use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant, 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_tooltip::Tooltip;
use crate::ui_theme::{ use crate::ui_theme::{
BG_BASE, BG_ELEVATED_HI, BORDER_SUBTLE, RADIUS_SM, SPACE_2, STATE_SUCCESS, TEXT_PRIMARY, BG_BASE, BG_ELEVATED, BG_ELEVATED_HI, BORDER_SUBTLE, RADIUS_SM, SPACE_2, STATE_SUCCESS,
TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL, 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. /// Side length of a swatch button in the card-back / background pickers.
@@ -134,6 +136,23 @@ struct SettingsPanelScrollable;
#[derive(Component, Debug)] #[derive(Component, Debug)]
struct SettingsScrollNode; 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<ThemeThumbnailPair>,
}
/// Tags interactive buttons inside the Settings panel. /// Tags interactive buttons inside the Settings panel.
#[derive(Component, Debug)] #[derive(Component, Debug)]
enum SettingsButton { enum SettingsButton {
@@ -370,6 +389,7 @@ fn sync_settings_panel_visibility(
progress: Option<Res<ProgressResource>>, progress: Option<Res<ProgressResource>>,
font_res: Option<Res<FontResource>>, font_res: Option<Res<FontResource>>,
theme_registry: Option<Res<crate::theme::ThemeRegistry>>, theme_registry: Option<Res<crate::theme::ThemeRegistry>>,
theme_thumbs: Option<Res<ThemeThumbnailCache>>,
card_images: Option<Res<crate::card_plugin::CardImageSet>>, card_images: Option<Res<crate::card_plugin::CardImageSet>>,
) { ) {
if !screen.is_changed() { if !screen.is_changed() {
@@ -385,15 +405,27 @@ fn sync_settings_panel_visibility(
let unlocked_bgs = progress let unlocked_bgs = progress
.as_ref() .as_ref()
.map_or(&[0][..], |p| p.0.unlocked_backgrounds.as_slice()); .map_or(&[0][..], |p| p.0.unlocked_backgrounds.as_slice());
// Snapshot themes by id+display_name so spawn_settings_panel // Snapshot themes by id, display_name and (optional)
// doesn't have to know about the registry shape. Empty when // thumbnail pair so spawn_settings_panel doesn't have to
// know about the registry / cache shapes. Empty when
// ThemeRegistryPlugin isn't installed (tests under // ThemeRegistryPlugin isn't installed (tests under
// MinimalPlugins) — the picker row simply won't render. // 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<ThemePickerEntry> = theme_registry
.as_deref() .as_deref()
.map(|r| { .map(|r| {
r.iter() 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() .collect()
}) })
.unwrap_or_default(); .unwrap_or_default();
@@ -1010,7 +1042,7 @@ fn spawn_settings_panel(
sync_status: &str, sync_status: &str,
unlocked_card_backs: &[usize], unlocked_card_backs: &[usize],
unlocked_backgrounds: &[usize], unlocked_backgrounds: &[usize],
themes: &[(String, String)], themes: &[ThemePickerEntry],
scroll_offset: f32, scroll_offset: f32,
font_res: Option<&FontResource>, font_res: Option<&FontResource>,
theme_overrides_back: bool, theme_overrides_back: bool,
@@ -1384,6 +1416,13 @@ fn picker_row(
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub(crate) struct CardBackPickerOverriddenByTheme; 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 /// Renders the "Card Back" row in its overridden-by-theme state: a
/// labelled caption explaining why the swatches are hidden, with no /// labelled caption explaining why the swatches are hidden, with no
/// interactive children. This is what the player sees when the active /// 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`] /// Picker row for card-art themes. Distinct from [`picker_row`]
/// because themes are identified by `String` ids (matching /// because themes are identified by `String` ids (matching
/// `ThemeMeta::id`) instead of dense indices, and each chip carries /// `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( fn theme_picker_row(
parent: &mut ChildSpawnerCommands, parent: &mut ChildSpawnerCommands,
label: &str, label: &str,
themes: &[(String, String)], themes: &[ThemePickerEntry],
selected_id: &str, selected_id: &str,
tooltip: &'static str, tooltip: &'static str,
font_res: Option<&FontResource>, font_res: Option<&FontResource>,
@@ -1461,19 +1511,25 @@ fn theme_picker_row(
label_font, label_font,
TextColor(TEXT_SECONDARY), TextColor(TEXT_SECONDARY),
)); ));
for (id, display_name) in themes { for entry in themes {
let is_selected = id == selected_id; let is_selected = entry.id == selected_id;
let bg = if is_selected { STATE_SUCCESS } else { BG_ELEVATED_HI }; let bg = if is_selected { STATE_SUCCESS } else { BG_ELEVATED_HI };
row.spawn(( row.spawn((
SettingsButton::SelectTheme(id.clone()), SettingsButton::SelectTheme(entry.id.clone()),
Button, Button,
Tooltip::new(tooltip), Tooltip::new(tooltip),
Node { 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 — // Theme names are wider than numeric chips —
// pad horizontally instead of using a fixed // pad horizontally instead of using a fixed
// square swatch. // 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), min_height: Val::Px(SWATCH_PX),
row_gap: VAL_SPACE_2,
justify_content: JustifyContent::Center, justify_content: JustifyContent::Center,
align_items: AlignItems::Center, align_items: AlignItems::Center,
border: UiRect::all(Val::Px(1.0)), border: UiRect::all(Val::Px(1.0)),
@@ -1484,9 +1540,10 @@ fn theme_picker_row(
BorderColor::all(BORDER_SUBTLE), BorderColor::all(BORDER_SUBTLE),
)) ))
.with_children(|b| { .with_children(|b| {
spawn_thumbnail_pair(b, entry.thumbnails.as_ref());
let text_color = if is_selected { BG_BASE } else { TEXT_PRIMARY }; let text_color = if is_selected { BG_BASE } else { TEXT_PRIMARY };
b.spawn(( b.spawn((
Text::new(display_name.clone()), Text::new(entry.display_name.clone()),
chip_font.clone(), chip_font.clone(),
TextColor(text_color), 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<Image>) {
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. /// Status text + manual "Sync Now" button.
fn sync_row(parent: &mut ChildSpawnerCommands, status_text: &str, font_res: Option<&FontResource>) { fn sync_row(parent: &mut ChildSpawnerCommands, status_text: &str, font_res: Option<&FontResource>) {
let status_font = TextFont { 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<Image> resource so we can mint stable handles
// for the synthetic thumbnail pair.
app.init_resource::<Assets<Image>>();
let (ace_handle, back_handle) = {
let mut images = app.world_mut().resource_mut::<Assets<Image>>();
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::<SettingsScreen>().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<ThemeThumbnailMarker>>()
.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<ThemeThumbnailMarker>>()
.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 // Window geometry persistence
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
+4 -1
View File
@@ -31,7 +31,10 @@ use solitaire_core::card::{Rank, Suit};
pub use importer::{import_theme, import_theme_into, ImportError, ThemeId}; pub use importer::{import_theme, import_theme_into, ImportError, ThemeId};
pub use loader::{CardThemeLoader, CardThemeLoaderError}; pub use loader::{CardThemeLoader, CardThemeLoaderError};
pub use manifest::ThemeManifest; 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::{ pub use registry::{
build_registry, refresh_registry, ThemeEntry, ThemeRegistry, ThemeRegistryPlugin, build_registry, refresh_registry, ThemeEntry, ThemeRegistry, ThemeRegistryPlugin,
}; };
+275 -1
View File
@@ -8,24 +8,82 @@
//! exposed for tests and for any embedder that wants to load an //! exposed for tests and for any embedder that wants to load an
//! alternative theme manually. //! alternative theme manually.
use std::collections::HashMap;
use bevy::asset::AssetEvent; use bevy::asset::AssetEvent;
use bevy::ecs::message::MessageReader; use bevy::ecs::message::MessageReader;
use bevy::math::UVec2;
use bevy::prelude::*; use bevy::prelude::*;
use solitaire_core::card::{Rank, Suit}; 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::card_plugin::CardImageSet;
use crate::events::StateChangedEvent; use crate::events::StateChangedEvent;
use super::loader::CardThemeLoader; use super::loader::CardThemeLoader;
use super::registry::ThemeRegistry;
use super::{CardKey, CardTheme}; 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 /// Resource pointing at the currently-active card theme. Populated on
/// startup with the bundled default theme and replaced by [`set_theme`] /// startup with the bundled default theme and replaced by [`set_theme`]
/// when the player switches. /// when the player switches.
#[derive(Resource, Debug)] #[derive(Resource, Debug)]
pub struct ActiveTheme(pub Handle<CardTheme>); pub struct ActiveTheme(pub Handle<CardTheme>);
/// One pair of preview-sized `Handle<Image>` 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<Image>,
/// Rasterised `back.svg` of the theme.
pub back: Handle<Image>,
}
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<String, ThemeThumbnailPair>,
}
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` /// Bevy plugin that loads the default theme and keeps `CardImageSet`
/// in sync with `Assets<CardTheme>`. /// in sync with `Assets<CardTheme>`.
/// ///
@@ -45,6 +103,7 @@ pub struct ThemePlugin;
impl Plugin for ThemePlugin { impl Plugin for ThemePlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.init_asset::<CardTheme>() app.init_asset::<CardTheme>()
.init_resource::<ThemeThumbnailCache>()
.register_asset_loader(crate::assets::SvgLoader) .register_asset_loader(crate::assets::SvgLoader)
.register_asset_loader(CardThemeLoader) .register_asset_loader(CardThemeLoader)
.add_systems(Startup, load_initial_theme) .add_systems(Startup, load_initial_theme)
@@ -53,6 +112,7 @@ impl Plugin for ThemePlugin {
( (
sync_card_image_set_with_active_theme, sync_card_image_set_with_active_theme,
react_to_settings_theme_change, react_to_settings_theme_change,
ensure_theme_thumbnails,
), ),
); );
} }
@@ -231,6 +291,104 @@ pub fn set_theme(
handle 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 `<user_theme_dir>/<id>/<filename>`.
/// 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<Vec<u8>> {
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<Image>`, 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<Image>,
) -> Handle<Image> {
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<Image>,
) -> 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<Image>` 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<Res<ThemeRegistry>>,
mut cache: ResMut<ThemeThumbnailCache>,
mut images: ResMut<Assets<Image>>,
) {
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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -352,4 +510,120 @@ mod tests {
let url2 = format!("themes://{}/theme.ron", "user_uploaded"); let url2 = format!("themes://{}/theme.ron", "user_uploaded");
assert_eq!(url2, "themes://user_uploaded/theme.ron"); 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<Image>` slots.
#[test]
fn theme_thumbnails_generated_for_default_theme() {
let mut images = Assets::<Image>::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::<Image>::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<Image>` allocations and growing `Assets<Image>`
/// without bound.
#[test]
fn ensure_theme_thumbnails_caches_after_first_run() {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.init_resource::<Assets<Image>>();
app.init_resource::<ThemeThumbnailCache>();
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::<ThemeThumbnailCache>()
.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::<ThemeThumbnailCache>()
.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"
);
}
} }