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: image **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", ))), )