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;
#[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)
}
}
#[derive(Clone, Debug)]
pub struct ImageSource {
data: Arc<ImageData>,
}
impl ImageSource {
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))
}
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,
}),
}
}
pub fn same(&self, other: &ImageSource) -> bool {
Arc::ptr_eq(&self.data, &other.data)
}
pub fn natural_size(&self) -> Size {
Size::new(self.data.width as f64, self.data.height as f64)
}
pub fn image_data(&self) -> &ImageData {
&self.data
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum ImageFit {
Fill,
#[default]
Contain,
Cover,
}
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),
),
}
}
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)
}
pub struct ImageView {
source: ImageSource,
fit: ImageFit,
}
#[allow(non_snake_case)]
pub fn Image(source: ImageSource) -> ImageView {
ImageView {
source,
fit: ImageFit::default(),
}
}
impl ImageView {
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;
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
}
}
pub struct ImageWidget {
source: ImageSource,
fit: ImageFit,
}
fn constrain_size_preserve_aspect_ratio(natural: Size, bc: &BoxConstraints) -> Size {
let mut width = natural.width;
let mut height = natural.height;
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();
if width > max.width {
width = max.width;
height = width / aspect;
}
if height > max.height {
height = max.height;
width = height * aspect;
}
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 {
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))
}
#[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); 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();
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() {
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);
}
fn two_by_two_png() -> Vec<u8> {
use image::{ImageEncoder, codecs::png::PngEncoder};
let pixels: [u8; 2 * 2 * 4] = [
255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 0, 255, ];
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
}
#[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() {
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); assert_eq!(rect.y0, 0.0);
}
#[test]
fn contain_letterboxes_a_taller_dest_around_a_wider_natural_image() {
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); }
#[test]
fn contain_preserves_aspect_ratio_at_a_non_trivial_ratio() {
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() {
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); }
#[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));
}
}
#[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));
}
#[test]
fn aspect_ratio_square_large_in_narrow_unbounded() {
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() {
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() {
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() {
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() {
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() {
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)));
assert_eq!(size, Size::ZERO);
}
#[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];
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];
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();
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));
}
}