Skip to content

Support zooming and panning in the image viewer#43944

Merged
MrSubidubi merged 17 commits intozed-industries:mainfrom
MostlyKIGuess:zoom-image
Jan 21, 2026
Merged

Support zooming and panning in the image viewer#43944
MrSubidubi merged 17 commits intozed-industries:mainfrom
MostlyKIGuess:zoom-image

Conversation

@MostlyKIGuess
Copy link
Contributor

@MostlyKIGuess MostlyKIGuess commented Dec 1, 2025

Implemented Pan and Zoom on the Image Viewer.

Demo:

2025-12-02.02-37-27.mp4

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.

- 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. - shortcuts keys for all zoom
actions.
@cla-bot cla-bot bot added the cla-signed The user has signed the Contributor License Agreement label Dec 1, 2025
@MrSubidubi MrSubidubi changed the title feat: add interactive image viewer with zoom and pan Support zooming and panning in image viewer Dec 1, 2025
@MrSubidubi MrSubidubi self-assigned this Dec 2, 2025
Copy link
Member

@MrSubidubi MrSubidubi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this! I'll give this a more proper review soonish (got some other things on my list first), but just to give you a few TODOs I just noticed:

  • Can we add buttons for the new actions to the toolbar, optimally the right corner? If we do not have icons for some of these, we usually source ours from https://lucide.dev/icons/ (Please only do if you actually have to, we should have quite a few already).
  • Whatever the checkered background is doing in your example is.. not optimal. Could you ensure that the checkered background is actually just drawn behind the image (so one can see the transparent parts of the image) and that the squares properly follow the scaling of the image?

Thanks!

- Refactor checkerboard rendering to respect the scaled image size and
pan - Add ImageViewToolbarControls that observes the active ImageView
and provides zoom in/out, reset
@MostlyKIGuess
Copy link
Contributor Author

Thanks for this! I'll give this a more proper review soonish (got some other things on my list first), but just to give you a few TODOs I just noticed:

* Can we add buttons for the new actions to the toolbar, optimally the right corner? If we do not have icons for some of these, we usually source ours from https://lucide.dev/icons/ (Please only do if you actually have to, we should have quite a few already).

* Whatever the checkered background is doing in your example is.. not optimal. Could you ensure that the checkered background is actually just drawn behind the image (so one can see the transparent parts of the image) and that the squares properly follow the scaling of the image?

Thanks!

Fixed the checkered background error and this toolbar:
Made it so that on hover it shows the shortcut ( I really love this part about Zed so I had to add it ).

image

@MostlyKIGuess
Copy link
Contributor Author

MostlyKIGuess commented Dec 2, 2025

weird that the terminal test failed on mac, I am looking into it, but can we re-run the test ?

Edit: Yeah the re-run made it pass

@MostlyKIGuess
Copy link
Contributor Author

The last commit vastly improved the performance, realized we didn't even need to draw it :D

@MostlyKIGuess
Copy link
Contributor Author

@MrSubidubi Could you review this?

Copy link
Member

@MrSubidubi MrSubidubi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the follow up! A few notes:

  • We do need the checkered background for partially transparent images, e.g. zed/crates/zed/resources/app-icon-preview@2x.png. We do have the outline, but I'd definitely prefer the checkered background, as removing it would feel like a regression.
  • Dragging does not account for the current zoom factor and can thus get out of sync. Can add a video of this if you'd like, but this basically means that the image does not move as many pixels as I drag on certain zoom levels. IMO this should be 1:1 mapping without any drift.
  • The zoom level in the bottom half of the screen can get out of the view. I think we should probably just remove it, because we have the appropiate zoom level now present in the toolbar as well.

That should be it for this round!

Also, quick note, no need to ping me here. I'll get back to you as soon as I can, but this can (sadly) sometimes take some time. Thanks!

@MostlyKIGuess
Copy link
Contributor Author

Thanks for the follow up! A few notes:

* We do need the checkered background for partially transparent images, e.g. `zed/crates/zed/resources/app-icon-preview@2x.png`. We do have the outline, but I'd definitely prefer the checkered background, as removing it would feel like a regression.

* Dragging does not account for the current zoom factor and can thus get out of sync. Can add a video of this if you'd like, but this basically means that the image does not move as many pixels as I drag on certain zoom levels. IMO this should be 1:1 mapping without any drift.

* The zoom level in the bottom half of the screen can get out of the view. I think we should probably just remove it, because we have the appropiate zoom level now present in the toolbar as well.

That should be it for this round!

Also, quick note, no need to ping me here. I'll get back to you as soon as I can, but this can (sadly) sometimes take some time. Thanks!

I don't know what you mean exactly by the 2nd point, video would be helpful yes!

