Skip to main content

lumen_assets/
loader.rs

1//! How a source path becomes a decoded asset.
2//!
3//! [`AssetLoader`] is the extension point: a loader claims one or more file
4//! extensions, declares which [`AssetKind`] it produces, and turns one
5//! [`LoadContext`] into a [`LoadedAsset`]. [`AssetLoaders`] is the registry
6//! the [`crate::AssetServer`] resolves through, and the built-in image and
7//! SVG paths are ordinary loaders registered into it by
8//! [`AssetLoaders::default`].
9//!
10//! The shape follows Bevy's `AssetLoader`, adapted to how this crate loads:
11//!
12//! - Loading is synchronous. A load runs on the decode worker pool, so a
13//!   loader blocks its worker thread instead of awaiting.
14//! - A load is described by a path plus, optionally, bytes an
15//!   [`crate::AssetSource`] already resolved. It is not a byte stream,
16//!   because the built-in image path hands the path to `image::open` rather
17//!   than reading the file itself.
18//! - The loader is resolved on the main thread at enqueue time and travels
19//!   with the job, so registering a loader never has to be synchronised with
20//!   a running worker.
21
22use std::borrow::Cow;
23use std::collections::HashMap;
24use std::path::Path;
25use std::sync::Arc;
26
27use crate::loaders::{ImageLoader, SvgLoader};
28use crate::{LoadErrorKind, LoadedImage, LoadedSvg};
29
30/// Which family of asset a loader produces.
31///
32/// The kind selects the content cache the decoded payload lands in, and it
33/// is what [`AssetLoaders::kind_for`] reports for a path without loading it.
34#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
35pub enum AssetKind {
36    /// Raster image, cached as [`LoadedImage`].
37    Image,
38    /// Vector image, cached as [`LoadedSvg`].
39    Svg,
40}
41
42/// One successfully loaded asset, tagged by family.
43///
44/// This is what a loader returns and what the drain dispatches on.
45pub enum LoadedAsset {
46    /// A decoded raster image.
47    Image(LoadedImage),
48    /// A pre-rendered SVG.
49    Svg(LoadedSvg),
50}
51
52impl LoadedAsset {
53    /// Returns the family this payload belongs to.
54    pub fn kind(&self) -> AssetKind {
55        match self {
56            Self::Image(_) => AssetKind::Image,
57            Self::Svg(_) => AssetKind::Svg,
58        }
59    }
60}
61
62/// Everything a loader is given for one load.
63///
64/// `bytes` is `Some` when an asset source (a registered `.lpak` bundle, say)
65/// already produced the file contents; in that case `path` is the requested
66/// path and may be a `lumen://app/...` URI rather than a filesystem location.
67/// `bytes` is `None` when the path is expected to be read from disk.
68pub struct LoadContext<'a> {
69    path: &'a Path,
70    bytes: Option<&'a [u8]>,
71}
72
73impl<'a> LoadContext<'a> {
74    /// Builds a context for `path`, optionally carrying bytes an asset source
75    /// already resolved.
76    pub fn new(path: &'a Path, bytes: Option<&'a [u8]>) -> Self {
77        Self { path, bytes }
78    }
79
80    /// The requested path. Use it for error messages and for decoders that
81    /// take a path directly.
82    pub fn path(&self) -> &'a Path {
83        self.path
84    }
85
86    /// Bytes an asset source already resolved, or `None` when the loader is
87    /// expected to read [`Self::path`] itself.
88    pub fn bytes(&self) -> Option<&'a [u8]> {
89        self.bytes
90    }
91
92    /// The asset's bytes: the pre-resolved ones when a source supplied them,
93    /// otherwise the file read from disk.
94    pub fn read_bytes(&self) -> Result<Cow<'a, [u8]>, LoadErrorKind> {
95        match self.bytes {
96            Some(b) => Ok(Cow::Borrowed(b)),
97            None => std::fs::read(self.path)
98                .map(Cow::Owned)
99                .map_err(LoadErrorKind::from),
100        }
101    }
102}
103
104/// Turns one source path into a decoded asset.
105///
106/// Implement it to teach the asset pipeline a format it does not know, then
107/// register it with [`crate::AssetServer::register_loader`] (or
108/// [`crate::register_asset_loader`] from a plugin's `build`). Loaders run on
109/// the decode worker pool, so an implementation must be `Send + Sync` and
110/// should assume it is blocking a thread.
111pub trait AssetLoader: Send + Sync + 'static {
112    /// File extensions this loader claims, lowercase and without the dot.
113    /// Registering a loader claims every extension it names; a later
114    /// registration wins over an earlier one for the same extension.
115    fn extensions(&self) -> &[&str];
116
117    /// The family this loader produces. The object-safe stand-in for Bevy's
118    /// associated `type Asset`.
119    fn kind(&self) -> AssetKind;
120
121    /// Loads one asset. Returning `Err` caches the failure against the path
122    /// and attaches a failure component to every entity waiting on it.
123    fn load(&self, ctx: &LoadContext<'_>) -> Result<LoadedAsset, LoadErrorKind>;
124}
125
126/// Extension-keyed loader registry.
127///
128/// [`Self::default`] registers the built-in image and SVG loaders and
129/// installs the image loader as the fallback, which is what makes a path with
130/// an unrecognised extension attempt an image decode.
131pub struct AssetLoaders {
132    by_extension: HashMap<String, Arc<dyn AssetLoader>>,
133    fallback: Option<Arc<dyn AssetLoader>>,
134}
135
136impl Default for AssetLoaders {
137    fn default() -> Self {
138        let mut loaders = Self::empty();
139        let image: Arc<dyn AssetLoader> = Arc::new(ImageLoader);
140        loaders.register_arc(image.clone());
141        loaders.register(SvgLoader);
142        loaders.set_fallback(Some(image));
143        loaders
144    }
145}
146
147impl AssetLoaders {
148    /// A registry with no loaders and no fallback. Every load fails with
149    /// [`LoadErrorKind::Unsupported`] until something is registered.
150    pub fn empty() -> Self {
151        Self {
152            by_extension: HashMap::new(),
153            fallback: None,
154        }
155    }
156
157    /// Registers `loader` for each extension it names.
158    pub fn register(&mut self, loader: impl AssetLoader) {
159        self.register_arc(Arc::new(loader));
160    }
161
162    /// Registers an already shared loader. Useful when the same instance
163    /// should also serve as the fallback.
164    pub fn register_arc(&mut self, loader: Arc<dyn AssetLoader>) {
165        for ext in loader.extensions() {
166            self.by_extension
167                .insert(ext.to_ascii_lowercase(), loader.clone());
168        }
169    }
170
171    /// Sets the loader used for paths whose extension nothing claims, or
172    /// clears it so those paths fail with [`LoadErrorKind::Unsupported`].
173    pub fn set_fallback(&mut self, loader: Option<Arc<dyn AssetLoader>>) {
174        self.fallback = loader;
175    }
176
177    /// The loader that handles unclaimed extensions, if any.
178    pub fn fallback(&self) -> Option<&Arc<dyn AssetLoader>> {
179        self.fallback.as_ref()
180    }
181
182    /// The loader that will handle `path`: the one registered for its
183    /// extension, else the fallback.
184    pub fn resolve(&self, path: &Path) -> Option<Arc<dyn AssetLoader>> {
185        asset_extension(path)
186            .and_then(|ext| self.by_extension.get(&ext))
187            .or(self.fallback.as_ref())
188            .cloned()
189    }
190
191    /// The family `path` will load as, if any loader handles it.
192    pub fn kind_for(&self, path: &Path) -> Option<AssetKind> {
193        self.resolve(path).map(|l| l.kind())
194    }
195
196    /// Every claimed extension, unordered.
197    pub fn extensions(&self) -> impl Iterator<Item = &str> {
198        self.by_extension.keys().map(String::as_str)
199    }
200}
201
202/// The lowercase extension used for loader lookup.
203///
204/// Falls back to the last dot-separated segment of the final path component
205/// when `Path::extension` yields nothing, so `lumen://app/icons/sun.svg`
206/// resolves the same way a filesystem path does.
207pub fn asset_extension(path: &Path) -> Option<String> {
208    if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
209        return Some(ext.to_ascii_lowercase());
210    }
211    let name = path.to_str()?.rsplit(['/', '\\']).next()?;
212    let (_, ext) = name.rsplit_once('.')?;
213    (!ext.is_empty()).then(|| ext.to_ascii_lowercase())
214}