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
232impl Widget for ImageWidget {
233 fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
234 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 #[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); 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 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 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 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, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 0, 255, ];
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 #[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 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); assert_eq!(rect.y0, 0.0);
360 }
361
362 #[test]
363 fn contain_letterboxes_a_taller_dest_around_a_wider_natural_image() {
364 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); }
376
377 #[test]
378 fn contain_preserves_aspect_ratio_at_a_non_trivial_ratio() {
379 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 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); }
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 #[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 #[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 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 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 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}