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}