Support zooming and panning in the image viewer (#43944)

MostlyK and MrSubidubi created

Implemented Pan and Zoom on the Image Viewer.

Demo:



https://github.com/user-attachments/assets/855bafe8-fdc2-4945-9bfb-e48382264806





Closes #9584



Release Notes:

- Add zoom in/out, reset, fit-to-view and zoom-to-actual actions 
- Support scroll-wheel zoom with modifier, click-and-drag panning 
- Renders a zoom percentage overlay. 
- ImageView Toolbar Added to control zoom in/out and fit view.

---------

Co-authored-by: MrSubidubi <finn@zed.dev>

Change summary

assets/keymaps/default-linux.json       |  11 
assets/keymaps/default-macos.json       |  12 
assets/keymaps/default-windows.json     |  11 
crates/image_viewer/src/image_viewer.rs | 613 +++++++++++++++++++++++---
crates/zed/src/zed.rs                   |   3 
5 files changed, 569 insertions(+), 81 deletions(-)

Detailed changes

assets/keymaps/default-linux.json 🔗

@@ -1342,6 +1342,17 @@
       "ctrl-shift-i": "branch_picker::FilterRemotes",
     },
   },
+  {
+    "context": "ImageViewer",
+    "bindings": {
+      "ctrl-=": "image_viewer::ZoomIn",
+      "ctrl-+": "image_viewer::ZoomIn",
+      "ctrl--": "image_viewer::ZoomOut",
+      "ctrl-0": "image_viewer::ResetZoom",
+      "ctrl-1": "image_viewer::ZoomToActualSize",
+      "ctrl-shift-0": "image_viewer::FitToView",
+    },
+  },
   {
     "context": "RunModal",
     "bindings": {

assets/keymaps/default-macos.json 🔗

@@ -1444,6 +1444,18 @@
       "cmd-shift-i": "branch_picker::FilterRemotes",
     },
   },
+  {
+    "context": "ImageViewer",
+    "use_key_equivalents": true,
+    "bindings": {
+      "cmd-=": "image_viewer::ZoomIn",
+      "cmd-+": "image_viewer::ZoomIn",
+      "cmd--": "image_viewer::ZoomOut",
+      "cmd-0": "image_viewer::ResetZoom",
+      "cmd-1": "image_viewer::ZoomToActualSize",
+      "cmd-shift-0": "image_viewer::FitToView",
+    },
+  },
   {
     "context": "RunModal",
     "bindings": {

assets/keymaps/default-windows.json 🔗

@@ -1363,6 +1363,17 @@
       "ctrl-shift-i": "branch_picker::FilterRemotes",
     },
   },