@MrSubidubi
Copy link
Member

Sorry for the slight delay in review - here is the video:

Bildschirmaufnahme.2025-12-22.um.11.19.44.mov

The image should move as far as the cursor here IMO, so the amount of pixels moved by the mouse should be the same for the image itself.

Similarly, the sqares in the background should have a constant size, meaning that if I enlarge the images, I also enlarge the squares and vice versa. Does that make sense?

- compute left/top from container bounds and scaled size and use
  absolute positioning with pan offsets;
- fall back to flex centering when not positioning manually.
- only clear dragging state and notify when dragging was active.
@MostlyKIGuess
Copy link
Contributor Author

Sorry for the slight delay in review - here is the video:
Bildschirmaufnahme.2025-12-22.um.11.19.44.mov

The image should move as far as the cursor here IMO, so the amount of pixels moved by the mouse should be the same for the image itself.

Similarly, the sqares in the background should have a constant size, meaning that if I enlarge the images, I also enlarge the squares and vice versa. Does that make sense?

So this fixes the offset you are talking about and the squares are magnified with the zoom level.

I am although working on optimizing this rendering process, I guess we can do that in the next PR or this itself..

ideas like:

  • rendering gray first -> so we only need to render the light square -> calculate the region of drawing, loop and draw.
  • geometry batching ( I need to understand how to do this first.. )
  • I am looking around to find a way to reduce GPU calls so the rendering is not a heavy load with lots of calls..

@MostlyKIGuess
Copy link
Contributor Author

What's the standard way zed-staff profile render performance/draw calls for GPUI?
I'm on NixOs , so my default thought is RenderDoc, but I'm wondering if there are specific internal debug flags or a lighter workflow you use to spot these?

- now using 48.0 * zoom for square size, compute rows/cols from bounds,
  skip alternate squares
- clamp tile size to bounds and paint a single grey color.
@MostlyKIGuess
Copy link
Contributor Author

MostlyKIGuess commented Dec 23, 2025

  • The lag with 12 pixel rendering was practically too much to be considered optimal
    so the simplest fix for making it render faster was to just render bigger, hence the lesser calls..

  • I suppose this can be a TODO in gpui when they implement adding a background we can replace with the checkerboard style, something like .bg()? perhaps ( for this use case that is )

  • I do want to add that, using just the single color is fastest, if it's not a bigger issue, it would be much nicer to just have something like greyish background for transparent images.

@nk9
Copy link

nk9 commented Jan 10, 2026

I don't have a build setup to try this myself, but could this be made faster by building a small 2x2 or one-line checkerboard manually and then tiling it? And/or caching the background after creating it once at the correct size and then compositing the image overtop on draw.

@MostlyKIGuess
Copy link
Contributor Author

I don't have a build setup to try this myself, but could this be made faster by building a small 2x2 or one-line checkerboard manually and then tiling it? And/or caching the background after creating it once at the correct size and then compositing the image overtop on draw.

I have tried this yes, my optimization essentially will perform the same, right now I only draw half of the squares with 4x the size, and the one line thing you are talking about, I assume you are talking about PathBuilder/Vector path, GPUI doesn't support that and it also doesn't support the background you mentioned.

Copy link
Member

@MrSubidubi MrSubidubi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, this is so much better already, thank you very much! (And sorry for the delay from my side, was too occupied elsewhere).

I left quite a few comments below, but we should already be close to merging.

Most notably though, we still have two mayor issues as shown in the video here:

Bildschirmaufnahme.2026-01-14.um.18.08.23.mov

As you see,

  • there is a brief flicker at the start. This happens I believe because we flex layout once, and I think we should drop the special case for this here. This might be a bit tricky, but we could add a custom element for the image to draw it (would introduce a bit of boilerplate), but I'd like us to get rid of this. If you need more input for this, please hit me up and I'll get into more detail here/look at this myself.
  • zooming with the mouse wheel does not take the current positon of the mouse into account and that feels counterintuitive. I think when we resize there, the resize-operation should take the cursor position into account.

Other than that, looks so much better, thank you already! As for profiling, we are currently doing and experimenting a lot with tracy, but there is no official guideline. So far performance has been fine for me, although I fully agree checkered background support would be nice to have here. Feel free to throw a TODO somewhere, but we can also discuss more here.

