frust-widgets 0.5.2

Baseline widget set for Frust: text, buttons, images, flex, stack and scroll containers, and the authoring helpers for custom widgets.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
//! The `Image` widget: decode-once PNG/JPEG bitmaps painted
//! through the scene's [`Command::Image`](frust_scene::Command::Image).
//!
//! [`ImageSource`] is the decode-once, cheaply-clonable handle app code holds
//! (and re-passes every frame): [`ImageSource::decode`] runs the `image` crate
//! exactly once, at construction, and never again — [`ImageView::rebuild`]
//! only ever compares [`ImageSource::same`] (an `Arc` pointer check), so a
//! rebuild that re-supplies the *same* decoded source (even a fresh `.clone()`
//! of it) costs nothing beyond a refcount bump.
//!
//! [`Image`] is the declarative view-fn; [`ImageFit`] controls how the
//! widget's laid-out box relates to the image's natural pixel size at paint
//! time (`Fill` stretches, `Contain` letterboxes, `Cover` center-crops via
//! [`PaintScene::push_clip`]).

use frust_core::{
    BoxConstraints, BuildCtx, ChangeFlags, LayoutCtx, PaintCtx, PaintScene, View, Widget,
};
use kurbo::{Rect, Size};
use peniko::{Blob, ImageAlphaType, ImageData, ImageFormat};
use std::sync::Arc;

/// Error decoding image bytes via [`ImageSource::decode`].
///
/// Wraps the `image` crate's own decode error; callers that need to match on
/// the underlying failure can reach it through [`std::error::Error::source`].
#[derive(Debug)]
pub struct ImageError(image::ImageError);

impl std::fmt::Display for ImageError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "failed to decode image: {}", self.0)
    }
}

impl std::error::Error for ImageError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        Some(&self.0)
    }
}

/// A decode-once, cheaply-clonable handle around a decoded RGBA8 image.
///
/// Identity is the inner `Arc` pointer: [`ImageSource::same`] compares by
/// pointer, not pixel content, in O(1) regardless of image size — the signal
/// [`ImageView`]'s `rebuild` relies on to know "same image, nothing to redo"
/// without ever touching the decoder again.
#[derive(Clone, Debug)]
pub struct ImageSource {
    data: Arc<ImageData>,
}

impl ImageSource {
    /// Decode PNG/JPEG bytes into straight-alpha RGBA8 once, wrapping the
    /// result in a shareable handle. Callers own the resulting `ImageSource`
    /// (typically stashing it in app state) and re-pass it into [`Image`]
    /// every frame — this never re-decodes.
    pub fn decode(bytes: &[u8]) -> Result<Self, ImageError> {
        let decoded = image::load_from_memory(bytes).map_err(ImageError)?;
        let rgba = decoded.to_rgba8();
        let (width, height) = rgba.dimensions();
        Ok(Self::from_rgba8(rgba.into_raw(), width, height))
    }

    /// Wrap already-decoded straight-alpha RGBA8 pixel data directly (e.g. a
    /// procedurally generated image, or a test fixture) without going through
    /// the `image` crate's decoder.
    ///
    /// `pixels.len()` must equal `width * height * 4`; a mismatched length is
    /// a caller bug and is not checked here (mirrors `peniko::ImageData`'s own
    /// contract — the renderer will simply read `width * height * 4` bytes).
    pub fn from_rgba8(pixels: Vec<u8>, width: u32, height: u32) -> Self {
        Self {
            data: Arc::new(ImageData {
                data: Blob::from(pixels),
                format: ImageFormat::Rgba8,
                alpha_type: ImageAlphaType::Alpha,
                width,
                height,
            }),
        }
    }

    /// Whether `self` and `other` share the same decoded data (`Arc` pointer
    /// identity, not content equality) — never true for two independently
    /// decoded sources even if their bytes matched, since each decode
    /// allocates a fresh `Arc`.
    pub fn same(&self, other: &ImageSource) -> bool {
        Arc::ptr_eq(&self.data, &other.data)
    }

