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)]`.
|
/// `None` thanks to `#[serde(default)]`.
|
||||||
#[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
|
||||||
|
/// 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 {
|
fn default_draw_mode() -> DrawMode {
|
||||||
@@ -138,6 +146,10 @@ fn default_music_volume() -> f32 {
|
|||||||
0.5
|
0.5
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_theme_id() -> String {
|
||||||
|
"default".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for Settings {
|
impl Default for Settings {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -152,6 +164,7 @@ impl Default for Settings {
|
|||||||
first_run_complete: false,
|
first_run_complete: false,
|
||||||
color_blind_mode: false,
|
color_blind_mode: false,
|
||||||
window_geometry: None,
|
window_geometry: None,
|
||||||
|
selected_theme_id: default_theme_id(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -304,6 +317,7 @@ mod tests {
|
|||||||
first_run_complete: true,
|
first_run_complete: true,
|
||||||
color_blind_mode: false,
|
color_blind_mode: false,
|
||||||
window_geometry: None,
|
window_geometry: None,
|
||||||
|
selected_theme_id: "default".to_string(),
|
||||||
};
|
};
|
||||||
save_settings_to(&path, &s).expect("save");
|
save_settings_to(&path, &s).expect("save");
|
||||||
let loaded = load_settings_from(&path);
|
let loaded = load_settings_from(&path);
|
||||||
|
|||||||
@@ -147,6 +147,10 @@ enum SettingsButton {
|
|||||||
SelectCardBack(usize),
|
SelectCardBack(usize),
|
||||||
/// Select a specific background by index from the picker row.
|
/// Select a specific background by index from the picker row.
|
||||||
SelectBackground(usize),
|
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 {
|
impl SettingsButton {
|
||||||
@@ -172,6 +176,7 @@ impl SettingsButton {
|
|||||||
// priority so entity-index tiebreaking yields left → right.
|
// priority so entity-index tiebreaking yields left → right.
|
||||||
SettingsButton::SelectCardBack(_) => 70,
|
SettingsButton::SelectCardBack(_) => 70,
|
||||||
SettingsButton::SelectBackground(_) => 80,
|
SettingsButton::SelectBackground(_) => 80,
|
||||||
|
SettingsButton::SelectTheme(_) => 85,
|
||||||
// Sync section
|
// Sync section
|
||||||
SettingsButton::SyncNow => 90,
|
SettingsButton::SyncNow => 90,
|
||||||
// Done is tagged by `attach_focusable_to_modal_buttons` and
|
// Done is tagged by `attach_focusable_to_modal_buttons` and
|
||||||
@@ -353,6 +358,7 @@ fn sync_settings_panel_visibility(
|
|||||||
sync_status: Option<Res<SyncStatusResource>>,
|
sync_status: Option<Res<SyncStatusResource>>,
|
||||||
progress: Option<Res<ProgressResource>>,
|
progress: Option<Res<ProgressResource>>,
|
||||||
font_res: Option<Res<FontResource>>,
|
font_res: Option<Res<FontResource>>,
|
||||||
|
theme_registry: Option<Res<crate::theme::ThemeRegistry>>,
|
||||||
) {
|
) {
|
||||||
if !screen.is_changed() {
|
if !screen.is_changed() {
|
||||||
return;
|
return;
|
||||||
@@ -367,12 +373,25 @@ fn sync_settings_panel_visibility(
|
|||||||
let unlocked_bgs = progress
|
let unlocked_bgs = progress
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map_or(&[0][..], |p| p.0.unlocked_backgrounds.as_slice());
|
.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(
|
spawn_settings_panel(
|
||||||
&mut commands,
|
&mut commands,
|
||||||
&settings.0,
|
&settings.0,
|
||||||
&status_label,
|
&status_label,
|
||||||
unlocked_backs,
|
unlocked_backs,
|
||||||
unlocked_bgs,
|
unlocked_bgs,
|
||||||
|
&themes,
|
||||||
scroll_pos.0,
|
scroll_pos.0,
|
||||||
font_res.as_deref(),
|
font_res.as_deref(),
|
||||||
);
|
);
|
||||||
@@ -617,6 +636,13 @@ fn handle_settings_buttons(
|
|||||||
persist(&path, &settings.0);
|
persist(&path, &settings.0);
|
||||||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
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 => {
|
SettingsButton::SyncNow => {
|
||||||
manual_sync.write(ManualSyncRequestEvent);
|
manual_sync.write(ManualSyncRequestEvent);
|
||||||
}
|
}
|
||||||
@@ -902,12 +928,14 @@ fn persist_window_geometry_after_debounce(
|
|||||||
// UI construction
|
// UI construction
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn spawn_settings_panel(
|
fn spawn_settings_panel(
|
||||||
commands: &mut Commands,
|
commands: &mut Commands,
|
||||||
settings: &Settings,
|
settings: &Settings,
|
||||||
sync_status: &str,
|
sync_status: &str,
|
||||||
unlocked_card_backs: &[usize],
|
unlocked_card_backs: &[usize],
|
||||||
unlocked_backgrounds: &[usize],
|
unlocked_backgrounds: &[usize],
|
||||||
|
themes: &[(String, String)],
|
||||||
scroll_offset: f32,
|
scroll_offset: f32,
|
||||||
font_res: Option<&FontResource>,
|
font_res: Option<&FontResource>,
|
||||||
) {
|
) {
|
||||||
@@ -1014,6 +1042,19 @@ fn spawn_settings_panel(
|
|||||||
"Choose your felt art. New felts unlock at higher levels.",
|
"Choose your felt art. New felts unlock at higher levels.",
|
||||||
font_res,
|
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 ---
|
// --- Sync ---
|
||||||
section_label(body, "Sync", font_res);
|
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.
|
/// Status text + manual "Sync Now" button.
|
||||||
fn sync_row(parent: &mut ChildSpawnerCommands, status_text: &str, font_res: Option<&FontResource>) {
|
fn sync_row(parent: &mut ChildSpawnerCommands, status_text: &str, font_res: Option<&FontResource>) {
|
||||||
let status_font = TextFont {
|
let status_font = TextFont {
|
||||||
|
|||||||
@@ -47,17 +47,68 @@ impl Plugin for ThemePlugin {
|
|||||||
app.init_asset::<CardTheme>()
|
app.init_asset::<CardTheme>()
|
||||||
.register_asset_loader(crate::assets::SvgLoader)
|
.register_asset_loader(crate::assets::SvgLoader)
|
||||||
.register_asset_loader(CardThemeLoader)
|
.register_asset_loader(CardThemeLoader)
|
||||||
.add_systems(Startup, load_default_theme)
|
.add_systems(Startup, load_initial_theme)
|
||||||
.add_systems(Update, sync_card_image_set_with_active_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
|
/// Kicks off the initial theme load — the one named by
|
||||||
/// [`ActiveTheme`]. The actual rasterisation runs asynchronously on
|
/// `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
|
/// the asset task pool; the sync system below picks up the
|
||||||
/// `LoadedWithDependencies` event when every face + back is ready.
|
/// `LoadedWithDependencies` event when every face + back is ready.
|
||||||
fn load_default_theme(asset_server: Res<AssetServer>, mut commands: Commands) {
|
fn load_initial_theme(
|
||||||
let handle: Handle<CardTheme> = asset_server.load(DEFAULT_THEME_MANIFEST_URL);
|
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));
|
commands.insert_resource(ActiveTheme(handle));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user