1use crate::{
2 AnyElement, AnyImageCache, App, Asset, AssetLogger, Bounds, DefiniteLength, Element, ElementId,
3 Entity, GlobalElementId, Hitbox, Image, ImageCache, ImageExt, InteractiveElement,
4 Interactivity, IntoElement, LayoutId, Length, ObjectFit, Pixels, RenderImage, Resource,
5 SharedString, SharedUri, StyleRefinement, Styled, Task, Window, decode_static_image,
6 decode_static_image_from_decoder, px,
7};
8use anyhow::Result;
9
10use futures::Future;
11use gpui_util::ResultExt;
12use image::{
13 AnimationDecoder, ImageError, ImageFormat, Rgba,
14 codecs::{gif::GifDecoder, webp::WebPDecoder},
15};
16use scheduler::Instant;
17use smallvec::SmallVec;
18use std::{
19 fs,
20 io::{self, Cursor},
21 ops::{Deref, DerefMut},
22 path::{Path, PathBuf},
23 str::FromStr,
24 sync::Arc,
25 time::Duration,
26};
27use thiserror::Error;
28
29use super::{Stateful, StatefulInteractiveElement};
30
31pub const LOADING_DELAY: Duration = Duration::from_millis(200);
33
34pub type ImgResourceLoader = AssetLogger<ImageAssetLoader>;
39
40#[derive(Clone)]
42pub enum ImageSource {
43 Resource(Resource),
45 Render(Arc<RenderImage>),
47 Image(Arc<Image>),
49 Custom(Arc<dyn Fn(&mut Window, &mut App) -> Option<Result<Arc<RenderImage>, ImageCacheError>>>),
51}
52
53fn is_uri(uri: &str) -> bool {
54 url::Url::from_str(uri).is_ok()
55}
56
57impl From<SharedUri> for ImageSource {
58 fn from(value: SharedUri) -> Self {
59 Self::Resource(Resource::Uri(value))
60 }
61}
62
63impl<'a> From<&'a str> for ImageSource {
64 fn from(s: &'a str) -> Self {
65 if is_uri(s) {
66 Self::Resource(Resource::Uri(s.to_string().into()))
67 } else {
68 Self::Resource(Resource::Embedded(s.to_string().into()))
69 }
70 }
71}
72
73impl From<String> for ImageSource {
74 fn from(s: String) -> Self {
75 if is_uri(&s) {
76 Self::Resource(Resource::Uri(s.into()))
77 } else {
78 Self::Resource(Resource::Embedded(s.into()))
79 }
80 }
81}
82
83impl From<SharedString> for ImageSource {
84 fn from(s: SharedString) -> Self {
85 s.as_ref().into()
86 }
87}
88
89impl From<&Path> for ImageSource {
90 fn from(value: &Path) -> Self {
91 Self::Resource(value.to_path_buf().into())
92 }
93}
94
95impl From<Arc<Path>> for ImageSource {
96 fn from(value: Arc<Path>) -> Self {
97 Self::Resource(value.into())
98 }
99}
100
101impl From<PathBuf> for ImageSource {
102 fn from(value: PathBuf) -> Self {
103 Self::Resource(value.into())
104 }
105}
106
107impl From<Arc<RenderImage>> for ImageSource {
108 fn from(value: Arc<RenderImage>) -> Self {
109 Self::Render(value)
110 }
111}
112
113impl From<Arc<Image>> for ImageSource {
114 fn from(value: Arc<Image>) -> Self {
115 Self::Image(value)
116 }
117}
118
119impl<F> From<F> for ImageSource
120where
121 F: Fn(&mut Window, &mut App) -> Option<Result<Arc<RenderImage>, ImageCacheError>> + 'static,
122{
123 fn from(value: F) -> Self {
124 Self::Custom(Arc::new(value))
125 }
126}
127
128pub struct ImageStyle {
130 grayscale: bool,
131 object_fit: ObjectFit,
132 loading: Option<Box<dyn Fn() -> AnyElement>>,
133 fallback: Option<Box<dyn Fn() -> AnyElement>>,
134}
135
136impl Default for ImageStyle {
137 fn default() -> Self {
138 Self {
139 grayscale: false,
140 object_fit: ObjectFit::Contain,
141 loading: None,
142 fallback: None,
143 }
144 }
145}
146
147pub trait StyledImage: Sized {
149 fn image_style(&mut self) -> &mut ImageStyle;
151
152 fn grayscale(mut self, grayscale: bool) -> Self {
154 self.image_style().grayscale = grayscale;
155 self
156 }
157
158 fn object_fit(mut self, object_fit: ObjectFit) -> Self {
160 self.image_style().object_fit = object_fit;
161 self
162 }
163
164 fn with_fallback(mut self, fallback: impl Fn() -> AnyElement + 'static) -> Self {
167 self.image_style().fallback = Some(Box::new(fallback));
168 self
169 }
170
171 fn with_loading(mut self, loading: impl Fn() -> AnyElement + 'static) -> Self {
174 self.image_style().loading = Some(Box::new(loading));
175 self
176 }
177}
178
179impl StyledImage for Img {
180 fn image_style(&mut self) -> &mut ImageStyle {
181 &mut self.style
182 }
183}
184
185impl StyledImage for Stateful<Img> {
186 fn image_style(&mut self) -> &mut ImageStyle {
187 &mut self.element.style
188 }
189}
190
191pub struct Img {
193 interactivity: Interactivity,
194 source: ImageSource,
195 style: ImageStyle,
196 image_cache: Option<AnyImageCache>,
197}
198
199#[track_caller]
201pub fn img(source: impl Into<ImageSource>) -> Img {
202 Img {
203 interactivity: Interactivity::new(),
204 source: source.into(),
205 style: ImageStyle::default(),
206 image_cache: None,
207 }
208}
209
210impl Img {
211 pub fn extensions() -> &'static [&'static str] {
213 &[
215 "avif", "jpg", "jpeg", "png", "gif", "webp", "tif", "tiff", "tga", "dds", "bmp", "ico",
216 "hdr", "exr", "pbm", "pam", "ppm", "pgm", "ff", "farbfeld", "qoi", "svg",
217 ]
218 }
219
220 #[inline]
230 pub fn image_cache<I: ImageCache>(self, image_cache: &Entity<I>) -> Self {
231 Self {
232 image_cache: Some(image_cache.clone().into()),
233 ..self
234 }
235 }
236}
237
238impl Deref for Stateful<Img> {
239 type Target = Img;
240
241 fn deref(&self) -> &Self::Target {
242 &self.element
243 }
244}
245
246impl DerefMut for Stateful<Img> {
247 fn deref_mut(&mut self) -> &mut Self::Target {
248 &mut self.element
249 }
250}
251
252struct ImgState {
254 frame_index: usize,
255 last_frame_time: Option<Instant>,
256 started_loading: Option<(Instant, Task<()>)>,
257}
258
259pub struct ImgLayoutState {
261 frame_index: usize,
262 replacement: Option<AnyElement>,
263}
264
265impl Element for Img {
266 type RequestLayoutState = ImgLayoutState;
267 type PrepaintState = Option<Hitbox>;
268
269 fn id(&self) -> Option<ElementId> {
270 self.interactivity.element_id.clone()
271 }
272
273 fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
274 self.interactivity.source_location()
275 }
276
277 fn request_layout(
278 &mut self,
279 global_id: Option<&GlobalElementId>,
280 window: &mut Window,
281 cx: &mut App,
282 ) -> (LayoutId, Self::RequestLayoutState) {
283 let mut layout_state = ImgLayoutState {
284 frame_index: 0,
285 replacement: None,
286 };
287
288 window.with_optional_element_state(global_id, |state, window| {
289 let mut state = state.map(|state| {
290 state.unwrap_or(ImgState {
291 frame_index: 0,
292 last_frame_time: None,
293 started_loading: None,
294 })
295 });
296
297 let mut frame_index = state.as_ref().map(|state| state.frame_index).unwrap_or(0);
298
299 let layout_id = self.interactivity.request_layout(
300 global_id,
301 window,
302 cx,
303 |mut style, window, cx| {
304 let mut replacement_id = None;
305
306 match self.source.use_data(
307 self.image_cache
308 .clone()
309 .or_else(|| window.frame_state.image_cache_stack.last().cloned()),
310 window,
311 cx,
312 ) {
313 Some(Ok(data)) => {
314 let frame_count = data.frame_count();
315 let max_frame_index = frame_count.saturating_sub(1);
316
317 if let Some(state) = &mut state {
318 state.frame_index = state.frame_index.min(max_frame_index);
319 if frame_count > 1 && !cx.reduce_motion() {
320 if window.is_window_active() {
321 let current_time = Instant::now();
322 if let Some(last_frame_time) = state.last_frame_time {
323 let elapsed = current_time - last_frame_time;
324 let frame_duration =
325 Duration::from(data.delay(state.frame_index));
326
327 if elapsed >= frame_duration {
328 state.frame_index =
329 (state.frame_index + 1) % frame_count;
330 state.last_frame_time =
331 Some(current_time - (elapsed - frame_duration));
332 }
333 } else {
334 state.last_frame_time = Some(current_time);
335 }
336 } else {
337 state.last_frame_time = None;
338 }
339 } else {
340 state.last_frame_time = None;
341 }
342 state.started_loading = None;
343 frame_index = state.frame_index;
344 }
345
346 let image_size = data.render_size(frame_index);
347
348 if style.aspect_ratio.is_none() {
349 style.aspect_ratio = Some(image_size.width / image_size.height);
350 }
351
352 if let Length::Auto = style.size.width {
353 style.size.width = match style.size.height {
354 Length::Definite(DefiniteLength::Absolute(abs_length)) => {
355 let height_px = abs_length.to_pixels(window.rem_size());
356 Length::Definite(
357 px(image_size.width.0 * height_px.0
358 / image_size.height.0)
359 .into(),
360 )
361 }
362 _ => Length::Definite(image_size.width.into()),
363 };
364 }
365
366 if let Length::Auto = style.size.height {
367 style.size.height = match style.size.width {
368 Length::Definite(DefiniteLength::Absolute(abs_length)) => {
369 let width_px = abs_length.to_pixels(window.rem_size());
370 Length::Definite(
371 px(image_size.height.0 * width_px.0
372 / image_size.width.0)
373 .into(),
374 )
375 }
376 _ => Length::Definite(image_size.height.into()),
377 };
378 }
379
380 if global_id.is_some()
381 && data.frame_count() > 1
382 && window.is_window_active()
383 && !cx.reduce_motion()
384 {
385 window.request_animation_frame();
386 }
387 }
388 Some(_err) => {
389 if let Some(fallback) = self.style.fallback.as_ref() {
390 let mut element = fallback();
391 replacement_id = Some(element.request_layout(window, cx));
392 layout_state.replacement = Some(element);
393 }
394 if let Some(state) = &mut state {
395 state.started_loading = None;
396 }
397 }
398 None => {
399 if let Some(state) = &mut state {
400 if let Some((started_loading, _)) = state.started_loading {
401 if started_loading.elapsed() > LOADING_DELAY
402 && let Some(loading) = self.style.loading.as_ref()
403 {
404 let mut element = loading();
405 replacement_id = Some(element.request_layout(window, cx));
406 layout_state.replacement = Some(element);
407 }
408 } else {
409 let current_view = window.current_view();
410 let task = window.spawn(cx, async move |cx| {
411 cx.background_executor().timer(LOADING_DELAY).await;
412 cx.update(move |_, cx| {
413 cx.notify(current_view);
414 })
415 .ok();
416 });
417 state.started_loading = Some((Instant::now(), task));
418 }
419 }
420 }
421 }
422
423 window.request_layout(style, replacement_id, cx)
424 },
425 );
426
427 layout_state.frame_index = frame_index;
428
429 ((layout_id, layout_state), state)
430 })
431 }
432
433 fn prepaint(
434 &mut self,
435 global_id: Option<&GlobalElementId>,
436 bounds: Bounds<Pixels>,
437 request_layout: &mut Self::RequestLayoutState,
438 window: &mut Window,
439 cx: &mut App,
440 ) -> Self::PrepaintState {
441 self.interactivity.prepaint(
442 global_id,
443 bounds,
444 bounds.size,
445 window,
446 cx,
447 |_, _, hitbox, window, cx| {
448 if let Some(replacement) = &mut request_layout.replacement {
449 replacement.prepaint(window, cx);
450 }
451
452 hitbox
453 },
454 )
455 }
456
457 fn paint(
458 &mut self,
459 global_id: Option<&GlobalElementId>,
460 bounds: Bounds<Pixels>,
461 layout_state: &mut Self::RequestLayoutState,
462 hitbox: &mut Self::PrepaintState,
463 window: &mut Window,
464 cx: &mut App,
465 ) {
466 let source = self.source.clone();
467 self.interactivity.paint(
468 global_id,
469 bounds,
470 hitbox.as_ref(),
471 window,
472 cx,
473 |style, window, cx| {
474 if let Some(Ok(data)) = source.use_data(
475 self.image_cache
476 .clone()
477 .or_else(|| window.frame_state.image_cache_stack.last().cloned()),
478 window,
479 cx,
480 ) {
481 if data.frame_count() == 0 {
482 return;
483 }
484 let new_bounds = self
485 .style
486 .object_fit
487 .get_bounds(bounds, data.size(layout_state.frame_index));
488 let corner_radii = style.corner_radii.to_pixels(window.rem_size());
489 window
490 .paint_image(
491 bounds,
492 new_bounds,
493 corner_radii,
494 data,
495 layout_state.frame_index,
496 self.style.grayscale,
497 )
498 .log_err();
499 } else if let Some(replacement) = &mut layout_state.replacement {
500 replacement.paint(window, cx);
501 }
502 },
503 )
504 }
505}
506
507impl Styled for Img {
508 fn style(&mut self) -> &mut StyleRefinement {
509 &mut self.interactivity.base_style
510 }
511}
512
513impl InteractiveElement for Img {
514 fn interactivity(&mut self) -> &mut Interactivity {
515 &mut self.interactivity
516 }
517}
518
519impl IntoElement for Img {
520 type Element = Self;
521
522 fn into_element(self) -> Self::Element {
523 self
524 }
525}
526
527impl StatefulInteractiveElement for Img {}
528
529impl ImageSource {
530 pub(crate) fn use_data(
531 &self,
532 cache: Option<AnyImageCache>,
533 window: &mut Window,
534 cx: &mut App,
535 ) -> Option<Result<Arc<RenderImage>, ImageCacheError>> {
536 match self {
537 ImageSource::Resource(resource) => {
538 if let Some(cache) = cache {
539 cache.load(resource, window, cx)
540 } else {
541 window.use_asset::<ImgResourceLoader>(resource, cx)
542 }
543 }
544 ImageSource::Custom(loading_fn) => loading_fn(window, cx),
545 ImageSource::Render(data) => Some(Ok(data.to_owned())),
546 ImageSource::Image(data) => window.use_asset::<AssetLogger<ImageDecoder>>(data, cx),
547 }
548 }
549
550 pub(crate) fn get_data(
551 &self,
552 cache: Option<AnyImageCache>,
553 window: &mut Window,
554 cx: &mut App,
555 ) -> Option<Result<Arc<RenderImage>, ImageCacheError>> {
556 match self {
557 ImageSource::Resource(resource) => {
558 if let Some(cache) = cache {
559 cache.load(resource, window, cx)
560 } else {
561 window.get_asset::<ImgResourceLoader>(resource, cx)
562 }
563 }
564 ImageSource::Custom(loading_fn) => loading_fn(window, cx),
565 ImageSource::Render(data) => Some(Ok(data.to_owned())),
566 ImageSource::Image(data) => window.get_asset::<AssetLogger<ImageDecoder>>(data, cx),
567 }
568 }
569
570 pub fn remove_asset(&self, cx: &mut App) {
572 match self {
573 ImageSource::Resource(resource) => {
574 cx.remove_asset::<ImgResourceLoader>(resource);
575 }
576 ImageSource::Custom(_) | ImageSource::Render(_) => {}
577 ImageSource::Image(data) => cx.remove_asset::<AssetLogger<ImageDecoder>>(data),
578 }
579 }
580
581 #[cfg(any(test, feature = "test-support"))]
584 pub fn is_asset_cached(&self, cx: &App) -> bool {
585 match self {
586 ImageSource::Resource(resource) => cx.has_asset::<ImgResourceLoader>(resource),
587 ImageSource::Custom(_) | ImageSource::Render(_) => false,
588 ImageSource::Image(data) => cx.has_asset::<AssetLogger<ImageDecoder>>(data),
589 }
590 }
591}
592
593#[derive(Clone)]
594enum ImageDecoder {}
595
596impl Asset for ImageDecoder {
597 type Source = Arc<Image>;
598 type Output = Result<Arc<RenderImage>, ImageCacheError>;
599
600 fn load(
601 source: Self::Source,
602 cx: &mut App,
603 ) -> impl Future<Output = Self::Output> + Send + 'static {
604 let renderer = cx.svg_renderer();
605 async move { source.to_image_data(renderer).map_err(Into::into) }
606 }
607}
608
609#[derive(Clone)]
611pub enum ImageAssetLoader {}
612
613impl Asset for ImageAssetLoader {
614 type Source = Resource;
615 type Output = Result<Arc<RenderImage>, ImageCacheError>;
616
617 fn load(
618 source: Self::Source,
619 cx: &mut App,
620 ) -> impl Future<Output = Self::Output> + Send + 'static {
621 let client = cx.http_client();
622 let svg_renderer = cx.svg_renderer();
625 let asset_source = cx.asset_source().clone();
626 async move {
627 let bytes = match source.clone() {
628 Resource::Path(uri) => fs::read(uri.as_ref())?,
629 Resource::Uri(uri) => {
630 use anyhow::Context as _;
631 use futures::AsyncReadExt as _;
632
633 let mut response = client
634 .get(uri.as_ref(), ().into(), true)
635 .await
636 .with_context(|| format!("loading image asset from {uri:?}"))?;
637 let mut body = Vec::new();
638 response.body_mut().read_to_end(&mut body).await?;
639 if !response.status().is_success() {
640 let mut body = String::from_utf8_lossy(&body).into_owned();
641 let first_line = body.lines().next().unwrap_or("").trim_end();
642 body.truncate(first_line.len());
643 return Err(ImageCacheError::BadStatus {
644 uri,
645 status: response.status(),
646 body,
647 });
648 }
649 body
650 }
651 Resource::Embedded(path) => {
652 let data = asset_source.load(&path).ok().flatten();
653 if let Some(data) = data {
654 data.to_vec()
655 } else {
656 return Err(ImageCacheError::Asset(
657 format!("Embedded resource not found: {}", path).into(),
658 ));
659 }
660 }
661 };
662
663 if let Ok(format) = image::guess_format(&bytes) {
664 let data = match format {
665 ImageFormat::Gif => {
666 let decoder = GifDecoder::new(Cursor::new(&bytes))?;
667 let mut frames = SmallVec::new();
668
669 for frame in decoder.into_frames() {
670 match frame {
671 Ok(mut frame) => {
672 for pixel in frame.buffer_mut().chunks_exact_mut(4) {
674 pixel.swap(0, 2);
675 }
676 frames.push(frame);
677 }
678 Err(err) => {
679 log::debug!(
680 "Skipping GIF frame in {source:?} due to decode error: {err}"
681 );
682 }
683 }
684 }
685
686 if frames.is_empty() {
687 return Err(ImageCacheError::Other(Arc::new(anyhow::anyhow!(
688 "GIF could not be decoded: all frames failed ({source:?})"
689 ))));
690 }
691
692 frames
693 }
694 ImageFormat::WebP => {
695 let mut decoder = WebPDecoder::new(Cursor::new(&bytes))?;
696
697 if decoder.has_animation() {
698 let _ = decoder.set_background_color(Rgba([0, 0, 0, 0]));
699 let mut frames = SmallVec::new();
700
701 for frame in decoder.into_frames() {
702 match frame {
703 Ok(mut frame) => {
704 for pixel in frame.buffer_mut().chunks_exact_mut(4) {
706 pixel.swap(0, 2);
707 }
708 frames.push(frame);
709 }
710 Err(err) => {
711 log::debug!(
712 "Skipping WebP frame in {source:?} due to decode error: {err}"
713 );
714 }
715 }
716 }
717
718 if frames.is_empty() {
719 return Err(ImageCacheError::Other(Arc::new(anyhow::anyhow!(
720 "WebP could not be decoded: all frames failed ({source:?})"
721 ))));
722 }
723
724 frames
725 } else {
726 decode_static_image_from_decoder(decoder)?
727 }
728 }
729 _ => decode_static_image(&bytes, format)?,
730 };
731
732 Ok(Arc::new(RenderImage::new(data)))
733 } else {
734 svg_renderer
735 .render_single_frame(&bytes, 1.0)
736 .map_err(Into::into)
737 }
738 }
739 }
740}
741
742#[derive(Debug, Error, Clone)]
744pub enum ImageCacheError {
745 #[error("error: {0}")]
747 Other(#[from] Arc<anyhow::Error>),
748 #[error("IO error: {0}")]
750 Io(Arc<std::io::Error>),
751 #[error("unexpected http status for {uri}: {status}, body: {body}")]
753 BadStatus {
754 uri: SharedUri,
756 status: http_client::StatusCode,
758 body: String,
760 },
761 #[error("asset error: {0}")]
763 Asset(SharedString),
764 #[error("image error: {0}")]
766 Image(Arc<ImageError>),
767 #[error("svg error: {0}")]
769 Usvg(Arc<usvg::Error>),
770}
771
772impl From<anyhow::Error> for ImageCacheError {
773 fn from(value: anyhow::Error) -> Self {
774 Self::Other(Arc::new(value))
775 }
776}
777
778impl From<io::Error> for ImageCacheError {
779 fn from(value: io::Error) -> Self {
780 Self::Io(Arc::new(value))
781 }
782}
783
784impl From<usvg::Error> for ImageCacheError {
785 fn from(value: usvg::Error) -> Self {
786 Self::Usvg(Arc::new(value))
787 }
788}
789
790impl From<image::ImageError> for ImageCacheError {
791 fn from(value: image::ImageError) -> Self {
792 Self::Image(Arc::new(value))
793 }
794}
795
796#[cfg(test)]
797mod tests {
798 use super::*;
799 use crate::{ParentElement as _, TestAppContext, canvas, div, point, px, size};
800 use image::{Frame, ImageBuffer, Rgba};
801
802 const TEST_IMG_ID: &str = "test-img";
803
804 fn test_image(frame_count: usize) -> Arc<RenderImage> {
805 let frame = Frame::new(ImageBuffer::from_pixel(1, 1, Rgba([0, 0, 0, 0])));
806 Arc::new(RenderImage::new(SmallVec::from_iter(
807 (0..frame_count).map(|_| frame.clone()),
808 )))
809 }
810
811 fn test_image_with_size(width: u32, height: u32) -> Arc<RenderImage> {
812 let frame = Frame::new(ImageBuffer::from_pixel(width, height, Rgba([0, 0, 0, 0])));
813 Arc::new(RenderImage::new(SmallVec::from_elem(frame, 1)))
814 }
815
816 fn seed_frame_index(frame_index: usize) -> impl IntoElement {
818 canvas(
819 |_, _, _| (),
820 move |_, _, window, _| {
821 window.with_global_id(TEST_IMG_ID.into(), |id, window| {
822 window.with_element_state::<ImgState, _>(id, |state, _| {
823 let mut state = state.expect("img state should be initialized");
824 state.frame_index = frame_index;
825 ((), state)
826 });
827 });
828 },
829 )
830 }
831
832 #[gpui::test]
833 fn zero_frame_image_does_not_panic_on_paint(cx: &mut TestAppContext) {
834 cx.add_empty_window()
835 .draw(point(px(0.), px(0.)), size(px(100.), px(100.)), |_, _| {
836 img(ImageSource::Render(test_image(0))).into_any_element()
837 });
838 }
839
840 #[gpui::test]
841 fn image_object_fit_cover_crops_to_element_bounds(cx: &mut TestAppContext) {
842 let window = cx.add_empty_window();
843 let image = test_image_with_size(200, 100);
844 window.draw(point(px(0.), px(0.)), size(px(100.), px(100.)), |_, _| {
845 img(ImageSource::Render(image.clone()))
846 .size_full()
847 .object_fit(ObjectFit::Fill)
848 .into_any_element()
849 });
850 let full_tile_bounds = window.update(|window, _| {
851 window
852 .frame_state
853 .rendered_frame
854 .scene
855 .polychrome_sprites
856 .last()
857 .expect("fill image should paint a sprite")
858 .tile
859 .bounds
860 });
861
862 window.draw(point(px(10.), px(20.)), size(px(100.), px(100.)), |_, _| {
863 img(ImageSource::Render(image))
864 .size_full()
865 .object_fit(ObjectFit::Cover)
866 .into_any_element()
867 });
868
869 let (rendered_bounds, rendered_tile_bounds, scale_factor) = window.update(|window, _| {
870 let sprite = window
871 .frame_state
872 .rendered_frame
873 .scene
874 .polychrome_sprites
875 .last()
876 .expect("cover image should paint a sprite");
877 (sprite.bounds, sprite.tile.bounds, window.scale_factor())
878 });
879 assert_eq!(
880 rendered_bounds,
881 Bounds {
882 origin: point(px(10.).scale(scale_factor), px(20.).scale(scale_factor)),
883 size: size(px(100.).scale(scale_factor), px(100.).scale(scale_factor)),
884 }
885 );
886 assert_eq!(
887 (
888 rendered_tile_bounds.origin.x.0 - full_tile_bounds.origin.x.0,
889 rendered_tile_bounds.origin.y.0 - full_tile_bounds.origin.y.0,
890 rendered_tile_bounds.size.width.0,
891 rendered_tile_bounds.size.height.0,
892 ),
893 (50, 0, 100, 100),
894 );
895 }
896
897 #[gpui::test]
898 fn explicit_aspect_ratio_is_not_overridden_by_intrinsic_ratio(cx: &mut TestAppContext) {
899 let window = cx.add_empty_window();
900
901 window.draw(point(px(0.), px(0.)), size(px(100.), px(100.)), |_, _| {
903 div()
904 .size(px(100.))
905 .overflow_hidden()
906 .child(
907 img(ImageSource::Render(test_image_with_size(100, 200)))
908 .size_full()
909 .aspect_square()
910 .object_fit(ObjectFit::Contain),
911 )
912 .into_any_element()
913 });
914
915 let (rendered_bounds, scale_factor) = window.update(|window, _| {
916 let sprite = window
917 .frame_state
918 .rendered_frame
919 .scene
920 .polychrome_sprites
921 .last()
922 .expect("contained image should paint a sprite");
923 (sprite.bounds, window.scale_factor())
924 });
925
926 assert_eq!(
928 rendered_bounds,
929 Bounds {
930 origin: point(px(25.).scale(scale_factor), px(0.).scale(scale_factor)),
931 size: size(px(50.).scale(scale_factor), px(100.).scale(scale_factor)),
932 }
933 );
934 }
935
936 #[gpui::test]
937 fn image_object_fit_cover_clamps_corner_radii_to_visible_bounds(cx: &mut TestAppContext) {
938 let window = cx.add_empty_window();
939 window.draw(point(px(0.), px(0.)), size(px(100.), px(100.)), |_, _| {
940 img(ImageSource::Render(test_image_with_size(200, 100)))
941 .size_full()
942 .rounded(px(100.))
943 .object_fit(ObjectFit::Cover)
944 .into_any_element()
945 });
946
947 let (corner_radius, expected_corner_radius) = window.update(|window, _| {
948 (
949 window
950 .frame_state
951 .rendered_frame
952 .scene
953 .polychrome_sprites
954 .last()
955 .map(|sprite| sprite.corner_radii.top_left),
956 px(50.).scale(window.scale_factor()),
957 )
958 });
959 assert_eq!(corner_radius, Some(expected_corner_radius));
960 }
961
962 #[gpui::test]
963 fn stale_frame_index_is_clamped_when_image_changes(cx: &mut TestAppContext) {
964 let window = cx.add_empty_window();
965
966 window.draw(point(px(0.), px(0.)), size(px(100.), px(100.)), |_, _| {
970 div()
971 .child(img(ImageSource::Render(test_image(5))).id(TEST_IMG_ID))
972 .child(seed_frame_index(4))
973 .into_any_element()
974 });
975 window.draw(point(px(0.), px(0.)), size(px(100.), px(100.)), |_, _| {
976 img(ImageSource::Render(test_image(1)))
977 .id(TEST_IMG_ID)
978 .into_any_element()
979 });
980 }
981}