    /// The natural (decoded pixel) size.
    pub fn natural_size(&self) -> Size {
        Size::new(self.data.width as f64, self.data.height as f64)
    }

    /// Borrow the decoded `peniko::ImageData` this source wraps, for painting.
    ///
    /// This returns a borrow of already-decoded pixels and triggers no decode.
    /// The source caches pixel data once at construction via [`Self::decode`];
    /// this accessor hands back a reference to that cache. Calling this
    /// repeatedly with the same [`ImageSource`] is zero-cost and does not
    /// re-trigger the image decoder.
    pub fn image_data(&self) -> &ImageData {
        &self.data
    }
}

/// How an [`Image`]'s laid-out box relates to its source's natural pixel size
/// at paint time.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum ImageFit {
    /// Stretch the image to exactly fill the widget's box, ignoring aspect
    /// ratio.
    Fill,
    /// Scale the image (preserving aspect ratio) to fit entirely within the
    /// widget's box; the non-fitting axis is letterboxed (centered, with
    /// empty space on either side).
    #[default]
    Contain,
    /// Scale the image (preserving aspect ratio) to cover the widget's box
    /// entirely; the overflowing axis is center-cropped via a clip.
    Cover,
}

/// Compute the rect (relative to the widget's own origin) the image, scaled
/// per `fit`, should be drawn into.
///
/// For [`ImageFit::Fill`] this exactly equals `(0, 0, dest.width,
/// dest.height)`. For [`ImageFit::Contain`] it is centered and no larger than
/// `dest` on either axis (possibly smaller on one — the letterbox). For
/// [`ImageFit::Cover`] it is centered and no smaller than `dest` on either
/// axis (possibly larger on one — the caller must clip to `dest` around it).
///
/// A degenerate (zero-area) `natural` or `dest` collapses to `dest` itself
/// (nothing sensible to scale).
fn fit_rect(natural: Size, dest: Size, fit: ImageFit) -> Rect {
    if natural.width <= 0.0 || natural.height <= 0.0 || dest.width <= 0.0 || dest.height <= 0.0 {
        return Rect::new(0.0, 0.0, dest.width, dest.height);
    }
    match fit {
        ImageFit::Fill => Rect::new(0.0, 0.0, dest.width, dest.height),
        ImageFit::Contain => centered_scaled_rect(
            natural,
            dest,
            (dest.width / natural.width).min(dest.height / natural.height),
        ),
        ImageFit::Cover => centered_scaled_rect(
            natural,
            dest,
            (dest.width / natural.width).max(dest.height / natural.height),
        ),
    }
}

/// Scale `natural` by `scale` and center the result within `dest`.
fn centered_scaled_rect(natural: Size, dest: Size, scale: f64) -> Rect {
    let w = natural.width * scale;
    let h = natural.height * scale;
    let x = (dest.width - w) / 2.0;
    let y = (dest.height - h) / 2.0;
    Rect::new(x, y, x + w, y + h)
}

/// A declarative description of an image.
///
/// Construct with [`Image`]; set the fit mode with [`ImageView::fit`]
/// (defaults to [`ImageFit::Contain`]).
pub struct ImageView {
    source: ImageSource,
    fit: ImageFit,
}

/// Create an image view over a decoded [`ImageSource`], defaulting to
/// [`ImageFit::Contain`].
#[allow(non_snake_case)]
pub fn Image(source: ImageSource) -> ImageView {
    ImageView {
        source,
        fit: ImageFit::default(),
    }
}

impl ImageView {
    /// Set how the image's natural size relates to its laid-out box at paint
    /// time.
    pub fn fit(mut self, fit: ImageFit) -> Self {
        self.fit = fit;
        self
    }
}

