feat(engine): card-theme picker in Settings → Cosmetic
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:
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user