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:
parent
b865db455d
commit
dd74643993
12 changed files with 210 additions and 90 deletions
|
@ -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),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()]
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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"),
|
||||
)
|
||||
|
|
59
crates/gpui/examples/image.rs
Normal file
59
crates/gpui/examples/image.rs
Normal 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"),
|
||||
})
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()),
|
||||
),
|
||||
)
|
||||
}),
|
||||
|
|
|
@ -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)),
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
))),
|
||||
)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue