From ac2f097559ecbaab6f55ca5a519f53b80ac54afb Mon Sep 17 00:00:00 2001 From: MostlyK <135974627+MostlyKIGuess@users.noreply.github.com> Date: Wed, 11 Mar 2026 22:42:48 +0530 Subject: [PATCH] image_viewer: Add pinch event support (#47351) This change implements pinch / magnification gesture handling. This uses the following wayland [protocol](https://wayland.app/protocols/pointer-gestures-unstable-v1). And the following [API](https://developer.apple.com/documentation/appkit/nsevent/magnification) for mac. - Original: https://github.com/gpui-ce/gpui-ce/pull/11 Release Notes: - Zooming works with pinching in and out inside Image Viewer --- crates/gpui/src/elements/div.rs | 92 ++++++++++++++++ crates/gpui/src/interactive.rs | 55 ++++++++++ crates/gpui/src/window.rs | 6 ++ crates/gpui_linux/src/linux/wayland/client.rs | 100 ++++++++++++++++++ crates/gpui_macos/src/events.rs | 25 ++++- crates/gpui_macos/src/window.rs | 4 + crates/image_viewer/src/image_viewer.rs | 38 ++++++- 7 files changed, 314 insertions(+), 6 deletions(-) diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index 3599affc3c792f3c93b3b94cfc44740d7c38caf7..bf185b1b6cc20e0f0f484fd0029c78a6211e6a3a 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -15,6 +15,8 @@ //! and Tailwind-like styling that you can use to build your own custom elements. Div is //! constructed by combining these two systems into an all-in-one element. +#[cfg(any(target_os = "linux", target_os = "macos"))] +use crate::PinchEvent; use crate::{ AbsoluteLength, Action, AnyDrag, AnyElement, AnyTooltip, AnyView, App, Bounds, ClickEvent, DispatchPhase, Display, Element, ElementId, Entity, FocusHandle, Global, GlobalElementId, @@ -353,6 +355,43 @@ impl Interactivity { })); } + /// Bind the given callback to pinch gesture events during the bubble phase. + /// + /// Note: This event is only available on macOS and Wayland (Linux). + /// On Windows, pinch gestures are simulated as scroll wheel events with Ctrl held. + /// + /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. + #[cfg(any(target_os = "linux", target_os = "macos"))] + pub fn on_pinch(&mut self, listener: impl Fn(&PinchEvent, &mut Window, &mut App) + 'static) { + self.pinch_listeners + .push(Box::new(move |event, phase, hitbox, window, cx| { + if phase == DispatchPhase::Bubble && hitbox.is_hovered(window) { + (listener)(event, window, cx); + } + })); + } + + /// Bind the given callback to pinch gesture events during the capture phase. + /// + /// Note: This event is only available on macOS and Wayland (Linux). + /// On Windows, pinch gestures are simulated as scroll wheel events with Ctrl held. + /// + /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. + #[cfg(any(target_os = "linux", target_os = "macos"))] + pub fn capture_pinch( + &mut self, + listener: impl Fn(&PinchEvent, &mut Window, &mut App) + 'static, + ) { + self.pinch_listeners + .push(Box::new(move |event, phase, _hitbox, window, cx| { + if phase == DispatchPhase::Capture { + (listener)(event, window, cx); + } else { + cx.propagate(); + } + })); + } + /// Bind the given callback to an action dispatch during the capture phase. /// The imperative API equivalent to [`InteractiveElement::capture_action`]. /// @@ -635,6 +674,16 @@ impl Interactivity { pub fn block_mouse_except_scroll(&mut self) { self.hitbox_behavior = HitboxBehavior::BlockMouseExceptScroll; } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + fn has_pinch_listeners(&self) -> bool { + !self.pinch_listeners.is_empty() + } + + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + fn has_pinch_listeners(&self) -> bool { + false + } } /// A trait for elements that want to use the standard GPUI event handlers that don't @@ -905,6 +954,34 @@ pub trait InteractiveElement: Sized { self } + /// Bind the given callback to pinch gesture events during the bubble phase. + /// The fluent API equivalent to [`Interactivity::on_pinch`]. + /// + /// Note: This event is only available on macOS and Wayland (Linux). + /// On Windows, pinch gestures are simulated as scroll wheel events with Ctrl held. + /// + /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. + #[cfg(any(target_os = "linux", target_os = "macos"))] + fn on_pinch(mut self, listener: impl Fn(&PinchEvent, &mut Window, &mut App) + 'static) -> Self { + self.interactivity().on_pinch(listener); + self + } + + /// Bind the given callback to pinch gesture events during the capture phase. + /// The fluent API equivalent to [`Interactivity::capture_pinch`]. + /// + /// Note: This event is only available on macOS and Wayland (Linux). + /// On Windows, pinch gestures are simulated as scroll wheel events with Ctrl held. + /// + /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. + #[cfg(any(target_os = "linux", target_os = "macos"))] + fn capture_pinch( + mut self, + listener: impl Fn(&PinchEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.interactivity().capture_pinch(listener); + self + } /// Capture the given action, before normal action dispatch can fire. /// The fluent API equivalent to [`Interactivity::capture_action`]. /// @@ -1290,6 +1367,10 @@ pub(crate) type MouseMoveListener = pub(crate) type ScrollWheelListener = Box; +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub(crate) type PinchListener = + Box; + pub(crate) type ClickListener = Rc; pub(crate) type DragListener = @@ -1644,6 +1725,8 @@ pub struct Interactivity { pub(crate) mouse_pressure_listeners: Vec, pub(crate) mouse_move_listeners: Vec, pub(crate) scroll_wheel_listeners: Vec, + #[cfg(any(target_os = "linux", target_os = "macos"))] + pub(crate) pinch_listeners: Vec, pub(crate) key_down_listeners: Vec, pub(crate) key_up_listeners: Vec, pub(crate) modifiers_changed_listeners: Vec, @@ -1847,6 +1930,7 @@ impl Interactivity { || !self.click_listeners.is_empty() || !self.aux_click_listeners.is_empty() || !self.scroll_wheel_listeners.is_empty() + || self.has_pinch_listeners() || self.drag_listener.is_some() || !self.drop_listeners.is_empty() || self.tooltip_builder.is_some() @@ -2213,6 +2297,14 @@ impl Interactivity { }) } + #[cfg(any(target_os = "linux", target_os = "macos"))] + for listener in self.pinch_listeners.drain(..) { + let hitbox = hitbox.clone(); + window.on_mouse_event(move |event: &PinchEvent, phase, window, cx| { + listener(event, phase, &hitbox, window, cx); + }) + } + if self.hover_style.is_some() || self.base_style.mouse_cursor.is_some() || cx.active_drag.is_some() && !self.drag_over_styles.is_empty() diff --git a/crates/gpui/src/interactive.rs b/crates/gpui/src/interactive.rs index 5316a5992bb41d11ef5b6518555a9a20795f894c..3d3ddb49f70b2f96772627d085c93ce31b6dc0b5 100644 --- a/crates/gpui/src/interactive.rs +++ b/crates/gpui/src/interactive.rs @@ -17,6 +17,9 @@ pub trait KeyEvent: InputEvent {} /// A mouse event from the platform. pub trait MouseEvent: InputEvent {} +/// A gesture event from the platform. +pub trait GestureEvent: InputEvent {} + /// The key down event equivalent for the platform. #[derive(Clone, Debug, Eq, PartialEq)] pub struct KeyDownEvent { @@ -467,6 +470,51 @@ impl Default for ScrollDelta { } } +/// A pinch gesture event from the platform, generated when the user performs +/// a pinch-to-zoom gesture (typically on a trackpad). +/// +/// Note: This event is only available on macOS and Wayland (Linux). +/// On Windows, pinch gestures are simulated as scroll wheel events with Ctrl held. +#[derive(Clone, Debug, Default)] +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub struct PinchEvent { + /// The position of the pinch center on the window. + pub position: Point, + + /// The zoom delta for this event. + /// Positive values indicate zooming in, negative values indicate zooming out. + /// For example, 0.1 represents a 10% zoom increase. + pub delta: f32, + + /// The modifiers that were held down during the pinch gesture. + pub modifiers: Modifiers, + + /// The phase of the pinch gesture. + pub phase: TouchPhase, +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +impl Sealed for PinchEvent {} +#[cfg(any(target_os = "linux", target_os = "macos"))] +impl InputEvent for PinchEvent { + fn to_platform_input(self) -> PlatformInput { + PlatformInput::Pinch(self) + } +} +#[cfg(any(target_os = "linux", target_os = "macos"))] +impl GestureEvent for PinchEvent {} +#[cfg(any(target_os = "linux", target_os = "macos"))] +impl MouseEvent for PinchEvent {} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +impl Deref for PinchEvent { + type Target = Modifiers; + + fn deref(&self) -> &Self::Target { + &self.modifiers + } +} + impl ScrollDelta { /// Returns true if this is a precise scroll delta in pixels. pub fn precise(&self) -> bool { @@ -626,6 +674,9 @@ pub enum PlatformInput { MouseExited(MouseExitEvent), /// The scroll wheel was used. ScrollWheel(ScrollWheelEvent), + /// A pinch gesture was performed. + #[cfg(any(target_os = "linux", target_os = "macos"))] + Pinch(PinchEvent), /// Files were dragged and dropped onto the window. FileDrop(FileDropEvent), } @@ -642,6 +693,8 @@ impl PlatformInput { PlatformInput::MousePressure(event) => Some(event), PlatformInput::MouseExited(event) => Some(event), PlatformInput::ScrollWheel(event) => Some(event), + #[cfg(any(target_os = "linux", target_os = "macos"))] + PlatformInput::Pinch(event) => Some(event), PlatformInput::FileDrop(event) => Some(event), } } @@ -657,6 +710,8 @@ impl PlatformInput { PlatformInput::MousePressure(_) => None, PlatformInput::MouseExited(_) => None, PlatformInput::ScrollWheel(_) => None, + #[cfg(any(target_os = "linux", target_os = "macos"))] + PlatformInput::Pinch(_) => None, PlatformInput::FileDrop(_) => None, } } diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 3fcb911d2c58f8968bc6b0c66f26ed2de365dd53..e3c61a4fd31f35df591f20075221907270e352c8 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -3945,6 +3945,12 @@ impl Window { self.modifiers = scroll_wheel.modifiers; PlatformInput::ScrollWheel(scroll_wheel) } + #[cfg(any(target_os = "linux", target_os = "macos"))] + PlatformInput::Pinch(pinch) => { + self.mouse_position = pinch.position; + self.modifiers = pinch.modifiers; + PlatformInput::Pinch(pinch) + } // Translate dragging and dropping of external files from the operating system // to internal drag and drop events. PlatformInput::FileDrop(file_drop) => match file_drop { diff --git a/crates/gpui_linux/src/linux/wayland/client.rs b/crates/gpui_linux/src/linux/wayland/client.rs index 8dd48b878cc1ffcb87201e9b1b252966bfce5efb..ce49fca37232f256e570f584272519d8d6f34dd8 100644 --- a/crates/gpui_linux/src/linux/wayland/client.rs +++ b/crates/gpui_linux/src/linux/wayland/client.rs @@ -36,6 +36,9 @@ use wayland_client::{ wl_shm_pool, wl_surface, }, }; +use wayland_protocols::wp::pointer_gestures::zv1::client::{ + zwp_pointer_gesture_pinch_v1, zwp_pointer_gestures_v1, +}; use wayland_protocols::wp::primary_selection::zv1::client::zwp_primary_selection_offer_v1::{ self, ZwpPrimarySelectionOfferV1, }; @@ -124,6 +127,7 @@ pub struct Globals { pub layer_shell: Option, pub blur_manager: Option, pub text_input_manager: Option, + pub gesture_manager: Option, pub dialog: Option, pub executor: ForegroundExecutor, } @@ -164,6 +168,7 @@ impl Globals { layer_shell: globals.bind(&qh, 1..=5, ()).ok(), blur_manager: globals.bind(&qh, 1..=1, ()).ok(), text_input_manager: globals.bind(&qh, 1..=1, ()).ok(), + gesture_manager: globals.bind(&qh, 1..=3, ()).ok(), dialog: globals.bind(&qh, dialog_v..=dialog_v, ()).ok(), executor, qh, @@ -208,6 +213,8 @@ pub(crate) struct WaylandClientState { pub compositor_gpu: Option, wl_seat: wl_seat::WlSeat, // TODO: Multi seat support wl_pointer: Option, + pinch_gesture: Option, + pinch_scale: f32, wl_keyboard: Option, cursor_shape_device: Option, data_device: Option, @@ -584,6 +591,8 @@ impl WaylandClient { wl_seat: seat, wl_pointer: None, wl_keyboard: None, + pinch_gesture: None, + pinch_scale: 1.0, cursor_shape_device: None, data_device, primary_selection, @@ -1325,6 +1334,12 @@ impl Dispatch for WaylandClientStatePtr { .as_ref() .map(|cursor_shape_manager| cursor_shape_manager.get_pointer(&pointer, qh, ())); + state.pinch_gesture = state.globals.gesture_manager.as_ref().map( + |gesture_manager: &zwp_pointer_gestures_v1::ZwpPointerGesturesV1| { + gesture_manager.get_pinch_gesture(&pointer, qh, ()) + }, + ); + if let Some(wl_pointer) = &state.wl_pointer { wl_pointer.release(); } @@ -1998,6 +2013,91 @@ impl Dispatch for WaylandClientStatePtr { } } +impl Dispatch for WaylandClientStatePtr { + fn event( + _this: &mut Self, + _: &zwp_pointer_gestures_v1::ZwpPointerGesturesV1, + _: ::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + // The gesture manager doesn't generate events + } +} + +impl Dispatch + for WaylandClientStatePtr +{ + fn event( + this: &mut Self, + _: &zwp_pointer_gesture_pinch_v1::ZwpPointerGesturePinchV1, + event: ::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + use gpui::PinchEvent; + + let client = this.get_client(); + let mut state = client.borrow_mut(); + + let Some(window) = state.mouse_focused_window.clone() else { + return; + }; + + match event { + zwp_pointer_gesture_pinch_v1::Event::Begin { + serial: _, + time: _, + surface: _, + fingers: _, + } => { + state.pinch_scale = 1.0; + let input = PlatformInput::Pinch(PinchEvent { + position: state.mouse_location.unwrap_or(point(px(0.0), px(0.0))), + delta: 0.0, + modifiers: state.modifiers, + phase: TouchPhase::Started, + }); + drop(state); + window.handle_input(input); + } + zwp_pointer_gesture_pinch_v1::Event::Update { time: _, scale, .. } => { + let new_absolute_scale = scale as f32; + let previous_scale = state.pinch_scale; + let zoom_delta = new_absolute_scale - previous_scale; + state.pinch_scale = new_absolute_scale; + + let input = PlatformInput::Pinch(PinchEvent { + position: state.mouse_location.unwrap_or(point(px(0.0), px(0.0))), + delta: zoom_delta, + modifiers: state.modifiers, + phase: TouchPhase::Moved, + }); + drop(state); + window.handle_input(input); + } + zwp_pointer_gesture_pinch_v1::Event::End { + serial: _, + time: _, + cancelled: _, + } => { + state.pinch_scale = 1.0; + let input = PlatformInput::Pinch(PinchEvent { + position: state.mouse_location.unwrap_or(point(px(0.0), px(0.0))), + delta: 0.0, + modifiers: state.modifiers, + phase: TouchPhase::Ended, + }); + drop(state); + window.handle_input(input); + } + _ => {} + } + } +} + impl Dispatch for WaylandClientStatePtr { fn event( this: &mut Self, diff --git a/crates/gpui_macos/src/events.rs b/crates/gpui_macos/src/events.rs index 5970488a17fbf9395f4ba29f5b98a135f6d55f7f..71bcb105e8aa8c6c43fd5b7864881535454c5ec3 100644 --- a/crates/gpui_macos/src/events.rs +++ b/crates/gpui_macos/src/events.rs @@ -1,8 +1,8 @@ use gpui::{ Capslock, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseExitEvent, MouseMoveEvent, MousePressureEvent, MouseUpEvent, - NavigationDirection, Pixels, PlatformInput, PressureStage, ScrollDelta, ScrollWheelEvent, - TouchPhase, point, px, + NavigationDirection, PinchEvent, Pixels, PlatformInput, PressureStage, ScrollDelta, + ScrollWheelEvent, TouchPhase, point, px, }; use crate::{ @@ -234,6 +234,27 @@ pub(crate) unsafe fn platform_input_from_native( _ => None, } } + NSEventType::NSEventTypeMagnify => window_height.map(|window_height| { + let phase = match native_event.phase() { + NSEventPhase::NSEventPhaseMayBegin | NSEventPhase::NSEventPhaseBegan => { + TouchPhase::Started + } + NSEventPhase::NSEventPhaseEnded => TouchPhase::Ended, + _ => TouchPhase::Moved, + }; + + let magnification = native_event.magnification() as f32; + + PlatformInput::Pinch(PinchEvent { + position: point( + px(native_event.locationInWindow().x as f32), + window_height - px(native_event.locationInWindow().y as f32), + ), + delta: magnification, + modifiers: read_modifiers(native_event), + phase, + }) + }), NSEventType::NSScrollWheel => window_height.map(|window_height| { let phase = match native_event.phase() { NSEventPhase::NSEventPhaseMayBegin | NSEventPhase::NSEventPhaseBegan => { diff --git a/crates/gpui_macos/src/window.rs b/crates/gpui_macos/src/window.rs index 456ee31ac3b03780e68267621d66435b1ceab4a9..c20c86026a102464343fc7c8cfb03b69b19b7641 100644 --- a/crates/gpui_macos/src/window.rs +++ b/crates/gpui_macos/src/window.rs @@ -172,6 +172,10 @@ unsafe fn build_classes() { sel!(mouseExited:), handle_view_event as extern "C" fn(&Object, Sel, id), ); + decl.add_method( + sel!(magnifyWithEvent:), + handle_view_event as extern "C" fn(&Object, Sel, id), + ); decl.add_method( sel!(mouseDragged:), handle_view_event as extern "C" fn(&Object, Sel, id), diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index c223494bd709217439bdff9f6a7ba17e1a65494e..291603b2b3f1544f6c60f9c3bdbbb87d3f77c424 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -6,6 +6,8 @@ use std::path::Path; use anyhow::Context as _; use editor::{EditorSettings, items::entry_git_aware_label_color}; use file_icons::FileIcons; +#[cfg(any(target_os = "linux", target_os = "macos"))] +use gpui::PinchEvent; use gpui::{ AnyElement, App, Bounds, Context, DispatchPhase, Element, ElementId, Entity, EventEmitter, FocusHandle, Focusable, GlobalElementId, InspectorElementId, InteractiveElement, IntoElement, @@ -260,6 +262,12 @@ impl ImageView { cx.notify(); } } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + fn handle_pinch(&mut self, event: &PinchEvent, _window: &mut Window, cx: &mut Context) { + let zoom_factor = 1.0 + event.delta; + self.set_zoom(self.zoom_level * zoom_factor, Some(event.position), cx); + } } struct ImageContentElement { @@ -679,8 +687,9 @@ impl Render for ImageView { .size_full() .relative() .bg(cx.theme().colors().editor_background) - .child( - div() + .child({ + #[cfg(any(target_os = "linux", target_os = "macos"))] + let container = div() .id("image-container") .size_full() .overflow_hidden() @@ -690,13 +699,34 @@ impl Render for ImageView { gpui::CursorStyle::OpenHand }) .on_scroll_wheel(cx.listener(Self::handle_scroll_wheel)) + .on_pinch(cx.listener(Self::handle_pinch)) .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())), - ) + .child(ImageContentElement::new(cx.entity())); + + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + let container = 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())); + + container + }) } }