1use crate::{
2 AnyElement, AnyImageCache, App, Asset, AssetLogger, Bounds, DefiniteLength, Element, ElementId,
3 Entity, GlobalElementId, Hitbox, Image, ImageCache, InspectorElementId, 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 inspector_id: Option<&InspectorElementId>,
281 window: &mut Window,
282 cx: &mut App,
283 ) -> (LayoutId, Self::RequestLayoutState) {
284 let mut layout_state = ImgLayoutState {
285 frame_index: 0,
286 replacement: None,
287 };
288
289 window.with_optional_element_state(global_id, |state, window| {
290 let mut state = state.map(|state| {
291 state.unwrap_or(ImgState {
292 frame_index: 0,
293 last_frame_time: None,
294 started_loading: None,
295 })
296 });
297
298 let mut frame_index = state.as_ref().map(|state| state.frame_index).unwrap_or(0);
299
300 let layout_id = self.interactivity.request_layout(
301 global_id,
302 inspector_id,
303 window,
304 cx,
305 |mut style, window, cx| {
306 let mut replacement_id = None;
307
308 match self.source.use_data(
309 self.image_cache
310 .clone()
311 .or_else(|| window.image_cache_stack.last().cloned()),
312 window,
313 cx,
314 ) {
315 Some(Ok(data)) => {
316 let frame_count = data.frame_count();
317 let max_frame_index = frame_count.saturating_sub(1);
318
319 if let Some(state) = &mut state {
320 state.frame_index = state.frame_index.min(max_frame_index);
321 if frame_count > 1 && !cx.reduce_motion() {
322 if window.is_window_active() {
323 let current_time = Instant::now();
324 if let Some(last_frame_time) = state.last_frame_time {
325 let elapsed = current_time - last_frame_time;
326 let frame_duration =
327 Duration::from(data.delay(state.frame_index));
328
329 if elapsed >= frame_duration {
330 state.frame_index =
331 (state.frame_index + 1) % frame_count;
332 state.last_frame_time =
333 Some(current_time - (elapsed - frame_duration));
334 }
335 } else {
336 state.last_frame_time = Some(current_time);
337 }
338 } else {
339 state.last_frame_time = None;
340 }
341 } else {
342 state.last_frame_time = None;
343 }
344 state.started_loading = None;
345 frame_index = state.frame_index;
346 }
347
348 let image_size = data.render_size(frame_index);
349
350 if style.aspect_ratio.is_none() {
351 style.aspect_ratio = Some(image_size.width / image_size.height);
352 }
353
354 if let Length::Auto = style.size.width {
355 style.size.width = match style.size.height {
356 Length::Definite(DefiniteLength::Absolute(abs_length)) => {
357 let height_px = abs_length.to_pixels(window.rem_size());
358 Length::Definite(
359 px(image_size.width.0 * height_px.0
360 / image_size.height.0)
361 .into(),
362 )
363 }
364 _ => Length::Definite(image_size.width.into()),
365 };
366 }
367
368 if let Length::Auto = style.size.height {
369 style.size.height = match style.size.width {
370 Length::Definite(DefiniteLength::Absolute(abs_length)) => {
371 let width_px = abs_length.to_pixels(window.rem_size());
372 Length::Definite(
373 px(image_size.height.0 * width_px.0
374 / image_size.width.0)
375 .into(),
376 )
377 }
378 _ => Length::Definite(image_size.height.into()),
379 };
380 }
381
382 if global_id.is_some()
383 && data.frame_count() > 1
384 && window.is_window_active()
385 && !cx.reduce_motion()
386 {
387 window.request_animation_frame();
388 }
389 }
390 Some(_err) => {
391 if let Some(fallback) = self.style.fallback.as_ref() {
392 let mut element = fallback();
393 replacement_id = Some(element.request_layout(window, cx));
394 layout_state.replacement = Some(element);
395 }
396 if let Some(state) = &mut state {
397 state.started_loading = None;
398 }
399 }
400 None => {
401 if let Some(state) = &mut state {
402 if let Some((started_loading, _)) = state.started_loading {
403 if started_loading.elapsed() > LOADING_DELAY
404 && let Some(loading) = self.style.loading.as_ref()
405 {
406 let mut element = loading();
407 replacement_id = Some(element.request_layout(window, cx));
408 layout_state.replacement = Some(element);
409 }
410 } else {
411 let current_view = window.current_view();
412 let task = window.spawn(cx, async move |cx| {
413 cx.background_executor().timer(LOADING_DELAY).await;
414 cx.update(move |_, cx| {
415 cx.notify(current_view);
416 })
417 .ok();
418 });
419 state.started_loading = Some((Instant::now(), task));
420 }
421 }
422 }
423 }
424
425 window.request_layout(style, replacement_id, cx)
426 },
427 );
428
429 layout_state.frame_index = frame_index;
430
431 ((layout_id, layout_state), state)
432 })
433 }
434
435 fn prepaint(
436 &mut self,
437 global_id: Option<&GlobalElementId>,
438 inspector_id: Option<&InspectorElementId>,
439 bounds: Bounds<Pixels>,
440 request_layout: &mut Self::RequestLayoutState,
441 window: &mut Window,
442 cx: &mut App,
443 ) -> Self::PrepaintState {
444 self.interactivity.prepaint(
445 global_id,
446 inspector_id,
447 bounds,
448 bounds.size,
449 window,
450 cx,
451 |_, _, hitbox, window, cx| {
452 if let Some(replacement) = &mut request_layout.replacement {
453 replacement.prepaint(window, cx);
454 }
455
456 hitbox
457 },
458 )
459 }
460
461 fn paint(
462 &mut self,
463 global_id: Option<&GlobalElementId>,
464 inspector_id: Option<&InspectorElementId>,
465 bounds: Bounds<Pixels>,
466 layout_state: &mut Self::RequestLayoutState,
467 hitbox: &mut Self::PrepaintState,
468 window: &mut Window,
469 cx: &mut App,
470 ) {
471 let source = self.source.clone();
472 self.interactivity.paint(
473 global_id,
474 inspector_id,
475 bounds,
476 hitbox.as_ref(),
477 window,
478 cx,
479 |style, window, cx| {
480 if let Some(Ok(data)) = source.use_data(
481 self.image_cache
482 .clone()
483 .or_else(|| window.image_cache_stack.last().cloned()),
484 window,
485 cx,
486 ) {
487 if data.frame_count() == 0 {
488 return;
489 }
490 let new_bounds = self
491 .style
492 .object_fit
493 .get_bounds(bounds, data.size(layout_state.frame_index));
494 let corner_radii = style.corner_radii.to_pixels(window.rem_size());
495 window
496 .paint_image(
497 bounds,
498 new_bounds,
499 corner_radii,
500 data,
501 layout_state.frame_index,
502 self.style.grayscale,
503 )
504 .log_err();
505 } else if let Some(replacement) = &mut layout_state.replacement {
506 replacement.paint(window, cx);
507 }
508 },
509 )
510 }
511}
512
513impl Styled for Img {
514 fn style(&mut self) -> &mut StyleRefinement {
515 &mut self.interactivity.base_style
516 }
517}
518
519impl InteractiveElement for Img {
520 fn interactivity(&mut self) -> &mut Interactivity {
521 &mut self.interactivity
522 }
523}
524
525impl IntoElement for Img {
526 type Element = Self;
527
528 fn into_element(self) -> Self::Element {
529 self
530 }
531}
532
533impl StatefulInteractiveElement for Img {}
534
535impl ImageSource {
536 pub(crate) fn use_data(
537 &self,
538 cache: Option<AnyImageCache>,
539 window: &mut Window,
540 cx: &mut App,
541 ) -> Option<Result<Arc<RenderImage>, ImageCacheError>> {
542 match self {
543 ImageSource::Resource(resource) => {
544 if let Some(cache) = cache {
545 cache.load(resource, window, cx)
546 } else {
547 window.use_asset::<ImgResourceLoader>(resource, cx)
548 }
549 }
550 ImageSource::Custom(loading_fn) => loading_fn(window, cx),
551 ImageSource::Render(data) => Some(Ok(data.to_owned())),
552 ImageSource::Image(data) => window.use_asset::<AssetLogger<ImageDecoder>>(data, cx),
553 }
554 }
555
556 pub(crate) fn get_data(
557 &self,
558 cache: Option<AnyImageCache>,
559 window: &mut Window,
560 cx: &mut App,
561 ) -> Option<Result<Arc<RenderImage>, ImageCacheError>> {
562 match self {
563 ImageSource::Resource(resource) => {
564 if let Some(cache) = cache {
565 cache.load(resource, window, cx)
566 } else {
567 window.get_asset::<ImgResourceLoader>(resource, cx)
568 }
569 }
570 ImageSource::Custom(loading_fn) => loading_fn(window, cx),
571 ImageSource::Render(data) => Some(Ok(data.to_owned())),
572 ImageSource::Image(data) => window.get_asset::<AssetLogger<ImageDecoder>>(data, cx),
573 }
574 }
575
576 pub fn remove_asset(&self, cx: &mut App) {
578 match self {
579 ImageSource::Resource(resource) => {
580 cx.remove_asset::<ImgResourceLoader>(resource);
581 }
582 ImageSource::Custom(_) | ImageSource::Render(_) => {}
583 ImageSource::Image(data) => cx.remove_asset::<AssetLogger<ImageDecoder>>(data),
584 }
585 }
586
587 #[cfg(any(test, feature = "test-support"))]
590 pub fn is_asset_cached(&self, cx: &App) -> bool {
591 match self {
592 ImageSource::Resource(resource) => cx.has_asset::<ImgResourceLoader>(resource),
593 ImageSource::Custom(_) | ImageSource::Render(_) => false,
594 ImageSource::Image(data) => cx.has_asset::<AssetLogger<ImageDecoder>>(data),
595 }
596 }
597}
598
599#[derive(Clone)]
600enum ImageDecoder {}
601
602impl Asset for ImageDecoder {
603 type Source = Arc<Image>;
604 type Output = Result<Arc<RenderImage>, ImageCacheError>;
605
606 fn load(
607 source: Self::Source,
608 cx: &mut App,
609 ) -> impl Future<Output = Self::Output> + Send + 'static {
610 let renderer = cx.svg_renderer();
611 async move { source.to_image_data(renderer).map_err(Into::into) }
612 }
613}
614
615#[derive(Clone)]
617pub enum ImageAssetLoader {}
618
619impl Asset for ImageAssetLoader {
620 type Source = Resource;
621 type Output = Result<Arc<RenderImage>, ImageCacheError>;
622
623 fn load(
624 source: Self::Source,
625 cx: &mut App,
626 ) -> impl Future<Output = Self::Output> + Send + 'static {
627 let client = cx.http_client();
628 let svg_renderer = cx.svg_renderer();
631 let asset_source = cx.asset_source().clone();
632 async move {
633 let bytes = match source.clone() {
634 Resource::Path(uri) => fs::read(uri.as_ref())?,
635 Resource::Uri(uri) => {
636 use anyhow::Context as _;
637 use futures::AsyncReadExt as _;
638
639 let mut response = client
640 .get(uri.as_ref(), ().into(), true)
641 .await
642 .with_context(|| format!("loading image asset from {uri:?}"))?;
643 let mut body = Vec::new();
644 response.body_mut().read_to_end(&mut body).await?;
645 if !response.status().is_success() {
646 let mut body = String::from_utf8_lossy(&body).into_owned();
647 let first_line = body.lines().next().unwrap_or("").trim_end();
648 body.truncate(first_line.len());
649 return Err(ImageCacheError::BadStatus {
650 uri,
651 status: response.status(),
652 body,
653 });
654 }
655 body
656 }
657 Resource::Embedded(path) => {
658 let data = asset_source.load(&path).ok().flatten();
659 if let Some(data) = data {
660 data.to_vec()
661 } else {
662 return Err(ImageCacheError::Asset(
663 format!("Embedded resource not found: {}", path).into(),
664 ));
665 }
666 }
667 };
668
669 if let Ok(format) = image::guess_format(&bytes) {
670 let data = match format {
671 ImageFormat::Gif => {
672 let decoder = GifDecoder::new(Cursor::new(&bytes))?;
673 let mut frames = SmallVec::new();
674
675 for frame in decoder.into_frames() {
676 match frame {
677 Ok(mut frame) => {
678 for pixel in frame.buffer_mut().chunks_exact_mut(4) {
680 pixel.swap(0, 2);
681 }
682 frames.push(frame);
683 }
684 Err(err) => {
685 log::debug!(
686 "Skipping GIF frame in {source:?} due to decode error: {err}"
687 );
688 }
689 }
690 }
691
692 if frames.is_empty() {
693 return Err(ImageCacheError::Other(Arc::new(anyhow::anyhow!(
694 "GIF could not be decoded: all frames failed ({source:?})"
695 ))));
696 }
697
698 frames
699 }
700 ImageFormat::WebP => {
701 let mut decoder = WebPDecoder::new(Cursor::new(&bytes))?;
702
703 if decoder.has_animation() {
704 let _ = decoder.set_background_color(Rgba([0, 0, 0, 0]));
705 let mut frames = SmallVec::new();
706
707 for frame in decoder.into_frames() {
708 match frame {
709 Ok(mut frame) => {
710 for pixel in frame.buffer_mut().chunks_exact_mut(4) {
712 pixel.swap(0, 2);
713 }
714 frames.push(frame);
715 }
716 Err(err) => {
717 log::debug!(
718 "Skipping WebP frame in {source:?} due to decode error: {err}"
719 );
720 }
721 }
722 }
723
724 if frames.is_empty() {
725 return Err(ImageCacheError::Other(Arc::new(anyhow::anyhow!(
726 "WebP could not be decoded: all frames failed ({source:?})"
727 ))));
728 }
729
730 frames
731 } else {
732 decode_static_image_from_decoder(decoder)?
733 }
734 }
735 _ => decode_static_image(&bytes, format)?,
736 };
737
738 Ok(Arc::new(RenderImage::new(data)))
739 } else {
740 svg_renderer
741 .render_single_frame(&bytes, 1.0)
742 .map_err(Into::into)
743 }
744 }
745 }
746}
747
748#[derive(Debug, Error, Clone)]
750pub enum ImageCacheError {
751 #[error("error: {0}")]
753 Other(#[from] Arc<anyhow::Error>),
754 #[error("IO error: {0}")]
756 Io(Arc<std::io::Error>),
757 #[error("unexpected http status for {uri}: {status}, body: {body}")]
759 BadStatus {
760 uri: SharedUri,
762 status: http_client::StatusCode,
764 body: String,
766 },
767 #[error("asset error: {0}")]
769 Asset(SharedString),
770 #[error("image error: {0}")]
772 Image(Arc<ImageError>),
773 #[error("svg error: {0}")]
775 Usvg(Arc<usvg::Error>),
776}
777
778impl From<anyhow::Error> for ImageCacheError {
779 fn from(value: anyhow::Error) -> Self {
780 Self::Other(Arc::new(value))
781 }
782}
783
784impl From<io::Error> for ImageCacheError {
785 fn from(value: io::Error) -> Self {
786 Self::Io(Arc::new(value))
787 }
788}
789
790impl From<usvg::Error> for ImageCacheError {
791 fn from(value: usvg::Error) -> Self {
792 Self::Usvg(Arc::new(value))
793 }
794}
795
796impl From<image::ImageError> for ImageCacheError {
797 fn from(value: image::ImageError) -> Self {
798 Self::Image(Arc::new(value))
799 }
800}
801
802#[cfg(test)]
803mod tests {
804 use super::*;
805 use crate::{ParentElement as _, TestAppContext, canvas, div, point, px, size};
806 use image::{Frame, ImageBuffer, Rgba};
807
808 const TEST_IMG_ID: &str = "test-img";
809
810 fn test_image(frame_count: usize) -> Arc<RenderImage> {
811 let frame = Frame::new(ImageBuffer::from_pixel(1, 1, Rgba([0, 0, 0, 0])));
812 Arc::new(RenderImage::new(SmallVec::from_iter(
813 (0..frame_count).map(|_| frame.clone()),
814 )))
815 }
816
817 fn test_image_with_size(width: u32, height: u32) -> Arc<RenderImage> {
818 let frame = Frame::new(ImageBuffer::from_pixel(width, height, Rgba([0, 0, 0, 0])));
819 Arc::new(RenderImage::new(SmallVec::from_elem(frame, 1)))
820 }
821
822 fn seed_frame_index(frame_index: usize) -> impl IntoElement {
824 canvas(
825 |_, _, _| (),
826 move |_, _, window, _| {
827 window.with_global_id(TEST_IMG_ID.into(), |id, window| {
828 window.with_element_state::<ImgState, _>(id, |state, _| {
829 let mut state = state.expect("img state should be initialized");
830 state.frame_index = frame_index;
831 ((), state)
832 });
833 });
834 },
835 )
836 }
837
838 #[gpui::test]
839 fn zero_frame_image_does_not_panic_on_paint(cx: &mut TestAppContext) {
840 cx.add_empty_window()
841 .draw(point(px(0.), px(0.)), size(px(100.), px(100.)), |_, _| {
842 img(ImageSource::Render(test_image(0))).into_any_element()
843 });
844 }
845
846 #[gpui::test]
847 fn image_object_fit_cover_crops_to_element_bounds(cx: &mut TestAppContext) {
848 let window = cx.add_empty_window();
849 let image = test_image_with_size(200, 100);
850 window.draw(point(px(0.), px(0.)), size(px(100.), px(100.)), |_, _| {
851 img(ImageSource::Render(image.clone()))
852 .size_full()
853 .object_fit(ObjectFit::Fill)
854 .into_any_element()
855 });
856 let full_tile_bounds = window.update(|window, _| {
857 window
858 .rendered_frame
859 .scene
860 .polychrome_sprites
861 .last()
862 .expect("fill image should paint a sprite")
863 .tile
864 .bounds
865 });
866
867 window.draw(point(px(10.), px(20.)), size(px(100.), px(100.)), |_, _| {
868 img(ImageSource::Render(image))
869 .size_full()
870 .object_fit(ObjectFit::Cover)
871 .into_any_element()
872 });
873
874 let (rendered_bounds, rendered_tile_bounds, scale_factor) = window.update(|window, _| {
875 let sprite = window
876 .rendered_frame
877 .scene
878 .polychrome_sprites
879 .last()
880 .expect("cover image should paint a sprite");
881 (sprite.bounds, sprite.tile.bounds, window.scale_factor())
882 });
883 assert_eq!(
884 rendered_bounds,
885 Bounds {
886 origin: point(px(10.).scale(scale_factor), px(20.).scale(scale_factor)),
887 size: size(px(100.).scale(scale_factor), px(100.).scale(scale_factor)),
888 }
889 );
890 assert_eq!(
891 (
892 rendered_tile_bounds.origin.x.0 - full_tile_bounds.origin.x.0,
893 rendered_tile_bounds.origin.y.0 - full_tile_bounds.origin.y.0,
894 rendered_tile_bounds.size.width.0,
895 rendered_tile_bounds.size.height.0,
896 ),
897 (50, 0, 100, 100),
898 );
899 }
900
901 #[gpui::test]
902 fn explicit_aspect_ratio_is_not_overridden_by_intrinsic_ratio(cx: &mut TestAppContext) {
903 let window = cx.add_empty_window();
904
905 window.draw(point(px(0.), px(0.)), size(px(100.), px(100.)), |_, _| {
907 div()
908 .size(px(100.))
909 .overflow_hidden()
910 .child(
911 img(ImageSource::Render(test_image_with_size(100, 200)))
912 .size_full()
913 .aspect_square()
914 .object_fit(ObjectFit::Contain),
915 )
916 .into_any_element()
917 });
918
919 let (rendered_bounds, scale_factor) = window.update(|window, _| {
920 let sprite = window
921 .rendered_frame
922 .scene
923 .polychrome_sprites
924 .last()
925 .expect("contained image should paint a sprite");
926 (sprite.bounds, window.scale_factor())
927 });
928
929 assert_eq!(
931 rendered_bounds,
932 Bounds {
933 origin: point(px(25.).scale(scale_factor), px(0.).scale(scale_factor)),
934 size: size(px(50.).scale(scale_factor), px(100.).scale(scale_factor)),
935 }
936 );
937 }
938
939 #[gpui::test]
940 fn image_object_fit_cover_clamps_corner_radii_to_visible_bounds(cx: &mut TestAppContext) {
941 let window = cx.add_empty_window();
942 window.draw(point(px(0.), px(0.)), size(px(100.), px(100.)), |_, _| {
943 img(ImageSource::Render(test_image_with_size(200, 100)))
944 .size_full()
945 .rounded(px(100.))
946 .object_fit(ObjectFit::Cover)
947 .into_any_element()
948 });
949
950 let (corner_radius, expected_corner_radius) = window.update(|window, _| {
951 (
952 window
953 .rendered_frame
954 .scene
955 .polychrome_sprites
956 .last()
957 .map(|sprite| sprite.corner_radii.top_left),
958 px(50.).scale(window.scale_factor()),
959 )
960 });
961 assert_eq!(corner_radius, Some(expected_corner_radius));
962 }
963
964 #[gpui::test]
965 fn stale_frame_index_is_clamped_when_image_changes(cx: &mut TestAppContext) {
966 let window = cx.add_empty_window();
967
968 window.draw(point(px(0.), px(0.)), size(px(100.), px(100.)), |_, _| {
972 div()
973 .child(img(ImageSource::Render(test_image(5))).id(TEST_IMG_ID))
974 .child(seed_frame_index(4))
975 .into_any_element()
976 });
977 window.draw(point(px(0.), px(0.)), size(px(100.), px(100.)), |_, _| {
978 img(ImageSource::Render(test_image(1)))
979 .id(TEST_IMG_ID)
980 .into_any_element()
981 });
982 }
983}