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)]
pub window_geometry: Option<WindowGeometry>,
/// Identifier of the active card-art theme. Matches `meta.id` from
/// the theme's `theme.ron` manifest. `"default"` is the bundled
/// theme and is always present in the registry; user-supplied
/// themes register under their own ids when they're imported.
/// Older `settings.json` files default cleanly to `"default"` via
/// `#[serde(default = ...)]`.
/// the theme's `theme.ron` manifest. `"classic"` and `"dark"` are
/// always present; user-supplied themes register under their own ids.
/// Older `settings.json` files that stored `"default"` will fall
/// back to the dark embedded theme at runtime.
#[serde(default = "default_theme_id")]
pub selected_theme_id: String,
/// 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 {
"default".to_string()
"classic".to_string()
}
/// 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
(
meta: (
id: "default",
name: "Default",
author: "Solitaire Quest",
id: "dark",
name: "Dark",
author: "Ferrous Solitaire",
version: "0.1.0",
card_aspect: (2, 3),
),
+2 -2
View File
@@ -11,8 +11,8 @@ 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,
bundled_theme_url, dark_theme_svg_bytes, populate_embedded_dark_theme,
register_theme_asset_sources, AssetSourcesPlugin, DARK_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};
+117 -157
View File
@@ -60,99 +60,89 @@ use crate::assets::user_dir::user_theme_dir;
/// from the user-themes directory.
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
/// Phase 4 `ActiveTheme` initialisation — should use exactly this
/// constant rather than re-typing the URL inline. Changing where the
/// default theme lives in the asset graph then becomes a single-line
/// change in this file.
pub const DEFAULT_THEME_MANIFEST_URL: &str =
"embedded://solitaire_engine/assets/themes/default/theme.ron";
/// Code that wants to load the embedded Dark theme — including
/// `ActiveTheme` initialisation — should use exactly this constant
/// rather than re-typing the URL inline.
pub const DARK_THEME_MANIFEST_URL: &str =
"embedded://solitaire_engine/assets/themes/dark/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
/// [`DEFAULT_THEME_MANIFEST_URL`] by the unit test
/// `default_theme_url_constant_matches_embedded_path`.
const DEFAULT_THEME_MANIFEST_PATH: &str = "solitaire_engine/assets/themes/default/theme.ron";
/// [`DARK_THEME_MANIFEST_URL`] by the unit test
/// `dark_theme_url_constant_matches_embedded_path`.
const DARK_THEME_MANIFEST_PATH: &str = "solitaire_engine/assets/themes/dark/theme.ron";
/// Bytes of the bundled default theme manifest. Embedded at compile
/// time via `include_bytes!` so the binary is self-contained even if
/// the workspace's `solitaire_engine/assets/` directory is absent at
/// runtime (e.g. when shipped to a player).
const DEFAULT_THEME_MANIFEST_BYTES: &[u8] =
include_bytes!("../../assets/themes/default/theme.ron");
/// Bytes of the bundled Dark theme manifest, embedded at compile time.
const DARK_THEME_MANIFEST_BYTES: &[u8] =
include_bytes!("../../assets/themes/dark/theme.ron");
/// Generates a `(stable_path, bytes)` entry for one default-theme
/// SVG so the bulk-embed table below stays declarative. The path
/// matches what `theme.ron` references; `include_bytes!` resolves
/// relative to this source file.
macro_rules! embed_default_svg {
/// Generates a `(stable_path, bytes)` entry for one Dark-theme SVG.
macro_rules! embed_dark_svg {
($name:literal) => {
(
concat!("solitaire_engine/assets/themes/default/", $name),
include_bytes!(concat!("../../assets/themes/default/", $name)) as &[u8],
concat!("solitaire_engine/assets/themes/dark/", $name),
include_bytes!(concat!("../../assets/themes/dark/", $name)) as &[u8],
)
};
}
/// Every default-theme SVG file bundled into the binary. Adding a new
/// face / back artwork is a single `embed_default_svg!(...)` line —
/// the populate function below iterates this table.
const DEFAULT_THEME_SVGS: &[(&str, &[u8])] = &[
embed_default_svg!("back.svg"),
embed_default_svg!("clubs_ace.svg"),
embed_default_svg!("clubs_2.svg"),
embed_default_svg!("clubs_3.svg"),
embed_default_svg!("clubs_4.svg"),
embed_default_svg!("clubs_5.svg"),
embed_default_svg!("clubs_6.svg"),
embed_default_svg!("clubs_7.svg"),
embed_default_svg!("clubs_8.svg"),
embed_default_svg!("clubs_9.svg"),
embed_default_svg!("clubs_10.svg"),
embed_default_svg!("clubs_jack.svg"),
embed_default_svg!("clubs_queen.svg"),
embed_default_svg!("clubs_king.svg"),
embed_default_svg!("diamonds_ace.svg"),
embed_default_svg!("diamonds_2.svg"),
embed_default_svg!("diamonds_3.svg"),
embed_default_svg!("diamonds_4.svg"),
embed_default_svg!("diamonds_5.svg"),
embed_default_svg!("diamonds_6.svg"),
embed_default_svg!("diamonds_7.svg"),
embed_default_svg!("diamonds_8.svg"),
embed_default_svg!("diamonds_9.svg"),
embed_default_svg!("diamonds_10.svg"),
embed_default_svg!("diamonds_jack.svg"),
embed_default_svg!("diamonds_queen.svg"),
embed_default_svg!("diamonds_king.svg"),
embed_default_svg!("hearts_ace.svg"),
embed_default_svg!("hearts_2.svg"),
embed_default_svg!("hearts_3.svg"),
embed_default_svg!("hearts_4.svg"),
embed_default_svg!("hearts_5.svg"),
embed_default_svg!("hearts_6.svg"),
embed_default_svg!("hearts_7.svg"),
embed_default_svg!("hearts_8.svg"),
embed_default_svg!("hearts_9.svg"),
embed_default_svg!("hearts_10.svg"),
embed_default_svg!("hearts_jack.svg"),
embed_default_svg!("hearts_queen.svg"),
embed_default_svg!("hearts_king.svg"),
embed_default_svg!("spades_ace.svg"),
embed_default_svg!("spades_2.svg"),
embed_default_svg!("spades_3.svg"),
embed_default_svg!("spades_4.svg"),
embed_default_svg!("spades_5.svg"),
embed_default_svg!("spades_6.svg"),
embed_default_svg!("spades_7.svg"),
embed_default_svg!("spades_8.svg"),
embed_default_svg!("spades_9.svg"),
embed_default_svg!("spades_10.svg"),
embed_default_svg!("spades_jack.svg"),
embed_default_svg!("spades_queen.svg"),
embed_default_svg!("spades_king.svg"),
/// Every Dark-theme SVG file bundled into the binary.
const DARK_THEME_SVGS: &[(&str, &[u8])] = &[
embed_dark_svg!("back.svg"),
embed_dark_svg!("clubs_ace.svg"),
embed_dark_svg!("clubs_2.svg"),
embed_dark_svg!("clubs_3.svg"),
embed_dark_svg!("clubs_4.svg"),
embed_dark_svg!("clubs_5.svg"),
embed_dark_svg!("clubs_6.svg"),
embed_dark_svg!("clubs_7.svg"),
embed_dark_svg!("clubs_8.svg"),
embed_dark_svg!("clubs_9.svg"),
embed_dark_svg!("clubs_10.svg"),
embed_dark_svg!("clubs_jack.svg"),
embed_dark_svg!("clubs_queen.svg"),
embed_dark_svg!("clubs_king.svg"),
embed_dark_svg!("diamonds_ace.svg"),
embed_dark_svg!("diamonds_2.svg"),
embed_dark_svg!("diamonds_3.svg"),
embed_dark_svg!("diamonds_4.svg"),
embed_dark_svg!("diamonds_5.svg"),
embed_dark_svg!("diamonds_6.svg"),
embed_dark_svg!("diamonds_7.svg"),
embed_dark_svg!("diamonds_8.svg"),
embed_dark_svg!("diamonds_9.svg"),
embed_dark_svg!("diamonds_10.svg"),
embed_dark_svg!("diamonds_jack.svg"),
embed_dark_svg!("diamonds_queen.svg"),
embed_dark_svg!("diamonds_king.svg"),
embed_dark_svg!("hearts_ace.svg"),
embed_dark_svg!("hearts_2.svg"),
embed_dark_svg!("hearts_3.svg"),
embed_dark_svg!("hearts_4.svg"),
embed_dark_svg!("hearts_5.svg"),
embed_dark_svg!("hearts_6.svg"),
embed_dark_svg!("hearts_7.svg"),
embed_dark_svg!("hearts_8.svg"),
embed_dark_svg!("hearts_9.svg"),
embed_dark_svg!("hearts_10.svg"),
embed_dark_svg!("hearts_jack.svg"),
embed_dark_svg!("hearts_queen.svg"),
embed_dark_svg!("hearts_king.svg"),
embed_dark_svg!("spades_ace.svg"),
embed_dark_svg!("spades_2.svg"),
embed_dark_svg!("spades_3.svg"),
embed_dark_svg!("spades_4.svg"),
embed_dark_svg!("spades_5.svg"),
embed_dark_svg!("spades_6.svg"),
embed_dark_svg!("spades_7.svg"),
embed_dark_svg!("spades_8.svg"),
embed_dark_svg!("spades_9.svg"),
embed_dark_svg!("spades_10.svg"),
embed_dark_svg!("spades_jack.svg"),
embed_dark_svg!("spades_queen.svg"),
embed_dark_svg!("spades_king.svg"),
];
/// Registers asset sources that must be in place *before*
@@ -190,62 +180,53 @@ pub struct AssetSourcesPlugin;
impl Plugin for AssetSourcesPlugin {
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
/// 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]> {
/// The thumbnail generator uses this to rasterise preview-sized art
/// without going through Bevy's async asset graph.
pub fn dark_theme_svg_bytes(filename: &str) -> Option<&'static [u8]> {
let suffix = format!("/{filename}");
DEFAULT_THEME_SVGS
DARK_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
/// unit test below can exercise it without spinning up a full Bevy
/// `App` with `AssetPlugin`.
/// Returns the manifest URL for a bundled (non-user) theme by id, or
/// `None` if `id` belongs to a user theme that lives under `themes://`.
///
/// **Adding files to the bundled default theme** is a single edit:
/// append one `embed_default_svg!("filename.svg")` line to the
/// `DEFAULT_THEME_SVGS` table above. The file resolves relative to
/// `solitaire_engine/assets/themes/default/` and registers under
/// the matching `embedded://` URL automatically.
pub fn populate_embedded_default_theme(app: &mut App) {
/// Callers that need to resolve a theme URL without access to
/// [`crate::theme::ThemeRegistry`] (e.g. Startup systems where registry
/// ordering isn't guaranteed) should use this instead of constructing
/// the URL manually.
pub fn bundled_theme_url(id: &str) -> Option<&'static str> {
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
.world_mut()
.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(
std::path::PathBuf::from(DEFAULT_THEME_MANIFEST_PATH),
std::path::Path::new(DEFAULT_THEME_MANIFEST_PATH),
DEFAULT_THEME_MANIFEST_BYTES,
std::path::PathBuf::from(DARK_THEME_MANIFEST_PATH),
std::path::Path::new(DARK_THEME_MANIFEST_PATH),
DARK_THEME_MANIFEST_BYTES,
);
// Then every face + back SVG. The manifest references each by the
// same relative path used here.
for (path, bytes) in DEFAULT_THEME_SVGS {
for (path, bytes) in DARK_THEME_SVGS {
registry.insert_asset(
std::path::PathBuf::from(*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]
fn populate_embedded_default_theme_runs_without_asset_plugin() {
fn populate_embedded_dark_theme_runs_without_asset_plugin() {
let mut app = App::new();
populate_embedded_default_theme(&mut app);
// Resource exists and has been inserted into.
populate_embedded_dark_theme(&mut app);
assert!(app
.world()
.get_resource::<EmbeddedAssetRegistry>()
.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]
fn embedded_default_theme_manifest_validates() {
fn embedded_dark_theme_manifest_validates() {
use crate::theme::ThemeManifest;
let manifest: ThemeManifest = ron::de::from_bytes(DEFAULT_THEME_MANIFEST_BYTES)
.expect("default manifest must parse as RON");
let manifest: ThemeManifest = ron::de::from_bytes(DARK_THEME_MANIFEST_BYTES)
.expect("dark manifest must parse as RON");
let faces = manifest
.validate()
.expect("default manifest must list all 52 faces");
.expect("dark manifest must list all 52 faces");
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() {
fn dark_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"
dark_theme_svg_bytes("back.svg").is_some(),
"dark theme must bundle a back.svg"
);
assert!(
default_theme_svg_bytes("spades_ace.svg").is_some(),
"default theme must bundle a spades_ace.svg"
dark_theme_svg_bytes("spades_ace.svg").is_some(),
"dark 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());
fn dark_theme_svg_bytes_returns_none_for_unknown_file() {
assert!(dark_theme_svg_bytes("nope.svg").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]
fn default_theme_url_constant_matches_embedded_path() {
let url_tail = DEFAULT_THEME_MANIFEST_URL
fn dark_theme_url_constant_matches_embedded_path() {
let url_tail = DARK_THEME_MANIFEST_URL
.strip_prefix("embedded://")
.expect("default theme URL must use embedded:// scheme");
assert_eq!(url_tail, DEFAULT_THEME_MANIFEST_PATH);
.expect("dark theme URL must use embedded:// scheme");
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 use assets::{
populate_embedded_default_theme, register_theme_asset_sources, AssetSourcesPlugin,
DEFAULT_THEME_MANIFEST_URL, USER_THEMES,
bundled_theme_url, populate_embedded_dark_theme, register_theme_asset_sources,
AssetSourcesPlugin, DARK_THEME_MANIFEST_URL, USER_THEMES,
};
pub use theme::{
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 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::events::StateChangedEvent;
@@ -126,12 +126,13 @@ 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("dark");
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);
commands.insert_resource(ActiveTheme(handle));
}
@@ -161,11 +162,9 @@ 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 = bundled_theme_url(new_id)
.map(str::to_string)
.unwrap_or_else(|| format!("themes://{new_id}/theme.ron"));
let handle: Handle<CardTheme> = asset_server.load(url);
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
/// `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".
/// - For the embedded `dark` theme, reads from the in-binary table via
/// [`dark_theme_svg_bytes`]. No filesystem I/O.
/// - For bundled non-embedded themes (e.g. `classic`), reads from the
/// `assets/themes/<id>/` directory.
/// - For user themes, reads from `<user_theme_dir>/<id>/<filename>`.
/// Returns `None` for any I/O failure.
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());
if theme_id == "dark" {
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);
std::fs::read(&path).ok()
}
@@ -503,22 +510,20 @@ mod tests {
// set_theme that doesn't require an App. We assert the URL
// shape so a future refactor doesn't accidentally change the
// path layout.
let url = format!("themes://{}/theme.ron", "default");
assert_eq!(url, "themes://default/theme.ron");
let url2 = format!("themes://{}/theme.ron", "user_uploaded");
assert_eq!(url2, "themes://user_uploaded/theme.ron");
let url = format!("themes://{}/theme.ron", "user_uploaded");
assert_eq!(url, "themes://user_uploaded/theme.ron");
}
/// Test 1: the bundled default theme always has embedded SVG bytes
/// available, so calling `generate_thumbnail_pair_for("default", …)`
/// Test 1: the bundled dark theme always has embedded SVG bytes
/// available, so calling `generate_thumbnail_pair_for("dark", …)`
/// must produce two non-default `Handle<Image>` slots.
#[test]
fn theme_thumbnails_generated_for_default_theme() {
fn theme_thumbnails_generated_for_dark_theme() {
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!(
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
// collection — the handles are real, not dangling.
@@ -558,18 +563,17 @@ mod tests {
);
}
/// `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.
/// `read_theme_preview_svg_bytes` for the dark theme always returns
/// embedded bytes for the canonical preview pair.
#[test]
fn read_default_theme_preview_returns_some_for_canonical_files() {
fn read_dark_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"
read_theme_preview_svg_bytes("dark", PREVIEW_BACK_FILENAME).is_some(),
"dark 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"
read_theme_preview_svg_bytes("dark", PREVIEW_FACE_FILENAME).is_some(),
"dark theme spades_ace.svg must be embedded"
);
}
@@ -586,12 +590,12 @@ mod tests {
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(),
id: "dark".into(),
display_name: "Dark".into(),
manifest_url: crate::assets::DARK_THEME_MANIFEST_URL.into(),
meta: ThemeMeta {
id: "default".into(),
name: "Default".into(),
id: "dark".into(),
name: "Dark".into(),
author: "x".into(),
version: "x".into(),
card_aspect: (2, 3),
@@ -605,18 +609,18 @@ mod tests {
let first_ace = app
.world()
.resource::<ThemeThumbnailCache>()
.get("default")
.get("dark")
.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.
app.update();
let second_ace = app
.world()
.resource::<ThemeThumbnailCache>()
.get("default")
.get("dark")
.map(|p| p.ace.clone())
.expect("default theme thumbnail must still exist");
.expect("dark theme thumbnail must still exist");
assert_eq!(
first_ace.id(),
+19 -21
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, DARK_THEME_MANIFEST_URL};
/// One entry in the [`ThemeRegistry`] — the data the picker UI needs
/// 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`].
pub fn build_registry(user_dir: &Path) -> ThemeRegistry {
let mut entries = Vec::new();
entries.push(default_entry());
entries.push(classic_entry());
entries.push(dark_entry());
entries.extend(discover_user_themes(user_dir));
ThemeRegistry { entries }
}
/// The bundled default theme entry — inserted unconditionally so the
/// picker always has at least one option.
fn default_entry() -> ThemeEntry {
/// The always-present embedded Dark theme entry.
fn dark_entry() -> ThemeEntry {
ThemeEntry {
id: "default".to_string(),
display_name: "Default".to_string(),
manifest_url: DEFAULT_THEME_MANIFEST_URL.to_string(),
id: "dark".to_string(),
display_name: "Dark".to_string(),
manifest_url: DARK_THEME_MANIFEST_URL.to_string(),
meta: ThemeMeta {
id: "default".to_string(),
name: "Default".to_string(),
id: "dark".to_string(),
name: "Dark".to_string(),
author: "Ferrous Solitaire".to_string(),
version: "1.0".to_string(),
version: "1.0.0".to_string(),
card_aspect: (2, 3),
},
}
@@ -265,8 +264,8 @@ mod tests {
let tmp = tempfile::tempdir().unwrap();
let registry = build_registry(tmp.path());
assert_eq!(registry.len(), BUNDLED_COUNT);
assert_eq!(registry.entries[0].id, "default");
assert_eq!(registry.entries[1].id, "classic");
assert_eq!(registry.entries[0].id, "classic");
assert_eq!(registry.entries[1].id, "dark");
}
#[test]
@@ -275,7 +274,8 @@ mod tests {
"/definitely/not/a/real/path/should/not/panic",
));
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]
@@ -333,7 +333,7 @@ mod tests {
let registry = build_registry(tmp.path());
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]
@@ -373,15 +373,13 @@ mod tests {
refresh_registry(&mut registry, tmp.path());
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());
}
#[test]
fn default_entry_url_matches_embedded_constant() {
// Ensures the picker always gets a URL it can hand to the
// asset server for the bundled theme.
let entry = default_entry();
assert_eq!(entry.manifest_url, DEFAULT_THEME_MANIFEST_URL);
fn dark_entry_url_matches_embedded_constant() {
let entry = dark_entry();
assert_eq!(entry.manifest_url, DARK_THEME_MANIFEST_URL);
}
}