feat(engine): theme-store preview thumbnails (Phase H part 2) #179
@@ -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<Vec<u8>, 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
|
||||
|
||||
@@ -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<InstallResult>)>);
|
||||
#[derive(Resource, Default)]
|
||||
struct StoreBaseUrl(Option<String>);
|
||||
|
||||
/// 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<String, Handle<Image>>);
|
||||
|
||||
/// One in-flight preview download: raw PNG bytes or the fetch error.
|
||||
type PreviewFetch = Task<Result<Vec<u8>, 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::<CatalogTask>()
|
||||
.init_resource::<InstallTask>()
|
||||
.init_resource::<StoreBaseUrl>()
|
||||
.init_resource::<PreviewCache>()
|
||||
.init_resource::<PreviewTasks>()
|
||||
// Esc-close reads keyboard input; register defensively so
|
||||
// the plugin works under MinimalPlugins in tests.
|
||||
.init_resource::<ButtonInput<KeyCode>>()
|
||||
@@ -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<CatalogTask>,
|
||||
mut store_base: ResMut<StoreBaseUrl>,
|
||||
mut warning_toast: MessageWriter<WarningToastEvent>,
|
||||
previews: Res<PreviewCache>,
|
||||
mut commands: Commands,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
) {
|
||||
@@ -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<CatalogTask>,
|
||||
mut catalog_state: ResMut<CatalogState>,
|
||||
screens: Query<Entity, With<ThemeStoreScreen>>,
|
||||
registry: Option<Res<ThemeRegistry>>,
|
||||
rt: Option<Res<TokioRuntimeResource>>,
|
||||
store_base: Res<StoreBaseUrl>,
|
||||
previews: Res<PreviewCache>,
|
||||
mut preview_tasks: ResMut<PreviewTasks>,
|
||||
mut commands: Commands,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
) {
|
||||
@@ -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<Image>` is absent (`MinimalPlugins` tests).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn poll_preview_tasks(
|
||||
mut preview_tasks: ResMut<PreviewTasks>,
|
||||
mut previews: ResMut<PreviewCache>,
|
||||
images: Option<ResMut<Assets<Image>>>,
|
||||
catalog_state: Res<CatalogState>,
|
||||
screens: Query<Entity, With<ThemeStoreScreen>>,
|
||||
registry: Option<Res<ThemeRegistry>>,
|
||||
mut commands: Commands,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
) {
|
||||
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<Entity, With<ThemeStoreScreen>>,
|
||||
mut info_toast: MessageWriter<InfoToastEvent>,
|
||||
mut warning_toast: MessageWriter<WarningToastEvent>,
|
||||
previews: Res<PreviewCache>,
|
||||
mut commands: Commands,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
) {
|
||||
@@ -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<Image>>,
|
||||
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::<Image>::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::<Image>::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<ImageNode>>()
|
||||
.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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user