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}