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:
<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.
This commit is contained in:
Bennet Bo Fenner 2024-01-30 06:56:51 +01:00 committed by GitHub
parent b865db455d
commit dd74643993
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 210 additions and 90 deletions

View file

@ -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),
})
}
}

View file

@ -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()]

View file

@ -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,

View file

@ -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,

View file

@ -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"),
)

View file

@ -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"),
})
});
});
}

View file

@ -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)

View file

@ -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({

View file

@ -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 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 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 {
self.0.fmt(f)
match self {
Self::File(s) => write!(f, "File({:?})", s),
Self::Network(s) => write!(f, "Network({:?})", s),
}
}
}
impl std::fmt::Display for SharedUrl {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0.as_ref())
}
}
impl<T: Into<SharedString>> From<T> for SharedUrl {
fn from(value: T) -> Self {
Self(value.into())
write!(f, "{}", self.as_ref())
}
}

View file

@ -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()),
),
)
}),

View file

@ -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)),
)),
)
}

View file

@ -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",
))),
)