gpui: Support loading image from filesystem (#6978)

Bennet Bo Fenner created

This PR implements support for loading and displaying images from a
local file using gpui's `img` element.

API Changes:
- Changed `SharedUrl` to `SharedUrl::File`, `SharedUrl::Network`

Usage:
```rust
// load from network
img(SharedUrl::network(...)) // previously img(SharedUrl(...)

// load from filesystem
img(SharedUrl::file(...))
```

This will be useful when implementing markdown image support, because we
need to be able to render images from the filesystem (relative/absolute
path), e.g. when implementing markdown preview #5064.

I also added an example `image` to the gpui crate, let me know if this
is useful. Showcase:
<img width="872" alt="image"
src="https://github.com/zed-industries/zed/assets/53836821/b4310a26-db81-44fa-9a7b-61e7d0ad4349">

**Note**: The example is fetching images from [Lorem
Picsum](https://picsum.photos) ([Github
Repo](https://github.com/DMarby/picsum-photos)), which is a free
resource for fetching images in a specific size. Please let me know if
you're okay with using this in the example.

Change summary

crates/client/src/user.rs                                         |  2 
crates/collab/src/tests/integration_tests.rs                      |  6 
crates/collab_ui/src/chat_panel.rs                                |  4 
crates/collab_ui/src/chat_panel/message_editor.rs                 |  6 
crates/collab_ui/src/notifications/stories/collab_notification.rs |  6 
crates/gpui/examples/image.rs                                     | 59 
crates/gpui/src/elements/img.rs                                   | 12 
crates/gpui/src/image_cache.rs                                    | 37 
crates/gpui/src/shared_url.rs                                     | 64 
crates/theme/src/styles/stories/players.rs                        | 42 
crates/ui/src/components/stories/avatar.rs                        | 46 
crates/ui/src/components/stories/list_item.rs                     | 16 
12 files changed, 210 insertions(+), 90 deletions(-)

Detailed changes

crates/client/src/user.rs 🔗

@@ -707,7 +707,7 @@ impl User {
         Arc::new(User {
             id: message.id,
             github_login: message.github_login,
-            avatar_uri: message.avatar_url.into(),
+            avatar_uri: SharedUrl::network(message.avatar_url),
         })
     }
 }

crates/collab/src/tests/integration_tests.rs 🔗

@@ -9,7 +9,7 @@ use fs::{repository::GitFileStatus, FakeFs, Fs as _, RemoveOptions};
 use futures::StreamExt as _;
 use gpui::{
     px, size, AppContext, BackgroundExecutor, Model, Modifiers, MouseButton, MouseDownEvent,
-    TestAppContext,
+    SharedUrl, TestAppContext,
 };
 use language::{
     language_settings::{AllLanguageSettings, Formatter},
@@ -1828,7 +1828,7 @@ async fn test_active_call_events(
             owner: Arc::new(User {
                 id: client_a.user_id().unwrap(),
                 github_login: "user_a".to_string(),
-                avatar_uri: "avatar_a".into(),
+                avatar_uri: SharedUrl::network("avatar_a"),
             }),
             project_id: project_a_id,
             worktree_root_names: vec!["a".to_string()],
@@ -1846,7 +1846,7 @@ async fn test_active_call_events(
             owner: Arc::new(User {
                 id: client_b.user_id().unwrap(),
                 github_login: "user_b".to_string(),
-                avatar_uri: "avatar_b".into(),
+                avatar_uri: SharedUrl::network("avatar_b"),
             }),
             project_id: project_b_id,
             worktree_root_names: vec!["b".to_string()]

crates/collab_ui/src/chat_panel.rs 🔗

@@ -714,7 +714,7 @@ fn format_timestamp(
 #[cfg(test)]
 mod tests {
     use super::*;
-    use gpui::HighlightStyle;
+    use gpui::{HighlightStyle, SharedUrl};
     use pretty_assertions::assert_eq;
     use rich_text::Highlight;
     use time::{Date, OffsetDateTime, Time, UtcOffset};
@@ -730,7 +730,7 @@ mod tests {
             timestamp: OffsetDateTime::now_utc(),
             sender: Arc::new(client::User {
                 github_login: "fgh".into(),
-                avatar_uri: "avatar_fgh".into(),
+                avatar_uri: SharedUrl::network("avatar_fgh"),
                 id: 103,
             }),
             nonce: 5,

crates/collab_ui/src/chat_panel/message_editor.rs 🔗

@@ -365,7 +365,7 @@ impl Render for MessageEditor {
 mod tests {
     use super::*;
     use client::{Client, User, UserStore};
-    use gpui::TestAppContext;
+    use gpui::{SharedUrl, TestAppContext};
     use language::{Language, LanguageConfig};
     use rpc::proto;
     use settings::SettingsStore;
@@ -392,7 +392,7 @@ mod tests {
                         user: Arc::new(User {
                             github_login: "a-b".into(),
                             id: 101,
-                            avatar_uri: "avatar_a-b".into(),
+                            avatar_uri: SharedUrl::network("avatar_a-b"),
                         }),
                         kind: proto::channel_member::Kind::Member,
                         role: proto::ChannelRole::Member,
@@ -401,7 +401,7 @@ mod tests {
                         user: Arc::new(User {
                             github_login: "C_D".into(),
                             id: 102,
-                            avatar_uri: "avatar_C_D".into(),
+                            avatar_uri: SharedUrl::network("avatar_C_D"),
                         }),
                         kind: proto::channel_member::Kind::Member,
                         role: proto::ChannelRole::Member,

crates/collab_ui/src/notifications/stories/collab_notification.rs 🔗

@@ -1,4 +1,4 @@
-use gpui::prelude::*;
+use gpui::{prelude::*, SharedUrl};
 use story::{StoryContainer, StoryItem, StorySection};
 use ui::prelude::*;
 
@@ -19,7 +19,7 @@ impl Render for CollabNotificationStory {
                 "Incoming Call Notification",
                 window_container(400., 72.).child(
                     CollabNotification::new(
-                        "https://avatars.githubusercontent.com/u/1486634?v=4",
+                        SharedUrl::network("https://avatars.githubusercontent.com/u/1486634?v=4"),
                         Button::new("accept", "Accept"),
                         Button::new("decline", "Decline"),
                     )
@@ -36,7 +36,7 @@ impl Render for CollabNotificationStory {
                 "Project Shared Notification",
                 window_container(400., 72.).child(
                     CollabNotification::new(
-                        "https://avatars.githubusercontent.com/u/1714999?v=4",
+                        SharedUrl::network("https://avatars.githubusercontent.com/u/1714999?v=4"),
                         Button::new("open", "Open"),
                         Button::new("dismiss", "Dismiss"),
                     )

crates/gpui/examples/image.rs 🔗

@@ -0,0 +1,59 @@
+use gpui::*;
+
+#[derive(IntoElement)]
+struct ImageFromResource {
+    text: SharedString,
+    resource: SharedUrl,
+}
+
+impl RenderOnce for ImageFromResource {
+    fn render(self, _: &mut WindowContext) -> impl IntoElement {
+        div().child(
+            div()
+                .flex_row()
+                .size_full()
+                .gap_4()
+                .child(self.text)
+                .child(img(self.resource).w(px(512.0)).h(px(512.0))),
+        )
+    }
+}
+
+struct ImageShowcase {
+    local_resource: SharedUrl,
+    remote_resource: SharedUrl,
+}
+
+impl Render for ImageShowcase {
+    fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
+        div()
+            .flex()
+            .flex_row()
+            .size_full()
+            .justify_center()
+            .items_center()
+            .gap_8()
+            .bg(rgb(0xFFFFFF))
+            .child(ImageFromResource {
+                text: "Image loaded from a local file".into(),
+                resource: self.local_resource.clone(),
+            })
+            .child(ImageFromResource {
+                text: "Image loaded from a remote resource".into(),
+                resource: self.remote_resource.clone(),
+            })
+    }
+}
+
+fn main() {
+    env_logger::init();
+
+    App::new().run(|cx: &mut AppContext| {
+        cx.open_window(WindowOptions::default(), |cx| {
+            cx.new_view(|_cx| ImageShowcase {
+                local_resource: SharedUrl::file("../zed/resources/app-icon.png"),
+                remote_resource: SharedUrl::network("https://picsum.photos/512/512"),
+            })
+        });
+    });
+}

crates/gpui/src/elements/img.rs 🔗

@@ -27,18 +27,6 @@ impl From<SharedUrl> for ImageSource {
     }
 }
 
-impl From<&'static str> for ImageSource {
-    fn from(uri: &'static str) -> Self {
-        Self::Uri(uri.into())
-    }
-}
-
-impl From<String> for ImageSource {
-    fn from(uri: String) -> Self {
-        Self::Uri(uri.into())
-    }
-}
-
 impl From<Arc<ImageData>> for ImageSource {
     fn from(value: Arc<ImageData>) -> Self {
         Self::Data(value)

crates/gpui/src/image_cache.rs 🔗

@@ -68,22 +68,31 @@ impl ImageCache {
                         {
                             let uri = uri.clone();
                             async move {
-                                let mut response =
-                                    client.get(uri.as_ref(), ().into(), true).await?;
-                                let mut body = Vec::new();
-                                response.body_mut().read_to_end(&mut body).await?;
+                                match uri {
+                                    SharedUrl::File(uri) => {
+                                        let image = image::open(uri.as_ref())?.into_bgra8();
+                                        Ok(Arc::new(ImageData::new(image)))
+                                    }
+                                    SharedUrl::Network(uri) => {
+                                        let mut response =
+                                            client.get(uri.as_ref(), ().into(), true).await?;
+                                        let mut body = Vec::new();
+                                        response.body_mut().read_to_end(&mut body).await?;
 
-                                if !response.status().is_success() {
-                                    return Err(Error::BadStatus {
-                                        status: response.status(),
-                                        body: String::from_utf8_lossy(&body).into_owned(),
-                                    });
-                                }
+                                        if !response.status().is_success() {
+                                            return Err(Error::BadStatus {
+                                                status: response.status(),
+                                                body: String::from_utf8_lossy(&body).into_owned(),
+                                            });
+                                        }
 
-                                let format = image::guess_format(&body)?;
-                                let image = image::load_from_memory_with_format(&body, format)?
-                                    .into_bgra8();
-                                Ok(Arc::new(ImageData::new(image)))
+                                        let format = image::guess_format(&body)?;
+                                        let image =
+                                            image::load_from_memory_with_format(&body, format)?
+                                                .into_bgra8();
+                                        Ok(Arc::new(ImageData::new(image)))
+                                    }
+                                }
                             }
                         }
                         .map_err({

crates/gpui/src/shared_url.rs 🔗

@@ -1,25 +1,65 @@
-use derive_more::{Deref, DerefMut};
+use std::ops::{Deref, DerefMut};
 
 use crate::SharedString;
 
-/// A [`SharedString`] containing a URL.
-#[derive(Deref, DerefMut, Default, PartialEq, Eq, Hash, Clone)]
-pub struct SharedUrl(SharedString);
+/// A URL stored in a `SharedString` pointing to a file or a remote resource.
+#[derive(PartialEq, Eq, Hash, Clone)]
+pub enum SharedUrl {
+    /// A path to a local file.
+    File(SharedString),
+    /// A URL to a remote resource.
+    Network(SharedString),
+}
 
-impl std::fmt::Debug for SharedUrl {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        self.0.fmt(f)
+impl SharedUrl {
+    /// Create a URL pointing to a local file.
+    pub fn file<S: Into<SharedString>>(s: S) -> Self {
+        Self::File(s.into())
+    }
+
+    /// Create a URL pointing to a remote resource.
+    pub fn network<S: Into<SharedString>>(s: S) -> Self {
+        Self::Network(s.into())
     }
 }
 
-impl std::fmt::Display for SharedUrl {
+impl Default for SharedUrl {
+    fn default() -> Self {
+        Self::Network(SharedString::default())
+    }
+}
+
+impl Deref for SharedUrl {
+    type Target = SharedString;
+
+    fn deref(&self) -> &Self::Target {
+        match self {
+            Self::File(s) => s,
+            Self::Network(s) => s,
+        }
+    }
+}
+
+impl DerefMut for SharedUrl {
+    fn deref_mut(&mut self) -> &mut Self::Target {
+        match self {
+            Self::File(s) => s,
+            Self::Network(s) => s,
+        }
+    }
+}
+
+impl std::fmt::Debug for SharedUrl {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        write!(f, "{}", self.0.as_ref())
+        match self {
+            Self::File(s) => write!(f, "File({:?})", s),
+            Self::Network(s) => write!(f, "Network({:?})", s),
+        }
     }
 }
 
-impl<T: Into<SharedString>> From<T> for SharedUrl {
-    fn from(value: T) -> Self {
-        Self(value.into())
+impl std::fmt::Display for SharedUrl {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(f, "{}", self.as_ref())
     }
 }

crates/theme/src/styles/stories/players.rs 🔗

@@ -1,4 +1,4 @@
-use gpui::{div, img, px, IntoElement, ParentElement, Render, Styled, ViewContext};
+use gpui::{div, img, px, IntoElement, ParentElement, Render, SharedUrl, Styled, ViewContext};
 use story::Story;
 
 use crate::{ActiveTheme, PlayerColors};
@@ -53,10 +53,12 @@ impl Render for PlayerStory {
                             .border_2()
                             .border_color(player.cursor)
                             .child(
-                                img("https://avatars.githubusercontent.com/u/1714999?v=4")
-                                    .rounded_full()
-                                    .size_6()
-                                    .bg(gpui::red()),
+                                img(SharedUrl::network(
+                                    "https://avatars.githubusercontent.com/u/1714999?v=4",
+                                ))
+                                .rounded_full()
+                                .size_6()
+                                .bg(gpui::red()),
                             )
                     }),
                 ))
@@ -82,10 +84,12 @@ impl Render for PlayerStory {
                                     .border_color(player.background)
                                     .size(px(28.))
                                     .child(
-                                        img("https://avatars.githubusercontent.com/u/1714999?v=4")
-                                            .rounded_full()
-                                            .size(px(24.))
-                                            .bg(gpui::red()),
+                                        img(SharedUrl::network(
+                                            "https://avatars.githubusercontent.com/u/1714999?v=4",
+                                        ))
+                                        .rounded_full()
+                                        .size(px(24.))
+                                        .bg(gpui::red()),
                                     ),
                             )
                             .child(
@@ -98,10 +102,12 @@ impl Render for PlayerStory {
                                     .border_color(player.background)
                                     .size(px(28.))
                                     .child(
-                                        img("https://avatars.githubusercontent.com/u/1714999?v=4")
-                                            .rounded_full()
-                                            .size(px(24.))
-                                            .bg(gpui::red()),
+                                        img(SharedUrl::network(
+                                            "https://avatars.githubusercontent.com/u/1714999?v=4",
+                                        ))
+                                        .rounded_full()
+                                        .size(px(24.))
+                                        .bg(gpui::red()),
                                     ),
                             )
                             .child(
@@ -114,10 +120,12 @@ impl Render for PlayerStory {
                                     .border_color(player.background)
                                     .size(px(28.))
                                     .child(
-                                        img("https://avatars.githubusercontent.com/u/1714999?v=4")
-                                            .rounded_full()
-                                            .size(px(24.))
-                                            .bg(gpui::red()),
+                                        img(SharedUrl::network(
+                                            "https://avatars.githubusercontent.com/u/1714999?v=4",
+                                        ))
+                                        .rounded_full()
+                                        .size(px(24.))
+                                        .bg(gpui::red()),
                                     ),
                             )
                     }),

crates/ui/src/components/stories/avatar.rs 🔗

@@ -1,4 +1,4 @@
-use gpui::Render;
+use gpui::{Render, SharedUrl};
 use story::{StoryContainer, StoryItem, StorySection};
 
 use crate::{prelude::*, AudioStatus, Availability, AvatarAvailabilityIndicator};
@@ -13,50 +13,66 @@ impl Render for AvatarStory {
                 StorySection::new()
                     .child(StoryItem::new(
                         "Default",
-                        Avatar::new("https://avatars.githubusercontent.com/u/1714999?v=4"),
+                        Avatar::new(SharedUrl::network(
+                            "https://avatars.githubusercontent.com/u/1714999?v=4",
+                        )),
                     ))
                     .child(StoryItem::new(
                         "Default",
-                        Avatar::new("https://avatars.githubusercontent.com/u/326587?v=4"),
+                        Avatar::new(SharedUrl::network(
+                            "https://avatars.githubusercontent.com/u/326587?v=4",
+                        )),
                     )),
             )
             .child(
                 StorySection::new()
                     .child(StoryItem::new(
                         "With free availability indicator",
-                        Avatar::new("https://avatars.githubusercontent.com/u/326587?v=4")
-                            .indicator(AvatarAvailabilityIndicator::new(Availability::Free)),
+                        Avatar::new(SharedUrl::network(
+                            "https://avatars.githubusercontent.com/u/326587?v=4",
+                        ))
+                        .indicator(AvatarAvailabilityIndicator::new(Availability::Free)),
                     ))
                     .child(StoryItem::new(
                         "With busy availability indicator",
-                        Avatar::new("https://avatars.githubusercontent.com/u/326587?v=4")
-                            .indicator(AvatarAvailabilityIndicator::new(Availability::Busy)),
+                        Avatar::new(SharedUrl::network(
+                            "https://avatars.githubusercontent.com/u/326587?v=4",
+                        ))
+                        .indicator(AvatarAvailabilityIndicator::new(Availability::Busy)),
                     )),
             )
             .child(
                 StorySection::new()
                     .child(StoryItem::new(
                         "With info border",
-                        Avatar::new("https://avatars.githubusercontent.com/u/326587?v=4")
-                            .border_color(cx.theme().status().info_border),
+                        Avatar::new(SharedUrl::network(
+                            "https://avatars.githubusercontent.com/u/326587?v=4",
+                        ))
+                        .border_color(cx.theme().status().info_border),
                     ))
                     .child(StoryItem::new(
                         "With error border",
-                        Avatar::new("https://avatars.githubusercontent.com/u/326587?v=4")
-                            .border_color(cx.theme().status().error_border),
+                        Avatar::new(SharedUrl::network(
+                            "https://avatars.githubusercontent.com/u/326587?v=4",
+                        ))
+                        .border_color(cx.theme().status().error_border),
                     )),
             )
             .child(
                 StorySection::new()
                     .child(StoryItem::new(
                         "With muted audio indicator",
-                        Avatar::new("https://avatars.githubusercontent.com/u/326587?v=4")
-                            .indicator(AvatarAudioStatusIndicator::new(AudioStatus::Muted)),
+                        Avatar::new(SharedUrl::network(
+                            "https://avatars.githubusercontent.com/u/326587?v=4",
+                        ))
+                        .indicator(AvatarAudioStatusIndicator::new(AudioStatus::Muted)),
                     ))
                     .child(StoryItem::new(
                         "With deafened audio indicator",
-                        Avatar::new("https://avatars.githubusercontent.com/u/326587?v=4")
-                            .indicator(AvatarAudioStatusIndicator::new(AudioStatus::Deafened)),
+                        Avatar::new(SharedUrl::network(
+                            "https://avatars.githubusercontent.com/u/326587?v=4",
+                        ))
+                        .indicator(AvatarAudioStatusIndicator::new(AudioStatus::Deafened)),
                     )),
             )
     }

crates/ui/src/components/stories/list_item.rs 🔗

@@ -45,7 +45,7 @@ impl Render for ListItemStory {
             .child(
                 ListItem::new("with_start slot avatar")
                     .child("Hello, world!")
-                    .start_slot(Avatar::new(SharedUrl::from(
+                    .start_slot(Avatar::new(SharedUrl::network(
                         "https://avatars.githubusercontent.com/u/1714999?v=4",
                     ))),
             )
@@ -53,7 +53,7 @@ impl Render for ListItemStory {
             .child(
                 ListItem::new("with_left_avatar")
                     .child("Hello, world!")
-                    .end_slot(Avatar::new(SharedUrl::from(
+                    .end_slot(Avatar::new(SharedUrl::network(
                         "https://avatars.githubusercontent.com/u/1714999?v=4",
                     ))),
             )
@@ -64,23 +64,23 @@ impl Render for ListItemStory {
                     .end_slot(
                         h_flex()
                             .gap_2()
-                            .child(Avatar::new(SharedUrl::from(
+                            .child(Avatar::new(SharedUrl::network(
                                 "https://avatars.githubusercontent.com/u/1789?v=4",
                             )))
-                            .child(Avatar::new(SharedUrl::from(
+                            .child(Avatar::new(SharedUrl::network(
                                 "https://avatars.githubusercontent.com/u/1789?v=4",
                             )))
-                            .child(Avatar::new(SharedUrl::from(
+                            .child(Avatar::new(SharedUrl::network(
                                 "https://avatars.githubusercontent.com/u/1789?v=4",
                             )))
-                            .child(Avatar::new(SharedUrl::from(
+                            .child(Avatar::new(SharedUrl::network(
                                 "https://avatars.githubusercontent.com/u/1789?v=4",
                             )))
-                            .child(Avatar::new(SharedUrl::from(
+                            .child(Avatar::new(SharedUrl::network(
                                 "https://avatars.githubusercontent.com/u/1789?v=4",
                             ))),
                     )
-                    .end_hover_slot(Avatar::new(SharedUrl::from(
+                    .end_hover_slot(Avatar::new(SharedUrl::network(
                         "https://avatars.githubusercontent.com/u/1714999?v=4",
                     ))),
             )