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}