Comment on lines +640 to +660
.child({
canvas(
move |_, _, _| {},
move |_, _, window, _cx| {
window.on_mouse_event(move |_event: &MouseUpEvent, phase, _window, cx| {
if phase == DispatchPhase::Bubble {
if let Some(entity) = entity.upgrade() {
entity.update(cx, |this, cx| {
if this.is_dragging {
this.is_dragging = false;
this.last_mouse_position = None;
cx.notify();
}
});
}
}
});
},
)
.absolute()
.size_0()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that listener can just live within the window without the canvas around it, and we only need to attach it if we are currently actually dragging the image, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes!

focus_handle: FocusHandle,
zoom_level: f32,
pan_offset: Point<Pixels>,
is_dragging: bool,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should make is_dragging a method like

fn is_dragging(&self) -> bool {
    self.last_mouse_position.is_some()
}

, right? We set both simultaneously, so perhaps better to just keep one of these.


actions!(
image_viewer,
[ZoomIn, ZoomOut, ResetZoom, FitToView, ZoomToActualSize]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we have some generic comments for these actions?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added very basic explanations

project,
focus_handle: cx.focus_handle(),
zoom_level: 1.0,
pan_offset: point(px(0.0), px(0.0)),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
pan_offset: point(px(0.0), px(0.0)),
pan_offset: Point::default(),

Nit, I think that can be done in a few more places and is a bit easier to read IMO.

Comment on lines +125 to +126
if let (Some(bounds), Some((img_width, img_height))) =
(self.container_bounds, self.image_size)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if let (Some(bounds), Some((img_width, img_height))) =
(self.container_bounds, self.image_size)
if let Some(bounds, (img_width, img_height)) =
self.container_bounds.zip(self.image_size)

Comment on lines +796 to +805
if let Some(item) = active_pane_item {
if let Some(image_view) = item.downcast::<ImageView>() {
self._subscription = Some(cx.observe(&image_view, |_, _, cx| {
cx.notify();
}));
self.image_view = Some(image_view.downgrade());
cx.notify();
return ToolbarItemLocation::PrimaryRight;
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can also if let chain here.

if event.modifiers.control || event.modifiers.platform {
let delta: f32 = match event.delta {
ScrollDelta::Pixels(pixels) => pixels.y.into(),
ScrollDelta::Lines(lines) => lines.y * 20.0,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's lift the 20.0 here in this method into another constant pleaes

} else {
let delta = match event.delta {
ScrollDelta::Pixels(pixels) => pixels,
ScrollDelta::Lines(lines) => point(px(lines.x * 20.0), px(lines.y * 20.0)),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
ScrollDelta::Lines(lines) => point(px(lines.x * 20.0), px(lines.y * 20.0)),
ScrollDelta::Lines(lines) => lines.map(|delta| px(delta * 20.0))

ScrollDelta::Pixels(pixels) => pixels,
ScrollDelta::Lines(lines) => point(px(lines.x * 20.0), px(lines.y * 20.0)),
};
self.pan_offset = point(self.pan_offset.x + delta.x, self.pan_offset.y + delta.y);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
self.pan_offset = point(self.pan_offset.x + delta.x, self.pan_offset.y + delta.y);
self.pan_offset += delta;

Comment on lines +234 to +235
let delta = point(event.position.x - last_pos.x, event.position.y - last_pos.y);
self.pan_offset = point(self.pan_offset.x + delta.x, self.pan_offset.y + delta.y);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
let delta = point(event.position.x - last_pos.x, event.position.y - last_pos.y);
self.pan_offset = point(self.pan_offset.x + delta.x, self.pan_offset.y + delta.y);
let delta = event.position - last_pos;
self.pan_offset += delta;

flicker

- added basic info on actions and extracted magic numbers to constants
- use intuitive scaling with translation of (1-scale_factor) on the
  offset
- attach global listener only during drag
@MostlyKIGuess
Copy link
Contributor Author

Thank you for the detailed review!

Other than that, looks so much better, thank you already! As for profiling, we are currently doing and experimenting a lot with tracy, but there is no official guideline. So far performance has been fine for me, although I fully agree checkered background support would be nice to have here. Feel free to throw a TODO somewhere, but we can also discuss more here.

Yeah, this is acceptable performance compared to earlier.
Maybe this goes into GPUI instead of it being here. So the follows ups would be:

  1. To have background support from GPUI.
  2. I also wanted to add that I have a PR in the gpui-ce for touchpad support in linux and mac. Perhaps that can be added later for zooming in through the touchpad.

Copy link
Member

@MrSubidubi MrSubidubi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, one mast mid-sized thing and I think we are good, thank you for all your efforts.

I think we need to refactor this a bit so that we have the container bounds of the current frame and not the bounds of the previous frame. Using the information from the previous frame, we

  • always do not render an image on the first frame, which causes a brief but noticeable flicker
  • always have a small flicker whenever we open other panels/resize because again, sizing calculation is done with sizes from the previous frame.

I think we need to change stuff of this to utilize a small wrapper instead that impls Element. We can then request the layout as we currently do and get the corresponding bounds during prepaint, which we can then utilize and which corresponds to the actual current bounds as opposed to the bounds from the previous frame. Then we should basically be able to use the current render of the image viewer in that prepaint, request the layout slightly delayed there and can prepaint and paint after as needed.

I think

impl Element for WithRemSize {
might be a good skeleton for this in combination with StickyItems (obviously we'd need this to be slightly different), but this would resolve that issue. What do you think?

@MostlyKIGuess
Copy link
Contributor Author

might be a good skeleton for this in combination with StickyItems (obviously we'd need this to be slightly different), but this would resolve that issue. What do you think?

I wasn't able to get flickering after setting position to left and top. I am not exactly sure by what you mean flickering here. If it's that blink feeling, maybe that could stem from me drawing the background color first and the checkboard pattern rendering before the image.

Parallely, I can implement it with the paint, prepaint, request_layout methods.

@MrSubidubi
Copy link
Member

maybe that could stem from me drawing the background color first and the checkboard pattern rendering before the image.

I think that is precisely what I wanted to describe and I think we cannot get rid of this unless going for an Element impl - I am happy to stand corrected here, but I think that we will not get around this if we want to update/paint the image on the same frame.

@MostlyKIGuess
Copy link
Contributor Author

I think that is precisely what I wanted to describe and I think we cannot get rid of this unless going for an Element impl - I am happy to stand corrected here, but I think that we will not get around this if we want to update/paint the image on the same frame.

Yeah, this can be added as a todo on GPUI perhaps where the render is parallel for the background and the image. To describe it precisely an ability to set an image background/pattern.

- Replace canvas-based container bounds tracking with
  ImageContentElement that wraps a Div and updates bounds during
  prepaint.
- This ensures positioning uses current-frame bounds instead of
  previous-frame bounds.
@MostlyKIGuess
Copy link
Contributor Author

This should help as the rendering now would use current frame bounds instead of the previous one.

@MrSubidubi
Copy link
Member

Went ahead and applied the last set of fixes myself - feel free to check these out, we should now always properly layout the image according to the current viewport, no matter what. If there is anything, please let me know, otherwise, I think we are good to go!

@MostlyKIGuess
Copy link
Contributor Author

Went ahead and applied the last set of fixes myself - feel free to check these out, we should now always properly layout the image according to the current viewport, no matter what. If there is anything, please let me know, otherwise, I think we are good to go!

Thank you! I tested it locally and works as expected.

I can add gesture support along with adding the following changes in GPUI: Gesture Support GPUI-CE PR as continuation.

Copy link
Member

@MrSubidubi MrSubidubi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for taking another look!

I think the pinch support sounds interesting, feel free to open a PR for that with the support for the image viewer here and I'll gladly take a look!

Lastly, thank you so so much for bearing with me here and for your perseverance, I for one think (and hope you agree) it was worthwhile and am very happy to merge this now. Thank you so much!

@MrSubidubi MrSubidubi changed the title Support zooming and panning in image viewer Support zooming and panning in the image viewer Jan 21, 2026
@MrSubidubi MrSubidubi merged commit 59738a7 into zed-industries:main Jan 21, 2026
27 checks passed
@github-project-automation github-project-automation bot moved this from Community PRs to Done in Quality Week – December 2025 Jan 21, 2026
@craig552uk
Copy link

Thank you @MostlyKIGuess @MrSubidubi for your work on this. I have been watching this PR eagerly!

@MostlyKIGuess
Copy link
Contributor Author

Lastly, thank you so so much for bearing with me here and for your perseverance, I for one think (and hope you agree) it was worthwhile and am very happy to merge this now. Thank you so much!

Thanks a ton for the thorough review and patience, really appreciate it. Learned a lot from this one.

Looking forward to the pinch-zoom follow-up: #47351

zcg pushed a commit to zcg/zedpro that referenced this pull request Jan 29, 2026
Implemented Pan and Zoom on the Image Viewer.

Demo:



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





Closes zed-industries#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>
@MrSubidubi
Copy link
Member

Hey @MostlyKIGuess ! Since this landed, we now have checkered backgruound support in our shaders! 🎉 Can I interest you in looking into that?

Happy to look at a PR, I'll probably do it myself at some point but currently don't have the time to do it immediately :/

@MostlyKIGuess
Copy link
Contributor Author

Hey @MostlyKIGuess ! Since this landed, we now have checkered backgruound support in our shaders! 🎉 Can I interest you in looking into that?

Happy to look at a PR, I'll probably do it myself at some point but currently don't have the time to do it immediately :/

Yeah sure, happy to take this up.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cla-signed The user has signed the Contributor License Agreement

Projects

Development

Successfully merging this pull request may close these issues.

Image Viewer: Pan & Zoom

4 participants