+  {
+    "context": "ImageViewer",
+    "bindings": {
+      "ctrl-=": "image_viewer::ZoomIn",
+      "ctrl-+": "image_viewer::ZoomIn",
+      "ctrl--": "image_viewer::ZoomOut",
+      "ctrl-0": "image_viewer::ResetZoom",
+      "ctrl-1": "image_viewer::ZoomToActualSize",
+      "ctrl-shift-0": "image_viewer::FitToView",
+    },
+  },
   {
     "context": "RunModal",
     "bindings": {

crates/image_viewer/src/image_viewer.rs 🔗

@@ -7,39 +7,79 @@ use anyhow::Context as _;
 use editor::{EditorSettings, items::entry_git_aware_label_color};
 use file_icons::FileIcons;
 use gpui::{
-    AnyElement, App, Bounds, Context, Entity, EventEmitter, FocusHandle, Focusable,
-    InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, Styled, Task, WeakEntity,
-    Window, canvas, div, fill, img, opaque_grey, point, size,
+    AnyElement, App, Bounds, Context, DispatchPhase, Element, ElementId, Entity, EventEmitter,
+    FocusHandle, Focusable, GlobalElementId, InspectorElementId, InteractiveElement, IntoElement,
+    LayoutId, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, Pixels,
+    Point, Render, ScrollDelta, ScrollWheelEvent, Style, Styled, Task, WeakEntity, Window, actions,
+    canvas, div, img, opaque_grey, point, px, size,
 };
 use language::File as _;
 use persistence::IMAGE_VIEWER;
 use project::{ImageItem, Project, ProjectPath, image_store::ImageItemEvent};
 use settings::Settings;
 use theme::{Theme, ThemeSettings};
-use ui::prelude::*;
+use ui::{Tooltip, prelude::*};
 use util::paths::PathExt;
 use workspace::{
-    ItemId, ItemSettings, Pane, ToolbarItemLocation, Workspace, WorkspaceId, delete_unloaded_items,
+    ItemId, ItemSettings, Pane, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace,
+    WorkspaceId, delete_unloaded_items,
     invalid_item_view::InvalidItemView,
-    item::{BreadcrumbText, Item, ProjectItem, SerializableItem, TabContentParams},
+    item::{BreadcrumbText, Item, ItemHandle, ProjectItem, SerializableItem, TabContentParams},
 };
 
 pub use crate::image_info::*;
 pub use crate::image_viewer_settings::*;
 
+actions!(
+    image_viewer,
+    [
+        /// Zoom in the image.
+        ZoomIn,
+        /// Zoom out the image.
+        ZoomOut,
+        /// Reset zoom to 100%.
+        ResetZoom,
+        /// Fit the image to view.
+        FitToView,
+        /// Zoom to actual size (100%).
+        ZoomToActualSize
+    ]
+);
+
+const MIN_ZOOM: f32 = 0.1;
+const MAX_ZOOM: f32 = 20.0;
+const ZOOM_STEP: f32 = 1.1;
+const SCROLL_LINE_MULTIPLIER: f32 = 20.0;
+const BASE_SQUARE_SIZE: f32 = 48.0;
+
 pub struct ImageView {
     image_item: Entity<ImageItem>,
     project: Entity<Project>,
     focus_handle: FocusHandle,
+    zoom_level: f32,
+    pan_offset: Point<Pixels>,
+    last_mouse_position: Option<Point<Pixels>>,
+    container_bounds: Option<Bounds<Pixels>>,
+    image_size: Option<(u32, u32)>,
 }
 
 impl ImageView {
+    fn is_dragging(&self) -> bool {
+        self.last_mouse_position.is_some()
+    }
+
     pub fn new(
         image_item: Entity<ImageItem>,
         project: Entity<Project>,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Self {
+        // Start loading the image to render in the background to prevent the view
+        // from flickering in most cases.
+        let _ = image_item.update(cx, |image, cx| {
+            image.image.clone().get_render_image(window, cx)
+        });
+
         cx.subscribe(&image_item, Self::on_image_event).detach();
         cx.on_release_in(window, |this, window, cx| {
             let image_data = this.image_item.read(cx).image.clone();
@@ -50,10 +90,20 @@ impl ImageView {
         })
         .detach();
 
+        let image_size = image_item
+            .read(cx)
+            .image_metadata
+            .map(|m| (m.width, m.height));
+
         Self {
             image_item,
             project,
             focus_handle: cx.focus_handle(),
+            zoom_level: 1.0,
+            pan_offset: Point::default(),
+            last_mouse_position: None,
+            container_bounds: None,
+            image_size,
         }
     }
 
@@ -67,12 +117,339 @@ impl ImageView {
             ImageItemEvent::MetadataUpdated
             | ImageItemEvent::FileHandleChanged
             | ImageItemEvent::Reloaded => {
+                self.image_size = self
+                    .image_item
+                    .read(cx)
+                    .image_metadata
+                    .map(|m| (m.width, m.height));
                 cx.emit(ImageViewEvent::TitleChanged);
                 cx.notify();
             }
             ImageItemEvent::ReloadNeeded => {}
         }
     }
+
+    fn zoom_in(&mut self, _: &ZoomIn, _window: &mut Window, cx: &mut Context<Self>) {
+        self.set_zoom(self.zoom_level * ZOOM_STEP, None, cx);
+    }
+
+    fn zoom_out(&mut self, _: &ZoomOut, _window: &mut Window, cx: &mut Context<Self>) {
+        self.set_zoom(self.zoom_level / ZOOM_STEP, None, cx);
+    }
+
+    fn reset_zoom(&mut self, _: &ResetZoom, _window: &mut Window, cx: &mut Context<Self>) {
+        self.zoom_level = 1.0;
+        self.pan_offset = Point::default();
+        cx.notify();
+    }
+
+    fn fit_to_view(&mut self, _: &FitToView, _window: &mut Window, cx: &mut Context<Self>) {
+        if let Some((bounds, (img_width, img_height))) = self.container_bounds.zip(self.image_size)
+        {
+            let container_width: f32 = bounds.size.width.into();
+            let container_height: f32 = bounds.size.height.into();
+            let scale_x = container_width / img_width as f32;
+            let scale_y = container_height / img_height as f32;
+            self.zoom_level = scale_x.min(scale_y).min(1.0);
+            self.pan_offset = Point::default();
+            cx.notify();
+        }
+    }
+
+    fn zoom_to_actual_size(
+        &mut self,
+        _: &ZoomToActualSize,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.zoom_level = 1.0;
+        self.pan_offset = Point::default();
+        cx.notify();
+    }
+
+    fn set_zoom(
+        &mut self,
+        new_zoom: f32,
+        zoom_center: Option<Point<Pixels>>,
+        cx: &mut Context<Self>,
+    ) {
+        let old_zoom = self.zoom_level;
+        self.zoom_level = new_zoom.clamp(MIN_ZOOM, MAX_ZOOM);
+
+        if let Some((center, bounds)) = zoom_center.zip(self.container_bounds) {
+            let relative_center = point(
+                center.x - bounds.origin.x - bounds.size.width / 2.0,
+                center.y - bounds.origin.y - bounds.size.height / 2.0,
+            );
+
+            let mouse_offset_from_image = relative_center - self.pan_offset;
+
+            let zoom_ratio = self.zoom_level / old_zoom;
+
+            self.pan_offset += mouse_offset_from_image * (1.0 - zoom_ratio);
+        }
+
+        cx.notify();
+    }
+
+    fn handle_scroll_wheel(
+        &mut self,
+        event: &ScrollWheelEvent,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        if event.modifiers.control || event.modifiers.platform {
+            let delta: f32 = match event.delta {
+                ScrollDelta::Pixels(pixels) => pixels.y.into(),
+                ScrollDelta::Lines(lines) => lines.y * SCROLL_LINE_MULTIPLIER,
+            };
+            let zoom_factor = if delta > 0.0 {
+                1.0 + delta.abs() * 0.01
+            } else {
+                1.0 / (1.0 + delta.abs() * 0.01)
+            };
+            self.set_zoom(self.zoom_level * zoom_factor, Some(event.position), cx);
+        } else {
+            let delta = match event.delta {
+                ScrollDelta::Pixels(pixels) => pixels,
+                ScrollDelta::Lines(lines) => lines.map(|d| px(d * SCROLL_LINE_MULTIPLIER)),
+            };
+            self.pan_offset += delta;
+            cx.notify();
+        }
+    }
+
+    fn handle_mouse_down(
+        &mut self,
+        event: &MouseDownEvent,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        if event.button == MouseButton::Left || event.button == MouseButton::Middle {
+            self.last_mouse_position = Some(event.position);
+            cx.notify();
+        }
+    }
+
+    fn handle_mouse_up(
+        &mut self,
+        _event: &MouseUpEvent,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.last_mouse_position = None;
+        cx.notify();
+    }
+
+    fn handle_mouse_move(
+        &mut self,
+        event: &MouseMoveEvent,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        if self.is_dragging() {
+            if let Some(last_pos) = self.last_mouse_position {
+                let delta = event.position - last_pos;
+                self.pan_offset += delta;
+            }
+            self.last_mouse_position = Some(event.position);
+            cx.notify();
+        }
+    }
+}
+
+struct ImageContentElement {
+    image_view: Entity<ImageView>,
+}
+
+impl ImageContentElement {
+    fn new(image_view: Entity<ImageView>) -> Self {
+        Self { image_view }
+    }
+}
+
+impl IntoElement for ImageContentElement {
+    type Element = Self;
+
+    fn into_element(self) -> Self::Element {
+        self
+    }
+}
+
+impl Element for ImageContentElement {
+    type RequestLayoutState = ();
+    type PrepaintState = Option<(AnyElement, bool)>;
+
+    fn id(&self) -> Option<ElementId> {
+        None
+    }
+
+    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
+        None
+    }
+
+    fn request_layout(
+        &mut self,
+        _id: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> (LayoutId, Self::RequestLayoutState) {
+        (
+            window.request_layout(
+                Style {
+                    size: size(relative(1.).into(), relative(1.).into()),
+                    ..Default::default()
+                },
+                [],
+                cx,
+            ),
+            (),
+        )
+    }
+
+    fn prepaint(
+        &mut self,
+        _id: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
+        bounds: Bounds<Pixels>,
+        _request_layout: &mut Self::RequestLayoutState,
+        window: &mut Window,
+        cx: &mut App,
+    ) -> Self::PrepaintState {
+        let image_view = self.image_view.read(cx);
+        let image = image_view.image_item.read(cx).image.clone();
+
+        let zoom_level = image_view.zoom_level;
+        let pan_offset = image_view.pan_offset;
+        let border_color = cx.theme().colors().border;
+
+        let is_dragging = image_view.is_dragging();
+
+        let scaled_size = image_view
+            .image_size
+            .map(|(w, h)| (px(w as f32 * zoom_level), px(h as f32 * zoom_level)));
+
+        let (mut left, mut top) = (px(0.0), px(0.0));
+        let mut scaled_width = px(0.0);
+        let mut scaled_height = px(0.0);
+
+        if let Some((width, height)) = scaled_size {
+            scaled_width = width;
+            scaled_height = height;
+
+            let center_x = bounds.size.width / 2.0;
+            let center_y = bounds.size.height / 2.0;
+
+            left = center_x - (scaled_width / 2.0) + pan_offset.x;
+            top = center_y - (scaled_height / 2.0) + pan_offset.y;
+        }
+
+        self.image_view.update(cx, |this, _| {
+            this.container_bounds = Some(bounds);
+        });
+
+        let mut image_content = div()
+            .relative()
+            .size_full()
+            .child(
+                div()
+                    .absolute()
+                    .left(left)
+                    .top(top)
+                    .w(scaled_width)
+                    .h(scaled_height)
+                    .child(
+                        canvas(
+                            |_, _, _| {},
+                            move |bounds, _, window, _cx| {
+                                let bounds_x: f32 = bounds.origin.x.into();
+                                let bounds_y: f32 = bounds.origin.y.into();
+                                let bounds_width: f32 = bounds.size.width.into();
+                                let bounds_height: f32 = bounds.size.height.into();
+                                let square_size = BASE_SQUARE_SIZE * zoom_level;
+                                let cols = (bounds_width / square_size).ceil() as i32 + 1;
+                                let rows = (bounds_height / square_size).ceil() as i32 + 1;
+                                for row in 0..rows {
+                                    for col in 0..cols {
+                                        if (row + col) % 2 == 0 {
+                                            continue;
+                                        }
+                                        let x = bounds_x + col as f32 * square_size;
+                                        let y = bounds_y + row as f32 * square_size;
+                                        let w = square_size.min(bounds_x + bounds_width - x);
+                                        let h = square_size.min(bounds_y + bounds_height - y);
+                                        if w > 0.0 && h > 0.0 {
+                                            let rect = Bounds::new(
+                                                point(px(x), px(y)),
+                                                size(px(w), px(h)),
+                                            );
+                                            window.paint_quad(gpui::fill(
+                                                rect,
+                                                opaque_grey(0.6, 1.0),
+                                            ));
+                                        }
+                                    }
+                                }
+                                let border_rect = Bounds::new(
+                                    point(px(bounds_x), px(bounds_y)),
+                                    size(px(bounds_width), px(bounds_height)),
+                                );
+                                window.paint_quad(gpui::outline(
+                                    border_rect,
+                                    border_color,
+                                    gpui::BorderStyle::default(),
+                                ));
+                            },
+                        )
+                        .size_full()
+                        .absolute()
+                        .top_0()
+                        .left_0()
+                        .bg(gpui::rgb(0xCCCCCD)),
+                    )
+                    .child({
+                        img(image)
+                            .id(("image-viewer-image", self.image_view.entity_id()))
+                            .size_full()
+                    }),
+            )
+            .into_any_element();
+
+        image_content.prepaint_as_root(bounds.origin, bounds.size.into(), window, cx);
+        Some((image_content, is_dragging))
+    }
+
+    fn paint(
+        &mut self,
+        _id: Option<&GlobalElementId>,
+        _inspector_id: Option<&InspectorElementId>,
+        _bounds: Bounds<Pixels>,
+        _request_layout: &mut Self::RequestLayoutState,
+        prepaint: &mut Self::PrepaintState,
+        window: &mut Window,
+        cx: &mut App,
+    ) {
+        let Some((mut element, is_dragging)) = prepaint.take() else {
+            return;
+        };
+
+        if is_dragging {
+            let image_view = self.image_view.downgrade();
+            window.on_mouse_event(move |_event: &MouseUpEvent, phase, _window, cx| {
+                if phase == DispatchPhase::Bubble
+                    && let Some(entity) = image_view.upgrade()
+                {
+                    entity.update(cx, |this, cx| {
+                        this.last_mouse_position = None;
+                        cx.notify();
+                    });
+                }
+            });
+        }
+
+        element.paint(window, cx);
+    }
 }
 
 pub enum ImageViewEvent {
@@ -191,6 +568,11 @@ impl Item for ImageView {
             image_item: self.image_item.clone(),
             project: self.project.clone(),
             focus_handle: cx.focus_handle(),
+            zoom_level: self.zoom_level,
+            pan_offset: self.pan_offset,
+            last_mouse_position: None,
+            container_bounds: None,
+            image_size: self.image_size,
         })))
     }
 
