Detailed changes
@@ -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",
@@ -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" }
@@ -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.
+ }
+ },
}
})
}
@@ -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::*;
@@ -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>),
}
@@ -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
@@ -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 = ?
+ }
+ }
+ }
+}
@@ -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>>,
@@ -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
@@ -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,