Skip to main content

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}