feat(engine): bundle Rusty Pixel as a built-in theme

The pixel-art card theme generated via Claude Design (53 PNGs at
256x384, ~340 KB total) now ships embedded in the binary alongside
the existing default SVG theme. Players see the new theme in the
picker out of the box without needing to drop files into
~/.local/share/solitaire_quest/themes/.

solitaire_engine/assets/themes/rusty-pixel/:
  - 53 PNGs (52 face cards + 1 back) at 256x384
  - theme.ron declaring meta.id = "rusty-pixel",
    card_aspect = (2, 3), pixel_art = true

assets/sources.rs:
  - New constants RUSTY_PIXEL_THEME_MANIFEST_URL,
    RUSTY_PIXEL_THEME_MANIFEST_PATH,
    RUSTY_PIXEL_THEME_MANIFEST_BYTES.
  - New embed_rusty_pixel_png! macro mirroring embed_default_svg!.
  - New RUSTY_PIXEL_THEME_PNGS table — 53 entries, one per file.
  - New rusty_pixel_theme_png_bytes(filename) lookup helper
    mirroring default_theme_svg_bytes for the thumbnail cache.
  - New populate_embedded_rusty_pixel_theme(app) registers the
    manifest + every PNG into Bevy's EmbeddedAssetRegistry.
  - AssetSourcesPlugin::build now calls both populate functions
    so the picker has both themes loadable from the binary alone.

theme/registry.rs:
  - New rusty_pixel_entry() returns the bundled metadata.
  - build_registry now inserts default + rusty-pixel ahead of the
    user-dir scan, and filters user themes whose id collides with
    a bundled built-in. Bundled wins on collision because it's
    guaranteed complete; the user's overriding copy may be partial
    or stale.
  - Updated existing tests for the new len()=2-instead-of-1 baseline.
  - New test user_theme_id_collision_with_bundled_is_dropped pins
    the dedup contract.

theme/plugin.rs:
  - load_initial_theme + react_to_settings_theme_change now both
    consult a new manifest_url_for(theme_id) helper that routes
    bundled built-ins through embedded:// and unknown ids through
    themes://. Drops the previous hard-coded "default →
    DEFAULT_THEME_MANIFEST_URL else themes://" branch.
  - read_theme_preview_bytes also checks the rusty-pixel embed
    table before falling through to the user-dir filesystem read,
    so the picker chip's thumbnail works on a fresh install where
    the user-dir doesn't exist.

Workspace: 1172 passing tests / 0 failing, was 1171 (+1 net from
the new collision test). cargo clippy --workspace --all-targets
-- -D warnings clean. Binary grows by ~340 KB (the 53 bundled
PNGs).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-06 19:28:53 -07:00
parent 17e3112502
commit 21ec03b157
58 changed files with 326 additions and 24 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

