02bcc8b4af
New ui_modal::spawn_empty_state — glyph + headline + optional detail, centred, with next-step actions composed alongside rather than configured in. Glyphs restricted to FiraMono-covered ranges (suits, arrows, ASCII) per the section-10/11 Android constraint. Swept every improvised surface onto it: - Theme store: loading / error / empty-catalog branches - Leaderboard: fetching / error / be-the-first branches - You-hub Replays tab: dedicated no-replays state (selector and actions no longer spawn dead controls when history is empty) - Stats: first-launch nudge above the em-dash grid The Account tab's sync row was deliberately NOT swept: it is a live status + action row, not an empty state, and its deeper treatment belongs to Phase M's sync-transparency work. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
907 lines
31 KiB
Rust
907 lines
31 KiB
Rust
//! In-game theme store: browse the sync server's theme catalog and
|
|
//! install themes without leaving the app.
|
|
//!
|
|
//! Settings → Cosmetic → "Browse theme store" fires
|
|
//! [`crate::events::ThemeStoreOpenRequestEvent`]; this plugin opens a
|
|
//! modal listing the catalog (fetched on [`AsyncComputeTaskPool`]) and
|
|
//! handles per-theme Install buttons. An install downloads the archive
|
|
//! (SHA-256-verified by [`ThemeStoreClient`]), writes it atomically
|
|
//! into `user_theme_dir()`, and runs it through the same hardened
|
|
//! [`import_theme`] pipeline as a manually dropped zip — the store
|
|
//! never bypasses the importer's validation.
|
|
//!
|
|
//! Native-only: downloads need `reqwest`/Tokio and the importer is
|
|
//! 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;
|
|
|
|
use solitaire_data::theme_store_client::{ThemeStoreClient, ThemeStoreError};
|
|
use solitaire_data::{Settings, SyncBackend};
|
|
use solitaire_sync::ThemeCatalogEntry;
|
|
|
|
use crate::assets::user_theme_dir;
|
|
use crate::events::{InfoToastEvent, ThemeStoreOpenRequestEvent, WarningToastEvent};
|
|
use crate::font_plugin::FontResource;
|
|
use crate::resources::TokioRuntimeResource;
|
|
use crate::settings_plugin::{SettingsPanel, SettingsResource};
|
|
use crate::theme::{ImportError, ThemeRegistry, import_theme, refresh_registry};
|
|
use crate::ui_modal::{
|
|
ButtonVariant, ModalScrim, ScrimDismissible, spawn_empty_state, spawn_modal,
|
|
spawn_modal_actions, spawn_modal_button, spawn_modal_header,
|
|
};
|
|
use crate::ui_theme::{
|
|
BORDER_SUBTLE, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3,
|
|
Z_MODAL_PANEL,
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Errors
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Everything that can go wrong between clicking Install and the theme
|
|
/// appearing in the picker.
|
|
#[derive(Debug, Error)]
|
|
enum ThemeInstallError {
|
|
/// Catalog fetch or archive download failed (network, HTTP status,
|
|
/// size/checksum verification).
|
|
#[error(transparent)]
|
|
Download(#[from] ThemeStoreError),
|
|
/// The verified archive could not be written into the themes dir.
|
|
#[error("could not save archive: {0}")]
|
|
Io(#[from] std::io::Error),
|
|
/// The downloaded archive failed importer validation.
|
|
#[error(transparent)]
|
|
Import(#[from] ImportError),
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Components & resources
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Marker on the theme-store modal's scrim root.
|
|
#[derive(Component)]
|
|
pub struct ThemeStoreScreen;
|
|
|
|
/// Marker on the modal's Close button.
|
|
#[derive(Component)]
|
|
struct ThemeStoreCloseButton;
|
|
|
|
/// Per-row Install button carrying its catalog entry.
|
|
#[derive(Component)]
|
|
struct ThemeStoreInstallButton(ThemeCatalogEntry);
|
|
|
|
/// Catalog fetch lifecycle. The modal renders directly from this state
|
|
/// and is rebuilt (despawn + respawn, mirroring the leaderboard panel)
|
|
/// whenever it changes.
|
|
#[derive(Resource, Default)]
|
|
enum CatalogState {
|
|
/// No fetch has run since the modal was last opened.
|
|
#[default]
|
|
Idle,
|
|
Loading,
|
|
Loaded(Vec<ThemeCatalogEntry>),
|
|
Error(String),
|
|
}
|
|
|
|
/// In-flight catalog fetch, polled without blocking.
|
|
#[derive(Resource, Default)]
|
|
struct CatalogTask(Option<Task<Result<Vec<ThemeCatalogEntry>, ThemeStoreError>>>);
|
|
|
|
/// Result of one download + import: `Ok(Some(id))` on a fresh install,
|
|
/// `Ok(None)` when the theme was already installed (id collision) —
|
|
/// informational, not an error.
|
|
type InstallResult = Result<Option<String>, ThemeInstallError>;
|
|
|
|
/// In-flight download + import. `String` is the theme id, for toasts.
|
|
#[derive(Resource, Default)]
|
|
struct InstallTask(Option<(String, Task<InstallResult>)>);
|
|
|
|
/// Server base URL captured when the modal opens. Catalog
|
|
/// `download_url`s are server-relative, so installs join them onto
|
|
/// this. `None` while the modal has never been opened.
|
|
#[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
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Bevy plugin for the in-game theme store. See the module docs.
|
|
pub struct ThemeStorePlugin;
|
|
|
|
impl Plugin for ThemeStorePlugin {
|
|
fn build(&self, app: &mut App) {
|
|
app.init_resource::<CatalogState>()
|
|
.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>>()
|
|
.add_message::<ThemeStoreOpenRequestEvent>()
|
|
.add_message::<InfoToastEvent>()
|
|
.add_message::<WarningToastEvent>()
|
|
.add_systems(
|
|
Update,
|
|
(
|
|
handle_open_request,
|
|
poll_catalog_task,
|
|
poll_preview_tasks,
|
|
handle_install_buttons,
|
|
poll_install_task,
|
|
handle_close_button,
|
|
)
|
|
.chain(),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Systems
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Resolves the store's server base URL from the player's sync
|
|
/// backend. The store rides the same self-hosted server as sync;
|
|
/// without one configured there is nothing to browse.
|
|
fn store_base_url(settings: &Settings) -> Option<String> {
|
|
match &settings.sync_backend {
|
|
SyncBackend::SolitaireServer { url, .. } => Some(url.clone()),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Opens the store modal and kicks off the catalog fetch.
|
|
///
|
|
/// Mirrors `open_sync_setup_modal`: the Settings button closes the
|
|
/// settings panel in the same frame it fires the event, and despawns
|
|
/// are deferred, so the settings scrim is excluded from the
|
|
/// another-modal-is-open guard.
|
|
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
|
|
fn handle_open_request(
|
|
mut events: MessageReader<ThemeStoreOpenRequestEvent>,
|
|
existing: Query<(), With<ThemeStoreScreen>>,
|
|
other_modal_scrims: Query<
|
|
(),
|
|
(
|
|
With<ModalScrim>,
|
|
Without<ThemeStoreScreen>,
|
|
Without<SettingsPanel>,
|
|
),
|
|
>,
|
|
settings: Option<Res<SettingsResource>>,
|
|
rt: Option<Res<TokioRuntimeResource>>,
|
|
mut catalog_state: ResMut<CatalogState>,
|
|
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>>,
|
|
) {
|
|
if events.is_empty() {
|
|
return;
|
|
}
|
|
events.clear();
|
|
if !existing.is_empty() || !other_modal_scrims.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let Some(base_url) = settings.as_deref().and_then(|s| store_base_url(&s.0)) else {
|
|
warning_toast.write(WarningToastEvent(
|
|
"Theme store needs a server — connect one in Settings → Sync.".to_string(),
|
|
));
|
|
return;
|
|
};
|
|
let Some(rt) = rt else {
|
|
warning_toast.write(WarningToastEvent(
|
|
"Theme store unavailable — no network runtime.".to_string(),
|
|
));
|
|
return;
|
|
};
|
|
|
|
store_base.0 = Some(base_url.clone());
|
|
*catalog_state = CatalogState::Loading;
|
|
let rt = rt.0.clone();
|
|
catalog_task.0 = Some(AsyncComputeTaskPool::get().spawn(async move {
|
|
rt.block_on(async { ThemeStoreClient::new(base_url).fetch_catalog().await })
|
|
}));
|
|
|
|
spawn_store_modal(
|
|
&mut commands,
|
|
&catalog_state,
|
|
None,
|
|
&previews,
|
|
font_res.as_deref(),
|
|
);
|
|
}
|
|
|
|
/// 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>>,
|
|
) {
|
|
let Some(task) = catalog_task.0.as_mut() else {
|
|
return;
|
|
};
|
|
let Some(result) = future::block_on(future::poll_once(task)) else {
|
|
return;
|
|
};
|
|
catalog_task.0 = None;
|
|
|
|
*catalog_state = match result {
|
|
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())
|
|
}
|
|
};
|
|
|
|
rebuild_open_modal(
|
|
&screens,
|
|
&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(
|
|
interactions: Query<(&Interaction, &ThemeStoreInstallButton), Changed<Interaction>>,
|
|
rt: Option<Res<TokioRuntimeResource>>,
|
|
store_base: Res<StoreBaseUrl>,
|
|
mut install_task: ResMut<InstallTask>,
|
|
mut info_toast: MessageWriter<InfoToastEvent>,
|
|
) {
|
|
for (interaction, button) in &interactions {
|
|
if *interaction != Interaction::Pressed || install_task.0.is_some() {
|
|
continue;
|
|
}
|
|
let (Some(rt), Some(base_url)) = (rt.as_ref(), store_base.0.clone()) else {
|
|
continue;
|
|
};
|
|
let entry = button.0.clone();
|
|
info_toast.write(InfoToastEvent(format!("Downloading '{}'…", entry.name)));
|
|
let rt = rt.0.clone();
|
|
let task = AsyncComputeTaskPool::get().spawn(async move {
|
|
let bytes = rt
|
|
.block_on(async { ThemeStoreClient::new(base_url).download_theme(&entry).await })?;
|
|
install_verified_archive(&entry.id, &bytes)
|
|
});
|
|
install_task.0 = Some((button.0.id.clone(), task));
|
|
}
|
|
}
|
|
|
|
/// Downloads land here (already SHA-256-verified): write the archive
|
|
/// atomically into the user themes dir (`.tmp` + rename, §2.5), then
|
|
/// run the standard importer. Returns `Ok(None)` when the theme was
|
|
/// already installed.
|
|
fn install_verified_archive(id: &str, bytes: &[u8]) -> Result<Option<String>, ThemeInstallError> {
|
|
let themes_dir = user_theme_dir();
|
|
std::fs::create_dir_all(&themes_dir)?;
|
|
let final_path = themes_dir.join(format!("{id}.zip"));
|
|
let tmp_path = themes_dir.join(format!("{id}.zip.tmp"));
|
|
std::fs::write(&tmp_path, bytes)?;
|
|
std::fs::rename(&tmp_path, &final_path)?;
|
|
|
|
match import_theme(&final_path) {
|
|
Ok(theme_id) => Ok(Some(theme_id.as_str().to_string())),
|
|
Err(ImportError::IdCollision { .. }) => Ok(None),
|
|
Err(e) => {
|
|
// Don't leave a zip that fails validation lying around for
|
|
// the folder-scan flow to re-try on every scan.
|
|
let _ = std::fs::remove_file(&final_path);
|
|
Err(e.into())
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Polls the install task; on completion refreshes the theme registry,
|
|
/// toasts the outcome, and rebuilds the modal so the row flips to
|
|
/// "Installed".
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn poll_install_task(
|
|
mut install_task: ResMut<InstallTask>,
|
|
mut registry: Option<ResMut<ThemeRegistry>>,
|
|
catalog_state: Res<CatalogState>,
|
|
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>>,
|
|
) {
|
|
let Some((id, task)) = install_task.0.as_mut() else {
|
|
return;
|
|
};
|
|
let Some(result) = future::block_on(future::poll_once(task)) else {
|
|
return;
|
|
};
|
|
let id = id.clone();
|
|
install_task.0 = None;
|
|
|
|
match result {
|
|
Ok(Some(theme_id)) => {
|
|
if let Some(registry) = registry.as_deref_mut() {
|
|
refresh_registry(registry, &user_theme_dir());
|
|
}
|
|
info_toast.write(InfoToastEvent(format!(
|
|
"Theme '{theme_id}' installed — pick it in Settings → Cosmetic."
|
|
)));
|
|
}
|
|
Ok(None) => {
|
|
info_toast.write(InfoToastEvent(format!("Theme '{id}' already installed.")));
|
|
}
|
|
Err(e) => {
|
|
warn!("theme store: install of '{id}' failed: {e}");
|
|
warning_toast.write(WarningToastEvent(format!("Install failed: {e}")));
|
|
}
|
|
}
|
|
|
|
rebuild_open_modal(
|
|
&screens,
|
|
&mut commands,
|
|
&catalog_state,
|
|
registry.as_deref(),
|
|
&previews,
|
|
font_res.as_deref(),
|
|
);
|
|
}
|
|
|
|
/// Despawns the store modal when Close is pressed or on Esc (Phase C
|
|
/// dismissal audit). The store only ever stacks over Settings and
|
|
/// nothing stacks over the store, so it owns Esc whenever it is open
|
|
/// (Settings' own Esc handler is gated on being topmost).
|
|
fn handle_close_button(
|
|
interactions: Query<&Interaction, (Changed<Interaction>, With<ThemeStoreCloseButton>)>,
|
|
keys: Res<ButtonInput<KeyCode>>,
|
|
screens: Query<Entity, With<ThemeStoreScreen>>,
|
|
mut commands: Commands,
|
|
) {
|
|
let clicked = interactions.iter().any(|i| *i == Interaction::Pressed);
|
|
let esc = keys.just_pressed(KeyCode::Escape) && !screens.is_empty();
|
|
if !clicked && !esc {
|
|
return;
|
|
}
|
|
for entity in &screens {
|
|
commands.entity(entity).despawn();
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Modal rendering
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Despawn + respawn the modal for every open screen (Bevy despawns
|
|
/// are deferred, so this is safe within one frame).
|
|
fn rebuild_open_modal(
|
|
screens: &Query<Entity, With<ThemeStoreScreen>>,
|
|
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, previews, font_res);
|
|
}
|
|
}
|
|
|
|
/// Spawns the store modal for the current catalog state.
|
|
fn spawn_store_modal(
|
|
commands: &mut Commands,
|
|
catalog_state: &CatalogState,
|
|
registry: Option<&ThemeRegistry>,
|
|
previews: &PreviewCache,
|
|
font_res: Option<&FontResource>,
|
|
) {
|
|
let body_font = TextFont {
|
|
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
|
font_size: TYPE_BODY,
|
|
..default()
|
|
};
|
|
let caption_font = TextFont {
|
|
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
|
font_size: TYPE_CAPTION,
|
|
..default()
|
|
};
|
|
|
|
let scrim = spawn_modal(commands, ThemeStoreScreen, Z_MODAL_PANEL + 1, |card| {
|
|
spawn_modal_header(card, "Theme Store", font_res);
|
|
|
|
match catalog_state {
|
|
CatalogState::Idle | CatalogState::Loading => {
|
|
spawn_empty_state(
|
|
card,
|
|
"\u{21BB}",
|
|
"Loading the catalog\u{2026}",
|
|
None,
|
|
font_res,
|
|
);
|
|
}
|
|
CatalogState::Error(msg) => {
|
|
spawn_empty_state(
|
|
card,
|
|
"!",
|
|
"Couldn't load the catalog.",
|
|
Some(msg.as_str()),
|
|
font_res,
|
|
);
|
|
}
|
|
CatalogState::Loaded(entries) if entries.is_empty() => {
|
|
spawn_empty_state(
|
|
card,
|
|
"#",
|
|
"The server has no themes yet.",
|
|
Some("Themes dropped into the server's theme_store folder appear here."),
|
|
font_res,
|
|
);
|
|
}
|
|
CatalogState::Loaded(entries) => {
|
|
for entry in entries {
|
|
let installed =
|
|
registry.is_some_and(|registry| registry.find(&entry.id).is_some());
|
|
spawn_store_row(
|
|
card,
|
|
entry,
|
|
installed,
|
|
previews.0.get(&entry.id),
|
|
&body_font,
|
|
&caption_font,
|
|
font_res,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
spawn_modal_actions(card, |actions| {
|
|
spawn_modal_button(
|
|
actions,
|
|
ThemeStoreCloseButton,
|
|
"Close",
|
|
None,
|
|
ButtonVariant::Primary,
|
|
font_res,
|
|
);
|
|
});
|
|
});
|
|
commands.entity(scrim).insert(ScrimDismissible);
|
|
}
|
|
|
|
/// 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>,
|
|
) {
|
|
parent
|
|
.spawn((
|
|
Node {
|
|
flex_direction: FlexDirection::Row,
|
|
justify_content: JustifyContent::SpaceBetween,
|
|
align_items: AlignItems::Center,
|
|
column_gap: VAL_SPACE_3,
|
|
padding: UiRect::vertical(VAL_SPACE_2),
|
|
border: UiRect::bottom(Val::Px(1.0)),
|
|
..default()
|
|
},
|
|
BorderColor::all(BORDER_SUBTLE),
|
|
))
|
|
.with_children(|row| {
|
|
row.spawn(Node {
|
|
flex_direction: FlexDirection::Row,
|
|
align_items: AlignItems::Center,
|
|
column_gap: VAL_SPACE_3,
|
|
..default()
|
|
})
|
|
.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((
|
|
Text::new("Installed"),
|
|
caption_font.clone(),
|
|
TextColor(TEXT_SECONDARY),
|
|
));
|
|
} else {
|
|
spawn_modal_button(
|
|
row,
|
|
ThemeStoreInstallButton(entry.clone()),
|
|
"Install",
|
|
None,
|
|
ButtonVariant::Secondary,
|
|
font_res,
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use bevy::ecs::message::Messages;
|
|
|
|
fn headless_app() -> App {
|
|
let mut app = App::new();
|
|
app.add_plugins(MinimalPlugins)
|
|
.add_plugins(ThemeStorePlugin);
|
|
app.update();
|
|
app
|
|
}
|
|
|
|
fn settings_with_server() -> SettingsResource {
|
|
SettingsResource(Settings {
|
|
sync_backend: SyncBackend::SolitaireServer {
|
|
url: "http://127.0.0.1:1".to_string(),
|
|
username: "tester".to_string(),
|
|
avatar_url: None,
|
|
},
|
|
..Settings::default()
|
|
})
|
|
}
|
|
|
|
fn screen_count(app: &mut App) -> usize {
|
|
app.world_mut()
|
|
.query::<&ThemeStoreScreen>()
|
|
.iter(app.world())
|
|
.count()
|
|
}
|
|
|
|
#[test]
|
|
fn store_base_url_requires_server_backend() {
|
|
assert_eq!(store_base_url(&Settings::default()), None);
|
|
assert_eq!(
|
|
store_base_url(&settings_with_server().0).as_deref(),
|
|
Some("http://127.0.0.1:1")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn open_request_without_server_warns_and_spawns_nothing() {
|
|
let mut app = headless_app();
|
|
// Default settings → SyncBackend::Local → no store.
|
|
app.insert_resource(SettingsResource(Settings::default()));
|
|
app.world_mut()
|
|
.resource_mut::<Messages<ThemeStoreOpenRequestEvent>>()
|
|
.write(ThemeStoreOpenRequestEvent);
|
|
app.update();
|
|
|
|
assert_eq!(screen_count(&mut app), 0);
|
|
assert!(
|
|
!app.world()
|
|
.resource::<Messages<WarningToastEvent>>()
|
|
.is_empty(),
|
|
"player must be told why the store did not open"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn open_request_without_runtime_warns_and_spawns_nothing() {
|
|
let mut app = headless_app();
|
|
app.insert_resource(settings_with_server());
|
|
// No TokioRuntimeResource inserted.
|
|
app.world_mut()
|
|
.resource_mut::<Messages<ThemeStoreOpenRequestEvent>>()
|
|
.write(ThemeStoreOpenRequestEvent);
|
|
app.update();
|
|
|
|
assert_eq!(screen_count(&mut app), 0);
|
|
assert!(
|
|
!app.world()
|
|
.resource::<Messages<WarningToastEvent>>()
|
|
.is_empty()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn open_request_with_server_spawns_modal_in_loading_state() {
|
|
let mut app = headless_app();
|
|
app.insert_resource(settings_with_server());
|
|
app.insert_resource(TokioRuntimeResource::new().expect("test runtime"));
|
|
app.world_mut()
|
|
.resource_mut::<Messages<ThemeStoreOpenRequestEvent>>()
|
|
.write(ThemeStoreOpenRequestEvent);
|
|
app.update();
|
|
|
|
assert_eq!(screen_count(&mut app), 1);
|
|
assert!(matches!(
|
|
*app.world().resource::<CatalogState>(),
|
|
CatalogState::Loading | CatalogState::Error(_)
|
|
));
|
|
assert_eq!(
|
|
app.world().resource::<StoreBaseUrl>().0.as_deref(),
|
|
Some("http://127.0.0.1:1")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn second_open_request_does_not_stack_a_second_modal() {
|
|
let mut app = headless_app();
|
|
app.insert_resource(settings_with_server());
|
|
app.insert_resource(TokioRuntimeResource::new().expect("test runtime"));
|
|
for _ in 0..2 {
|
|
app.world_mut()
|
|
.resource_mut::<Messages<ThemeStoreOpenRequestEvent>>()
|
|
.write(ThemeStoreOpenRequestEvent);
|
|
app.update();
|
|
}
|
|
assert_eq!(screen_count(&mut app), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn failed_catalog_fetch_rebuilds_modal_with_error_state() {
|
|
let mut app = headless_app();
|
|
app.insert_resource(settings_with_server());
|
|
app.insert_resource(TokioRuntimeResource::new().expect("test runtime"));
|
|
app.world_mut()
|
|
.resource_mut::<Messages<ThemeStoreOpenRequestEvent>>()
|
|
.write(ThemeStoreOpenRequestEvent);
|
|
app.update();
|
|
|
|
// 127.0.0.1:1 refuses connections; the fetch task must resolve
|
|
// to an error within a bounded number of frames and the modal
|
|
// must survive the rebuild.
|
|
for _ in 0..200 {
|
|
app.update();
|
|
if matches!(
|
|
*app.world().resource::<CatalogState>(),
|
|
CatalogState::Error(_)
|
|
) {
|
|
break;
|
|
}
|
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
|
}
|
|
assert!(matches!(
|
|
*app.world().resource::<CatalogState>(),
|
|
CatalogState::Error(_)
|
|
));
|
|
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"
|
|
);
|
|
}
|
|
}
|