mod image_info; mod image_viewer_settings; use std::path::Path; use anyhow::Context as _; use editor::{ Editor, EditorEvent, EditorSettings, RevealInFileManager, actions::SelectAll, items::entry_git_aware_label_color, }; use file_icons::FileIcons; use gpui::{ AnyElement, App, Bounds, Context, DispatchPhase, Element, ElementId, Entity, EventEmitter, FocusHandle, Focusable, Font, GlobalElementId, InspectorElementId, InteractiveElement, IntoElement, LayoutId, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, PinchEvent, Pixels, Point, Render, ScrollDelta, ScrollWheelEvent, Style, Styled, Subscription, Task, WeakEntity, Window, actions, checkerboard, div, img, point, px, size, }; use language::File as _; use persistence::ImageViewerDb; use project::{ ImageItem, Project, ProjectPath, git_store::GitStoreEvent, image_store::ImageItemEvent, }; use settings::Settings; use theme_settings::ThemeSettings; use ui::{Tooltip, prelude::*}; use util::paths::PathExt; use workspace::{ ItemId, ItemSettings, Pane, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace, WorkspaceId, delete_unloaded_items, invalid_item_view::InvalidItemView, item::{HighlightedText, Item, ItemHandle, ProjectItem, SerializableItem, TabContentParams}, }; pub use crate::image_info::*; pub use crate::image_viewer_settings::*; actions!( image_viewer, [ /// Zoom in the image. ZoomIn, /// Zoom out the image. ZoomOut, /// Reset zoom to 100%. ResetZoom, /// Fit the image to view. FitToView, /// Zoom to actual size (100%). ZoomToActualSize ] ); const MIN_ZOOM: f32 = 0.1; const MAX_ZOOM: f32 = 20.0; const ZOOM_STEP: f32 = 1.1; const SCROLL_LINE_MULTIPLIER: f32 = 20.0; const BASE_SQUARE_SIZE: f32 = 32.0; const ZOOM_EDITOR_MIN_DIGITS: usize = 3; // Reserve room for common values like 100%. const ZOOM_EDITOR_MAX_DIGITS: usize = 4; // MAX_ZOOM is 2000%. const ZOOM_EDITOR_APPROX_CHAR_WIDTH: f32 = 8.0; // Approximate width of one small UI digit. const ZOOM_EDITOR_HORIZONTAL_PADDING: f32 = 12.0; // Extra room for cursor and editor edge padding. pub struct ImageView { image_item: Entity, project: Entity, focus_handle: FocusHandle, zoom_level: f32, pan_offset: Point, last_mouse_position: Option>, container_bounds: Option>, image_size: Option<(u32, u32)>, } impl ImageView { fn is_dragging(&self) -> bool { self.last_mouse_position.is_some() } pub fn new( image_item: Entity, project: Entity, window: &mut Window, cx: &mut Context, ) -> Self { // Start loading the image to render in the background to prevent the view // from flickering in most cases. let _ = image_item.update(cx, |image, cx| { image.image.clone().get_render_image(window, cx) }); cx.subscribe(&image_item, Self::on_image_event).detach(); let git_store = project.read(cx).git_store().clone(); cx.subscribe(&git_store, |_, _, event, cx| { if matches!(event, GitStoreEvent::DiffBaseChanged(_)) { cx.emit(ImageViewEvent::TitleChanged); } }) .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(); let image_size = image_item .read(cx) .image_metadata .map(|m| (m.width, m.height)); Self { image_item, project, focus_handle: cx.focus_handle(), zoom_level: 1.0, pan_offset: Point::default(), last_mouse_position: None, container_bounds: None, image_size, } } fn on_image_event( &mut self, _: Entity, event: &ImageItemEvent, cx: &mut Context, ) { match event { ImageItemEvent::MetadataUpdated | ImageItemEvent::FileHandleChanged | ImageItemEvent::Reloaded => { self.image_size = self .image_item .read(cx) .image_metadata .map(|m| (m.width, m.height)); cx.emit(ImageViewEvent::TitleChanged); cx.notify(); } ImageItemEvent::ReloadNeeded => {} } } fn zoom_in(&mut self, _: &ZoomIn, _window: &mut Window, cx: &mut Context) { self.set_zoom(self.zoom_level * ZOOM_STEP, None, cx); } fn zoom_out(&mut self, _: &ZoomOut, _window: &mut Window, cx: &mut Context) { self.set_zoom(self.zoom_level / ZOOM_STEP, None, cx); } fn reset_zoom(&mut self, _: &ResetZoom, _window: &mut Window, cx: &mut Context) { self.zoom_level = 1.0; self.pan_offset = Point::default(); cx.notify(); } fn fit_to_view(&mut self, _: &FitToView, _window: &mut Window, cx: &mut Context) { if let Some((bounds, image_size)) = self.container_bounds.zip(self.image_size) { self.zoom_level = ImageView::compute_fit_to_view_zoom(bounds, image_size); self.pan_offset = Point::default(); cx.notify(); } } fn compute_fit_to_view_zoom(container_bounds: Bounds, image_size: (u32, u32)) -> f32 { let (image_width, image_height) = image_size; let container_width: f32 = container_bounds.size.width.into(); let container_height: f32 = container_bounds.size.height.into(); let scale_x = container_width / image_width as f32; let scale_y = container_height / image_height as f32; scale_x.min(scale_y).min(1.0) } fn zoom_to_actual_size( &mut self, _: &ZoomToActualSize, _window: &mut Window, cx: &mut Context, ) { self.zoom_level = 1.0; self.pan_offset = Point::default(); cx.notify(); } fn reveal_in_file_manager( &mut self, _: &RevealInFileManager, _window: &mut Window, cx: &mut Context, ) { if let Some(path) = self.image_item.read(cx).abs_path(cx) { self.project .update(cx, |project, cx| project.reveal_path(&path, cx)); } } fn set_zoom( &mut self, new_zoom: f32, zoom_center: Option>, cx: &mut Context, ) { let old_zoom = self.zoom_level; self.zoom_level = new_zoom.clamp(MIN_ZOOM, MAX_ZOOM); if let Some((center, bounds)) = zoom_center.zip(self.container_bounds) { let relative_center = point( center.x - bounds.origin.x - bounds.size.width / 2.0, center.y - bounds.origin.y - bounds.size.height / 2.0, ); let mouse_offset_from_image = relative_center - self.pan_offset; let zoom_ratio = self.zoom_level / old_zoom; self.pan_offset += mouse_offset_from_image * (1.0 - zoom_ratio); } cx.notify(); } fn handle_scroll_wheel( &mut self, event: &ScrollWheelEvent, _window: &mut Window, cx: &mut Context, ) { if event.modifiers.control || event.modifiers.platform { let delta: f32 = match event.delta { ScrollDelta::Pixels(pixels) => pixels.y.into(), ScrollDelta::Lines(lines) => lines.y * SCROLL_LINE_MULTIPLIER, }; let zoom_factor = if delta > 0.0 { 1.0 + delta.abs() * 0.01 } else { 1.0 / (1.0 + delta.abs() * 0.01) }; self.set_zoom(self.zoom_level * zoom_factor, Some(event.position), cx); } else { let delta = match event.delta { ScrollDelta::Pixels(pixels) => pixels, ScrollDelta::Lines(lines) => lines.map(|d| px(d * SCROLL_LINE_MULTIPLIER)), }; self.pan_offset += delta; cx.notify(); } } fn handle_mouse_down( &mut self, event: &MouseDownEvent, _window: &mut Window, cx: &mut Context, ) { if event.button == MouseButton::Left || event.button == MouseButton::Middle { self.last_mouse_position = Some(event.position); cx.notify(); } } fn handle_mouse_up( &mut self, _event: &MouseUpEvent, _window: &mut Window, cx: &mut Context, ) { self.last_mouse_position = None; cx.notify(); } fn handle_mouse_move( &mut self, event: &MouseMoveEvent, _window: &mut Window, cx: &mut Context, ) { if self.is_dragging() { if let Some(last_pos) = self.last_mouse_position { let delta = event.position - last_pos; self.pan_offset += delta; } self.last_mouse_position = Some(event.position); cx.notify(); } } fn handle_pinch(&mut self, event: &PinchEvent, _window: &mut Window, cx: &mut Context) { let zoom_factor = 1.0 + event.delta; self.set_zoom(self.zoom_level * zoom_factor, Some(event.position), cx); } } struct ImageContentElement { image_view: Entity, } impl ImageContentElement { fn new(image_view: Entity) -> Self { Self { image_view } } } impl IntoElement for ImageContentElement { type Element = Self; fn into_element(self) -> Self::Element { self } } impl Element for ImageContentElement { type RequestLayoutState = (); type PrepaintState = Option<(AnyElement, bool)>; fn id(&self) -> Option { None } fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { None } fn request_layout( &mut self, _id: Option<&GlobalElementId>, _inspector_id: Option<&InspectorElementId>, window: &mut Window, cx: &mut App, ) -> (LayoutId, Self::RequestLayoutState) { ( window.request_layout( Style { size: size(relative(1.).into(), relative(1.).into()), ..Default::default() }, [], cx, ), (), ) } fn prepaint( &mut self, _id: Option<&GlobalElementId>, _inspector_id: Option<&InspectorElementId>, bounds: Bounds, _request_layout: &mut Self::RequestLayoutState, window: &mut Window, cx: &mut App, ) -> Self::PrepaintState { let image_view = self.image_view.read(cx); let image = image_view.image_item.read(cx).image.clone(); let first_layout = image_view.container_bounds.is_none(); let initial_zoom_level = first_layout .then(|| { image_view .image_size .map(|image_size| ImageView::compute_fit_to_view_zoom(bounds, image_size)) }) .flatten(); let zoom_level = initial_zoom_level.unwrap_or(image_view.zoom_level); let pan_offset = image_view.pan_offset; let border_color = cx.theme().colors().border; let is_dragging = image_view.is_dragging(); let scaled_size = image_view .image_size .map(|(w, h)| (px(w as f32 * zoom_level), px(h as f32 * zoom_level))); let (mut left, mut top) = (px(0.0), px(0.0)); let mut scaled_width = px(0.0); let mut scaled_height = px(0.0); if let Some((width, height)) = scaled_size { scaled_width = width; scaled_height = height; let center_x = bounds.size.width / 2.0; let center_y = bounds.size.height / 2.0; left = center_x - (scaled_width / 2.0) + pan_offset.x; top = center_y - (scaled_height / 2.0) + pan_offset.y; } self.image_view.update(cx, |this, _| { this.container_bounds = Some(bounds); if let Some(initial_zoom_level) = initial_zoom_level { this.zoom_level = initial_zoom_level; } }); let mut image_content = div() .relative() .size_full() .child( div() .absolute() .left(left) .top(top) .w(scaled_width) .h(scaled_height) .child( div() .size_full() .absolute() .top_0() .left_0() .child(div().size_full().bg(checkerboard( cx.theme().colors().panel_background, BASE_SQUARE_SIZE * zoom_level, ))) .border_1() .border_color(border_color), ) .child({ img(image) .id(("image-viewer-image", self.image_view.entity_id())) .size_full() }), ) .into_any_element(); image_content.prepaint_as_root(bounds.origin, bounds.size.into(), window, cx); Some((image_content, is_dragging)) } fn paint( &mut self, _id: Option<&GlobalElementId>, _inspector_id: Option<&InspectorElementId>, _bounds: Bounds, _request_layout: &mut Self::RequestLayoutState, prepaint: &mut Self::PrepaintState, window: &mut Window, cx: &mut App, ) { let Some((mut element, is_dragging)) = prepaint.take() else { return; }; if is_dragging { let image_view = self.image_view.downgrade(); window.on_mouse_event(move |_event: &MouseUpEvent, phase, _window, cx| { if phase == DispatchPhase::Bubble && let Some(entity) = image_view.upgrade() { entity.update(cx, |this, cx| { this.last_mouse_position = None; cx.notify(); }); } }); } element.paint(window, cx); } } pub enum ImageViewEvent { TitleChanged, } impl EventEmitter for ImageView {} impl Item for ImageView { type Event = ImageViewEvent; fn to_item_events(event: &Self::Event, f: &mut dyn 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) .git_store() .read(cx) .display_status_for_project_path(&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, cx: &App) -> Option<(Vec, Option)> { let text = breadcrumbs_text_for_image(self.project.read(cx), self.image_item.read(cx), cx); let font = ThemeSettings::get_global(cx).buffer_font.clone(); Some(( vec![HighlightedText { text: text.into(), highlights: vec![], }], Some(font), )) } 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(), zoom_level: self.zoom_level, pan_offset: self.pan_offset, last_mouse_position: None, container_bounds: None, image_size: self.image_size, }))) } fn has_deleted_file(&self, cx: &App) -> bool { self.image_item.read(cx).file.disk_state().is_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().to_rel_path_buf(); 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>> { let db = ImageViewerDb::global(cx); window.spawn(cx, async move |cx| { let image_path = db .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> { let db = ImageViewerDb::global(cx); delete_unloaded_items(alive_items, workspace_id, "image_viewers", &db, 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)?; let db = ImageViewerDb::global(cx); Some(cx.background_spawn({ async move { log::debug!("Saving image at path {image_path:?}"); db.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, _window: &mut Window, cx: &mut Context) -> impl IntoElement { div() .track_focus(&self.focus_handle(cx)) .key_context("ImageViewer") .on_action(cx.listener(Self::zoom_in)) .on_action(cx.listener(Self::zoom_out)) .on_action(cx.listener(Self::reset_zoom)) .on_action(cx.listener(Self::fit_to_view)) .on_action(cx.listener(Self::zoom_to_actual_size)) .on_action(cx.listener(Self::reveal_in_file_manager)) .size_full() .relative() .bg(cx.theme().colors().editor_background) .child({ let container = div() .id("image-container") .size_full() .overflow_hidden() .cursor(if self.is_dragging() { gpui::CursorStyle::ClosedHand } else { gpui::CursorStyle::OpenHand }) .on_scroll_wheel(cx.listener(Self::handle_scroll_wheel)) .on_pinch(cx.listener(Self::handle_pinch)) .on_mouse_down(MouseButton::Left, cx.listener(Self::handle_mouse_down)) .on_mouse_down(MouseButton::Middle, cx.listener(Self::handle_mouse_down)) .on_mouse_up(MouseButton::Left, cx.listener(Self::handle_mouse_up)) .on_mouse_up(MouseButton::Middle, cx.listener(Self::handle_mouse_up)) .on_mouse_move(cx.listener(Self::handle_mouse_move)) .child(ImageContentElement::new(cx.entity())); container }) } } 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 struct ImageViewToolbarControls { image_view: Option>, _subscription: Option, zoom_editor: Option>, _zoom_subscription: Option, } impl ImageViewToolbarControls { pub fn new() -> Self { Self { image_view: None, _subscription: None, zoom_editor: None, _zoom_subscription: None, } } fn start_editing_zoom(&mut self, window: &mut Window, cx: &mut Context) { let Some(image_view) = self.image_view.as_ref().and_then(|v| v.upgrade()) else { return; }; let zoom_level = image_view.read(cx).zoom_level; let zoom_percentage = (zoom_level * 100.0).round() as i32; let editor = cx.new(|cx| { let mut editor = Editor::single_line(window, cx); editor.set_text(zoom_percentage.to_string(), window, cx); editor.set_text_style_refinement(gpui::TextStyleRefinement { color: Some(cx.theme().colors().text), text_align: Some(gpui::TextAlign::Center), font_size: Some(TextSize::Small.rems(cx).into()), ..Default::default() }); editor.select_all(&SelectAll, window, cx); editor }); let subscription = cx.subscribe_in(&editor, window, { move |this, editor, event, window, cx| match event { EditorEvent::Blurred => this.commit_edit(cx), EditorEvent::Edited { .. } => { let text = editor.read(cx).text(cx); let sanitized = text .chars() .filter(|ch| ch.is_ascii_digit()) .take(ZOOM_EDITOR_MAX_DIGITS) .collect::(); if sanitized != text { editor.update(cx, |editor, cx| editor.set_text(sanitized, window, cx)); } cx.notify(); } _ => {} } }); editor.focus_handle(cx).focus(window, cx); self.zoom_editor = Some(editor); self._zoom_subscription = Some(subscription); cx.notify(); } fn commit_edit(&mut self, cx: &mut Context) { let Some(editor) = self.zoom_editor.as_ref() else { self.cancel_edit(cx); return; }; let input = editor.read(cx).text(cx); let parsed = input .trim() .parse::() .ok() .filter(|parsed| *parsed > 0); let Some(parsed) = parsed else { self.cancel_edit(cx); return; }; self._zoom_subscription = None; self.zoom_editor = None; let new_zoom = (parsed as f32 / 100.0).clamp(MIN_ZOOM, MAX_ZOOM); if let Some(image_view) = self.image_view.as_ref().and_then(|v| v.upgrade()) { image_view.update(cx, |this, cx| { this.set_zoom(new_zoom, None, cx); }); } cx.notify(); } fn cancel_edit(&mut self, cx: &mut Context) { self.zoom_editor = None; self._zoom_subscription = None; cx.notify(); } } impl Render for ImageViewToolbarControls { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let Some(image_view) = self.image_view.as_ref().and_then(|v| v.upgrade()) else { return div().into_any_element(); }; h_flex() .gap_1() .child( IconButton::new("zoom-out", IconName::Dash) .icon_size(IconSize::Small) .tooltip(|_window, cx| Tooltip::for_action("Zoom Out", &ZoomOut, cx)) .on_click({ let image_view = image_view.downgrade(); move |_, window, cx| { if let Some(view) = image_view.upgrade() { view.update(cx, |this, cx| { this.zoom_out(&ZoomOut, window, cx); }); } } }), ) .child(if let Some(editor) = self.zoom_editor.as_ref() { // Grow with input, defaulting to 3-digit zoom. let editor_width = px((editor .read(cx) .text(cx) .chars() .count() .clamp(ZOOM_EDITOR_MIN_DIGITS, ZOOM_EDITOR_MAX_DIGITS) as f32 * ZOOM_EDITOR_APPROX_CHAR_WIDTH) + ZOOM_EDITOR_HORIZONTAL_PADDING); h_flex() .w(editor_width) .capture_key_down(|event, _window, cx| { if event.keystroke.modifiers.control || event.keystroke.modifiers.platform { return; } // Only allow digits to be entered if let Some(text) = event.keystroke.key_char.as_deref() && !text.chars().all(|ch| ch.is_ascii_digit()) { cx.stop_propagation(); } }) .child(editor.clone()) .on_action::({ move |_: &menu::Confirm, window, _| { window.blur(); } }) .on_action(cx.listener(|this, _: &menu::Cancel, _, cx| { this.cancel_edit(cx); })) .into_any_element() } else { let zoom_level = image_view.read(cx).zoom_level; let zoom_percentage = format!("{}%", (zoom_level * 100.0).round() as i32); h_flex() .px_1() .cursor_pointer() .child(Label::new(zoom_percentage).size(LabelSize::Small)) .id("zoom-label") .tooltip(|_window, cx| { Tooltip::with_meta("Edit Zoom", None, "Right-click to reset to 100%.", cx) }) .on_click(cx.listener(|this, _, window, cx| { this.start_editing_zoom(window, cx); })) .on_mouse_down(MouseButton::Right, { let image_view = image_view.downgrade(); move |_, window, cx| { if let Some(view) = image_view.upgrade() { view.update(cx, |this, cx| { this.reset_zoom(&ResetZoom, window, cx); }); } } }) .into_any_element() }) .child( IconButton::new("zoom-in", IconName::Plus) .icon_size(IconSize::Small) .tooltip(|_, cx| Tooltip::for_action("Zoom In", &ZoomIn, cx)) .on_click({ let image_view = image_view.downgrade(); move |_, window, cx| { if let Some(view) = image_view.upgrade() { view.update(cx, |this, cx| { this.zoom_in(&ZoomIn, window, cx); }); } } }), ) .child( IconButton::new("fit-to-view", IconName::Maximize) .icon_size(IconSize::Small) .tooltip(|_window, cx| Tooltip::for_action("Fit to View", &FitToView, cx)) .on_click({ let image_view = image_view.downgrade(); move |_, window, cx| { if let Some(view) = image_view.upgrade() { view.update(cx, |this, cx| { this.fit_to_view(&FitToView, window, cx); }); } } }), ) .into_any_element() } } impl EventEmitter for ImageViewToolbarControls {} impl ToolbarItemView for ImageViewToolbarControls { fn set_active_pane_item( &mut self, active_pane_item: Option<&dyn ItemHandle>, _window: &mut Window, cx: &mut Context, ) -> ToolbarItemLocation { self.image_view = None; self._subscription = None; self.zoom_editor = None; self._zoom_subscription = None; if let Some(item) = active_pane_item.and_then(|i| i.downcast::()) { self._subscription = Some(cx.observe(&item, |_, _, cx| { cx.notify(); })); self.image_view = Some(item.downgrade()); cx.notify(); return ToolbarItemLocation::PrimaryRight; } ToolbarItemLocation::Hidden } } 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!(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 = ? } } } }