@@ -0,0 +1,76 @@
// Rusty Pixel — pixel-art card theme generated via Claude Design.
//
// 53 PNGs at 256×384 (4× nearest-neighbor upscale of the source
// 64×96 grid). Card aspect 2:3 matches the engine's layout
// assumption. Drop this directory under
// `<data_dir>/solitaire_quest/themes/rusty-pixel/` and the theme
// registry picks it up at next launch.
(
meta: (
id: "rusty-pixel",
name: "Rusty Pixel",
author: "Claude Design",
version: "0.1.0",
card_aspect: (2, 3),
// Opt in to nearest-neighbor sampling so the pixel grid stays
// crisp at non-integer scales (Bevy's default bilinear filter
// mushes 256x384 pixel art when displayed at ~150x200 on a
// typical desktop window).
pixel_art: true,
),
back: "back.png",
faces: {
"clubs_ace": "clubs_ace.png",
"clubs_2": "clubs_2.png",
"clubs_3": "clubs_3.png",
"clubs_4": "clubs_4.png",
"clubs_5": "clubs_5.png",
"clubs_6": "clubs_6.png",
"clubs_7": "clubs_7.png",
"clubs_8": "clubs_8.png",
"clubs_9": "clubs_9.png",
"clubs_10": "clubs_10.png",
"clubs_jack": "clubs_jack.png",
"clubs_queen": "clubs_queen.png",
"clubs_king": "clubs_king.png",
"diamonds_ace": "diamonds_ace.png",
"diamonds_2": "diamonds_2.png",
"diamonds_3": "diamonds_3.png",
"diamonds_4": "diamonds_4.png",
"diamonds_5": "diamonds_5.png",
"diamonds_6": "diamonds_6.png",
"diamonds_7": "diamonds_7.png",
"diamonds_8": "diamonds_8.png",
"diamonds_9": "diamonds_9.png",
"diamonds_10": "diamonds_10.png",
"diamonds_jack": "diamonds_jack.png",
"diamonds_queen": "diamonds_queen.png",
"diamonds_king": "diamonds_king.png",
"hearts_ace": "hearts_ace.png",
"hearts_2": "hearts_2.png",
"hearts_3": "hearts_3.png",
"hearts_4": "hearts_4.png",
"hearts_5": "hearts_5.png",
"hearts_6": "hearts_6.png",
"hearts_7": "hearts_7.png",
"hearts_8": "hearts_8.png",
"hearts_9": "hearts_9.png",
"hearts_10": "hearts_10.png",
"hearts_jack": "hearts_jack.png",
"hearts_queen": "hearts_queen.png",
"hearts_king": "hearts_king.png",
"spades_ace": "spades_ace.png",
"spades_2": "spades_2.png",
"spades_3": "spades_3.png",
"spades_4": "spades_4.png",
"spades_5": "spades_5.png",
"spades_6": "spades_6.png",
"spades_7": "spades_7.png",
"spades_8": "spades_8.png",
"spades_9": "spades_9.png",
"spades_10": "spades_10.png",
"spades_jack": "spades_jack.png",
"spades_queen": "spades_queen.png",
"spades_king": "spades_king.png",
},
)
+4 -2
View File
@@ -11,8 +11,10 @@ pub mod svg_loader;
pub mod user_dir;
pub use sources::{
default_theme_svg_bytes, populate_embedded_default_theme, register_theme_asset_sources,
AssetSourcesPlugin, DEFAULT_THEME_MANIFEST_URL, USER_THEMES,
default_theme_svg_bytes, populate_embedded_default_theme,
populate_embedded_rusty_pixel_theme, register_theme_asset_sources,
rusty_pixel_theme_png_bytes, AssetSourcesPlugin, DEFAULT_THEME_MANIFEST_URL,
RUSTY_PIXEL_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};
+132
View File
@@ -155,6 +155,100 @@ const DEFAULT_THEME_SVGS: &[(&str, &[u8])] = &[
embed_default_svg!("spades_king.svg"),
];
/// Stable embedded asset URL of the bundled rusty-pixel theme manifest.
///
/// `theme/plugin.rs::manifest_url_for` uses this when the player
/// selects "Rusty Pixel" so the manifest loads from the binary's
/// embedded asset registry rather than `themes://` (which would
/// require a user-supplied copy on disk).
pub const RUSTY_PIXEL_THEME_MANIFEST_URL: &str =
"embedded://solitaire_engine/assets/themes/rusty-pixel/theme.ron";
/// Path the embedded rusty-pixel theme manifest registers under,
/// relative to the `embedded://` source root. Kept in lockstep with
/// [`RUSTY_PIXEL_THEME_MANIFEST_URL`] by the unit test
/// `rusty_pixel_theme_url_constant_matches_embedded_path`.
const RUSTY_PIXEL_THEME_MANIFEST_PATH: &str =
"solitaire_engine/assets/themes/rusty-pixel/theme.ron";
/// Bytes of the bundled rusty-pixel theme manifest. Mirrors the
/// default-theme embed pattern — `include_bytes!` resolves at compile
/// time so the binary ships the manifest even on machines whose
/// `solitaire_engine/assets/` directory is absent at runtime.
const RUSTY_PIXEL_THEME_MANIFEST_BYTES: &[u8] =
include_bytes!("../../assets/themes/rusty-pixel/theme.ron");
/// Generates a `(stable_path, bytes)` entry for one rusty-pixel
/// theme PNG. Mirrors [`embed_default_svg!`] for the second bundled
/// theme — the path matches what `theme.ron` references.
macro_rules! embed_rusty_pixel_png {
($name:literal) => {
(
concat!("solitaire_engine/assets/themes/rusty-pixel/", $name),
include_bytes!(concat!("../../assets/themes/rusty-pixel/", $name)) as &[u8],
)
};
}
/// Every rusty-pixel theme PNG bundled into the binary. 53 entries:
/// 52 face cards + 1 back. The macro pulls each PNG via
/// `include_bytes!` so adding a new file is a one-line append.
const RUSTY_PIXEL_THEME_PNGS: &[(&str, &[u8])] = &[
embed_rusty_pixel_png!("back.png"),
embed_rusty_pixel_png!("clubs_ace.png"),
embed_rusty_pixel_png!("clubs_2.png"),
embed_rusty_pixel_png!("clubs_3.png"),
embed_rusty_pixel_png!("clubs_4.png"),
embed_rusty_pixel_png!("clubs_5.png"),
embed_rusty_pixel_png!("clubs_6.png"),
embed_rusty_pixel_png!("clubs_7.png"),
embed_rusty_pixel_png!("clubs_8.png"),
embed_rusty_pixel_png!("clubs_9.png"),
embed_rusty_pixel_png!("clubs_10.png"),
embed_rusty_pixel_png!("clubs_jack.png"),
embed_rusty_pixel_png!("clubs_queen.png"),
embed_rusty_pixel_png!("clubs_king.png"),
embed_rusty_pixel_png!("diamonds_ace.png"),
embed_rusty_pixel_png!("diamonds_2.png"),
embed_rusty_pixel_png!("diamonds_3.png"),
embed_rusty_pixel_png!("diamonds_4.png"),
embed_rusty_pixel_png!("diamonds_5.png"),
embed_rusty_pixel_png!("diamonds_6.png"),
embed_rusty_pixel_png!("diamonds_7.png"),
embed_rusty_pixel_png!("diamonds_8.png"),
embed_rusty_pixel_png!("diamonds_9.png"),
embed_rusty_pixel_png!("diamonds_10.png"),
embed_rusty_pixel_png!("diamonds_jack.png"),
embed_rusty_pixel_png!("diamonds_queen.png"),
embed_rusty_pixel_png!("diamonds_king.png"),
embed_rusty_pixel_png!("hearts_ace.png"),
embed_rusty_pixel_png!("hearts_2.png"),
embed_rusty_pixel_png!("hearts_3.png"),
embed_rusty_pixel_png!("hearts_4.png"),
embed_rusty_pixel_png!("hearts_5.png"),
embed_rusty_pixel_png!("hearts_6.png"),
embed_rusty_pixel_png!("hearts_7.png"),
embed_rusty_pixel_png!("hearts_8.png"),
embed_rusty_pixel_png!("hearts_9.png"),
embed_rusty_pixel_png!("hearts_10.png"),
embed_rusty_pixel_png!("hearts_jack.png"),
embed_rusty_pixel_png!("hearts_queen.png"),
embed_rusty_pixel_png!("hearts_king.png"),
embed_rusty_pixel_png!("spades_ace.png"),
embed_rusty_pixel_png!("spades_2.png"),
embed_rusty_pixel_png!("spades_3.png"),
embed_rusty_pixel_png!("spades_4.png"),
embed_rusty_pixel_png!("spades_5.png"),
embed_rusty_pixel_png!("spades_6.png"),
embed_rusty_pixel_png!("spades_7.png"),
embed_rusty_pixel_png!("spades_8.png"),
embed_rusty_pixel_png!("spades_9.png"),
embed_rusty_pixel_png!("spades_10.png"),
embed_rusty_pixel_png!("spades_jack.png"),
embed_rusty_pixel_png!("spades_queen.png"),
embed_rusty_pixel_png!("spades_king.png"),
];
/// Registers asset sources that must be in place *before*
/// `AssetPlugin` is built.
///
@@ -191,6 +285,7 @@ pub struct AssetSourcesPlugin;
impl Plugin for AssetSourcesPlugin {
fn build(&self, app: &mut App) {
populate_embedded_default_theme(app);
populate_embedded_rusty_pixel_theme(app);
}
}
@@ -254,6 +349,43 @@ pub fn populate_embedded_default_theme(app: &mut App) {
}
}
/// Returns the embedded PNG bytes for a single rusty-pixel theme file
/// (e.g. `"back.png"` or `"spades_ace.png"`), or `None` when the
/// filename is not bundled. Mirrors [`default_theme_svg_bytes`] for
/// the second bundled theme so the picker thumbnail cache can read
/// preview-sized art without going through the async asset graph.
pub fn rusty_pixel_theme_png_bytes(filename: &str) -> Option<&'static [u8]> {
let suffix = format!("/{filename}");
RUSTY_PIXEL_THEME_PNGS
.iter()
.find(|(path, _)| path.ends_with(&suffix))
.map(|(_, bytes)| *bytes)
}
/// Pushes the bundled rusty-pixel theme manifest + every face/back
/// PNG into the [`EmbeddedAssetRegistry`]. Pairs with
/// [`populate_embedded_default_theme`] — both are called from
/// [`AssetSourcesPlugin::build`] after `AssetPlugin` has set up the
/// embedded source.
pub fn populate_embedded_rusty_pixel_theme(app: &mut App) {
let registry = app
.world_mut()
.get_resource_or_insert_with(EmbeddedAssetRegistry::default);
registry.insert_asset(
std::path::PathBuf::from(RUSTY_PIXEL_THEME_MANIFEST_PATH),
std::path::Path::new(RUSTY_PIXEL_THEME_MANIFEST_PATH),
RUSTY_PIXEL_THEME_MANIFEST_BYTES,
);
for (path, bytes) in RUSTY_PIXEL_THEME_PNGS {
registry.insert_asset(
std::path::PathBuf::from(*path),
std::path::Path::new(*path),
*bytes,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
+34 -12
View File
@@ -17,7 +17,8 @@ use bevy::prelude::*;
use solitaire_core::card::{Rank, Suit};
use crate::assets::{
default_theme_svg_bytes, rasterize_svg, user_theme_dir, DEFAULT_THEME_MANIFEST_URL,
default_theme_svg_bytes, rasterize_svg, rusty_pixel_theme_png_bytes, user_theme_dir,
DEFAULT_THEME_MANIFEST_URL, RUSTY_PIXEL_THEME_MANIFEST_URL,
};
use crate::card_plugin::CardImageSet;
use crate::events::StateChangedEvent;
@@ -128,16 +129,32 @@ fn load_initial_theme(
settings: Option<Res<crate::settings_plugin::SettingsResource>>,
mut commands: Commands,
) {
let url = match settings.as_deref() {
Some(s) if s.0.selected_theme_id != "default" => {
format!("themes://{}/theme.ron", s.0.selected_theme_id)
}
_ => DEFAULT_THEME_MANIFEST_URL.to_string(),
};
let id = settings
.as_deref()
.map(|s| s.0.selected_theme_id.as_str())
.unwrap_or("default");
let url = manifest_url_for(id);
let handle: Handle<CardTheme> = asset_server.load(url);
commands.insert_resource(ActiveTheme(handle));
}
/// Resolves a theme id to its manifest asset URL.
///
/// Bundled built-ins (default, rusty-pixel) route to `embedded://`
/// so the binary's compile-time-baked manifest + face files load
/// without touching disk. Anything else routes to `themes://`,
/// which `register_theme_asset_sources` points at the user themes
/// directory. Callers (load_initial_theme,
/// react_to_settings_theme_change) consult this helper instead of
/// hard-coding the URL shape per id.
fn manifest_url_for(theme_id: &str) -> String {
match theme_id {
"default" => DEFAULT_THEME_MANIFEST_URL.to_string(),
"rusty-pixel" => RUSTY_PIXEL_THEME_MANIFEST_URL.to_string(),
_ => format!("themes://{theme_id}/theme.ron"),
}
}
/// Watches [`crate::settings_plugin::SettingsChangedEvent`] and
/// triggers a fresh theme load whenever
/// `Settings::selected_theme_id` changes. The settings panel's theme
@@ -163,11 +180,7 @@ fn react_to_settings_theme_change(
return;
}
let url = if new_id == "default" {
DEFAULT_THEME_MANIFEST_URL.to_string()
} else {
format!("themes://{new_id}/theme.ron")
};
let url = manifest_url_for(new_id);
let handle: Handle<CardTheme> = asset_server.load(url);
commands.insert_resource(ActiveTheme(handle));
}
@@ -362,11 +375,20 @@ enum ThemePreviewBytes {
/// `<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> {
// Bundled built-ins consult their embed tables before any
// filesystem I/O so the thumbnail works on a fresh install where
// the user themes directory doesn't exist yet.
if theme_id == "default" {
let filename = format!("{basename}.svg");
return default_theme_svg_bytes(&filename)
.map(|b| ThemePreviewBytes::Svg(b.to_vec()));
}
if theme_id == "rusty-pixel" {
let filename = format!("{basename}.png");
if let Some(bytes) = rusty_pixel_theme_png_bytes(&filename) {
return Some(ThemePreviewBytes::Png(bytes.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));
+80 -10
View File
@@ -25,7 +25,7 @@ use bevy::prelude::{App, Plugin, Resource, Startup};
use serde::Deserialize;
use super::ThemeMeta;
use crate::assets::{user_theme_dir, DEFAULT_THEME_MANIFEST_URL};
use crate::assets::{user_theme_dir, DEFAULT_THEME_MANIFEST_URL, RUSTY_PIXEL_THEME_MANIFEST_URL};
/// One entry in the [`ThemeRegistry`] — the data the picker UI needs
/// to render a row and load the theme on selection.
@@ -98,10 +98,24 @@ fn build_registry_on_startup(mut registry: bevy::ecs::system::ResMut<ThemeRegist
/// Pure helper: builds a registry given an explicit user-themes
/// directory. Tests pass a temp dir; production uses
/// [`user_theme_dir`].
///
/// Order: bundled built-ins first (default, then rusty-pixel), then
/// user themes in `read_dir` order. User themes whose `id` collides
/// with a bundled built-in are silently dropped — built-ins win the
/// collision because they're guaranteed to be a complete, valid set;
/// the user's overriding copy may be partial or stale and silently
/// preferring a complete-but-different theme is less surprising than
/// crashing on a missing face.
pub fn build_registry(user_dir: &Path) -> ThemeRegistry {
let mut entries = Vec::new();
entries.push(default_entry());
entries.extend(discover_user_themes(user_dir));
entries.push(rusty_pixel_entry());
let bundled_ids: std::collections::HashSet<String> =
entries.iter().map(|e| e.id.clone()).collect();
let user = discover_user_themes(user_dir)
.into_iter()
.filter(|t| !bundled_ids.contains(&t.id));
entries.extend(user);
ThemeRegistry { entries }
}
@@ -123,6 +137,26 @@ fn default_entry() -> ThemeEntry {
}
}
/// The bundled rusty-pixel theme entry — pixel-art faces by Claude
/// Design, embedded under `embedded://solitaire_engine/assets/themes/rusty-pixel/`.
/// Inserted alongside the default so the picker offers both
/// out-of-the-box on a fresh install with no user themes directory.
fn rusty_pixel_entry() -> ThemeEntry {
ThemeEntry {
id: "rusty-pixel".to_string(),
display_name: "Rusty Pixel".to_string(),
manifest_url: RUSTY_PIXEL_THEME_MANIFEST_URL.to_string(),
meta: ThemeMeta {
id: "rusty-pixel".to_string(),
name: "Rusty Pixel".to_string(),
author: "Claude Design".to_string(),
version: "0.1.0".to_string(),
card_aspect: (2, 3),
pixel_art: true,
},
}
}
/// Walks `user_dir`, treating every immediate subdirectory as a
/// candidate theme. A subdirectory contributes one entry if and only
/// if it contains a `theme.ron` whose `meta` block parses cleanly and
@@ -240,20 +274,22 @@ mod tests {
}
#[test]
fn empty_user_dir_yields_only_the_default_entry() {
fn empty_user_dir_yields_only_the_bundled_built_ins() {
let tmp = tempfile::tempdir().unwrap();
let registry = build_registry(tmp.path());
assert_eq!(registry.len(), 1);
assert_eq!(registry.len(), 2, "default + rusty-pixel always present");
assert_eq!(registry.entries[0].id, "default");
assert_eq!(registry.entries[1].id, "rusty-pixel");
}
#[test]
fn nonexistent_user_dir_still_yields_default() {
fn nonexistent_user_dir_still_yields_bundled_built_ins() {
let registry = build_registry(Path::new(
"/definitely/not/a/real/path/should/not/panic",
));
assert_eq!(registry.len(), 1);
assert_eq!(registry.len(), 2);
assert_eq!(registry.entries[0].id, "default");
assert_eq!(registry.entries[1].id, "rusty-pixel");
}
#[test]
@@ -264,12 +300,40 @@ mod tests {
write_manifest(&theme_dir, "midnight", "Midnight");
let registry = build_registry(tmp.path());
assert_eq!(registry.len(), 2);
assert_eq!(registry.len(), 3, "default + rusty-pixel + midnight");
let entry = registry.find("midnight").expect("midnight registered");
assert_eq!(entry.display_name, "Midnight");
assert_eq!(entry.manifest_url, "themes://midnight/theme.ron");
}
#[test]
fn user_theme_id_collision_with_bundled_is_dropped() {
// A user-supplied directory whose `id` matches a bundled
// built-in (rusty-pixel) must not produce a duplicate
// registry entry. The bundled version wins because it's
// guaranteed complete; the user's overriding copy may be
// partial, stale, or otherwise broken.
let tmp = tempfile::tempdir().unwrap();
let theme_dir = tmp.path().join("rusty-pixel");
fs::create_dir_all(&theme_dir).unwrap();
write_manifest(&theme_dir, "rusty-pixel", "User Override");
let registry = build_registry(tmp.path());
assert_eq!(
registry.len(), 2,
"user override of bundled id must not appear as a duplicate",
);
let entry = registry.find("rusty-pixel").expect("rusty-pixel registered");
assert_eq!(
entry.display_name, "Rusty Pixel",
"bundled entry's display_name wins over the user override",
);
assert_eq!(
entry.manifest_url, RUSTY_PIXEL_THEME_MANIFEST_URL,
"bundled embed:// URL wins over the user themes:// URL",
);
}
#[test]
fn full_manifest_also_works_via_meta_only_parser() {
// The meta-only deserialiser must tolerate the full ThemeManifest
@@ -310,8 +374,9 @@ mod tests {
write_manifest(&theme_dir, "../etc/passwd", "Evil");
let registry = build_registry(tmp.path());
assert_eq!(registry.len(), 1, "escape attempt must not register");
assert_eq!(registry.len(), 2, "escape attempt must not register; built-ins remain");
assert_eq!(registry.entries[0].id, "default");
assert_eq!(registry.entries[1].id, "rusty-pixel");
}
#[test]
@@ -322,7 +387,11 @@ mod tests {
fs::write(lonely.join("readme.md"), "wrong filename").unwrap();
let registry = build_registry(tmp.path());
assert_eq!(registry.len(), 1);
assert_eq!(
registry.len(),
2,
"no user themes register; only the bundled built-ins remain",
);
}
#[test]
@@ -351,8 +420,9 @@ mod tests {
refresh_registry(&mut registry, tmp.path());
assert_eq!(registry.len(), 1);
assert_eq!(registry.len(), 2, "stale entry replaced; built-ins remain");
assert_eq!(registry.entries[0].id, "default");
assert_eq!(registry.entries[1].id, "rusty-pixel");
assert!(registry.find("stale").is_none());
}