Merge pull request 'feat(engine): theme-store preview thumbnails (Phase H part 2)' (#179) from feat/theme-store-previews into master
This commit was merged in pull request #179.
This commit is contained in:
@@ -19,6 +19,10 @@ use thiserror::Error;
|
|||||||
/// so downloading it is pure waste.
|
/// so downloading it is pure waste.
|
||||||
pub const MAX_THEME_DOWNLOAD_BYTES: u64 = 20 * 1024 * 1024;
|
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`].
|
/// Errors surfaced by [`ThemeStoreClient`].
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum ThemeStoreError {
|
pub enum ThemeStoreError {
|
||||||
@@ -110,6 +114,42 @@ impl ThemeStoreClient {
|
|||||||
verify_archive(&bytes, entry)?;
|
verify_archive(&bytes, entry)?;
|
||||||
Ok(bytes.to_vec())
|
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
|
/// Checks downloaded `bytes` against the catalog `entry`'s declared
|
||||||
|
|||||||
@@ -49,10 +49,11 @@ use solitaire_core::klondike_adapter::foundation_from_slot;
|
|||||||
use solitaire_data::AnimSpeed;
|
use solitaire_data::AnimSpeed;
|
||||||
|
|
||||||
use crate::animation_plugin::CardAnim;
|
use crate::animation_plugin::CardAnim;
|
||||||
use crate::card_plugin::CardEntity;
|
use crate::card_animation::{MotionCurve, sample_curve};
|
||||||
|
use crate::card_plugin::{CardEntity, CardEntityIndex};
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
DrawRequestEvent, FoundationCompletedEvent, MoveRejectedEvent, MoveRequestEvent,
|
DrawRequestEvent, FoundationCompletedEvent, HintVisualEvent, MoveRejectedEvent,
|
||||||
NewGameRequestEvent,
|
MoveRequestEvent, NewGameRequestEvent, StateChangedEvent,
|
||||||
};
|
};
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::layout::LayoutResource;
|
use crate::layout::LayoutResource;
|
||||||
@@ -207,6 +208,8 @@ impl Plugin for FeedbackAnimPlugin {
|
|||||||
.add_message::<MoveRejectedEvent>()
|
.add_message::<MoveRejectedEvent>()
|
||||||
.add_message::<NewGameRequestEvent>()
|
.add_message::<NewGameRequestEvent>()
|
||||||
.add_message::<FoundationCompletedEvent>()
|
.add_message::<FoundationCompletedEvent>()
|
||||||
|
.add_message::<HintVisualEvent>()
|
||||||
|
.add_message::<StateChangedEvent>()
|
||||||
.add_message::<RequestRedraw>()
|
.add_message::<RequestRedraw>()
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
@@ -224,6 +227,20 @@ impl Plugin for FeedbackAnimPlugin {
|
|||||||
start_deal_anim.after(GameMutation),
|
start_deal_anim.after(GameMutation),
|
||||||
start_foundation_flourish.after(GameMutation),
|
start_foundation_flourish.after(GameMutation),
|
||||||
),
|
),
|
||||||
|
)
|
||||||
|
// Hint ghost (Phase H): the spawn reads card Transform/Sprite,
|
||||||
|
// so it orders after the board painters; the tick only touches
|
||||||
|
// ghost entities (Without<CardEntity>) and stays conflict-free.
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
(
|
||||||
|
spawn_hint_ghost
|
||||||
|
.after(GameMutation)
|
||||||
|
.after(crate::card_plugin::BoardVisuals),
|
||||||
|
tick_hint_ghosts,
|
||||||
|
despawn_hint_ghosts_on_state_change.after(GameMutation),
|
||||||
|
)
|
||||||
|
.chain(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -664,6 +681,147 @@ fn pile_cards(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Phase H — hint ghost-motion preview
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Duration of one ghost glide from the hinted card to its destination.
|
||||||
|
const HINT_GHOST_PASS_SECS: f32 = 0.7;
|
||||||
|
/// How many glides one hint plays before the ghost despawns. Two reads
|
||||||
|
/// as "this move, over there" without outstaying the 2 s static
|
||||||
|
/// highlight it accompanies.
|
||||||
|
const HINT_GHOST_PASSES: f32 = 2.0;
|
||||||
|
/// Ghost translucency — clearly a projection, never mistakable for the
|
||||||
|
/// real card.
|
||||||
|
const HINT_GHOST_ALPHA: f32 = 0.45;
|
||||||
|
/// Ghost render depth: above every settled pile (~1.04 max) and the
|
||||||
|
/// in-flight `CardAnim` lift (50), below a dragged card (500).
|
||||||
|
const HINT_GHOST_Z: f32 = 400.0;
|
||||||
|
|
||||||
|
/// A translucent copy of the hinted card gliding to the suggested
|
||||||
|
/// destination (Phase H). Purely decorative — despawned by timer, by a
|
||||||
|
/// newer hint, or by any state change.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct HintGhost {
|
||||||
|
start: Vec3,
|
||||||
|
target: Vec3,
|
||||||
|
elapsed: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalised progress of the current glide pass, restarting from the
|
||||||
|
/// source each pass. Pure for unit testing.
|
||||||
|
fn hint_ghost_pass_t(elapsed: f32) -> f32 {
|
||||||
|
(elapsed % HINT_GHOST_PASS_SECS) / HINT_GHOST_PASS_SECS
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ghost alpha at `pass_t` — full strength for most of the glide, then
|
||||||
|
/// fading over the last 20 % so the loop restart reads as a repeat
|
||||||
|
/// rather than a teleport. Pure for unit testing.
|
||||||
|
fn hint_ghost_alpha(pass_t: f32) -> f32 {
|
||||||
|
let fade_in_tail = ((pass_t - 0.8) / 0.2).clamp(0.0, 1.0);
|
||||||
|
HINT_GHOST_ALPHA * (1.0 - fade_in_tail)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns the ghost when a hint fires. The static highlights (source
|
||||||
|
/// card + gold destination pile) still spawn regardless; under
|
||||||
|
/// reduce-motion they are the whole story and no ghost appears
|
||||||
|
/// (`design-system.md` §Accessibility).
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn spawn_hint_ghost(
|
||||||
|
mut events: MessageReader<HintVisualEvent>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
|
index: Option<Res<CardEntityIndex>>,
|
||||||
|
layout: Option<Res<LayoutResource>>,
|
||||||
|
cards: Query<(&Transform, &Sprite), With<CardEntity>>,
|
||||||
|
existing: Query<Entity, With<HintGhost>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
if events.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if settings.is_some_and(|s| s.0.reduce_motion_mode) {
|
||||||
|
events.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let (Some(index), Some(layout)) = (index, layout) else {
|
||||||
|
events.clear();
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for ev in events.read() {
|
||||||
|
// A fresh hint replaces any ghost still in flight.
|
||||||
|
for entity in &existing {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
}
|
||||||
|
let Some(card_entity) = index.get(&ev.source_card) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Ok((transform, sprite)) = cards.get(card_entity) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(&dest) = layout.0.pile_positions.get(&ev.dest_pile) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let start = transform.translation.truncate().extend(HINT_GHOST_Z);
|
||||||
|
let mut ghost_sprite = sprite.clone();
|
||||||
|
ghost_sprite.color = ghost_sprite.color.with_alpha(HINT_GHOST_ALPHA);
|
||||||
|
commands.spawn((
|
||||||
|
HintGhost {
|
||||||
|
start,
|
||||||
|
target: dest.extend(HINT_GHOST_Z),
|
||||||
|
elapsed: 0.0,
|
||||||
|
},
|
||||||
|
ghost_sprite,
|
||||||
|
Transform::from_translation(start),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advances every ghost: eased glide per pass, tail fade, despawn after
|
||||||
|
/// [`HINT_GHOST_PASSES`]. Frozen while paused, like every other
|
||||||
|
/// decorative animation.
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
fn tick_hint_ghosts(
|
||||||
|
time: Res<Time>,
|
||||||
|
paused: Option<Res<PausedResource>>,
|
||||||
|
mut ghosts: Query<
|
||||||
|
(Entity, &mut HintGhost, &mut Transform, &mut Sprite),
|
||||||
|
(Without<CardEntity>, Without<PileMarker>),
|
||||||
|
>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
if paused.is_some_and(|p| p.0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let dt = time.delta_secs();
|
||||||
|
for (entity, mut ghost, mut transform, mut sprite) in ghosts.iter_mut() {
|
||||||
|
ghost.elapsed += dt;
|
||||||
|
if ghost.elapsed >= HINT_GHOST_PASS_SECS * HINT_GHOST_PASSES {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let pass_t = hint_ghost_pass_t(ghost.elapsed);
|
||||||
|
let eased = sample_curve(MotionCurve::SmoothSnap, pass_t);
|
||||||
|
transform.translation = ghost.start.lerp(ghost.target, eased);
|
||||||
|
sprite.color = sprite.color.with_alpha(hint_ghost_alpha(pass_t));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A ghost previews a move against the *current* board; the moment the
|
||||||
|
/// board changes (move, undo, draw, new game) it is stale and vanishes.
|
||||||
|
fn despawn_hint_ghosts_on_state_change(
|
||||||
|
mut events: MessageReader<StateChangedEvent>,
|
||||||
|
ghosts: Query<Entity, With<HintGhost>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
if events.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
events.clear();
|
||||||
|
for entity in &ghosts {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Unit tests (pure functions only — no Bevy world required)
|
// Unit tests (pure functions only — no Bevy world required)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -955,4 +1113,147 @@ mod tests {
|
|||||||
"FoundationFlourish must not be inserted under reduce-motion"
|
"FoundationFlourish must not be inserted under reduce-motion"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Phase H — hint ghost-motion preview
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hint_ghost_pass_t_restarts_each_pass() {
|
||||||
|
assert_eq!(hint_ghost_pass_t(0.0), 0.0);
|
||||||
|
// Just past one full pass, progress wraps back near zero.
|
||||||
|
assert!(hint_ghost_pass_t(HINT_GHOST_PASS_SECS + 0.01) < 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hint_ghost_alpha_fades_only_in_the_tail() {
|
||||||
|
assert_eq!(hint_ghost_alpha(0.0), HINT_GHOST_ALPHA);
|
||||||
|
assert_eq!(hint_ghost_alpha(0.79), HINT_GHOST_ALPHA);
|
||||||
|
assert!(hint_ghost_alpha(0.9) < HINT_GHOST_ALPHA);
|
||||||
|
assert!(hint_ghost_alpha(1.0).abs() < 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// App with one indexed card entity and a real layout, ready to
|
||||||
|
/// receive `HintVisualEvent`s.
|
||||||
|
fn ghost_app(reduce_motion: bool) -> (App, Card) {
|
||||||
|
use solitaire_core::{Deck, Rank, Suit};
|
||||||
|
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins)
|
||||||
|
.add_plugins(FeedbackAnimPlugin);
|
||||||
|
// Sibling systems in the plugin take GameStateResource
|
||||||
|
// non-optionally; give them a fresh deal.
|
||||||
|
app.insert_resource(GameStateResource(
|
||||||
|
solitaire_core::game_state::GameState::new(1, solitaire_core::DrawStockConfig::DrawOne),
|
||||||
|
));
|
||||||
|
app.insert_resource(SettingsResource(solitaire_data::Settings {
|
||||||
|
reduce_motion_mode: reduce_motion,
|
||||||
|
..Default::default()
|
||||||
|
}));
|
||||||
|
|
||||||
|
let card = Card::new(Deck::Deck1, Suit::Spades, Rank::Ace);
|
||||||
|
let entity = app
|
||||||
|
.world_mut()
|
||||||
|
.spawn((
|
||||||
|
CardEntity { card: card.clone() },
|
||||||
|
Sprite::default(),
|
||||||
|
Transform::from_xyz(-100.0, 40.0, 1.0),
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
let mut index = CardEntityIndex::default();
|
||||||
|
index.0.insert(card.clone(), entity);
|
||||||
|
app.insert_resource(index);
|
||||||
|
app.insert_resource(LayoutResource(crate::layout::compute_layout(
|
||||||
|
Vec2::new(1280.0, 720.0),
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
true,
|
||||||
|
)));
|
||||||
|
app.update();
|
||||||
|
(app, card)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fire_hint(app: &mut App, card: Card) {
|
||||||
|
use solitaire_core::Tableau;
|
||||||
|
app.world_mut().write_message(HintVisualEvent {
|
||||||
|
source_card: card,
|
||||||
|
dest_pile: KlondikePile::Tableau(Tableau::Tableau2),
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ghost_count(app: &mut App) -> usize {
|
||||||
|
app.world_mut()
|
||||||
|
.query::<&HintGhost>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hint_event_spawns_one_ghost_at_the_source_card() {
|
||||||
|
let (mut app, card) = ghost_app(false);
|
||||||
|
fire_hint(&mut app, card);
|
||||||
|
|
||||||
|
assert_eq!(ghost_count(&mut app), 1, "hint must spawn one ghost");
|
||||||
|
let transform = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<&Transform, With<HintGhost>>()
|
||||||
|
.single(app.world())
|
||||||
|
.expect("ghost transform");
|
||||||
|
// The tick system may have advanced the ghost by one real-time
|
||||||
|
// frame already; assert it is still essentially at the source.
|
||||||
|
assert!(
|
||||||
|
transform
|
||||||
|
.translation
|
||||||
|
.truncate()
|
||||||
|
.distance(Vec2::new(-100.0, 40.0))
|
||||||
|
< 5.0,
|
||||||
|
"ghost must start at the hinted card, got {:?}",
|
||||||
|
transform.translation
|
||||||
|
);
|
||||||
|
assert_eq!(transform.translation.z, HINT_GHOST_Z);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hint_ghost_suppressed_under_reduce_motion() {
|
||||||
|
let (mut app, card) = ghost_app(true);
|
||||||
|
fire_hint(&mut app, card);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
ghost_count(&mut app),
|
||||||
|
0,
|
||||||
|
"no ghost may spawn under reduce-motion; the static highlight is the whole story"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn state_change_despawns_the_ghost() {
|
||||||
|
let (mut app, card) = ghost_app(false);
|
||||||
|
fire_hint(&mut app, card);
|
||||||
|
assert_eq!(ghost_count(&mut app), 1);
|
||||||
|
|
||||||
|
app.world_mut().write_message(StateChangedEvent);
|
||||||
|
app.update();
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
ghost_count(&mut app),
|
||||||
|
0,
|
||||||
|
"a board change makes the previewed move stale — the ghost must vanish"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn second_hint_replaces_the_first_ghost() {
|
||||||
|
let (mut app, card) = ghost_app(false);
|
||||||
|
fire_hint(&mut app, card.clone());
|
||||||
|
fire_hint(&mut app, card);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
ghost_count(&mut app),
|
||||||
|
1,
|
||||||
|
"re-hinting must replace the in-flight ghost, not stack a second"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,9 @@
|
|||||||
//! filesystem-based; the plugin is gated out on wasm32 alongside
|
//! filesystem-based; the plugin is gated out on wasm32 alongside
|
||||||
//! `SyncPlugin`.
|
//! `SyncPlugin`.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use bevy::asset::RenderAssetUsages;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
|
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
@@ -105,6 +108,21 @@ struct InstallTask(Option<(String, Task<InstallResult>)>);
|
|||||||
#[derive(Resource, Default)]
|
#[derive(Resource, Default)]
|
||||||
struct StoreBaseUrl(Option<String>);
|
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
|
// Plugin
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -118,6 +136,8 @@ impl Plugin for ThemeStorePlugin {
|
|||||||
.init_resource::<CatalogTask>()
|
.init_resource::<CatalogTask>()
|
||||||
.init_resource::<InstallTask>()
|
.init_resource::<InstallTask>()
|
||||||
.init_resource::<StoreBaseUrl>()
|
.init_resource::<StoreBaseUrl>()
|
||||||
|
.init_resource::<PreviewCache>()
|
||||||
|
.init_resource::<PreviewTasks>()
|
||||||
// Esc-close reads keyboard input; register defensively so
|
// Esc-close reads keyboard input; register defensively so
|
||||||
// the plugin works under MinimalPlugins in tests.
|
// the plugin works under MinimalPlugins in tests.
|
||||||
.init_resource::<ButtonInput<KeyCode>>()
|
.init_resource::<ButtonInput<KeyCode>>()
|
||||||
@@ -129,6 +149,7 @@ impl Plugin for ThemeStorePlugin {
|
|||||||
(
|
(
|
||||||
handle_open_request,
|
handle_open_request,
|
||||||
poll_catalog_task,
|
poll_catalog_task,
|
||||||
|
poll_preview_tasks,
|
||||||
handle_install_buttons,
|
handle_install_buttons,
|
||||||
poll_install_task,
|
poll_install_task,
|
||||||
handle_close_button,
|
handle_close_button,
|
||||||
@@ -176,6 +197,7 @@ fn handle_open_request(
|
|||||||
mut catalog_task: ResMut<CatalogTask>,
|
mut catalog_task: ResMut<CatalogTask>,
|
||||||
mut store_base: ResMut<StoreBaseUrl>,
|
mut store_base: ResMut<StoreBaseUrl>,
|
||||||
mut warning_toast: MessageWriter<WarningToastEvent>,
|
mut warning_toast: MessageWriter<WarningToastEvent>,
|
||||||
|
previews: Res<PreviewCache>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
font_res: Option<Res<FontResource>>,
|
font_res: Option<Res<FontResource>>,
|
||||||
) {
|
) {
|
||||||
@@ -207,16 +229,28 @@ fn handle_open_request(
|
|||||||
rt.block_on(async { ThemeStoreClient::new(base_url).fetch_catalog().await })
|
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.
|
/// rebuilds the modal if it is still open.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn poll_catalog_task(
|
fn poll_catalog_task(
|
||||||
mut catalog_task: ResMut<CatalogTask>,
|
mut catalog_task: ResMut<CatalogTask>,
|
||||||
mut catalog_state: ResMut<CatalogState>,
|
mut catalog_state: ResMut<CatalogState>,
|
||||||
screens: Query<Entity, With<ThemeStoreScreen>>,
|
screens: Query<Entity, With<ThemeStoreScreen>>,
|
||||||
registry: Option<Res<ThemeRegistry>>,
|
registry: Option<Res<ThemeRegistry>>,
|
||||||
|
rt: Option<Res<TokioRuntimeResource>>,
|
||||||
|
store_base: Res<StoreBaseUrl>,
|
||||||
|
previews: Res<PreviewCache>,
|
||||||
|
mut preview_tasks: ResMut<PreviewTasks>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
font_res: Option<Res<FontResource>>,
|
font_res: Option<Res<FontResource>>,
|
||||||
) {
|
) {
|
||||||
@@ -229,7 +263,29 @@ fn poll_catalog_task(
|
|||||||
catalog_task.0 = None;
|
catalog_task.0 = None;
|
||||||
|
|
||||||
*catalog_state = match result {
|
*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) => {
|
Err(e) => {
|
||||||
warn!("theme store: catalog fetch failed: {e}");
|
warn!("theme store: catalog fetch failed: {e}");
|
||||||
CatalogState::Error(e.to_string())
|
CatalogState::Error(e.to_string())
|
||||||
@@ -241,10 +297,69 @@ fn poll_catalog_task(
|
|||||||
&mut commands,
|
&mut commands,
|
||||||
&catalog_state,
|
&catalog_state,
|
||||||
registry.as_deref(),
|
registry.as_deref(),
|
||||||
|
&previews,
|
||||||
font_res.as_deref(),
|
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.
|
/// Starts a download + import task when an Install button is pressed.
|
||||||
/// One install at a time; repeat clicks while busy are ignored.
|
/// One install at a time; repeat clicks while busy are ignored.
|
||||||
fn handle_install_buttons(
|
fn handle_install_buttons(
|
||||||
@@ -308,6 +423,7 @@ fn poll_install_task(
|
|||||||
screens: Query<Entity, With<ThemeStoreScreen>>,
|
screens: Query<Entity, With<ThemeStoreScreen>>,
|
||||||
mut info_toast: MessageWriter<InfoToastEvent>,
|
mut info_toast: MessageWriter<InfoToastEvent>,
|
||||||
mut warning_toast: MessageWriter<WarningToastEvent>,
|
mut warning_toast: MessageWriter<WarningToastEvent>,
|
||||||
|
previews: Res<PreviewCache>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
font_res: Option<Res<FontResource>>,
|
font_res: Option<Res<FontResource>>,
|
||||||
) {
|
) {
|
||||||
@@ -343,6 +459,7 @@ fn poll_install_task(
|
|||||||
&mut commands,
|
&mut commands,
|
||||||
&catalog_state,
|
&catalog_state,
|
||||||
registry.as_deref(),
|
registry.as_deref(),
|
||||||
|
&previews,
|
||||||
font_res.as_deref(),
|
font_res.as_deref(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -378,11 +495,12 @@ fn rebuild_open_modal(
|
|||||||
commands: &mut Commands,
|
commands: &mut Commands,
|
||||||
catalog_state: &CatalogState,
|
catalog_state: &CatalogState,
|
||||||
registry: Option<&ThemeRegistry>,
|
registry: Option<&ThemeRegistry>,
|
||||||
|
previews: &PreviewCache,
|
||||||
font_res: Option<&FontResource>,
|
font_res: Option<&FontResource>,
|
||||||
) {
|
) {
|
||||||
for entity in screens {
|
for entity in screens {
|
||||||
commands.entity(entity).despawn();
|
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,
|
commands: &mut Commands,
|
||||||
catalog_state: &CatalogState,
|
catalog_state: &CatalogState,
|
||||||
registry: Option<&ThemeRegistry>,
|
registry: Option<&ThemeRegistry>,
|
||||||
|
previews: &PreviewCache,
|
||||||
font_res: Option<&FontResource>,
|
font_res: Option<&FontResource>,
|
||||||
) {
|
) {
|
||||||
let body_font = TextFont {
|
let body_font = TextFont {
|
||||||
@@ -433,7 +552,15 @@ fn spawn_store_modal(
|
|||||||
for entry in entries {
|
for entry in entries {
|
||||||
let installed =
|
let installed =
|
||||||
registry.is_some_and(|registry| registry.find(&entry.id).is_some());
|
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);
|
commands.entity(scrim).insert(ScrimDismissible);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One catalog row: name + author/size caption on the left, Install
|
/// Height of a row's preview thumbnail in logical pixels; the width
|
||||||
/// button (or "Installed" caption) on the right.
|
/// 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(
|
fn spawn_store_row(
|
||||||
parent: &mut ChildSpawnerCommands,
|
parent: &mut ChildSpawnerCommands,
|
||||||
entry: &ThemeCatalogEntry,
|
entry: &ThemeCatalogEntry,
|
||||||
installed: bool,
|
installed: bool,
|
||||||
|
preview: Option<&Handle<Image>>,
|
||||||
body_font: &TextFont,
|
body_font: &TextFont,
|
||||||
caption_font: &TextFont,
|
caption_font: &TextFont,
|
||||||
font_res: Option<&FontResource>,
|
font_res: Option<&FontResource>,
|
||||||
@@ -477,25 +612,45 @@ fn spawn_store_row(
|
|||||||
))
|
))
|
||||||
.with_children(|row| {
|
.with_children(|row| {
|
||||||
row.spawn(Node {
|
row.spawn(Node {
|
||||||
flex_direction: FlexDirection::Column,
|
flex_direction: FlexDirection::Row,
|
||||||
row_gap: VAL_SPACE_2,
|
align_items: AlignItems::Center,
|
||||||
|
column_gap: VAL_SPACE_3,
|
||||||
..default()
|
..default()
|
||||||
})
|
})
|
||||||
.with_children(|col| {
|
.with_children(|left| {
|
||||||
col.spawn((
|
if let Some(handle) = preview {
|
||||||
Text::new(entry.name.clone()),
|
let (aw, ah) = entry.card_aspect;
|
||||||
body_font.clone(),
|
let width = PREVIEW_THUMB_HEIGHT_PX * aw.max(1) as f32 / ah.max(1) as f32;
|
||||||
TextColor(TEXT_PRIMARY),
|
left.spawn((
|
||||||
));
|
ImageNode::new(handle.clone()),
|
||||||
col.spawn((
|
Node {
|
||||||
Text::new(format!(
|
width: Val::Px(width),
|
||||||
"by {} — {} KB",
|
height: Val::Px(PREVIEW_THUMB_HEIGHT_PX),
|
||||||
entry.author,
|
..default()
|
||||||
entry.size_bytes.div_ceil(1024)
|
},
|
||||||
)),
|
));
|
||||||
caption_font.clone(),
|
}
|
||||||
TextColor(TEXT_SECONDARY),
|
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 {
|
if installed {
|
||||||
row.spawn((
|
row.spawn((
|
||||||
@@ -661,4 +816,85 @@ mod tests {
|
|||||||
));
|
));
|
||||||
assert_eq!(screen_count(&mut app), 1);
|
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