editor: Add Ctrl+scroll wheel zoom for buffer font size (#53452)

Sean Hagstrom and Dmitry Soluyanov created

Closes https://github.com/zed-industries/zed/pull/53452

Release Notes:

- Add event handling on editor to increase/decrease font-size when using
the scroll-wheel and holding the secondary modifier (Ctrl on
Linux/Windows, and Cmd on macOS)

Screen Capture:


https://github.com/user-attachments/assets/bf298be4-e2c9-470c-afef-b7e79c2d3ae6

---------

Co-authored-by: Dmitry Soluyanov <dimitri.soluyanov@yandex.ru>

Change summary

crates/editor/src/element.rs | 120 +++++++++++++++++++++++--------------
crates/zed/src/zed.rs        |  97 ++++++++++++++++++++++++++++++
2 files changed, 168 insertions(+), 49 deletions(-)

Detailed changes

crates/editor/src/element.rs 🔗

@@ -7672,59 +7672,85 @@ impl EditorElement {
                 .max(0.01);
 
             move |event: &ScrollWheelEvent, phase, window, cx| {
-                let scroll_sensitivity = {
-                    if event.modifiers.alt {
-                        fast_scroll_sensitivity
-                    } else {
-                        base_scroll_sensitivity
-                    }
-                };
-
                 if phase == DispatchPhase::Bubble && hitbox.should_handle_scroll(window) {
-                    delta = delta.coalesce(event.delta);
-                    editor.update(cx, |editor, cx| {
-                        let position_map: &PositionMap = &position_map;
-
-                        let line_height = position_map.line_height;
-                        let glyph_width = position_map.em_layout_width;
-                        let (delta, axis) = match delta {
-                            gpui::ScrollDelta::Pixels(mut pixels) => {
-                                //Trackpad
-                                let axis = position_map.snapshot.ongoing_scroll.filter(&mut pixels);
-                                (pixels, axis)
-                            }
+                    if event.modifiers.secondary() {
+                        let delta_y = match event.delta {
+                            ScrollDelta::Pixels(pixels) => pixels.y.into(),
+                            ScrollDelta::Lines(lines) => lines.y,
+                        };
+
+                        if delta_y > 0.0 {
+                            window.dispatch_action(
+                                Box::new(zed_actions::IncreaseBufferFontSize { persist: false }),
+                                cx,
+                            );
+                        } else if delta_y < 0.0 {
+                            window.dispatch_action(
+                                Box::new(zed_actions::DecreaseBufferFontSize { persist: false }),
+                                cx,
+                            );
+                        }
 
-                            gpui::ScrollDelta::Lines(lines) => {
-                                //Not trackpad
-                                let pixels = point(lines.x * glyph_width, lines.y * line_height);
-                                (pixels, None)
+                        cx.stop_propagation();
+                    } else {
+                        let scroll_sensitivity = {
+                            if event.modifiers.alt {
+                                fast_scroll_sensitivity
+                            } else {
+                                base_scroll_sensitivity
                             }
                         };
 
-                        let current_scroll_position = position_map.snapshot.scroll_position();
-                        let x = (current_scroll_position.x * ScrollPixelOffset::from(glyph_width)
-                            - ScrollPixelOffset::from(delta.x * scroll_sensitivity))
-                            / ScrollPixelOffset::from(glyph_width);
-                        let y = (current_scroll_position.y * ScrollPixelOffset::from(line_height)
-                            - ScrollPixelOffset::from(delta.y * scroll_sensitivity))
-                            / ScrollPixelOffset::from(line_height);
-                        let mut scroll_position =
-                            point(x, y).clamp(&point(0., 0.), &position_map.scroll_max);
-                        let forbid_vertical_scroll = editor.scroll_manager.forbid_vertical_scroll();
-                        if forbid_vertical_scroll {
-                            scroll_position.y = current_scroll_position.y;
-                        }
+                        delta = delta.coalesce(event.delta);
+                        editor.update(cx, |editor, cx| {
+                            let position_map: &PositionMap = &position_map;
+
+                            let line_height = position_map.line_height;
+                            let glyph_width = position_map.em_layout_width;
+                            let (delta, axis) = match delta {
+                                gpui::ScrollDelta::Pixels(mut pixels) => {
+                                    //Trackpad
+                                    let axis =
+                                        position_map.snapshot.ongoing_scroll.filter(&mut pixels);
+                                    (pixels, axis)
+                                }
 
-                        if scroll_position != current_scroll_position {
-                            editor.scroll(scroll_position, axis, window, cx);
-                            cx.stop_propagation();
-                        } else if y < 0. {
-                            // Due to clamping, we may fail to detect cases of overscroll to the top;
-                            // We want the scroll manager to get an update in such cases and detect the change of direction
-                            // on the next frame.
-                            cx.notify();
-                        }
-                    });
+                                gpui::ScrollDelta::Lines(lines) => {
+                                    //Not trackpad
+                                    let pixels =
+                                        point(lines.x * glyph_width, lines.y * line_height);
+                                    (pixels, None)
+                                }
+                            };
+
+                            let current_scroll_position = position_map.snapshot.scroll_position();
+                            let x = (current_scroll_position.x
+                                * ScrollPixelOffset::from(glyph_width)
+                                - ScrollPixelOffset::from(delta.x * scroll_sensitivity))
+                                / ScrollPixelOffset::from(glyph_width);
+                            let y = (current_scroll_position.y
+                                * ScrollPixelOffset::from(line_height)
+                                - ScrollPixelOffset::from(delta.y * scroll_sensitivity))
+                                / ScrollPixelOffset::from(line_height);
+                            let mut scroll_position =
+                                point(x, y).clamp(&point(0., 0.), &position_map.scroll_max);
+                            let forbid_vertical_scroll =
+                                editor.scroll_manager.forbid_vertical_scroll();
+                            if forbid_vertical_scroll {
+                                scroll_position.y = current_scroll_position.y;
+                            }
+
+                            if scroll_position != current_scroll_position {
+                                editor.scroll(scroll_position, axis, window, cx);
+                                cx.stop_propagation();
+                            } else if y < 0. {
+                                // Due to clamping, we may fail to detect cases of overscroll to the top;
+                                // We want the scroll manager to get an update in such cases and detect the change of direction
+                                // on the next frame.
+                                cx.notify();
+                            }
+                        });
+                    }
                 }
             }
         });

crates/zed/src/zed.rs 🔗

@@ -2406,8 +2406,8 @@ mod tests {
         DisplayPoint, Editor, MultiBufferOffset, SelectionEffects, display_map::DisplayRow,
     };
     use gpui::{
-        Action, AnyWindowHandle, App, AssetSource, BorrowAppContext, TestAppContext, UpdateGlobal,
-        VisualTestContext, WindowHandle, actions,
+        Action, AnyWindowHandle, App, AssetSource, BorrowAppContext, Modifiers, TestAppContext,
+        UpdateGlobal, VisualTestContext, WindowHandle, actions, point, px,
     };
     use language::LanguageRegistry;
     use languages::{markdown_lang, rust_lang};
@@ -4089,6 +4089,99 @@ mod tests {
         buffer.assert_released();
     }
 
+    #[gpui::test]
+    async fn test_editor_zoom_with_scroll_wheel(cx: &mut TestAppContext) {
+        let app_state = init_test(cx);
+        app_state
+            .fs
+            .as_fake()
+            .insert_tree(path!("/root"), json!({ "file.txt": "hello\nworld\n" }))
+            .await;
+
+        let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
+        let window =
+            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+        let workspace = window
+            .read_with(cx, |mw, _| mw.workspace().clone())
+            .unwrap();
+        let cx = &mut VisualTestContext::from_window(*window, cx);
+
+        let mouse_position = point(px(250.), px(250.));
+
+        let event_modifiers = {
+            #[cfg(target_os = "macos")]
+            {
+                Modifiers {
+                    platform: true,
+                    ..Modifiers::default()
+                }
+            }
+
+            #[cfg(not(target_os = "macos"))]
+            {
+                Modifiers {
+                    control: true,
+                    ..Modifiers::default()
+                }
+            }
+        };
+
+        workspace
+            .update_in(cx, |workspace, window, cx| {
+                workspace.open_abs_path(
+                    PathBuf::from(path!("/root/file.txt")),
+                    OpenOptions::default(),
+                    window,
+                    cx,
+                )
+            })
+            .await
+            .unwrap()
+            .downcast::<Editor>()
+            .unwrap();
+
+        cx.update(|window, cx| {
+            window.draw(cx).clear();
+        });
+
+        let initial_font_size =
+            cx.update(|_, cx| ThemeSettings::get_global(cx).buffer_font_size(cx).as_f32());
+
+        cx.simulate_event(gpui::ScrollWheelEvent {
+            position: mouse_position,
+            delta: gpui::ScrollDelta::Pixels(point(px(0.), px(1.))),
+            modifiers: event_modifiers,
+            ..Default::default()
+        });
+
+        let increased_font_size =
+            cx.update(|_, cx| ThemeSettings::get_global(cx).buffer_font_size(cx).as_f32());
+
+        assert!(
+            increased_font_size > initial_font_size,
+            "Editor buffer font-size should have increased from scroll-zoom"
+        );
+
+        cx.update(|window, cx| {
+            window.draw(cx).clear();
+        });
+
+        cx.simulate_event(gpui::ScrollWheelEvent {
+            position: mouse_position,
+            delta: gpui::ScrollDelta::Pixels(point(px(0.), px(-1.))),
+            modifiers: event_modifiers,
+            ..Default::default()
+        });
+
+        let decreased_font_size =
+            cx.update(|_, cx| ThemeSettings::get_global(cx).buffer_font_size(cx).as_f32());
+
+        assert!(
+            decreased_font_size < increased_font_size,
+            "Editor buffer font-size should have decreased from scroll-zoom"
+        );
+    }
+
     #[gpui::test]
     async fn test_navigation(cx: &mut TestAppContext) {
         let app_state = init_test(cx);