feat(engine): rename themes — Classic is default, Dark replaces Default
Build and Deploy / build-and-push (push) Successful in 33s

- Rename assets/themes/default/ → assets/themes/dark/; update theme.ron
  id/name to "dark"/"Dark"
- Rename all DEFAULT_THEME_* constants → DARK_THEME_* and
  default_theme_svg_bytes / populate_embedded_default_theme → dark_*
- Add bundled_theme_url() helper for URL resolution without needing the
  registry (used by Startup systems where ordering isn't guaranteed)
- Registry now lists Classic first (new player default), Dark second
- settings.rs default_theme_id() returns "classic" so fresh installs
  start on the white card theme

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-13 22:51:09 -07:00
parent 7a0d57b2b1
commit 20b7a617e0
61 changed files with 198 additions and 237 deletions
+5 -6
View File
@@ -143,11 +143,10 @@ pub struct Settings {
#[serde(default)] #[serde(default)]
pub window_geometry: Option<WindowGeometry>, pub window_geometry: Option<WindowGeometry>,
/// Identifier of the active card-art theme. Matches `meta.id` from /// Identifier of the active card-art theme. Matches `meta.id` from
/// the theme's `theme.ron` manifest. `"default"` is the bundled /// the theme's `theme.ron` manifest. `"classic"` and `"dark"` are
/// theme and is always present in the registry; user-supplied /// always present; user-supplied themes register under their own ids.
/// themes register under their own ids when they're imported. /// Older `settings.json` files that stored `"default"` will fall
/// Older `settings.json` files default cleanly to `"default"` via /// back to the dark embedded theme at runtime.
/// `#[serde(default = ...)]`.
#[serde(default = "default_theme_id")] #[serde(default = "default_theme_id")]
pub selected_theme_id: String, pub selected_theme_id: String,
/// Set to `true` once the achievement-onboarding info-toast has been /// Set to `true` once the achievement-onboarding info-toast has been
@@ -273,7 +272,7 @@ fn default_music_volume() -> f32 {
} }
fn default_theme_id() -> String { fn default_theme_id() -> String {
"default".to_string() "classic".to_string()
} }
/// Default tooltip-hover dwell delay in seconds. Mirrors /// Default tooltip-hover dwell delay in seconds. Mirrors

Before

Width:  |  Height:  |  Size: 956 B

After

Width:  |  Height:  |  Size: 956 B

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -13,9 +13,9 @@
// ranks: ace, 2, 3, 4, 5, 6, 7, 8, 9, 10, jack, queen, king // ranks: ace, 2, 3, 4, 5, 6, 7, 8, 9, 10, jack, queen, king
( (
meta: ( meta: (
id: "default", id: "dark",
name: "Default", name: "Dark",
author: "Solitaire Quest", author: "Ferrous Solitaire",
version: "0.1.0", version: "0.1.0",
card_aspect: (2, 3), card_aspect: (2, 3),
), ),
+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::{
default_theme_svg_bytes, populate_embedded_default_theme, register_theme_asset_sources, bundled_theme_url, dark_theme_svg_bytes, populate_embedded_dark_theme,
AssetSourcesPlugin, DEFAULT_THEME_MANIFEST_URL, USER_THEMES, register_theme_asset_sources, AssetSourcesPlugin, DARK_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};
+117 -157
View File
@@ -60,99 +60,89 @@ use crate::assets::user_dir::user_theme_dir;
/// from the user-themes directory. /// from the user-themes directory.
pub const USER_THEMES: &str = "themes"; pub const USER_THEMES: &str = "themes";
/// Stable embedded asset URL of the bundled default theme manifest. /// Stable embedded asset URL of the bundled Dark theme manifest.
/// ///
/// Code that wants to load the embedded default — including the future /// Code that wants to load the embedded Dark theme — including
/// Phase 4 `ActiveTheme` initialisation — should use exactly this /// `ActiveTheme` initialisation — should use exactly this constant
/// constant rather than re-typing the URL inline. Changing where the /// rather than re-typing the URL inline.
/// default theme lives in the asset graph then becomes a single-line pub const DARK_THEME_MANIFEST_URL: &str =
/// change in this file. "embedded://solitaire_engine/assets/themes/dark/theme.ron";
pub const DEFAULT_THEME_MANIFEST_URL: &str =
"embedded://solitaire_engine/assets/themes/default/theme.ron";
/// Path the embedded default-theme manifest registers under, relative /// Path the embedded Dark-theme manifest registers under, relative
/// to the `embedded://` source root. Kept in lockstep with /// to the `embedded://` source root. Kept in lockstep with
/// [`DEFAULT_THEME_MANIFEST_URL`] by the unit test /// [`DARK_THEME_MANIFEST_URL`] by the unit test
/// `default_theme_url_constant_matches_embedded_path`. /// `dark_theme_url_constant_matches_embedded_path`.
const DEFAULT_THEME_MANIFEST_PATH: &str = "solitaire_engine/assets/themes/default/theme.ron"; const DARK_THEME_MANIFEST_PATH: &str = "solitaire_engine/assets/themes/dark/theme.ron";
/// Bytes of the bundled default theme manifest. Embedded at compile /// Bytes of the bundled Dark theme manifest, embedded at compile time.
/// time via `include_bytes!` so the binary is self-contained even if const DARK_THEME_MANIFEST_BYTES: &[u8] =
/// the workspace's `solitaire_engine/assets/` directory is absent at include_bytes!("../../assets/themes/dark/theme.ron");
/// runtime (e.g. when shipped to a player).
const DEFAULT_THEME_MANIFEST_BYTES: &[u8] =
include_bytes!("../../assets/themes/default/theme.ron");
/// Generates a `(stable_path, bytes)` entry for one default-theme /// Generates a `(stable_path, bytes)` entry for one Dark-theme SVG.
/// SVG so the bulk-embed table below stays declarative. The path macro_rules! embed_dark_svg {
/// matches what `theme.ron` references; `include_bytes!` resolves
/// relative to this source file.
macro_rules! embed_default_svg {
($name:literal) => { ($name:literal) => {
( (
concat!("solitaire_engine/assets/themes/default/", $name), concat!("solitaire_engine/assets/themes/dark/", $name),
include_bytes!(concat!("../../assets/themes/default/", $name)) as &[u8], include_bytes!(concat!("../../assets/themes/dark/", $name)) as &[u8],
) )
}; };
} }
/// Every default-theme SVG file bundled into the binary. Adding a new /// Every Dark-theme SVG file bundled into the binary.
/// face / back artwork is a single `embed_default_svg!(...)` line — const DARK_THEME_SVGS: &[(&str, &[u8])] = &[
/// the populate function below iterates this table. embed_dark_svg!("back.svg"),
const DEFAULT_THEME_SVGS: &[(&str, &[u8])] = &[ embed_dark_svg!("clubs_ace.svg"),
embed_default_svg!("back.svg"), embed_dark_svg!("clubs_2.svg"),
embed_default_svg!("clubs_ace.svg"), embed_dark_svg!("clubs_3.svg"),
embed_default_svg!("clubs_2.svg"), embed_dark_svg!("clubs_4.svg"),
embed_default_svg!("clubs_3.svg"), embed_dark_svg!("clubs_5.svg"),
embed_default_svg!("clubs_4.svg"), embed_dark_svg!("clubs_6.svg"),
embed_default_svg!("clubs_5.svg"), embed_dark_svg!("clubs_7.svg"),
embed_default_svg!("clubs_6.svg"), embed_dark_svg!("clubs_8.svg"),
embed_default_svg!("clubs_7.svg"), embed_dark_svg!("clubs_9.svg"),
embed_default_svg!("clubs_8.svg"), embed_dark_svg!("clubs_10.svg"),
embed_default_svg!("clubs_9.svg"), embed_dark_svg!("clubs_jack.svg"),
embed_default_svg!("clubs_10.svg"), embed_dark_svg!("clubs_queen.svg"),
embed_default_svg!("clubs_jack.svg"), embed_dark_svg!("clubs_king.svg"),
embed_default_svg!("clubs_queen.svg"), embed_dark_svg!("diamonds_ace.svg"),
embed_default_svg!("clubs_king.svg"), embed_dark_svg!("diamonds_2.svg"),
embed_default_svg!("diamonds_ace.svg"), embed_dark_svg!("diamonds_3.svg"),
embed_default_svg!("diamonds_2.svg"), embed_dark_svg!("diamonds_4.svg"),
embed_default_svg!("diamonds_3.svg"), embed_dark_svg!("diamonds_5.svg"),
embed_default_svg!("diamonds_4.svg"), embed_dark_svg!("diamonds_6.svg"),
embed_default_svg!("diamonds_5.svg"), embed_dark_svg!("diamonds_7.svg"),
embed_default_svg!("diamonds_6.svg"), embed_dark_svg!("diamonds_8.svg"),
embed_default_svg!("diamonds_7.svg"), embed_dark_svg!("diamonds_9.svg"),
embed_default_svg!("diamonds_8.svg"), embed_dark_svg!("diamonds_10.svg"),
embed_default_svg!("diamonds_9.svg"), embed_dark_svg!("diamonds_jack.svg"),
embed_default_svg!("diamonds_10.svg"), embed_dark_svg!("diamonds_queen.svg"),
embed_default_svg!("diamonds_jack.svg"), embed_dark_svg!("diamonds_king.svg"),
embed_default_svg!("diamonds_queen.svg"), embed_dark_svg!("hearts_ace.svg"),
embed_default_svg!("diamonds_king.svg"), embed_dark_svg!("hearts_2.svg"),
embed_default_svg!("hearts_ace.svg"), embed_dark_svg!("hearts_3.svg"),
embed_default_svg!("hearts_2.svg"), embed_dark_svg!("hearts_4.svg"),
embed_default_svg!("hearts_3.svg"), embed_dark_svg!("hearts_5.svg"),
embed_default_svg!("hearts_4.svg"), embed_dark_svg!("hearts_6.svg"),
embed_default_svg!("hearts_5.svg"), embed_dark_svg!("hearts_7.svg"),
embed_default_svg!("hearts_6.svg"), embed_dark_svg!("hearts_8.svg"),
embed_default_svg!("hearts_7.svg"), embed_dark_svg!("hearts_9.svg"),
embed_default_svg!("hearts_8.svg"), embed_dark_svg!("hearts_10.svg"),
embed_default_svg!("hearts_9.svg"), embed_dark_svg!("hearts_jack.svg"),
embed_default_svg!("hearts_10.svg"), embed_dark_svg!("hearts_queen.svg"),
embed_default_svg!("hearts_jack.svg"), embed_dark_svg!("hearts_king.svg"),
embed_default_svg!("hearts_queen.svg"), embed_dark_svg!("spades_ace.svg"),
embed_default_svg!("hearts_king.svg"), embed_dark_svg!("spades_2.svg"),
embed_default_svg!("spades_ace.svg"), embed_dark_svg!("spades_3.svg"),
embed_default_svg!("spades_2.svg"), embed_dark_svg!("spades_4.svg"),
embed_default_svg!("spades_3.svg"), embed_dark_svg!("spades_5.svg"),
embed_default_svg!("spades_4.svg"), embed_dark_svg!("spades_6.svg"),
embed_default_svg!("spades_5.svg"), embed_dark_svg!("spades_7.svg"),
embed_default_svg!("spades_6.svg"), embed_dark_svg!("spades_8.svg"),
embed_default_svg!("spades_7.svg"), embed_dark_svg!("spades_9.svg"),
embed_default_svg!("spades_8.svg"), embed_dark_svg!("spades_10.svg"),
embed_default_svg!("spades_9.svg"), embed_dark_svg!("spades_jack.svg"),
embed_default_svg!("spades_10.svg"), embed_dark_svg!("spades_queen.svg"),
embed_default_svg!("spades_jack.svg"), embed_dark_svg!("spades_king.svg"),
embed_default_svg!("spades_queen.svg"),
embed_default_svg!("spades_king.svg"),
]; ];
/// Registers asset sources that must be in place *before* /// Registers asset sources that must be in place *before*
@@ -190,62 +180,53 @@ pub struct AssetSourcesPlugin;
impl Plugin for AssetSourcesPlugin { impl Plugin for AssetSourcesPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
populate_embedded_default_theme(app); populate_embedded_dark_theme(app);
} }
} }
/// Returns the embedded SVG bytes for a single default-theme file /// Returns the embedded SVG bytes for a single Dark-theme file
/// (e.g. `"back.svg"` or `"spades_ace.svg"`), or `None` when the /// (e.g. `"back.svg"` or `"spades_ace.svg"`), or `None` when the
/// filename is not bundled. /// filename is not bundled.
/// ///
/// The thumbnail generator in /// The thumbnail generator uses this to rasterise preview-sized art
/// [`crate::theme::ThemeThumbnailCache`] uses this to rasterise /// without going through Bevy's async asset graph.
/// preview-sized art for the picker UI without going through Bevy's pub fn dark_theme_svg_bytes(filename: &str) -> Option<&'static [u8]> {
/// 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}"); let suffix = format!("/{filename}");
DEFAULT_THEME_SVGS DARK_THEME_SVGS
.iter() .iter()
.find(|(path, _)| path.ends_with(&suffix)) .find(|(path, _)| path.ends_with(&suffix))
.map(|(_, bytes)| *bytes) .map(|(_, bytes)| *bytes)
} }
/// Pushes every bundled default-theme file into the /// Returns the manifest URL for a bundled (non-user) theme by id, or
/// [`EmbeddedAssetRegistry`] under its stable URL. Keeping this in a /// `None` if `id` belongs to a user theme that lives under `themes://`.
/// free function (and not inside the `Plugin::build` body) means the
/// unit test below can exercise it without spinning up a full Bevy
/// `App` with `AssetPlugin`.
/// ///
/// **Adding files to the bundled default theme** is a single edit: /// Callers that need to resolve a theme URL without access to
/// append one `embed_default_svg!("filename.svg")` line to the /// [`crate::theme::ThemeRegistry`] (e.g. Startup systems where registry
/// `DEFAULT_THEME_SVGS` table above. The file resolves relative to /// ordering isn't guaranteed) should use this instead of constructing
/// `solitaire_engine/assets/themes/default/` and registers under /// the URL manually.
/// the matching `embedded://` URL automatically. pub fn bundled_theme_url(id: &str) -> Option<&'static str> {
pub fn populate_embedded_default_theme(app: &mut App) { match id {
"dark" => Some(DARK_THEME_MANIFEST_URL),
"classic" => Some("themes/classic/theme.ron"),
_ => None,
}
}
/// Pushes every bundled Dark-theme file into the
/// [`EmbeddedAssetRegistry`] under its stable URL.
pub fn populate_embedded_dark_theme(app: &mut App) {
let registry = app let registry = app
.world_mut() .world_mut()
.get_resource_or_insert_with(EmbeddedAssetRegistry::default); .get_resource_or_insert_with(EmbeddedAssetRegistry::default);
// The manifest first — its asset URL is the entry point everything
// else (`set_theme`, the registry, the loader) references via
// `DEFAULT_THEME_MANIFEST_URL`.
//
// `full_path` is only consulted by the optional `embedded_watcher`
// cargo feature (which we don't enable). Use the manifest's
// logical workspace path so a future debugger session sees a
// sensible source-of-truth string.
registry.insert_asset( registry.insert_asset(
std::path::PathBuf::from(DEFAULT_THEME_MANIFEST_PATH), std::path::PathBuf::from(DARK_THEME_MANIFEST_PATH),
std::path::Path::new(DEFAULT_THEME_MANIFEST_PATH), std::path::Path::new(DARK_THEME_MANIFEST_PATH),
DEFAULT_THEME_MANIFEST_BYTES, DARK_THEME_MANIFEST_BYTES,
); );
// Then every face + back SVG. The manifest references each by the for (path, bytes) in DARK_THEME_SVGS {
// same relative path used here.
for (path, bytes) in DEFAULT_THEME_SVGS {
registry.insert_asset( registry.insert_asset(
std::path::PathBuf::from(*path), std::path::PathBuf::from(*path),
std::path::Path::new(*path), std::path::Path::new(*path),
@@ -277,72 +258,51 @@ mod tests {
); );
} }
/// `populate_embedded_default_theme` must work as a drop-in step
/// regardless of whether `EmbeddedAssetRegistry` already exists,
/// so it can be called both from `AssetSourcesPlugin::build`
/// (after `AssetPlugin` initialised it) and from this test (which
/// uses the resource's `get_resource_or_insert_with` fallback).
#[test] #[test]
fn populate_embedded_default_theme_runs_without_asset_plugin() { fn populate_embedded_dark_theme_runs_without_asset_plugin() {
let mut app = App::new(); let mut app = App::new();
populate_embedded_default_theme(&mut app); populate_embedded_dark_theme(&mut app);
// Resource exists and has been inserted into.
assert!(app assert!(app
.world() .world()
.get_resource::<EmbeddedAssetRegistry>() .get_resource::<EmbeddedAssetRegistry>()
.is_some()); .is_some());
} }
/// The bundled default theme stub must satisfy
/// `ThemeManifest::validate` — otherwise the embedded source
/// would register a manifest the loader will then reject at
/// runtime.
#[test] #[test]
fn embedded_default_theme_manifest_validates() { fn embedded_dark_theme_manifest_validates() {
use crate::theme::ThemeManifest; use crate::theme::ThemeManifest;
let manifest: ThemeManifest = ron::de::from_bytes(DEFAULT_THEME_MANIFEST_BYTES) let manifest: ThemeManifest = ron::de::from_bytes(DARK_THEME_MANIFEST_BYTES)
.expect("default manifest must parse as RON"); .expect("dark manifest must parse as RON");
let faces = manifest let faces = manifest
.validate() .validate()
.expect("default manifest must list all 52 faces"); .expect("dark manifest must list all 52 faces");
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] #[test]
fn default_theme_svg_bytes_finds_back_and_ace_of_spades() { fn dark_theme_svg_bytes_finds_back_and_ace_of_spades() {
assert!( assert!(
default_theme_svg_bytes("back.svg").is_some(), dark_theme_svg_bytes("back.svg").is_some(),
"default theme must bundle a back.svg" "dark theme must bundle a back.svg"
); );
assert!( assert!(
default_theme_svg_bytes("spades_ace.svg").is_some(), dark_theme_svg_bytes("spades_ace.svg").is_some(),
"default theme must bundle a spades_ace.svg" "dark theme must bundle a spades_ace.svg"
); );
} }
#[test] #[test]
fn default_theme_svg_bytes_returns_none_for_unknown_file() { fn dark_theme_svg_bytes_returns_none_for_unknown_file() {
assert!(default_theme_svg_bytes("nope.svg").is_none()); assert!(dark_theme_svg_bytes("nope.svg").is_none());
assert!(default_theme_svg_bytes("").is_none()); assert!(dark_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
/// another. Pin them together in the test suite so any drift
/// fails CI.
#[test] #[test]
fn default_theme_url_constant_matches_embedded_path() { fn dark_theme_url_constant_matches_embedded_path() {
let url_tail = DEFAULT_THEME_MANIFEST_URL let url_tail = DARK_THEME_MANIFEST_URL
.strip_prefix("embedded://") .strip_prefix("embedded://")
.expect("default theme URL must use embedded:// scheme"); .expect("dark theme URL must use embedded:// scheme");
assert_eq!(url_tail, DEFAULT_THEME_MANIFEST_PATH); assert_eq!(url_tail, DARK_THEME_MANIFEST_PATH);
} }
} }
+2 -2
View File
@@ -53,8 +53,8 @@ pub mod weekly_goals_plugin;
pub mod win_summary_plugin; pub mod win_summary_plugin;
pub use assets::{ pub use assets::{
populate_embedded_default_theme, register_theme_asset_sources, AssetSourcesPlugin, bundled_theme_url, populate_embedded_dark_theme, register_theme_asset_sources,
DEFAULT_THEME_MANIFEST_URL, USER_THEMES, AssetSourcesPlugin, DARK_THEME_MANIFEST_URL, USER_THEMES,
}; };
pub use theme::{ pub use theme::{
set_theme, ActiveTheme, CardTheme, CardThemeLoader, ThemeEntry, ThemePlugin, ThemeRegistry, set_theme, ActiveTheme, CardTheme, CardThemeLoader, ThemeEntry, ThemePlugin, ThemeRegistry,
+50 -46
View File
@@ -15,7 +15,7 @@ use bevy::prelude::*;
use solitaire_core::card::{Rank, Suit}; use solitaire_core::card::{Rank, Suit};
use crate::assets::{ use crate::assets::{
default_theme_svg_bytes, rasterize_svg, user_theme_dir, DEFAULT_THEME_MANIFEST_URL, bundled_theme_url, dark_theme_svg_bytes, rasterize_svg, user_theme_dir,
}; };
use crate::card_plugin::CardImageSet; use crate::card_plugin::CardImageSet;
use crate::events::StateChangedEvent; use crate::events::StateChangedEvent;
@@ -126,12 +126,13 @@ fn load_initial_theme(
settings: Option<Res<crate::settings_plugin::SettingsResource>>, settings: Option<Res<crate::settings_plugin::SettingsResource>>,
mut commands: Commands, mut commands: Commands,
) { ) {
let url = match settings.as_deref() { let id = settings
Some(s) if s.0.selected_theme_id != "default" => { .as_deref()
format!("themes://{}/theme.ron", s.0.selected_theme_id) .map(|s| s.0.selected_theme_id.as_str())
} .unwrap_or("dark");
_ => DEFAULT_THEME_MANIFEST_URL.to_string(), let url = bundled_theme_url(id)
}; .map(str::to_string)
.unwrap_or_else(|| format!("themes://{id}/theme.ron"));
let handle: Handle<CardTheme> = asset_server.load(url); let handle: Handle<CardTheme> = asset_server.load(url);
commands.insert_resource(ActiveTheme(handle)); commands.insert_resource(ActiveTheme(handle));
} }
@@ -161,11 +162,9 @@ fn react_to_settings_theme_change(
return; return;
} }
let url = if new_id == "default" { let url = bundled_theme_url(new_id)
DEFAULT_THEME_MANIFEST_URL.to_string() .map(str::to_string)
} else { .unwrap_or_else(|| format!("themes://{new_id}/theme.ron"));
format!("themes://{new_id}/theme.ron")
};
let handle: Handle<CardTheme> = asset_server.load(url); let handle: Handle<CardTheme> = asset_server.load(url);
commands.insert_resource(ActiveTheme(handle)); commands.insert_resource(ActiveTheme(handle));
} }
@@ -305,16 +304,24 @@ const PREVIEW_BACK_FILENAME: &str = "back.svg";
/// Resolves the SVG bytes for one preview file (`back.svg` or /// Resolves the SVG bytes for one preview file (`back.svg` or
/// `spades_ace.svg`) belonging to the named theme. /// `spades_ace.svg`) belonging to the named theme.
/// ///
/// - For the bundled `default` theme, reads from the embedded /// - For the embedded `dark` theme, reads from the in-binary table via
/// `DEFAULT_THEME_SVGS` table via [`default_theme_svg_bytes`]. No /// [`dark_theme_svg_bytes`]. No filesystem I/O.
/// filesystem I/O. /// - For bundled non-embedded themes (e.g. `classic`), reads from the
/// - For any user theme, reads from `<user_theme_dir>/<id>/<filename>`. /// `assets/themes/<id>/` directory.
/// Returns `None` for any I/O failure (file missing, permission /// - For user themes, reads from `<user_theme_dir>/<id>/<filename>`.
/// denied, etc.) — the caller treats `None` as "render placeholder". /// Returns `None` for any I/O failure.
fn read_theme_preview_svg_bytes(theme_id: &str, filename: &str) -> Option<Vec<u8>> { fn read_theme_preview_svg_bytes(theme_id: &str, filename: &str) -> Option<Vec<u8>> {
if theme_id == "default" { if theme_id == "dark" {
return default_theme_svg_bytes(filename).map(|b| b.to_vec()); return dark_theme_svg_bytes(filename).map(|b| b.to_vec());
} }
// Bundled non-embedded themes live alongside the binary in assets/.
let bundled_path = std::path::Path::new("assets/themes")
.join(theme_id)
.join(filename);
if let Ok(bytes) = std::fs::read(&bundled_path) {
return Some(bytes);
}
// Fall back to user theme dir.
let path = user_theme_dir().join(theme_id).join(filename); let path = user_theme_dir().join(theme_id).join(filename);
std::fs::read(&path).ok() std::fs::read(&path).ok()
} }
@@ -503,22 +510,20 @@ mod tests {
// set_theme that doesn't require an App. We assert the URL // set_theme that doesn't require an App. We assert the URL
// shape so a future refactor doesn't accidentally change the // shape so a future refactor doesn't accidentally change the
// path layout. // path layout.
let url = format!("themes://{}/theme.ron", "default"); let url = format!("themes://{}/theme.ron", "user_uploaded");
assert_eq!(url, "themes://default/theme.ron"); assert_eq!(url, "themes://user_uploaded/theme.ron");
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 /// Test 1: the bundled dark theme always has embedded SVG bytes
/// available, so calling `generate_thumbnail_pair_for("default", …)` /// available, so calling `generate_thumbnail_pair_for("dark", …)`
/// must produce two non-default `Handle<Image>` slots. /// must produce two non-default `Handle<Image>` slots.
#[test] #[test]
fn theme_thumbnails_generated_for_default_theme() { fn theme_thumbnails_generated_for_dark_theme() {
let mut images = Assets::<Image>::default(); let mut images = Assets::<Image>::default();
let pair = generate_thumbnail_pair_for("default", &mut images); let pair = generate_thumbnail_pair_for("dark", &mut images);
assert!( assert!(
pair.is_fully_populated(), pair.is_fully_populated(),
"default theme must yield both ace + back thumbnail handles" "dark theme must yield both ace + back thumbnail handles"
); );
// And the underlying images must actually exist in the assets // And the underlying images must actually exist in the assets
// collection — the handles are real, not dangling. // collection — the handles are real, not dangling.
@@ -558,18 +563,17 @@ mod tests {
); );
} }
/// `read_theme_preview_svg_bytes` for the default theme always /// `read_theme_preview_svg_bytes` for the dark theme always returns
/// returns embedded bytes for the canonical preview pair /// embedded bytes for the canonical preview pair.
/// covering the happy-path branch of the helper.
#[test] #[test]
fn read_default_theme_preview_returns_some_for_canonical_files() { fn read_dark_theme_preview_returns_some_for_canonical_files() {
assert!( assert!(
read_theme_preview_svg_bytes("default", PREVIEW_BACK_FILENAME).is_some(), read_theme_preview_svg_bytes("dark", PREVIEW_BACK_FILENAME).is_some(),
"default theme back.svg must be embedded" "dark theme back.svg must be embedded"
); );
assert!( assert!(
read_theme_preview_svg_bytes("default", PREVIEW_FACE_FILENAME).is_some(), read_theme_preview_svg_bytes("dark", PREVIEW_FACE_FILENAME).is_some(),
"default theme spades_ace.svg must be embedded" "dark theme spades_ace.svg must be embedded"
); );
} }
@@ -586,12 +590,12 @@ mod tests {
app.init_resource::<ThemeThumbnailCache>(); app.init_resource::<ThemeThumbnailCache>();
app.insert_resource(ThemeRegistry { app.insert_resource(ThemeRegistry {
entries: vec![crate::theme::ThemeEntry { entries: vec![crate::theme::ThemeEntry {
id: "default".into(), id: "dark".into(),
display_name: "Default".into(), display_name: "Dark".into(),
manifest_url: crate::assets::DEFAULT_THEME_MANIFEST_URL.into(), manifest_url: crate::assets::DARK_THEME_MANIFEST_URL.into(),
meta: ThemeMeta { meta: ThemeMeta {
id: "default".into(), id: "dark".into(),
name: "Default".into(), name: "Dark".into(),
author: "x".into(), author: "x".into(),
version: "x".into(), version: "x".into(),
card_aspect: (2, 3), card_aspect: (2, 3),
@@ -605,18 +609,18 @@ mod tests {
let first_ace = app let first_ace = app
.world() .world()
.resource::<ThemeThumbnailCache>() .resource::<ThemeThumbnailCache>()
.get("default") .get("dark")
.map(|p| p.ace.clone()) .map(|p| p.ace.clone())
.expect("default theme thumbnail must exist after one tick"); .expect("dark theme thumbnail must exist after one tick");
// Second tick must NOT replace the cached handle. // Second tick must NOT replace the cached handle.
app.update(); app.update();
let second_ace = app let second_ace = app
.world() .world()
.resource::<ThemeThumbnailCache>() .resource::<ThemeThumbnailCache>()
.get("default") .get("dark")
.map(|p| p.ace.clone()) .map(|p| p.ace.clone())
.expect("default theme thumbnail must still exist"); .expect("dark theme thumbnail must still exist");
assert_eq!( assert_eq!(
first_ace.id(), first_ace.id(),
+19 -21
View File
@@ -25,7 +25,7 @@ use bevy::prelude::{App, Plugin, Resource, Startup};
use serde::Deserialize; use serde::Deserialize;
use super::ThemeMeta; use super::ThemeMeta;
use crate::assets::{user_theme_dir, DEFAULT_THEME_MANIFEST_URL}; use crate::assets::{user_theme_dir, DARK_THEME_MANIFEST_URL};
/// One entry in the [`ThemeRegistry`] — the data the picker UI needs /// One entry in the [`ThemeRegistry`] — the data the picker UI needs
/// to render a row and load the theme on selection. /// to render a row and load the theme on selection.
@@ -100,24 +100,23 @@ fn build_registry_on_startup(mut registry: bevy::ecs::system::ResMut<ThemeRegist
/// [`user_theme_dir`]. /// [`user_theme_dir`].
pub fn build_registry(user_dir: &Path) -> ThemeRegistry { pub fn build_registry(user_dir: &Path) -> ThemeRegistry {
let mut entries = Vec::new(); let mut entries = Vec::new();
entries.push(default_entry());
entries.push(classic_entry()); entries.push(classic_entry());
entries.push(dark_entry());
entries.extend(discover_user_themes(user_dir)); entries.extend(discover_user_themes(user_dir));
ThemeRegistry { entries } ThemeRegistry { entries }
} }
/// The bundled default theme entry — inserted unconditionally so the /// The always-present embedded Dark theme entry.
/// picker always has at least one option. fn dark_entry() -> ThemeEntry {
fn default_entry() -> ThemeEntry {
ThemeEntry { ThemeEntry {
id: "default".to_string(), id: "dark".to_string(),
display_name: "Default".to_string(), display_name: "Dark".to_string(),
manifest_url: DEFAULT_THEME_MANIFEST_URL.to_string(), manifest_url: DARK_THEME_MANIFEST_URL.to_string(),
meta: ThemeMeta { meta: ThemeMeta {
id: "default".to_string(), id: "dark".to_string(),
name: "Default".to_string(), name: "Dark".to_string(),
author: "Ferrous Solitaire".to_string(), author: "Ferrous Solitaire".to_string(),
version: "1.0".to_string(), version: "1.0.0".to_string(),
card_aspect: (2, 3), card_aspect: (2, 3),
}, },
} }
@@ -265,8 +264,8 @@ mod tests {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let registry = build_registry(tmp.path()); let registry = build_registry(tmp.path());
assert_eq!(registry.len(), BUNDLED_COUNT); assert_eq!(registry.len(), BUNDLED_COUNT);
assert_eq!(registry.entries[0].id, "default"); assert_eq!(registry.entries[0].id, "classic");
assert_eq!(registry.entries[1].id, "classic"); assert_eq!(registry.entries[1].id, "dark");
} }
#[test] #[test]
@@ -275,7 +274,8 @@ mod tests {
"/definitely/not/a/real/path/should/not/panic", "/definitely/not/a/real/path/should/not/panic",
)); ));
assert_eq!(registry.len(), BUNDLED_COUNT); assert_eq!(registry.len(), BUNDLED_COUNT);
assert_eq!(registry.entries[0].id, "default"); assert!(registry.find("classic").is_some());
assert!(registry.find("dark").is_some());
} }
#[test] #[test]
@@ -333,7 +333,7 @@ mod tests {
let registry = build_registry(tmp.path()); let registry = build_registry(tmp.path());
assert_eq!(registry.len(), BUNDLED_COUNT, "escape attempt must not register"); assert_eq!(registry.len(), BUNDLED_COUNT, "escape attempt must not register");
assert_eq!(registry.entries[0].id, "default"); assert!(registry.find("classic").is_some());
} }
#[test] #[test]
@@ -373,15 +373,13 @@ mod tests {
refresh_registry(&mut registry, tmp.path()); refresh_registry(&mut registry, tmp.path());
assert_eq!(registry.len(), BUNDLED_COUNT); assert_eq!(registry.len(), BUNDLED_COUNT);
assert_eq!(registry.entries[0].id, "default"); assert!(registry.find("classic").is_some());
assert!(registry.find("stale").is_none()); assert!(registry.find("stale").is_none());
} }
#[test] #[test]
fn default_entry_url_matches_embedded_constant() { fn dark_entry_url_matches_embedded_constant() {
// Ensures the picker always gets a URL it can hand to the let entry = dark_entry();
// asset server for the bundled theme. assert_eq!(entry.manifest_url, DARK_THEME_MANIFEST_URL);
let entry = default_entry();
assert_eq!(entry.manifest_url, DEFAULT_THEME_MANIFEST_URL);
} }
} }