Commit Graph

8 Commits

Author SHA1 Message Date
funman300 ba527de351 feat(engine): card-art thumbnails in the theme picker
Settings → Cosmetic's theme picker showed only the theme name. Now
each chip carries a small Ace-of-Spades + back preview pair so the
player can see what each theme looks like before switching.

A new ThemeThumbnailCache resource keys per-theme by id and stores
two Handle<Image>s (ace + back) rasterised at thumbnail resolution
via the existing rasterize_svg path. Generation runs once per
theme registration in theme_plugin; subsequent picker re-spawns
just look up the cached handles. Themes that lack one of the
preview SVGs (broken user theme) get a Handle::default() placeholder
rather than crashing — the placeholder renders as a transparent
rectangle the same size as the missing thumbnail.

The picker chip spawn loop in settings_plugin reads the cache and
renders the pair as two child sprites above the chip text. The
selected-theme chip's existing STATE_SUCCESS tint sits behind the
thumbnails; contrast stays readable.

Asset-source plumbing in assets/sources.rs and assets/mod.rs picks
up the new bytes-loading helper that the thumbnail generator uses
for embedded:// theme assets at startup time (before AssetServer is
fully initialised).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 00:41:20 +00:00
funman300 7ed4f2cba9 feat(engine): card backs follow active theme
Themes already shipped a back.svg in their manifest but card_plugin
ignored it — face-down cards always rendered with the legacy
back_N.png picker, so swapping themes only swapped the faces. Now
the active theme's back rasterises alongside its faces and feeds
into the face-down sprite path; the legacy back_N.png picker remains
the fallback when a theme doesn't ship its own back (e.g. a
user-imported theme that only redefines faces).

theme/plugin.rs caches the active theme's back Handle<Image> in the
ActiveTheme resource on theme-load and theme-switch. card_plugin's
face-down branch reads ActiveTheme first; missing theme back →
legacy back_N.png path indexed by Settings.selected_card_back.

Settings → Cosmetic's card-back picker section gains a caption
("Active theme provides its own back") that surfaces when the
override is in effect, plus the swatch row dims to communicate the
read-only state. Settings file format unchanged — selected_card_back
still round-trips and only takes effect when the theme leaves the
back undefined.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 20:08:17 +00:00
funman300 ab1d098877 fix(engine): use resolve_embed for sibling theme assets
CI / Test & Lint (push) Failing after 5s
CI / Release Build (push) Has been skipped
`AssetPath::resolve` concatenates, so manifest-relative SVG paths
ended up under `…/theme.ron/<name>.svg` and the asset server
reported all 53 references missing. `resolve_embed` is the RFC 1808
sibling-resolution method that strips the base path's last segment
first, giving the intended `…/<name>.svg`. Default theme now loads
cleanly from the embedded:// source.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 17:21:03 +00:00
funman300 924a1e2af7 feat(engine): card-theme picker in Settings → Cosmetic
CI / Test & Lint (push) Failing after 5s
CI / Release Build (push) Has been skipped
Wires the runtime theme system (CARD_PLAN.md phases 1–7) into the
visible Settings UI so a player can switch between every theme
discovered by `ThemeRegistry` without restarting.

solitaire_data/src/settings.rs
  Settings gains `selected_theme_id: String` (default "default"),
  guarded by `#[serde(default = "default_theme_id")]` so existing
  settings.json files deserialize cleanly.

