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