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
232/// Constrain a natural size to fit within the given constraints while preserving
233/// aspect ratio. Follows Flutter's `BoxConstraints.constrainSizeAndAttemptToPreserveAspectRatio`.
234///
235/// For a degenerate (zero or non-finite) natural size, falls back to
236/// `bc.constrain(natural)` (the existing behaviour for degenerate images).
237/// For a valid aspect ratio, clamps width and height on each axis (min, then max)
238/// while adjusting the other axis to preserve the aspect ratio.
239fn constrain_size_preserve_aspect_ratio(natural: Size, bc: &BoxConstraints) -> Size {
240    let mut width = natural.width;
241    let mut height = natural.height;
242
243    // Degenerate natural size: fall back to regular constrain
244    if width <= 0.0 || height <= 0.0 || !width.is_finite() || !height.is_finite() {
245        return bc.constrain(natural);
246    }
247
248    let aspect = width / height;
249    let max = bc.max();
250    let min = bc.min();
251
252    // Clamp to max on each axis, preserving aspect ratio
253    if width > max.width {
254        width = max.width;
255        height = width / aspect;
256    }
257    if height > max.height {
258        height = max.height;
259        width = height * aspect;
260    }
261
262    // Clamp to min on each axis, preserving aspect ratio
263    if width < min.width {
264        width = min.width;
265        height = width / aspect;
266    }
267    if height < min.height {
268        height = min.height;
269        width = height * aspect;
270    }
271
272    bc.constrain(Size::new(width, height))
273}
274
275impl Widget for ImageWidget {
276    fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
277        // A tight constraint (e.g. this image sits inside a `SizedBox`) wins
278        // outright. For a non-tight constraint, preserve the natural aspect ratio
279        // by sizing the box according to the aspect-ratio-preserving algorithm,
280        // then further constraining the result. `fit` only affects how that box's
281        // content is scaled at paint time, never the box itself.
282        if bc.is_tight() {
283            return bc.max();
284        }
285        constrain_size_preserve_aspect_ratio(self.source.natural_size(), bc)
286    }
287
288    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
289        let natural = self.source.natural_size();
290        let dest_size = ctx.size();
291        let local = fit_rect(natural, dest_size, self.fit);
292        let origin = ctx.origin();
293        let absolute = local + origin.to_vec2();
294
295        let needs_clip = self.fit == ImageFit::Cover
296            && (local.width() > dest_size.width || local.height() > dest_size.height);
297        if needs_clip {
298            scene.push_clip(origin, dest_size);
299        }
300        scene.draw_image(self.source.image_data(), absolute);
301        if needs_clip {
302            scene.pop_clip();
303        }
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310    use frust_core::BuildCtx;
311    use kurbo::Point;
312
313    fn rgba(width: u32, height: u32) -> ImageSource {
314        ImageSource::from_rgba8(vec![0u8; (width * height * 4) as usize], width, height)
315    }
316
317    fn build(view: &ImageView) -> ImageWidget {
318        let mut counter = 0u64;
319        <ImageView as View<()>>::build(view, &mut BuildCtx::new(&mut counter))
320    }
321
322    // -- ImageSource ---------------------------------------------------
323
324    #[test]
325    fn from_rgba8_reports_natural_size() {
326        let source = rgba(4, 8);
327        assert_eq!(source.natural_size(), Size::new(4.0, 8.0));
328    }
329
330    #[test]
331    fn same_is_true_only_for_a_shared_arc() {
332        let a = rgba(2, 2);
333        let b = a.clone();
334        let c = rgba(2, 2); // independently constructed, same pixels, different Arc.
335        assert!(a.same(&b), "a clone shares the Arc");
336        assert!(!a.same(&c), "an independently built source never matches");
337    }
338
339    #[test]
340    fn decode_error_reports_the_underlying_image_crate_failure() {
341        let err = ImageSource::decode(b"not an image").unwrap_err();
342        // The `Display` impl should at least name what went wrong, and
343        // `source()` must recover the underlying `image::ImageError`.
344        assert!(err.to_string().contains("failed to decode image"));
345        assert!(std::error::Error::source(&err).is_some());
346    }
347
348    #[test]
349    fn decode_a_two_by_two_png_round_trips_dimensions_and_alpha_type() {
350        // A minimal, hand-encoded 2x2 RGBA PNG (generated once and embedded as
351        // bytes — no filesystem/asset dependency for the test).
352        let png = two_by_two_png();
353        let source = ImageSource::decode(&png).expect("valid PNG decodes");
354        assert_eq!(source.natural_size(), Size::new(2.0, 2.0));
355        assert_eq!(source.image_data().alpha_type, ImageAlphaType::Alpha);
356        assert_eq!(source.image_data().format, ImageFormat::Rgba8);
357        assert_eq!(source.image_data().data.len(), 2 * 2 * 4);
358    }
359
360    /// Encode a tiny 2x2 RGBA PNG in-process via the `image` crate itself, so
361    /// the decode-path test above has no external fixture file to keep in
362    /// sync.
363    fn two_by_two_png() -> Vec<u8> {
364        use image::{ImageEncoder, codecs::png::PngEncoder};
365        let pixels: [u8; 2 * 2 * 4] = [
366            255, 0, 0, 255, // red
367            0, 255, 0, 255, // green
368            0, 0, 255, 255, // blue
369            255, 255, 0, 255, // yellow
370        ];
371        let mut out = Vec::new();
372        PngEncoder::new(&mut out)
373            .write_image(&pixels, 2, 2, image::ExtendedColorType::Rgba8)
374            .expect("encoding a tiny in-memory PNG must not fail");
375        out
376    }
377
378    // -- fit math --------------------------------------------------------
379
380    #[test]
381    fn fill_always_matches_dest_regardless_of_aspect_ratio() {
382        let rect = fit_rect(
383            Size::new(10.0, 20.0),
384            Size::new(100.0, 50.0),
385            ImageFit::Fill,
386        );
387        assert_eq!(rect, Rect::new(0.0, 0.0, 100.0, 50.0));
388    }
389
390    #[test]
391    fn contain_letterboxes_a_wider_dest_around_a_taller_natural_image() {
392        // Natural 1:2 (portrait) into a 200x100 (landscape) dest: height-bound
393        // scale (100/2 = 50), width shrinks to 50 and is centered.
394        let rect = fit_rect(
395            Size::new(100.0, 200.0),
396            Size::new(200.0, 100.0),
397            ImageFit::Contain,
398        );
399        assert_eq!(rect.width(), 50.0);
400        assert_eq!(rect.height(), 100.0);
401        assert_eq!(rect.x0, 75.0); // (200 - 50) / 2
402        assert_eq!(rect.y0, 0.0);
403    }
404
405    #[test]
406    fn contain_letterboxes_a_taller_dest_around_a_wider_natural_image() {
407        // Natural 2:1 (landscape) into a 100x200 (portrait) dest: width-bound
408        // scale (100/2 = 50), height shrinks to 50 and is centered.
409        let rect = fit_rect(
410            Size::new(200.0, 100.0),
411            Size::new(100.0, 200.0),
412            ImageFit::Contain,
413        );
414        assert_eq!(rect.width(), 100.0);
415        assert_eq!(rect.height(), 50.0);
416        assert_eq!(rect.x0, 0.0);
417        assert_eq!(rect.y0, 75.0); // (200 - 50) / 2
418    }
419
420    #[test]
421    fn contain_preserves_aspect_ratio_at_a_non_trivial_ratio() {
422        // A 3:4 natural image into a 300x300 square dest: width-bound scale
423        // (300/3 = 100) since 3:4 is portrait-ish; check aspect is preserved.
424        let rect = fit_rect(
425            Size::new(300.0, 400.0),
426            Size::new(300.0, 300.0),
427            ImageFit::Contain,
428        );
429        let natural_aspect = 300.0 / 400.0;
430        let rect_aspect = rect.width() / rect.height();
431        assert!(
432            (natural_aspect - rect_aspect).abs() < 1e-9,
433            "contain must preserve the natural aspect ratio"
434        );
435        assert!(rect.width() <= 300.0 && rect.height() <= 300.0);
436    }
437
438    #[test]
439    fn cover_crops_a_wider_dest_around_a_taller_natural_image() {
440        // Natural 1:2 (portrait) into a 200x100 (landscape) dest: width-bound
441        // scale (200/1 = 200) so the image overflows vertically and is
442        // center-cropped.
443        let rect = fit_rect(
444            Size::new(100.0, 200.0),
445            Size::new(200.0, 100.0),
446            ImageFit::Cover,
447        );
448        assert_eq!(rect.width(), 200.0);
449        assert_eq!(rect.height(), 400.0);
450        assert_eq!(rect.x0, 0.0);
451        assert_eq!(rect.y0, -150.0); // (100 - 400) / 2
452    }
453
454    #[test]
455    fn cover_at_least_covers_dest_on_both_axes() {
456        let dest = Size::new(150.0, 90.0);
457        let rect = fit_rect(Size::new(37.0, 51.0), dest, ImageFit::Cover);
458        assert!(rect.width() >= dest.width - 1e-9);
459        assert!(rect.height() >= dest.height - 1e-9);
460    }
461
462    #[test]
463    fn fit_rect_collapses_to_dest_for_degenerate_natural_size() {
464        let dest = Size::new(40.0, 20.0);
465        for fit in [ImageFit::Fill, ImageFit::Contain, ImageFit::Cover] {
466            let rect = fit_rect(Size::ZERO, dest, fit);
467            assert_eq!(rect, Rect::new(0.0, 0.0, dest.width, dest.height));
468        }
469    }
470
471    // -- layout ------------------------------------------------------------
472
473    #[test]
474    fn loose_constraints_choose_the_natural_size() {
475        let view = Image(rgba(40, 30));
476        let mut w: ImageWidget = build(&view);
477        let mut lctx = LayoutCtx::new();
478        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
479        assert_eq!(size, Size::new(40.0, 30.0));
480    }
481
482    #[test]
483    fn tight_constraints_force_the_exact_box_regardless_of_natural_size() {
484        let view = Image(rgba(40, 30));
485        let mut w: ImageWidget = build(&view);
486        let mut lctx = LayoutCtx::new();
487        let size = w.layout(&mut lctx, &BoxConstraints::tight(Size::new(200.0, 10.0)));
488        assert_eq!(size, Size::new(200.0, 10.0));
489    }
490
491    #[test]
492    fn loose_constraints_clamp_a_natural_size_larger_than_the_bound() {
493        let view = Image(rgba(1000, 1000));
494        let mut w: ImageWidget = build(&view);
495        let mut lctx = LayoutCtx::new();
496        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(50.0, 50.0)));
497        assert_eq!(size, Size::new(50.0, 50.0));
498    }
499
500    // -- aspect ratio preservation in layout -----------------------------------
501
502    #[test]
503    fn aspect_ratio_square_large_in_narrow_unbounded() {
504        // The Pixel 5 case: 1024x1024 image in a 393-wide, unbounded-height Column
505        // -> should become 393x393
506        let view = Image(rgba(1024, 1024));
507        let mut w: ImageWidget = build(&view);
508        let mut lctx = LayoutCtx::new();
509        let size = w.layout(
510            &mut lctx,
511            &BoxConstraints::loose(Size::new(393.0, f64::INFINITY)),
512        );
513        assert_eq!(size, Size::new(393.0, 393.0));
514    }
515
516    #[test]
517    fn aspect_ratio_square_large_in_both_axes_bounded() {
518        // Both axes constrained; height is tighter; scale down to 200x200
519        let view = Image(rgba(1024, 1024));
520        let mut w: ImageWidget = build(&view);
521        let mut lctx = LayoutCtx::new();
522        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(393.0, 200.0)));
523        assert_eq!(size, Size::new(200.0, 200.0));
524    }
525
526    #[test]
527    fn aspect_ratio_landscape_constrained_by_width() {
528        // 2:1 landscape (800x400) in max(400, INF): width-limited, height scales down
529        // -> should become 400x200
530        let view = Image(rgba(800, 400));
531        let mut w: ImageWidget = build(&view);
532        let mut lctx = LayoutCtx::new();
533        let size = w.layout(
534            &mut lctx,
535            &BoxConstraints::loose(Size::new(400.0, f64::INFINITY)),
536        );
537        assert_eq!(size, Size::new(400.0, 200.0));
538    }
539
540    #[test]
541    fn aspect_ratio_small_fits_unchanged() {
542        // Small image fits within bounds; should stay 64x32
543        let view = Image(rgba(64, 32));
544        let mut w: ImageWidget = build(&view);
545        let mut lctx = LayoutCtx::new();
546        let size = w.layout(
547            &mut lctx,
548            &BoxConstraints::loose(Size::new(393.0, f64::INFINITY)),
549        );
550        assert_eq!(size, Size::new(64.0, 32.0));
551    }
552
553    #[test]
554    fn aspect_ratio_with_min_width_constraint() {
555        // Min width of 100 forces 10x10 to scale up to 100x100 to maintain aspect
556        let view = Image(rgba(10, 10));
557        let mut w: ImageWidget = build(&view);
558        let mut lctx = LayoutCtx::new();
559        let min = Size::new(100.0, 0.0);
560        let max = Size::new(200.0, f64::INFINITY);
561        let bc = BoxConstraints::new(min, max);
562        let size = w.layout(&mut lctx, &bc);
563        assert_eq!(size, Size::new(100.0, 100.0));
564    }
565
566    #[test]
567    fn aspect_ratio_degenerate_0x0_natural_uses_fallback_constrain() {
568        // Degenerate natural size should use bc.constrain fallback
569        let view = Image(ImageSource::from_rgba8(vec![], 0, 0));
570        let mut w: ImageWidget = build(&view);
571        let mut lctx = LayoutCtx::new();
572        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(50.0, 50.0)));
573        // bc.constrain on Size::ZERO should be Size::ZERO
574        assert_eq!(size, Size::ZERO);
575    }
576
577    // -- paint / cache identity --------------------------------------------
578
579    /// A minimal recording [`PaintScene`] capturing only what `Image::paint`
580    /// touches: clip push/pop and the drawn image's dest rect + dimensions.
581    #[derive(Default)]
582    struct RecordingScene {
583        clips: Vec<(Point, Size)>,
584        pops: u32,
585        images: Vec<(u32, u32, Rect)>,
586    }
587
588    impl PaintScene for RecordingScene {
589        fn fill_rect(&mut self, _origin: Point, _size: Size, _color: peniko::Color) {}
590        fn draw_text(&mut self, _origin: Point, _text: &str) {}
591        fn push_clip(&mut self, origin: Point, size: Size) {
592            self.clips.push((origin, size));
593        }
594        fn pop_clip(&mut self) {
595            self.pops += 1;
596        }
597        fn draw_image(&mut self, data: &ImageData, dest: Rect) {
598            self.images.push((data.width, data.height, dest));
599        }
600    }
601
602    fn paint_at(w: &mut ImageWidget, origin: Point, size: Size) -> RecordingScene {
603        let mut ctx = PaintCtx::new(origin, size);
604        let mut scene = RecordingScene::default();
605        w.paint(&mut ctx, &mut scene);
606        scene
607    }
608
609    #[test]
610    fn fill_paints_the_full_dest_rect_with_no_clip() {
611        let view = Image(rgba(10, 10)).fit(ImageFit::Fill);
612        let mut w: ImageWidget = build(&view);
613        let scene = paint_at(&mut w, Point::new(0.0, 0.0), Size::new(50.0, 20.0));
614        assert!(scene.clips.is_empty(), "Fill never clips");
615        assert_eq!(scene.pops, 0);
616        assert_eq!(scene.images.len(), 1);
617        let (w_px, h_px, dest) = scene.images[0];
618        assert_eq!((w_px, h_px), (10, 10));
619        assert_eq!(dest, Rect::new(0.0, 0.0, 50.0, 20.0));
620    }
621
622    #[test]
623    fn contain_paints_a_letterboxed_rect_with_no_clip() {
624        let view = Image(rgba(100, 200)).fit(ImageFit::Contain);
625        let mut w: ImageWidget = build(&view);
626        let scene = paint_at(&mut w, Point::new(5.0, 5.0), Size::new(200.0, 100.0));
627        assert!(scene.clips.is_empty(), "Contain never overflows dest");
628        let (_, _, dest) = scene.images[0];
629        // Same shape as the pure fit_rect test, offset by the paint origin
630        // (5, 5).
631        assert_eq!(dest.width(), 50.0);
632        assert_eq!(dest.x0, 5.0 + 75.0);
633        assert_eq!(dest.y0, 5.0);
634    }
635
636    #[test]
637    fn cover_clips_to_dest_around_an_overflowing_scaled_rect() {
638        let view = Image(rgba(100, 200)).fit(ImageFit::Cover);
639        let mut w: ImageWidget = build(&view);
640        let origin = Point::new(2.0, 3.0);
641        let dest_size = Size::new(200.0, 100.0);
642        let scene = paint_at(&mut w, origin, dest_size);
643        assert_eq!(
644            scene.clips,
645            vec![(origin, dest_size)],
646            "Cover clips to the widget's own box"
647        );
648        assert_eq!(scene.pops, 1, "the clip must be popped");
649        let (_, _, dest) = scene.images[0];
650        // The drawn rect overflows the widget's dest on the vertical axis
651        // (matches the pure cover fit-math test, offset by `origin`).
652        assert!(dest.height() > dest_size.height);
653    }
654
655    #[test]
656    fn rebuild_with_a_cloned_source_keeps_the_widgets_arc_identity() {
657        let source = rgba(8, 8);
658        let view: ImageView = Image(source.clone());
659        let mut w: ImageWidget = build(&view);
660        let original_source = w.source.clone();
661
662        // The next view re-passes a *clone* of the same source (the ordinary
663        // "build function re-runs every frame" shape) — never a fresh decode.
664        let next: ImageView = Image(source.clone());
665        let mut counter = 0u64;
666        <ImageView as View<()>>::rebuild(&next, &view, &mut w, &mut BuildCtx::new(&mut counter));
667
668        assert!(
669            w.source.same(&original_source),
670            "a cloned-source rebuild must keep the same decoded Arc, never re-decode"
671        );
672    }
673
674    #[test]
675    fn rebuild_with_a_different_source_replaces_the_widgets_source() {
676        let first = rgba(8, 8);
677        let second = rgba(16, 16);
678        let view: ImageView = Image(first.clone());
679        let mut w: ImageWidget = build(&view);
680
681        let next: ImageView = Image(second.clone());
682        let mut counter = 0u64;
683        let flags = <ImageView as View<()>>::rebuild(
684            &next,
685            &view,
686            &mut w,
687            &mut BuildCtx::new(&mut counter),
688        );
689
690        assert!(w.source.same(&second));
691        assert!(!w.source.same(&first));
692        assert!(flags.contains(ChangeFlags::LAYOUT));
693        assert!(flags.contains(ChangeFlags::PAINT));
694    }
695
696    #[test]
697    fn rebuild_with_a_different_fit_flags_paint_only() {
698        let source = rgba(8, 8);
699        let view: ImageView = Image(source.clone()).fit(ImageFit::Contain);
700        let mut w: ImageWidget = build(&view);
701
702        let next: ImageView = Image(source).fit(ImageFit::Cover);
703        let mut counter = 0u64;
704        let flags = <ImageView as View<()>>::rebuild(
705            &next,
706            &view,
707            &mut w,
708            &mut BuildCtx::new(&mut counter),
709        );
710
711        assert_eq!(w.fit, ImageFit::Cover);
712        assert!(flags.contains(ChangeFlags::PAINT));
713        assert!(!flags.contains(ChangeFlags::LAYOUT));
714    }
715}