Skip to main content

rosace_widgets/
image.rs

1//! Image widget — renders a PNG image (or a placeholder) inside a fixed-size box.
2//!
3//! # Example
4//! ```rust,ignore
5//! let img = ImageWidget::new()
6//!     .file("assets/photo.png")
7//!     .fit(ImageFit::Cover)
8//!     .width(320.0)
9//!     .height(200.0);
10//! img.render(&mut canvas, &font_cache, 10.0, 10.0, &theme);
11//! ```
12
13use std::collections::HashMap;
14use std::path::PathBuf;
15use rosace_core::types::{Point, Rect, Size};
16use rosace_render::{Color, FontCache, SkiaCanvas};
17use rosace_theme::ThemeData;
18
19/// Controls how the image is scaled to fit the widget's bounding box.
20#[derive(Debug, Clone, PartialEq)]
21pub enum ImageFit {
22    /// Stretch to fill the target rect exactly (may distort aspect ratio).
23    Fill,
24    /// Scale uniformly to fit within the rect, preserving aspect ratio (letterbox).
25    Contain,
26    /// Scale uniformly to cover the rect, cropping edges (center crop).
27    Cover,
28    /// Natural size — no scaling (may be clipped if larger than the widget).
29    None,
30}
31
32/// The data source for an [`ImageWidget`].
33#[derive(Debug, Clone, PartialEq)]
34pub enum ImageSource {
35    /// Load the image from a file path.
36    File(PathBuf),
37    /// Use already-loaded PNG bytes.
38    Bytes(Vec<u8>),
39    /// Show a colored placeholder rectangle (no image data).
40    Placeholder,
41}
42
43/// A widget that renders an image from a file path or raw bytes.
44///
45/// Falls back to a colored placeholder when the image cannot be decoded.
46pub struct ImageWidget {
47    pub source: ImageSource,
48    pub fit: ImageFit,
49    pub width: f32,
50    pub height: f32,
51    pub placeholder_color: Color,
52    /// Accessible/SEO alt text (D107/Phase 25) — `None` produces no
53    /// semantics entry at all (an image with no `.alt(...)` call is
54    /// decorative, matching HTML's own convention of an empty/absent
55    /// `alt` attribute), not an empty-string placeholder.
56    pub alt: Option<String>,
57}
58
59impl ImageWidget {
60    /// Create a new `ImageWidget` with placeholder defaults.
61    pub fn new() -> Self {
62        Self {
63            source: ImageSource::Placeholder,
64            fit: ImageFit::Contain,
65            width: 200.0,
66            height: 200.0,
67            placeholder_color: Color::rgb(60, 65, 90),
68            alt: None,
69        }
70    }
71
72    /// Sets the accessible/SEO alt text.
73    pub fn alt(mut self, alt: impl Into<String>) -> Self {
74        self.alt = Some(alt.into());
75        self
76    }
77
78    /// Set the image source to a file path.
79    pub fn file(mut self, path: impl Into<PathBuf>) -> Self {
80        self.source = ImageSource::File(path.into());
81        self
82    }
83
84    /// Load the image from a bundled **asset** by logical name — resolved
85    /// per-platform via [`rosace_core::asset`] (dev: `assets/<name>`; mobile:
86    /// the app bundle). Prefer this over [`file`](Self::file) for shipped
87    /// images: it's portable across platforms and hot-reloads under `rsc dev`.
88    ///
89    /// ```ignore
90    /// ImageWidget::new().asset("logo.png").fit(ImageFit::Contain)
91    /// ```
92    pub fn asset(self, name: impl rosace_core::asset::AssetRef) -> Self {
93        self.file(rosace_core::asset::resolve(name))
94    }
95
96    /// Set the image source to raw PNG bytes.
97    pub fn bytes(mut self, data: Vec<u8>) -> Self {
98        self.source = ImageSource::Bytes(data);
99        self
100    }
101
102    /// Set the fit mode.
103    pub fn fit(mut self, f: ImageFit) -> Self {
104        self.fit = f;
105        self
106    }
107
108    /// Set the widget width in pixels.
109    pub fn width(mut self, w: f32) -> Self {
110        self.width = w;
111        self
112    }
113
114    /// Set the widget height in pixels.
115    pub fn height(mut self, h: f32) -> Self {
116        self.height = h;
117        self
118    }
119
120    /// Set the placeholder background color shown when no image is available.
121    pub fn placeholder_color(mut self, c: Color) -> Self {
122        self.placeholder_color = c;
123        self
124    }
125
126    /// Render the image at `(x, y)`.
127    ///
128    /// Falls back to a colored placeholder with a simple icon if the image
129    /// cannot be loaded or decoded.
130    pub fn render(
131        &self,
132        canvas: &mut SkiaCanvas,
133        font: &FontCache,
134        x: f32,
135        y: f32,
136        _theme: &ThemeData,
137    ) {
138        let pixmap_bytes = match &self.source {
139            ImageSource::Placeholder => None,
140            ImageSource::File(path) => std::fs::read(path).ok(),
141            ImageSource::Bytes(b) => Some(b.clone()),
142        };
143
144        let loaded = pixmap_bytes.and_then(|bytes| tiny_skia::Pixmap::decode_png(&bytes).ok());
145
146        match loaded {
147            Some(pixmap) => {
148                self.blit_pixmap(canvas, x, y, &pixmap);
149            }
150            None => {
151                // Placeholder rectangle.
152                canvas.fill_rect(
153                    Rect {
154                        origin: Point { x, y },
155                        size: Size {
156                            width: self.width,
157                            height: self.height,
158                        },
159                    },
160                    self.placeholder_color,
161                );
162                // Simple camera / image icon.
163                let cx = x + self.width / 2.0;
164                let cy = y + self.height / 2.0;
165                canvas.fill_circle(
166                    Point { x: cx, y: cy - 15.0 },
167                    12.0,
168                    Color::rgb(100, 110, 140),
169                );
170                canvas.fill_rect(
171                    Rect {
172                        origin: Point {
173                            x: cx - 20.0,
174                            y: cy + 5.0,
175                        },
176                        size: Size {
177                            width: 40.0,
178                            height: 20.0,
179                        },
180                    },
181                    Color::rgb(80, 90, 120),
182                );
183                if let ImageSource::File(path) = &self.source {
184                    let name = path.file_name().unwrap_or_default().to_string_lossy();
185                    canvas.draw_text(
186                        &name,
187                        Point {
188                            x: x + 4.0,
189                            y: y + self.height - 18.0,
190                        },
191                        Color::rgb(140, 145, 175),
192                        font,
193                        10.0,
194                    );
195                }
196            }
197        }
198    }
199
200    /// Blit a decoded pixmap onto the canvas using the widget's fit mode.
201    fn blit_pixmap(&self, canvas: &mut SkiaCanvas, x: f32, y: f32, pixmap: &tiny_skia::Pixmap) {
202        let src_w = pixmap.width() as f32;
203        let src_h = pixmap.height() as f32;
204
205        let (draw_x, draw_y, draw_w, draw_h, src_crop_x, src_crop_y, src_crop_w, src_crop_h) =
206            self.compute_fit(src_w, src_h, x, y);
207
208        let dst_w = draw_w as u32;
209        let dst_h = draw_h as u32;
210        if dst_w == 0 || dst_h == 0 {
211            return;
212        }
213
214        let scale_x = src_crop_w / draw_w;
215        let scale_y = src_crop_h / draw_h;
216
217        let canvas_w = canvas.width() as i32;
218        let canvas_h = canvas.height() as i32;
219        let pixels = canvas.pixels_mut();
220
221        for dy in 0..dst_h {
222            for dx in 0..dst_w {
223                let px = draw_x as i32 + dx as i32;
224                let py = draw_y as i32 + dy as i32;
225                if px < 0 || py < 0 || px >= canvas_w || py >= canvas_h {
226                    continue;
227                }
228
229                let sx = (src_crop_x + dx as f32 * scale_x) as u32;
230                let sy = (src_crop_y + dy as f32 * scale_y) as u32;
231                let sx = sx.min(pixmap.width() - 1);
232                let sy = sy.min(pixmap.height() - 1);
233
234                let src_idx = (sy * pixmap.width() + sx) as usize * 4;
235                let dst_idx = (py * canvas_w + px) as usize * 4;
236
237                if let (Some(src), Some(dst)) = (
238                    pixmap.data().get(src_idx..src_idx + 4),
239                    pixels.get_mut(dst_idx..dst_idx + 4),
240                ) {
241                    // tiny-skia outputs premultiplied RGBA; alpha-blend onto canvas.
242                    let alpha = src[3];
243                    if alpha == 255 {
244                        dst[0] = src[0];
245                        dst[1] = src[1];
246                        dst[2] = src[2];
247                        dst[3] = src[3];
248                    } else if alpha > 0 {
249                        let a = alpha as u32;
250                        let ia = 255 - a;
251                        dst[0] = ((src[0] as u32 * a + dst[0] as u32 * ia) / 255) as u8;
252                        dst[1] = ((src[1] as u32 * a + dst[1] as u32 * ia) / 255) as u8;
253                        dst[2] = ((src[2] as u32 * a + dst[2] as u32 * ia) / 255) as u8;
254                        dst[3] = 255;
255                    }
256                }
257            }
258        }
259    }
260
261    /// Compute `(draw_x, draw_y, draw_w, draw_h, src_crop_x, src_crop_y, src_crop_w, src_crop_h)`.
262    fn compute_fit(
263        &self,
264        src_w: f32,
265        src_h: f32,
266        x: f32,
267        y: f32,
268    ) -> (f32, f32, f32, f32, f32, f32, f32, f32) {
269        match self.fit {
270            ImageFit::Fill => (x, y, self.width, self.height, 0.0, 0.0, src_w, src_h),
271            ImageFit::None => (
272                x,
273                y,
274                src_w.min(self.width),
275                src_h.min(self.height),
276                0.0,
277                0.0,
278                src_w.min(self.width),
279                src_h.min(self.height),
280            ),
281            ImageFit::Contain => {
282                let scale = (self.width / src_w).min(self.height / src_h);
283                let dw = src_w * scale;
284                let dh = src_h * scale;
285                let dx = x + (self.width - dw) / 2.0;
286                let dy = y + (self.height - dh) / 2.0;
287                (dx, dy, dw, dh, 0.0, 0.0, src_w, src_h)
288            }
289            ImageFit::Cover => {
290                let scale = (self.width / src_w).max(self.height / src_h);
291                let scaled_w = src_w * scale;
292                let scaled_h = src_h * scale;
293                let crop_x = (scaled_w - self.width) / 2.0 / scale;
294                let crop_y = (scaled_h - self.height) / 2.0 / scale;
295                let crop_w = self.width / scale;
296                let crop_h = self.height / scale;
297                (x, y, self.width, self.height, crop_x, crop_y, crop_w, crop_h)
298            }
299        }
300    }
301}
302
303impl Default for ImageWidget {
304    fn default() -> Self {
305        Self::new()
306    }
307}
308
309// ---------------------------------------------------------------------------
310// ImageCache
311// ---------------------------------------------------------------------------
312
313/// A decoded image: shared premultiplied-RGBA pixels + dimensions. The
314/// `Arc` is what `DrawCommand::BlitRgba` carries, so a cached image costs
315/// zero copies per frame — and its stable content makes the compositor's
316/// GPU texture key (a content hash) stable across frames too.
317#[derive(Debug, Clone)]
318pub struct DecodedImage {
319    pub width:  u32,
320    pub height: u32,
321    pub pixels: std::sync::Arc<Vec<u8>>,
322}
323
324/// Caches decoded images to avoid re-decoding on every frame — keyed by
325/// file path (or a content hash for byte sources). Wired for real in
326/// Phase 27 (`Image::paint` previously did `fs::read` + PNG decode on
327/// EVERY paint — the former Known-Issues "orphaned ImageCache" entry);
328/// paint-time access goes through [`ImageCache::global`].
329///
330/// Unbounded by design for now: it holds each distinct image an app ever
331/// shows, which is the same bound the old decode-per-paint had on peak
332/// memory. A byte budget + eviction is real follow-up work, tracked with
333/// the compositor's image-texture eviction.
334pub struct ImageCache {
335    cache: HashMap<PathBuf, DecodedImage>,
336    by_bytes: HashMap<u64, DecodedImage>,
337}
338
339impl ImageCache {
340    /// Create a new empty cache.
341    pub fn new() -> Self {
342        Self {
343            cache: HashMap::new(),
344            by_bytes: HashMap::new(),
345        }
346    }
347
348    /// The process-wide cache used by `Image::paint` — same global-service
349    /// pattern as the scroll-offset channel.
350    pub fn global() -> &'static std::sync::Mutex<ImageCache> {
351        use std::sync::{Mutex, OnceLock};
352        static CACHE: OnceLock<Mutex<ImageCache>> = OnceLock::new();
353        CACHE.get_or_init(|| Mutex::new(ImageCache::new()))
354    }
355
356    /// Drop the decode for a single path (the rest of the cache survives).
357    /// The dev asset watcher uses [`ImageCache::clear`] for a blanket flush;
358    /// this is the targeted variant for when only one asset changed.
359    pub fn invalidate(&mut self, path: impl Into<PathBuf>) {
360        self.cache.remove(&path.into());
361    }
362
363    /// Return the cached image for `path`, loading and decoding it on first
364    /// access. Returns `None` if the file cannot be read or decoded as PNG.
365    pub fn get_or_load(&mut self, path: impl Into<PathBuf>) -> Option<DecodedImage> {
366        let path = path.into();
367        if !self.cache.contains_key(&path) {
368            let bytes = std::fs::read(&path).ok()?;
369            let pixmap = tiny_skia::Pixmap::decode_png(&bytes).ok()?;
370            self.cache.insert(path.clone(), DecodedImage {
371                width:  pixmap.width(),
372                height: pixmap.height(),
373                pixels: std::sync::Arc::new(pixmap.data().to_vec()),
374            });
375        }
376        self.cache.get(&path).cloned()
377    }
378
379    /// Decode-once for byte sources, keyed by a content hash (dims + len +
380    /// sampled windows — same scheme as the compositor's texture key).
381    pub fn get_or_decode_bytes(&mut self, bytes: &[u8]) -> Option<DecodedImage> {
382        let mut h: u64 = 0xcbf29ce484222325;
383        let mut eat = |b: u8| {
384            h ^= b as u64;
385            h = h.wrapping_mul(0x100000001b3);
386        };
387        for b in (bytes.len() as u64).to_le_bytes() { eat(b); }
388        let n = bytes.len();
389        for &start in &[0usize, n / 2, n.saturating_sub(32)] {
390            for &b in &bytes[start..(start + 32).min(n)] { eat(b); }
391        }
392        if let std::collections::hash_map::Entry::Vacant(e) = self.by_bytes.entry(h) {
393            let pixmap = tiny_skia::Pixmap::decode_png(bytes).ok()?;
394            e.insert(DecodedImage {
395                width:  pixmap.width(),
396                height: pixmap.height(),
397                pixels: std::sync::Arc::new(pixmap.data().to_vec()),
398            });
399        }
400        self.by_bytes.get(&h).cloned()
401    }
402
403    /// Number of cached entries (path + byte sources).
404    pub fn len(&self) -> usize {
405        self.cache.len() + self.by_bytes.len()
406    }
407
408    /// Returns `true` if the cache holds no entries.
409    pub fn is_empty(&self) -> bool {
410        self.cache.is_empty() && self.by_bytes.is_empty()
411    }
412
413    /// Remove all cached entries.
414    pub fn clear(&mut self) {
415        self.cache.clear();
416        self.by_bytes.clear();
417    }
418
419    /// Returns `true` if the given path is already in the cache.
420    pub fn contains(&self, path: impl Into<PathBuf>) -> bool {
421        self.cache.contains_key(&path.into())
422    }
423}
424
425impl Default for ImageCache {
426    fn default() -> Self {
427        Self::new()
428    }
429}
430
431// ---------------------------------------------------------------------------
432// Tests
433// ---------------------------------------------------------------------------
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438
439    // ── ImageWidget construction ──────────────────────────────────────────────
440
441    #[test]
442    fn image_widget_new_defaults() {
443        let w = ImageWidget::new();
444        assert_eq!(w.source, ImageSource::Placeholder);
445        assert_eq!(w.fit, ImageFit::Contain);
446        assert_eq!(w.width, 200.0);
447        assert_eq!(w.height, 200.0);
448    }
449
450    #[test]
451    fn image_widget_file_source() {
452        let w = ImageWidget::new().file("/tmp/photo.png");
453        assert_eq!(w.source, ImageSource::File(PathBuf::from("/tmp/photo.png")));
454    }
455
456    #[test]
457    fn image_widget_bytes_source() {
458        let data = vec![0u8, 1, 2, 3];
459        let w = ImageWidget::new().bytes(data.clone());
460        assert_eq!(w.source, ImageSource::Bytes(data));
461    }
462
463    #[test]
464    fn image_widget_fit_setter() {
465        let w = ImageWidget::new().fit(ImageFit::Cover);
466        assert_eq!(w.fit, ImageFit::Cover);
467    }
468
469    #[test]
470    fn image_widget_size_setters() {
471        let w = ImageWidget::new().width(640.0).height(480.0);
472        assert_eq!(w.width, 640.0);
473        assert_eq!(w.height, 480.0);
474    }
475
476    // ── compute_fit geometry ──────────────────────────────────────────────────
477
478    #[test]
479    fn image_fit_contain() {
480        // 100×50 image into 200×200 widget — should scale uniformly (scale=2)
481        // → drawn at 200×100, offset to centre vertically.
482        let w = ImageWidget::new().fit(ImageFit::Contain).width(200.0).height(200.0);
483        let (dx, dy, dw, dh, scx, scy, scw, sch) = w.compute_fit(100.0, 50.0, 0.0, 0.0);
484        assert!((dw - 200.0).abs() < 0.01, "dw={dw}");
485        assert!((dh - 100.0).abs() < 0.01, "dh={dh}");
486        assert!((dx - 0.0).abs() < 0.01, "dx={dx}");
487        assert!((dy - 50.0).abs() < 0.01, "dy={dy}");
488        // source crop covers full image
489        assert!((scx).abs() < 0.01);
490        assert!((scy).abs() < 0.01);
491        assert!((scw - 100.0).abs() < 0.01);
492        assert!((sch - 50.0).abs() < 0.01);
493    }
494
495    #[test]
496    fn image_fit_fill() {
497        let w = ImageWidget::new().fit(ImageFit::Fill).width(300.0).height(150.0);
498        let (dx, dy, dw, dh, scx, scy, scw, sch) = w.compute_fit(100.0, 100.0, 5.0, 5.0);
499        assert_eq!(dx, 5.0);
500        assert_eq!(dy, 5.0);
501        assert_eq!(dw, 300.0);
502        assert_eq!(dh, 150.0);
503        assert_eq!(scx, 0.0);
504        assert_eq!(scy, 0.0);
505        assert_eq!(scw, 100.0);
506        assert_eq!(sch, 100.0);
507    }
508
509    #[test]
510    fn image_fit_cover() {
511        // 100×100 image into 200×100 widget — scale=2 to cover width,
512        // crop 50px from top and bottom in source space (25px each side).
513        let w = ImageWidget::new().fit(ImageFit::Cover).width(200.0).height(100.0);
514        let (dx, dy, dw, dh, scx, scy, scw, sch) = w.compute_fit(100.0, 100.0, 0.0, 0.0);
515        assert!((dw - 200.0).abs() < 0.01, "dw={dw}");
516        assert!((dh - 100.0).abs() < 0.01, "dh={dh}");
517        assert!((dx).abs() < 0.01, "dx={dx}");
518        assert!((dy).abs() < 0.01, "dy={dy}");
519        // crop: source crop_w = widget_w / scale = 200/2 = 100 ✓ (full width)
520        assert!((scx).abs() < 0.01, "scx={scx}");
521        assert!((scy - 25.0).abs() < 0.01, "scy={scy}");
522        assert!((scw - 100.0).abs() < 0.01, "scw={scw}");
523        assert!((sch - 50.0).abs() < 0.01, "sch={sch}");
524    }
525
526    // ── ImageCache ────────────────────────────────────────────────────────────
527
528    #[test]
529    fn image_cache_new_empty() {
530        let cache = ImageCache::new();
531        assert!(cache.is_empty());
532        assert_eq!(cache.len(), 0);
533    }
534
535    #[test]
536    fn image_cache_contains_after_miss() {
537        let cache = ImageCache::new();
538        // A non-existent path should NOT end up cached.
539        assert!(!cache.contains("/no/such/file.png"));
540    }
541}