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:
@@ -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};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<ThemeThumbnailPair>,
|
||||
}
|
||||
|
||||
/// Tags interactive buttons inside the Settings panel.
|
||||
#[derive(Component, Debug)]
|
||||
enum SettingsButton {
|
||||
@@ -370,6 +389,7 @@ fn sync_settings_panel_visibility(
|
||||
progress: Option<Res<ProgressResource>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
theme_registry: Option<Res<crate::theme::ThemeRegistry>>,
|
||||
theme_thumbs: Option<Res<ThemeThumbnailCache>>,
|
||||
card_images: Option<Res<crate::card_plugin::CardImageSet>>,
|
||||
) {
|
||||
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<ThemePickerEntry> = 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<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.
|
||||
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<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
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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<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`
|
||||
/// in sync with `Assets<CardTheme>`.
|
||||
///
|
||||
@@ -45,6 +103,7 @@ pub struct ThemePlugin;
|
||||
impl Plugin for ThemePlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_asset::<CardTheme>()
|
||||
.init_resource::<ThemeThumbnailCache>()
|
||||
.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 `<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)]
|
||||
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<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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user