Image viewer (#9425)

Kyle Kelley and Mikayla Maki created

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

<img width="1840" alt="image"
src="https://github.com/zed-industries/zed/assets/836375/3bccfa8e-aa5c-421f-9dfa-671caa274c3c">

---------

Co-authored-by: Mikayla Maki <mikayla@zed.dev>

Change summary

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

Detailed changes

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

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" }

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<Arc<PathBuf>> for ImageSource {
     }
 }
 
+impl From<PathBuf> for ImageSource {
+    fn from(value: PathBuf) -> Self {
+        Self::File(value.into())
+    }
+}
+
 impl From<Arc<ImageData>> for ImageSource {
     fn from(value: Arc<ImageData>) -> Self {
         Self::Data(value)
@@ -63,6 +69,44 @@ impl From<CVImageBuffer> for ImageSource {
     }
 }
 
+impl ImageSource {
+    fn data(&self, cx: &mut ElementContext) -> Option<Arc<ImageData>> {
+        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<Hitbox>;
 
     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.
+                        }
+                    },
                 }
             })
     }

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::*;

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

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

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<Project>,
+        path: &ProjectPath,
+        cx: &mut AppContext,
+    ) -> Option<Task<gpui::Result<Model<Self>>>> {
+        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<ProjectEntryId> {
+        None
+    }
+
+    fn project_path(&self, _: &AppContext) -> Option<ProjectPath> {
+        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<usize>,
+        _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<Self>) {
+        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<Project>,
+        _workspace: WeakView<Workspace>,
+        workspace_id: WorkspaceId,
+        item_id: ItemId,
+        cx: &mut ViewContext<Pane>,
+    ) -> Task<anyhow::Result<View<Self>>> {
+        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<Self>,
+    ) -> Option<View<Self>>
+    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<Self>) -> 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<Project>,
+        item: Model<Self::Item>,
+        cx: &mut ViewContext<Self>,
+    ) -> 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::<ImageView>(cx);
+    workspace::register_deserializable_item::<ImageView>(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<WorkspaceDb> =
+            &[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<Option<PathBuf>> {
+                SELECT image_path
+                FROM image_viewers
+                WHERE item_id = ? AND workspace_id = ?
+            }
+        }
+    }
+}

crates/workspace/src/pane.rs 🔗

@@ -172,7 +172,7 @@ pub struct Pane {
     new_item_menu: Option<View<ContextMenu>>,
     split_item_menu: Option<View<ContextMenu>>,
     //     tab_context_menu: View<ContextMenu>,
-    workspace: WeakView<Workspace>,
+    pub(crate) workspace: WeakView<Workspace>,
     project: Model<Project>,
     drag_split_direction: Option<SplitDirection>,
     can_drop_predicate: Option<Arc<dyn Fn(&dyn Any, &mut WindowContext) -> bool>>,

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

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,