From 56bd96bc64f84fdd782eefb2c02c29500913487c Mon Sep 17 00:00:00 2001 From: Kyle Kelley Date: Tue, 19 Mar 2024 10:13:10 -0700 Subject: [PATCH] Image viewer (#9425) This builds on #9353 by adding an image viewer to Zed. Closes #5251. Release Notes: - Added support for rendering image files ([#5251](https://github.com/zed-industries/zed/issues/5251)). image --------- Co-authored-by: Mikayla Maki --- Cargo.lock | 14 ++ Cargo.toml | 2 + crates/gpui/src/elements/img.rs | 127 +++++++---- crates/gpui/src/gpui.rs | 2 +- crates/gpui/src/image_cache.rs | 4 +- crates/image_viewer/Cargo.toml | 22 ++ crates/image_viewer/src/image_viewer.rs | 291 ++++++++++++++++++++++++ crates/workspace/src/pane.rs | 2 +- crates/zed/Cargo.toml | 1 + crates/zed/src/main.rs | 2 + 10 files changed, 422 insertions(+), 45 deletions(-) create mode 100644 crates/image_viewer/Cargo.toml create mode 100644 crates/image_viewer/src/image_viewer.rs diff --git a/Cargo.lock b/Cargo.lock index 9c1188b406..383cdddc51 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4784,6 +4784,19 @@ dependencies = [ "tiff", ] +[[package]] +name = "image_viewer" +version = "0.1.0" +dependencies = [ + "anyhow", + "db", + "gpui", + "project", + "ui", + "util", + "workspace", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -12691,6 +12704,7 @@ dependencies = [ "futures 0.3.28", "go_to_line", "gpui", + "image_viewer", "install_cli", "isahc", "journal", diff --git a/Cargo.toml b/Cargo.toml index ff1fe93e4f..e30174873f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ members = [ "crates/go_to_line", "crates/gpui", "crates/gpui_macros", + "crates/image_viewer", "crates/install_cli", "crates/journal", "crates/language", @@ -140,6 +141,7 @@ go_to_line = { path = "crates/go_to_line" } gpui = { path = "crates/gpui" } gpui_macros = { path = "crates/gpui_macros" } install_cli = { path = "crates/install_cli" } +image_viewer = { path = "crates/image_viewer" } journal = { path = "crates/journal" } language = { path = "crates/language" } language_selector = { path = "crates/language_selector" } diff --git a/crates/gpui/src/elements/img.rs b/crates/gpui/src/elements/img.rs index 2ed8c09360..953e45d0a0 100644 --- a/crates/gpui/src/elements/img.rs +++ b/crates/gpui/src/elements/img.rs @@ -2,9 +2,9 @@ use std::path::PathBuf; use std::sync::Arc; use crate::{ - point, size, Bounds, DevicePixels, Element, ElementContext, Hitbox, ImageData, - InteractiveElement, Interactivity, IntoElement, LayoutId, Pixels, SharedUri, Size, - StyleRefinement, Styled, UriOrPath, + point, px, size, AbsoluteLength, Bounds, DefiniteLength, DevicePixels, Element, ElementContext, + Hitbox, ImageData, InteractiveElement, Interactivity, IntoElement, LayoutId, Length, Pixels, + SharedUri, Size, StyleRefinement, Styled, UriOrPath, }; use futures::FutureExt; #[cfg(target_os = "macos")] @@ -50,6 +50,12 @@ impl From> for ImageSource { } } +impl From for ImageSource { + fn from(value: PathBuf) -> Self { + Self::File(value.into()) + } +} + impl From> for ImageSource { fn from(value: Arc) -> Self { Self::Data(value) @@ -63,6 +69,44 @@ impl From for ImageSource { } } +impl ImageSource { + fn data(&self, cx: &mut ElementContext) -> Option> { + match self { + ImageSource::Uri(_) | ImageSource::File(_) => { + let uri_or_path: UriOrPath = match self { + ImageSource::Uri(uri) => uri.clone().into(), + ImageSource::File(path) => path.clone().into(), + _ => unreachable!(), + }; + + let image_future = cx.image_cache.get(uri_or_path.clone(), cx); + if let Some(data) = image_future + .clone() + .now_or_never() + .and_then(|result| result.ok()) + { + return Some(data); + } else { + cx.spawn(|mut cx| async move { + if image_future.await.ok().is_some() { + cx.on_next_frame(|cx| cx.refresh()); + } + }) + .detach(); + + return None; + } + } + + ImageSource::Data(data) => { + return Some(data.clone()); + } + #[cfg(target_os = "macos")] + ImageSource::Surface(_) => None, + } + } +} + /// An image element. pub struct Img { interactivity: Interactivity, @@ -174,9 +218,25 @@ impl Element for Img { type AfterLayout = Option; fn before_layout(&mut self, cx: &mut ElementContext) -> (LayoutId, Self::BeforeLayout) { - let layout_id = self - .interactivity - .before_layout(cx, |style, cx| cx.request_layout(&style, [])); + let layout_id = self.interactivity.before_layout(cx, |mut style, cx| { + if let Some(data) = self.source.data(cx) { + let image_size = data.size(); + match (style.size.width, style.size.height) { + (Length::Auto, Length::Auto) => { + style.size = Size { + width: Length::Definite(DefiniteLength::Absolute( + AbsoluteLength::Pixels(px(image_size.width.0 as f32)), + )), + height: Length::Definite(DefiniteLength::Absolute( + AbsoluteLength::Pixels(px(image_size.height.0 as f32)), + )), + } + } + _ => {} + } + } + cx.request_layout(&style, []) + }); (layout_id, ()) } @@ -201,46 +261,29 @@ impl Element for Img { self.interactivity .paint(bounds, hitbox.as_ref(), cx, |style, cx| { let corner_radii = style.corner_radii.to_pixels(bounds.size, cx.rem_size()); - match source { - ImageSource::Uri(_) | ImageSource::File(_) => { - let uri_or_path: UriOrPath = match source { - ImageSource::Uri(uri) => uri.into(), - ImageSource::File(path) => path.into(), - _ => unreachable!(), - }; - let image_future = cx.image_cache.get(uri_or_path.clone(), cx); - if let Some(data) = image_future - .clone() - .now_or_never() - .and_then(|result| result.ok()) - { - let new_bounds = self.object_fit.get_bounds(bounds, data.size()); - cx.paint_image(new_bounds, corner_radii, data, self.grayscale) - .log_err(); - } else { - cx.spawn(|mut cx| async move { - if image_future.await.ok().is_some() { - cx.on_next_frame(|cx| cx.refresh()); - } - }) - .detach(); - } - } - - ImageSource::Data(data) => { - let new_bounds = self.object_fit.get_bounds(bounds, data.size()); - cx.paint_image(new_bounds, corner_radii, data, self.grayscale) + match source.data(cx) { + Some(data) => { + let bounds = self.object_fit.get_bounds(bounds, data.size()); + cx.paint_image(bounds, corner_radii, data, self.grayscale) .log_err(); } - - #[cfg(target_os = "macos")] - ImageSource::Surface(surface) => { - let size = size(surface.width().into(), surface.height().into()); - let new_bounds = self.object_fit.get_bounds(bounds, size); - // TODO: Add support for corner_radii and grayscale. - cx.paint_surface(new_bounds, surface); + #[cfg(not(target_os = "macos"))] + None => { + // No renderable image loaded yet. Do nothing. } + #[cfg(target_os = "macos")] + None => match source { + ImageSource::Surface(surface) => { + let size = size(surface.width().into(), surface.height().into()); + let new_bounds = self.object_fit.get_bounds(bounds, size); + // TODO: Add support for corner_radii and grayscale. + cx.paint_surface(new_bounds, surface); + } + _ => { + // No renderable image loaded yet. Do nothing. + } + }, } }) } diff --git a/crates/gpui/src/gpui.rs b/crates/gpui/src/gpui.rs index b3a30a305f..d9365c282a 100644 --- a/crates/gpui/src/gpui.rs +++ b/crates/gpui/src/gpui.rs @@ -125,7 +125,7 @@ pub use elements::*; pub use executor::*; pub use geometry::*; pub use gpui_macros::{register_action, test, IntoElement, Render}; -use image_cache::*; +pub use image_cache::*; pub use input::*; pub use interactive::*; use key_dispatch::*; diff --git a/crates/gpui/src/image_cache.rs b/crates/gpui/src/image_cache.rs index cffa5f637b..b48701de8b 100644 --- a/crates/gpui/src/image_cache.rs +++ b/crates/gpui/src/image_cache.rs @@ -8,6 +8,8 @@ use std::sync::Arc; use thiserror::Error; use util::http::{self, HttpClient}; +pub use image::ImageFormat; + #[derive(PartialEq, Eq, Hash, Clone)] pub(crate) struct RenderImageParams { pub(crate) image_id: ImageId, @@ -46,7 +48,7 @@ pub(crate) struct ImageCache { } #[derive(Debug, PartialEq, Eq, Hash, Clone)] -pub enum UriOrPath { +pub(crate) enum UriOrPath { Uri(SharedUri), Path(Arc), } diff --git a/crates/image_viewer/Cargo.toml b/crates/image_viewer/Cargo.toml new file mode 100644 index 0000000000..7b9e0d9db3 --- /dev/null +++ b/crates/image_viewer/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "image_viewer" +version = "0.1.0" +edition = "2021" +publish = false +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/image_viewer.rs" +doctest = false + +[dependencies] +anyhow.workspace = true +db.workspace = true +gpui.workspace = true +ui.workspace = true +util.workspace = true +workspace.workspace = true +project.workspace = true diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs new file mode 100644 index 0000000000..d77389098a --- /dev/null +++ b/crates/image_viewer/src/image_viewer.rs @@ -0,0 +1,291 @@ +use gpui::{ + canvas, div, fill, img, opaque_grey, point, size, AnyElement, AppContext, Bounds, Context, + Element, EventEmitter, FocusHandle, FocusableView, InteractiveElement, IntoElement, Model, + ParentElement, Render, Styled, Task, View, ViewContext, VisualContext, WeakView, WindowContext, +}; +use persistence::IMAGE_VIEWER; +use ui::{h_flex, prelude::*}; + +use project::{Project, ProjectEntryId, ProjectPath}; +use std::{ffi::OsStr, path::PathBuf}; +use util::ResultExt; +use workspace::{ + item::{Item, ProjectItem}, + ItemId, Pane, Workspace, WorkspaceId, +}; + +const IMAGE_VIEWER_KIND: &str = "ImageView"; + +pub struct ImageItem { + path: PathBuf, + project_path: ProjectPath, +} + +impl project::Item for ImageItem { + fn try_open( + project: &Model, + path: &ProjectPath, + cx: &mut AppContext, + ) -> Option>>> { + let path = path.clone(); + let project = project.clone(); + + let ext = path + .path + .extension() + .and_then(OsStr::to_str) + .unwrap_or_default(); + + let format = gpui::ImageFormat::from_extension(ext); + if format.is_some() { + Some(cx.spawn(|mut cx| async move { + let abs_path = project + .read_with(&cx, |project, cx| project.absolute_path(&path, cx))? + .ok_or_else(|| anyhow::anyhow!("Failed to find the absolute path"))?; + + cx.new_model(|_| ImageItem { + path: abs_path, + project_path: path, + }) + })) + } else { + None + } + } + + fn entry_id(&self, _: &AppContext) -> Option { + None + } + + fn project_path(&self, _: &AppContext) -> Option { + Some(self.project_path.clone()) + } +} + +pub struct ImageView { + path: PathBuf, + focus_handle: FocusHandle, +} + +impl Item for ImageView { + type Event = (); + + fn tab_content( + &self, + _detail: Option, + _selected: bool, + _cx: &WindowContext, + ) -> AnyElement { + self.path + .file_name() + .unwrap_or_else(|| self.path.as_os_str()) + .to_string_lossy() + .to_string() + .into_any_element() + } + + fn added_to_workspace(&mut self, workspace: &mut Workspace, cx: &mut ViewContext) { + let item_id = cx.entity_id().as_u64(); + let workspace_id = workspace.database_id(); + let image_path = self.path.clone(); + + cx.background_executor() + .spawn({ + let image_path = image_path.clone(); + async move { + IMAGE_VIEWER + .save_image_path(item_id, workspace_id, image_path) + .await + .log_err(); + } + }) + .detach(); + } + + fn serialized_item_kind() -> Option<&'static str> { + Some(IMAGE_VIEWER_KIND) + } + + fn deserialize( + _project: Model, + _workspace: WeakView, + workspace_id: WorkspaceId, + item_id: ItemId, + cx: &mut ViewContext, + ) -> Task>> { + cx.spawn(|_pane, mut cx| async move { + let image_path = IMAGE_VIEWER + .get_image_path(item_id, workspace_id)? + .ok_or_else(|| anyhow::anyhow!("No image path found"))?; + + cx.new_view(|cx| ImageView { + path: image_path, + focus_handle: cx.focus_handle(), + }) + }) + } + + fn clone_on_split( + &self, + _workspace_id: WorkspaceId, + cx: &mut ViewContext, + ) -> Option> + where + Self: Sized, + { + Some(cx.new_view(|cx| Self { + path: self.path.clone(), + focus_handle: cx.focus_handle(), + })) + } +} + +impl EventEmitter<()> for ImageView {} +impl FocusableView for ImageView { + fn focus_handle(&self, _cx: &AppContext) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for ImageView { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + let im = img(self.path.clone()).into_any(); + + div() + .track_focus(&self.focus_handle) + .size_full() + .child( + // Checkered background behind the image + canvas( + |_, _| (), + |bounds, _, cx| { + let square_size = 32.0; + + let start_y = bounds.origin.y.0; + let height = bounds.size.height.0; + let start_x = bounds.origin.x.0; + let width = bounds.size.width.0; + + let mut y = start_y; + let mut x = start_x; + let mut color_swapper = true; + // draw checkerboard pattern + while y <= start_y + height { + // Keeping track of the grid in order to be resilient to resizing + let start_swap = color_swapper; + while x <= start_x + width { + let rect = Bounds::new( + point(px(x), px(y)), + size(px(square_size), px(square_size)), + ); + + let color = if color_swapper { + opaque_grey(0.6, 0.4) + } else { + opaque_grey(0.7, 0.4) + }; + + cx.paint_quad(fill(rect, color)); + color_swapper = !color_swapper; + x += square_size; + } + x = start_x; + color_swapper = !start_swap; + y += square_size; + } + }, + ) + .border_2() + .border_color(cx.theme().styles.colors.border) + .size_full() + .absolute() + .top_0() + .left_0(), + ) + .child( + v_flex() + .h_full() + .justify_around() + .child(h_flex().w_full().justify_around().child(im)), + ) + } +} + +impl ProjectItem for ImageView { + type Item = ImageItem; + + fn for_project_item( + _project: Model, + item: Model, + cx: &mut ViewContext, + ) -> Self + where + Self: Sized, + { + Self { + path: item.read(cx).path.clone(), + focus_handle: cx.focus_handle(), + } + } +} + +pub fn init(cx: &mut AppContext) { + workspace::register_project_item::(cx); + workspace::register_deserializable_item::(cx) +} + +mod persistence { + use std::path::PathBuf; + + use db::{define_connection, query, sqlez_macros::sql}; + use workspace::{ItemId, WorkspaceDb, WorkspaceId}; + + define_connection! { + pub static ref IMAGE_VIEWER: ImageViewerDb = + &[sql!( + CREATE TABLE image_viewers ( + workspace_id INTEGER, + item_id INTEGER UNIQUE, + + image_path BLOB, + + PRIMARY KEY(workspace_id, item_id), + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) + ON DELETE CASCADE + ) STRICT; + )]; + } + + impl ImageViewerDb { + query! { + pub async fn update_workspace_id( + new_id: WorkspaceId, + old_id: WorkspaceId, + item_id: ItemId + ) -> Result<()> { + UPDATE image_viewers + SET workspace_id = ? + WHERE workspace_id = ? AND item_id = ? + } + } + + query! { + pub async fn save_image_path( + item_id: ItemId, + workspace_id: WorkspaceId, + image_path: PathBuf + ) -> Result<()> { + INSERT OR REPLACE INTO image_viewers(item_id, workspace_id, image_path) + VALUES (?, ?, ?) + } + } + + query! { + pub fn get_image_path(item_id: ItemId, workspace_id: WorkspaceId) -> Result> { + SELECT image_path + FROM image_viewers + WHERE item_id = ? AND workspace_id = ? + } + } + } +} diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index fe13476512..ff6063b4e8 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -172,7 +172,7 @@ pub struct Pane { new_item_menu: Option>, split_item_menu: Option>, // tab_context_menu: View, - workspace: WeakView, + pub(crate) workspace: WeakView, project: Model, drag_split_direction: Option, can_drop_predicate: Option bool>>, diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 90af8ecda5..f39bc5c3f1 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -46,6 +46,7 @@ fs.workspace = true futures.workspace = true go_to_line.workspace = true gpui.workspace = true +image_viewer.workspace = true install_cli.workspace = true isahc.workspace = true journal.workspace = true diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index f222a850b2..9746b97dba 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -15,6 +15,7 @@ use env_logger::Builder; use fs::RealFs; use futures::{future, StreamExt}; use gpui::{App, AppContext, AsyncAppContext, Context, SemanticVersion, Task}; +use image_viewer; use isahc::{prelude::Configurable, Request}; use language::LanguageRegistry; use log::LevelFilter; @@ -165,6 +166,7 @@ fn main() { command_palette::init(cx); language::init(cx); editor::init(cx); + image_viewer::init(cx); diagnostics::init(cx); copilot::init( copilot_language_server_id,