Skip to main content

frust_widgets/
image.rs

1//! The `Image` widget: decode-once PNG/JPEG bitmaps painted
2//! through the scene's [`Command::Image`](frust_scene::Command::Image).
3//!
4//! [`ImageSource`] is the decode-once, cheaply-clonable handle app code holds
5//! (and re-passes every frame): [`ImageSource::decode`] runs the `image` crate
6//! exactly once, at construction, and never again — [`ImageView::rebuild`]
7//! only ever compares [`ImageSource::same`] (an `Arc` pointer check), so a
8//! rebuild that re-supplies the *same* decoded source (even a fresh `.clone()`
9//! of it) costs nothing beyond a refcount bump.
10//!
11//! [`Image`] is the declarative view-fn; [`ImageFit`] controls how the
12//! widget's laid-out box relates to the image's natural pixel size at paint
13//! time (`Fill` stretches, `Contain` letterboxes, `Cover` center-crops via
14//! [`PaintScene::push_clip`]).
15
16use frust_core::{
17    BoxConstraints, BuildCtx, ChangeFlags, LayoutCtx, PaintCtx, PaintScene, View, Widget,
18};
19use kurbo::{Rect, Size};
20use peniko::{Blob, ImageAlphaType, ImageData, ImageFormat};
21use std::sync::Arc;
22
23/// Error decoding image bytes via [`ImageSource::decode`].
24///
25/// Wraps the `image` crate's own decode error; callers that need to match on
26/// the underlying failure can reach it through [`std::error::Error::source`].
27#[derive(Debug)]
28pub struct ImageError(image::ImageError);
29
30impl std::fmt::Display for ImageError {
31    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32        write!(f, "failed to decode image: {}", self.0)
33    }
34}
35
36impl std::error::Error for ImageError {
37    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
38        Some(&self.0)
39    }
40}
41
42/// A decode-once, cheaply-clonable handle around a decoded RGBA8 image.
43///
44/// Identity is the inner `Arc` pointer: [`ImageSource::same`] compares by
45/// pointer, not pixel content, in O(1) regardless of image size — the signal
46/// [`ImageView`]'s `rebuild` relies on to know "same image, nothing to redo"
47/// without ever touching the decoder again.
48#[derive(Clone, Debug)]
49pub struct ImageSource {
50    data: Arc<ImageData>,
51}
52
53impl ImageSource {
54    /// Decode PNG/JPEG bytes into straight-alpha RGBA8 once, wrapping the
55    /// result in a shareable handle. Callers own the resulting `ImageSource`
56    /// (typically stashing it in app state) and re-pass it into [`Image`]
57    /// every frame — this never re-decodes.
58    pub fn decode(bytes: &[u8]) -> Result<Self, ImageError> {
59        let decoded = image::load_from_memory(bytes).map_err(ImageError)?;
60        let rgba = decoded.to_rgba8();
61        let (width, height) = rgba.dimensions();
62        Ok(Self::from_rgba8(rgba.into_raw(), width, height))
63    }
64
65    /// Wrap already-decoded straight-alpha RGBA8 pixel data directly (e.g. a
66    /// procedurally generated image, or a test fixture) without going through
67    /// the `image` crate's decoder.
68    ///
69    /// `pixels.len()` must equal `width * height * 4`; a mismatched length is
70    /// a caller bug and is not checked here (mirrors `peniko::ImageData`'s own
71    /// contract — the renderer will simply read `width * height * 4` bytes).
72    pub fn from_rgba8(pixels: Vec<u8>, width: u32, height: u32) -> Self {
73        Self {
74            data: Arc::new(ImageData {
75                data: Blob::from(pixels),
76                format: ImageFormat::Rgba8,
77                alpha_type: ImageAlphaType::Alpha,
78                width,
79                height,
80            }),
81        }
82    }
83
84    /// Whether `self` and `other` share the same decoded data (`Arc` pointer
85    /// identity, not content equality) — never true for two independently
86    /// decoded sources even if their bytes matched, since each decode
87    /// allocates a fresh `Arc`.
88    pub fn same(&self, other: &ImageSource) -> bool {
89        Arc::ptr_eq(&self.data, &other.data)
90    }
91
92    /// The natural (decoded pixel) size.
93    pub fn natural_size(&self) -> Size {
94        Size::new(self.data.width as f64, self.data.height as f64)
95    }
96
97    /// Borrow the decoded `peniko::ImageData` this source wraps, for painting.
98    ///
99    /// This returns a borrow of already-decoded pixels and triggers no decode.
100    /// The source caches pixel data once at construction via [`Self::decode`];
101    /// this accessor hands back a reference to that cache. Calling this
102    /// repeatedly with the same [`ImageSource`] is zero-cost and does not
103    /// re-trigger the image decoder.
104    pub fn image_data(&self) -> &ImageData {
105        &self.data
106    }
107}
108
109/// How an [`Image`]'s laid-out box relates to its source's natural pixel size
110/// at paint time.
111#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
112pub enum ImageFit {
113    /// Stretch the image to exactly fill the widget's box, ignoring aspect
114    /// ratio.
115    Fill,
116    /// Scale the image (preserving aspect ratio) to fit entirely within the
117    /// widget's box; the non-fitting axis is letterboxed (centered, with
118    /// empty space on either side).
119    #[default]
120    Contain,
121    /// Scale the image (preserving aspect ratio) to cover the widget's box
122    /// entirely; the overflowing axis is center-cropped via a clip.
123    Cover,
124}
125
126/// Compute the rect (relative to the widget's own origin) the image, scaled
127/// per `fit`, should be drawn into.
128///
129/// For [`ImageFit::Fill`] this exactly equals `(0, 0, dest.width,
130/// dest.height)`. For [`ImageFit::Contain`] it is centered and no larger than
131/// `dest` on either axis (possibly smaller on one — the letterbox). For
132/// [`ImageFit::Cover`] it is centered and no smaller than `dest` on either
133/// axis (possibly larger on one — the caller must clip to `dest` around it).
134///
135/// A degenerate (zero-area) `natural` or `dest` collapses to `dest` itself
136/// (nothing sensible to scale).
137fn fit_rect(natural: Size, dest: Size, fit: ImageFit) -> Rect {
138    if natural.width <= 0.0 || natural.height <= 0.0 || dest.width <= 0.0 || dest.height <= 0.0 {
139        return Rect::new(0.0, 0.0, dest.width, dest.height);
140    }
141    match fit {
142        ImageFit::Fill => Rect::new(0.0, 0.0, dest.width, dest.height),
143        ImageFit::Contain => centered_scaled_rect(
144            natural,
145            dest,
146            (dest.width / natural.width).min(dest.height / natural.height),
147        ),
148        ImageFit::Cover => centered_scaled_rect(
149            natural,
150            dest,
151            (dest.width / natural.width).max(dest.height / natural.height),
152        ),
153    }
154}
155
156/// Scale `natural` by `scale` and center the result within `dest`.
157fn centered_scaled_rect(natural: Size, dest: Size, scale: f64) -> Rect {
158    let w = natural.width * scale;
159    let h = natural.height * scale;
160    let x = (dest.width - w) / 2.0;
161    let y = (dest.height - h) / 2.0;
162    Rect::new(x, y, x + w, y + h)
163}
164
165/// A declarative description of an image.
166///
167/// Construct with [`Image`]; set the fit mode with [`ImageView::fit`]
168/// (defaults to [`ImageFit::Contain`]).
169pub struct ImageView {
170    source: ImageSource,
171    fit: ImageFit,
172}
173
174/// Create an image view over a decoded [`ImageSource`], defaulting to
175/// [`ImageFit::Contain`].
176#[allow(non_snake_case)]
177pub fn Image(source: ImageSource) -> ImageView {
178    ImageView {
179        source,
180        fit: ImageFit::default(),
181    }
182}
183
184impl ImageView {
185    /// Set how the image's natural size relates to its laid-out box at paint
186    /// time.
187    pub fn fit(mut self, fit: ImageFit) -> Self {
188        self.fit = fit;
189        self
190    }
191}
192
193impl<State: 'static> View<State> for ImageView {
194    type Element = ImageWidget;
195
196    fn build(&self, _ctx: &mut BuildCtx<'_>) -> ImageWidget {
197        ImageWidget {
198            source: self.source.clone(),
199            fit: self.fit,
200        }
201    }
202
203    fn rebuild(
204        &self,
205        prev: &Self,
206        element: &mut ImageWidget,
207        _ctx: &mut BuildCtx<'_>,
208    ) -> ChangeFlags {
209        let mut flags = ChangeFlags::NONE;
210        // `same()` is an Arc-pointer check, never a decode: swapping to a
211        // genuinely different source is the only path that touches the
212        // decoder, and only inside `ImageSource::decode` at construction —
213        // never here.
214        if !prev.source.same(&self.source) {
215            element.source = self.source.clone();
216            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
217        }
218        if prev.fit != self.fit {
219            element.fit = self.fit;
220            flags |= ChangeFlags::PAINT;
221        }
222        flags
223    }
224}
225
226/// The retained widget for an [`ImageView`].
227pub struct ImageWidget {
228    source: ImageSource,
229    fit: ImageFit,
230}
231
232impl Widget for ImageWidget {
233    fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
234        // A tight constraint (e.g. this image sits inside a `SizedBox`) wins
235        // outright; otherwise the image prefers its natural size, clamped
236        // into the incoming bounds — mirrors `TextWidget`'s intrinsic-size
237        // pattern. `fit` only affects how that box's content is scaled at
238        // paint time, never the box itself.
239        if bc.is_tight() {
240            return bc.max();
241        }
242        bc.constrain(self.source.natural_size())
243    }
244
245    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
246        let natural = self.source.natural_size();
247        let dest_size = ctx.size();
248        let local = fit_rect(natural, dest_size, self.fit);
249        let origin = ctx.origin();
250        let absolute = local + origin.to_vec2();
251
252        let needs_clip = self.fit == ImageFit::Cover
253            && (local.width() > dest_size.width || local.height() > dest_size.height);
254        if needs_clip {
255            scene.push_clip(origin, dest_size);
256        }
257        scene.draw_image(self.source.image_data(), absolute);
258        if needs_clip {
259            scene.pop_clip();
260        }
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267    use frust_core::BuildCtx;
268    use kurbo::Point;
269
270    fn rgba(width: u32, height: u32) -> ImageSource {
271        ImageSource::from_rgba8(vec![0u8; (width * height * 4) as usize], width, height)
272    }
273
274    fn build(view: &ImageView) -> ImageWidget {
275        let mut counter = 0u64;
276        <ImageView as View<()>>::build(view, &mut BuildCtx::new(&mut counter))
277    }
278
279    // -- ImageSource ---------------------------------------------------
280
281    #[test]
282    fn from_rgba8_reports_natural_size() {
283        let source = rgba(4, 8);
284        assert_eq!(source.natural_size(), Size::new(4.0, 8.0));
285    }
286
287    #[test]
288    fn same_is_true_only_for_a_shared_arc() {
289        let a = rgba(2, 2);
290        let b = a.clone();
291        let c = rgba(2, 2); // independently constructed, same pixels, different Arc.
292        assert!(a.same(&b), "a clone shares the Arc");
293        assert!(!a.same(&c), "an independently built source never matches");
294    }
295
296    #[test]
297    fn decode_error_reports_the_underlying_image_crate_failure() {
298        let err = ImageSource::decode(b"not an image").unwrap_err();
299        // The `Display` impl should at least name what went wrong, and
300        // `source()` must recover the underlying `image::ImageError`.
301        assert!(err.to_string().contains("failed to decode image"));
302        assert!(std::error::Error::source(&err).is_some());
303    }
304
305    #[test]
306    fn decode_a_two_by_two_png_round_trips_dimensions_and_alpha_type() {
307        // A minimal, hand-encoded 2x2 RGBA PNG (generated once and embedded as
308        // bytes — no filesystem/asset dependency for the test).
309        let png = two_by_two_png();
310        let source = ImageSource::decode(&png).expect("valid PNG decodes");
311        assert_eq!(source.natural_size(), Size::new(2.0, 2.0));
312        assert_eq!(source.image_data().alpha_type, ImageAlphaType::Alpha);
313        assert_eq!(source.image_data().format, ImageFormat::Rgba8);
314        assert_eq!(source.image_data().data.len(), 2 * 2 * 4);
315    }
316
317    /// Encode a tiny 2x2 RGBA PNG in-process via the `image` crate itself, so
318    /// the decode-path test above has no external fixture file to keep in
319    /// sync.
320    fn two_by_two_png() -> Vec<u8> {
321        use image::{ImageEncoder, codecs::png::PngEncoder};
322        let pixels: [u8; 2 * 2 * 4] = [
323            255, 0, 0, 255, // red
324            0, 255, 0, 255, // green
325            0, 0, 255, 255, // blue
326            255, 255, 0, 255, // yellow
327        ];
328        let mut out = Vec::new();
329        PngEncoder::new(&mut out)
330            .write_image(&pixels, 2, 2, image::ExtendedColorType::Rgba8)
331            .expect("encoding a tiny in-memory PNG must not fail");
332        out
333    }
334
335    // -- fit math --------------------------------------------------------
336
337    #[test]
338    fn fill_always_matches_dest_regardless_of_aspect_ratio() {
339        let rect = fit_rect(
340            Size::new(10.0, 20.0),
341            Size::new(100.0, 50.0),
342            ImageFit::Fill,
343        );
344        assert_eq!(rect, Rect::new(0.0, 0.0, 100.0, 50.0));
345    }
346
347    #[test]
348    fn contain_letterboxes_a_wider_dest_around_a_taller_natural_image() {
349        // Natural 1:2 (portrait) into a 200x100 (landscape) dest: height-bound
350        // scale (100/2 = 50), width shrinks to 50 and is centered.
351        let rect = fit_rect(
352            Size::new(100.0, 200.0),
353            Size::new(200.0, 100.0),
354            ImageFit::Contain,
355        );
356        assert_eq!(rect.width(), 50.0);
357        assert_eq!(rect.height(), 100.0);
358        assert_eq!(rect.x0, 75.0); // (200 - 50) / 2
359        assert_eq!(rect.y0, 0.0);
360    }
361
362    #[test]
363    fn contain_letterboxes_a_taller_dest_around_a_wider_natural_image() {
364        // Natural 2:1 (landscape) into a 100x200 (portrait) dest: width-bound
365        // scale (100/2 = 50), height shrinks to 50 and is centered.
366        let rect = fit_rect(
367            Size::new(200.0, 100.0),
368            Size::new(100.0, 200.0),
369            ImageFit::Contain,
370        );
371        assert_eq!(rect.width(), 100.0);
372        assert_eq!(rect.height(), 50.0);
373        assert_eq!(rect.x0, 0.0);
374        assert_eq!(rect.y0, 75.0); // (200 - 50) / 2
375    }
376
377    #[test]
378    fn contain_preserves_aspect_ratio_at_a_non_trivial_ratio() {
379        // A 3:4 natural image into a 300x300 square dest: width-bound scale
380        // (300/3 = 100) since 3:4 is portrait-ish; check aspect is preserved.
381        let rect = fit_rect(
382            Size::new(300.0, 400.0),
383            Size::new(300.0, 300.0),
384            ImageFit::Contain,
385        );
386        let natural_aspect = 300.0 / 400.0;
387        let rect_aspect = rect.width() / rect.height();
388        assert!(
389            (natural_aspect - rect_aspect).abs() < 1e-9,
390            "contain must preserve the natural aspect ratio"
391        );
392        assert!(rect.width() <= 300.0 && rect.height() <= 300.0);
393    }
394
395    #[test]
396    fn cover_crops_a_wider_dest_around_a_taller_natural_image() {
397        // Natural 1:2 (portrait) into a 200x100 (landscape) dest: width-bound
398        // scale (200/1 = 200) so the image overflows vertically and is
399        // center-cropped.
400        let rect = fit_rect(
401            Size::new(100.0, 200.0),
402            Size::new(200.0, 100.0),
403            ImageFit::Cover,
404        );
405        assert_eq!(rect.width(), 200.0);
406        assert_eq!(rect.height(), 400.0);
407        assert_eq!(rect.x0, 0.0);
408        assert_eq!(rect.y0, -150.0); // (100 - 400) / 2
409    }
410
411    #[test]
412    fn cover_at_least_covers_dest_on_both_axes() {
413        let dest = Size::new(150.0, 90.0);
414        let rect = fit_rect(Size::new(37.0, 51.0), dest, ImageFit::Cover);
415        assert!(rect.width() >= dest.width - 1e-9);
416        assert!(rect.height() >= dest.height - 1e-9);
417    }
418
419    #[test]
420    fn fit_rect_collapses_to_dest_for_degenerate_natural_size() {
421        let dest = Size::new(40.0, 20.0);
422        for fit in [ImageFit::Fill, ImageFit::Contain, ImageFit::Cover] {
423            let rect = fit_rect(Size::ZERO, dest, fit);
424            assert_eq!(rect, Rect::new(0.0, 0.0, dest.width, dest.height));
425        }
426    }
427
428    // -- layout ------------------------------------------------------------
429
430    #[test]
431    fn loose_constraints_choose_the_natural_size() {
432        let view = Image(rgba(40, 30));
433        let mut w: ImageWidget = build(&view);
434        let mut lctx = LayoutCtx::new();
435        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
436        assert_eq!(size, Size::new(40.0, 30.0));
437    }
438
439    #[test]
440    fn tight_constraints_force_the_exact_box_regardless_of_natural_size() {
441        let view = Image(rgba(40, 30));
442        let mut w: ImageWidget = build(&view);
443        let mut lctx = LayoutCtx::new();
444        let size = w.layout(&mut lctx, &BoxConstraints::tight(Size::new(200.0, 10.0)));
445        assert_eq!(size, Size::new(200.0, 10.0));
446    }
447
448    #[test]
449    fn loose_constraints_clamp_a_natural_size_larger_than_the_bound() {
450        let view = Image(rgba(1000, 1000));
451        let mut w: ImageWidget = build(&view);
452        let mut lctx = LayoutCtx::new();
453        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(50.0, 50.0)));
454        assert_eq!(size, Size::new(50.0, 50.0));
455    }
456
457    // -- paint / cache identity --------------------------------------------
458
459    /// A minimal recording [`PaintScene`] capturing only what `Image::paint`
460    /// touches: clip push/pop and the drawn image's dest rect + dimensions.
461    #[derive(Default)]
462    struct RecordingScene {
463        clips: Vec<(Point, Size)>,
464        pops: u32,
465        images: Vec<(u32, u32, Rect)>,
466    }
467
468    impl PaintScene for RecordingScene {
469        fn fill_rect(&mut self, _origin: Point, _size: Size, _color: peniko::Color) {}
470        fn draw_text(&mut self, _origin: Point, _text: &str) {}
471        fn push_clip(&mut self, origin: Point, size: Size) {
472            self.clips.push((origin, size));
473        }
474        fn pop_clip(&mut self) {
475            self.pops += 1;
476        }
477        fn draw_image(&mut self, data: &ImageData, dest: Rect) {
478            self.images.push((data.width, data.height, dest));
479        }
480    }
481
482    fn paint_at(w: &mut ImageWidget, origin: Point, size: Size) -> RecordingScene {
483        let mut ctx = PaintCtx::new(origin, size);
484        let mut scene = RecordingScene::default();
485        w.paint(&mut ctx, &mut scene);
486        scene
487    }
488
489    #[test]
490    fn fill_paints_the_full_dest_rect_with_no_clip() {
491        let view = Image(rgba(10, 10)).fit(ImageFit::Fill);
492        let mut w: ImageWidget = build(&view);
493        let scene = paint_at(&mut w, Point::new(0.0, 0.0), Size::new(50.0, 20.0));
494        assert!(scene.clips.is_empty(), "Fill never clips");
495        assert_eq!(scene.pops, 0);
496        assert_eq!(scene.images.len(), 1);
497        let (w_px, h_px, dest) = scene.images[0];
498        assert_eq!((w_px, h_px), (10, 10));
499        assert_eq!(dest, Rect::new(0.0, 0.0, 50.0, 20.0));
500    }
501
502    #[test]
503    fn contain_paints_a_letterboxed_rect_with_no_clip() {
504        let view = Image(rgba(100, 200)).fit(ImageFit::Contain);
505        let mut w: ImageWidget = build(&view);
506        let scene = paint_at(&mut w, Point::new(5.0, 5.0), Size::new(200.0, 100.0));
507        assert!(scene.clips.is_empty(), "Contain never overflows dest");
508        let (_, _, dest) = scene.images[0];
509        // Same shape as the pure fit_rect test, offset by the paint origin
510        // (5, 5).
511        assert_eq!(dest.width(), 50.0);
512        assert_eq!(dest.x0, 5.0 + 75.0);
513        assert_eq!(dest.y0, 5.0);
514    }
515
516    #[test]
517    fn cover_clips_to_dest_around_an_overflowing_scaled_rect() {
518        let view = Image(rgba(100, 200)).fit(ImageFit::Cover);
519        let mut w: ImageWidget = build(&view);
520        let origin = Point::new(2.0, 3.0);
521        let dest_size = Size::new(200.0, 100.0);
522        let scene = paint_at(&mut w, origin, dest_size);
523        assert_eq!(
524            scene.clips,
525            vec![(origin, dest_size)],
526            "Cover clips to the widget's own box"
527        );
528        assert_eq!(scene.pops, 1, "the clip must be popped");
529        let (_, _, dest) = scene.images[0];
530        // The drawn rect overflows the widget's dest on the vertical axis
531        // (matches the pure cover fit-math test, offset by `origin`).
532        assert!(dest.height() > dest_size.height);
533    }
534
535    #[test]
536    fn rebuild_with_a_cloned_source_keeps_the_widgets_arc_identity() {
537        let source = rgba(8, 8);
538        let view: ImageView = Image(source.clone());
539        let mut w: ImageWidget = build(&view);
540        let original_source = w.source.clone();
541
542        // The next view re-passes a *clone* of the same source (the ordinary
543        // "app_logic re-runs every frame" shape) — never a fresh decode.
544        let next: ImageView = Image(source.clone());
545        let mut counter = 0u64;
546        <ImageView as View<()>>::rebuild(&next, &view, &mut w, &mut BuildCtx::new(&mut counter));
547
548        assert!(
549            w.source.same(&original_source),
550            "a cloned-source rebuild must keep the same decoded Arc, never re-decode"
551        );
552    }
553
554    #[test]
555    fn rebuild_with_a_different_source_replaces_the_widgets_source() {
556        let first = rgba(8, 8);
557        let second = rgba(16, 16);
558        let view: ImageView = Image(first.clone());
559        let mut w: ImageWidget = build(&view);
560
561        let next: ImageView = Image(second.clone());
562        let mut counter = 0u64;
563        let flags = <ImageView as View<()>>::rebuild(
564            &next,
565            &view,
566            &mut w,
567            &mut BuildCtx::new(&mut counter),
568        );
569
570        assert!(w.source.same(&second));
571        assert!(!w.source.same(&first));
572        assert!(flags.contains(ChangeFlags::LAYOUT));
573        assert!(flags.contains(ChangeFlags::PAINT));
574    }
575
576    #[test]
577    fn rebuild_with_a_different_fit_flags_paint_only() {
578        let source = rgba(8, 8);
579        let view: ImageView = Image(source.clone()).fit(ImageFit::Contain);
580        let mut w: ImageWidget = build(&view);
581
582        let next: ImageView = Image(source).fit(ImageFit::Cover);
583        let mut counter = 0u64;
584        let flags = <ImageView as View<()>>::rebuild(
585            &next,
586            &view,
587            &mut w,
588            &mut BuildCtx::new(&mut counter),
589        );
590
591        assert_eq!(w.fit, ImageFit::Cover);
592        assert!(flags.contains(ChangeFlags::PAINT));
593        assert!(!flags.contains(ChangeFlags::LAYOUT));
594    }
595}