use super::{CornerRadius, Element, Point}; use crate::{ assets::asset_cache::{AssetCache, AssetSource, AssetState}, event::DispatchedEvent, image_cache::{AnimatedImage, AnimatedImageBehavior, FitType, ImageCache, StaticImage}, AfterLayoutContext, AppContext, EventContext, LayoutContext, PaintContext, SingletonEntity, SizeConstraint, }; pub use crate::image_cache::CacheOption; use instant::Instant; use pathfinder_geometry::rect::RectF; use pathfinder_geometry::vector::{vec2f, Vector2F, Vector2I}; use std::sync::Arc; use std::time::Duration; pub struct Image { source: AssetSource, opacity: f32, size: Option, origin: Option, fit_type: FitType, animated_image_behavior: AnimatedImageBehavior, cache_option: CacheOption, started_at: Option, corner_radius: CornerRadius, top_aligned: bool, right_aligned: bool, /// The "back up" element to render when an asset is not ready or encountered an error. /// This could be None in two situations: (1) the caller does not provide a before_load_element /// or (2) the caller provided one but it's no longer needed due to the image having loaded. before_load_element: Option>, /// To avoid duplicating delayed repaint, we store whether or not we've requested a /// repaint on behalf of this element. /// /// Note: we use this for asset loading but not for animation repaint. An animated image /// may request several repaints in its lifetime. requested_repaint_after_load: bool, #[cfg(debug_assertions)] /// Captures the location of the constructor call site. This is used for debugging purposes. constructor_location: Option<&'static std::panic::Location<'static>>, } impl Image { /// Creates an image element with an explicit [`CacheOption`]. /// /// Use [`CacheOption::BySize`] for images rendered at a fixed size (icons, thumbnails); /// a CPU-resized copy is cached per size. /// Use [`CacheOption::Original`] for images whose display size changes continuously /// (e.g. background images); only the original asset is cached and the GPU scales it. #[cfg_attr(debug_assertions, track_caller)] pub fn new(source: AssetSource, cache_option: CacheOption) -> Self { Self { source, opacity: 1., size: None, origin: None, fit_type: FitType::Contain, animated_image_behavior: AnimatedImageBehavior::default(), cache_option, started_at: None, corner_radius: CornerRadius::default(), top_aligned: false, right_aligned: false, before_load_element: None, requested_repaint_after_load: false, #[cfg(debug_assertions)] constructor_location: Some(std::panic::Location::caller()), } } pub fn with_corner_radius(mut self, radius: CornerRadius) -> Self { self.corner_radius = radius; self } pub fn with_opacity(mut self, opacity: f32) -> Self { self.opacity = opacity; self } pub fn cover(mut self) -> Self { self.fit_type = FitType::Cover; self } pub fn contain(mut self) -> Self { self.fit_type = FitType::Contain; self } /// Stretches the image to fill the element bounds without preserving the aspect ratio. pub fn stretch(mut self) -> Self { self.fit_type = FitType::Stretch; self } /// Renders animated image sources as a static preview of their first frame. pub fn first_frame_preview(mut self) -> Self { self.animated_image_behavior = AnimatedImageBehavior::FirstFramePreview; self } /// Aligns the image to the top of the element bounds instead of centering vertically. /// Useful for cover-fit images where the bottom should be clipped rather than /// cropping equally from top and bottom. pub fn top_aligned(mut self) -> Self { self.top_aligned = true; self } /// Aligns the image to the right of the element bounds and pins it to the top. /// Useful for contain-fit images where the image is narrower than the element /// and the empty space should appear on the left rather than being split on both sides. pub fn right_aligned(mut self) -> Self { self.right_aligned = true; self } /// Enables animated images for the current image element. The start time indicates /// the timestamp at which the animated image started rendering. The element uses /// this timestamp to calculate which frame of the animation to display at a given /// moment. /// Animations are still fairly experimental, so you should do extensive testing to /// make sure there's no performance degradation from using an animation. pub fn enable_animation_with_start_time(mut self, started_at: Instant) -> Self { self.started_at = Some(started_at); self } pub fn before_load(mut self, element: Box) -> Self { self.before_load_element = Some(element); self } fn paint_static_image( &mut self, image: Arc, size: Vector2F, origin: Vector2F, bounds: Vector2I, ctx: &mut PaintContext, ) { let desired_image_size = match self.cache_option { CacheOption::Original => { dimensions(image.size().to_f32(), bounds.to_f32(), self.fit_type) } _ => image.size().to_f32(), }; let logical_image_size = desired_image_size / ctx.scene.scale_factor(); let Some(rect) = image_rect( size, origin, logical_image_size, self.top_aligned, self.right_aligned, ) else { self.origin = None; log::error!( "invalid image rect before draw_image source={:?} element_size=({}, {}) image_size=({}, {}) desired_image_size=({}, {}) logical_image_size=({}, {}) origin=({}, {}) bounds=({}, {}) fit_type={:?} cache_option={:?}", self.source, size.x(), size.y(), image.width(), image.height(), desired_image_size.x(), desired_image_size.y(), logical_image_size.x(), logical_image_size.y(), origin.x(), origin.y(), bounds.x(), bounds.y(), self.fit_type, self.cache_option, ); return; }; self.origin = Some(Point::from_vec2f(rect.origin(), ctx.scene.z_index())); #[cfg(debug_assertions)] ctx.scene .set_location_for_panic_logging(self.constructor_location); ctx.scene .draw_image(rect, image, self.opacity, self.corner_radius); } fn paint_animated_image( &mut self, animated_image: Arc, size: Vector2F, origin: Vector2F, bounds: Vector2I, ctx: &mut PaintContext, ) { // If self.started_at is not provided, we set it to current time // so only the first frame is shown. let started_at = self.started_at.unwrap_or_else(Instant::now); let elapsed_time = started_at.elapsed().as_millis(); // After about ~50 days, casting `elapsed_time` to a u32 will // silently overflow. The gif may jump and start playing from a // different frame. match animated_image.get_current_frame(elapsed_time as u32) { Ok((frame, remaining_delay)) => { self.paint_static_image(frame.clone(), size, origin, bounds, ctx); // Only repaint if self.started_at is set. Otherwise // enable_animation_with_start_time has not been called // and we shouldn't animate. if self.started_at.is_some() { ctx.repaint_after(Duration::from_millis(remaining_delay as u64)); } } Err(e) => { log::error!("Unable to retrieve current frame from image: {e:?}"); } } } } fn image_rect( size: Vector2F, origin: Vector2F, logical_image_size: Vector2F, top_aligned: bool, right_aligned: bool, ) -> Option { let offset = if right_aligned { vec2f(size.x() - logical_image_size.x(), 0.0) } else if top_aligned { vec2f((size.x() - logical_image_size.x()) / 2.0, 0.0) } else { (size - logical_image_size) / 2.0 }; let origin = origin + offset; let rect = RectF::new(origin, logical_image_size); if rect.origin().x().is_finite() && rect.origin().y().is_finite() && rect.size().x().is_finite() && rect.size().y().is_finite() { Some(rect) } else { None } } /// Returns desired dimensions of the image given the original size (x, y), desired container size /// (dest_x, dest_y) and fit_type. /// Returns a vector with new dimensions maintaining the original aspect ratio, /// unless the FitType is `Stretch`. fn dimensions(original: Vector2F, dest: Vector2F, fit_type: FitType) -> Vector2F { let ratio_x = dest.x() / original.x(); let ratio_y = dest.y() / original.y(); let ratio = match fit_type { FitType::Contain => ratio_x.min(ratio_y), FitType::Cover => ratio_x.max(ratio_y), FitType::Stretch => { // Stretch doesn't maintain aspect ratio return dest; } }; let x = original.x() * ratio; let y = original.y() * ratio; vec2f(x.max(1.), y.max(1.)).round() } impl Element for Image { fn layout( &mut self, constraint: SizeConstraint, ctx: &mut LayoutContext, app: &AppContext, ) -> Vector2F { let size = constraint.max; self.size = Some(size); if let Some(before_load_element) = self.before_load_element.as_mut() { before_load_element.layout(constraint, ctx, app); } size } fn after_layout(&mut self, ctx: &mut AfterLayoutContext, app: &AppContext) { if let Some(before_load_element) = self.before_load_element.as_mut() { before_load_element.after_layout(ctx, app); } } fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) { let Some(size) = self.size else { return; }; let bounds = (size * ctx.scene.scale_factor()).to_i32(); if !size.x().is_finite() || !size.y().is_finite() || size.x() <= 0.0 || size.y() <= 0.0 || bounds.x() <= 0 || bounds.y() <= 0 { log::warn!( "image paint with suspicious size source={:?} element_size=({}, {}) bounds=({}, {}) fit_type={:?} cache_option={:?}", self.source, size.x(), size.y(), bounds.x(), bounds.y(), self.fit_type, self.cache_option, ); } let assert_cache = AssetCache::as_ref(app); let image = ImageCache::as_ref(app).image( self.source.clone(), bounds, self.fit_type, self.animated_image_behavior, self.cache_option, ctx.max_texture_dimension_2d, assert_cache, ); match image { AssetState::Loading { handle } => { if !self.requested_repaint_after_load { ctx.repaint_after_load(handle); self.requested_repaint_after_load = true; } if let Some(before_load_element) = self.before_load_element.as_mut() { before_load_element.paint(origin, ctx, app); } } AssetState::Evicted => { if let Some(before_load_element) = self.before_load_element.as_mut() { before_load_element.paint(origin, ctx, app); } } AssetState::FailedToLoad(_) => { if let Some(before_load_element) = self.before_load_element.as_mut() { before_load_element.paint(origin, ctx, app); } } AssetState::Loaded { data } => { // Don't waste time calling layout() and after_layout() on the backup element once the main // one has loaded. self.before_load_element = None; match data.as_ref() { crate::image_cache::Image::Static(static_image) => { self.paint_static_image(static_image.clone(), size, origin, bounds, ctx) } crate::image_cache::Image::Animated(animated_image) => { self.paint_animated_image(animated_image.clone(), size, origin, bounds, ctx) } } } } } fn size(&self) -> Option { self.size } fn dispatch_event( &mut self, _: &DispatchedEvent, _: &mut EventContext, _: &AppContext, ) -> bool { false } fn origin(&self) -> Option { self.origin } } #[cfg(test)] #[path = "image_tests.rs"] mod tests;