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