impl<State: 'static> View<State> for ImageView {
    type Element = ImageWidget;

    fn build(&self, _ctx: &mut BuildCtx<'_>) -> ImageWidget {
        ImageWidget {
            source: self.source.clone(),
            fit: self.fit,
        }
    }

    fn rebuild(
        &self,
        prev: &Self,
        element: &mut ImageWidget,
        _ctx: &mut BuildCtx<'_>,
    ) -> ChangeFlags {
        let mut flags = ChangeFlags::NONE;
        // `same()` is an Arc-pointer check, never a decode: swapping to a
        // genuinely different source is the only path that touches the
        // decoder, and only inside `ImageSource::decode` at construction —
        // never here.
        if !prev.source.same(&self.source) {
            element.source = self.source.clone();
            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
        }
        if prev.fit != self.fit {
            element.fit = self.fit;
            flags |= ChangeFlags::PAINT;
        }
        flags
    }
}

/// The retained widget for an [`ImageView`].
pub struct ImageWidget {
    source: ImageSource,
    fit: ImageFit,
}

/// Constrain a natural size to fit within the given constraints while preserving
/// aspect ratio. Follows Flutter's `BoxConstraints.constrainSizeAndAttemptToPreserveAspectRatio`.
///
/// For a degenerate (zero or non-finite) natural size, falls back to
/// `bc.constrain(natural)` (the existing behaviour for degenerate images).
/// For a valid aspect ratio, clamps width and height on each axis (min, then max)
/// while adjusting the other axis to preserve the aspect ratio.
fn constrain_size_preserve_aspect_ratio(natural: Size, bc: &BoxConstraints) -> Size {
    let mut width = natural.width;
    let mut height = natural.height;

    // Degenerate natural size: fall back to regular constrain
    if width <= 0.0 || height <= 0.0 || !width.is_finite() || !height.is_finite() {
        return bc.constrain(natural);
    }

    let aspect = width / height;
    let max = bc.max();
    let min = bc.min();

    // Clamp to max on each axis, preserving aspect ratio
    if width > max.width {
        width = max.width;
        height = width / aspect;
    }
    if height > max.height {
        height = max.height;
        width = height * aspect;
    }

    // Clamp to min on each axis, preserving aspect ratio
    if width < min.width {
        width = min.width;
        height = width / aspect;
    }
    if height < min.height {
        height = min.height;
        width = height * aspect;
    }

    bc.constrain(Size::new(width, height))
}

