Compare commits

...

5 Commits

Author SHA1 Message Date
funman300 9a0d6496c5 docs(changelog): cut 0.46.0 — Phase H feedback polish
Android Release / build-apk (push) Successful in 5m36s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 17:50:29 -07:00
funman300 7669a1bb56 Merge pull request 'feat(engine): theme-store preview thumbnails (Phase H part 2)' (#179) from feat/theme-store-previews into master
Test / fmt (push) Successful in 4s
Test / test (push) Successful in 4m41s
Build and Deploy / build-and-push (push) Successful in 8m56s
Web E2E / web-e2e (push) Successful in 7m33s
2026-07-14 00:46:46 +00:00
funman300 ddee605874 feat(engine): hint ghost-motion preview (Phase H)
Test / fmt (pull_request) Successful in 4s
Test / test (pull_request) Successful in 4m20s
A hint now spawns a translucent copy of the hinted card that glides to
the suggested destination twice (0.7s per pass, SmoothSnap easing, tail
fade so the loop reads as a repeat), alongside the existing static
source/destination highlights. Auto-disabled under reduce-motion — the
static highlights remain the whole story. The ghost despawns on timer,
on a fresh hint, or the moment the board changes (a preview of a stale
board is worse than none).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 17:40:53 -07:00
funman300 ae8118d7df feat(engine): theme-store preview thumbnails (Phase H part 2)
Test / fmt (pull_request) Successful in 4s
Test / test (pull_request) Successful in 4m36s
The live catalog has advertised preview_url per theme since the store
shipped, but the client never fetched it — rows were text-only. The
modal now downloads each advertised preview PNG off the main thread
(same AsyncComputeTaskPool + Tokio pattern as the catalog fetch and
avatar image), decodes it into an Image asset, and renders a thumbnail
sized by the theme's own card_aspect at the head of the row. Previews
pop in as they arrive and are cached for the session, so reopening the
store is instant. Failures log and leave the row text-only; the decode
path is skipped entirely under MinimalPlugins (no Assets<Image>).

solitaire_data grows ThemeStoreClient::fetch_preview with a 512 KB cap
(previews carry no checksum — decorative only).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 17:26:47 -07:00
funman300 739c94d3bb Merge pull request 'polish(engine): Phase H part 1 — unified toast stack, onboarding fixes' (#178) from feat/feedback-polish into master
Test / fmt (push) Successful in 4s
Test / test (push) Successful in 4m24s
Build and Deploy / build-and-push (push) Successful in 9m8s
Web E2E / web-e2e (push) Successful in 8m15s
2026-07-14 00:14:03 +00:00
4 changed files with 634 additions and 27 deletions
+30
View File
@@ -6,6 +6,36 @@ project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [0.46.0] — 2026-07-13
### Added
- **Theme-store previews.** The store modal now shows each theme's
preview image next to its name — the server has advertised them since
the store launched; the client finally fetches them. Thumbnails load
in the background, pop in as they arrive, and are remembered for the
session. (#179)
- **Hint ghost preview.** Asking for a hint now also plays a translucent
copy of the suggested card gliding to its destination (twice, then it
fades) alongside the usual highlights — you see the move, not just the
pieces. Automatically disabled when reduce-motion is on. (#179)
### Changed
- **One toast style, one place.** Queued info banners and instant
celebration/warning/error toasts now share a single bottom-anchored
stack that clears the touch action bar; simultaneous toasts stack
upward instead of overlapping. (#178)
### Fixed
- **Touch onboarding copy.** The how-to-play slide no longer tells touch
players to left/right-click; it now describes drag, double-tap, and
the bottom-bar Hint button. (#178)
- **First launch shows one screen at a time.** On a fresh install the
Home screen no longer spawns underneath the welcome tour; the order is
now onboarding → Home → table. (#178)
## [0.45.0] — 2026-07-13
### Added
+40
View File
@@ -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
+304 -3
View File
@@ -49,10 +49,11 @@ use solitaire_core::klondike_adapter::foundation_from_slot;
use solitaire_data::AnimSpeed;
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::{
DrawRequestEvent, FoundationCompletedEvent, MoveRejectedEvent, MoveRequestEvent,
NewGameRequestEvent,
DrawRequestEvent, FoundationCompletedEvent, HintVisualEvent, MoveRejectedEvent,
MoveRequestEvent, NewGameRequestEvent, StateChangedEvent,
};
use crate::game_plugin::GameMutation;
use crate::layout::LayoutResource;
@@ -207,6 +208,8 @@ impl Plugin for FeedbackAnimPlugin {
.add_message::<MoveRejectedEvent>()
.add_message::<NewGameRequestEvent>()
.add_message::<FoundationCompletedEvent>()
.add_message::<HintVisualEvent>()
.add_message::<StateChangedEvent>()
.add_message::<RequestRedraw>()
.add_systems(
Update,
@@ -224,6 +227,20 @@ impl Plugin for FeedbackAnimPlugin {
start_deal_anim.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)
// ---------------------------------------------------------------------------
@@ -955,4 +1113,147 @@ mod tests {
"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"
);
}
}
+260 -24
View File
@@ -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"
);
}
}