Seregon/StratoSDK

StratoSDK is a framework with a declarative approach similar to Flutter/React, written and designed entirely for Rust.

Rust/27.3 KB/No license
crates/strato-ui-core/src/elements/image.rs
1use super::{CornerRadius, Element, Point};
2use crate::{
3 assets::asset_cache::{AssetCache, AssetSource, AssetState},
4 event::DispatchedEvent,
5 image_cache::{AnimatedImage, AnimatedImageBehavior, FitType, ImageCache, StaticImage},
6 AfterLayoutContext, AppContext, EventContext, LayoutContext, PaintContext, SingletonEntity,
7 SizeConstraint,
8};
9 
10pub use crate::image_cache::CacheOption;
11use instant::Instant;
12use pathfinder_geometry::rect::RectF;
13use pathfinder_geometry::vector::{vec2f, Vector2F, Vector2I};
14use std::sync::Arc;
15use std::time::Duration;
16 
17pub struct Image {
18 source: AssetSource,
19 opacity: f32,
20 size: Option<Vector2F>,
21 origin: Option<Point>,
22 fit_type: FitType,
23 animated_image_behavior: AnimatedImageBehavior,
24 cache_option: CacheOption,
25 started_at: Option<Instant>,
26 corner_radius: CornerRadius,
27 top_aligned: bool,
28 right_aligned: bool,
29 
30 /// The "back up" element to render when an asset is not ready or encountered an error.
31 /// This could be None in two situations: (1) the caller does not provide a before_load_element
32 /// or (2) the caller provided one but it's no longer needed due to the image having loaded.
33 before_load_element: Option<Box<dyn Element>>,
34 
35 /// To avoid duplicating delayed repaint, we store whether or not we've requested a
36 /// repaint on behalf of this element.
37 ///
38 /// Note: we use this for asset loading but not for animation repaint. An animated image
39 /// may request several repaints in its lifetime.
40 requested_repaint_after_load: bool,
41 #[cfg(debug_assertions)]
42 /// Captures the location of the constructor call site. This is used for debugging purposes.
43 constructor_location: Option<&'static std::panic::Location<'static>>,
44}
45 
46impl Image {
47 /// Creates an image element with an explicit [`CacheOption`].
48 ///
49 /// Use [`CacheOption::BySize`] for images rendered at a fixed size (icons, thumbnails);
50 /// a CPU-resized copy is cached per size.
51 /// Use [`CacheOption::Original`] for images whose display size changes continuously
52 /// (e.g. background images); only the original asset is cached and the GPU scales it.
53 #[cfg_attr(debug_assertions, track_caller)]
54 pub fn new(source: AssetSource, cache_option: CacheOption) -> Self {
55 Self {
56 source,
57 opacity: 1.,
58 size: None,
59 origin: None,
60 fit_type: FitType::Contain,
61 animated_image_behavior: AnimatedImageBehavior::default(),
62 cache_option,
63 started_at: None,
64 corner_radius: CornerRadius::default(),
65 top_aligned: false,
66 right_aligned: false,
67 before_load_element: None,
68 requested_repaint_after_load: false,
69 #[cfg(debug_assertions)]
70 constructor_location: Some(std::panic::Location::caller()),
71 }
72 }
73 
74 pub fn with_corner_radius(mut self, radius: CornerRadius) -> Self {
75 self.corner_radius = radius;
76 self
77 }
78 
79 pub fn with_opacity(mut self, opacity: f32) -> Self {
80 self.opacity = opacity;
81 self
82 }
83 
84 pub fn cover(mut self) -> Self {
85 self.fit_type = FitType::Cover;
86 self
87 }
88 
89 pub fn contain(mut self) -> Self {
90 self.fit_type = FitType::Contain;
91 self
92 }
93 
94 /// Stretches the image to fill the element bounds without preserving the aspect ratio.
95 pub fn stretch(mut self) -> Self {
96 self.fit_type = FitType::Stretch;
97 self
98 }
99 
100 /// Renders animated image sources as a static preview of their first frame.
101 pub fn first_frame_preview(mut self) -> Self {
102 self.animated_image_behavior = AnimatedImageBehavior::FirstFramePreview;
103 self
104 }
105 
106 /// Aligns the image to the top of the element bounds instead of centering vertically.
107 /// Useful for cover-fit images where the bottom should be clipped rather than
108 /// cropping equally from top and bottom.
109 pub fn top_aligned(mut self) -> Self {
110 self.top_aligned = true;
111 self
112 }
113 
114 /// Aligns the image to the right of the element bounds and pins it to the top.
115 /// Useful for contain-fit images where the image is narrower than the element
116 /// and the empty space should appear on the left rather than being split on both sides.
117 pub fn right_aligned(mut self) -> Self {
118 self.right_aligned = true;
119 self
120 }
121 
122 /// Enables animated images for the current image element. The start time indicates
123 /// the timestamp at which the animated image started rendering. The element uses
124 /// this timestamp to calculate which frame of the animation to display at a given
125 /// moment.
126 /// Animations are still fairly experimental, so you should do extensive testing to
127 /// make sure there's no performance degradation from using an animation.
128 pub fn enable_animation_with_start_time(mut self, started_at: Instant) -> Self {
129 self.started_at = Some(started_at);
130 self
131 }
132 
133 pub fn before_load(mut self, element: Box<dyn Element>) -> Self {
134 self.before_load_element = Some(element);
135 self
136 }
137 
138 fn paint_static_image(
139 &mut self,
140 image: Arc<StaticImage>,
141 size: Vector2F,
142 origin: Vector2F,
143 bounds: Vector2I,
144 ctx: &mut PaintContext,
145 ) {
146 let desired_image_size = match self.cache_option {
147 CacheOption::Original => {
148 dimensions(image.size().to_f32(), bounds.to_f32(), self.fit_type)
149 }
150 _ => image.size().to_f32(),
151 };
152 let logical_image_size = desired_image_size / ctx.scene.scale_factor();
153 let Some(rect) = image_rect(
154 size,
155 origin,
156 logical_image_size,
157 self.top_aligned,
158 self.right_aligned,
159 ) else {
160 self.origin = None;
161 log::error!(
162 "invalid image rect before draw_image source={:?} element_size=({}, {}) image_size=({}, {}) desired_image_size=({}, {}) logical_image_size=({}, {}) origin=({}, {}) bounds=({}, {}) fit_type={:?} cache_option={:?}",
163 self.source,
164 size.x(),
165 size.y(),
166 image.width(),
167 image.height(),
168 desired_image_size.x(),
169 desired_image_size.y(),
170 logical_image_size.x(),
171 logical_image_size.y(),
172 origin.x(),
173 origin.y(),
174 bounds.x(),
175 bounds.y(),
176 self.fit_type,
177 self.cache_option,
178 );
179 return;
180 };
181 
182 self.origin = Some(Point::from_vec2f(rect.origin(), ctx.scene.z_index()));
183 
184 #[cfg(debug_assertions)]
185 ctx.scene
186 .set_location_for_panic_logging(self.constructor_location);
187 
188 ctx.scene
189 .draw_image(rect, image, self.opacity, self.corner_radius);
190 }
191 
192 fn paint_animated_image(
193 &mut self,
194 animated_image: Arc<AnimatedImage>,
195 size: Vector2F,
196 origin: Vector2F,
197 bounds: Vector2I,
198 ctx: &mut PaintContext,
199 ) {
200 // If self.started_at is not provided, we set it to current time
201 // so only the first frame is shown.
202 let started_at = self.started_at.unwrap_or_else(Instant::now);
203 let elapsed_time = started_at.elapsed().as_millis();
204 // After about ~50 days, casting `elapsed_time` to a u32 will
205 // silently overflow. The gif may jump and start playing from a
206 // different frame.
207 match animated_image.get_current_frame(elapsed_time as u32) {
208 Ok((frame, remaining_delay)) => {
209 self.paint_static_image(frame.clone(), size, origin, bounds, ctx);
210 // Only repaint if self.started_at is set. Otherwise
211 // enable_animation_with_start_time has not been called
212 // and we shouldn't animate.
213 if self.started_at.is_some() {
214 ctx.repaint_after(Duration::from_millis(remaining_delay as u64));
215 }
216 }
217 Err(e) => {
218 log::error!("Unable to retrieve current frame from image: {e:?}");
219 }
220 }
221 }
222}
223 
224fn image_rect(
225 size: Vector2F,
226 origin: Vector2F,
227 logical_image_size: Vector2F,
228 top_aligned: bool,
229 right_aligned: bool,
230) -> Option<RectF> {
231 let offset = if right_aligned {
232 vec2f(size.x() - logical_image_size.x(), 0.0)
233 } else if top_aligned {
234 vec2f((size.x() - logical_image_size.x()) / 2.0, 0.0)
235 } else {
236 (size - logical_image_size) / 2.0
237 };
238 let origin = origin + offset;
239 let rect = RectF::new(origin, logical_image_size);
240 if rect.origin().x().is_finite()
241 && rect.origin().y().is_finite()
242 && rect.size().x().is_finite()
243 && rect.size().y().is_finite()
244 {
245 Some(rect)
246 } else {
247 None
248 }
249}
250 
251/// Returns desired dimensions of the image given the original size (x, y), desired container size
252/// (dest_x, dest_y) and fit_type.
253/// Returns a vector with new dimensions maintaining the original aspect ratio,
254/// unless the FitType is `Stretch`.
255fn dimensions(original: Vector2F, dest: Vector2F, fit_type: FitType) -> Vector2F {
256 let ratio_x = dest.x() / original.x();
257 let ratio_y = dest.y() / original.y();
258 
259 let ratio = match fit_type {
260 FitType::Contain => ratio_x.min(ratio_y),
261 FitType::Cover => ratio_x.max(ratio_y),
262 FitType::Stretch => {
263 // Stretch doesn't maintain aspect ratio
264 return dest;
265 }
266 };
267 
268 let x = original.x() * ratio;
269 let y = original.y() * ratio;
270 
271 vec2f(x.max(1.), y.max(1.)).round()
272}
273 
274impl Element for Image {
275 fn layout(
276 &mut self,
277 constraint: SizeConstraint,
278 ctx: &mut LayoutContext,
279 app: &AppContext,
280 ) -> Vector2F {
281 let size = constraint.max;
282 self.size = Some(size);
283 
284 if let Some(before_load_element) = self.before_load_element.as_mut() {
285 before_load_element.layout(constraint, ctx, app);
286 }
287 
288 size
289 }
290 
291 fn after_layout(&mut self, ctx: &mut AfterLayoutContext, app: &AppContext) {
292 if let Some(before_load_element) = self.before_load_element.as_mut() {
293 before_load_element.after_layout(ctx, app);
294 }
295 }
296 
297 fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) {
298 let Some(size) = self.size else {
299 return;
300 };
301 
302 let bounds = (size * ctx.scene.scale_factor()).to_i32();
303 if !size.x().is_finite()
304 || !size.y().is_finite()
305 || size.x() <= 0.0
306 || size.y() <= 0.0
307 || bounds.x() <= 0
308 || bounds.y() <= 0
309 {
310 log::warn!(
311 "image paint with suspicious size source={:?} element_size=({}, {}) bounds=({}, {}) fit_type={:?} cache_option={:?}",
312 self.source,
313 size.x(),
314 size.y(),
315 bounds.x(),
316 bounds.y(),
317 self.fit_type,
318 self.cache_option,
319 );
320 }
321 let assert_cache = AssetCache::as_ref(app);
322 let image = ImageCache::as_ref(app).image(
323 self.source.clone(),
324 bounds,
325 self.fit_type,
326 self.animated_image_behavior,
327 self.cache_option,
328 ctx.max_texture_dimension_2d,
329 assert_cache,
330 );
331 
332 match image {
333 AssetState::Loading { handle } => {
334 if !self.requested_repaint_after_load {
335 ctx.repaint_after_load(handle);
336 self.requested_repaint_after_load = true;
337 }
338 
339 if let Some(before_load_element) = self.before_load_element.as_mut() {
340 before_load_element.paint(origin, ctx, app);
341 }
342 }
343 AssetState::Evicted => {
344 if let Some(before_load_element) = self.before_load_element.as_mut() {
345 before_load_element.paint(origin, ctx, app);
346 }
347 }
348 AssetState::FailedToLoad(_) => {
349 if let Some(before_load_element) = self.before_load_element.as_mut() {
350 before_load_element.paint(origin, ctx, app);
351 }
352 }
353 AssetState::Loaded { data } => {
354 // Don't waste time calling layout() and after_layout() on the backup element once the main
355 // one has loaded.
356 self.before_load_element = None;
357 
358 match data.as_ref() {
359 crate::image_cache::Image::Static(static_image) => {
360 self.paint_static_image(static_image.clone(), size, origin, bounds, ctx)
361 }
362 crate::image_cache::Image::Animated(animated_image) => {
363 self.paint_animated_image(animated_image.clone(), size, origin, bounds, ctx)
364 }
365 }
366 }
367 }
368 }
369 
370 fn size(&self) -> Option<Vector2F> {
371 self.size
372 }
373 
374 fn dispatch_event(
375 &mut self,
376 _: &DispatchedEvent,
377 _: &mut EventContext,
378 _: &AppContext,
379 ) -> bool {
380 false
381 }
382 
383 fn origin(&self) -> Option<Point> {
384 self.origin
385 }
386}
387 
388#[cfg(test)]
389#[path = "image_tests.rs"]
390mod tests;
391