diff --git a/solitaire_data/src/theme_store_client.rs b/solitaire_data/src/theme_store_client.rs index a84e15d..a518968 100644 --- a/solitaire_data/src/theme_store_client.rs +++ b/solitaire_data/src/theme_store_client.rs @@ -19,6 +19,10 @@ use thiserror::Error; /// so downloading it is pure waste. pub const MAX_THEME_DOWNLOAD_BYTES: u64 = 20 * 1024 * 1024; +/// Hard cap on a preview PNG download — previews are small decorative +/// thumbnails; anything past this is a misconfigured server. +pub const MAX_PREVIEW_BYTES: u64 = 512 * 1024; + /// Errors surfaced by [`ThemeStoreClient`]. #[derive(Debug, Error)] pub enum ThemeStoreError { @@ -110,6 +114,42 @@ impl ThemeStoreClient { verify_archive(&bytes, entry)?; Ok(bytes.to_vec()) } + + /// Fetch the preview PNG the catalog advertises for `entry`. + /// + /// Returns `Http(404)` when the entry carries no `preview_url` — + /// the same shape the server answers with when the file is absent, + /// so callers only handle one "no preview" case. Previews are + /// decorative; unlike archives they carry no checksum, only the + /// [`MAX_PREVIEW_BYTES`] size cap. + pub async fn fetch_preview( + &self, + entry: &ThemeCatalogEntry, + ) -> Result, ThemeStoreError> { + let Some(path) = entry.preview_url.as_deref() else { + return Err(ThemeStoreError::Http(404)); + }; + let resp = self + .client + .get(format!("{}{}", self.base_url, path)) + .send() + .await + .map_err(|e| ThemeStoreError::Network(e.to_string()))?; + if !resp.status().is_success() { + return Err(ThemeStoreError::Http(resp.status().as_u16())); + } + let bytes = resp + .bytes() + .await + .map_err(|e| ThemeStoreError::Network(e.to_string()))?; + if bytes.len() as u64 > MAX_PREVIEW_BYTES { + return Err(ThemeStoreError::Oversized { + expected: MAX_PREVIEW_BYTES, + got: bytes.len() as u64, + }); + } + Ok(bytes.to_vec()) + } } /// Checks downloaded `bytes` against the catalog `entry`'s declared diff --git a/solitaire_engine/src/theme_store_plugin.rs b/solitaire_engine/src/theme_store_plugin.rs index 49645c5..a635854 100644 --- a/solitaire_engine/src/theme_store_plugin.rs +++ b/solitaire_engine/src/theme_store_plugin.rs @@ -14,6 +14,9 @@ //! filesystem-based; the plugin is gated out on wasm32 alongside //! `SyncPlugin`. +use std::collections::HashMap; + +use bevy::asset::RenderAssetUsages; use bevy::prelude::*; use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future}; use thiserror::Error; @@ -105,6 +108,21 @@ struct InstallTask(Option<(String, Task)>); #[derive(Resource, Default)] struct StoreBaseUrl(Option); +/// Decoded preview thumbnails by theme id (Phase H). Session-lifetime: +/// previews survive closing and reopening the store, so a revisit +/// renders instantly without refetching. +#[derive(Resource, Default)] +struct PreviewCache(HashMap>); + +/// One in-flight preview download: raw PNG bytes or the fetch error. +type PreviewFetch = Task, ThemeStoreError>>; + +/// In-flight preview downloads, one per catalog entry that advertises +/// a `preview_url` not already in [`PreviewCache`]. `String` is the +/// theme id the bytes belong to. +#[derive(Resource, Default)] +struct PreviewTasks(Vec<(String, PreviewFetch)>); + // --------------------------------------------------------------------------- // Plugin // --------------------------------------------------------------------------- @@ -118,6 +136,8 @@ impl Plugin for ThemeStorePlugin { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() + .init_resource::() // Esc-close reads keyboard input; register defensively so // the plugin works under MinimalPlugins in tests. .init_resource::>() @@ -129,6 +149,7 @@ impl Plugin for ThemeStorePlugin { ( handle_open_request, poll_catalog_task, + poll_preview_tasks, handle_install_buttons, poll_install_task, handle_close_button, @@ -176,6 +197,7 @@ fn handle_open_request( mut catalog_task: ResMut, mut store_base: ResMut, mut warning_toast: MessageWriter, + previews: Res, mut commands: Commands, font_res: Option>, ) { @@ -207,16 +229,28 @@ fn handle_open_request( rt.block_on(async { ThemeStoreClient::new(base_url).fetch_catalog().await }) })); - spawn_store_modal(&mut commands, &catalog_state, None, font_res.as_deref()); + spawn_store_modal( + &mut commands, + &catalog_state, + None, + &previews, + font_res.as_deref(), + ); } -/// Polls the catalog fetch; on completion updates [`CatalogState`] and +/// Polls the catalog fetch; on completion updates [`CatalogState`], +/// kicks off preview downloads for entries not yet in the cache, and /// rebuilds the modal if it is still open. +#[allow(clippy::too_many_arguments)] fn poll_catalog_task( mut catalog_task: ResMut, mut catalog_state: ResMut, screens: Query>, registry: Option>, + rt: Option>, + store_base: Res, + previews: Res, + mut preview_tasks: ResMut, mut commands: Commands, font_res: Option>, ) { @@ -229,7 +263,29 @@ fn poll_catalog_task( catalog_task.0 = None; *catalog_state = match result { - Ok(entries) => CatalogState::Loaded(entries), + Ok(entries) => { + // Fetch previews for anything new. Decorative — failures + // just leave the row text-only, so errors only log. + if let (Some(rt), Some(base_url)) = (rt.as_ref(), store_base.0.as_deref()) { + for entry in entries + .iter() + .filter(|e| e.preview_url.is_some() && !previews.0.contains_key(&e.id)) + { + let rt = rt.0.clone(); + let base_url = base_url.to_owned(); + let entry = entry.clone(); + preview_tasks.0.push(( + entry.id.clone(), + AsyncComputeTaskPool::get().spawn(async move { + rt.block_on(async { + ThemeStoreClient::new(base_url).fetch_preview(&entry).await + }) + }), + )); + } + } + CatalogState::Loaded(entries) + } Err(e) => { warn!("theme store: catalog fetch failed: {e}"); CatalogState::Error(e.to_string()) @@ -241,10 +297,69 @@ fn poll_catalog_task( &mut commands, &catalog_state, registry.as_deref(), + &previews, font_res.as_deref(), ); } +/// Polls in-flight preview downloads. Each finished PNG is decoded into +/// an [`Image`] asset and cached by theme id; the open modal rebuilds +/// once per frame that added at least one preview, so thumbnails pop in +/// as they arrive. Failures log and leave the row text-only. Skipped +/// entirely when `Assets` is absent (`MinimalPlugins` tests). +#[allow(clippy::too_many_arguments)] +fn poll_preview_tasks( + mut preview_tasks: ResMut, + mut previews: ResMut, + images: Option>>, + catalog_state: Res, + screens: Query>, + registry: Option>, + mut commands: Commands, + font_res: Option>, +) { + if preview_tasks.0.is_empty() { + return; + } + let Some(mut images) = images else { + return; + }; + + let mut added = false; + preview_tasks.0.retain_mut(|(id, task)| { + let Some(result) = future::block_on(future::poll_once(task)) else { + return true; // still downloading + }; + match result { + Ok(bytes) => match image::load_from_memory(&bytes) { + Ok(dyn_img) => { + let handle = images.add(Image::from_dynamic( + dyn_img, + true, + RenderAssetUsages::RENDER_WORLD, + )); + previews.0.insert(id.clone(), handle); + added = true; + } + Err(e) => warn!("theme store: preview for '{id}' failed to decode: {e}"), + }, + Err(e) => warn!("theme store: preview fetch for '{id}' failed: {e}"), + } + false + }); + + if added { + rebuild_open_modal( + &screens, + &mut commands, + &catalog_state, + registry.as_deref(), + &previews, + font_res.as_deref(), + ); + } +} + /// Starts a download + import task when an Install button is pressed. /// One install at a time; repeat clicks while busy are ignored. fn handle_install_buttons( @@ -308,6 +423,7 @@ fn poll_install_task( screens: Query>, mut info_toast: MessageWriter, mut warning_toast: MessageWriter, + previews: Res, mut commands: Commands, font_res: Option>, ) { @@ -343,6 +459,7 @@ fn poll_install_task( &mut commands, &catalog_state, registry.as_deref(), + &previews, font_res.as_deref(), ); } @@ -378,11 +495,12 @@ fn rebuild_open_modal( commands: &mut Commands, catalog_state: &CatalogState, registry: Option<&ThemeRegistry>, + previews: &PreviewCache, font_res: Option<&FontResource>, ) { for entity in screens { commands.entity(entity).despawn(); - spawn_store_modal(commands, catalog_state, registry, font_res); + spawn_store_modal(commands, catalog_state, registry, previews, font_res); } } @@ -391,6 +509,7 @@ fn spawn_store_modal( commands: &mut Commands, catalog_state: &CatalogState, registry: Option<&ThemeRegistry>, + previews: &PreviewCache, font_res: Option<&FontResource>, ) { let body_font = TextFont { @@ -433,7 +552,15 @@ fn spawn_store_modal( for entry in entries { let installed = registry.is_some_and(|registry| registry.find(&entry.id).is_some()); - spawn_store_row(card, entry, installed, &body_font, &caption_font, font_res); + spawn_store_row( + card, + entry, + installed, + previews.0.get(&entry.id), + &body_font, + &caption_font, + font_res, + ); } } } @@ -452,12 +579,20 @@ fn spawn_store_modal( commands.entity(scrim).insert(ScrimDismissible); } -/// One catalog row: name + author/size caption on the left, Install -/// button (or "Installed" caption) on the right. +/// Height of a row's preview thumbnail in logical pixels; the width +/// follows the theme's own `card_aspect` so a preview is never +/// stretched. +const PREVIEW_THUMB_HEIGHT_PX: f32 = 72.0; + +/// One catalog row: preview thumbnail (when downloaded) + name + +/// author/size caption on the left, Install button (or "Installed" +/// caption) on the right. +#[allow(clippy::too_many_arguments)] fn spawn_store_row( parent: &mut ChildSpawnerCommands, entry: &ThemeCatalogEntry, installed: bool, + preview: Option<&Handle>, body_font: &TextFont, caption_font: &TextFont, font_res: Option<&FontResource>, @@ -477,25 +612,45 @@ fn spawn_store_row( )) .with_children(|row| { row.spawn(Node { - flex_direction: FlexDirection::Column, - row_gap: VAL_SPACE_2, + flex_direction: FlexDirection::Row, + align_items: AlignItems::Center, + column_gap: VAL_SPACE_3, ..default() }) - .with_children(|col| { - col.spawn(( - Text::new(entry.name.clone()), - body_font.clone(), - TextColor(TEXT_PRIMARY), - )); - col.spawn(( - Text::new(format!( - "by {} — {} KB", - entry.author, - entry.size_bytes.div_ceil(1024) - )), - caption_font.clone(), - TextColor(TEXT_SECONDARY), - )); + .with_children(|left| { + if let Some(handle) = preview { + let (aw, ah) = entry.card_aspect; + let width = PREVIEW_THUMB_HEIGHT_PX * aw.max(1) as f32 / ah.max(1) as f32; + left.spawn(( + ImageNode::new(handle.clone()), + Node { + width: Val::Px(width), + height: Val::Px(PREVIEW_THUMB_HEIGHT_PX), + ..default() + }, + )); + } + left.spawn(Node { + flex_direction: FlexDirection::Column, + row_gap: VAL_SPACE_2, + ..default() + }) + .with_children(|col| { + col.spawn(( + Text::new(entry.name.clone()), + body_font.clone(), + TextColor(TEXT_PRIMARY), + )); + col.spawn(( + Text::new(format!( + "by {} — {} KB", + entry.author, + entry.size_bytes.div_ceil(1024) + )), + caption_font.clone(), + TextColor(TEXT_SECONDARY), + )); + }); }); if installed { row.spawn(( @@ -661,4 +816,85 @@ mod tests { )); assert_eq!(screen_count(&mut app), 1); } + + // ----------------------------------------------------------------------- + // Phase H part 2: preview thumbnails + // ----------------------------------------------------------------------- + + fn catalog_entry(id: &str, preview: bool) -> ThemeCatalogEntry { + ThemeCatalogEntry { + id: id.into(), + name: id.into(), + author: "Test".into(), + version: "1.0.0".into(), + card_aspect: (2, 3), + size_bytes: 1024, + sha256: "00".into(), + download_url: format!("/api/themes/{id}/download"), + preview_url: preview.then(|| format!("/api/themes/{id}/preview")), + } + } + + /// A row whose theme id is in the [`PreviewCache`] renders an + /// `ImageNode` thumbnail; a row without one stays text-only. + #[test] + fn loaded_row_shows_preview_thumbnail_only_when_cached() { + let mut app = headless_app(); + let cached = catalog_entry("with-preview", true); + let uncached = catalog_entry("without-preview", false); + + let mut cache = PreviewCache::default(); + cache + .0 + .insert(cached.id.clone(), Handle::::default()); + let state = CatalogState::Loaded(vec![cached, uncached]); + + { + let world = app.world_mut(); + let mut commands = world.commands(); + spawn_store_modal(&mut commands, &state, None, &cache, None); + } + app.update(); + + let thumbs = app + .world_mut() + .query::<&ImageNode>() + .iter(app.world()) + .count(); + assert_eq!( + thumbs, 1, + "exactly the cached entry must render a preview thumbnail" + ); + } + + /// The thumbnail width follows the theme's own card aspect so a + /// preview never renders stretched. + #[test] + fn preview_thumbnail_width_follows_card_aspect() { + let mut app = headless_app(); + let mut entry = catalog_entry("wide", true); + entry.card_aspect = (1, 1); // square theme art + let mut cache = PreviewCache::default(); + cache.0.insert(entry.id.clone(), Handle::::default()); + let state = CatalogState::Loaded(vec![entry]); + + { + let world = app.world_mut(); + let mut commands = world.commands(); + spawn_store_modal(&mut commands, &state, None, &cache, None); + } + app.update(); + + let node = app + .world_mut() + .query_filtered::<&Node, With>() + .single(app.world()) + .expect("thumbnail node must exist"); + assert_eq!(node.height, Val::Px(PREVIEW_THUMB_HEIGHT_PX)); + assert_eq!( + node.width, + Val::Px(PREVIEW_THUMB_HEIGHT_PX), + "a 1:1 aspect must yield a square thumbnail" + ); + } }