impl Widget for ImageWidget {
    fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
        // A tight constraint (e.g. this image sits inside a `SizedBox`) wins
        // outright. For a non-tight constraint, preserve the natural aspect ratio
        // by sizing the box according to the aspect-ratio-preserving algorithm,
        // then further constraining the result. `fit` only affects how that box's
        // content is scaled at paint time, never the box itself.
        if bc.is_tight() {
            return bc.max();
        }
        constrain_size_preserve_aspect_ratio(self.source.natural_size(), bc)
    }

    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
        let natural = self.source.natural_size();
        let dest_size = ctx.size();
        let local = fit_rect(natural, dest_size, self.fit);
        let origin = ctx.origin();
        let absolute = local + origin.to_vec2();

        let needs_clip = self.fit == ImageFit::Cover
            && (local.width() > dest_size.width || local.height() > dest_size.height);
        if needs_clip {
            scene.push_clip(origin, dest_size);
        }
        scene.draw_image(self.source.image_data(), absolute);
        if needs_clip {
            scene.pop_clip();
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use frust_core::BuildCtx;
    use kurbo::Point;

    fn rgba(width: u32, height: u32) -> ImageSource {
        ImageSource::from_rgba8(vec![0u8; (width * height * 4) as usize], width, height)
    }

    fn build(view: &ImageView) -> ImageWidget {
        let mut counter = 0u64;
        <ImageView as View<()>>::build(view, &mut BuildCtx::new(&mut counter))
    }

    // -- ImageSource ---------------------------------------------------

    #[test]
    fn from_rgba8_reports_natural_size() {
        let source = rgba(4, 8);
        assert_eq!(source.natural_size(), Size::new(4.0, 8.0));
    }

    #[test]
    fn same_is_true_only_for_a_shared_arc() {
        let a = rgba(2, 2);
        let b = a.clone();
        let c = rgba(2, 2); // independently constructed, same pixels, different Arc.
        assert!(a.same(&b), "a clone shares the Arc");
        assert!(!a.same(&c), "an independently built source never matches");
    }

    #[test]
    fn decode_error_reports_the_underlying_image_crate_failure() {
        let err = ImageSource::decode(b"not an image").unwrap_err();
        // The `Display` impl should at least name what went wrong, and
        // `source()` must recover the underlying `image::ImageError`.
        assert!(err.to_string().contains("failed to decode image"));
        assert!(std::error::Error::source(&err).is_some());
    }

    #[test]
    fn decode_a_two_by_two_png_round_trips_dimensions_and_alpha_type() {
        // A minimal, hand-encoded 2x2 RGBA PNG (generated once and embedded as
        // bytes — no filesystem/asset dependency for the test).
        let png = two_by_two_png();
        let source = ImageSource::decode(&png).expect("valid PNG decodes");
        assert_eq!(source.natural_size(), Size::new(2.0, 2.0));
        assert_eq!(source.image_data().alpha_type, ImageAlphaType::Alpha);
        assert_eq!(source.image_data().format, ImageFormat::Rgba8);
        assert_eq!(source.image_data().data.len(), 2 * 2 * 4);
    }

    /// Encode a tiny 2x2 RGBA PNG in-process via the `image` crate itself, so
    /// the decode-path test above has no external fixture file to keep in
    /// sync.
    fn two_by_two_png() -> Vec<u8> {
        use image::{ImageEncoder, codecs::png::PngEncoder};
        let pixels: [u8; 2 * 2 * 4] = [
            255, 0, 0, 255, // red
            0, 255, 0, 255, // green
            0, 0, 255, 255, // blue
            255, 255, 0, 255, // yellow
        ];
        let mut out = Vec::new();
        PngEncoder::new(&mut out)
            .write_image(&pixels, 2, 2, image::ExtendedColorType::Rgba8)
            .expect("encoding a tiny in-memory PNG must not fail");
        out
    }

    // -- fit math --------------------------------------------------------

    #[test]
    fn fill_always_matches_dest_regardless_of_aspect_ratio() {
        let rect = fit_rect(
            Size::new(10.0, 20.0),
            Size::new(100.0, 50.0),
            ImageFit::Fill,
        );
        assert_eq!(rect, Rect::new(0.0, 0.0, 100.0, 50.0));
    }

    #[test]
    fn contain_letterboxes_a_wider_dest_around_a_taller_natural_image() {
        // Natural 1:2 (portrait) into a 200x100 (landscape) dest: height-bound
        // scale (100/2 = 50), width shrinks to 50 and is centered.
        let rect = fit_rect(
            Size::new(100.0, 200.0),
            Size::new(200.0, 100.0),
            ImageFit::Contain,
        );
        assert_eq!(rect.width(), 50.0);
        assert_eq!(rect.height(), 100.0);
        assert_eq!(rect.x0, 75.0); // (200 - 50) / 2
        assert_eq!(rect.y0, 0.0);
    }

    #[test]
    fn contain_letterboxes_a_taller_dest_around_a_wider_natural_image() {
        // Natural 2:1 (landscape) into a 100x200 (portrait) dest: width-bound
        // scale (100/2 = 50), height shrinks to 50 and is centered.
        let rect = fit_rect(
            Size::new(200.0, 100.0),
            Size::new(100.0, 200.0),
            ImageFit::Contain,
        );
        assert_eq!(rect.width(), 100.0);
        assert_eq!(rect.height(), 50.0);
        assert_eq!(rect.x0, 0.0);
        assert_eq!(rect.y0, 75.0); // (200 - 50) / 2
    }

    #[test]
    fn contain_preserves_aspect_ratio_at_a_non_trivial_ratio() {
        // A 3:4 natural image into a 300x300 square dest: width-bound scale
        // (300/3 = 100) since 3:4 is portrait-ish; check aspect is preserved.
        let rect = fit_rect(
            Size::new(300.0, 400.0),
            Size::new(300.0, 300.0),
            ImageFit::Contain,
        );
        let natural_aspect = 300.0 / 400.0;
        let rect_aspect = rect.width() / rect.height();
        assert!(
            (natural_aspect - rect_aspect).abs() < 1e-9,
            "contain must preserve the natural aspect ratio"
        );
        assert!(rect.width() <= 300.0 && rect.height() <= 300.0);
    }

    #[test]
    fn cover_crops_a_wider_dest_around_a_taller_natural_image() {
        // Natural 1:2 (portrait) into a 200x100 (landscape) dest: width-bound
        // scale (200/1 = 200) so the image overflows vertically and is
        // center-cropped.
        let rect = fit_rect(
            Size::new(100.0, 200.0),
            Size::new(200.0, 100.0),
            ImageFit::Cover,
        );
        assert_eq!(rect.width(), 200.0);
        assert_eq!(rect.height(), 400.0);
        assert_eq!(rect.x0, 0.0);
        assert_eq!(rect.y0, -150.0); // (100 - 400) / 2
    }

    #[test]
    fn cover_at_least_covers_dest_on_both_axes() {
        let dest = Size::new(150.0, 90.0);
        let rect = fit_rect(Size::new(37.0, 51.0), dest, ImageFit::Cover);
        assert!(rect.width() >= dest.width - 1e-9);
        assert!(rect.height() >= dest.height - 1e-9);
    }

    #[test]
    fn fit_rect_collapses_to_dest_for_degenerate_natural_size() {
        let dest = Size::new(40.0, 20.0);
        for fit in [ImageFit::Fill, ImageFit::Contain, ImageFit::Cover] {
            let rect = fit_rect(Size::ZERO, dest, fit);
            assert_eq!(rect, Rect::new(0.0, 0.0, dest.width, dest.height));
        }
    }

    // -- layout ------------------------------------------------------------

    #[test]
    fn loose_constraints_choose_the_natural_size() {
        let view = Image(rgba(40, 30));
        let mut w: ImageWidget = build(&view);
        let mut lctx = LayoutCtx::new();
        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
        assert_eq!(size, Size::new(40.0, 30.0));
    }

    #[test]
    fn tight_constraints_force_the_exact_box_regardless_of_natural_size() {
        let view = Image(rgba(40, 30));
        let mut w: ImageWidget = build(&view);
        let mut lctx = LayoutCtx::new();
        let size = w.layout(&mut lctx, &BoxConstraints::tight(Size::new(200.0, 10.0)));
        assert_eq!(size, Size::new(200.0, 10.0));
    }

    #[test]
    fn loose_constraints_clamp_a_natural_size_larger_than_the_bound() {
        let view = Image(rgba(1000, 1000));
        let mut w: ImageWidget = build(&view);
        let mut lctx = LayoutCtx::new();
        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(50.0, 50.0)));
        assert_eq!(size, Size::new(50.0, 50.0));
    }

    // -- aspect ratio preservation in layout -----------------------------------

    #[test]
    fn aspect_ratio_square_large_in_narrow_unbounded() {
        // The Pixel 5 case: 1024x1024 image in a 393-wide, unbounded-height Column
        // -> should become 393x393
        let view = Image(rgba(1024, 1024));
        let mut w: ImageWidget = build(&view);
        let mut lctx = LayoutCtx::new();
        let size = w.layout(
            &mut lctx,
            &BoxConstraints::loose(Size::new(393.0, f64::INFINITY)),
        );
        assert_eq!(size, Size::new(393.0, 393.0));
    }

    #[test]
    fn aspect_ratio_square_large_in_both_axes_bounded() {
        // Both axes constrained; height is tighter; scale down to 200x200
        let view = Image(rgba(1024, 1024));
        let mut w: ImageWidget = build(&view);
        let mut lctx = LayoutCtx::new();
        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(393.0, 200.0)));
        assert_eq!(size, Size::new(200.0, 200.0));
    }

    #[test]
    fn aspect_ratio_landscape_constrained_by_width() {
        // 2:1 landscape (800x400) in max(400, INF): width-limited, height scales down
        // -> should become 400x200
        let view = Image(rgba(800, 400));
        let mut w: ImageWidget = build(&view);
        let mut lctx = LayoutCtx::new();
        let size = w.layout(
            &mut lctx,
            &BoxConstraints::loose(Size::new(400.0, f64::INFINITY)),
        );
        assert_eq!(size, Size::new(400.0, 200.0));
    }

    #[test]
    fn aspect_ratio_small_fits_unchanged() {
        // Small image fits within bounds; should stay 64x32
        let view = Image(rgba(64, 32));
        let mut w: ImageWidget = build(&view);
        let mut lctx = LayoutCtx::new();
        let size = w.layout(
            &mut lctx,
            &BoxConstraints::loose(Size::new(393.0, f64::INFINITY)),
        );
        assert_eq!(size, Size::new(64.0, 32.0));
    }

    #[test]
    fn aspect_ratio_with_min_width_constraint() {
        // Min width of 100 forces 10x10 to scale up to 100x100 to maintain aspect
        let view = Image(rgba(10, 10));
        let mut w: ImageWidget = build(&view);
        let mut lctx = LayoutCtx::new();
        let min = Size::new(100.0, 0.0);
        let max = Size::new(200.0, f64::INFINITY);
        let bc = BoxConstraints::new(min, max);
        let size = w.layout(&mut lctx, &bc);
        assert_eq!(size, Size::new(100.0, 100.0));
    }

    #[test]
    fn aspect_ratio_degenerate_0x0_natural_uses_fallback_constrain() {
        // Degenerate natural size should use bc.constrain fallback
        let view = Image(ImageSource::from_rgba8(vec![], 0, 0));
        let mut w: ImageWidget = build(&view);
        let mut lctx = LayoutCtx::new();
        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(50.0, 50.0)));
        // bc.constrain on Size::ZERO should be Size::ZERO
        assert_eq!(size, Size::ZERO);
    }

    // -- paint / cache identity --------------------------------------------

    /// A minimal recording [`PaintScene`] capturing only what `Image::paint`
    /// touches: clip push/pop and the drawn image's dest rect + dimensions.
    #[derive(Default)]
    struct RecordingScene {
        clips: Vec<(Point, Size)>,
        pops: u32,
        images: Vec<(u32, u32, Rect)>,
    }

    impl PaintScene for RecordingScene {
        fn fill_rect(&mut self, _origin: Point, _size: Size, _color: peniko::Color) {}
        fn draw_text(&mut self, _origin: Point, _text: &str) {}
        fn push_clip(&mut self, origin: Point, size: Size) {
            self.clips.push((origin, size));
        }
        fn pop_clip(&mut self) {
            self.pops += 1;
        }
        fn draw_image(&mut self, data: &ImageData, dest: Rect) {
            self.images.push((data.width, data.height, dest));
        }
    }

    fn paint_at(w: &mut ImageWidget, origin: Point, size: Size) -> RecordingScene {
        let mut ctx = PaintCtx::new(origin, size);
        let mut scene = RecordingScene::default();
        w.paint(&mut ctx, &mut scene);
        scene
    }

    #[test]
    fn fill_paints_the_full_dest_rect_with_no_clip() {
        let view = Image(rgba(10, 10)).fit(ImageFit::Fill);
        let mut w: ImageWidget = build(&view);
        let scene = paint_at(&mut w, Point::new(0.0, 0.0), Size::new(50.0, 20.0));
        assert!(scene.clips.is_empty(), "Fill never clips");
        assert_eq!(scene.pops, 0);
        assert_eq!(scene.images.len(), 1);
        let (w_px, h_px, dest) = scene.images[0];
        assert_eq!((w_px, h_px), (10, 10));
        assert_eq!(dest, Rect::new(0.0, 0.0, 50.0, 20.0));
    }

    #[test]
    fn contain_paints_a_letterboxed_rect_with_no_clip() {
        let view = Image(rgba(100, 200)).fit(ImageFit::Contain);
        let mut w: ImageWidget = build(&view);
        let scene = paint_at(&mut w, Point::new(5.0, 5.0), Size::new(200.0, 100.0));
        assert!(scene.clips.is_empty(), "Contain never overflows dest");
        let (_, _, dest) = scene.images[0];
        // Same shape as the pure fit_rect test, offset by the paint origin
        // (5, 5).
        assert_eq!(dest.width(), 50.0);
        assert_eq!(dest.x0, 5.0 + 75.0);
        assert_eq!(dest.y0, 5.0);
    }

    #[test]
    fn cover_clips_to_dest_around_an_overflowing_scaled_rect() {
        let view = Image(rgba(100, 200)).fit(ImageFit::Cover);
        let mut w: ImageWidget = build(&view);
        let origin = Point::new(2.0, 3.0);
        let dest_size = Size::new(200.0, 100.0);
        let scene = paint_at(&mut w, origin, dest_size);
        assert_eq!(
            scene.clips,
            vec![(origin, dest_size)],
            "Cover clips to the widget's own box"
        );
        assert_eq!(scene.pops, 1, "the clip must be popped");
        let (_, _, dest) = scene.images[0];
        // The drawn rect overflows the widget's dest on the vertical axis
        // (matches the pure cover fit-math test, offset by `origin`).
        assert!(dest.height() > dest_size.height);
    }

    #[test]
    fn rebuild_with_a_cloned_source_keeps_the_widgets_arc_identity() {
        let source = rgba(8, 8);
        let view: ImageView = Image(source.clone());
        let mut w: ImageWidget = build(&view);
        let original_source = w.source.clone();

        // The next view re-passes a *clone* of the same source (the ordinary
        // "build function re-runs every frame" shape) — never a fresh decode.
        let next: ImageView = Image(source.clone());
        let mut counter = 0u64;
        <ImageView as View<()>>::rebuild(&next, &view, &mut w, &mut BuildCtx::new(&mut counter));

        assert!(
            w.source.same(&original_source),
            "a cloned-source rebuild must keep the same decoded Arc, never re-decode"
        );
    }

    #[test]
    fn rebuild_with_a_different_source_replaces_the_widgets_source() {
        let first = rgba(8, 8);
        let second = rgba(16, 16);
        let view: ImageView = Image(first.clone());
        let mut w: ImageWidget = build(&view);

        let next: ImageView = Image(second.clone());
        let mut counter = 0u64;
        let flags = <ImageView as View<()>>::rebuild(
            &next,
            &view,
            &mut w,
            &mut BuildCtx::new(&mut counter),
        );

        assert!(w.source.same(&second));
        assert!(!w.source.same(&first));
        assert!(flags.contains(ChangeFlags::LAYOUT));
        assert!(flags.contains(ChangeFlags::PAINT));
    }

    #[test]
    fn rebuild_with_a_different_fit_flags_paint_only() {
        let source = rgba(8, 8);
        let view: ImageView = Image(source.clone()).fit(ImageFit::Contain);
        let mut w: ImageWidget = build(&view);

        let next: ImageView = Image(source).fit(ImageFit::Cover);
        let mut counter = 0u64;
        let flags = <ImageView as View<()>>::rebuild(
            &next,
            &view,
            &mut w,
            &mut BuildCtx::new(&mut counter),
        );

        assert_eq!(w.fit, ImageFit::Cover);
        assert!(flags.contains(ChangeFlags::PAINT));
        assert!(!flags.contains(ChangeFlags::LAYOUT));
    }
}