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>
This commit is contained in:
@@ -11,8 +11,8 @@ pub mod svg_loader;
|
|||||||
pub mod user_dir;
|
pub mod user_dir;
|
||||||
|
|
||||||
pub use sources::{
|
pub use sources::{
|
||||||
populate_embedded_default_theme, register_theme_asset_sources, AssetSourcesPlugin,
|
default_theme_svg_bytes, populate_embedded_default_theme, register_theme_asset_sources,
|
||||||
DEFAULT_THEME_MANIFEST_URL, USER_THEMES,
|
AssetSourcesPlugin, DEFAULT_THEME_MANIFEST_URL, USER_THEMES,
|
||||||
};
|
};
|
||||||
pub use svg_loader::{rasterize_svg, SvgLoader, SvgLoaderError, SvgLoaderSettings};
|
pub use svg_loader::{rasterize_svg, SvgLoader, SvgLoaderError, SvgLoaderSettings};
|
||||||
pub use user_dir::{set_user_theme_dir, user_theme_dir};
|
pub use user_dir::{set_user_theme_dir, user_theme_dir};
|
||||||
|
|||||||
@@ -194,6 +194,25 @@ impl Plugin for AssetSourcesPlugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the embedded SVG bytes for a single default-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]> {
|
||||||
|
let suffix = format!("/{filename}");
|
||||||
|
DEFAULT_THEME_SVGS
|
||||||
|
.iter()
|
||||||
|
.find(|(path, _)| path.ends_with(&suffix))
|
||||||
|
.map(|(_, bytes)| *bytes)
|
||||||
|
}
|
||||||
|
|
||||||
/// Pushes every bundled default-theme file into the
|
/// Pushes every bundled default-theme file into the
|
||||||
/// [`EmbeddedAssetRegistry`] under its stable URL. Keeping this in a
|
/// [`EmbeddedAssetRegistry`] under its stable URL. Keeping this in a
|
||||||
/// free function (and not inside the `Plugin::build` body) means the
|
/// free function (and not inside the `Plugin::build` body) means the
|
||||||
@@ -291,6 +310,29 @@ mod tests {
|
|||||||
assert_eq!(faces.len(), 52);
|
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() {
|
||||||
|
assert!(
|
||||||
|
default_theme_svg_bytes("back.svg").is_some(),
|
||||||
|
"default theme must bundle a back.svg"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
default_theme_svg_bytes("spades_ace.svg").is_some(),
|
||||||
|
"default 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());
|
||||||
|
}
|
||||||
|
|
||||||
/// Belt-and-braces: if anyone edits `DEFAULT_THEME_MANIFEST_PATH`
|
/// Belt-and-braces: if anyone edits `DEFAULT_THEME_MANIFEST_PATH`
|
||||||
/// without updating `DEFAULT_THEME_MANIFEST_URL` (or vice versa)
|
/// without updating `DEFAULT_THEME_MANIFEST_URL` (or vice versa)
|
||||||
/// the asset would register at one path and be loaded from
|
/// the asset would register at one path and be loaded from
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ use crate::events::{ManualSyncRequestEvent, ToggleSettingsRequestEvent};
|
|||||||
use crate::font_plugin::FontResource;
|
use crate::font_plugin::FontResource;
|
||||||
use crate::progress_plugin::ProgressResource;
|
use crate::progress_plugin::ProgressResource;
|
||||||
use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource};
|
use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource};
|
||||||
|
use crate::theme::{ThemeThumbnailCache, ThemeThumbnailPair};
|
||||||
use crate::ui_focus::{FocusGroup, FocusRow, Focusable, FocusedButton};
|
use crate::ui_focus::{FocusGroup, FocusRow, Focusable, FocusedButton};
|
||||||
use crate::ui_modal::{
|
use crate::ui_modal::{
|
||||||
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
||||||
@@ -32,8 +33,9 @@ use crate::ui_modal::{
|
|||||||
};
|
};
|
||||||
use crate::ui_tooltip::Tooltip;
|
use crate::ui_tooltip::Tooltip;
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
BG_BASE, BG_ELEVATED_HI, BORDER_SUBTLE, RADIUS_SM, SPACE_2, STATE_SUCCESS, TEXT_PRIMARY,
|
BG_BASE, BG_ELEVATED, BG_ELEVATED_HI, BORDER_SUBTLE, RADIUS_SM, SPACE_2, STATE_SUCCESS,
|
||||||
TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL,
|
TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3,
|
||||||
|
Z_MODAL_PANEL,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Side length of a swatch button in the card-back / background pickers.
|
/// Side length of a swatch button in the card-back / background pickers.
|
||||||
@@ -134,6 +136,23 @@ struct SettingsPanelScrollable;
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
struct SettingsScrollNode;
|
struct SettingsScrollNode;
|
||||||
|
|
||||||
|
/// Snapshot row used by [`spawn_settings_panel`] to render the card-art
|
||||||
|
/// theme picker. Carries the `ThemeRegistry` entry's display fields plus
|
||||||
|
/// the (optional) thumbnail pair from [`ThemeThumbnailCache`]. A `None`
|
||||||
|
/// thumbnail means the picker should render a placeholder swatch — used
|
||||||
|
/// when the cache hasn't generated handles yet, or when a user theme
|
||||||
|
/// is missing one of the required preview SVGs.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct ThemePickerEntry {
|
||||||
|
/// Stable theme id (matches `ThemeMeta::id`).
|
||||||
|
id: String,
|
||||||
|
/// Player-facing label.
|
||||||
|
display_name: String,
|
||||||
|
/// Pre-generated picker preview pair, when ready. `None` collapses
|
||||||
|
/// the chip to its plain-text fallback.
|
||||||
|
thumbnails: Option<ThemeThumbnailPair>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Tags interactive buttons inside the Settings panel.
|
/// Tags interactive buttons inside the Settings panel.
|
||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
enum SettingsButton {
|
enum SettingsButton {
|
||||||
@@ -370,6 +389,7 @@ fn sync_settings_panel_visibility(
|
|||||||
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>>,
|
theme_registry: Option<Res<crate::theme::ThemeRegistry>>,
|
||||||
|
theme_thumbs: Option<Res<ThemeThumbnailCache>>,
|
||||||
card_images: Option<Res<crate::card_plugin::CardImageSet>>,
|
card_images: Option<Res<crate::card_plugin::CardImageSet>>,
|
||||||
) {
|
) {
|
||||||
if !screen.is_changed() {
|
if !screen.is_changed() {
|
||||||
@@ -385,15 +405,27 @@ 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
|
// Snapshot themes by id, display_name and (optional)
|
||||||
// doesn't have to know about the registry shape. Empty when
|
// thumbnail pair so spawn_settings_panel doesn't have to
|
||||||
|
// know about the registry / cache shapes. Empty when
|
||||||
// ThemeRegistryPlugin isn't installed (tests under
|
// ThemeRegistryPlugin isn't installed (tests under
|
||||||
// MinimalPlugins) — the picker row simply won't render.
|
// MinimalPlugins) — the picker row simply won't render.
|
||||||
let themes: Vec<(String, String)> = theme_registry
|
// Missing thumbnails (cache not ready, or partial user
|
||||||
|
// theme) leave `thumbnails: None` so the chip renders its
|
||||||
|
// plain-text fallback instead of a broken sprite.
|
||||||
|
let themes: Vec<ThemePickerEntry> = theme_registry
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map(|r| {
|
.map(|r| {
|
||||||
r.iter()
|
r.iter()
|
||||||
.map(|e| (e.id.clone(), e.display_name.clone()))
|
.map(|e| ThemePickerEntry {
|
||||||
|
id: e.id.clone(),
|
||||||
|
display_name: e.display_name.clone(),
|
||||||
|
thumbnails: theme_thumbs
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|c| c.get(&e.id))
|
||||||
|
.filter(|p| p.is_fully_populated())
|
||||||
|
.cloned(),
|
||||||
|
})
|
||||||
.collect()
|
.collect()
|
||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
@@ -1010,7 +1042,7 @@ fn spawn_settings_panel(
|
|||||||
sync_status: &str,
|
sync_status: &str,
|
||||||
unlocked_card_backs: &[usize],
|
unlocked_card_backs: &[usize],
|
||||||
unlocked_backgrounds: &[usize],
|
unlocked_backgrounds: &[usize],
|
||||||
themes: &[(String, String)],
|
themes: &[ThemePickerEntry],
|
||||||
scroll_offset: f32,
|
scroll_offset: f32,
|
||||||
font_res: Option<&FontResource>,
|
font_res: Option<&FontResource>,
|
||||||
theme_overrides_back: bool,
|
theme_overrides_back: bool,
|
||||||
@@ -1384,6 +1416,13 @@ fn picker_row(
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub(crate) struct CardBackPickerOverriddenByTheme;
|
pub(crate) struct CardBackPickerOverriddenByTheme;
|
||||||
|
|
||||||
|
/// Marker placed on every preview-thumbnail [`ImageNode`] inside a
|
||||||
|
/// theme picker chip. Lets tests assert that a chip's children include
|
||||||
|
/// the rasterised preview pair, and lets a future system update or
|
||||||
|
/// hot-swap thumbnails without scanning the whole UI tree.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub(crate) struct ThemeThumbnailMarker;
|
||||||
|
|
||||||
/// Renders the "Card Back" row in its overridden-by-theme state: a
|
/// Renders the "Card Back" row in its overridden-by-theme state: a
|
||||||
/// labelled caption explaining why the swatches are hidden, with no
|
/// labelled caption explaining why the swatches are hidden, with no
|
||||||
/// interactive children. This is what the player sees when the active
|
/// interactive children. This is what the player sees when the active
|
||||||
@@ -1426,14 +1465,25 @@ fn picker_row_overridden_by_theme(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Logical width (px) of one preview thumbnail inside a picker chip.
|
||||||
|
/// Mirrors [`crate::theme::THEME_THUMBNAIL_WIDTH_PX`] but at the UI
|
||||||
|
/// scale used by Bevy's flex layout. The rasterised image itself is
|
||||||
|
/// 100×140 px; the chip displays it at the same logical size so
|
||||||
|
/// scaling artifacts stay minimal.
|
||||||
|
const THUMBNAIL_LOGICAL_WIDTH_PX: f32 = 50.0;
|
||||||
|
/// Logical height counterpart to [`THUMBNAIL_LOGICAL_WIDTH_PX`] —
|
||||||
|
/// preserves the 2:3 card aspect.
|
||||||
|
const THUMBNAIL_LOGICAL_HEIGHT_PX: f32 = 70.0;
|
||||||
|
|
||||||
/// Picker row for card-art themes. Distinct from [`picker_row`]
|
/// Picker row for card-art themes. Distinct from [`picker_row`]
|
||||||
/// because themes are identified by `String` ids (matching
|
/// because themes are identified by `String` ids (matching
|
||||||
/// `ThemeMeta::id`) instead of dense indices, and each chip carries
|
/// `ThemeMeta::id`) instead of dense indices, and each chip carries
|
||||||
/// the theme's display name rather than a numeric label.
|
/// the theme's display name plus a small Ace + back preview pair
|
||||||
|
/// (when available in [`ThemeThumbnailCache`]).
|
||||||
fn theme_picker_row(
|
fn theme_picker_row(
|
||||||
parent: &mut ChildSpawnerCommands,
|
parent: &mut ChildSpawnerCommands,
|
||||||
label: &str,
|
label: &str,
|
||||||
themes: &[(String, String)],
|
themes: &[ThemePickerEntry],
|
||||||
selected_id: &str,
|
selected_id: &str,
|
||||||
tooltip: &'static str,
|
tooltip: &'static str,
|
||||||
font_res: Option<&FontResource>,
|
font_res: Option<&FontResource>,
|
||||||
@@ -1461,19 +1511,25 @@ fn theme_picker_row(
|
|||||||
label_font,
|
label_font,
|
||||||
TextColor(TEXT_SECONDARY),
|
TextColor(TEXT_SECONDARY),
|
||||||
));
|
));
|
||||||
for (id, display_name) in themes {
|
for entry in themes {
|
||||||
let is_selected = id == selected_id;
|
let is_selected = entry.id == selected_id;
|
||||||
let bg = if is_selected { STATE_SUCCESS } else { BG_ELEVATED_HI };
|
let bg = if is_selected { STATE_SUCCESS } else { BG_ELEVATED_HI };
|
||||||
row.spawn((
|
row.spawn((
|
||||||
SettingsButton::SelectTheme(id.clone()),
|
SettingsButton::SelectTheme(entry.id.clone()),
|
||||||
Button,
|
Button,
|
||||||
Tooltip::new(tooltip),
|
Tooltip::new(tooltip),
|
||||||
Node {
|
Node {
|
||||||
|
// Chips with thumbnails stack the preview pair
|
||||||
|
// above the label so a glance reveals the
|
||||||
|
// theme's art without hovering for the
|
||||||
|
// tooltip.
|
||||||
|
flex_direction: FlexDirection::Column,
|
||||||
// Theme names are wider than numeric chips —
|
// Theme names are wider than numeric chips —
|
||||||
// pad horizontally instead of using a fixed
|
// pad horizontally instead of using a fixed
|
||||||
// square swatch.
|
// square swatch.
|
||||||
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2),
|
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_2),
|
||||||
min_height: Val::Px(SWATCH_PX),
|
min_height: Val::Px(SWATCH_PX),
|
||||||
|
row_gap: VAL_SPACE_2,
|
||||||
justify_content: JustifyContent::Center,
|
justify_content: JustifyContent::Center,
|
||||||
align_items: AlignItems::Center,
|
align_items: AlignItems::Center,
|
||||||
border: UiRect::all(Val::Px(1.0)),
|
border: UiRect::all(Val::Px(1.0)),
|
||||||
@@ -1484,9 +1540,10 @@ fn theme_picker_row(
|
|||||||
BorderColor::all(BORDER_SUBTLE),
|
BorderColor::all(BORDER_SUBTLE),
|
||||||
))
|
))
|
||||||
.with_children(|b| {
|
.with_children(|b| {
|
||||||
|
spawn_thumbnail_pair(b, entry.thumbnails.as_ref());
|
||||||
let text_color = if is_selected { BG_BASE } else { TEXT_PRIMARY };
|
let text_color = if is_selected { BG_BASE } else { TEXT_PRIMARY };
|
||||||
b.spawn((
|
b.spawn((
|
||||||
Text::new(display_name.clone()),
|
Text::new(entry.display_name.clone()),
|
||||||
chip_font.clone(),
|
chip_font.clone(),
|
||||||
TextColor(text_color),
|
TextColor(text_color),
|
||||||
));
|
));
|
||||||
@@ -1495,6 +1552,70 @@ fn theme_picker_row(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Spawns the Ace + back preview pair for a theme picker chip.
|
||||||
|
///
|
||||||
|
/// When `thumbnails` is `Some(_)` and both handles are non-default,
|
||||||
|
/// renders two `ImageNode` siblings (Ace on the left, back on the
|
||||||
|
/// right). When the thumbnails are missing or only partially loaded,
|
||||||
|
/// renders two muted `BG_ELEVATED` placeholder rectangles at the same
|
||||||
|
/// logical size — keeping the chip's overall footprint stable so the
|
||||||
|
/// picker row layout doesn't reflow as the cache fills in.
|
||||||
|
fn spawn_thumbnail_pair(
|
||||||
|
parent: &mut ChildSpawnerCommands,
|
||||||
|
thumbnails: Option<&ThemeThumbnailPair>,
|
||||||
|
) {
|
||||||
|
parent
|
||||||
|
.spawn(Node {
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
column_gap: VAL_SPACE_2,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
.with_children(|pair| {
|
||||||
|
match thumbnails {
|
||||||
|
Some(t) if t.is_fully_populated() => {
|
||||||
|
spawn_thumbnail_image(pair, t.ace.clone());
|
||||||
|
spawn_thumbnail_image(pair, t.back.clone());
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
spawn_thumbnail_placeholder(pair);
|
||||||
|
spawn_thumbnail_placeholder(pair);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns one `ImageNode` thumbnail at the canonical preview size.
|
||||||
|
/// Tagged with [`ThemeThumbnailMarker`] so tests can scan a chip's
|
||||||
|
/// children for the rendered preview without crawling the whole UI.
|
||||||
|
fn spawn_thumbnail_image(parent: &mut ChildSpawnerCommands, image: Handle<Image>) {
|
||||||
|
parent.spawn((
|
||||||
|
ThemeThumbnailMarker,
|
||||||
|
ImageNode::new(image),
|
||||||
|
Node {
|
||||||
|
width: Val::Px(THUMBNAIL_LOGICAL_WIDTH_PX),
|
||||||
|
height: Val::Px(THUMBNAIL_LOGICAL_HEIGHT_PX),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns a muted placeholder rectangle for the case where the cache
|
||||||
|
/// has not yet generated thumbnails for a theme — or when a user theme
|
||||||
|
/// is missing one of its preview SVGs. Same logical size as
|
||||||
|
/// [`spawn_thumbnail_image`] so chip layout stays stable.
|
||||||
|
fn spawn_thumbnail_placeholder(parent: &mut ChildSpawnerCommands) {
|
||||||
|
parent.spawn((
|
||||||
|
Node {
|
||||||
|
width: Val::Px(THUMBNAIL_LOGICAL_WIDTH_PX),
|
||||||
|
height: Val::Px(THUMBNAIL_LOGICAL_HEIGHT_PX),
|
||||||
|
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(BG_ELEVATED),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
/// 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 {
|
||||||
@@ -1943,6 +2064,83 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Test 3 of the thumbnail-picker spec: when [`ThemeRegistry`] has
|
||||||
|
/// at least one theme and the [`ThemeThumbnailCache`] holds a
|
||||||
|
/// fully-populated [`ThemeThumbnailPair`] for that theme's id, the
|
||||||
|
/// rendered chip carries a [`ThemeThumbnailMarker`]-tagged
|
||||||
|
/// `ImageNode` for each preview slot.
|
||||||
|
#[test]
|
||||||
|
fn theme_picker_chip_includes_thumbnail_sprite_when_thumbnails_loaded() {
|
||||||
|
use crate::theme::{ThemeEntry, ThemeRegistry, ThemeThumbnailCache, ThemeThumbnailPair};
|
||||||
|
|
||||||
|
let mut app = headless_app_with_focus();
|
||||||
|
// Prime an Assets<Image> resource so we can mint stable handles
|
||||||
|
// for the synthetic thumbnail pair.
|
||||||
|
app.init_resource::<Assets<Image>>();
|
||||||
|
let (ace_handle, back_handle) = {
|
||||||
|
let mut images = app.world_mut().resource_mut::<Assets<Image>>();
|
||||||
|
let ace = images.add(Image::default());
|
||||||
|
let back = images.add(Image::default());
|
||||||
|
(ace, back)
|
||||||
|
};
|
||||||
|
// Inject one theme entry + a matching thumbnail pair.
|
||||||
|
app.insert_resource(ThemeRegistry {
|
||||||
|
entries: vec![ThemeEntry {
|
||||||
|
id: "test_theme".into(),
|
||||||
|
display_name: "Test Theme".into(),
|
||||||
|
manifest_url: "themes://test_theme/theme.ron".into(),
|
||||||
|
meta: crate::theme::ThemeMeta {
|
||||||
|
id: "test_theme".into(),
|
||||||
|
name: "Test Theme".into(),
|
||||||
|
author: "x".into(),
|
||||||
|
version: "x".into(),
|
||||||
|
card_aspect: (2, 3),
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
let mut cache = ThemeThumbnailCache::default();
|
||||||
|
cache.entries.insert(
|
||||||
|
"test_theme".into(),
|
||||||
|
ThemeThumbnailPair {
|
||||||
|
ace: ace_handle.clone(),
|
||||||
|
back: back_handle.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
app.insert_resource(cache);
|
||||||
|
|
||||||
|
// Open the panel and let the spawn + child-flush systems run.
|
||||||
|
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
|
||||||
|
app.update();
|
||||||
|
app.update();
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
// Find every ImageNode tagged with ThemeThumbnailMarker — the
|
||||||
|
// theme picker chip for "test_theme" must contribute exactly
|
||||||
|
// two of them (ace + back).
|
||||||
|
let thumbnail_count = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<&ImageNode, With<ThemeThumbnailMarker>>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count();
|
||||||
|
assert!(
|
||||||
|
thumbnail_count >= 2,
|
||||||
|
"expected at least one ace + back thumbnail (2 sprites); got {thumbnail_count}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Spot-check: at least one thumbnail's image handle matches one
|
||||||
|
// of the ones we inserted into the cache. This guards against a
|
||||||
|
// future refactor that accidentally clones the wrong handle.
|
||||||
|
let any_matches = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<&ImageNode, With<ThemeThumbnailMarker>>()
|
||||||
|
.iter(app.world())
|
||||||
|
.any(|node| node.image == ace_handle || node.image == back_handle);
|
||||||
|
assert!(
|
||||||
|
any_matches,
|
||||||
|
"at least one rendered thumbnail must reuse the cached handle"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Window geometry persistence
|
// Window geometry persistence
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|||||||
@@ -31,7 +31,10 @@ use solitaire_core::card::{Rank, Suit};
|
|||||||
pub use importer::{import_theme, import_theme_into, ImportError, ThemeId};
|
pub use importer::{import_theme, import_theme_into, ImportError, ThemeId};
|
||||||
pub use loader::{CardThemeLoader, CardThemeLoaderError};
|
pub use loader::{CardThemeLoader, CardThemeLoaderError};
|
||||||
pub use manifest::ThemeManifest;
|
pub use manifest::ThemeManifest;
|
||||||
pub use plugin::{set_theme, ActiveTheme, ThemePlugin};
|
pub use plugin::{
|
||||||
|
ensure_theme_thumbnails, set_theme, ActiveTheme, ThemePlugin, ThemeThumbnailCache,
|
||||||
|
ThemeThumbnailPair, THEME_THUMBNAIL_HEIGHT_PX, THEME_THUMBNAIL_WIDTH_PX,
|
||||||
|
};
|
||||||
pub use registry::{
|
pub use registry::{
|
||||||
build_registry, refresh_registry, ThemeEntry, ThemeRegistry, ThemeRegistryPlugin,
|
build_registry, refresh_registry, ThemeEntry, ThemeRegistry, ThemeRegistryPlugin,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,24 +8,82 @@
|
|||||||
//! exposed for tests and for any embedder that wants to load an
|
//! exposed for tests and for any embedder that wants to load an
|
||||||
//! alternative theme manually.
|
//! alternative theme manually.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use bevy::asset::AssetEvent;
|
use bevy::asset::AssetEvent;
|
||||||
use bevy::ecs::message::MessageReader;
|
use bevy::ecs::message::MessageReader;
|
||||||
|
use bevy::math::UVec2;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use solitaire_core::card::{Rank, Suit};
|
use solitaire_core::card::{Rank, Suit};
|
||||||
|
|
||||||
use crate::assets::DEFAULT_THEME_MANIFEST_URL;
|
use crate::assets::{
|
||||||
|
default_theme_svg_bytes, rasterize_svg, user_theme_dir, DEFAULT_THEME_MANIFEST_URL,
|
||||||
|
};
|
||||||
use crate::card_plugin::CardImageSet;
|
use crate::card_plugin::CardImageSet;
|
||||||
use crate::events::StateChangedEvent;
|
use crate::events::StateChangedEvent;
|
||||||
|
|
||||||
use super::loader::CardThemeLoader;
|
use super::loader::CardThemeLoader;
|
||||||
|
use super::registry::ThemeRegistry;
|
||||||
use super::{CardKey, CardTheme};
|
use super::{CardKey, CardTheme};
|
||||||
|
|
||||||
|
/// Width (logical px) of one Settings → Cosmetic theme-picker
|
||||||
|
/// thumbnail. A 2:3 card aspect at 100×140 keeps each chip a small
|
||||||
|
/// glanceable preview without bloating the picker row.
|
||||||
|
pub const THEME_THUMBNAIL_WIDTH_PX: u32 = 100;
|
||||||
|
/// Height counterpart to [`THEME_THUMBNAIL_WIDTH_PX`].
|
||||||
|
pub const THEME_THUMBNAIL_HEIGHT_PX: u32 = 140;
|
||||||
|
|
||||||
/// Resource pointing at the currently-active card theme. Populated on
|
/// Resource pointing at the currently-active card theme. Populated on
|
||||||
/// startup with the bundled default theme and replaced by [`set_theme`]
|
/// startup with the bundled default theme and replaced by [`set_theme`]
|
||||||
/// when the player switches.
|
/// when the player switches.
|
||||||
#[derive(Resource, Debug)]
|
#[derive(Resource, Debug)]
|
||||||
pub struct ActiveTheme(pub Handle<CardTheme>);
|
pub struct ActiveTheme(pub Handle<CardTheme>);
|
||||||
|
|
||||||
|
/// One pair of preview-sized `Handle<Image>` for the Settings picker:
|
||||||
|
/// the theme's Ace of Spades and its card back.
|
||||||
|
///
|
||||||
|
/// Either handle may be [`Handle::default`] when the underlying SVG
|
||||||
|
/// could not be located (e.g. a user theme that ships only a partial
|
||||||
|
/// set of files). The picker UI treats the default-handle case as
|
||||||
|
/// "render a placeholder swatch instead of an image" so a broken
|
||||||
|
/// theme can never crash the panel.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct ThemeThumbnailPair {
|
||||||
|
/// Rasterised `spades_ace.svg` of the theme.
|
||||||
|
pub ace: Handle<Image>,
|
||||||
|
/// Rasterised `back.svg` of the theme.
|
||||||
|
pub back: Handle<Image>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ThemeThumbnailPair {
|
||||||
|
/// Returns `true` only when *both* preview slots resolve to a
|
||||||
|
/// non-default handle — a theme with at least one missing SVG is
|
||||||
|
/// considered incomplete and renders the placeholder for the
|
||||||
|
/// missing slot.
|
||||||
|
pub fn is_fully_populated(&self) -> bool {
|
||||||
|
self.ace != Handle::default() && self.back != Handle::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resource caching one [`ThemeThumbnailPair`] per registered theme,
|
||||||
|
/// keyed by `ThemeMeta::id`.
|
||||||
|
///
|
||||||
|
/// Populated lazily by [`ensure_theme_thumbnails`] whenever the
|
||||||
|
/// [`ThemeRegistry`] grows or changes. The Settings panel reads from
|
||||||
|
/// this cache by id and falls back to the placeholder rendering path
|
||||||
|
/// when an entry is missing.
|
||||||
|
#[derive(Resource, Debug, Default)]
|
||||||
|
pub struct ThemeThumbnailCache {
|
||||||
|
pub entries: HashMap<String, ThemeThumbnailPair>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ThemeThumbnailCache {
|
||||||
|
/// Returns the cached pair for `theme_id`, if any.
|
||||||
|
pub fn get(&self, theme_id: &str) -> Option<&ThemeThumbnailPair> {
|
||||||
|
self.entries.get(theme_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Bevy plugin that loads the default theme and keeps `CardImageSet`
|
/// Bevy plugin that loads the default theme and keeps `CardImageSet`
|
||||||
/// in sync with `Assets<CardTheme>`.
|
/// in sync with `Assets<CardTheme>`.
|
||||||
///
|
///
|
||||||
@@ -45,6 +103,7 @@ pub struct ThemePlugin;
|
|||||||
impl Plugin for ThemePlugin {
|
impl Plugin for ThemePlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.init_asset::<CardTheme>()
|
app.init_asset::<CardTheme>()
|
||||||
|
.init_resource::<ThemeThumbnailCache>()
|
||||||
.register_asset_loader(crate::assets::SvgLoader)
|
.register_asset_loader(crate::assets::SvgLoader)
|
||||||
.register_asset_loader(CardThemeLoader)
|
.register_asset_loader(CardThemeLoader)
|
||||||
.add_systems(Startup, load_initial_theme)
|
.add_systems(Startup, load_initial_theme)
|
||||||
@@ -53,6 +112,7 @@ impl Plugin for ThemePlugin {
|
|||||||
(
|
(
|
||||||
sync_card_image_set_with_active_theme,
|
sync_card_image_set_with_active_theme,
|
||||||
react_to_settings_theme_change,
|
react_to_settings_theme_change,
|
||||||
|
ensure_theme_thumbnails,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -231,6 +291,104 @@ pub fn set_theme(
|
|||||||
handle
|
handle
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Picker-thumbnail generation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Filename of the canonical "preview face" SVG inside a theme — the
|
||||||
|
/// Ace of Spades. Matches `CardKey::manifest_name(Spades, Ace)` so the
|
||||||
|
/// path resolves the same way whether we're reading from disk or from
|
||||||
|
/// the bundled-default lookup table.
|
||||||
|
const PREVIEW_FACE_FILENAME: &str = "spades_ace.svg";
|
||||||
|
|
||||||
|
/// Filename of the back SVG inside a theme.
|
||||||
|
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".
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
let path = user_theme_dir().join(theme_id).join(filename);
|
||||||
|
std::fs::read(&path).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure helper: rasterises one SVG preview byte slice at the picker's
|
||||||
|
/// thumbnail dimensions, inserts the resulting `Image` into
|
||||||
|
/// `Assets<Image>`, and returns the new handle. Returns
|
||||||
|
/// [`Handle::default`] if rasterisation fails (malformed SVG, etc.) so
|
||||||
|
/// the picker can render a placeholder for broken themes without
|
||||||
|
/// crashing.
|
||||||
|
fn rasterize_preview_to_handle(
|
||||||
|
svg_bytes: &[u8],
|
||||||
|
images: &mut Assets<Image>,
|
||||||
|
) -> Handle<Image> {
|
||||||
|
let target = UVec2::new(THEME_THUMBNAIL_WIDTH_PX, THEME_THUMBNAIL_HEIGHT_PX);
|
||||||
|
match rasterize_svg(svg_bytes, target) {
|
||||||
|
Ok(image) => images.add(image),
|
||||||
|
Err(err) => {
|
||||||
|
warn!("theme thumbnail rasterise failed: {err}");
|
||||||
|
Handle::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a [`ThemeThumbnailPair`] for a single theme. Either handle
|
||||||
|
/// is [`Handle::default`] when the matching SVG could not be located
|
||||||
|
/// or rasterised.
|
||||||
|
fn generate_thumbnail_pair_for(
|
||||||
|
theme_id: &str,
|
||||||
|
images: &mut Assets<Image>,
|
||||||
|
) -> ThemeThumbnailPair {
|
||||||
|
let ace = read_theme_preview_svg_bytes(theme_id, PREVIEW_FACE_FILENAME)
|
||||||
|
.map(|b| rasterize_preview_to_handle(&b, images))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let back = read_theme_preview_svg_bytes(theme_id, PREVIEW_BACK_FILENAME)
|
||||||
|
.map(|b| rasterize_preview_to_handle(&b, images))
|
||||||
|
.unwrap_or_default();
|
||||||
|
ThemeThumbnailPair { ace, back }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// System that generates a [`ThemeThumbnailPair`] for every registered
|
||||||
|
/// theme that doesn't yet have one in [`ThemeThumbnailCache`].
|
||||||
|
///
|
||||||
|
/// Runs each frame but the early-exit check (`already cached?`) keeps
|
||||||
|
/// the steady-state cost to a single hash lookup per theme. Generation
|
||||||
|
/// itself only happens once per theme — the SVGs are rasterised and
|
||||||
|
/// inserted into `Assets<Image>` and the handles cached forever.
|
||||||
|
///
|
||||||
|
/// Lazy-on-first-pass beats Startup-only for two reasons:
|
||||||
|
///
|
||||||
|
/// - The `ThemeRegistry` is built by a different `Startup` system, and
|
||||||
|
/// Bevy doesn't guarantee inter-system Startup ordering without
|
||||||
|
/// explicit `.after()` chaining. Polling each Update tick removes
|
||||||
|
/// the dependency.
|
||||||
|
/// - The future `refresh_registry` path (used after a successful
|
||||||
|
/// theme import in Phase 7) adds entries mid-session — this system
|
||||||
|
/// picks them up automatically without any extra wiring.
|
||||||
|
pub fn ensure_theme_thumbnails(
|
||||||
|
registry: Option<Res<ThemeRegistry>>,
|
||||||
|
mut cache: ResMut<ThemeThumbnailCache>,
|
||||||
|
mut images: ResMut<Assets<Image>>,
|
||||||
|
) {
|
||||||
|
let Some(registry) = registry else { return };
|
||||||
|
for entry in registry.iter() {
|
||||||
|
if cache.entries.contains_key(&entry.id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let pair = generate_thumbnail_pair_for(&entry.id, &mut images);
|
||||||
|
cache.entries.insert(entry.id.clone(), pair);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -352,4 +510,120 @@ mod tests {
|
|||||||
let url2 = format!("themes://{}/theme.ron", "user_uploaded");
|
let url2 = format!("themes://{}/theme.ron", "user_uploaded");
|
||||||
assert_eq!(url2, "themes://user_uploaded/theme.ron");
|
assert_eq!(url2, "themes://user_uploaded/theme.ron");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Test 1: the bundled default theme always has embedded SVG bytes
|
||||||
|
/// available, so calling `generate_thumbnail_pair_for("default", …)`
|
||||||
|
/// must produce two non-default `Handle<Image>` slots.
|
||||||
|
#[test]
|
||||||
|
fn theme_thumbnails_generated_for_default_theme() {
|
||||||
|
let mut images = Assets::<Image>::default();
|
||||||
|
let pair = generate_thumbnail_pair_for("default", &mut images);
|
||||||
|
assert!(
|
||||||
|
pair.is_fully_populated(),
|
||||||
|
"default 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.
|
||||||
|
assert!(images.get(&pair.ace).is_some(), "ace image must be inserted");
|
||||||
|
assert!(images.get(&pair.back).is_some(), "back image must be inserted");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test 2: when a theme is registered but its preview SVGs are not
|
||||||
|
/// available on disk (a broken user-supplied theme), thumbnail
|
||||||
|
/// generation must NOT panic and must leave the missing slots as
|
||||||
|
/// the default handle so the picker UI can render its placeholder.
|
||||||
|
#[test]
|
||||||
|
fn theme_thumbnails_handle_missing_svg_gracefully() {
|
||||||
|
let mut images = Assets::<Image>::default();
|
||||||
|
// A theme id that definitely has no files on disk under the
|
||||||
|
// user_theme_dir (the directory may not even exist on a
|
||||||
|
// fresh test machine). The function reads the filesystem
|
||||||
|
// lazily and silently returns None on I/O failures — no
|
||||||
|
// panic, no rasterise attempt.
|
||||||
|
let pair = generate_thumbnail_pair_for(
|
||||||
|
"this-theme-does-not-exist-on-disk-for-testing",
|
||||||
|
&mut images,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
pair.ace,
|
||||||
|
Handle::default(),
|
||||||
|
"missing ace.svg must yield Handle::default placeholder"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
pair.back,
|
||||||
|
Handle::default(),
|
||||||
|
"missing back.svg must yield Handle::default placeholder"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!pair.is_fully_populated(),
|
||||||
|
"incomplete pair must report not-fully-populated"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `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.
|
||||||
|
#[test]
|
||||||
|
fn read_default_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"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
read_theme_preview_svg_bytes("default", PREVIEW_FACE_FILENAME).is_some(),
|
||||||
|
"default theme spades_ace.svg must be embedded"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `ensure_theme_thumbnails` is idempotent: calling it twice with
|
||||||
|
/// the same registry must not regenerate or replace already-cached
|
||||||
|
/// entries. This guards against the per-frame Update tick churning
|
||||||
|
/// new `Handle<Image>` allocations and growing `Assets<Image>`
|
||||||
|
/// without bound.
|
||||||
|
#[test]
|
||||||
|
fn ensure_theme_thumbnails_caches_after_first_run() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins);
|
||||||
|
app.init_resource::<Assets<Image>>();
|
||||||
|
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(),
|
||||||
|
meta: ThemeMeta {
|
||||||
|
id: "default".into(),
|
||||||
|
name: "Default".into(),
|
||||||
|
author: "x".into(),
|
||||||
|
version: "x".into(),
|
||||||
|
card_aspect: (2, 3),
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
app.add_systems(Update, ensure_theme_thumbnails);
|
||||||
|
|
||||||
|
// First tick generates the entry.
|
||||||
|
app.update();
|
||||||
|
let first_ace = app
|
||||||
|
.world()
|
||||||
|
.resource::<ThemeThumbnailCache>()
|
||||||
|
.get("default")
|
||||||
|
.map(|p| p.ace.clone())
|
||||||
|
.expect("default 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")
|
||||||
|
.map(|p| p.ace.clone())
|
||||||
|
.expect("default theme thumbnail must still exist");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
first_ace.id(),
|
||||||
|
second_ace.id(),
|
||||||
|
"cached thumbnail handle must be stable across ticks"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user