mod image_info; mod image_viewer_settings; use std::path::Path; use anyhow::Context as _; use editor::{EditorSettings, items::entry_git_aware_label_color}; use file_icons::FileIcons; use gpui::{ AnyElement, App, Bounds, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, Styled, Task, WeakEntity, Window, canvas, div, fill, img, opaque_grey, point, size, }; use language::{DiskState, File as _}; use persistence::IMAGE_VIEWER; use project::{ImageItem, Project, ProjectPath, image_store::ImageItemEvent}; use settings::Settings; use theme::{Theme, ThemeSettings}; use ui::prelude::*; use util::paths::PathExt; use workspace::{ ItemId, ItemSettings, Pane, ToolbarItemLocation, Workspace, WorkspaceId, delete_unloaded_items, invalid_item_view::InvalidItemView, item::{BreadcrumbText, Item, ProjectItem, SerializableItem, TabContentParams}, }; pub use crate::image_info::*; pub use crate::image_viewer_settings::*; pub struct ImageView { image_item: Entity, project: Entity, focus_handle: FocusHandle, } impl ImageView { pub fn new( image_item: Entity, project: Entity, window: &mut Window, cx: &mut Context, ) -> Self { cx.subscribe(&image_item, Self::on_image_event).detach(); cx.on_release_in(window, |this, window, cx| { let image_data = this.image_item.read(cx).image.clone(); if let Some(image) = image_data.clone().get_render_image(window, cx) { cx.drop_image(image, None); } image_data.remove_asset(cx); }) .detach(); Self { image_item, project, focus_handle: cx.focus_handle(), } } fn on_image_event( &mut self, _: Entity, event: &ImageItemEvent, cx: &mut Context, ) { match event { ImageItemEvent::MetadataUpdated | ImageItemEvent::FileHandleChanged | ImageItemEvent::Reloaded => { cx.emit(ImageViewEvent::TitleChanged); cx.notify(); } ImageItemEvent::ReloadNeeded => {} } } } pub enum ImageViewEvent { TitleChanged, } impl EventEmitter for ImageView {} impl Item for ImageView { type Event = ImageViewEvent; fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) { match event { ImageViewEvent::TitleChanged => { f(workspace::item::ItemEvent::UpdateTab); f(workspace::item::ItemEvent::UpdateBreadcrumbs); } } } fn for_each_project_item( &self, cx: &App, f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem), ) { f(self.image_item.entity_id(), self.image_item.read(cx)) } fn tab_tooltip_text(&self, cx: &App) -> Option { let abs_path = self.image_item.read(cx).abs_path(cx)?; let file_path = abs_path.compact().to_string_lossy().into_owned(); Some(file_path.into()) } fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement { let project_path = self.image_item.read(cx).project_path(cx); let label_color = if ItemSettings::get_global(cx).git_status { let git_status = self .project .read(cx) .project_path_git_status(&project_path, cx) .map(|status| status.summary()) .unwrap_or_default(); self.project .read(cx) .entry_for_path(&project_path, cx) .map(|entry| { entry_git_aware_label_color(git_status, entry.is_ignored, params.selected) }) .unwrap_or_else(|| params.text_color()) } else { params.text_color() }; Label::new(self.tab_content_text(params.detail.unwrap_or_default(), cx)) .single_line() .color(label_color) .when(params.preview, |this| this.italic()) .into_any_element() } fn tab_content_text(&self, _: usize, cx: &App) -> SharedString { self.image_item .read(cx) .file .file_name(cx) .to_string() .into() } fn tab_icon(&self, _: &Window, cx: &App) -> Option { let path = self.image_item.read(cx).abs_path(cx)?; ItemSettings::get_global(cx) .file_icons .then(|| FileIcons::get_icon(&path, cx)) .flatten() .map(Icon::from_path) } fn breadcrumb_location(&self, cx: &App) -> ToolbarItemLocation { let show_breadcrumb = EditorSettings::get_global(cx).toolbar.breadcrumbs; if show_breadcrumb { ToolbarItemLocation::PrimaryLeft } else { ToolbarItemLocation::Hidden } } fn breadcrumbs(&self, _theme: &Theme, cx: &App) -> Option> { let text = breadcrumbs_text_for_image(self.project.read(cx), self.image_item.read(cx), cx); let settings = ThemeSettings::get_global(cx); Some(vec![BreadcrumbText { text, highlights: None, font: Some(settings.buffer_font.clone()), }]) } fn can_split(&self) -> bool { true } fn clone_on_split( &self, _workspace_id: Option, _: &mut Window, cx: &mut Context, ) -> Task>> where Self: Sized, { Task::ready(Some(cx.new(|cx| Self { image_item: self.image_item.clone(), project: self.project.clone(), focus_handle: cx.focus_handle(), }))) } fn has_deleted_file(&self, cx: &App) -> bool { self.image_item.read(cx).file.disk_state() == DiskState::Deleted } fn buffer_kind(&self, _: &App) -> workspace::item::ItemBufferKind { workspace::item::ItemBufferKind::Singleton } } fn breadcrumbs_text_for_image(project: &Project, image: &ImageItem, cx: &App) -> String { let mut path = image.file.path().clone(); if project.visible_worktrees(cx).count() > 1 && let Some(worktree) = project.worktree_for_id(image.project_path(cx).worktree_id, cx) { path = worktree.read(cx).root_name().join(&path); } path.display(project.path_style(cx)).to_string() } impl SerializableItem for ImageView { fn serialized_item_kind() -> &'static str { "ImageView" } fn deserialize( project: Entity, _workspace: WeakEntity, workspace_id: WorkspaceId, item_id: ItemId, window: &mut Window, cx: &mut App, ) -> Task>> { window.spawn(cx, async move |cx| { let image_path = IMAGE_VIEWER .get_image_path(item_id, workspace_id)? .context("No image path found")?; let (worktree, relative_path) = project .update(cx, |project, cx| { project.find_or_create_worktree(image_path.clone(), false, cx) })? .await .context("Path not found")?; let worktree_id = worktree.update(cx, |worktree, _cx| worktree.id())?; let project_path = ProjectPath { worktree_id, path: relative_path, }; let image_item = project .update(cx, |project, cx| project.open_image(project_path, cx))? .await?; cx.update( |window, cx| Ok(cx.new(|cx| ImageView::new(image_item, project, window, cx))), )? }) } fn cleanup( workspace_id: WorkspaceId, alive_items: Vec, _window: &mut Window, cx: &mut App, ) -> Task> { delete_unloaded_items( alive_items, workspace_id, "image_viewers", &IMAGE_VIEWER, cx, ) } fn serialize( &mut self, workspace: &mut Workspace, item_id: ItemId, _closing: bool, _window: &mut Window, cx: &mut Context, ) -> Option>> { let workspace_id = workspace.database_id()?; let image_path = self.image_item.read(cx).abs_path(cx)?; Some(cx.background_spawn({ async move { log::debug!("Saving image at path {image_path:?}"); IMAGE_VIEWER .save_image_path(item_id, workspace_id, image_path) .await } })) } fn should_serialize(&self, _event: &Self::Event) -> bool { false } } impl EventEmitter<()> for ImageView {} impl Focusable for ImageView { fn focus_handle(&self, _cx: &App) -> FocusHandle { self.focus_handle.clone() } } impl Render for ImageView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let image = self.image_item.read(cx).image.clone(); let checkered_background = |bounds: Bounds, _, window: &mut Window, _cx: &mut App| { let square_size: f32 = 32.0; let start_y = bounds.origin.y.into(); let height: f32 = bounds.size.height.into(); let start_x = bounds.origin.x.into(); let width: f32 = bounds.size.width.into(); 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 { // Clamp square dimensions to not exceed bounds let square_width = square_size.min(start_x + width - x); let square_height = square_size.min(start_y + height - y); let rect = Bounds::new( point(px(x), px(y)), size(px(square_width), px(square_height)), ); let color = if color_swapper { opaque_grey(0.6, 0.4) } else { opaque_grey(0.7, 0.4) }; window.paint_quad(fill(rect, color)); color_swapper = !color_swapper; x += square_size; } x = start_x; color_swapper = !start_swap; y += square_size; } }; div().track_focus(&self.focus_handle(cx)).size_full().child( div() .flex() .justify_center() .items_center() .w_full() // TODO: In browser based Tailwind & Flex this would be h-screen and we'd use w-full .h_full() .child( div() .relative() .max_w_full() .max_h_full() .child( canvas(|_, _, _| (), checkered_background) .border_2() .border_color(cx.theme().styles.colors.border) .size_full() .absolute() .top_0() .left_0(), ) .child( img(image) .object_fit(ObjectFit::ScaleDown) .max_w_full() .max_h_full() .id("img"), ), ), ) } } impl ProjectItem for ImageView { type Item = ImageItem; fn for_project_item( project: Entity, _: Option<&Pane>, item: Entity, window: &mut Window, cx: &mut Context, ) -> Self where Self: Sized, { Self::new(item, project, window, cx) } fn for_broken_project_item( abs_path: &Path, is_local: bool, e: &anyhow::Error, window: &mut Window, cx: &mut App, ) -> Option where Self: Sized, { Some(InvalidItemView::new(abs_path, is_local, e, window, cx)) } } pub fn init(cx: &mut App) { workspace::register_project_item::(cx); workspace::register_serializable_item::(cx); } mod persistence { use std::path::PathBuf; use db::{ query, sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection}, sqlez_macros::sql, }; use workspace::{ItemId, WorkspaceDb, WorkspaceId}; pub struct ImageViewerDb(ThreadSafeConnection); impl Domain for ImageViewerDb { const NAME: &str = stringify!(ImageViewerDb); const MIGRATIONS: &[&str] = &[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; )]; } db::static_connection!(IMAGE_VIEWER, ImageViewerDb, [WorkspaceDb]); impl ImageViewerDb { 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 = ? } } } }