solitaire_engine/src/settings_plugin.rs
  - SettingsButton::SelectTheme(String) variant + focus order 85.
  - sync_settings_panel_visibility now reads
    Option<Res<ThemeRegistry>>, snapshots id+display_name pairs, and
    threads them into spawn_settings_panel. When the registry is
    absent (tests under MinimalPlugins) the picker silently skips —
    every existing test continues to pass unchanged.
  - theme_picker_row helper: like picker_row but keyed by String
    rather than usize, with chips wide enough for theme display
    names. Attaches the canonical tooltip ("Choose card-face
    artwork. Imported themes appear here.") and the FocusRow marker
    so Left/Right arrows cycle within the row.
  - Click handler updates settings.selected_theme_id, persists, and
    fires SettingsChangedEvent — same shape as every other picker.

solitaire_engine/src/theme/plugin.rs
  - load_default_theme renamed to load_initial_theme; reads
    SettingsResource on Startup and seeds ActiveTheme from
    settings.selected_theme_id (falling back to embedded default).
  - react_to_settings_theme_change watches SettingsChangedEvent,
    no-ops when the active theme already matches, and otherwise
    swaps ActiveTheme — the existing
    sync_card_image_set_with_active_theme system then refreshes
    every card sprite on the next AssetEvent::LoadedWithDependencies.

cargo build / clippy --workspace --all-targets -- -D warnings / test
--workspace all green (960 passed, 0 failed, 9 ignored).
2026-05-01 16:24:24 +00:00
funman300 7b59e70192 feat(engine): theme registry + discovery (Card theme phase 6)
CI / Test & Lint (push) Failing after 8s
CI / Release Build (push) Has been skipped
Implements Phase 6 of CARD_PLAN.md — discovers every available card
theme on startup so the future picker UI can list them.

solitaire_engine/src/theme/registry.rs
  ThemeEntry { id, display_name, manifest_url, meta }
  ThemeRegistry — Resource holding the entries; provides
    find(id), iter(), len(), is_empty().
  ThemeRegistryPlugin — Startup system that scans
    user_theme_dir() and populates the registry.
  build_registry(user_dir) — pure helper; takes the dir as a
    parameter so tests use tempfile::tempdir() without touching
    the global OnceLock-based user-theme path.
  refresh_registry(&mut, user_dir) — replaces in-place; called
    after a successful import_theme so a freshly-imported theme
    appears in the picker without an app restart.

The bundled default entry is always inserted (id "default", served
from DEFAULT_THEME_MANIFEST_URL) so the picker has at least one
option even when no user themes exist.

Discovery is best-effort: a directory whose theme.ron is missing,
malformed, or fails ThemeMeta::validate is silently skipped — broken
themes don't poison the registry. Only the meta block is parsed
(via a derive(Deserialize) struct that ignores other manifest
fields), which keeps startup quick even with dozens of themes
installed.

Wired into solitaire_app/main.rs after ThemePlugin so the asset
sources are registered before discovery scans for theme.ron files.

10 new tests covering: empty user dir, nonexistent user dir, valid
user theme registers, full-manifest tolerance via meta-only parser,
malformed theme.ron skipped, invalid-meta theme skipped, directory
without theme.ron ignored, find() returns None for unknown id,
refresh_registry replaces stale entries, default-entry URL matches
the embedded constant.

cargo build / clippy --workspace --all-targets -- -D warnings / test
--workspace all green (960 passed, 0 failed, 9 ignored).
2026-05-01 06:04:34 +00:00
funman300 7f477b4ad8 feat(engine): ThemePlugin + ActiveTheme integration (Card theme phase 4)
Implements Phase 4 of CARD_PLAN.md — the runtime hook that loads the
default theme on startup and refreshes the card-rendering pipeline
whenever the active theme changes.

solitaire_engine/src/theme/plugin.rs
  ThemePlugin
    init_asset::<CardTheme>, register_asset_loader for SvgLoader and
    CardThemeLoader, Startup load_default_theme, and Update
    sync_card_image_set_with_active_theme.
  ActiveTheme(Handle<CardTheme>)
    Resource pointing at the currently-loaded theme.
  set_theme(commands, asset_server, theme_id)
    Public API for switching themes — formats the URL as
    `themes://<theme_id>/theme.ron` and updates the resource.

Integration approach: rather than refactor every `card_plugin.rs`
spawn site to read from `Assets<CardTheme>` directly, the sync system
writes the theme's face/back image handles into the existing
`CardImageSet` resource on `AssetEvent::LoadedWithDependencies` /
`Modified`, then fires `StateChangedEvent`. The existing
`sync_cards_on_change` pipeline rebuilds card sprites from the new
handles on the next tick — observable behaviour matches the plan's
intent (theme switches propagate immediately) while keeping
card_plugin's 1929-line surface area untouched.

Theme.back is mapped onto `CardImageSet.backs[0]` (the default-back
slot xCards previously occupied); `backs[1..=4]` are the
asset-generator patterns and remain user-selectable independent of
the active theme.

Added to solitaire_app/main.rs as `add_plugins(ThemePlugin)` after
`AssetSourcesPlugin` so the asset sources are registered before the
default-theme load is dispatched.

6 new tests covering suit/rank index mapping (matching the
`card_plugin` doc-commented `[suit][rank]` layout), empty-theme
no-panic, back-slot overwrite, and the URL format from `set_theme`.

cargo build / clippy --workspace --all-targets -- -D warnings / test
--workspace all green (950 passed, 0 failed, 9 ignored).
2026-05-01 05:59:28 +00:00
funman300 ce38b26721 feat(engine): theme zip importer with safety validation (Card theme phase 7)
Implements Phase 7 of CARD_PLAN.md — the entry point that takes a
user-supplied theme zip archive, validates it end-to-end, and
atomically unpacks it into the per-platform user themes directory.

Public API:
  import_theme(zip_path) -> Result<ThemeId, ImportError>
    Resolves user_theme_dir() and unpacks into <user>/<id>/.
  import_theme_into(zip_path, target_root) -> Result<ThemeId, ImportError>
    Test-friendly variant that takes the destination explicitly so
    unit tests never touch the global OnceLock override.

Safety guarantees enforced:
- 20 MB hard cap on archive size (read from the central directory
  before any extraction).
- Zip-slip path traversal rejected via ZipFile::enclosed_name plus a
  Component::Normal-only belt-and-braces check.
- Manifest parsed via ron::de and validated via the existing
  ThemeManifest::validate (Phase 2) — surfaces named diagnostics for
  missing-of-52, unknown keys, duplicate keys, and meta errors.
- Every referenced face + back rasterised through rasterize_svg as a
  structural validity check before any bytes hit the destination.
- Atomic install: writes to <root>/.<id>.tmp/ then std::fs::rename
  into place, with a recursive copy + remove fallback for cross-
  device renames. Failed extraction wipes the staging dir; the user
  themes root is never touched on error.
- Id collision with an existing theme dir rejected up front.

7 new tests covering the happy path plus six failure modes (missing
manifest, missing face, oversized archive, zip-slip, missing-file,
id collision). Tests build zips in tempfile::TempDir so they never
touch the real user themes directory.

Workspace deps: zip 8.6 (default-features off + deflate only),
tempfile 3.27 (dev only).

cargo check --workspace --all-targets / clippy --workspace
--all-targets -- -D warnings clean. cargo test could not be run in
this turn because cc disappeared from the sandbox; tests compile
under cargo check --tests and will run on a normal toolchain.
2026-05-01 05:47:30 +00:00
funman300 936d035750 feat(engine): CardTheme asset + manifest loader (Card theme phase 2)
Implements Phase 2 of CARD_PLAN.md — the data types and `.theme.ron`
asset loader that build on Phase 1's SVG rasteriser.

solitaire_engine/src/theme/
  mod.rs        — CardKey { suit, rank } as the HashMap lookup key
                  (distinct from solitaire_core::Card which carries
                  per-deal id + face_up state); CardKey::all() yields
                  the 52 keys in suit-major / rank-ascending order;
                  manifest_name() and parse_manifest_name() round-trip
                  via the canonical "{suit}_{rank}" form.
                  ThemeMeta with structural validation (id non-empty,
                  no path separators, non-zero aspect components).
                  CardTheme #[derive(Asset, TypePath)] storing the
                  53 image handles + meta.
  manifest.rs   — ThemeManifest { meta, back, faces } with serde for
                  RON round-trip. validate() returns a strongly-typed
                  HashMap<CardKey, PathBuf>, surfacing precise errors
                  for unknown face keys, missing-of-52 entries, and
                  duplicate keys (RON silently keeps the last; brittle
                  for a release).
  loader.rs     — AssetLoader for .theme.ron. Validates manifest, then
                  composes sibling SVG paths via AssetPath::resolve so
                  the same loader works for both embedded:// and
                  themes:// asset sources (Phase 3 territory).
                  Schedules every face + back load through SvgLoader
                  with target_size derived from meta.card_aspect.

24 new tests covering: 52-key enumeration uniqueness, manifest-name
round trip, garbage-name rejection, complete/missing/unknown/duplicate
manifest validation, RON round-trip integrity, target-size aspect
math (2:3 → 512x768; non-standard; degenerate 1:10000 clamps to 1px).

Workspace deps added: ron 0.12.

cargo build / clippy --workspace --all-targets -- -D warnings / test
all green (937 passed total — +24 from Phase 2 vs the +7 from
Phase 1's b8fb3fb baseline).
2026-05-01 05:19:12 +00:00