1use 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#[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#[derive(Clone, Debug)]
49pub struct ImageSource {
50 data: Arc<ImageData>,
51}
52
53impl ImageSource {
54 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 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 pub fn same(&self, other: &ImageSource) -> bool {
89 Arc::ptr_eq(&self.data, &other.data)
90 }
91
92 pub fn natural_size(&self) -> Size {
94 Size::new(self.data.width as f64, self.data.height as f64)
95 }
96
97 pub fn image_data(&self) -> &ImageData {
105 &self.data
106 }
107}
108
109#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
112pub enum ImageFit {
113 Fill,
116 #[default]
120 Contain,
121 Cover,
124}
125
126fn 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
156fn 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
165pub struct ImageView {
170 source: ImageSource,
171 fit: ImageFit,
172}
173
174#[allow(non_snake_case)]
177pub fn Image(source: ImageSource) -> ImageView {
178 ImageView {
179 source,
180 fit: ImageFit::default(),
181 }
182}
183
184impl ImageView {
185 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 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
226pub struct ImageWidget {
228 source: ImageSource,
229 fit: ImageFit,
230}
231
232fn constrain_size_preserve_aspect_ratio(natural: Size, bc: &BoxConstraints) -> Size {
240 let mut width = natural.width;
241 let mut height = natural.height;
242
243 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 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 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 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 #[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); 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 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 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 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, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 0, 255, ];
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 #[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 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); assert_eq!(rect.y0, 0.0);
403 }
404
405 #[test]
406 fn contain_letterboxes_a_taller_dest_around_a_wider_natural_image() {
407 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); }
419
420 #[test]
421 fn contain_preserves_aspect_ratio_at_a_non_trivial_ratio() {
422 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 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); }
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 #[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 #[test]
503 fn aspect_ratio_square_large_in_narrow_unbounded() {
504 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 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 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 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 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 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 assert_eq!(size, Size::ZERO);
575 }
576
577 #[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 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 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 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}