feat(engine): theme thumbnails accept PNG faces alongside SVG

The theme picker chip's thumbnail loader hardcoded `.svg`
filenames (`spades_ace.svg`, `back.svg`) — a holdover from when
every shipped theme was vector-art. Raster-art user themes (e.g.
the v0.19 pixel-art theme generated via Claude Design and dropped
into ~/.local/share/solitaire_quest/themes/rusty-pixel/) had real
PNGs in their directory but the picker rendered placeholders
because it never tried the PNG sibling.

The fix is scoped to the thumbnail-cache pipeline. In-game card
rendering already worked via Bevy's standard PNG asset loader on
manifest-declared face/back paths — only the picker's small
preview chip was affected.

Changes in solitaire_engine/src/theme/plugin.rs:

  - PREVIEW_FACE_FILENAME / PREVIEW_BACK_FILENAME (with embedded
    `.svg` suffix) replaced by PREVIEW_FACE_BASENAME /
    PREVIEW_BACK_BASENAME ("spades_ace" / "back"). The function
    appends the extension itself.
  - read_theme_preview_svg_bytes -> read_theme_preview_bytes
    returns ThemePreviewBytes::{Svg, Png}. For "default" the
    embedded table stays SVG-only. For user themes the function
    tries `<basename>.svg` first (matching the bundled
    convention) and falls back to `<basename>.png` second.
  - rasterize_preview_to_handle gains a Png branch that calls a
    new decode_png_for_thumbnail helper (Bevy's
    Image::from_buffer with ImageType::Format(ImageFormat::Png)).
    PNGs decode at native dimensions; the picker chip's UI
    layout scales them at draw time. SVGs continue to rasterise
    at the fixed 100x140 thumbnail size as before.
  - generate_thumbnail_pair_for is unchanged in shape; just
    threads the new enum through.

Tests:

  - read_default_theme_preview_returns_some_for_canonical_files
    updated to match the new function signature and assert on
    the Svg variant explicitly.
  - New png_only_user_theme_generates_real_thumbnails creates a
    temp theme dir, writes a 2x3 PNG (encoded at runtime via the
    `image` dev-dep so the bytes are guaranteed valid), and
    asserts both ace + back yield non-default Handle<Image>.
    Cleans up the temp dir afterward.

solitaire_engine/Cargo.toml: image = "0.25" added as a
dev-dependency for the test's runtime PNG encoding. Already a
transitive Bevy dep so the build graph is unchanged.

