lumen_assets/lib.rs
1//! Async asset pipeline with a content-addressed handle cache.
2//!
3//! - [`spawn_pending_decodes`] picks up entities tagged with [`ImageSource`] and consults the global cache on [`AssetServer`].
4//! - On a cache hit, the existing decoded [`Handle`] is cloned in (Arc bump, no decode runs).
5//! - On a cold miss, the request is pushed onto a bounded `crossbeam-channel` job queue drained by an N-worker thread pool (`N = available_parallelism().min(4)`).
6//! Subsequent entities requesting the same path join a pending fan-out list.
7//! - Every request carries a monotonic per-entity `request_id`; results whose id no longer matches the entity's current id are discarded on completion. This kills the stale-decode race after rapid `ScriptCommand::SetSrc` storms.
8//! - What a path decodes *into* is decided by the loader registry: each [`AssetLoader`] claims file extensions and produces one [`AssetKind`], and the built-in image and SVG paths are ordinary registered loaders. An app or plugin adds a format by registering another one; see [`register_asset_loader`].
9//! - Where the bytes come from is decided by the [`AssetSource`] list, consulted before the job is queued. [`BundleSource`] (`.lpak` archives, `lumen://app/...` URIs) is installed by default.
10//! - [`drain_completed_decodes`] delivers the resulting `Handle` to every still-valid waiter; failures attach [`ImageLoadFailed`] carrying a typed [`LoadErrorKind`].
11//! - Attaching a decoded payload raises [`lumen_core::render_world::FrameDirty`]. An asset is the one piece of scene content that arrives ticks after the tree it belongs to, and a tick whose frame is not dirty is never extracted, so without the raise the arrival would wait for an unrelated repaint.
12//! - [`keep_frame_loop_awake_for_assets`] holds an event-driven loop open while an [`ImageSource`] in the tree has no verdict on it yet, so an element that enters the tree after [`spawn_pending_decodes`] has run is looked up on the next tick rather than at the next unrelated event. The claim is keyed on the element, so it retires when the element has its content or leaves the tree.
13//! - Handles are strong [`Arc`]s to decoded data; the cache holds [`Handle<T>`] entries that share identity across consumers.
14//! - A renderer's image upload cache is keyed by the underlying `peniko::Blob` identity, so identical handles short-circuit the upload.
15//! - A [`notify::RecommendedWatcher`] tracks every loaded file URL. On change the asset cache invalidates the affected entry, the request id of every entity referencing that path is bumped, and an [`AssetReloadRequested`] message fires. [`reload_changed_images`] is the consumer that re-enqueues the load; the runtime installs it for a hot-reload session. The watcher wakes a parked frame loop so the change is picked up without waiting for input.
16//!
17//! Eviction is true LRU bounded by `max_bytes` (default 256 MiB). Insert / evict mutate a running `bytes_used: usize` in O(1); a `debug_assert!` reconciles the running counter against a full sweep at most once per second when debug assertions are on.
18
19#![forbid(unsafe_code)]
20#![warn(missing_docs)]
21
22pub mod bundle;
23pub mod loader;
24pub mod loaders;
25pub mod source;
26
27pub use bundle::{BundleError, LumenBundle, parse_lumen_uri};
28pub use loader::{AssetKind, AssetLoader, AssetLoaders, LoadContext, LoadedAsset, asset_extension};
29pub use loaders::{ImageLoader, SvgLoader};
30pub use source::{AssetSource, BundleSource, SourceReader};
31
32use bevy_ecs::message::{MessageReader, MessageWriter};
33use bevy_ecs::prelude::*;
34use crossbeam_channel::{Receiver, Sender};
35use lru::LruCache;
36use lumen_core::components::Visuals;
37use lumen_core::prelude::*;
38use lumen_core::render_world::{BackgroundClip, opacity_model, parent_scroll_offsets};
39use lumen_core::time::Instant;
40use notify::{RecommendedWatcher, RecursiveMode, Watcher};
41use std::collections::hash_map::Entry as MapEntry;
42use std::collections::{HashMap, HashSet};
43use std::num::NonZeroUsize;
44use std::panic::{AssertUnwindSafe, catch_unwind};
45use std::path::{Path, PathBuf};
46use std::sync::{Arc, Mutex};
47use std::thread::JoinHandle;
48
49/// Implements `Deref` for a single-field `Handle` newtype so field access
50/// flows through to the inner decoded payload. Every `Loaded*` wrapper shares
51/// the identical `fn deref(&self) -> &Target { &self.0 }` body.
52macro_rules! deref_newtype {
53 ($ty:ty => $target:ty) => {
54 impl std::ops::Deref for $ty {
55 type Target = $target;
56 fn deref(&self) -> &$target {
57 &self.0
58 }
59 }
60 };
61}
62
63/// Inserts a decoded asset into one of the content caches with the shared
64/// byte-accounting protocol: subtract any prior entry's bytes, add the new
65/// entry's, push under `path`, evict to the cap, subscribe the watcher, and
66/// reconcile. `$cache` is the `LruCache` field on `self`.
67macro_rules! insert_cached_asset {
68 ($self:ident, $cache:ident, $path:expr, $val:expr) => {{
69 let path = $path;
70 let val = $val;
71 // Pop any prior entry first so `bytes_used` accounting stays in sync.
72 if let Some(prev) = $self.$cache.pop(&path) {
73 $self.bytes_used = $self.bytes_used.saturating_sub(prev.0.bytes());
74 }
75 $self.bytes_used = $self.bytes_used.saturating_add(val.0.bytes());
76 $self.$cache.push(path.clone(), val);
77 $self.evict_until($self.max_bytes);
78 $self.watch_path(&path);
79 $self.maybe_reconcile_bytes();
80 }};
81}
82
83/// Handles one successful decode outcome in [`drain_completed_decodes`]:
84/// inserts the payload into its cache (LRU-bumping recency) via `$insert`,
85/// then attaches a clone to every surviving waiter, clearing its `Enqueued`
86/// tag. The Image and Svg arms differ only in the cache insert method and the
87/// payload binding.
88///
89/// The writes are `try_`, because a waiter was registered when the decode was
90/// enqueued and the entity can be gone by the time the result arrives - a
91/// script handler rebuilding a list despawns the rows it re-spawns. The
92/// decode still belongs in the cache; only the entity write is dropped.
93macro_rules! dispatch_decoded {
94 ($server:ident, $commands:ident, $path:expr, $insert:ident, $payload:ident, $surviving:ident) => {{
95 $server.$insert($path, $payload.clone());
96 for w in $surviving {
97 let mut ec = $commands.entity(w.entity);
98 ec.try_remove::<Enqueued>();
99 ec.try_insert($payload.clone());
100 }
101 }};
102}
103
104/// Default max bytes held across the image + SVG content caches (256 MiB).
105/// Override with [`AssetServer::with_max_bytes`].
106pub const DEFAULT_MAX_BYTES: usize = 256 * 1024 * 1024;
107
108/// Source path for an image asset, spawned by `<image src="...">` in markup.
109/// Replaced by [`LoadedImage`] (or [`ImageLoadFailed`]) once the asset plugin finishes decoding.
110#[derive(Component, Clone, Debug)]
111pub struct ImageSource(pub PathBuf);
112
113/// Marks an element whose [`ImageSource`] is its background (`bg: url(...)`)
114/// rather than its content. The image loads and caches like any other
115/// source; it paints behind the element's content, clipped to its box, and
116/// never sizes the element.
117#[derive(Component, Clone, Copy, Debug, Default, PartialEq, Eq)]
118pub struct BackgroundImage {
119 /// How the image fills the element's box (`bg-fit`).
120 pub fit: lumen_core::components::ImageFit,
121}
122
123/// The path an authored asset reference loads from: a `lumen://` URI as
124/// written, anything else resolved against the app directory.
125///
126/// Markup `src`, `bg: url(...)` and a script's `set_src` all resolve through
127/// this. The bundle
128/// source claims the `lumen://` scheme itself, so joining such a URI to the
129/// app directory would mangle it. The app directory comes from the
130/// process-global cache in [`lumen_core::app_paths`], which the runtime fills
131/// for every run, so a packaged, artifact or headless run resolves like a
132/// dev run rather than against the process's working directory.
133pub fn resolve_source_path(path: &str) -> PathBuf {
134 if path.starts_with("lumen://") {
135 PathBuf::from(path)
136 } else {
137 lumen_core::app_paths::resolve(path)
138 }
139}
140
141/// Newtype wrapper holding an `Arc<[u8]>` and implementing `AsRef<[u8]>` so it can be passed to
142/// `peniko::Blob::new`, which requires `Arc<T: ?Sized + AsRef<[u8]> + Send + Sync>`.
143pub struct PixBytes(pub Arc<[u8]>);
144
145impl AsRef<[u8]> for PixBytes {
146 fn as_ref(&self) -> &[u8] {
147 &self.0
148 }
149}
150
151/// Strong, content-addressed handle to a decoded asset.
152///
153/// - Wraps an `Arc<T>`; cloning is an `Arc` bump (`O(1)`, no data copy).
154/// - Identical handles share identity via [`Handle::id`].
155/// - [`AssetServer`] stores `Handle<T>` entries so entities sharing a path share one decoded asset.
156pub struct Handle<T> {
157 inner: Arc<T>,
158}
159
160impl<T> Handle<T> {
161 /// Returns the handle's stable identity (the underlying `Arc` pointer cast to `usize`).
162 /// Clones of the same handle return the same id; independent decodes of the same bytes return different ids.
163 pub fn id(&self) -> usize {
164 Arc::as_ptr(&self.inner) as usize
165 }
166
167 /// Returns the approximate in-memory byte cost of the underlying data via the [`AssetSize`] trait.
168 pub fn bytes(&self) -> usize
169 where
170 T: AssetSize,
171 {
172 self.inner.bytes()
173 }
174}
175
176impl<T> Clone for Handle<T> {
177 fn clone(&self) -> Self {
178 Self {
179 inner: self.inner.clone(),
180 }
181 }
182}
183
184impl<T> std::ops::Deref for Handle<T> {
185 type Target = T;
186 fn deref(&self) -> &T {
187 &self.inner
188 }
189}
190
191impl<T> From<Arc<T>> for Handle<T> {
192 fn from(inner: Arc<T>) -> Self {
193 Self { inner }
194 }
195}
196
197impl<T> From<T> for Handle<T> {
198 fn from(value: T) -> Self {
199 Self {
200 inner: Arc::new(value),
201 }
202 }
203}
204
205/// Implemented by asset payload types so the cache can budget memory.
206pub trait AssetSize {
207 /// Returns the approximate CPU-side byte cost of this asset, consulted by [`AssetServer::bytes_used`] and the LRU eviction sweep.
208 fn bytes(&self) -> usize;
209}
210
211/// Decoded image payload in RGBA8 with top-left origin.
212///
213/// - Shared via [`Handle<ImageData>`]; never deep-copied.
214/// - `blob` is a pre-built `peniko::Blob` whose stable identity keys a renderer's image upload cache across frames. `Blob` cloning is `Arc`-cheap.
215pub struct ImageData {
216 /// Width in pixels.
217 pub width: u32,
218 /// Height in pixels.
219 pub height: u32,
220 /// RGBA8 pixel buffer in row-major order (`width * height * 4` bytes), accessible to consumers other than the renderer, such as the MCP inspector.
221 pub rgba: Arc<[u8]>,
222 /// Pre-built `peniko::Blob` whose identity is the renderer's upload cache key.
223 pub blob: peniko::Blob<u8>,
224}
225
226impl AssetSize for ImageData {
227 fn bytes(&self) -> usize {
228 self.rgba.len()
229 }
230}
231
232/// Strong handle to a decoded image, attached as a component once `<image src="...">` resolves.
233/// Implements `Deref<Target = ImageData>` so field access (`img.width`, `img.blob`) flows through to the inner payload.
234#[derive(Component, Clone)]
235pub struct LoadedImage(pub Handle<ImageData>);
236
237deref_newtype!(LoadedImage => ImageData);
238
239impl std::fmt::Debug for LoadedImage {
240 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
241 f.debug_struct("LoadedImage")
242 .field("width", &self.width)
243 .field("height", &self.height)
244 .field("rgba_len", &self.rgba.len())
245 .field("handle_id", &self.0.id())
246 .finish_non_exhaustive()
247 }
248}
249
250/// Parsed SVG payload produced once at decode time and shared per-frame via [`Handle<SvgData>`] clones.
251pub struct SvgData {
252 /// Native pixel size derived from the SVG `viewBox` / `width` / `height`.
253 pub intrinsic: glam::Vec2,
254 /// The parsed document, painted at frame time by the renderer.
255 pub tree: usvg::Tree,
256 /// Identity of this decode, unique for the life of the process. A renderer keys its cached
257 /// drawing of the SVG on it; see [`SvgData::next_id`].
258 pub id: u64,
259 /// Raw source-file length in bytes; budgeted by the LRU as a proxy for the parsed tree's cost,
260 /// which usvg does not expose, so eviction is not blind to SVG memory pressure.
261 pub source_bytes: usize,
262}
263
264impl SvgData {
265 /// A fresh [`SvgData::id`]. Never repeats within a process, so a renderer cache keyed on it
266 /// cannot hand one SVG another's drawing.
267 pub fn next_id() -> u64 {
268 static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
269 NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
270 }
271}
272
273impl AssetSize for SvgData {
274 fn bytes(&self) -> usize {
275 self.source_bytes
276 }
277}
278
279/// Strong handle to a decoded SVG, with `Deref<Target = SvgData>` enabling field access (`svg.intrinsic`, `svg.tree`).
280#[derive(Component, Clone)]
281pub struct LoadedSvg(pub Handle<SvgData>);
282
283deref_newtype!(LoadedSvg => SvgData);
284
285/// Typed asset load-failure category.
286///
287/// Replaces the previous `String`-typed failure surface. Renderers branch on `kind` (a "broken image" icon for
288/// [`Self::NotFound`] / [`Self::Unsupported`], an error banner for [`Self::DecodeFailed`], silent drop for
289/// [`Self::Cancelled`]).
290#[derive(Debug)]
291pub enum LoadErrorKind {
292 /// Source file does not exist or is not reachable.
293 NotFound,
294 /// The bytes were read but the decoder rejected them. Carries the decoder's message.
295 DecodeFailed(String),
296 /// Underlying I/O error (permission denied, broken pipe, ...). Kept around so callers can inspect `kind()`.
297 Io(std::io::Error),
298 /// The job was discarded because a newer `SetSrc` superseded it before completion.
299 Cancelled,
300 /// File extension or content type that the asset crate does not handle.
301 Unsupported,
302}
303
304impl std::fmt::Display for LoadErrorKind {
305 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
306 match self {
307 Self::NotFound => write!(f, "not found"),
308 Self::DecodeFailed(msg) => write!(f, "decode failed: {msg}"),
309 Self::Io(e) => write!(f, "io error: {e}"),
310 Self::Cancelled => write!(f, "cancelled by newer request"),
311 Self::Unsupported => write!(f, "unsupported format"),
312 }
313 }
314}
315
316impl From<std::io::Error> for LoadErrorKind {
317 fn from(e: std::io::Error) -> Self {
318 if e.kind() == std::io::ErrorKind::NotFound {
319 LoadErrorKind::NotFound
320 } else {
321 LoadErrorKind::Io(e)
322 }
323 }
324}
325
326/// Attached to the entity when its image fails to decode. Carries the typed [`LoadErrorKind`] and a human-readable detail string.
327#[derive(Component, Debug)]
328pub struct ImageLoadFailed {
329 /// Categorized failure variant; branch on this in the render layer.
330 pub kind: LoadErrorKind,
331 /// Stringified failure detail, suitable for logs and error banners.
332 pub detail: String,
333}
334
335impl ImageLoadFailed {
336 /// Wraps a [`LoadErrorKind`] in a component, formatting `detail` via `Display`.
337 pub fn new(kind: LoadErrorKind) -> Self {
338 let detail = kind.to_string();
339 Self { kind, detail }
340 }
341}
342
343impl From<LoadErrorKind> for ImageLoadFailed {
344 fn from(kind: LoadErrorKind) -> Self {
345 Self::new(kind)
346 }
347}
348
349/// Monotonic per-entity asset request id. Attached to every entity carrying an [`ImageSource`].
350/// Every `ScriptCommand::SetSrc` runs through [`AssetServer::bump_request_id`] which increments this counter;
351/// in-flight decode jobs carrying an older id are discarded on completion.
352#[derive(Component, Clone, Copy, Debug, Default, PartialEq, Eq)]
353pub struct RequestId(pub u64);
354
355/// Message fired when a watched asset file changes on disk. [`reload_changed_images`] reacts by stripping
356/// `LoadedImage` / `LoadedSvg` / `ImageLoadFailed` / `Enqueued` from every entity whose `ImageSource` matches `path`
357/// so the pipeline re-enqueues the load. The runtime installs that consumer only for a hot-reload session.
358#[derive(Message, Clone, Debug)]
359pub struct AssetReloadRequested {
360 /// Canonicalized source path whose bytes-on-disk changed.
361 pub path: PathBuf,
362}
363
364/// One SVG to draw this frame. Defined in `lumen-assets` (not `lumen-core`) because the payload carries the parsed usvg tree.
365#[derive(Component, Clone)]
366pub struct ExtractedSvg {
367 /// Top-left position in window coordinates.
368 pub origin: glam::Vec2,
369 /// Target rect size in pixels.
370 pub size: glam::Vec2,
371 /// Native SVG size derived from the viewBox.
372 pub intrinsic: glam::Vec2,
373 /// Strong handle to the cached SVG payload; `Deref` exposes `asset.tree` and `asset.intrinsic`.
374 pub asset: Handle<SvgData>,
375 /// Scaling mode applied when fitting the SVG into the drawn rect.
376 pub fit: lumen_core::components::ImageFit,
377 /// Global paint order, computed by [`lumen_core::render_world::PaintOrder`].
378 pub order: u32,
379 /// Alpha multiplier carried from [`lumen_core::components::Opacity`]. Applied via `push_layer` at draw time when below 1.0.
380 pub alpha: f32,
381}
382
383/// Background-thread plumbing and content cache for the asset pipeline.
384///
385/// - Caches are keyed by source path; entities sharing a path share one decoded `LoadedImage` or `LoadedSvg` (Arc-cheap clones).
386/// - Successful decodes populate the LRU image / SVG caches; decode failures populate the LRU failure cache.
387/// - Entities requesting an in-flight path are appended to `pending` and receive the result via the drain.
388/// - What a path decodes into comes from the [`AssetLoaders`] registry, and where its bytes come from
389/// comes from the registered [`AssetSource`] list; both are replaceable per app.
390/// - Decode jobs run on a bounded N-worker `crossbeam-channel` pool (`N = available_parallelism().min(4)`).
391/// - A `notify::RecommendedWatcher` invalidates cache entries whose backing file changes on disk and fires [`AssetReloadRequested`].
392#[derive(Resource)]
393pub struct AssetServer {
394 job_tx: Option<Sender<DecodeJob>>,
395 result_rx: Receiver<DecodeResult>,
396 /// Worker thread join handles (one per pool worker). Drained by [`Self::shutdown`].
397 ///
398 /// Empty until the first decode is enqueued: the pool is
399 /// spawned lazily by [`Self::ensure_workers`] so an app that never
400 /// loads an image or SVG (the common case for a plain counter / form
401 /// UI) pays zero decode threads. See [`Self::worker_count`].
402 workers: Vec<JoinHandle<()>>,
403 /// Receiver end handed to each worker at spawn time. Held here so the
404 /// pool can be spawned lazily on first enqueue rather than eagerly at
405 /// construction. `None` after [`Self::shutdown`].
406 job_rx: Option<Receiver<DecodeJob>>,
407 /// Sender template cloned to each worker at spawn time; also keeps the
408 /// result channel connected before any worker exists. `None` after
409 /// [`Self::shutdown`].
410 result_tx: Option<Sender<DecodeResult>>,
411 /// Number of workers [`Self::ensure_workers`] will spawn on first use
412 /// (`available_parallelism().min(4)`).
413 worker_count: usize,
414 /// Cached successful image decodes; cloning is `Arc`-cheap. LRU-ordered.
415 image_cache: LruCache<PathBuf, LoadedImage>,
416 /// Cached successful SVG decodes; cloning is `Arc`-cheap. LRU-ordered.
417 svg_cache: LruCache<PathBuf, LoadedSvg>,
418 /// Cached decode failures keyed by source path. LRU-ordered.
419 failed_cache: LruCache<PathBuf, ImageLoadFailed>,
420 /// Entities awaiting an in-flight decode; one `DecodeResult` per path fans out to all matching waiters.
421 /// Each waiter records the `request_id` that was current when the job was enqueued. On completion, waiters
422 /// whose entity's current `RequestId` no longer matches are discarded - kills the stale-decode race.
423 pending: HashMap<PathBuf, Vec<PendingWaiter>>,
424 /// Per-entity monotonic request counter; incremented on every `bump_request_id`.
425 request_ids: HashMap<Entity, u64>,
426 /// Running sum of [`AssetSize::bytes`] across `image_cache` + `svg_cache`. Updated in O(1) on insert / evict.
427 /// Reconciled against a full sweep at most once per second when debug assertions are on.
428 bytes_used: usize,
429 /// Soft cap on `bytes_used`. Inserts evict LRU entries until the cap is satisfied.
430 max_bytes: usize,
431 /// Last time the running `bytes_used` was reconciled against a full sweep. Debug-only.
432 #[cfg(debug_assertions)]
433 last_reconcile: Instant,
434 /// Filesystem watcher rooted at every loaded file URL. `None` until the first successful decode is cached
435 /// (constructing the watcher lazily lets `AssetServer::default()` succeed in environments without an
436 /// fsevent / inotify backend, e.g. some CI sandboxes).
437 watcher: Option<RecommendedWatcher>,
438 /// Channel receiving filesystem-watch events. Drained by [`process_watch_events`] each tick.
439 watch_rx: Receiver<notify::Result<notify::Event>>,
440 /// Sender end of the watch channel; cloned into the watcher callback.
441 watch_tx: Sender<notify::Result<notify::Event>>,
442 /// Frame-loop waker the watcher callback fires after queueing an event, so a parked loop runs the tick that
443 /// drains it. Empty until [`process_watch_events`] first sees an [`EventLoopWaker`] resource.
444 watch_waker: Arc<std::sync::OnceLock<EventLoopWaker>>,
445 /// Set of paths the watcher is currently subscribed to. Watch ops are idempotent - subscribing a path
446 /// twice is cheap and avoids tracking per-path refcounts.
447 watched: HashSet<PathBuf>,
448 /// Shared cancellation flag handed to workers. Flipping it true at shutdown unblocks any worker that's
449 /// already started a decode call so the join handles complete promptly.
450 shutdown_flag: Arc<Mutex<bool>>,
451 /// Extension-keyed loader registry. Decides which [`AssetLoader`] runs
452 /// for a path and therefore what kind of asset it becomes. Defaults to
453 /// the built-in image and SVG loaders.
454 loaders: AssetLoaders,
455 /// The built-in `.lpak` source. Kept as its own field so
456 /// [`Self::register_bundle`] and the font-registration path in
457 /// `lumen-text-cosmic` can reach the bundles by type.
458 bundle_source: BundleSource,
459 /// Additional byte sources, consulted after the bundles and before the
460 /// filesystem, in registration order.
461 sources: Vec<Arc<dyn AssetSource>>,
462}
463
464struct PendingWaiter {
465 entity: Entity,
466 request_id: u64,
467}
468
469/// One unit of decode work.
470///
471/// The path is the cache key. Per-entity `request_id` tracking also lives on the `pending` fan-out list -
472/// every waiter (entity + snapshot id) records the id at enqueue time. On completion the drain compares
473/// waiter snapshots against the entity's *current* id to discard stale waiters; if no waiter survives the
474/// check, the result is dropped without inserting into the cache, so a SetSrc-storm doesn't pollute the LRU
475/// with transient decodes.
476struct DecodeJob {
477 path: PathBuf,
478 /// Loader resolved from the registry at enqueue time, on the main
479 /// thread. Travelling with the job is what keeps loader registration off
480 /// the workers' path: they never read the registry.
481 loader: Arc<dyn AssetLoader>,
482 /// Request id snapshot taken at the originating entity's enqueue time. Carried verbatim on the
483 /// `DecodeResult` so callers tracing job -> result pairs can correlate by id; the actual stale-waiter
484 /// check uses per-waiter ids on the `pending` list.
485 request_id: u64,
486 /// Bytes an [`AssetSource`] produced for this path, resolved on the
487 /// main thread because a worker cannot see the [`AssetServer`]. `None`
488 /// when no source claimed the path, and the loader reads it itself.
489 resolved_bytes: Option<Vec<u8>>,
490}
491
492struct DecodeResult {
493 path: PathBuf,
494 /// Request id forwarded from the originating `DecodeJob`; see [`DecodeJob::request_id`].
495 request_id: u64,
496 outcome: Result<LoadedAsset, LoadErrorKind>,
497}
498
499/// Outcome of a cache lookup for an entity's source path.
500enum CacheLookup {
501 HitImage(LoadedImage),
502 HitSvg(LoadedSvg),
503 HitFailed(ImageLoadFailed),
504 /// The path is already mid-flight; the entity has been appended to the pending list and the caller inserts `Enqueued`.
505 InFlight,
506 /// No prior decode existed; a new job was enqueued and the caller inserts `Enqueued`.
507 Enqueued,
508}
509
510impl Default for AssetServer {
511 fn default() -> Self {
512 Self::with_max_bytes(DEFAULT_MAX_BYTES)
513 }
514}
515
516impl AssetServer {
517 /// Constructs an `AssetServer` with `max_bytes` as the soft cap on the combined image + SVG caches.
518 /// `Self::default()` calls this with [`DEFAULT_MAX_BYTES`] (256 MiB).
519 pub fn with_max_bytes(max_bytes: usize) -> Self {
520 let (job_tx, job_rx) = crossbeam_channel::unbounded::<DecodeJob>();
521 let (result_tx, result_rx) = crossbeam_channel::unbounded::<DecodeResult>();
522 let (watch_tx, watch_rx) = crossbeam_channel::unbounded::<notify::Result<notify::Event>>();
523 // Bound worker count to `available_parallelism().min(4)`. The clamp keeps decode load off the
524 // bevy_ecs task pool and matches the spec - a four-worker pool can decode 4 images in parallel
525 // without burning a thread-per-job during burst loads.
526 let worker_count = std::thread::available_parallelism()
527 .map(|n| n.get())
528 .unwrap_or(2)
529 .clamp(1, 4);
530 let shutdown_flag = Arc::new(Mutex::new(false));
531 // Workers are not spawned here. The channel ends are stashed and the
532 // pool is materialised lazily on the first enqueue via
533 // `ensure_workers` - an app that loads no assets keeps these threads
534 // off the process entirely.
535 Self {
536 job_tx: Some(job_tx),
537 result_rx,
538 workers: Vec::new(),
539 job_rx: Some(job_rx),
540 result_tx: Some(result_tx),
541 worker_count,
542 image_cache: LruCache::unbounded(),
543 svg_cache: LruCache::unbounded(),
544 failed_cache: LruCache::unbounded(),
545 pending: HashMap::new(),
546 request_ids: HashMap::new(),
547 bytes_used: 0,
548 max_bytes,
549 #[cfg(debug_assertions)]
550 last_reconcile: Instant::now(),
551 watcher: None,
552 watch_rx,
553 watch_tx,
554 watch_waker: Arc::default(),
555 watched: HashSet::new(),
556 shutdown_flag,
557 loaders: AssetLoaders::default(),
558 bundle_source: BundleSource::default(),
559 sources: Vec::new(),
560 }
561 }
562
563 /// Teach the pipeline a format. The loader claims the extensions it
564 /// names, replacing whatever held them before, so an app can override a
565 /// built-in decoder as well as add one.
566 ///
567 /// From a plugin's `build`, prefer [`register_asset_loader`], which
568 /// installs the server first if it is not there yet.
569 pub fn register_loader(&mut self, loader: impl AssetLoader) {
570 self.loaders.register(loader);
571 }
572
573 /// The loader registry, for inspecting which loader handles a path.
574 pub fn loaders(&self) -> &AssetLoaders {
575 &self.loaders
576 }
577
578 /// The loader registry, for registering an already shared loader or
579 /// changing the fallback that handles unclaimed extensions.
580 pub fn loaders_mut(&mut self) -> &mut AssetLoaders {
581 &mut self.loaders
582 }
583
584 /// Add a byte source. Sources are consulted in registration order after
585 /// the bundles; a path none of them claims is read from disk.
586 pub fn register_source(&mut self, source: impl AssetSource) {
587 self.sources.push(Arc::new(source));
588 }
589
590 /// Register a `.lpak` bundle with the server. Subsequent decode
591 /// lookups for `lumen://app/<path>` URIs route through every
592 /// registered bundle in insertion order. Bundles are cheap to
593 /// clone (one `Arc<[u8]>` bump) so the same instance can be
594 /// shared with `lumen-text-cosmic`'s font_db registration pass.
595 ///
596 /// Set [`Self::set_bundle_root`] as well to serve plain filesystem
597 /// paths (`<app_dir>/icons/sun.png`) out of the archive.
598 pub fn register_bundle(&mut self, bundle: LumenBundle) {
599 self.bundle_source.register(bundle);
600 }
601
602 /// Declare the directory registered bundles were packed from. A
603 /// lookup for a path under this root is tried against the bundles
604 /// first, using the remainder of the path as the key; the entry
605 /// wins over the file on disk, so an app can ship the archive and
606 /// leave the loose files behind.
607 pub fn set_bundle_root(&mut self, root: impl Into<PathBuf>) {
608 self.bundle_source.set_root(root);
609 }
610
611 /// Iterate the currently registered bundles. Used by the
612 /// font-registration helper in `lumen-text-cosmic`.
613 pub fn bundles(&self) -> &[LumenBundle] {
614 self.bundle_source.bundles()
615 }
616
617 /// Resolve `lumen://app/<rel>` URI strings to bundled bytes. Returns
618 /// `None` when no bundle satisfies the URI or when the URI uses an
619 /// unknown scheme (callers fall back to disk in that case).
620 pub fn resolve_uri(&self, uri: &str) -> Option<Vec<u8>> {
621 self.bundle_source.read_uri(uri)
622 }
623
624 /// Resolve an [`ImageSource`]-shaped path out of the registered
625 /// bundles: either a `lumen://app/<key>` URI, or a filesystem path
626 /// under [`Self::set_bundle_root`] whose remainder is the key.
627 /// `None` means "not bundled", and the caller reads from disk.
628 pub fn resolve_bundled(&self, path: &Path) -> Option<Vec<u8>> {
629 self.bundle_source.read(path)
630 }
631
632 /// Bytes for `path` from the first source that claims it: the bundles,
633 /// then anything passed to [`Self::register_source`]. `None` leaves the
634 /// read to the loader, which means the filesystem.
635 fn resolve_source_bytes(&self, path: &Path) -> Option<Vec<u8>> {
636 source::read_chain(&self.bundle_source, &self.sources, path)
637 }
638
639 /// A [`SourceReader`] snapshot of the current source chain, for reading
640 /// raw bytes off the main thread. See the type's docs for the contract;
641 /// take a fresh one per request, because a snapshot does not see sources
642 /// registered after it.
643 pub fn source_reader(&self) -> SourceReader {
644 SourceReader::new(self.bundle_source.clone(), self.sources.clone())
645 }
646
647 /// Returns the approximate CPU bytes held by the image and SVG content caches.
648 /// O(1): returns the running counter maintained on insert / evict.
649 pub fn bytes_used(&self) -> usize {
650 self.bytes_used
651 }
652
653 /// Returns the current size cap. Inserts that would push `bytes_used` past this number evict LRU entries
654 /// before completing.
655 pub fn max_bytes(&self) -> usize {
656 self.max_bytes
657 }
658
659 /// Resets the size cap and immediately evicts down to the new bound.
660 pub fn set_max_bytes(&mut self, max_bytes: usize) {
661 self.max_bytes = max_bytes;
662 self.evict_until(max_bytes);
663 }
664
665 /// Recomputes `bytes_used` from scratch by summing every cached entry. O(n). Used only by the debug
666 /// reconciliation tick to catch any insert / evict path that forgot to update the counter.
667 fn recompute_bytes_used(&self) -> usize {
668 let mut total: usize = 0;
669 for (_, img) in self.image_cache.iter() {
670 total = total.saturating_add(img.0.bytes());
671 }
672 for (_, svg) in self.svg_cache.iter() {
673 total = total.saturating_add(svg.0.bytes());
674 }
675 total
676 }
677
678 /// Evicts the least-recently-used cached entries until `bytes_used <= target_bytes`.
679 /// Images are evicted before SVGs (image RGBA buffers dominate the budget by an order of magnitude).
680 /// Entities holding strong `Handle<...>` clones keep their decoded data alive via the Arc refcount.
681 pub fn evict_until(&mut self, target_bytes: usize) {
682 while self.bytes_used > target_bytes {
683 if let Some((_, img)) = self.image_cache.pop_lru() {
684 self.bytes_used = self.bytes_used.saturating_sub(img.0.bytes());
685 continue;
686 }
687 if let Some((_, svg)) = self.svg_cache.pop_lru() {
688 self.bytes_used = self.bytes_used.saturating_sub(svg.0.bytes());
689 continue;
690 }
691 break;
692 }
693 }
694
695 /// Bumps the per-entity monotonic request id and returns the new value.
696 ///
697 /// The runtime's `ScriptCommand::SetSrc` handler (in `lumen-runtime`) calls this **before**
698 /// re-inserting [`ImageSource`] on the entity. Any in-flight decode for the prior path will then be
699 /// identified as stale on completion via the snapshot id stored on its waiter entry, and either
700 /// dropped from the per-entity insert step (if some other waiter still wants the path) or discarded
701 /// entirely (no cache write) if every waiter has moved on.
702 ///
703 /// Skipping this call leaves the stale-decode race in place: the runtime swaps `ImageSource` while a
704 /// decode for the old bytes is in flight, then `drain_completed_decodes` attaches the old image.
705 pub fn bump_request_id(&mut self, entity: Entity) -> u64 {
706 let slot = self.request_ids.entry(entity).or_insert(0);
707 *slot = slot.saturating_add(1);
708 *slot
709 }
710
711 /// Returns the entity's current request id (or 0 if it has never been bumped).
712 pub fn current_request_id(&self, entity: Entity) -> u64 {
713 self.request_ids.get(&entity).copied().unwrap_or(0)
714 }
715
716 /// Spawn the decode worker pool if it has not been spawned yet.
717 /// Called from the enqueue path so the threads exist only once an app
718 /// requests a decode. Idempotent and cheap after the first
719 /// call (empty-check short-circuit). A no-op after [`Self::shutdown`]
720 /// (the channel templates are gone).
721 fn ensure_workers(&mut self) {
722 if !self.workers.is_empty() {
723 return;
724 }
725 let (Some(job_rx), Some(result_tx)) = (self.job_rx.as_ref(), self.result_tx.as_ref())
726 else {
727 return;
728 };
729 self.workers.reserve(self.worker_count);
730 for i in 0..self.worker_count {
731 let job_rx = job_rx.clone();
732 let result_tx = result_tx.clone();
733 let shutdown_flag = self.shutdown_flag.clone();
734 let handle = std::thread::Builder::new()
735 .name(format!("lumen-assets-worker-{i}"))
736 .spawn(move || worker_loop(job_rx, result_tx, shutdown_flag))
737 .expect("spawn asset worker");
738 self.workers.push(handle);
739 }
740 }
741
742 /// Looks up `path` in the cache and returns a [`CacheLookup`] outcome.
743 /// On miss, enqueues one decode job and registers `entity` (with its current request id) on the
744 /// pending list for fan-out. The lookup also touches the LRU recency on cache hits.
745 fn lookup_or_enqueue(&mut self, entity: Entity, path: PathBuf) -> CacheLookup {
746 if let Some(img) = self.image_cache.get(&path) {
747 return CacheLookup::HitImage(img.clone());
748 }
749 if let Some(svg) = self.svg_cache.get(&path) {
750 return CacheLookup::HitSvg(svg.clone());
751 }
752 if let Some(failed) = self.failed_cache.get(&path) {
753 return CacheLookup::HitFailed(ImageLoadFailed {
754 // `LoadErrorKind` is not `Clone` (it carries `io::Error`); clone-via-detail is acceptable for
755 // the cached-failure path because the original kind is preserved on the cache entry and only
756 // the consumer-facing detail string matters at this point.
757 kind: clone_kind(&failed.kind),
758 detail: failed.detail.clone(),
759 });
760 }
761 let request_id = self.current_request_id(entity);
762 // Pick the loader here, on the main thread, so the registry is never
763 // read from a worker. A path no loader claims (only possible once
764 // the fallback has been cleared) fails immediately rather than
765 // occupying a worker to reach the same answer.
766 let Some(loader) = self.loaders.resolve(&path) else {
767 return CacheLookup::HitFailed(ImageLoadFailed::new(LoadErrorKind::Unsupported));
768 };
769 // If a source holds this path, resolve the bytes synchronously here.
770 // The worker thread doesn't see the `AssetServer` resource, so we
771 // have to feed it the payload upfront.
772 let resolved_bytes = self.resolve_source_bytes(&path);
773 // Reaching here means a cache miss: this call will either enqueue a
774 // fresh decode (Vacant) or attach to one already in flight
775 // (Occupied - the pool was spawned by that prior enqueue). Either
776 // way the worker pool must exist; spawn it lazily now. Idempotent.
777 self.ensure_workers();
778 match self.pending.entry(path.clone()) {
779 MapEntry::Occupied(mut e) => {
780 e.get_mut().push(PendingWaiter { entity, request_id });
781 CacheLookup::InFlight
782 }
783 MapEntry::Vacant(e) => {
784 e.insert(vec![PendingWaiter { entity, request_id }]);
785 if let Some(tx) = self.job_tx.as_ref() {
786 let _ = tx.send(DecodeJob {
787 path,
788 loader,
789 request_id,
790 resolved_bytes,
791 });
792 }
793 CacheLookup::Enqueued
794 }
795 }
796 }
797
798 /// Ensures a `RecommendedWatcher` exists and subscribes to `path`'s parent directory so file rewrites are
799 /// observed. Watch ops are idempotent; resubscribing an already-watched path is cheap.
800 fn watch_path(&mut self, path: &Path) {
801 if self.watched.contains(path) {
802 return;
803 }
804 if self.watcher.is_none() {
805 let tx = self.watch_tx.clone();
806 let waker = Arc::clone(&self.watch_waker);
807 let watcher = RecommendedWatcher::new(
808 move |res| {
809 let _ = tx.send(res);
810 if let Some(w) = waker.get() {
811 w.wake();
812 }
813 },
814 notify::Config::default(),
815 );
816 match watcher {
817 Ok(w) => self.watcher = Some(w),
818 Err(e) => {
819 tracing::warn!("asset watcher init failed: {e}");
820 return;
821 }
822 }
823 }
824 if let Some(w) = self.watcher.as_mut() {
825 // Watching the file directly is sufficient on inotify / FSEvents; we mirror the path set into
826 // `self.watched` so we can map event paths back to cache keys without rescanning all caches.
827 if let Err(e) = w.watch(path, RecursiveMode::NonRecursive) {
828 tracing::warn!("asset watcher subscribe failed for {path:?}: {e}");
829 return;
830 }
831 self.watched.insert(path.to_path_buf());
832 }
833 }
834
835 /// Drops the watch on `path` so the next cache insert subscribes it again. An editor that saves by
836 /// writing a new file and renaming it over the old one leaves the subscription on the replaced file,
837 /// and every later save would go unseen if the path stayed marked as watched.
838 fn forget_watch(&mut self, path: &Path) {
839 if self.watched.remove(path)
840 && let Some(w) = self.watcher.as_mut()
841 {
842 // The platform may have dropped the registration with the replaced file already; either way the
843 // path is no longer covered.
844 let _ = w.unwatch(path);
845 }
846 }
847
848 /// Invalidates cache entries (success + failure) for `path` and returns whether anything was dropped.
849 /// `bytes_used` is updated in O(1) for every removed image / SVG.
850 fn invalidate_path(&mut self, path: &Path) -> bool {
851 let mut hit = false;
852 if let Some(img) = self.image_cache.pop(path) {
853 self.bytes_used = self.bytes_used.saturating_sub(img.0.bytes());
854 hit = true;
855 }
856 if let Some(svg) = self.svg_cache.pop(path) {
857 self.bytes_used = self.bytes_used.saturating_sub(svg.0.bytes());
858 hit = true;
859 }
860 if self.failed_cache.pop(path).is_some() {
861 hit = true;
862 }
863 hit
864 }
865
866 /// Drains the dispatcher channel, joins every worker, clears all caches, and detaches the watcher.
867 /// Idempotent - calling shutdown twice is a no-op after the first.
868 ///
869 /// Takes `&mut self` rather than `&self`. The audit spec suggested `&self` for ergonomics; the
870 /// in-place mutation of caches + the watcher option + the join-handle vec makes the `&mut self`
871 /// signature the natural choice once interior `Mutex`-wrapping is avoided. `AssetServer` is a
872 /// `Resource`, so consumers can reach it via `ResMut<AssetServer>::shutdown()`.
873 ///
874 /// `impl Drop for AssetServer` calls this automatically, so [`App`] tear-down (and any test or
875 /// benchmark that builds + drops the server) joins workers cleanly without an explicit shutdown call.
876 pub fn shutdown(&mut self) {
877 // Flip the cancellation flag and disconnect `job_tx`. Workers loop on `recv` and break on Err.
878 if let Ok(mut g) = self.shutdown_flag.lock() {
879 *g = true;
880 }
881 // Drop every sender so `job_rx.recv()` in each worker returns
882 // `RecvError`: the live `job_tx`, plus the lazy-spawn templates the
883 // pool would otherwise be revived from.
884 self.job_tx.take();
885 self.job_rx.take();
886 self.result_tx.take();
887 // Join every worker. Each one has at most one decode in flight (we don't preempt - a long
888 // PNG decode runs to completion), so this is bounded by the decoder's worst case.
889 for handle in self.workers.drain(..) {
890 let _ = handle.join();
891 }
892 // Drop any pending results without inserting into the cache.
893 while self.result_rx.try_recv().is_ok() {}
894 self.image_cache.clear();
895 self.svg_cache.clear();
896 self.failed_cache.clear();
897 self.pending.clear();
898 self.request_ids.clear();
899 self.bytes_used = 0;
900 self.watcher = None;
901 self.watched.clear();
902 }
903
904 /// Inserts a decoded image into the cache, updates `bytes_used`, evicts down to `max_bytes`, and
905 /// subscribes the watcher to the path so subsequent on-disk changes invalidate the entry.
906 fn insert_image(&mut self, path: PathBuf, img: LoadedImage) {
907 insert_cached_asset!(self, image_cache, path, img);
908 }
909
910 /// Inserts a decoded SVG into the cache.
911 fn insert_svg(&mut self, path: PathBuf, svg: LoadedSvg) {
912 insert_cached_asset!(self, svg_cache, path, svg);
913 }
914
915 /// Inserts a decode failure into the cache. Watches the path so a fix-on-disk invalidates the entry;
916 /// the previous `failed_cache` was permanent which left entities `ImageLoadFailed` forever.
917 fn insert_failure(&mut self, path: PathBuf, failure: ImageLoadFailed) {
918 self.failed_cache.push(path.clone(), failure);
919 self.watch_path(&path);
920 }
921
922 /// Debug-only invariant check: at most once per second, recompute `bytes_used` from scratch and assert
923 /// it matches the running counter. Catches insert / evict paths that forget to update the running
924 /// total. Release builds compile this out.
925 fn maybe_reconcile_bytes(&mut self) {
926 #[cfg(debug_assertions)]
927 {
928 let now = Instant::now();
929 if now.duration_since(self.last_reconcile).as_secs() >= 1 {
930 let recomputed = self.recompute_bytes_used();
931 debug_assert_eq!(
932 self.bytes_used, recomputed,
933 "bytes_used drift: running={} recomputed={}",
934 self.bytes_used, recomputed
935 );
936 self.last_reconcile = now;
937 }
938 }
939 }
940}
941
942impl Drop for AssetServer {
943 fn drop(&mut self) {
944 // Run shutdown so worker threads exit instead of leaking when an `App` is dropped (tests, benches,
945 // FFI host teardown). Idempotent: a prior explicit `shutdown()` is a no-op here.
946 if !self.workers.is_empty() || self.job_tx.is_some() {
947 self.shutdown();
948 }
949 }
950}
951
952/// Best-effort clone of `LoadErrorKind`. `io::Error` is not `Clone`; we degrade to a new `io::Error` carrying
953/// the same kind + message. Used only when handing out a cached failure entry to a fresh entity.
954fn clone_kind(k: &LoadErrorKind) -> LoadErrorKind {
955 match k {
956 LoadErrorKind::NotFound => LoadErrorKind::NotFound,
957 LoadErrorKind::DecodeFailed(msg) => LoadErrorKind::DecodeFailed(msg.clone()),
958 LoadErrorKind::Io(e) => LoadErrorKind::Io(std::io::Error::new(e.kind(), e.to_string())),
959 LoadErrorKind::Cancelled => LoadErrorKind::Cancelled,
960 LoadErrorKind::Unsupported => LoadErrorKind::Unsupported,
961 }
962}
963
964/// N-worker decode loop. Reads jobs from the shared `job_rx`, runs the loader the job carries, and forwards
965/// the result. Exits cleanly on `RecvError` (channel disconnected - the only way `AssetServer::shutdown`
966/// signals teardown).
967///
968/// Every job that reaches a worker answers, including one whose loader
969/// panics. A loader is third-party code; an escaping panic would unwind out
970/// of this thread and take the job's entry on the pending list with it, so
971/// the waiting element would keep its [`Enqueued`] marker for the rest of
972/// the session and the frame loop would stay scheduled on its behalf.
973/// Catching the unwind turns a panicking decode into a failed decode, which
974/// the element receives like any other verdict, and leaves the worker free
975/// for the next job.
976fn worker_loop(
977 job_rx: Receiver<DecodeJob>,
978 result_tx: Sender<DecodeResult>,
979 shutdown_flag: Arc<Mutex<bool>>,
980) {
981 while let Ok(job) = job_rx.recv() {
982 // Short-circuit if shutdown was requested between recv and decode.
983 if shutdown_flag.lock().map(|g| *g).unwrap_or(false) {
984 break;
985 }
986 let ctx = LoadContext::new(&job.path, job.resolved_bytes.as_deref());
987 let decoded = catch_unwind(AssertUnwindSafe(|| job.loader.load(&ctx)));
988 let outcome = decoded.unwrap_or_else(|_| {
989 // The panic hook has already printed the message and the
990 // location; the detail here is what the element's
991 // `ImageLoadFailed` carries.
992 let detail = format!("the loader for {:?} panicked", job.path);
993 Err(LoadErrorKind::DecodeFailed(detail))
994 });
995 let _ = result_tx.send(DecodeResult {
996 path: job.path,
997 request_id: job.request_id,
998 outcome,
999 });
1000 }
1001}
1002
1003/// Plugin that installs [`AssetServer`] and registers [`spawn_pending_decodes`], [`drain_completed_decodes`], [`process_watch_events`], and the image/SVG extract fns.
1004pub struct AssetsPlugin;
1005
1006impl Plugin for AssetsPlugin {
1007 fn build(self, app: &mut App) {
1008 // Init rather than insert: a plugin installed earlier may already
1009 // have created the server to register a loader on it, and replacing
1010 // it here would drop that registration.
1011 app.world.init_resource::<AssetServer>();
1012 app.add_message::<AssetReloadRequested>();
1013 app.add_systems(TickStage::Systems, spawn_pending_decodes);
1014 app.add_systems(
1015 TickStage::Systems,
1016 drain_completed_decodes.after(spawn_pending_decodes),
1017 );
1018 app.add_systems(
1019 TickStage::Systems,
1020 process_watch_events.after(drain_completed_decodes),
1021 );
1022 // D6: intrinsic-size stamping. `Added<LoadedImage>` from either
1023 // the cache-hit or the decode-completion path is visible here on
1024 // the tick after the insert; the write dirties layout via the
1025 // layout crate's `Changed<ImageComponent>` hook.
1026 app.add_systems(
1027 TickStage::Systems,
1028 stamp_image_natural_size.after(drain_completed_decodes),
1029 );
1030 // Late in the tick, so the loop-awake check reads the tree the
1031 // reconcilers and the hot reload left behind rather than the one
1032 // `spawn_pending_decodes` saw at the top of `Systems`.
1033 app.add_systems(TickStage::A11ySync, keep_frame_loop_awake_for_assets);
1034 app.add_extract_fn(extract_loaded_images);
1035 app.add_extract_fn(extract_loaded_svgs);
1036 }
1037}
1038
1039/// Registers an [`AssetLoader`] from a plugin's `build`, installing the
1040/// [`AssetServer`] first if this plugin runs before [`AssetsPlugin`].
1041///
1042/// ```no_run
1043/// # use lumen_core::prelude::*;
1044/// # use lumen_assets::{AssetKind, AssetLoader, LoadContext, LoadErrorKind, LoadedAsset};
1045/// # struct QoiLoader;
1046/// # impl AssetLoader for QoiLoader {
1047/// # fn extensions(&self) -> &[&str] { &["qoi"] }
1048/// # fn kind(&self) -> AssetKind { AssetKind::Image }
1049/// # fn load(&self, _ctx: &LoadContext<'_>) -> Result<LoadedAsset, LoadErrorKind> {
1050/// # Err(LoadErrorKind::Unsupported)
1051/// # }
1052/// # }
1053/// struct QoiPlugin;
1054///
1055/// impl Plugin for QoiPlugin {
1056/// fn build(self, app: &mut App) {
1057/// lumen_assets::register_asset_loader(app, QoiLoader);
1058/// }
1059/// }
1060/// ```
1061pub fn register_asset_loader(app: &mut App, loader: impl AssetLoader) {
1062 if !app.world.contains_resource::<AssetServer>() {
1063 app.world.insert_resource(AssetServer::default());
1064 }
1065 app.world
1066 .resource_mut::<AssetServer>()
1067 .register_loader(loader);
1068}
1069
1070// `LruCache::unbounded()` uses `NonZeroUsize::MAX` internally; we never use bounded mode because the byte
1071// budget is enforced via `evict_until(max_bytes)` directly on `bytes_used`. Keeping this in scope makes the
1072// dependency explicit.
1073#[allow(dead_code)]
1074const _LRU_CAP_PROBE: NonZeroUsize = NonZeroUsize::MIN;
1075
1076/// What placing an image needs from the whole tree: the scroll offset and
1077/// the alpha each element's own paints carry. A content image moves and
1078/// fades with its ancestors, and a background moves and fades with the
1079/// element whose colour it replaces, so both read the two the way the rect
1080/// extract does.
1081struct Placement {
1082 scroll: HashMap<Entity, glam::Vec2>,
1083 alphas: HashMap<Entity, f32>,
1084}
1085
1086impl Placement {
1087 fn new(main: &mut World, parents: &HashMap<Entity, Entity>) -> Self {
1088 Self {
1089 scroll: parent_scroll_offsets(main, parents),
1090 alphas: opacity_model(main, parents).draw,
1091 }
1092 }
1093
1094 /// The on-screen origin and the alpha of `e`: its layout position less
1095 /// its ancestors' scroll, and the alpha its own paints carry.
1096 fn place(&self, e: Entity, t: &Transform) -> (glam::Vec2, f32) {
1097 let origin = t.absolute - self.scroll.get(&e).copied().unwrap_or(glam::Vec2::ZERO);
1098 let alpha = self.alphas.get(&e).copied().unwrap_or(1.0);
1099 (origin, alpha)
1100 }
1101
1102 /// The on-screen origin, the alpha, and the clip of `e`'s background.
1103 fn place_background(
1104 &self,
1105 e: Entity,
1106 t: &Transform,
1107 visuals: Option<&Visuals>,
1108 ) -> (glam::Vec2, f32, BackgroundClip) {
1109 let (origin, alpha) = self.place(e, t);
1110 let radii = visuals
1111 .map(|v| v.corner_radii.unwrap_or([v.radius; 4]))
1112 .unwrap_or([0.0; 4]);
1113 let clip = BackgroundClip {
1114 origin,
1115 size: t.size,
1116 radii,
1117 };
1118 (origin, alpha, clip)
1119 }
1120}
1121
1122/// Extracts every `(Transform, LoadedSvg)` entity into an [`ExtractedSvg`] in the render world via keyed upsert.
1123/// Despawns prior-frame render entries whose source entity no longer matches.
1124pub fn extract_loaded_svgs(main: &mut World, render: &mut World) {
1125 use lumen_core::components::{ImageFit, SvgPayload};
1126 use lumen_core::render_world::{
1127 RenderEntityMap, build_parent_map, hidden_entities, paint_order_of,
1128 };
1129 let (parents, mut depth_cache) = build_parent_map(main);
1130 // A `Visible(false)` on this entity or any ancestor suppresses paint for
1131 // the whole subtree (CSS `visibility: hidden`), matching the core rect /
1132 // text extractors.
1133 let hidden = hidden_entities(main, &parents);
1134 let placement = Placement::new(main, &parents);
1135 #[allow(clippy::type_complexity)]
1136 let mut q = main.query::<(
1137 Entity,
1138 &Transform,
1139 &LoadedSvg,
1140 Option<&ImageFit>,
1141 Option<&BackgroundImage>,
1142 Option<&Visuals>,
1143 )>();
1144 let pairs: Vec<(Entity, ExtractedSvg, SvgPayload)> = q
1145 .iter(main)
1146 .filter(|(e, ..)| !hidden.contains(e))
1147 .map(|(e, t, svg, fit, bg, visuals)| {
1148 let (origin, alpha, fit, background) = match bg {
1149 Some(bg) => {
1150 let (origin, alpha, clip) = placement.place_background(e, t, visuals);
1151 (origin, alpha, bg.fit, Some(clip))
1152 }
1153 None => {
1154 let (origin, alpha) = placement.place(e, t);
1155 // Default fit for SVGs is `Contain` (aspect-preserving).
1156 (
1157 origin,
1158 alpha,
1159 fit.copied().unwrap_or(ImageFit::Contain),
1160 None,
1161 )
1162 }
1163 };
1164 let extracted = ExtractedSvg {
1165 origin,
1166 size: t.size,
1167 intrinsic: svg.intrinsic,
1168 asset: svg.0.clone(),
1169 fit,
1170 order: paint_order_of(e, &parents, &mut depth_cache),
1171 alpha,
1172 };
1173 // Sidecar payload for the Node-IR splice path. The walker downcasts the inner Arc
1174 // back to ExtractedSvg.
1175 let payload = SvgPayload {
1176 payload: std::sync::Arc::new(extracted.clone()),
1177 order: extracted.order,
1178 background,
1179 };
1180 (e, extracted, payload)
1181 })
1182 .collect();
1183 // Keyed upsert against `RenderEntityMap.svg`; reused render entities are filtered for current validity.
1184 let prior = std::mem::take(&mut render.resource_mut::<RenderEntityMap>().svg);
1185 let mut next: std::collections::HashMap<Entity, Entity> =
1186 std::collections::HashMap::with_capacity(pairs.len());
1187 for (main_e, svg, payload) in pairs {
1188 let reuse = prior
1189 .get(&main_e)
1190 .copied()
1191 .filter(|&re| render.get_entity(re).is_ok());
1192 let render_e = match reuse {
1193 Some(re) => {
1194 render.entity_mut(re).insert((svg, payload));
1195 re
1196 }
1197 None => render.spawn((svg, payload)).id(),
1198 };
1199 next.insert(main_e, render_e);
1200 }
1201 for (main_e, render_e) in &prior {
1202 if !next.contains_key(main_e)
1203 && let Ok(em) = render.get_entity_mut(*render_e)
1204 {
1205 em.despawn();
1206 }
1207 }
1208 render.resource_mut::<RenderEntityMap>().svg = next;
1209}
1210
1211/// Render-world sidecar carrying a pre-built `peniko::Blob` for an extracted image.
1212/// Queried alongside `ExtractedImage` by the renderer so the Blob reaches the painter unchanged, preserving its upload-cache identity.
1213///
1214/// ## Node IR splice (W2.1 follow-up)
1215///
1216/// The retained Node IR's `Node::Image { blob: Option<Arc<dyn Any + Send + Sync>> }` already accepts this
1217/// payload as an opaque `Arc<dyn Any>` so the walker downcasts back to `ExtractedImageBlob` and paints it.
1218/// On the on-screen winit path the splice is handled today by `render_frame` reading both `ExtractedImage` and
1219/// `ExtractedImageBlob` in lockstep and emitting the image draw alongside the retained-tree walk; the same
1220/// splice is the reason the offscreen retained-tree path doesn't yet light up images. The audit-follow-up that
1221/// finishes the migration must update `lumen_core::node_ir::transform_extracted_to_nodes` to query
1222/// `ExtractedImageBlob` and set `Node::Image.blob = Some(Arc::new(blob.clone()))` directly - which requires a
1223/// core-touching PR that this crate is not allowed to perform alone.
1224#[derive(Component, Clone)]
1225pub struct ExtractedImageBlob(pub peniko::Blob<u8>);
1226
1227/// Extracts every `(Transform, LoadedImage)` entity into a paired [`ExtractedImage`] + [`ExtractedImageBlob`]
1228/// in the render world. Clones the `Blob` (Arc-cheap) so the renderer's upload-cache identity is preserved across
1229/// frames.
1230///
1231/// The sidecar [`ExtractedImageBlob`] is the Node-IR-ready blob payload - see its doc-comment for the splice
1232/// path to `Node::Image { blob: Some(...) }`.
1233pub fn extract_loaded_images(main: &mut World, render: &mut World) {
1234 use lumen_core::components::{ImageBlob, ImageFit};
1235 use lumen_core::render_world::{build_parent_map, hidden_entities, paint_order_of};
1236 let (parents, mut depth_cache) = build_parent_map(main);
1237 // A `Visible(false)` on this entity or any ancestor suppresses paint for
1238 // the whole subtree (CSS `visibility: hidden`), matching the core rect /
1239 // text extractors.
1240 let hidden = hidden_entities(main, &parents);
1241 let placement = Placement::new(main, &parents);
1242 #[allow(clippy::type_complexity)]
1243 let mut q = main.query::<(
1244 Entity,
1245 &Transform,
1246 &LoadedImage,
1247 Option<&ImageFit>,
1248 Option<&BackgroundImage>,
1249 Option<&Visuals>,
1250 )>();
1251 let rows: Vec<(Entity, ExtractedImage, ExtractedImageBlob, ImageBlob)> = q
1252 .iter(main)
1253 .filter(|(e, ..)| !hidden.contains(e))
1254 .map(|(e, t, img, fit, bg, visuals)| {
1255 let (origin, alpha, fit, background) = match bg {
1256 Some(bg) => {
1257 let (origin, alpha, clip) = placement.place_background(e, t, visuals);
1258 (origin, alpha, bg.fit, Some(clip))
1259 }
1260 None => {
1261 let (origin, alpha) = placement.place(e, t);
1262 (origin, alpha, fit.copied().unwrap_or_default(), None)
1263 }
1264 };
1265 let extracted = ExtractedImage {
1266 origin,
1267 size: t.size,
1268 width: img.width,
1269 height: img.height,
1270 rgba: img.rgba.clone(),
1271 fit,
1272 order: paint_order_of(e, &parents, &mut depth_cache),
1273 alpha,
1274 background,
1275 };
1276 // Sidecar: the legacy `ExtractedImageBlob` keeps any external query path
1277 // (embedders, headless tests) working; the core-facing `ImageBlob` carries the same
1278 // payload type-erased so `lumen_core::node_ir::transform_extracted_to_nodes` can
1279 // splice it into `Node::Image.blob` without lumen-core needing a `peniko` dep.
1280 let blob = ExtractedImageBlob(img.blob.clone());
1281 let core_blob = ImageBlob(std::sync::Arc::new(blob.clone()));
1282 (e, extracted, blob, core_blob)
1283 })
1284 .collect();
1285 // Keyed upsert against `RenderEntityMap.image`; reused render entities are filtered for current validity.
1286 use lumen_core::render_world::RenderEntityMap;
1287 let prior = std::mem::take(&mut render.resource_mut::<RenderEntityMap>().image);
1288 let mut next: std::collections::HashMap<Entity, Entity> =
1289 std::collections::HashMap::with_capacity(rows.len());
1290 for (main_e, img, blob, core_blob) in rows {
1291 let reuse = prior
1292 .get(&main_e)
1293 .copied()
1294 .filter(|&re| render.get_entity(re).is_ok());
1295 let render_e = match reuse {
1296 Some(re) => {
1297 render.entity_mut(re).insert((img, blob, core_blob));
1298 re
1299 }
1300 None => render.spawn((img, blob, core_blob)).id(),
1301 };
1302 next.insert(main_e, render_e);
1303 }
1304 for (main_e, render_e) in &prior {
1305 if !next.contains_key(main_e)
1306 && let Ok(em) = render.get_entity_mut(*render_e)
1307 {
1308 em.despawn();
1309 }
1310 }
1311 render.resource_mut::<RenderEntityMap>().image = next;
1312}
1313
1314/// Raises [`FrameDirty`] because the pipeline just settled an entity's
1315/// source.
1316///
1317/// An asset is the one piece of scene content that arrives out of step with
1318/// the tree it belongs to: the tree spawns on one tick, and the decoded
1319/// bitmap is attached one or more ticks later, from a worker thread or from
1320/// the cache. Nothing else reports that arrival. `LoadedImage` is not one of
1321/// the components the frame-dirty roll-up watches, and an image sized by its
1322/// style (`width="100%"`) relayouts to the same box, so the write leaves
1323/// `Transform` untouched as well.
1324///
1325/// On a settled app the payload then lands on a tick that raises no other
1326/// change signal, and a tick with a clear [`FrameDirty`] is never extracted:
1327/// the window keeps showing a frame with a hole where the image is, until
1328/// something unrelated repaints. That is what makes an image disappear for
1329/// the rest of the session after a hot reload respawns the tree, while every
1330/// other element comes back.
1331///
1332/// A failure verdict raises it too. Nothing draws [`ImageLoadFailed`] today,
1333/// so that raise costs one present with nothing new on it; an attach path
1334/// that skips the raise is the shape of the defect above, and the next thing
1335/// to draw a broken source would inherit it.
1336///
1337/// The raise is one-shot per arrival: the entity carries its verdict
1338/// afterwards, so it leaves every attach path and the app parks again.
1339fn mark_frame_dirty(frame_dirty: &mut Option<ResMut<'_, FrameDirty>>) {
1340 if let Some(fd) = frame_dirty.as_mut() {
1341 fd.dirty = true;
1342 }
1343}
1344
1345/// Consults the content cache for each [`ImageSource`] entity lacking a [`LoadedImage`], [`LoadedSvg`], [`ImageLoadFailed`], or [`Enqueued`].
1346/// Cache hits attach the cached component synchronously; misses enqueue one decode job and append the entity (with its current `request_id`) to the fan-out list.
1347#[allow(clippy::type_complexity)]
1348pub fn spawn_pending_decodes(
1349 mut server: ResMut<AssetServer>,
1350 pending: Query<
1351 (Entity, &ImageSource),
1352 (
1353 Without<LoadedImage>,
1354 Without<LoadedSvg>,
1355 Without<ImageLoadFailed>,
1356 Without<Enqueued>,
1357 ),
1358 >,
1359 mut commands: Commands,
1360 mut frame_dirty: Option<ResMut<FrameDirty>>,
1361) {
1362 for (entity, source) in &pending {
1363 // `try_insert` throughout: the query answers for the world as this
1364 // system runs, and a later system in the same stage can despawn one
1365 // of these entities before the write lands.
1366 match server.lookup_or_enqueue(entity, source.0.clone()) {
1367 CacheLookup::HitImage(img) => {
1368 commands.entity(entity).try_insert(img);
1369 mark_frame_dirty(&mut frame_dirty);
1370 }
1371 CacheLookup::HitSvg(svg) => {
1372 commands.entity(entity).try_insert(svg);
1373 mark_frame_dirty(&mut frame_dirty);
1374 }
1375 CacheLookup::HitFailed(failed) => {
1376 commands.entity(entity).try_insert(failed);
1377 mark_frame_dirty(&mut frame_dirty);
1378 }
1379 CacheLookup::InFlight | CacheLookup::Enqueued => {
1380 commands.entity(entity).try_insert(Enqueued);
1381 }
1382 }
1383 }
1384}
1385
1386/// Drains completed decodes from the result channel.
1387///
1388/// For each result:
1389/// - Filters the path's waiter list against per-entity request ids. Waiters whose snapshot id no longer
1390/// matches their entity's current `RequestId` have been superseded by a newer `SetSrc` and are silently
1391/// dropped - they would race with the live decode.
1392/// - If at least one waiter survives the filter: inserts the payload into the success or failure cache
1393/// (LRU-bumping recency) and attaches the result component to every surviving waiter.
1394/// - If *no* waiter survives: the result is dropped entirely (no cache insert). This kills the stale-decode
1395/// race at the cache layer - a SetSrc-storm enqueues N transient decodes whose final results never poison
1396/// the LRU with paths the runtime no longer cares about.
1397pub fn drain_completed_decodes(
1398 mut server: ResMut<AssetServer>,
1399 mut commands: Commands,
1400 mut frame_dirty: Option<ResMut<FrameDirty>>,
1401) {
1402 while let Ok(result) = server.result_rx.try_recv() {
1403 let waiters = server.pending.remove(&result.path).unwrap_or_default();
1404 let surviving: Vec<PendingWaiter> = waiters
1405 .into_iter()
1406 .filter(|w| server.current_request_id(w.entity) == w.request_id)
1407 .collect();
1408 if surviving.is_empty() {
1409 // No waiter is interested any more; the request_id snapshot on the job is stale across every
1410 // waiter. Drop the result without touching the cache. Logs at trace so SetSrc-storm scenarios
1411 // remain inspectable in dev without spamming production builds.
1412 tracing::trace!(
1413 "asset: dropping stale decode for {:?} (request_id {} no longer current)",
1414 result.path,
1415 result.request_id
1416 );
1417 continue;
1418 }
1419 match result.outcome {
1420 Ok(LoadedAsset::Image(img)) => {
1421 dispatch_decoded!(server, commands, result.path, insert_image, img, surviving);
1422 mark_frame_dirty(&mut frame_dirty);
1423 }
1424 Ok(LoadedAsset::Svg(svg)) => {
1425 dispatch_decoded!(server, commands, result.path, insert_svg, svg, surviving);
1426 mark_frame_dirty(&mut frame_dirty);
1427 }
1428 Err(kind) => {
1429 // The failure is cached once under the path and cloned onto
1430 // every surviving waiter, so a second entity requesting the
1431 // same path gets the same verdict without re-running the
1432 // loader.
1433 let failure = ImageLoadFailed::new(kind);
1434 let detail = failure.detail.clone();
1435 let kind_copy = clone_kind(&failure.kind);
1436 server.insert_failure(result.path, failure);
1437 for w in surviving {
1438 let mut ec = commands.entity(w.entity);
1439 ec.try_remove::<Enqueued>();
1440 ec.try_insert(ImageLoadFailed {
1441 kind: clone_kind(&kind_copy),
1442 detail: detail.clone(),
1443 });
1444 }
1445 mark_frame_dirty(&mut frame_dirty);
1446 }
1447 }
1448 }
1449}
1450
1451/// Keeps the frame loop scheduled while an element is still waiting on the
1452/// content it named.
1453///
1454/// An event-driven loop parks the moment a tick leaves no work behind, and a
1455/// parked loop runs no system. An `<image>` that enters the tree late in a
1456/// tick - a hot reload respawning the tree, a page swap mounting a shared
1457/// template - is spawned after [`spawn_pending_decodes`] has already run, so
1458/// without a scheduled follow-up tick it sits unserviced and the window
1459/// keeps showing an empty box until some unrelated event wakes the app. A
1460/// decode already in flight needs the same thing for a different reason:
1461/// nothing wakes the loop when a worker finishes, so the result waits in the
1462/// channel until a tick runs [`drain_completed_decodes`] to read it.
1463///
1464/// Both states are one state on the element: an [`ImageSource`] with no
1465/// verdict on it yet. Keying the claim there rather than on the server's
1466/// in-flight map is what bounds it. A despawn takes the claim with it, so
1467/// the page swap that drops an element mid-decode parks the app instead of
1468/// pinning it at the frame rate, and every way out of the waiting state
1469/// retires the claim: the next tick looks an unlooked-up source up, and
1470/// every job answers with a payload or a failure, a panicking loader
1471/// included (see [`worker_loop`]). The flag therefore reaches `false` on its
1472/// own once the last element has its content, which is what
1473/// [`lumen_core::tick::work_pending`] promises the loops that read it.
1474///
1475/// Runs late in the tick so it sees the tree as the reconcilers left it.
1476#[allow(clippy::type_complexity)]
1477pub fn keep_frame_loop_awake_for_assets(
1478 waiting: Query<
1479 (),
1480 (
1481 With<ImageSource>,
1482 Without<LoadedImage>,
1483 Without<LoadedSvg>,
1484 Without<ImageLoadFailed>,
1485 ),
1486 >,
1487 active: Option<Res<AnimationsActive>>,
1488) {
1489 let Some(active) = active else {
1490 return;
1491 };
1492 if !waiting.is_empty() {
1493 active.request();
1494 }
1495}
1496
1497/// Drains filesystem-watch events and invalidates the cache for every changed path.
1498///
1499/// On each event: pops the path from `image_cache` / `svg_cache` / `failed_cache`, fires
1500/// [`AssetReloadRequested`] so consumers can re-enqueue, and bumps the request id of every entity currently
1501/// holding an `ImageSource(path)` so any in-flight decode for the now-invalidated bytes is identified as
1502/// stale on completion.
1503pub fn process_watch_events(
1504 mut server: ResMut<AssetServer>,
1505 sources: Query<(Entity, &ImageSource)>,
1506 mut writer: MessageWriter<AssetReloadRequested>,
1507 waker: Option<Res<EventLoopWaker>>,
1508) {
1509 // The waker resource appears after plugin build (headless: before the
1510 // loop; windowed: inside `run`), so wire it on the first tick that sees it.
1511 if let Some(w) = waker
1512 && server.watch_waker.get().is_none()
1513 {
1514 let _ = server.watch_waker.set((*w).clone());
1515 }
1516 // Collect changed paths first to release the borrow on `server.watch_rx`. `recv` borrows immutably; we
1517 // need `&mut server` afterwards for `invalidate_path` + `bump_request_id`.
1518 let mut changed: Vec<PathBuf> = Vec::new();
1519 while let Ok(ev) = server.watch_rx.try_recv() {
1520 match ev {
1521 Ok(event) => {
1522 use notify::EventKind;
1523 match event.kind {
1524 EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_) => {
1525 for p in event.paths {
1526 changed.push(p);
1527 }
1528 }
1529 _ => {}
1530 }
1531 }
1532 Err(e) => {
1533 tracing::warn!("asset watch error: {e}");
1534 }
1535 }
1536 }
1537 for path in changed {
1538 server.forget_watch(&path);
1539 if !server.invalidate_path(&path) {
1540 // Still notify consumers - they may want to re-resolve the path even if the cache had nothing.
1541 // But skip the request-id bumps in that case (nothing to reconcile).
1542 }
1543 // Bump request ids for every entity whose source matches.
1544 let affected: Vec<Entity> = sources
1545 .iter()
1546 .filter(|(_, src)| src.0 == path)
1547 .map(|(e, _)| e)
1548 .collect();
1549 for e in affected {
1550 server.bump_request_id(e);
1551 }
1552 writer.write(AssetReloadRequested { path });
1553 }
1554}
1555
1556/// Re-enqueues the load for every element showing a file that changed on disk.
1557///
1558/// Reads [`AssetReloadRequested`] and strips the decoded payload, a cached failure, and the [`Enqueued`] marker
1559/// from each entity whose [`ImageSource`] names the changed path, so [`spawn_pending_decodes`] looks the path
1560/// up again on the next tick. [`process_watch_events`] already dropped the cache entry and moved the entity's
1561/// request id on, so that lookup decodes the new bytes and a decode of the old bytes still in flight is
1562/// discarded.
1563///
1564/// Not installed by [`AssetsPlugin`]: reloading content is part of a hot-reload session, and the runtime
1565/// adds this system when it starts one, ordered after [`process_watch_events`].
1566pub fn reload_changed_images(
1567 mut reloads: MessageReader<AssetReloadRequested>,
1568 sources: Query<(Entity, &ImageSource)>,
1569 mut commands: Commands,
1570) {
1571 let changed: HashSet<PathBuf> = reloads.read().map(|r| r.path.clone()).collect();
1572 if changed.is_empty() {
1573 return;
1574 }
1575 for (entity, source) in &sources {
1576 if changed.contains(&source.0) {
1577 commands
1578 .entity(entity)
1579 .try_remove::<(LoadedImage, LoadedSvg, ImageLoadFailed, Enqueued)>();
1580 }
1581 }
1582}
1583
1584/// Marker component on entities whose decode has been enqueued; excluded from the [`spawn_pending_decodes`] query.
1585#[derive(Component, Clone, Copy, Debug, Default)]
1586pub struct Enqueued;
1587
1588/// A content image whose bitmap just landed. A [`BackgroundImage`] is left
1589/// out: a background never sizes its element.
1590type NewContentImage = (Changed<LoadedImage>, Without<BackgroundImage>);
1591
1592/// D6: stamp the decoded bitmap's intrinsic size onto
1593/// [`lumen_core::components::ImageComponent`] as soon as a
1594/// [`LoadedImage`] lands on the entity (cache hit or decode
1595/// completion - both surface as `Added`/`Changed<LoadedImage>`).
1596///
1597/// The layout backend's measure path reads `ImageComponent.natural_size`
1598/// for images without an explicit size (spec section 13: intrinsic = bitmap
1599/// logical size; explicit size wins because taffy passes known
1600/// dimensions first). The bitmap's pixel size is reported 1:1 as
1601/// logical pixels - no `@2x` asset-scale metadata exists yet. A
1602/// `Changed<ImageComponent>` hook in the layout crate turns this write
1603/// into a `DirtyLayout`, so the image relayouts to its natural size on
1604/// the tick after decode.
1605///
1606/// A [`BackgroundImage`] is skipped: a background never sizes its element.
1607pub fn stamp_image_natural_size(
1608 mut commands: Commands,
1609 changed: Query<(Entity, &ImageSource, &LoadedImage), NewContentImage>,
1610 mut existing: Query<&mut lumen_core::components::ImageComponent>,
1611) {
1612 for (entity, source, img) in &changed {
1613 let natural = glam::Vec2::new(img.width as f32, img.height as f32);
1614 let source_str = source.0.display().to_string();
1615 if let Ok(mut ic) = existing.get_mut(entity) {
1616 // Avoid bumping change detection on identical re-stamps.
1617 if ic.natural_size != Some(natural) || ic.source != source_str {
1618 ic.natural_size = Some(natural);
1619 ic.source = source_str;
1620 }
1621 } else {
1622 // `try_insert`, like every other write this crate queues: the
1623 // query answers for the world as this system runs, and another
1624 // system in the same stage can despawn the entity before the
1625 // buffer is applied. A page swap is exactly that - the `<if>`
1626 // reconciler despawns the outgoing page's tree while an image
1627 // in a shared template decodes into it on the same tick - and a
1628 // plain `insert` turns that into an "Entity despawned" failure
1629 // while applying command buffers.
1630 commands
1631 .entity(entity)
1632 .try_insert(lumen_core::components::ImageComponent {
1633 source: source_str,
1634 natural_size: Some(natural),
1635 });
1636 }
1637 }
1638}
1639
1640#[cfg(test)]
1641mod tests {
1642 use super::*;
1643
1644 fn make_image_data(byte_count: usize) -> ImageData {
1645 let bytes = vec![0u8; byte_count];
1646 ImageData {
1647 width: 4,
1648 height: 4,
1649 rgba: Arc::from(bytes.clone().into_boxed_slice()),
1650 blob: peniko::Blob::new(Arc::new(PixBytes(Arc::from(bytes.into_boxed_slice())))),
1651 }
1652 }
1653
1654 /// D6: once a `LoadedImage` lands on an entity, the stamp system
1655 /// writes the bitmap's intrinsic size into `ImageComponent` (the
1656 /// layout measure path's input) and re-stamps without bumping
1657 /// change detection when nothing changed.
1658 #[test]
1659 fn stamp_image_natural_size_writes_intrinsic() {
1660 use bevy_ecs::system::RunSystemOnce;
1661 let mut world = World::new();
1662 // make_image_data yields a 4x4 bitmap.
1663 let e = world
1664 .spawn((
1665 ImageSource(PathBuf::from("probe.png")),
1666 LoadedImage(make_image_data(64).into()),
1667 ))
1668 .id();
1669 world.run_system_once(stamp_image_natural_size).unwrap();
1670 let ic = world
1671 .get::<lumen_core::components::ImageComponent>(e)
1672 .expect("ImageComponent stamped");
1673 assert_eq!(ic.natural_size, Some(glam::Vec2::new(4.0, 4.0)));
1674 assert_eq!(ic.source, "probe.png");
1675
1676 // Identical re-stamp must not flip change detection (it would
1677 // re-dirty layout every tick via the Changed<ImageComponent>
1678 // hook).
1679 let tick = world
1680 .entity(e)
1681 .get_ref::<lumen_core::components::ImageComponent>()
1682 .unwrap()
1683 .last_changed();
1684 world.run_system_once(stamp_image_natural_size).unwrap();
1685 assert_eq!(
1686 world
1687 .entity(e)
1688 .get_ref::<lumen_core::components::ImageComponent>()
1689 .unwrap()
1690 .last_changed(),
1691 tick
1692 );
1693 }
1694
1695 /// A content image and an SVG move with their ancestors' scroll, the
1696 /// same as a background image does. A faded parent fades them as a
1697 /// group, so their own paints carry none of its opacity; an image's own
1698 /// opacity still folds into its paint.
1699 #[test]
1700 fn content_images_follow_ancestor_scroll_and_opacity() {
1701 use lumen_core::input::ScrollOffset;
1702 use lumen_core::render_world::RenderEntityMap;
1703 let mut main = World::new();
1704 let parent = main
1705 .spawn((
1706 Transform::default(),
1707 ScrollOffset(glam::Vec2::new(0.0, 50.0)),
1708 Opacity(0.5),
1709 ))
1710 .id();
1711 let at = Transform {
1712 absolute: glam::Vec2::new(0.0, 100.0),
1713 size: glam::Vec2::new(10.0, 10.0),
1714 ..Default::default()
1715 };
1716 let content = main
1717 .spawn((
1718 at,
1719 LoadedImage(make_image_data(64).into()),
1720 Opacity(0.25),
1721 ChildOf(parent),
1722 ))
1723 .id();
1724 let background = main
1725 .spawn((
1726 at,
1727 LoadedImage(make_image_data(64).into()),
1728 BackgroundImage::default(),
1729 ChildOf(parent),
1730 ))
1731 .id();
1732 let svg = main
1733 .spawn((
1734 at,
1735 LoadedSvg(
1736 SvgData {
1737 intrinsic: glam::Vec2::new(10.0, 10.0),
1738 tree: empty_svg_tree(),
1739 id: SvgData::next_id(),
1740 source_bytes: 0,
1741 }
1742 .into(),
1743 ),
1744 ChildOf(parent),
1745 ))
1746 .id();
1747 let mut render = World::new();
1748 render.init_resource::<RenderEntityMap>();
1749 extract_loaded_images(&mut main, &mut render);
1750 extract_loaded_svgs(&mut main, &mut render);
1751
1752 let map = render.resource::<RenderEntityMap>();
1753 let (content_e, background_e, svg_e) =
1754 (map.image[&content], map.image[&background], map.svg[&svg]);
1755 for (img, alpha) in [
1756 (render.get::<ExtractedImage>(content_e).unwrap(), 0.25),
1757 (render.get::<ExtractedImage>(background_e).unwrap(), 1.0),
1758 ] {
1759 assert_eq!(img.origin, glam::Vec2::new(0.0, 50.0));
1760 assert_eq!(img.alpha, alpha);
1761 }
1762 let svg = render.get::<ExtractedSvg>(svg_e).unwrap();
1763 assert_eq!(svg.origin, glam::Vec2::new(0.0, 50.0));
1764 assert_eq!(svg.alpha, 1.0);
1765 }
1766
1767 /// A waiter registered at enqueue time can be despawned before its
1768 /// decode lands - a script handler rebuilding a `<for>` list does
1769 /// exactly that. The drain must drop the write instead of panicking on
1770 /// a dead entity, and a live waiter for the same path still gets its
1771 /// verdict.
1772 #[test]
1773 fn a_decode_for_a_despawned_waiter_is_dropped() {
1774 use bevy_ecs::system::RunSystemOnce;
1775 let mut world = World::new();
1776 let path = PathBuf::from("/tmp/lumen-drain-despawned.png");
1777 let gone = world.spawn(ImageSource(path.clone())).id();
1778 let alive = world.spawn(ImageSource(path.clone())).id();
1779 world.despawn(gone);
1780
1781 let mut server = AssetServer::default();
1782 server.pending.insert(
1783 path.clone(),
1784 vec![
1785 PendingWaiter {
1786 entity: gone,
1787 request_id: 0,
1788 },
1789 PendingWaiter {
1790 entity: alive,
1791 request_id: 0,
1792 },
1793 ],
1794 );
1795 server
1796 .result_tx
1797 .as_ref()
1798 .expect("result channel")
1799 .send(DecodeResult {
1800 path,
1801 request_id: 0,
1802 outcome: Err(LoadErrorKind::NotFound),
1803 })
1804 .expect("queue the decode result");
1805 world.insert_resource(server);
1806
1807 world.run_system_once(drain_completed_decodes).unwrap();
1808
1809 assert!(
1810 world.get::<ImageLoadFailed>(alive).is_some(),
1811 "the live waiter still gets the verdict"
1812 );
1813 }
1814
1815 #[test]
1816 fn handle_clone_preserves_identity() {
1817 let data = make_image_data(64);
1818 let h1: Handle<ImageData> = data.into();
1819 let h2 = h1.clone();
1820 assert_eq!(h1.id(), h2.id(), "clones must share identity");
1821 assert_eq!(h1.bytes(), 64);
1822 }
1823
1824 #[test]
1825 fn cache_dedupes_in_flight_requests() {
1826 let mut server = AssetServer::default();
1827 let path = PathBuf::from("/tmp/lumen-cache-test-nonexistent.png");
1828 let e1 = Entity::from_raw_u32(1).unwrap();
1829 let e2 = Entity::from_raw_u32(2).unwrap();
1830 let e3 = Entity::from_raw_u32(3).unwrap();
1831 assert!(matches!(
1832 server.lookup_or_enqueue(e1, path.clone()),
1833 CacheLookup::Enqueued
1834 ));
1835 assert!(matches!(
1836 server.lookup_or_enqueue(e2, path.clone()),
1837 CacheLookup::InFlight
1838 ));
1839 assert!(matches!(
1840 server.lookup_or_enqueue(e3, path.clone()),
1841 CacheLookup::InFlight
1842 ));
1843 let waiters = server.pending.get(&path).expect("pending list");
1844 assert_eq!(waiters.len(), 3);
1845 }
1846
1847 #[test]
1848 fn evict_until_drops_lru_first() {
1849 let mut server = AssetServer::default();
1850 // Insert three 16 KB images.
1851 let paths: Vec<PathBuf> = (0..3u8)
1852 .map(|i| PathBuf::from(format!("/tmp/lumen-evict-test-{i}.png")))
1853 .collect();
1854 for p in &paths {
1855 server.insert_image(p.clone(), LoadedImage(make_image_data(16 * 1024).into()));
1856 }
1857 // Touch the most-recently-inserted entry so it's the freshest (insert already bumps recency, but make it explicit).
1858 let _touched = server.image_cache.get(&paths[2]).cloned();
1859 assert!(server.bytes_used() >= 48 * 1024);
1860 // Cap to 20 KB; the LRU drops the oldest two.
1861 server.evict_until(20 * 1024);
1862 assert!(server.bytes_used() <= 20 * 1024);
1863 assert!(server.image_cache.len() <= 1);
1864 // The freshest entry must survive - that's the LRU contract.
1865 assert!(server.image_cache.contains(&paths[2]));
1866 assert!(!server.image_cache.contains(&paths[0]));
1867 }
1868
1869 #[test]
1870 fn cache_returns_cached_hit_synchronously() {
1871 let mut server = AssetServer::default();
1872 let path = PathBuf::from("/tmp/lumen-cache-test-cached.png");
1873 let cached = LoadedImage(make_image_data(16).into());
1874 let cached_id = cached.0.id();
1875 server.insert_image(path.clone(), cached);
1876 let e = Entity::from_raw_u32(7).unwrap();
1877 match server.lookup_or_enqueue(e, path.clone()) {
1878 CacheLookup::HitImage(img) => {
1879 assert_eq!(img.0.id(), cached_id, "must hand out same handle");
1880 }
1881 _ => panic!("expected cache hit"),
1882 }
1883 assert!(
1884 !server.pending.contains_key(&path),
1885 "no decode should be queued"
1886 );
1887 }
1888
1889 #[test]
1890 fn bytes_used_is_o1_running_counter() {
1891 let mut server = AssetServer::default();
1892 let p1 = PathBuf::from("/tmp/lumen-bytes-1.png");
1893 let p2 = PathBuf::from("/tmp/lumen-bytes-2.png");
1894 server.insert_image(p1.clone(), LoadedImage(make_image_data(1024).into()));
1895 assert_eq!(server.bytes_used(), 1024);
1896 server.insert_image(p2.clone(), LoadedImage(make_image_data(2048).into()));
1897 assert_eq!(server.bytes_used(), 1024 + 2048);
1898 // Overwrite p1 with a smaller image; bytes_used must reflect the swap exactly.
1899 server.insert_image(p1.clone(), LoadedImage(make_image_data(512).into()));
1900 assert_eq!(server.bytes_used(), 512 + 2048);
1901 // Recomputed sweep must agree with the running counter.
1902 assert_eq!(server.bytes_used(), server.recompute_bytes_used());
1903 }
1904
1905 #[test]
1906 fn invalidate_path_drops_caches_and_decrements_bytes() {
1907 let mut server = AssetServer::default();
1908 let path = PathBuf::from("/tmp/lumen-invalidate.png");
1909 server.insert_image(path.clone(), LoadedImage(make_image_data(4096).into()));
1910 assert_eq!(server.bytes_used(), 4096);
1911 assert!(server.invalidate_path(&path));
1912 assert_eq!(server.bytes_used(), 0);
1913 assert!(!server.image_cache.contains(&path));
1914 }
1915
1916 #[test]
1917 fn request_id_monotonic_per_entity() {
1918 let mut server = AssetServer::default();
1919 let e = Entity::from_raw_u32(11).unwrap();
1920 assert_eq!(server.current_request_id(e), 0);
1921 assert_eq!(server.bump_request_id(e), 1);
1922 assert_eq!(server.bump_request_id(e), 2);
1923 assert_eq!(server.current_request_id(e), 2);
1924 }
1925
1926 #[test]
1927 fn load_error_kind_from_io_error_maps_not_found() {
1928 let io_err = std::io::Error::from(std::io::ErrorKind::NotFound);
1929 let k: LoadErrorKind = io_err.into();
1930 assert!(matches!(k, LoadErrorKind::NotFound));
1931 let other = std::io::Error::from(std::io::ErrorKind::PermissionDenied);
1932 let k2: LoadErrorKind = other.into();
1933 assert!(matches!(k2, LoadErrorKind::Io(_)));
1934 }
1935
1936 #[test]
1937 fn shutdown_clears_caches_and_joins_workers() {
1938 let mut server = AssetServer::default();
1939 // Workers are spawned lazily, so force one enqueue to materialise the
1940 // pool before asserting shutdown joins it.
1941 let e = Entity::from_raw_u32(9_001).expect("valid entity id");
1942 let _ = server.lookup_or_enqueue(e, PathBuf::from("/tmp/lumen-nonexistent.png"));
1943 let path = PathBuf::from("/tmp/lumen-shutdown-test.png");
1944 server.insert_image(path, LoadedImage(make_image_data(64).into()));
1945 assert_eq!(server.bytes_used(), 64);
1946 let worker_count = server.workers.len();
1947 assert!(worker_count >= 1);
1948 server.shutdown();
1949 assert_eq!(server.bytes_used(), 0);
1950 assert!(server.workers.is_empty());
1951 assert!(server.job_tx.is_none());
1952 // Idempotent.
1953 server.shutdown();
1954 }
1955
1956 #[test]
1957 fn svg_bytes_track_source_bytes() {
1958 let svg = SvgData {
1959 intrinsic: glam::Vec2::ZERO,
1960 tree: empty_svg_tree(),
1961 id: SvgData::next_id(),
1962 source_bytes: 12_345,
1963 };
1964 assert_eq!(svg.bytes(), 12_345);
1965 }
1966
1967 /// A minimal well-formed SVG with a known intrinsic size. Its file
1968 /// length is what the loader budgets as the payload's byte cost.
1969 const TINY_SVG: &[u8] = br#"<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8"/>"#;
1970
1971 /// The parsed form of [`TINY_SVG`], for payloads whose drawing a test
1972 /// never looks at.
1973 fn empty_svg_tree() -> usvg::Tree {
1974 usvg::Tree::from_data(TINY_SVG, &usvg::Options::default()).expect("tiny svg parses")
1975 }
1976
1977 /// Runs a loader the way a worker would: the path, plus bytes only when
1978 /// a source already produced them.
1979 fn load_from_disk(path: &Path) -> Result<LoadedAsset, LoadErrorKind> {
1980 let loaders = AssetLoaders::default();
1981 let loader = loaders.resolve(path).expect("a loader claims the path");
1982 loader.load(&LoadContext::new(path, None))
1983 }
1984
1985 #[test]
1986 fn default_registry_routes_by_extension() {
1987 let kind = |p: &str| AssetLoaders::default().kind_for(Path::new(p));
1988 assert_eq!(kind("pic.png"), Some(AssetKind::Image));
1989 assert_eq!(kind("icon.svg"), Some(AssetKind::Svg));
1990 // Extension matching is case-insensitive, and a `lumen://` URI
1991 // resolves the same way a filesystem path does.
1992 assert_eq!(kind("a/b/glyph.SVG"), Some(AssetKind::Svg));
1993 assert_eq!(kind("lumen://app/assets/x.svg"), Some(AssetKind::Svg));
1994 // Anything unclaimed falls back to the image loader, which is what
1995 // the pipeline did before extensions were registered explicitly.
1996 assert_eq!(kind("mystery.dat"), Some(AssetKind::Image));
1997 assert_eq!(kind("no-extension"), Some(AssetKind::Image));
1998 }
1999
2000 #[test]
2001 fn registry_without_fallback_refuses_unclaimed_extensions() {
2002 let mut loaders = AssetLoaders::default();
2003 loaders.set_fallback(None);
2004 assert_eq!(loaders.kind_for(Path::new("mystery.dat")), None);
2005 assert_eq!(
2006 loaders.kind_for(Path::new("pic.png")),
2007 Some(AssetKind::Image)
2008 );
2009 }
2010
2011 #[test]
2012 fn load_reads_from_disk_and_caches() {
2013 let dir = std::env::temp_dir().join(format!("lumen-assets-load-{}", std::process::id()));
2014 std::fs::create_dir_all(&dir).unwrap();
2015 let path = dir.join("badge.svg");
2016 std::fs::write(&path, TINY_SVG).unwrap();
2017
2018 let LoadedAsset::Svg(loaded) = load_from_disk(&path).expect("load the svg") else {
2019 panic!("a .svg path must load as an svg");
2020 };
2021 assert_eq!(loaded.intrinsic, glam::Vec2::new(8.0, 8.0));
2022 assert_eq!(
2023 loaded.source_bytes,
2024 TINY_SVG.len(),
2025 "the source length is the payload's byte cost"
2026 );
2027
2028 // Inserting into the server accounts bytes + lookup returns a HitSvg.
2029 let mut server = AssetServer::default();
2030 server.insert_svg(path.clone(), loaded);
2031 assert_eq!(server.bytes_used(), TINY_SVG.len());
2032 let e = Entity::from_raw_u32(7).unwrap();
2033 assert!(matches!(
2034 server.lookup_or_enqueue(e, path.clone()),
2035 CacheLookup::HitSvg(_)
2036 ));
2037 assert_eq!(server.bytes_used(), server.recompute_bytes_used());
2038
2039 let _ = std::fs::remove_dir_all(&dir);
2040 }
2041
2042 #[test]
2043 fn load_rejects_bytes_the_decoder_refuses() {
2044 let dir = std::env::temp_dir().join(format!("lumen-assets-bad-{}", std::process::id()));
2045 std::fs::create_dir_all(&dir).unwrap();
2046 let path = dir.join("broken.svg");
2047 std::fs::write(&path, b"<svg not really").unwrap();
2048 assert!(matches!(
2049 load_from_disk(&path),
2050 Err(LoadErrorKind::DecodeFailed(_))
2051 ));
2052 let _ = std::fs::remove_dir_all(&dir);
2053 }
2054
2055 #[test]
2056 fn worker_pool_decodes_off_thread() {
2057 // End-to-end through the real crossbeam worker pool: enqueue a path,
2058 // then read the decoded result off the channel.
2059 let dir = std::env::temp_dir().join(format!("lumen-assets-pool-{}", std::process::id()));
2060 std::fs::create_dir_all(&dir).unwrap();
2061 let path = dir.join("pool.svg");
2062 std::fs::write(&path, TINY_SVG).unwrap();
2063
2064 let mut server = AssetServer::default();
2065 let e = Entity::from_raw_u32(3).unwrap();
2066 assert!(matches!(
2067 server.lookup_or_enqueue(e, path.clone()),
2068 CacheLookup::Enqueued
2069 ));
2070 let result = server
2071 .result_rx
2072 .recv_timeout(std::time::Duration::from_secs(5))
2073 .expect("worker produced a decode result");
2074 assert_eq!(result.path, path);
2075 assert!(
2076 matches!(result.outcome, Ok(LoadedAsset::Svg(_))),
2077 "an svg path decodes to an Svg asset off the worker thread"
2078 );
2079 let _ = std::fs::remove_dir_all(&dir);
2080 }
2081
2082 /// Marks the one element the incoming page mounts, so the test can
2083 /// ask which index it took.
2084 #[derive(Component)]
2085 struct IncomingPage;
2086
2087 /// A page swap despawns the outgoing page's tree in the same stage the
2088 /// stamp runs in, so the stamp's write can be applied against an entity
2089 /// that is already gone - and whose index the incoming page has already
2090 /// reused. The write is dropped; a plain `insert` failed the whole
2091 /// command buffer instead, which took the app down on the first
2092 /// navigation away from a page holding an `<image>`.
2093 #[test]
2094 fn stamping_an_image_despawned_in_the_same_stage_is_dropped() {
2095 use bevy_ecs::schedule::{Schedule, ScheduleBuildSettings, SingleThreadedExecutor};
2096 let mut world = World::new();
2097 let outgoing = world
2098 .spawn((
2099 ImageSource(PathBuf::from("probe.png")),
2100 LoadedImage(make_image_data(64).into()),
2101 ))
2102 .id();
2103
2104 fn swap_the_page(mut commands: Commands, images: Query<Entity, With<ImageSource>>) {
2105 let outgoing: Vec<Entity> = images.iter().collect();
2106 // The allocator holds freed indices back in a batch and hands
2107 // them out once the batch fills, so whether the incoming page
2108 // lands on a freed index depends on how many despawns preceded
2109 // it. Reusing the index by hand makes the collision this test is
2110 // about happen on every run instead of on a busy app only.
2111 commands.queue(move |world: &mut World| {
2112 for e in outgoing {
2113 let freed = world
2114 .despawn_no_free(e)
2115 .expect("the outgoing element is still spawned");
2116 world
2117 .spawn_at(freed, IncomingPage)
2118 .expect("the freed index is valid and not spawned");
2119 }
2120 });
2121 }
2122
2123 let mut schedule = Schedule::default();
2124 schedule.set_executor(SingleThreadedExecutor::new());
2125 // No sync point between the two systems: both buffers are applied
2126 // together at the end of the schedule, the swap first. That is
2127 // the shape a page swap produces inside `TickStage::Systems`.
2128 schedule.set_build_settings(ScheduleBuildSettings {
2129 auto_insert_apply_deferred: false,
2130 ..ScheduleBuildSettings::default()
2131 });
2132 schedule.add_systems((swap_the_page, stamp_image_natural_size).chain());
2133 schedule.run(&mut world);
2134
2135 assert!(
2136 world.get_entity(outgoing).is_err(),
2137 "the despawn stands and the late stamp did not resurrect the element"
2138 );
2139 let mut mounted = world.query_filtered::<Entity, With<IncomingPage>>();
2140 let incoming = mounted.single(&world).unwrap();
2141 assert_eq!(
2142 incoming.index_u32(),
2143 outgoing.index_u32(),
2144 "the incoming element took the index the outgoing one freed, \
2145 which is what makes the stale write dangerous"
2146 );
2147 assert!(
2148 world.get::<ImageComponent>(incoming).is_none(),
2149 "the stamp addressed to the outgoing element did not land on the \
2150 one holding its index"
2151 );
2152 let mut stamped = world.query::<&ImageComponent>();
2153 assert_eq!(
2154 stamped.iter(&world).count(),
2155 0,
2156 "and it landed nowhere else either"
2157 );
2158 }
2159
2160 /// An asset arrives ticks after the tree it belongs to, and a tick whose
2161 /// frame is not dirty is never extracted. Attaching a payload has to
2162 /// raise the flag itself or the window keeps showing the frame that was
2163 /// painted before the image was there.
2164 #[test]
2165 fn attaching_a_cached_image_raises_frame_dirty() {
2166 use bevy_ecs::system::RunSystemOnce;
2167 let mut world = World::new();
2168 let path = PathBuf::from("/tmp/lumen-cache-hit-dirty.png");
2169 let mut server = AssetServer::default();
2170 server.insert_image(path.clone(), LoadedImage(make_image_data(64).into()));
2171 world.insert_resource(server);
2172 world.insert_resource(FrameDirty { dirty: false });
2173 let e = world.spawn(ImageSource(path)).id();
2174
2175 world.run_system_once(spawn_pending_decodes).unwrap();
2176
2177 assert!(
2178 world.get::<LoadedImage>(e).is_some(),
2179 "the cached decode is attached synchronously"
2180 );
2181 assert!(
2182 world.resource::<FrameDirty>().dirty,
2183 "the frame the image landed on is painted"
2184 );
2185 }
2186
2187 /// An `<image>` that enters the tree late in a tick is looked up on the
2188 /// next one, and an event-driven loop only runs that next tick if the
2189 /// tick it just finished said work was left. The claim retires as soon
2190 /// as the entity has its payload, so the app still parks.
2191 #[test]
2192 fn an_unresolved_image_holds_the_frame_loop_open() {
2193 use bevy_ecs::system::RunSystemOnce;
2194 let mut world = World::new();
2195 world.insert_resource(AnimationsActive::default());
2196 let e = world.spawn(ImageSource(PathBuf::from("probe.png"))).id();
2197
2198 world
2199 .run_system_once(keep_frame_loop_awake_for_assets)
2200 .unwrap();
2201 assert!(
2202 world.resource::<AnimationsActive>().get(),
2203 "a source with no verdict yet needs another tick"
2204 );
2205
2206 world.resource::<AnimationsActive>().clear();
2207 world
2208 .entity_mut(e)
2209 .insert(LoadedImage(make_image_data(64).into()));
2210 world
2211 .run_system_once(keep_frame_loop_awake_for_assets)
2212 .unwrap();
2213 assert!(
2214 !world.resource::<AnimationsActive>().get(),
2215 "every image holds its payload, so the app parks"
2216 );
2217 }
2218
2219 /// The claim belongs to the element, not to the pipeline. A page swap
2220 /// drops an element mid-decode and the job behind it may never answer -
2221 /// a loader that wedges on a socket, a worker that goes away - so a
2222 /// claim keyed on the server's in-flight map would hold the loop at the
2223 /// frame rate for the rest of the session, against a tree that no
2224 /// longer contains the thing it was waiting for.
2225 #[test]
2226 fn a_despawned_image_retires_its_claim_with_a_decode_still_out() {
2227 use bevy_ecs::system::RunSystemOnce;
2228 let mut world = World::new();
2229 world.insert_resource(AnimationsActive::default());
2230 let e = world.spawn(ImageSource(PathBuf::from("probe.png"))).id();
2231 world.entity_mut(e).insert(Enqueued);
2232
2233 world
2234 .run_system_once(keep_frame_loop_awake_for_assets)
2235 .unwrap();
2236 assert!(
2237 world.resource::<AnimationsActive>().get(),
2238 "a decode still out needs the tick that will deliver its result"
2239 );
2240
2241 world.resource::<AnimationsActive>().clear();
2242 world.entity_mut(e).despawn();
2243 world
2244 .run_system_once(keep_frame_loop_awake_for_assets)
2245 .unwrap();
2246 assert!(
2247 !world.resource::<AnimationsActive>().get(),
2248 "nothing in the tree is waiting any more, so the app parks"
2249 );
2250 }
2251
2252 /// A reload strips what the element was showing from every element on
2253 /// the changed path, so the next lookup decodes again, and leaves every
2254 /// other element alone.
2255 #[test]
2256 fn reload_changed_images_strips_only_the_changed_path() {
2257 use bevy_ecs::message::Messages;
2258 use bevy_ecs::system::RunSystemOnce;
2259 let mut world = World::new();
2260 world.init_resource::<Messages<AssetReloadRequested>>();
2261 let changed = PathBuf::from("/tmp/lumen-reload-changed.png");
2262 let other = PathBuf::from("/tmp/lumen-reload-other.png");
2263 let shown = world
2264 .spawn((
2265 ImageSource(changed.clone()),
2266 LoadedImage(make_image_data(64).into()),
2267 ))
2268 .id();
2269 let failed = world
2270 .spawn((
2271 ImageSource(changed.clone()),
2272 ImageLoadFailed::new(LoadErrorKind::NotFound),
2273 ))
2274 .id();
2275 let waiting = world.spawn((ImageSource(changed.clone()), Enqueued)).id();
2276 let untouched = world
2277 .spawn((ImageSource(other), LoadedImage(make_image_data(64).into())))
2278 .id();
2279 world.write_message(AssetReloadRequested { path: changed });
2280
2281 world.run_system_once(reload_changed_images).unwrap();
2282
2283 assert!(world.get::<LoadedImage>(shown).is_none());
2284 assert!(world.get::<ImageLoadFailed>(failed).is_none());
2285 assert!(world.get::<Enqueued>(waiting).is_none());
2286 assert!(
2287 world.get::<LoadedImage>(untouched).is_some(),
2288 "an element on another path keeps its image"
2289 );
2290 }
2291
2292 /// Writes an opaque `side` x `side` PNG to `path`.
2293 fn write_png(path: &Path, side: u32) {
2294 image::RgbaImage::from_pixel(side, side, image::Rgba([255, 0, 0, 255]))
2295 .save(path)
2296 .expect("write png");
2297 }
2298
2299 /// Ticks the asset systems until `done` holds, failing after a
2300 /// generous bound rather than hanging.
2301 fn tick_until(
2302 schedule: &mut bevy_ecs::schedule::Schedule,
2303 world: &mut World,
2304 done: impl Fn(&World) -> bool,
2305 ) {
2306 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
2307 while !done(world) {
2308 assert!(std::time::Instant::now() < deadline, "asset never settled");
2309 schedule.run(world);
2310 std::thread::sleep(std::time::Duration::from_millis(2));
2311 }
2312 }
2313
2314 /// The whole reload path: the file behind a shown image changes on disk,
2315 /// the watch event reaches the server, and the element ends up showing
2316 /// the new bytes. The event is queued by hand so the test does not
2317 /// depend on the platform watcher's timing.
2318 #[test]
2319 fn an_edited_image_reloads() {
2320 edited_file_reloads("content", |world, path| world.spawn(ImageSource(path)).id());
2321 }
2322
2323 /// A background (`bg: url(...)`) is an [`ImageSource`] like any other, so
2324 /// editing its file reloads it the same way.
2325 #[test]
2326 fn an_edited_background_image_reloads() {
2327 edited_file_reloads("background", |world, path| {
2328 world
2329 .spawn((ImageSource(path), BackgroundImage::default()))
2330 .id()
2331 });
2332 }
2333
2334 /// Show the file at a fresh path on the entity `spawn` makes, rewrite
2335 /// the file, report the change, and wait for the entity to show the new
2336 /// bytes.
2337 fn edited_file_reloads(label: &str, spawn: impl FnOnce(&mut World, PathBuf) -> Entity) {
2338 use bevy_ecs::message::Messages;
2339 use bevy_ecs::schedule::Schedule;
2340 let dir = std::env::temp_dir().join(format!(
2341 "lumen-assets-reload-{label}-{}",
2342 std::process::id()
2343 ));
2344 std::fs::create_dir_all(&dir).unwrap();
2345 let path = dir.join("edited.png");
2346 write_png(&path, 1);
2347
2348 let mut world = World::new();
2349 world.insert_resource(AssetServer::default());
2350 world.init_resource::<Messages<AssetReloadRequested>>();
2351 let mut schedule = Schedule::default();
2352 schedule.add_systems(
2353 (
2354 spawn_pending_decodes,
2355 drain_completed_decodes,
2356 process_watch_events,
2357 reload_changed_images,
2358 )
2359 .chain(),
2360 );
2361 let e = spawn(&mut world, path.clone());
2362 tick_until(&mut schedule, &mut world, |w| {
2363 w.get::<LoadedImage>(e).is_some()
2364 });
2365 assert_eq!(world.get::<LoadedImage>(e).unwrap().width, 1);
2366
2367 write_png(&path, 2);
2368 let event = notify::Event::new(notify::EventKind::Modify(notify::event::ModifyKind::Any))
2369 .add_path(path.clone());
2370 world
2371 .resource::<AssetServer>()
2372 .watch_tx
2373 .send(Ok(event))
2374 .unwrap();
2375 tick_until(&mut schedule, &mut world, |w| {
2376 w.get::<LoadedImage>(e).is_some_and(|img| img.width == 2)
2377 });
2378 let _ = std::fs::remove_dir_all(&dir);
2379 }
2380
2381 /// The watcher callback wakes the frame loop through the waker the
2382 /// server picked up from the world, so a parked app runs the tick that
2383 /// drains the event.
2384 #[test]
2385 fn the_watch_waker_is_wired_from_the_world() {
2386 use bevy_ecs::message::Messages;
2387 use bevy_ecs::system::RunSystemOnce;
2388 use std::sync::atomic::{AtomicUsize, Ordering};
2389 let wakes = Arc::new(AtomicUsize::new(0));
2390 let counter = Arc::clone(&wakes);
2391 let mut world = World::new();
2392 world.insert_resource(AssetServer::default());
2393 world.init_resource::<Messages<AssetReloadRequested>>();
2394 world.insert_resource(EventLoopWaker(Arc::new(move || {
2395 counter.fetch_add(1, Ordering::SeqCst);
2396 })));
2397
2398 world.run_system_once(process_watch_events).unwrap();
2399
2400 world
2401 .resource::<AssetServer>()
2402 .watch_waker
2403 .get()
2404 .expect("waker wired on the first tick")
2405 .wake();
2406 assert_eq!(wakes.load(Ordering::SeqCst), 1);
2407 }
2408}