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).
This commit is contained in:
funman300
2026-05-01 16:24:24 +00:00
parent a6b8348332
commit 924a1e2af7
3 changed files with 181 additions and 6 deletions
+14
View File
@@ -124,6 +124,14 @@ pub struct Settings {
/// `None` thanks to `#[serde(default)]`.
#[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 = ...)]`.
#[serde(default = "default_theme_id")]
pub selected_theme_id: String,
}
fn default_draw_mode() -> DrawMode {
@@ -138,6 +146,10 @@ fn default_music_volume() -> f32 {
0.5
}
fn default_theme_id() -> String {
"default".to_string()
}
impl Default for Settings {
fn default() -> Self {
Self {
@@ -152,6 +164,7 @@ impl Default for Settings {
first_run_complete: false,
color_blind_mode: false,
window_geometry: None,
selected_theme_id: default_theme_id(),
}
}
}
@@ -304,6 +317,7 @@ mod tests {
first_run_complete: true,
color_blind_mode: false,
window_geometry: None,
selected_theme_id: "default".to_string(),
};
save_settings_to(&path, &s).expect("save");
let loaded = load_settings_from(&path);
+110
View File
@@ -147,6 +147,10 @@ enum SettingsButton {
SelectCardBack(usize),
/// Select a specific background by index from the picker row.
SelectBackground(usize),
/// Select a specific card-art theme by `meta.id` from the
/// `ThemeRegistry`. The string is owned so the click handler can
/// hand it directly to `Settings::selected_theme_id`.
SelectTheme(String),
}
impl SettingsButton {
@@ -172,6 +176,7 @@ impl SettingsButton {
// priority so entity-index tiebreaking yields left → right.
SettingsButton::SelectCardBack(_) => 70,
SettingsButton::SelectBackground(_) => 80,
SettingsButton::SelectTheme(_) => 85,
// Sync section
SettingsButton::SyncNow => 90,
// Done is tagged by `attach_focusable_to_modal_buttons` and
@@ -353,6 +358,7 @@ fn sync_settings_panel_visibility(
sync_status: Option<Res<SyncStatusResource>>,
progress: Option<Res<ProgressResource>>,
font_res: Option<Res<FontResource>>,
theme_registry: Option<Res<crate::theme::ThemeRegistry>>,
) {
if !screen.is_changed() {
return;
@@ -367,12 +373,25 @@ fn sync_settings_panel_visibility(
let unlocked_bgs = progress
.as_ref()
.map_or(&[0][..], |p| p.0.unlocked_backgrounds.as_slice());
// Snapshot themes by id+display_name so spawn_settings_panel
// doesn't have to know about the registry shape. Empty when
// ThemeRegistryPlugin isn't installed (tests under
// MinimalPlugins) — the picker row simply won't render.
let themes: Vec<(String, String)> = theme_registry
.as_deref()
.map(|r| {
r.iter()
.map(|e| (e.id.clone(), e.display_name.clone()))
.collect()
})
.unwrap_or_default();
spawn_settings_panel(
&mut commands,
&settings.0,
&status_label,
unlocked_backs,
unlocked_bgs,
&themes,
scroll_pos.0,
font_res.as_deref(),
);
@@ -617,6 +636,13 @@ fn handle_settings_buttons(
persist(&path, &settings.0);
changed.write(SettingsChangedEvent(settings.0.clone()));
}
SettingsButton::SelectTheme(theme_id) => {
if settings.0.selected_theme_id != *theme_id {
settings.0.selected_theme_id = theme_id.clone();
persist(&path, &settings.0);
changed.write(SettingsChangedEvent(settings.0.clone()));
}
}
SettingsButton::SyncNow => {
manual_sync.write(ManualSyncRequestEvent);
}
@@ -902,12 +928,14 @@ fn persist_window_geometry_after_debounce(
// UI construction
// ---------------------------------------------------------------------------
#[allow(clippy::too_many_arguments)]
fn spawn_settings_panel(
commands: &mut Commands,
settings: &Settings,
sync_status: &str,
unlocked_card_backs: &[usize],
unlocked_backgrounds: &[usize],
themes: &[(String, String)],
scroll_offset: f32,
font_res: Option<&FontResource>,
) {
@@ -1014,6 +1042,19 @@ fn spawn_settings_panel(
"Choose your felt art. New felts unlock at higher levels.",
font_res,
);
// Card-art theme picker — only renders when the registry has
// entries (production: always; tests: only when
// ThemeRegistryPlugin is installed).
if !themes.is_empty() {
theme_picker_row(
body,
"Card Theme",
themes,
&settings.selected_theme_id,
"Choose card-face artwork. Imported themes appear here.",
font_res,
);
}
// --- Sync ---
section_label(body, "Sync", font_res);
@@ -1198,6 +1239,75 @@ fn picker_row(
});
}
/// Picker row for card-art themes. Distinct from [`picker_row`]
/// because themes are identified by `String` ids (matching
/// `ThemeMeta::id`) instead of dense indices, and each chip carries
/// the theme's display name rather than a numeric label.
fn theme_picker_row(
parent: &mut ChildSpawnerCommands,
label: &str,
themes: &[(String, String)],
selected_id: &str,
tooltip: &'static str,
font_res: Option<&FontResource>,
) {
let label_font = label_text_font(font_res);
let chip_font = TextFont {
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
font_size: TYPE_BODY,
..default()
};
parent
.spawn((
FocusRow,
Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: VAL_SPACE_2,
flex_wrap: FlexWrap::Wrap,
..default()
},
))
.with_children(|row| {
row.spawn((
Text::new(label.to_string()),
label_font,
TextColor(TEXT_SECONDARY),
));
for (id, display_name) in themes {
let is_selected = id == selected_id;
let bg = if is_selected { STATE_SUCCESS } else { BG_ELEVATED_HI };
row.spawn((
SettingsButton::SelectTheme(id.clone()),
Button,
Tooltip::new(tooltip),
Node {
// Theme names are wider than numeric chips —
// pad horizontally instead of using a fixed
// square swatch.
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2),
min_height: Val::Px(SWATCH_PX),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
..default()
},
BackgroundColor(bg),
BorderColor::all(BORDER_SUBTLE),
))
.with_children(|b| {
let text_color = if is_selected { BG_BASE } else { TEXT_PRIMARY };
b.spawn((
Text::new(display_name.clone()),
chip_font.clone(),
TextColor(text_color),
));
});
}
});
}
/// Status text + manual "Sync Now" button.
fn sync_row(parent: &mut ChildSpawnerCommands, status_text: &str, font_res: Option<&FontResource>) {
let status_font = TextFont {
+57 -6
View File
@@ -47,17 +47,68 @@ impl Plugin for ThemePlugin {
app.init_asset::<CardTheme>()
.register_asset_loader(crate::assets::SvgLoader)
.register_asset_loader(CardThemeLoader)
.add_systems(Startup, load_default_theme)
.add_systems(Update, sync_card_image_set_with_active_theme);
.add_systems(Startup, load_initial_theme)
.add_systems(
Update,
(
sync_card_image_set_with_active_theme,
react_to_settings_theme_change,
),
);
}
}
/// Kicks off the default-theme load and stashes the handle on
/// [`ActiveTheme`]. The actual rasterisation runs asynchronously on
/// Kicks off the initial theme load — the one named by
/// `Settings::selected_theme_id` if available, falling back to the
/// embedded default. The actual rasterisation runs asynchronously on
/// the asset task pool; the sync system below picks up the
/// `LoadedWithDependencies` event when every face + back is ready.
fn load_default_theme(asset_server: Res<AssetServer>, mut commands: Commands) {
let handle: Handle<CardTheme> = asset_server.load(DEFAULT_THEME_MANIFEST_URL);
fn load_initial_theme(
asset_server: Res<AssetServer>,
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 handle: Handle<CardTheme> = asset_server.load(url);
commands.insert_resource(ActiveTheme(handle));
}
/// Watches [`crate::settings_plugin::SettingsChangedEvent`] and
/// triggers a fresh theme load whenever
/// `Settings::selected_theme_id` changes. The settings panel's theme
/// picker fires the event after persisting; this system is the bridge
/// that turns the persisted choice into a live `set_theme` call.
fn react_to_settings_theme_change(
mut events: MessageReader<crate::settings_plugin::SettingsChangedEvent>,
asset_server: Res<AssetServer>,
active: Option<Res<ActiveTheme>>,
themes: Res<Assets<CardTheme>>,
mut commands: Commands,
) {
let Some(latest) = events.read().last() else {
return;
};
let new_id = latest.0.selected_theme_id.as_str();
// No-op if the active theme already matches the desired id.
if let Some(active) = active.as_deref()
&& let Some(theme) = themes.get(&active.0)
&& theme.meta.id == new_id
{
return;
}
let url = if new_id == "default" {
DEFAULT_THEME_MANIFEST_URL.to_string()
} else {
format!("themes://{new_id}/theme.ron")
};
let handle: Handle<CardTheme> = asset_server.load(url);
commands.insert_resource(ActiveTheme(handle));
}