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

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>
This commit is contained in:
MostlyKandMrSubidubi authored and GitHub committed 2026-01-21 22:48:30 +01:00
1 parent 20bc0f6f67
commit b162fb4019
5 files changed
+569 -81

No files matched your search

+11
View File
@@ -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": {
+12
View File
@@ -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": {
+11
View File
@@ -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": {
+532 -81
View File
@@ -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);
+3
View File
@@ -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",