@@ -303,81 +685,36 @@ impl Focusable for ImageView {
 }
 
 impl Render for ImageView {
-    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
-        let image = self.image_item.read(cx).image.clone();
-        let checkered_background =
-            |bounds: Bounds<Pixels>, _, window: &mut Window, _cx: &mut App| {
-                let square_size: f32 = 32.0;
-
-                let start_y = bounds.origin.y.into();
-                let height: f32 = bounds.size.height.into();
-                let start_x = bounds.origin.x.into();
-                let width: f32 = bounds.size.width.into();
-
-                let mut y = start_y;
-                let mut x = start_x;
-                let mut color_swapper = true;
-                // draw checkerboard pattern
-                while y < start_y + height {
-                    // Keeping track of the grid in order to be resilient to resizing
-                    let start_swap = color_swapper;
-                    while x < start_x + width {
-                        // Clamp square dimensions to not exceed bounds
-                        let square_width = square_size.min(start_x + width - x);
-                        let square_height = square_size.min(start_y + height - y);
-
-                        let rect = Bounds::new(
-                            point(px(x), px(y)),
-                            size(px(square_width), px(square_height)),
-                        );
-
-                        let color = if color_swapper {
-                            opaque_grey(0.6, 0.4)
-                        } else {
-                            opaque_grey(0.7, 0.4)
-                        };
-
-                        window.paint_quad(fill(rect, color));
-                        color_swapper = !color_swapper;
-                        x += square_size;
-                    }
-                    x = start_x;
-                    color_swapper = !start_swap;
-                    y += square_size;
-                }
-            };
-
-        div().track_focus(&self.focus_handle(cx)).size_full().child(
-            div()
-                .flex()
-                .justify_center()
-                .items_center()
-                .w_full()
-                // TODO: In browser based Tailwind & Flex this would be h-screen and we'd use w-full
-                .h_full()
-                .child(
-                    div()
-                        .relative()
-                        .max_w_full()
-                        .max_h_full()
-                        .child(
-                            canvas(|_, _, _| (), checkered_background)
-                                .border_2()
-                                .border_color(cx.theme().styles.colors.border)
-                                .size_full()
-                                .absolute()
-                                .top_0()
-                                .left_0(),
-                        )
-                        .child(
-                            img(image)
-                                .object_fit(ObjectFit::ScaleDown)
-                                .max_w_full()
-                                .max_h_full()
-                                .id("img"),
-                        ),
-                ),
-        )
+    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        div()
+            .track_focus(&self.focus_handle(cx))
+            .key_context("ImageViewer")
+            .on_action(cx.listener(Self::zoom_in))
+            .on_action(cx.listener(Self::zoom_out))
+            .on_action(cx.listener(Self::reset_zoom))
+            .on_action(cx.listener(Self::fit_to_view))
+            .on_action(cx.listener(Self::zoom_to_actual_size))
+            .size_full()
+            .relative()
+            .bg(cx.theme().colors().editor_background)
+            .child(
+                div()
+                    .id("image-container")
+                    .size_full()
+                    .overflow_hidden()
+                    .cursor(if self.is_dragging() {
+                        gpui::CursorStyle::ClosedHand
+                    } else {
+                        gpui::CursorStyle::OpenHand
+                    })
+                    .on_scroll_wheel(cx.listener(Self::handle_scroll_wheel))
+                    .on_mouse_down(MouseButton::Left, cx.listener(Self::handle_mouse_down))
+                    .on_mouse_down(MouseButton::Middle, cx.listener(Self::handle_mouse_down))
+                    .on_mouse_up(MouseButton::Left, cx.listener(Self::handle_mouse_up))
+                    .on_mouse_up(MouseButton::Middle, cx.listener(Self::handle_mouse_up))
+                    .on_mouse_move(cx.listener(Self::handle_mouse_move))
+                    .child(ImageContentElement::new(cx.entity())),
+            )
     }
 }
 
