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