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}