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:
Generated
+1
@@ -6929,6 +6929,7 @@ dependencies = [
|
||||
"bevy",
|
||||
"chrono",
|
||||
"dirs",
|
||||
"image",
|
||||
"kira",
|
||||
"resvg",
|
||||
"ron",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user