From dd74643993b13de285b184d6ab26cf0a16c00162 Mon Sep 17 00:00:00 2001
From: Bennet Bo Fenner <53836821+bennetbo@users.noreply.github.com>
Date: Tue, 30 Jan 2024 06:56:51 +0100
Subject: [PATCH] gpui: Support loading image from filesystem (#6978)
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:
**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.
---
crates/client/src/user.rs | 2 +-
crates/collab/src/tests/integration_tests.rs | 6 +-
crates/collab_ui/src/chat_panel.rs | 4 +-
.../src/chat_panel/message_editor.rs | 6 +-
.../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(-)
create mode 100644 crates/gpui/examples/image.rs
diff --git a/crates/client/src/user.rs b/crates/client/src/user.rs
index e571d2dc1528a2dcac5b42917e01e941f3fb44ef..23cd97994f698baebfc411f29bebfec314c2768e 100644
--- a/crates/client/src/user.rs
+++ b/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),
})
}
}
diff --git a/crates/collab/src/tests/integration_tests.rs b/crates/collab/src/tests/integration_tests.rs
index 625544e4a1572b67ccb09cb52a256631cb5ec88b..6dd1751c8c639979c44ab926cb23cf302736037f 100644
--- a/crates/collab/src/tests/integration_tests.rs
+++ b/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()]
diff --git a/crates/collab_ui/src/chat_panel.rs b/crates/collab_ui/src/chat_panel.rs
index b01cffebe9807b93681a5b60715f300b741b413f..14fab4301b4f2d844c442d1617b50064e6e5a139 100644
--- a/crates/collab_ui/src/chat_panel.rs
+++ b/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,
diff --git a/crates/collab_ui/src/chat_panel/message_editor.rs b/crates/collab_ui/src/chat_panel/message_editor.rs
index 06501fe3fc22d626151602452ee0423c5520202c..48cf4ab405104501ed2194e46f0aef87b57641d8 100644
--- a/crates/collab_ui/src/chat_panel/message_editor.rs
+++ b/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,
diff --git a/crates/collab_ui/src/notifications/stories/collab_notification.rs b/crates/collab_ui/src/notifications/stories/collab_notification.rs
index e67ce817b69db6c4c6e0c24c2093b8cbb708e153..e04a7b39047a2b2838cbaee857fc3267caf4cf0f 100644
--- a/crates/collab_ui/src/notifications/stories/collab_notification.rs
+++ b/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"),
)
diff --git a/crates/gpui/examples/image.rs b/crates/gpui/examples/image.rs
new file mode 100644
index 0000000000000000000000000000000000000000..01a9cfb435b9b65dffb567943fcc308cd4660b3b
--- /dev/null
+++ b/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) -> 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"),
+ })
+ });
+ });
+}
diff --git a/crates/gpui/src/elements/img.rs b/crates/gpui/src/elements/img.rs
index e7377373fe18cd7d6d26a610c2d5b776e02874fc..d1455d33d6346ecc9f4fe2f51a205bb3ab6267db 100644
--- a/crates/gpui/src/elements/img.rs
+++ b/crates/gpui/src/elements/img.rs
@@ -27,18 +27,6 @@ impl From for ImageSource {
}
}
-impl From<&'static str> for ImageSource {
- fn from(uri: &'static str) -> Self {
- Self::Uri(uri.into())
- }
-}
-
-impl From for ImageSource {
- fn from(uri: String) -> Self {
- Self::Uri(uri.into())
- }
-}
-
impl From> for ImageSource {
fn from(value: Arc) -> Self {
Self::Data(value)
diff --git a/crates/gpui/src/image_cache.rs b/crates/gpui/src/image_cache.rs
index 95b41c3b2c0619c24deb4207e41b01d23e615b21..318aea8265b622beccd7946e23ffc28511e5d528 100644
--- a/crates/gpui/src/image_cache.rs
+++ b/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({
diff --git a/crates/gpui/src/shared_url.rs b/crates/gpui/src/shared_url.rs
index 8fb901894367d3fa4d87e78d8b95e1a47df0ce10..d41948239171be93f9745c6ed81a341fcbfc5f74 100644
--- a/crates/gpui/src/shared_url.rs
+++ b/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: S) -> Self {
+ Self::File(s.into())
+ }
+
+ /// Create a URL pointing to a remote resource.
+ pub fn network>(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> From 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())
}
}
diff --git a/crates/theme/src/styles/stories/players.rs b/crates/theme/src/styles/stories/players.rs
index 21af258641760da33e90e20d9089ba0416d48768..8963736e61b4d431aa35c0382e4754aab43c71ce 100644
--- a/crates/theme/src/styles/stories/players.rs
+++ b/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()),
),
)
}),
diff --git a/crates/ui/src/components/stories/avatar.rs b/crates/ui/src/components/stories/avatar.rs
index 9da475b0d9be59d64580b4db416ed99afbb1e402..5820d2b8c9c54ed1698eb46603094cc4d360f76b 100644
--- a/crates/ui/src/components/stories/avatar.rs
+++ b/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)),
)),
)
}
diff --git a/crates/ui/src/components/stories/list_item.rs b/crates/ui/src/components/stories/list_item.rs
index ae7be9b9c74c6ce2084ae84615ae7468ec64dbe0..014b56b422f405353d63203c09f9e8b141039149 100644
--- a/crates/ui/src/components/stories/list_item.rs
+++ b/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",
))),
)