Skip to main content

spottedcat/utils/
image.rs

1//! Helpers for turning decoded `image` crate buffers into [`crate::Image`].
2//!
3//! These helpers keep the source pixel dimensions while deriving a default
4//! logical size from the current [`crate::scale_factor`].
5
6use crate::{Context, Image, Pt};
7
8/// Creates a [`crate::Image`] from an [`image::DynamicImage`].
9///
10/// The resulting image keeps the decoded pixel width and height and derives
11/// its logical [`Pt`][crate::Pt] size from the current scale factor.
12pub fn from_image(ctx: &mut Context, image: &image::DynamicImage) -> anyhow::Result<Image> {
13    let rgba = image.to_rgba8();
14    from_rgba_image(ctx, &rgba)
15}
16
17/// Creates a [`crate::Image`] from an [`image::RgbaImage`].
18///
19/// The resulting image keeps the source pixel width and height and derives
20/// its logical [`Pt`][crate::Pt] size from the current scale factor.
21pub fn from_rgba_image(ctx: &mut Context, image: &image::RgbaImage) -> anyhow::Result<Image> {
22    let width_px = image.width();
23    let height_px = image.height();
24    let scale_factor = ctx.scale_factor().max(1.0);
25    let width = Pt::from_physical_px(width_px as f64, scale_factor);
26    let height = Pt::from_physical_px(height_px as f64, scale_factor);
27    Image::new_from_rgba8_with_pixels(ctx, width_px, height_px, width, height, image.as_raw())
28}
29
30use std::sync::mpsc::{self, Receiver};
31
32/// A handle to an image that is loading asynchronously.
33#[derive(Debug)]
34pub struct LoadingImage {
35    path: String,
36    rx: Receiver<Result<(u32, u32, Vec<u8>), String>>,
37    image: Option<Image>,
38    error: Option<String>,
39}
40
41impl LoadingImage {
42    /// Returns the path of the image being loaded.
43    pub fn path(&self) -> &str {
44        &self.path
45    }
46
47    /// Checks if there was an error during loading or decoding.
48    pub fn error(&self) -> Option<&str> {
49        self.error.as_deref()
50    }
51
52    /// Checks if the image has finished loading and is ready.
53    ///
54    /// If loading succeeded, this returns `Some(image)`.
55    /// If still loading, returns `None`.
56    /// If failed, returns `None` and sets the error (queryable via `error()`).
57    pub fn poll(&mut self, ctx: &mut Context) -> Option<Image> {
58        if let Some(img) = self.image {
59            return Some(img);
60        }
61
62        if self.error.is_some() {
63            return None;
64        }
65
66        match self.rx.try_recv() {
67            Ok(Ok((width_px, height_px, rgba))) => {
68                let scale_factor = ctx.scale_factor().max(1.0);
69                let width = Pt::from_physical_px(width_px as f64, scale_factor);
70                let height = Pt::from_physical_px(height_px as f64, scale_factor);
71                match Image::new_from_rgba8_with_pixels(
72                    ctx, width_px, height_px, width, height, &rgba,
73                ) {
74                    Ok(img) => {
75                        self.image = Some(img);
76                        Some(img)
77                    }
78                    Err(e) => {
79                        self.error = Some(format!("Failed to register image on GPU: {:?}", e));
80                        None
81                    }
82                }
83            }
84            Ok(Err(e)) => {
85                self.error = Some(e);
86                None
87            }
88            Err(mpsc::TryRecvError::Empty) => None,
89            Err(mpsc::TryRecvError::Disconnected) => {
90                if self.image.is_none() {
91                    self.error = Some("Loading thread disconnected unexpectedly".to_string());
92                }
93                None
94            }
95        }
96    }
97
98    /// Returns the loaded Image if it is fully registered, otherwise returns None.
99    /// Internally polls the loading status automatically.
100    pub fn get(&mut self, ctx: &mut Context) -> Option<Image> {
101        self.poll(ctx)
102    }
103
104    /// Returns the loaded Image if it is ready, otherwise returns the fallback placeholder image.
105    /// Internally polls the loading status automatically.
106    pub fn get_or(&mut self, ctx: &mut Context, fallback: Image) -> Image {
107        self.get(ctx).unwrap_or(fallback)
108    }
109}
110
111/// Starts loading an image asynchronously from the specified path.
112///
113/// This reads and decodes the image on a background thread.
114/// Call `poll(ctx)` on the returned `LoadingImage` during your `update` or `draw`
115/// loop to obtain the registered `Image` once it is ready.
116pub fn load_image_async(path: impl Into<String>) -> LoadingImage {
117    let path = path.into();
118    let (tx, rx) = mpsc::channel();
119    let path_clone = path.clone();
120
121    #[cfg(not(target_arch = "wasm32"))]
122    {
123        std::thread::spawn(move || {
124            let res = (|| -> Result<(u32, u32, Vec<u8>), anyhow::Error> {
125                let bytes = crate::assets::load_asset(&path_clone)?;
126                let img = image::load_from_memory(&bytes)?;
127                let rgba = img.to_rgba8();
128                Ok((rgba.width(), rgba.height(), rgba.into_raw()))
129            })();
130            let _ = tx.send(res.map_err(|e| e.to_string()));
131        });
132    }
133
134    #[cfg(target_arch = "wasm32")]
135    {
136        wasm_bindgen_futures::spawn_local(async move {
137            let res = (|| -> Result<(u32, u32, Vec<u8>), anyhow::Error> {
138                let bytes = crate::assets::load_asset(&path_clone)?;
139                let img = image::load_from_memory(&bytes)?;
140                let rgba = img.to_rgba8();
141                Ok((rgba.width(), rgba.height(), rgba.into_raw()))
142            })();
143            let _ = tx.send(res.map_err(|e| e.to_string()));
144        });
145    }
146
147    LoadingImage {
148        path,
149        rx,
150        image: None,
151        error: None,
152    }
153}
154
155use std::collections::HashMap;
156
157/// A high-level manager that handles loading and caching multiple asynchronous images.
158#[derive(Debug, Default)]
159pub struct AsyncImageLoader {
160    loading: HashMap<String, LoadingImage>,
161    loaded: HashMap<String, Image>,
162    errors: HashMap<String, String>,
163}
164
165impl AsyncImageLoader {
166    /// Creates a new asynchronous image loader.
167    pub fn new() -> Self {
168        Self {
169            loading: HashMap::new(),
170            loaded: HashMap::new(),
171            errors: HashMap::new(),
172        }
173    }
174
175    /// Triggers the loading of an image from the specified path, if it is not already loaded or loading.
176    pub fn load(&mut self, path: impl Into<String>) {
177        let path = path.into();
178        if !self.loaded.contains_key(&path) && !self.loading.contains_key(&path) {
179            self.errors.remove(&path);
180            let loader = load_image_async(&path);
181            self.loading.insert(path, loader);
182        }
183    }
184
185    /// Updates the loading state and returns `(loaded_count, total_count)`.
186    ///
187    /// Both successfully loaded and failed assets count as "done".
188    pub fn progress(&mut self, ctx: &mut Context) -> (usize, usize) {
189        let mut finished = Vec::new();
190        for (path, loader) in self.loading.iter_mut() {
191            if let Some(img) = loader.get(ctx) {
192                finished.push((path.clone(), Ok(img)));
193            } else if let Some(err) = loader.error() {
194                finished.push((path.clone(), Err(err.to_string())));
195            }
196        }
197
198        for (path, result) in finished {
199            match result {
200                Ok(img) => {
201                    self.loaded.insert(path.clone(), img);
202                }
203                Err(err) => {
204                    self.errors.insert(path.clone(), err);
205                }
206            }
207            self.loading.remove(&path);
208        }
209
210        let done = self.loaded.len() + self.errors.len();
211        let total = done + self.loading.len();
212        (done, total)
213    }
214
215    /// Returns the loading progress ratio from `0.0` to `1.0`.
216    pub fn progress_ratio(&mut self, ctx: &mut Context) -> f32 {
217        let (done, total) = self.progress(ctx);
218        if total == 0 {
219            1.0
220        } else {
221            done as f32 / total as f32
222        }
223    }
224
225    /// Checks if all queued images have finished loading (either successfully or with error).
226    pub fn is_done(&mut self, ctx: &mut Context) -> bool {
227        let (done, total) = self.progress(ctx);
228        done == total && total > 0
229    }
230
231    /// Checks if a specific image has finished loading and is ready for rendering.
232    pub fn is_ready(&mut self, ctx: &mut Context, path: &str) -> bool {
233        if self.loaded.contains_key(path) {
234            return true;
235        }
236
237        if let Some(mut loader) = self.loading.remove(path) {
238            if let Some(img) = loader.get(ctx) {
239                self.loaded.insert(path.to_string(), img);
240                return true;
241            }
242            self.loading.insert(path.to_string(), loader);
243        }
244
245        false
246    }
247
248    /// Retrieves the loaded image handle if it is ready, otherwise returns `None`.
249    pub fn get(&mut self, ctx: &mut Context, path: &str) -> Option<Image> {
250        if let Some(&img) = self.loaded.get(path) {
251            return Some(img);
252        }
253
254        if let Some(mut loader) = self.loading.remove(path) {
255            if let Some(img) = loader.get(ctx) {
256                self.loaded.insert(path.to_string(), img);
257                return Some(img);
258            }
259            self.loading.insert(path.to_string(), loader);
260        }
261
262        None
263    }
264
265    /// Retrieves the loaded image if ready, otherwise returns the specified `fallback` image.
266    pub fn get_or(&mut self, ctx: &mut Context, path: &str, fallback: Image) -> Image {
267        self.get(ctx, path).unwrap_or(fallback)
268    }
269
270    /// Checks if loading failed for a specific path, returning the error message if any.
271    pub fn error(&self, path: &str) -> Option<&str> {
272        self.errors
273            .get(path)
274            .map(|s| s.as_str())
275            .or_else(|| self.loading.get(path).and_then(|loader| loader.error()))
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    #[test]
284    fn test_async_loading() {
285        let mut ctx = Context::new();
286        // Start loading the happy-tree image
287        let mut loading = load_image_async("assets/happy-tree.png");
288        assert_eq!(loading.path(), "assets/happy-tree.png");
289        assert!(loading.error().is_none());
290
291        // Wait a bit for the background thread to finish loading and decoding
292        let start = std::time::Instant::now();
293        let mut image = None;
294        while start.elapsed() < std::time::Duration::from_secs(5) {
295            if let Some(img) = loading.poll(&mut ctx) {
296                image = Some(img);
297                break;
298            }
299            if let Some(err) = loading.error() {
300                panic!("Failed to load asynchronously: {}", err);
301            }
302            std::thread::sleep(std::time::Duration::from_millis(10));
303        }
304
305        let img = image.expect("Failed to load image in 5 seconds");
306        // Verify dimensions (since happy-tree.png is decoded, it should have non-zero size)
307        assert!(img.width().as_f32() > 0.0);
308        assert!(img.height().as_f32() > 0.0);
309    }
310
311    #[test]
312    fn test_async_image_loader() {
313        let mut ctx = Context::new();
314        let fallback = Image::new(&mut ctx, Pt(1.0), Pt(1.0), &[255, 255, 255, 255]).unwrap();
315        let mut loader = AsyncImageLoader::new();
316
317        // 1. Initial state check
318        assert_eq!(loader.progress_ratio(&mut ctx), 1.0); // Empty is 100% loaded
319        assert!(!loader.is_done(&mut ctx)); // But is_done requires total > 0
320
321        // Trigger loading
322        loader.load("assets/happy-tree.png");
323
324        // 2. Loading state check (immediately after queuing)
325        assert!(loader.progress_ratio(&mut ctx) < 1.0);
326        assert!(!loader.is_done(&mut ctx));
327
328        // Wait for it to become ready
329        let start = std::time::Instant::now();
330        let mut ready = false;
331        while start.elapsed() < std::time::Duration::from_secs(5) {
332            if loader.is_done(&mut ctx) {
333                ready = true;
334                break;
335            }
336            if let Some(err) = loader.error("assets/happy-tree.png") {
337                panic!("Failed to load asynchronously via manager: {}", err);
338            }
339            std::thread::sleep(std::time::Duration::from_millis(10));
340        }
341
342        // 3. Final state check
343        assert!(ready);
344        assert_eq!(loader.progress_ratio(&mut ctx), 1.0);
345        assert!(loader.is_ready(&mut ctx, "assets/happy-tree.png"));
346
347        let img = loader.get_or(&mut ctx, "assets/happy-tree.png", fallback);
348        assert_ne!(img, fallback);
349        assert!(img.width().as_f32() > 0.0);
350    }
351}