image_viewer.rs

  1mod image_info;
  2mod image_viewer_settings;
  3
  4use std::path::Path;
  5
  6use anyhow::Context as _;
  7use editor::{EditorSettings, items::entry_git_aware_label_color};
  8use file_icons::FileIcons;
  9#[cfg(any(target_os = "linux", target_os = "macos"))]
 10use gpui::PinchEvent;
 11use gpui::{
 12    AnyElement, App, Bounds, Context, DispatchPhase, Element, ElementId, Entity, EventEmitter,
 13    FocusHandle, Focusable, Font, GlobalElementId, InspectorElementId, InteractiveElement,
 14    IntoElement, LayoutId, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent,
 15    ParentElement, Pixels, Point, Render, ScrollDelta, ScrollWheelEvent, Style, Styled, Task,
 16    WeakEntity, Window, actions, checkerboard, div, img, point, px, size,
 17};
 18use language::File as _;
 19use persistence::ImageViewerDb;
 20use project::{ImageItem, Project, ProjectPath, image_store::ImageItemEvent};
 21use settings::Settings;
 22use theme::ThemeSettings;
 23use ui::{Tooltip, prelude::*};
 24use util::paths::PathExt;
 25use workspace::{
 26    ItemId, ItemSettings, Pane, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace,
 27    WorkspaceId, delete_unloaded_items,
 28    invalid_item_view::InvalidItemView,
 29    item::{HighlightedText, Item, ItemHandle, ProjectItem, SerializableItem, TabContentParams},
 30};
 31
 32pub use crate::image_info::*;
 33pub use crate::image_viewer_settings::*;
 34
 35actions!(
 36    image_viewer,
 37    [
 38        /// Zoom in the image.
 39        ZoomIn,
 40        /// Zoom out the image.
 41        ZoomOut,
 42        /// Reset zoom to 100%.
 43        ResetZoom,
 44        /// Fit the image to view.
 45        FitToView,
 46        /// Zoom to actual size (100%).
 47        ZoomToActualSize
 48    ]
 49);
 50
 51const MIN_ZOOM: f32 = 0.1;
 52const MAX_ZOOM: f32 = 20.0;
 53const ZOOM_STEP: f32 = 1.1;
 54const SCROLL_LINE_MULTIPLIER: f32 = 20.0;
 55const BASE_SQUARE_SIZE: f32 = 32.0;
 56
 57pub struct ImageView {
 58    image_item: Entity<ImageItem>,
 59    project: Entity<Project>,
 60    focus_handle: FocusHandle,
 61    zoom_level: f32,
 62    pan_offset: Point<Pixels>,
 63    last_mouse_position: Option<Point<Pixels>>,
 64    container_bounds: Option<Bounds<Pixels>>,
 65    image_size: Option<(u32, u32)>,
 66}
 67
 68impl ImageView {
 69    fn is_dragging(&self) -> bool {
 70        self.last_mouse_position.is_some()
 71    }
 72
 73    pub fn new(
 74        image_item: Entity<ImageItem>,
 75        project: Entity<Project>,
 76        window: &mut Window,
 77        cx: &mut Context<Self>,
 78    ) -> Self {
 79        // Start loading the image to render in the background to prevent the view
 80        // from flickering in most cases.
 81        let _ = image_item.update(cx, |image, cx| {
 82            image.image.clone().get_render_image(window, cx)
 83        });
 84
 85        cx.subscribe(&image_item, Self::on_image_event).detach();
 86        cx.on_release_in(window, |this, window, cx| {
 87            let image_data = this.image_item.read(cx).image.clone();
 88            if let Some(image) = image_data.clone().get_render_image(window, cx) {
 89                cx.drop_image(image, None);
 90            }
 91            image_data.remove_asset(cx);
 92        })
 93        .detach();
 94
 95        let image_size = image_item
 96            .read(cx)
 97            .image_metadata
 98            .map(|m| (m.width, m.height));
 99
100        Self {
101            image_item,
102            project,
103            focus_handle: cx.focus_handle(),
104            zoom_level: 1.0,
105            pan_offset: Point::default(),
106            last_mouse_position: None,
107            container_bounds: None,
108            image_size,
109        }
110    }
111
112    fn on_image_event(
113        &mut self,
114        _: Entity<ImageItem>,
115        event: &ImageItemEvent,
116        cx: &mut Context<Self>,
117    ) {
118        match event {
119            ImageItemEvent::MetadataUpdated
120            | ImageItemEvent::FileHandleChanged
121            | ImageItemEvent::Reloaded => {
122                self.image_size = self
123                    .image_item
124                    .read(cx)
125                    .image_metadata
126                    .map(|m| (m.width, m.height));
127                cx.emit(ImageViewEvent::TitleChanged);
128                cx.notify();
129            }
130            ImageItemEvent::ReloadNeeded => {}
131        }
132    }
133
134    fn zoom_in(&mut self, _: &ZoomIn, _window: &mut Window, cx: &mut Context<Self>) {
135        self.set_zoom(self.zoom_level * ZOOM_STEP, None, cx);
136    }
137
138    fn zoom_out(&mut self, _: &ZoomOut, _window: &mut Window, cx: &mut Context<Self>) {
139        self.set_zoom(self.zoom_level / ZOOM_STEP, None, cx);
140    }
141
142    fn reset_zoom(&mut self, _: &ResetZoom, _window: &mut Window, cx: &mut Context<Self>) {
143        self.zoom_level = 1.0;
144        self.pan_offset = Point::default();
145        cx.notify();
146    }
147
148    fn fit_to_view(&mut self, _: &FitToView, _window: &mut Window, cx: &mut Context<Self>) {
149        if let Some((bounds, image_size)) = self.container_bounds.zip(self.image_size) {
150            self.zoom_level = ImageView::compute_fit_to_view_zoom(bounds, image_size);
151            self.pan_offset = Point::default();
152            cx.notify();
153        }
154    }
155
156    fn compute_fit_to_view_zoom(container_bounds: Bounds<Pixels>, image_size: (u32, u32)) -> f32 {
157        let (image_width, image_height) = image_size;
158        let container_width: f32 = container_bounds.size.width.into();
159        let container_height: f32 = container_bounds.size.height.into();
160        let scale_x = container_width / image_width as f32;
161        let scale_y = container_height / image_height as f32;
162        scale_x.min(scale_y).min(1.0)
163    }
164
165    fn zoom_to_actual_size(
166        &mut self,
167        _: &ZoomToActualSize,
168        _window: &mut Window,
169        cx: &mut Context<Self>,
170    ) {
171        self.zoom_level = 1.0;
172        self.pan_offset = Point::default();
173        cx.notify();
174    }
175
176    fn set_zoom(
177        &mut self,
178        new_zoom: f32,
179        zoom_center: Option<Point<Pixels>>,
180        cx: &mut Context<Self>,
181    ) {
182        let old_zoom = self.zoom_level;
183        self.zoom_level = new_zoom.clamp(MIN_ZOOM, MAX_ZOOM);
184
185        if let Some((center, bounds)) = zoom_center.zip(self.container_bounds) {
186            let relative_center = point(
187                center.x - bounds.origin.x - bounds.size.width / 2.0,
188                center.y - bounds.origin.y - bounds.size.height / 2.0,
189            );
190
191            let mouse_offset_from_image = relative_center - self.pan_offset;
192
193            let zoom_ratio = self.zoom_level / old_zoom;
194
195            self.pan_offset += mouse_offset_from_image * (1.0 - zoom_ratio);
196        }
197
198        cx.notify();
199    }
200
201    fn handle_scroll_wheel(
202        &mut self,
203        event: &ScrollWheelEvent,
204        _window: &mut Window,
205        cx: &mut Context<Self>,
206    ) {
207        if event.modifiers.control || event.modifiers.platform {
208            let delta: f32 = match event.delta {
209                ScrollDelta::Pixels(pixels) => pixels.y.into(),
210                ScrollDelta::Lines(lines) => lines.y * SCROLL_LINE_MULTIPLIER,
211            };
212            let zoom_factor = if delta > 0.0 {
213                1.0 + delta.abs() * 0.01
214            } else {
215                1.0 / (1.0 + delta.abs() * 0.01)
216            };
217            self.set_zoom(self.zoom_level * zoom_factor, Some(event.position), cx);
218        } else {
219            let delta = match event.delta {
220                ScrollDelta::Pixels(pixels) => pixels,
221                ScrollDelta::Lines(lines) => lines.map(|d| px(d * SCROLL_LINE_MULTIPLIER)),
222            };
223            self.pan_offset += delta;
224            cx.notify();
225        }
226    }
227
228    fn handle_mouse_down(
229        &mut self,
230        event: &MouseDownEvent,
231        _window: &mut Window,
232        cx: &mut Context<Self>,
233    ) {
234        if event.button == MouseButton::Left || event.button == MouseButton::Middle {
235            self.last_mouse_position = Some(event.position);
236            cx.notify();
237        }
238    }
239
240    fn handle_mouse_up(
241        &mut self,
242        _event: &MouseUpEvent,
243        _window: &mut Window,
244        cx: &mut Context<Self>,
245    ) {
246        self.last_mouse_position = None;
247        cx.notify();
248    }
249
250    fn handle_mouse_move(
251        &mut self,
252        event: &MouseMoveEvent,
253        _window: &mut Window,
254        cx: &mut Context<Self>,
255    ) {
256        if self.is_dragging() {
257            if let Some(last_pos) = self.last_mouse_position {
258                let delta = event.position - last_pos;
259                self.pan_offset += delta;
260            }
261            self.last_mouse_position = Some(event.position);
262            cx.notify();
263        }
264    }
265
266    #[cfg(any(target_os = "linux", target_os = "macos"))]
267    fn handle_pinch(&mut self, event: &PinchEvent, _window: &mut Window, cx: &mut Context<Self>) {
268        let zoom_factor = 1.0 + event.delta;
269        self.set_zoom(self.zoom_level * zoom_factor, Some(event.position), cx);
270    }
271}
272
273struct ImageContentElement {
274    image_view: Entity<ImageView>,
275}
276
277impl ImageContentElement {
278    fn new(image_view: Entity<ImageView>) -> Self {
279        Self { image_view }
280    }
281}
282
283impl IntoElement for ImageContentElement {
284    type Element = Self;
285
286    fn into_element(self) -> Self::Element {
287        self
288    }
289}
290
291impl Element for ImageContentElement {
292    type RequestLayoutState = ();
293    type PrepaintState = Option<(AnyElement, bool)>;
294
295    fn id(&self) -> Option<ElementId> {
296        None
297    }
298
299    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
300        None
301    }
302
303    fn request_layout(
304        &mut self,
305        _id: Option<&GlobalElementId>,
306        _inspector_id: Option<&InspectorElementId>,
307        window: &mut Window,
308        cx: &mut App,
309    ) -> (LayoutId, Self::RequestLayoutState) {
310        (
311            window.request_layout(
312                Style {
313                    size: size(relative(1.).into(), relative(1.).into()),
314                    ..Default::default()
315                },
316                [],
317                cx,
318            ),
319            (),
320        )
321    }
322
323    fn prepaint(
324        &mut self,
325        _id: Option<&GlobalElementId>,
326        _inspector_id: Option<&InspectorElementId>,
327        bounds: Bounds<Pixels>,
328        _request_layout: &mut Self::RequestLayoutState,
329        window: &mut Window,
330        cx: &mut App,
331    ) -> Self::PrepaintState {
332        let image_view = self.image_view.read(cx);
333        let image = image_view.image_item.read(cx).image.clone();
334
335        let first_layout = image_view.container_bounds.is_none();
336
337        let initial_zoom_level = first_layout
338            .then(|| {
339                image_view
340                    .image_size
341                    .map(|image_size| ImageView::compute_fit_to_view_zoom(bounds, image_size))
342            })
343            .flatten();
344
345        let zoom_level = initial_zoom_level.unwrap_or(image_view.zoom_level);
346
347        let pan_offset = image_view.pan_offset;
348        let border_color = cx.theme().colors().border;
349
350        let is_dragging = image_view.is_dragging();
351
352        let scaled_size = image_view
353            .image_size
354            .map(|(w, h)| (px(w as f32 * zoom_level), px(h as f32 * zoom_level)));
355
356        let (mut left, mut top) = (px(0.0), px(0.0));
357        let mut scaled_width = px(0.0);
358        let mut scaled_height = px(0.0);
359
360        if let Some((width, height)) = scaled_size {
361            scaled_width = width;
362            scaled_height = height;
363
364            let center_x = bounds.size.width / 2.0;
365            let center_y = bounds.size.height / 2.0;
366
367            left = center_x - (scaled_width / 2.0) + pan_offset.x;
368            top = center_y - (scaled_height / 2.0) + pan_offset.y;
369        }
370
371        self.image_view.update(cx, |this, _| {
372            this.container_bounds = Some(bounds);
373            if let Some(initial_zoom_level) = initial_zoom_level {
374                this.zoom_level = initial_zoom_level;
375            }
376        });
377
378        let mut image_content = div()
379            .relative()
380            .size_full()
381            .child(
382                div()
383                    .absolute()
384                    .left(left)
385                    .top(top)
386                    .w(scaled_width)
387                    .h(scaled_height)
388                    .child(
389                        div()
390                            .size_full()
391                            .absolute()
392                            .top_0()
393                            .left_0()
394                            .child(div().size_full().bg(checkerboard(
395                                cx.theme().colors().panel_background,
396                                BASE_SQUARE_SIZE * zoom_level,
397                            )))
398                            .border_1()
399                            .border_color(border_color),
400                    )
401                    .child({
402                        img(image)
403                            .id(("image-viewer-image", self.image_view.entity_id()))
404                            .size_full()
405                    }),
406            )
407            .into_any_element();
408
409        image_content.prepaint_as_root(bounds.origin, bounds.size.into(), window, cx);
410        Some((image_content, is_dragging))
411    }
412
413    fn paint(
414        &mut self,
415        _id: Option<&GlobalElementId>,
416        _inspector_id: Option<&InspectorElementId>,
417        _bounds: Bounds<Pixels>,
418        _request_layout: &mut Self::RequestLayoutState,
419        prepaint: &mut Self::PrepaintState,
420        window: &mut Window,
421        cx: &mut App,
422    ) {
423        let Some((mut element, is_dragging)) = prepaint.take() else {
424            return;
425        };
426
427        if is_dragging {
428            let image_view = self.image_view.downgrade();
429            window.on_mouse_event(move |_event: &MouseUpEvent, phase, _window, cx| {
430                if phase == DispatchPhase::Bubble
431                    && let Some(entity) = image_view.upgrade()
432                {
433                    entity.update(cx, |this, cx| {
434                        this.last_mouse_position = None;
435                        cx.notify();
436                    });
437                }
438            });
439        }
440
441        element.paint(window, cx);
442    }
443}
444
445pub enum ImageViewEvent {
446    TitleChanged,
447}
448
449impl EventEmitter<ImageViewEvent> for ImageView {}
450
451impl Item for ImageView {
452    type Event = ImageViewEvent;
453
454    fn to_item_events(event: &Self::Event, f: &mut dyn FnMut(workspace::item::ItemEvent)) {
455        match event {
456            ImageViewEvent::TitleChanged => {
457                f(workspace::item::ItemEvent::UpdateTab);
458                f(workspace::item::ItemEvent::UpdateBreadcrumbs);
459            }
460        }
461    }
462
463    fn for_each_project_item(
464        &self,
465        cx: &App,
466        f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
467    ) {
468        f(self.image_item.entity_id(), self.image_item.read(cx))
469    }
470
471    fn tab_tooltip_text(&self, cx: &App) -> Option<SharedString> {
472        let abs_path = self.image_item.read(cx).abs_path(cx)?;
473        let file_path = abs_path.compact().to_string_lossy().into_owned();
474        Some(file_path.into())
475    }
476
477    fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
478        let project_path = self.image_item.read(cx).project_path(cx);
479
480        let label_color = if ItemSettings::get_global(cx).git_status {
481            let git_status = self
482                .project
483                .read(cx)
484                .project_path_git_status(&project_path, cx)
485                .map(|status| status.summary())
486                .unwrap_or_default();
487
488            self.project
489                .read(cx)
490                .entry_for_path(&project_path, cx)
491                .map(|entry| {
492                    entry_git_aware_label_color(git_status, entry.is_ignored, params.selected)
493                })
494                .unwrap_or_else(|| params.text_color())
495        } else {
496            params.text_color()
497        };
498
499        Label::new(self.tab_content_text(params.detail.unwrap_or_default(), cx))
500            .single_line()
501            .color(label_color)
502            .when(params.preview, |this| this.italic())
503            .into_any_element()
504    }
505
506    fn tab_content_text(&self, _: usize, cx: &App) -> SharedString {
507        self.image_item
508            .read(cx)
509            .file
510            .file_name(cx)
511            .to_string()
512            .into()
513    }
514
515    fn tab_icon(&self, _: &Window, cx: &App) -> Option<Icon> {
516        let path = self.image_item.read(cx).abs_path(cx)?;
517        ItemSettings::get_global(cx)
518            .file_icons
519            .then(|| FileIcons::get_icon(&path, cx))
520            .flatten()
521            .map(Icon::from_path)
522    }
523
524    fn breadcrumb_location(&self, cx: &App) -> ToolbarItemLocation {
525        let show_breadcrumb = EditorSettings::get_global(cx).toolbar.breadcrumbs;
526        if show_breadcrumb {
527            ToolbarItemLocation::PrimaryLeft
528        } else {
529            ToolbarItemLocation::Hidden
530        }
531    }
532
533    fn breadcrumbs(&self, cx: &App) -> Option<(Vec<HighlightedText>, Option<Font>)> {
534        let text = breadcrumbs_text_for_image(self.project.read(cx), self.image_item.read(cx), cx);
535        let font = ThemeSettings::get_global(cx).buffer_font.clone();
536
537        Some((
538            vec![HighlightedText {
539                text: text.into(),
540                highlights: vec![],
541            }],
542            Some(font),
543        ))
544    }
545
546    fn can_split(&self) -> bool {
547        true
548    }
549
550    fn clone_on_split(
551        &self,
552        _workspace_id: Option<WorkspaceId>,
553        _: &mut Window,
554        cx: &mut Context<Self>,
555    ) -> Task<Option<Entity<Self>>>
556    where
557        Self: Sized,
558    {
559        Task::ready(Some(cx.new(|cx| Self {
560            image_item: self.image_item.clone(),
561            project: self.project.clone(),
562            focus_handle: cx.focus_handle(),
563            zoom_level: self.zoom_level,
564            pan_offset: self.pan_offset,
565            last_mouse_position: None,
566            container_bounds: None,
567            image_size: self.image_size,
568        })))
569    }
570
571    fn has_deleted_file(&self, cx: &App) -> bool {
572        self.image_item.read(cx).file.disk_state().is_deleted()
573    }
574    fn buffer_kind(&self, _: &App) -> workspace::item::ItemBufferKind {
575        workspace::item::ItemBufferKind::Singleton
576    }
577}
578
579fn breadcrumbs_text_for_image(project: &Project, image: &ImageItem, cx: &App) -> String {
580    let mut path = image.file.path().clone();
581    if project.visible_worktrees(cx).count() > 1
582        && let Some(worktree) = project.worktree_for_id(image.project_path(cx).worktree_id, cx)
583    {
584        path = worktree.read(cx).root_name().join(&path);
585    }
586
587    path.display(project.path_style(cx)).to_string()
588}
589
590impl SerializableItem for ImageView {
591    fn serialized_item_kind() -> &'static str {
592        "ImageView"
593    }
594
595    fn deserialize(
596        project: Entity<Project>,
597        _workspace: WeakEntity<Workspace>,
598        workspace_id: WorkspaceId,
599        item_id: ItemId,
600        window: &mut Window,
601        cx: &mut App,
602    ) -> Task<anyhow::Result<Entity<Self>>> {
603        let db = ImageViewerDb::global(cx);
604        window.spawn(cx, async move |cx| {
605            let image_path = db
606                .get_image_path(item_id, workspace_id)?
607                .context("No image path found")?;
608
609            let (worktree, relative_path) = project
610                .update(cx, |project, cx| {
611                    project.find_or_create_worktree(image_path.clone(), false, cx)
612                })
613                .await
614                .context("Path not found")?;
615            let worktree_id = worktree.update(cx, |worktree, _cx| worktree.id());
616
617            let project_path = ProjectPath {
618                worktree_id,
619                path: relative_path,
620            };
621
622            let image_item = project
623                .update(cx, |project, cx| project.open_image(project_path, cx))
624                .await?;
625
626            cx.update(
627                |window, cx| Ok(cx.new(|cx| ImageView::new(image_item, project, window, cx))),
628            )?
629        })
630    }
631
632    fn cleanup(
633        workspace_id: WorkspaceId,
634        alive_items: Vec<ItemId>,
635        _window: &mut Window,
636        cx: &mut App,
637    ) -> Task<anyhow::Result<()>> {
638        let db = ImageViewerDb::global(cx);
639        delete_unloaded_items(alive_items, workspace_id, "image_viewers", &db, cx)
640    }
641
642    fn serialize(
643        &mut self,
644        workspace: &mut Workspace,
645        item_id: ItemId,
646        _closing: bool,
647        _window: &mut Window,
648        cx: &mut Context<Self>,
649    ) -> Option<Task<anyhow::Result<()>>> {
650        let workspace_id = workspace.database_id()?;
651        let image_path = self.image_item.read(cx).abs_path(cx)?;
652
653        let db = ImageViewerDb::global(cx);
654        Some(cx.background_spawn({
655            async move {
656                log::debug!("Saving image at path {image_path:?}");
657                db.save_image_path(item_id, workspace_id, image_path).await
658            }
659        }))
660    }
661
662    fn should_serialize(&self, _event: &Self::Event) -> bool {
663        false
664    }
665}
666
667impl EventEmitter<()> for ImageView {}
668impl Focusable for ImageView {
669    fn focus_handle(&self, _cx: &App) -> FocusHandle {
670        self.focus_handle.clone()
671    }
672}
673
674impl Render for ImageView {
675    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
676        div()
677            .track_focus(&self.focus_handle(cx))
678            .key_context("ImageViewer")
679            .on_action(cx.listener(Self::zoom_in))
680            .on_action(cx.listener(Self::zoom_out))
681            .on_action(cx.listener(Self::reset_zoom))
682            .on_action(cx.listener(Self::fit_to_view))
683            .on_action(cx.listener(Self::zoom_to_actual_size))
684            .size_full()
685            .relative()
686            .bg(cx.theme().colors().editor_background)
687            .child({
688                #[cfg(any(target_os = "linux", target_os = "macos"))]
689                let container = div()
690                    .id("image-container")
691                    .size_full()
692                    .overflow_hidden()
693                    .cursor(if self.is_dragging() {
694                        gpui::CursorStyle::ClosedHand
695                    } else {
696                        gpui::CursorStyle::OpenHand
697                    })
698                    .on_scroll_wheel(cx.listener(Self::handle_scroll_wheel))
699                    .on_pinch(cx.listener(Self::handle_pinch))
700                    .on_mouse_down(MouseButton::Left, cx.listener(Self::handle_mouse_down))
701                    .on_mouse_down(MouseButton::Middle, cx.listener(Self::handle_mouse_down))
702                    .on_mouse_up(MouseButton::Left, cx.listener(Self::handle_mouse_up))
703                    .on_mouse_up(MouseButton::Middle, cx.listener(Self::handle_mouse_up))
704                    .on_mouse_move(cx.listener(Self::handle_mouse_move))
705                    .child(ImageContentElement::new(cx.entity()));
706
707                #[cfg(not(any(target_os = "linux", target_os = "macos")))]
708                let container = div()
709                    .id("image-container")
710                    .size_full()
711                    .overflow_hidden()
712                    .cursor(if self.is_dragging() {
713                        gpui::CursorStyle::ClosedHand
714                    } else {
715                        gpui::CursorStyle::OpenHand
716                    })
717                    .on_scroll_wheel(cx.listener(Self::handle_scroll_wheel))
718                    .on_mouse_down(MouseButton::Left, cx.listener(Self::handle_mouse_down))
719                    .on_mouse_down(MouseButton::Middle, cx.listener(Self::handle_mouse_down))
720                    .on_mouse_up(MouseButton::Left, cx.listener(Self::handle_mouse_up))
721                    .on_mouse_up(MouseButton::Middle, cx.listener(Self::handle_mouse_up))
722                    .on_mouse_move(cx.listener(Self::handle_mouse_move))
723                    .child(ImageContentElement::new(cx.entity()));
724
725                container
726            })
727    }
728}
729
730impl ProjectItem for ImageView {
731    type Item = ImageItem;
732
733    fn for_project_item(
734        project: Entity<Project>,
735        _: Option<&Pane>,
736        item: Entity<Self::Item>,
737        window: &mut Window,
738        cx: &mut Context<Self>,
739    ) -> Self
740    where
741        Self: Sized,
742    {
743        Self::new(item, project, window, cx)
744    }
745
746    fn for_broken_project_item(
747        abs_path: &Path,
748        is_local: bool,
749        e: &anyhow::Error,
750        window: &mut Window,
751        cx: &mut App,
752    ) -> Option<InvalidItemView>
753    where
754        Self: Sized,
755    {
756        Some(InvalidItemView::new(abs_path, is_local, e, window, cx))
757    }
758}
759
760pub struct ImageViewToolbarControls {
761    image_view: Option<WeakEntity<ImageView>>,
762    _subscription: Option<gpui::Subscription>,
763}
764
765impl ImageViewToolbarControls {
766    pub fn new() -> Self {
767        Self {
768            image_view: None,
769            _subscription: None,
770        }
771    }
772}
773
774impl Render for ImageViewToolbarControls {
775    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
776        let Some(image_view) = self.image_view.as_ref().and_then(|v| v.upgrade()) else {
777            return div().into_any_element();
778        };
779
780        let zoom_level = image_view.read(cx).zoom_level;
781        let zoom_percentage = format!("{}%", (zoom_level * 100.0).round() as i32);
782
783        h_flex()
784            .gap_1()
785            .child(
786                IconButton::new("zoom-out", IconName::Dash)
787                    .icon_size(IconSize::Small)
788                    .tooltip(|_window, cx| Tooltip::for_action("Zoom Out", &ZoomOut, cx))
789                    .on_click({
790                        let image_view = image_view.downgrade();
791                        move |_, window, cx| {
792                            if let Some(view) = image_view.upgrade() {
793                                view.update(cx, |this, cx| {
794                                    this.zoom_out(&ZoomOut, window, cx);
795                                });
796                            }
797                        }
798                    }),
799            )
800            .child(
801                Button::new("zoom-level", zoom_percentage)
802                    .label_size(LabelSize::Small)
803                    .tooltip(|_window, cx| Tooltip::for_action("Reset Zoom", &ResetZoom, cx))
804                    .on_click({
805                        let image_view = image_view.downgrade();
806                        move |_, window, cx| {
807                            if let Some(view) = image_view.upgrade() {
808                                view.update(cx, |this, cx| {
809                                    this.reset_zoom(&ResetZoom, window, cx);
810                                });
811                            }
812                        }
813                    }),
814            )
815            .child(
816                IconButton::new("zoom-in", IconName::Plus)
817                    .icon_size(IconSize::Small)
818                    .tooltip(|_window, cx| Tooltip::for_action("Zoom In", &ZoomIn, cx))
819                    .on_click({
820                        let image_view = image_view.downgrade();
821                        move |_, window, cx| {
822                            if let Some(view) = image_view.upgrade() {
823                                view.update(cx, |this, cx| {
824                                    this.zoom_in(&ZoomIn, window, cx);
825                                });
826                            }
827                        }
828                    }),
829            )
830            .child(
831                IconButton::new("fit-to-view", IconName::Maximize)
832                    .icon_size(IconSize::Small)
833                    .tooltip(|_window, cx| Tooltip::for_action("Fit to View", &FitToView, cx))
834                    .on_click({
835                        let image_view = image_view.downgrade();
836                        move |_, window, cx| {
837                            if let Some(view) = image_view.upgrade() {
838                                view.update(cx, |this, cx| {
839                                    this.fit_to_view(&FitToView, window, cx);
840                                });
841                            }
842                        }
843                    }),
844            )
845            .into_any_element()
846    }
847}
848
849impl EventEmitter<ToolbarItemEvent> for ImageViewToolbarControls {}
850
851impl ToolbarItemView for ImageViewToolbarControls {
852    fn set_active_pane_item(
853        &mut self,
854        active_pane_item: Option<&dyn ItemHandle>,
855        _window: &mut Window,
856        cx: &mut Context<Self>,
857    ) -> ToolbarItemLocation {
858        self.image_view = None;
859        self._subscription = None;
860
861        if let Some(item) = active_pane_item.and_then(|i| i.downcast::<ImageView>()) {
862            self._subscription = Some(cx.observe(&item, |_, _, cx| {
863                cx.notify();
864            }));
865            self.image_view = Some(item.downgrade());
866            cx.notify();
867            return ToolbarItemLocation::PrimaryRight;
868        }
869
870        ToolbarItemLocation::Hidden
871    }
872}
873
874pub fn init(cx: &mut App) {
875    workspace::register_project_item::<ImageView>(cx);
876    workspace::register_serializable_item::<ImageView>(cx);
877}
878
879mod persistence {
880    use std::path::PathBuf;
881
882    use db::{
883        query,
884        sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
885        sqlez_macros::sql,
886    };
887    use workspace::{ItemId, WorkspaceDb, WorkspaceId};
888
889    pub struct ImageViewerDb(ThreadSafeConnection);
890
891    impl Domain for ImageViewerDb {
892        const NAME: &str = stringify!(ImageViewerDb);
893
894        const MIGRATIONS: &[&str] = &[sql!(
895                CREATE TABLE image_viewers (
896                    workspace_id INTEGER,
897                    item_id INTEGER UNIQUE,
898
899                    image_path BLOB,
900
901                    PRIMARY KEY(workspace_id, item_id),
902                    FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
903                    ON DELETE CASCADE
904                ) STRICT;
905        )];
906    }
907
908    db::static_connection!(ImageViewerDb, [WorkspaceDb]);
909
910    impl ImageViewerDb {
911        query! {
912            pub async fn save_image_path(
913                item_id: ItemId,
914                workspace_id: WorkspaceId,
915                image_path: PathBuf
916            ) -> Result<()> {
917                INSERT OR REPLACE INTO image_viewers(item_id, workspace_id, image_path)
918                VALUES (?, ?, ?)
919            }
920        }
921
922        query! {
923            pub fn get_image_path(item_id: ItemId, workspace_id: WorkspaceId) -> Result<Option<PathBuf>> {
924                SELECT image_path
925                FROM image_viewers
926                WHERE item_id = ? AND workspace_id = ?
927            }
928        }
929    }
930}