@@ -411,6 +748,120 @@ impl ProjectItem for ImageView {
     }
 }
 
+pub struct ImageViewToolbarControls {
+    image_view: Option<WeakEntity<ImageView>>,
+    _subscription: Option<gpui::Subscription>,
+}
+
+impl ImageViewToolbarControls {
+    pub fn new() -> Self {
+        Self {
+            image_view: None,
+            _subscription: None,
+        }
+    }
+}
+
+impl Render for ImageViewToolbarControls {
+    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let Some(image_view) = self.image_view.as_ref().and_then(|v| v.upgrade()) else {
+            return div().into_any_element();
+        };
+
+        let zoom_level = image_view.read(cx).zoom_level;
+        let zoom_percentage = format!("{}%", (zoom_level * 100.0).round() as i32);
+
+        h_flex()
+            .gap_1()
+            .child(
+                IconButton::new("zoom-out", IconName::Dash)
+                    .icon_size(IconSize::Small)
+                    .tooltip(|_window, cx| Tooltip::for_action("Zoom Out", &ZoomOut, cx))
+                    .on_click({
+                        let image_view = image_view.downgrade();
+                        move |_, window, cx| {
+                            if let Some(view) = image_view.upgrade() {
+                                view.update(cx, |this, cx| {
+                                    this.zoom_out(&ZoomOut, window, cx);
+                                });
+                            }
+                        }
+                    }),
+            )
+            .child(
+                Button::new("zoom-level", zoom_percentage)
+                    .label_size(LabelSize::Small)
+                    .tooltip(|_window, cx| Tooltip::for_action("Reset Zoom", &ResetZoom, cx))
+                    .on_click({
+                        let image_view = image_view.downgrade();
+                        move |_, window, cx| {
+                            if let Some(view) = image_view.upgrade() {
+                                view.update(cx, |this, cx| {
+                                    this.reset_zoom(&ResetZoom, window, cx);
+                                });
+                            }
+                        }
+                    }),
+            )
+            .child(
+                IconButton::new("zoom-in", IconName::Plus)
+                    .icon_size(IconSize::Small)
+                    .tooltip(|_window, cx| Tooltip::for_action("Zoom In", &ZoomIn, cx))
+                    .on_click({
+                        let image_view = image_view.downgrade();
+                        move |_, window, cx| {
+                            if let Some(view) = image_view.upgrade() {
+                                view.update(cx, |this, cx| {
+                                    this.zoom_in(&ZoomIn, window, cx);
+                                });
+                            }
+                        }
+                    }),
+            )
+            .child(
+                IconButton::new("fit-to-view", IconName::Maximize)
+                    .icon_size(IconSize::Small)
+                    .tooltip(|_window, cx| Tooltip::for_action("Fit to View", &FitToView, cx))
+                    .on_click({
+                        let image_view = image_view.downgrade();
+                        move |_, window, cx| {
+                            if let Some(view) = image_view.upgrade() {
+                                view.update(cx, |this, cx| {
+                                    this.fit_to_view(&FitToView, window, cx);
+                                });
+                            }
+                        }
+                    }),
+            )
+            .into_any_element()
+    }
+}
+
+impl EventEmitter<ToolbarItemEvent> for ImageViewToolbarControls {}
+
+impl ToolbarItemView for ImageViewToolbarControls {
+    fn set_active_pane_item(
+        &mut self,
+        active_pane_item: Option<&dyn ItemHandle>,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> ToolbarItemLocation {
+        self.image_view = None;
+        self._subscription = None;
+
+        if let Some(item) = active_pane_item.and_then(|i| i.downcast::<ImageView>()) {
+            self._subscription = Some(cx.observe(&item, |_, _, cx| {
+                cx.notify();
+            }));
+            self.image_view = Some(item.downgrade());
+            cx.notify();
+            return ToolbarItemLocation::PrimaryRight;
+        }
+
+        ToolbarItemLocation::Hidden
+    }
+}
+
 pub fn init(cx: &mut App) {
     workspace::register_project_item::<ImageView>(cx);
     workspace::register_serializable_item::<ImageView>(cx);

crates/zed/src/zed.rs 🔗

@@ -1241,6 +1241,8 @@ fn initialize_pane(
             toolbar.add_item(agent_diff_toolbar, window, cx);
             let basedpyright_banner = cx.new(|cx| BasedPyrightBanner::new(workspace, cx));
             toolbar.add_item(basedpyright_banner, window, cx);
+            let image_view_toolbar = cx.new(|_| image_viewer::ImageViewToolbarControls::new());
+            toolbar.add_item(image_view_toolbar, window, cx);
         })
     });
 }
@@ -4794,6 +4796,7 @@ mod tests {
                 "git_picker",
                 "go_to_line",
                 "icon_theme_selector",
+                "image_viewer",
                 "inline_assistant",
                 "journal",
                 "keymap_editor",