Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T02:59:15.982Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

image_viewer.rs

1063 lines · 35.3 KB · rust
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
Served at tenant.openagents/omega Member data and write actions are omitted.