Workspace: 1171 passing tests / 0 failing, was 1170 (+1 new).
cargo clippy --workspace --all-targets -- -D warnings clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-06 19:13:52 -07:00
parent 9ff48ace5b
commit de4751115f
3 changed files with 177 additions and 45 deletions
Generated
+1
View File
@@ -6929,6 +6929,7 @@ dependencies = [
"bevy",
"chrono",
"dirs",
"image",
"kira",
"resvg",
"ron",
+5
View File
@@ -26,3 +26,8 @@ arboard = { workspace = true }
[dev-dependencies]
async-trait = { workspace = true }
tempfile = { workspace = true }
# Used by the theme-thumbnail tests to encode a known-valid PNG at
# runtime. Already a transitive dep of bevy_image, so the dev-dep is
# free in build-graph terms — it just makes the API surface available
# to test code without forcing us into bevy_image's re-export shape.
image = { version = "0.25", default-features = false, features = ["png"] }
+171 -45
View File
@@ -295,63 +295,118 @@ pub fn set_theme(
// 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";
/// Basename (no extension) of the canonical "preview face" inside a
/// theme — the Ace of Spades. Matches `CardKey::manifest_name(Spades,
/// Ace)`. The thumbnail loader appends `.svg` first and falls back to
/// `.png` so themes shipped as raster art still get real previews.
const PREVIEW_FACE_BASENAME: &str = "spades_ace";
/// Filename of the back SVG inside a theme.
const PREVIEW_BACK_FILENAME: &str = "back.svg";
/// Basename (no extension) of the back preview inside a theme. Matched
/// the same way as [`PREVIEW_FACE_BASENAME`].
const PREVIEW_BACK_BASENAME: &str = "back";
/// 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()
/// Bytes of one preview slot tagged with its source format. SVGs go
/// through `rasterize_svg` (vector → fixed-size pixmap); PNGs decode
/// directly into a `bevy::image::Image` whose intrinsic dimensions
/// the UI scales at draw time.
#[derive(Debug)]
enum ThemePreviewBytes {
/// SVG source — the bundled default theme's convention. Caller
/// rasterises through the existing `usvg` + `resvg` pipeline.
Svg(Vec<u8>),
/// PNG source — the convention for raster-art user themes (e.g.
/// pixel-art themes generated via Claude Design — see
/// `SESSION_HANDOFF.md` for the v0.19 drop-in flow).
Png(Vec<u8>),
}
/// 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.
/// Resolves the preview bytes for a card slot in `theme_id`, trying
/// `.svg` first (the bundled default's convention) and falling back
/// to `.png` for raster-art themes. Returns `None` when neither
/// extension resolves — the caller renders a placeholder.
///
/// - For the bundled `default` theme: reads from the embedded
/// `DEFAULT_THEME_SVGS` table via [`default_theme_svg_bytes`]. SVG
/// only — the embed table is `.svg` exclusive.
/// - For any user theme: reads from `<user_theme_dir>/<id>/`. Tries
/// `<basename>.svg` then `<basename>.png`. Either branch returns
/// `None` on I/O failure (file missing, permission denied, etc.).
fn read_theme_preview_bytes(theme_id: &str, basename: &str) -> Option<ThemePreviewBytes> {
if theme_id == "default" {
let filename = format!("{basename}.svg");
return default_theme_svg_bytes(&filename)
.map(|b| ThemePreviewBytes::Svg(b.to_vec()));
}
let dir = user_theme_dir().join(theme_id);
if let Ok(bytes) = std::fs::read(dir.join(format!("{basename}.svg"))) {
return Some(ThemePreviewBytes::Svg(bytes));
}
if let Ok(bytes) = std::fs::read(dir.join(format!("{basename}.png"))) {
return Some(ThemePreviewBytes::Png(bytes));
}
None
}
/// Decodes raster bytes (currently PNG) into a `bevy::image::Image`.
/// Bevy's `Image::from_buffer` dispatches via the supplied
/// `ImageType`, so this is a thin wrapper that translates I/O
/// failures into a logged warning + `None`.
fn decode_png_for_thumbnail(png_bytes: &[u8]) -> Option<Image> {
use bevy::image::{CompressedImageFormats, Image, ImageSampler, ImageType};
use bevy::asset::RenderAssetUsages;
Image::from_buffer(
png_bytes,
ImageType::Format(bevy::image::ImageFormat::Png),
CompressedImageFormats::default(),
true, // is_srgb — pixel-art faces are authored in sRGB
ImageSampler::Default,
RenderAssetUsages::default(),
)
.map_err(|e| warn!("theme thumbnail png decode failed: {e}"))
.ok()
}
/// Pure helper: turns one preview byte slice into a thumbnail
/// `Handle<Image>`. SVGs rasterise to a fixed
/// `THEME_THUMBNAIL_WIDTH_PX × THEME_THUMBNAIL_HEIGHT_PX` pixmap
/// (preserving aspect, centred); PNGs decode at their native
/// dimensions and Bevy's UI scales them at draw time. Returns
/// [`Handle::default`] on decode / rasterise failure so the picker
/// can render a placeholder without crashing.
fn rasterize_preview_to_handle(
svg_bytes: &[u8],
bytes: &ThemePreviewBytes,
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()
match bytes {
ThemePreviewBytes::Svg(b) => {
let target = UVec2::new(THEME_THUMBNAIL_WIDTH_PX, THEME_THUMBNAIL_HEIGHT_PX);
match rasterize_svg(b, target) {
Ok(image) => images.add(image),
Err(err) => {
warn!("theme thumbnail svg rasterise failed: {err}");
Handle::default()
}
}
}
ThemePreviewBytes::Png(b) => match decode_png_for_thumbnail(b) {
Some(image) => images.add(image),
None => Handle::default(),
},
}
}
/// Builds a [`ThemeThumbnailPair`] for a single theme. Either handle
/// is [`Handle::default`] when the matching SVG could not be located
/// or rasterised.
/// is [`Handle::default`] when the matching face / back file could
/// not be located in either `.svg` or `.png` form, or when decoding
/// failed.
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)
let ace = read_theme_preview_bytes(theme_id, PREVIEW_FACE_BASENAME)
.map(|b| rasterize_preview_to_handle(&b, images))
.unwrap_or_default();
let back = read_theme_preview_svg_bytes(theme_id, PREVIEW_BACK_FILENAME)
let back = read_theme_preview_bytes(theme_id, PREVIEW_BACK_BASENAME)
.map(|b| rasterize_preview_to_handle(&b, images))
.unwrap_or_default();
ThemeThumbnailPair { ace, back }
@@ -560,21 +615,92 @@ mod tests {
);
}
/// `read_theme_preview_svg_bytes` for the default theme always
/// returns embedded bytes for the canonical preview pair —
/// `read_theme_preview_bytes` for the default theme always
/// returns embedded SVG 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"
matches!(
read_theme_preview_bytes("default", PREVIEW_BACK_BASENAME),
Some(ThemePreviewBytes::Svg(_)),
),
"default theme back must resolve to embedded SVG bytes"
);
assert!(
read_theme_preview_svg_bytes("default", PREVIEW_FACE_FILENAME).is_some(),
"default theme spades_ace.svg must be embedded"
matches!(
read_theme_preview_bytes("default", PREVIEW_FACE_BASENAME),
Some(ThemePreviewBytes::Svg(_)),
),
"default theme spades_ace must resolve to embedded SVG bytes"
);
}
/// PNG raster-art themes (e.g. the v0.19 drop-in pixel-art theme
/// generated via Claude Design) must produce non-default
/// thumbnail handles in the picker. The function reads
/// `<user_theme_dir>/<id>/spades_ace.png` and `back.png`,
/// decodes them via Bevy's `Image::from_buffer`, and inserts the
/// resulting `Image` into `Assets<Image>`. Pins the v0.18 →
/// v0.19 SVG-only → SVG-or-PNG widening of the thumbnail
/// pipeline.
#[test]
fn png_only_user_theme_generates_real_thumbnails() {
// Drop a synthetic theme into a unique temp subdirectory so
// the test doesn't collide with whatever real themes the dev
// machine has installed under user_theme_dir().
let theme_id = format!(
"test-png-theme-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
);
let theme_dir = user_theme_dir().join(&theme_id);
std::fs::create_dir_all(&theme_dir).expect("create temp theme dir");
// Encode a real 2×3 RGBA PNG via the `image` dev-dep so the
// test exercises Bevy's actual PNG decoder. A handcrafted byte
// string is too fragile (DEFLATE encodes are non-trivial) and
// a `include_bytes!` of a checked-in PNG would shoulder
// committed binary into the repo.
let mut png_bytes: Vec<u8> = Vec::new();
let img = image::RgbaImage::from_pixel(2, 3, image::Rgba([200, 60, 60, 255]));
image::DynamicImage::ImageRgba8(img)
.write_to(
&mut std::io::Cursor::new(&mut png_bytes),
image::ImageFormat::Png,
)
.expect("encode tiny png");
std::fs::write(theme_dir.join("spades_ace.png"), &png_bytes)
.expect("write spades_ace.png");
std::fs::write(theme_dir.join("back.png"), &png_bytes)
.expect("write back.png");
let mut images = Assets::<Image>::default();
let pair = generate_thumbnail_pair_for(&theme_id, &mut images);
assert_ne!(
pair.ace,
Handle::default(),
"PNG-only theme must yield a real ace thumbnail handle, not the placeholder",
);
assert_ne!(
pair.back,
Handle::default(),
"PNG-only theme must yield a real back thumbnail handle, not the placeholder",
);
assert!(
pair.is_fully_populated(),
"complete PNG-only pair must report fully-populated",
);
// Cleanup — the test is robust to leftover dirs but tidy up
// anyway so /tmp doesn't grow on repeated CI runs.
let _ = std::fs::remove_dir_all(&theme_dir);
}
/// `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