gpui: Implement pinch event support for X11 and Windows (#51354)

MostlyK and John Tur created

Closes #51312 

- Remove platform-specific #[cfg] gates from PinchEvent, event
  listeners, and dispatch logic in GPUI
- Windows: Intercept Ctrl+ScrollWheel (emitted by precision trackpads
  for pinch gestures) and convert them to GPUI PinchEvents
- Image Viewer: remove redundant platform-specific blocks
- X11: Bump XInput version to 2.4 and implement handlers for
  XinputGesturePinch events


- [x] Added a solid test coverage and/or screenshots from doing manual
testing
- [x] Done a self-review taking into account security and performance
aspects
- [x] Aligned any UI changes with the [UI
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)

Release Notes:

- Pinching gestures now available on all devices.

---------

Co-authored-by: John Tur <john-tur@outlook.com>

Change summary

Cargo.toml                                     |   2 
crates/gpui/src/elements/div.rs                |  26 -
crates/gpui/src/interactive.rs                 |  11 
crates/gpui/src/window.rs                      |   1 
crates/gpui_linux/src/linux/x11/client.rs      |  67 +++
crates/gpui_linux/src/linux/x11/window.rs      |   8 
crates/gpui_windows/src/direct_manipulation.rs | 359 ++++++++++++++++++++
crates/gpui_windows/src/events.rs              |  22 +
crates/gpui_windows/src/gpui_windows.rs        |   1 
crates/gpui_windows/src/window.rs              |   6 
crates/image_viewer/src/image_viewer.rs        |  26 -
11 files changed, 463 insertions(+), 66 deletions(-)

Detailed changes

Cargo.toml 🔗

@@ -812,6 +812,7 @@ features = [
     "Win32_Graphics_Direct3D_Fxc",
     "Win32_Graphics_DirectComposition",
     "Win32_Graphics_DirectWrite",
+    "Win32_Graphics_DirectManipulation",
     "Win32_Graphics_Dwm",
     "Win32_Graphics_Dxgi",
     "Win32_Graphics_Dxgi_Common",
@@ -843,6 +844,7 @@ features = [
     "Win32_UI_HiDpi",
     "Win32_UI_Input_Ime",
     "Win32_UI_Input_KeyboardAndMouse",
+    "Win32_UI_Input_Pointer",
     "Win32_UI_Shell",
     "Win32_UI_Shell_Common",
     "Win32_UI_Shell_PropertiesSystem",

crates/gpui/src/elements/div.rs 🔗

@@ -15,7 +15,6 @@
 //! 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,
@@ -357,11 +356,7 @@ 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| {
@@ -373,11 +368,7 @@ impl Interactivity {
 
     /// 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,
@@ -675,15 +666,9 @@ impl Interactivity {
         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
@@ -957,11 +942,7 @@ pub trait InteractiveElement: Sized {
     /// 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
@@ -970,11 +951,7 @@ pub trait InteractiveElement: Sized {
     /// 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,
@@ -1367,7 +1344,6 @@ pub(crate) type MouseMoveListener =
 pub(crate) type ScrollWheelListener =
     Box<dyn Fn(&ScrollWheelEvent, DispatchPhase, &Hitbox, &mut Window, &mut App) + 'static>;
 
-#[cfg(any(target_os = "linux", target_os = "macos"))]
 pub(crate) type PinchListener =
     Box<dyn Fn(&PinchEvent, DispatchPhase, &Hitbox, &mut Window, &mut App) + 'static>;
 
@@ -1725,7 +1701,6 @@ pub struct Interactivity {
     pub(crate) mouse_pressure_listeners: Vec<MousePressureListener>,
     pub(crate) mouse_move_listeners: Vec<MouseMoveListener>,
     pub(crate) scroll_wheel_listeners: Vec<ScrollWheelListener>,
-    #[cfg(any(target_os = "linux", target_os = "macos"))]
     pub(crate) pinch_listeners: Vec<PinchListener>,
     pub(crate) key_down_listeners: Vec<KeyDownListener>,
     pub(crate) key_up_listeners: Vec<KeyUpListener>,
@@ -2297,7 +2272,6 @@ 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| {

crates/gpui/src/interactive.rs 🔗

@@ -473,10 +473,7 @@ 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<Pixels>,
@@ -493,20 +490,15 @@ pub struct PinchEvent {
     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;
 
@@ -675,7 +667,6 @@ pub enum PlatformInput {
     /// 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),
@@ -693,7 +684,6 @@ 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),
         }
@@ -710,7 +700,6 @@ 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,
         }

crates/gpui/src/window.rs 🔗

@@ -4146,7 +4146,6 @@ 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;

crates/gpui_linux/src/linux/x11/client.rs 🔗

@@ -176,6 +176,7 @@ pub struct X11ClientState {
     pub(crate) last_mouse_button: Option<MouseButton>,
     pub(crate) last_location: Point<Pixels>,
     pub(crate) current_count: usize,
+    pub(crate) pinch_scale: f32,
 
     pub(crate) gpu_context: GpuContext,
     pub(crate) compositor_gpu: Option<CompositorGpuHint>,
@@ -342,11 +343,12 @@ impl X11Client {
         xcb_connection.prefetch_extension_information(render::X11_EXTENSION_NAME)?;
         xcb_connection.prefetch_extension_information(xinput::X11_EXTENSION_NAME)?;
 
-        // Announce to X server that XInput up to 2.1 is supported. To increase this to 2.2 and
-        // beyond, support for touch events would need to be added.
+        // Announce to X server that XInput up to 2.4 is supported.
+        // Version 2.4 is needed for gesture events (GesturePinchBegin/Update/End).
+        // If the server only supports an older version, gesture events simply won't be delivered.
         let xinput_version = get_reply(
             || "XInput XiQueryVersion failed",
-            xcb_connection.xinput_xi_query_version(2, 1),
+            xcb_connection.xinput_xi_query_version(2, 4),
         )?;
         assert!(
             xinput_version.major_version >= 2,
@@ -502,6 +504,7 @@ impl X11Client {
             last_mouse_button: None,
             last_location: Point::new(px(0.0), px(0.0)),
             current_count: 0,
+            pinch_scale: 1.0,
             gpu_context: Rc::new(RefCell::new(None)),
             compositor_gpu,
             scale_factor,
@@ -1324,6 +1327,64 @@ impl X11Client {
                     reset_pointer_device_scroll_positions(pointer);
                 }
             }
+            Event::XinputGesturePinchBegin(event) => {
+                let window = self.get_window(event.event)?;
+                let mut state = self.0.borrow_mut();
+                state.pinch_scale = 1.0;
+                let modifiers = modifiers_from_xinput_info(event.mods);
+                state.modifiers = modifiers;
+                let position = point(
+                    px(event.event_x as f32 / u16::MAX as f32 / state.scale_factor),
+                    px(event.event_y as f32 / u16::MAX as f32 / state.scale_factor),
+                );
+                drop(state);
+                window.handle_input(PlatformInput::Pinch(gpui::PinchEvent {
+                    position,
+                    delta: 0.0,
+                    modifiers,
+                    phase: gpui::TouchPhase::Started,
+                }));
+            }
+            Event::XinputGesturePinchUpdate(event) => {
+                let window = self.get_window(event.event)?;
+                let mut state = self.0.borrow_mut();
+                let modifiers = modifiers_from_xinput_info(event.mods);
+                state.modifiers = modifiers;
+                let position = point(
+                    px(event.event_x as f32 / u16::MAX as f32 / state.scale_factor),
+                    px(event.event_y as f32 / u16::MAX as f32 / state.scale_factor),
+                );
+                // scale is in FP16.16 format: divide by 65536 to get the float value
+                let new_absolute_scale = event.scale as f32 / 65536.0;
+                let previous_scale = state.pinch_scale;
+                let zoom_delta = new_absolute_scale - previous_scale;
+                state.pinch_scale = new_absolute_scale;
+                drop(state);
+                window.handle_input(PlatformInput::Pinch(gpui::PinchEvent {
+                    position,
+                    delta: zoom_delta,
+                    modifiers,
+                    phase: gpui::TouchPhase::Moved,
+                }));
+            }
+            Event::XinputGesturePinchEnd(event) => {
+                let window = self.get_window(event.event)?;
+                let mut state = self.0.borrow_mut();
+                state.pinch_scale = 1.0;
+                let modifiers = modifiers_from_xinput_info(event.mods);
+                state.modifiers = modifiers;
+                let position = point(
+                    px(event.event_x as f32 / u16::MAX as f32 / state.scale_factor),
+                    px(event.event_y as f32 / u16::MAX as f32 / state.scale_factor),
+                );
+                drop(state);
+                window.handle_input(PlatformInput::Pinch(gpui::PinchEvent {
+                    position,
+                    delta: 0.0,
+                    modifiers,
+                    phase: gpui::TouchPhase::Ended,
+                }));
+            }
             _ => {}
         };
 

crates/gpui_linux/src/linux/x11/window.rs 🔗

@@ -671,7 +671,13 @@ impl X11WindowState {
                                 | xinput::XIEventMask::BUTTON_PRESS
                                 | xinput::XIEventMask::BUTTON_RELEASE
                                 | xinput::XIEventMask::ENTER
-                                | xinput::XIEventMask::LEAVE,
+                                | xinput::XIEventMask::LEAVE
+                                // x11rb 0.13 doesn't define XIEventMask constants for gesture
+                                // events, so we construct them from the event opcodes (each
+                                // XInput event type N maps to mask bit N).
+                                | xinput::XIEventMask::from(1u32 << xinput::GESTURE_PINCH_BEGIN_EVENT)
+                                | xinput::XIEventMask::from(1u32 << xinput::GESTURE_PINCH_UPDATE_EVENT)
+                                | xinput::XIEventMask::from(1u32 << xinput::GESTURE_PINCH_END_EVENT),
                         ],
                     }],
                 ),

crates/gpui_windows/src/direct_manipulation.rs 🔗

@@ -0,0 +1,359 @@
+use std::cell::{Cell, RefCell};
+use std::rc::Rc;
+
+use ::util::ResultExt;
+use anyhow::Result;
+use gpui::*;
+use windows::Win32::{
+    Foundation::*,
+    Graphics::{DirectManipulation::*, Gdi::*},
+    System::Com::*,
+    UI::{Input::Pointer::*, WindowsAndMessaging::*},
+};
+
+use crate::*;
+
+/// Default viewport size in pixels. The actual content size doesn't matter
+/// because we're using the viewport only for gesture recognition, not for
+/// visual output.
+const DEFAULT_VIEWPORT_SIZE: i32 = 1000;
+
+pub(crate) struct DirectManipulationHandler {
+    manager: IDirectManipulationManager,
+    update_manager: IDirectManipulationUpdateManager,
+    viewport: IDirectManipulationViewport,
+    _handler_cookie: u32,
+    window: HWND,
+    scale_factor: Rc<Cell<f32>>,
+    pending_events: Rc<RefCell<Vec<PlatformInput>>>,
+}
+
+impl DirectManipulationHandler {
+    pub fn new(window: HWND, scale_factor: f32) -> Result<Self> {
+        unsafe {
+            let manager: IDirectManipulationManager =
+                CoCreateInstance(&DirectManipulationManager, None, CLSCTX_INPROC_SERVER)?;
+
+            let update_manager: IDirectManipulationUpdateManager = manager.GetUpdateManager()?;
+
+            let viewport: IDirectManipulationViewport = manager.CreateViewport(None, window)?;
+
+            let configuration = DIRECTMANIPULATION_CONFIGURATION_INTERACTION
+                | DIRECTMANIPULATION_CONFIGURATION_TRANSLATION_X
+                | DIRECTMANIPULATION_CONFIGURATION_TRANSLATION_Y
+                | DIRECTMANIPULATION_CONFIGURATION_TRANSLATION_INERTIA
+                | DIRECTMANIPULATION_CONFIGURATION_RAILS_X
+                | DIRECTMANIPULATION_CONFIGURATION_RAILS_Y
+                | DIRECTMANIPULATION_CONFIGURATION_SCALING;
+            viewport.ActivateConfiguration(configuration)?;
+
+            viewport.SetViewportOptions(
+                DIRECTMANIPULATION_VIEWPORT_OPTIONS_MANUALUPDATE
+                    | DIRECTMANIPULATION_VIEWPORT_OPTIONS_DISABLEPIXELSNAPPING,
+            )?;
+
+            let mut rect = RECT {
+                left: 0,
+                top: 0,
+                right: DEFAULT_VIEWPORT_SIZE,
+                bottom: DEFAULT_VIEWPORT_SIZE,
+            };
+            viewport.SetViewportRect(&mut rect)?;
+
+            manager.Activate(window)?;
+            viewport.Enable()?;
+
+            let scale_factor = Rc::new(Cell::new(scale_factor));
+            let pending_events = Rc::new(RefCell::new(Vec::new()));
+
+            let event_handler: IDirectManipulationViewportEventHandler =
+                DirectManipulationEventHandler::new(
+                    window,
+                    Rc::clone(&scale_factor),
+                    Rc::clone(&pending_events),
+                )
+                .into();
+
+            let handler_cookie = viewport.AddEventHandler(Some(window), &event_handler)?;
+
+            update_manager.Update(None)?;
+
+            Ok(Self {
+                manager,
+                update_manager,
+                viewport,
+                _handler_cookie: handler_cookie,
+                window,
+                scale_factor,
+                pending_events,
+            })
+        }
+    }
+
+    pub fn set_scale_factor(&self, scale_factor: f32) {
+        self.scale_factor.set(scale_factor);
+    }
+
+    pub fn on_pointer_hit_test(&self, wparam: WPARAM) {
+        unsafe {
+            let pointer_id = wparam.loword() as u32;
+            let mut pointer_type = POINTER_INPUT_TYPE::default();
+            if GetPointerType(pointer_id, &mut pointer_type).is_ok() && pointer_type == PT_TOUCHPAD
+            {
+                self.viewport.SetContact(pointer_id).log_err();
+            }
+        }
+    }
+
+    pub fn update(&self) {
+        unsafe {
+            self.update_manager.Update(None).log_err();
+        }
+    }
+
+    pub fn drain_events(&self) -> Vec<PlatformInput> {
+        std::mem::take(&mut *self.pending_events.borrow_mut())
+    }
+}
+
+impl Drop for DirectManipulationHandler {
+    fn drop(&mut self) {
+        unsafe {
+            self.viewport.Stop().log_err();
+            self.viewport.Abandon().log_err();
+            self.manager.Deactivate(self.window).log_err();
+        }
+    }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum GestureKind {
+    None,
+    Scroll,
+    Pinch,
+}
+
+#[windows_core::implement(IDirectManipulationViewportEventHandler)]
+struct DirectManipulationEventHandler {
+    window: HWND,
+    scale_factor: Rc<Cell<f32>>,
+    gesture_kind: Cell<GestureKind>,
+    last_scale: Cell<f32>,
+    last_x_offset: Cell<f32>,
+    last_y_offset: Cell<f32>,
+    scroll_phase: Cell<TouchPhase>,
+    pending_events: Rc<RefCell<Vec<PlatformInput>>>,
+}
+
+impl DirectManipulationEventHandler {
+    fn new(
+        window: HWND,
+        scale_factor: Rc<Cell<f32>>,
+        pending_events: Rc<RefCell<Vec<PlatformInput>>>,
+    ) -> Self {
+        Self {
+            window,
+            scale_factor,
+            gesture_kind: Cell::new(GestureKind::None),
+            last_scale: Cell::new(1.0),
+            last_x_offset: Cell::new(0.0),
+            last_y_offset: Cell::new(0.0),
+            scroll_phase: Cell::new(TouchPhase::Started),
+            pending_events,
+        }
+    }
+
+    fn end_gesture(&self) {
+        let position = self.mouse_position();
+        let modifiers = current_modifiers();
+        match self.gesture_kind.get() {
+            GestureKind::Scroll => {
+                self.pending_events
+                    .borrow_mut()
+                    .push(PlatformInput::ScrollWheel(ScrollWheelEvent {
+                        position,
+                        delta: ScrollDelta::Pixels(point(px(0.0), px(0.0))),
+                        modifiers,
+                        touch_phase: TouchPhase::Ended,
+                    }));
+            }
+            GestureKind::Pinch => {
+                self.pending_events
+                    .borrow_mut()
+                    .push(PlatformInput::Pinch(PinchEvent {
+                        position,
+                        delta: 0.0,
+                        modifiers,
+                        phase: TouchPhase::Ended,
+                    }));
+            }
+            GestureKind::None => {}
+        }
+        self.gesture_kind.set(GestureKind::None);
+    }
+
+    fn mouse_position(&self) -> Point<Pixels> {
+        let scale_factor = self.scale_factor.get();
+        unsafe {
+            let mut point: POINT = std::mem::zeroed();
+            let _ = GetCursorPos(&mut point);
+            let _ = ScreenToClient(self.window, &mut point);
+            logical_point(point.x as f32, point.y as f32, scale_factor)
+        }
+    }
+}
+
+impl IDirectManipulationViewportEventHandler_Impl for DirectManipulationEventHandler_Impl {
+    fn OnViewportStatusChanged(
+        &self,
+        viewport: windows_core::Ref<'_, IDirectManipulationViewport>,
+        current: DIRECTMANIPULATION_STATUS,
+        previous: DIRECTMANIPULATION_STATUS,
+    ) -> windows_core::Result<()> {
+        if current == previous {
+            return Ok(());
+        }
+
+        // A new gesture interrupted inertia, so end the old sequence.
+        if current == DIRECTMANIPULATION_RUNNING && previous == DIRECTMANIPULATION_INERTIA {
+            self.end_gesture();
+        }
+
+        if current == DIRECTMANIPULATION_READY {
+            self.end_gesture();
+
+            // Reset the content transform so the viewport is ready for the next gesture.
+            // ZoomToRect triggers a second RUNNING -> READY cycle, so prevent an infinite loop here.
+            if self.last_scale.get() != 1.0
+                || self.last_x_offset.get() != 0.0
+                || self.last_y_offset.get() != 0.0
+            {
+                if let Some(viewport) = viewport.as_ref() {
+                    unsafe {
+                        viewport
+                            .ZoomToRect(
+                                0.0,
+                                0.0,
+                                DEFAULT_VIEWPORT_SIZE as f32,
+                                DEFAULT_VIEWPORT_SIZE as f32,
+                                false,
+                            )
+                            .log_err();
+                    }
+                }
+            }
+
+            self.last_scale.set(1.0);
+            self.last_x_offset.set(0.0);
+            self.last_y_offset.set(0.0);
+        }
+
+        Ok(())
+    }
+
+    fn OnViewportUpdated(
+        &self,
+        _viewport: windows_core::Ref<'_, IDirectManipulationViewport>,
+    ) -> windows_core::Result<()> {
+        Ok(())
+    }
+
+    fn OnContentUpdated(
+        &self,
+        _viewport: windows_core::Ref<'_, IDirectManipulationViewport>,
+        content: windows_core::Ref<'_, IDirectManipulationContent>,
+    ) -> windows_core::Result<()> {
+        let content = content.as_ref().ok_or(E_POINTER)?;
+
+        // Get the 6-element content transform: [scale, 0, 0, scale, tx, ty]
+        let mut xform = [0.0f32; 6];
+        unsafe {
+            content.GetContentTransform(&mut xform)?;
+        }
+
+        let scale = xform[0];
+        let scale_factor = self.scale_factor.get();
+        let x_offset = xform[4] / scale_factor;
+        let y_offset = xform[5] / scale_factor;
+
+        if scale == 0.0 {
+            return Ok(());
+        }
+
+        let last_scale = self.last_scale.get();
+        let last_x = self.last_x_offset.get();
+        let last_y = self.last_y_offset.get();
+
+        if float_equals(scale, last_scale)
+            && float_equals(x_offset, last_x)
+            && float_equals(y_offset, last_y)
+        {
+            return Ok(());
+        }
+
+        let position = self.mouse_position();
+        let modifiers = current_modifiers();
+
+        // Direct Manipulation reports both translation and scale in every content update.
+        // Translation values can shift during a pinch due to the zoom center shifting.
+        // We classify each gesture as either scroll or pinch and only emit one type of event.
+        // We allow Scroll -> Pinch (a pinch can start with a small pan) but not the reverse.
+        if !float_equals(scale, 1.0) {
+            if self.gesture_kind.get() != GestureKind::Pinch {
+                self.end_gesture();
+                self.gesture_kind.set(GestureKind::Pinch);
+                self.pending_events
+                    .borrow_mut()
+                    .push(PlatformInput::Pinch(PinchEvent {
+                        position,
+                        delta: 0.0,
+                        modifiers,
+                        phase: TouchPhase::Started,
+                    }));
+            }
+        } else if self.gesture_kind.get() == GestureKind::None {
+            self.gesture_kind.set(GestureKind::Scroll);
+            self.scroll_phase.set(TouchPhase::Started);
+        }
+
+        match self.gesture_kind.get() {
+            GestureKind::Scroll => {
+                let dx = x_offset - last_x;
+                let dy = y_offset - last_y;
+                let touch_phase = self.scroll_phase.get();
+                self.scroll_phase.set(TouchPhase::Moved);
+                self.pending_events
+                    .borrow_mut()
+                    .push(PlatformInput::ScrollWheel(ScrollWheelEvent {
+                        position,
+                        delta: ScrollDelta::Pixels(point(px(dx), px(dy))),
+                        modifiers,
+                        touch_phase,
+                    }));
+            }
+            GestureKind::Pinch => {
+                let scale_delta = scale / last_scale;
+                self.pending_events
+                    .borrow_mut()
+                    .push(PlatformInput::Pinch(PinchEvent {
+                        position,
+                        delta: scale_delta - 1.0,
+                        modifiers,
+                        phase: TouchPhase::Moved,
+                    }));
+            }
+            GestureKind::None => {}
+        }
+
+        self.last_scale.set(scale);
+        self.last_x_offset.set(x_offset);
+        self.last_y_offset.set(y_offset);
+
+        Ok(())
+    }
+}
+
+fn float_equals(f1: f32, f2: f32) -> bool {
+    const EPSILON_SCALE: f32 = 0.00001;
+    (f1 - f2).abs() < EPSILON_SCALE * f1.abs().max(f2.abs()).max(EPSILON_SCALE)
+}

crates/gpui_windows/src/events.rs 🔗

@@ -111,6 +111,7 @@ impl WindowsWindowInner {
             WM_GPUI_CURSOR_STYLE_CHANGED => self.handle_cursor_changed(lparam),
             WM_GPUI_FORCE_UPDATE_WINDOW => self.draw_window(handle, true),
             WM_GPUI_GPU_DEVICE_LOST => self.handle_device_lost(lparam),
+            DM_POINTERHITTEST => self.handle_dm_pointer_hit_test(wparam),
             _ => None,
         };
         if let Some(n) = handled {
@@ -758,6 +759,10 @@ impl WindowsWindowInner {
         self.state.scale_factor.set(new_scale_factor);
         self.state.border_offset.update(handle).log_err();
 
+        self.state
+            .direct_manipulation
+            .set_scale_factor(new_scale_factor);
+
         if is_maximized {
             // Get the monitor and its work area at the new DPI
             let monitor = unsafe { MonitorFromWindow(handle, MONITOR_DEFAULTTONEAREST) };
@@ -1139,10 +1144,27 @@ impl WindowsWindowInner {
         Some(0)
     }
 
+    fn handle_dm_pointer_hit_test(&self, wparam: WPARAM) -> Option<isize> {
+        self.state.direct_manipulation.on_pointer_hit_test(wparam);
+        None
+    }
+
     #[inline]
     fn draw_window(&self, handle: HWND, force_render: bool) -> Option<isize> {
         let mut request_frame = self.state.callbacks.request_frame.take()?;
 
+        self.state.direct_manipulation.update();
+
+        let events = self.state.direct_manipulation.drain_events();
+        if !events.is_empty() {
+            if let Some(mut func) = self.state.callbacks.input.take() {
+                for event in events {
+                    func(event);
+                }
+                self.state.callbacks.input.set(Some(func));
+            }
+        }
+
         if force_render {
             // Re-enable drawing after a device loss recovery. The forced render
             // will rebuild the scene with fresh atlas textures.

crates/gpui_windows/src/window.rs 🔗

@@ -26,6 +26,7 @@ use windows::{
     core::*,
 };
 
+use crate::direct_manipulation::DirectManipulationHandler;
 use crate::*;
 use gpui::*;
 
@@ -57,6 +58,7 @@ pub struct WindowsWindowState {
     pub last_reported_modifiers: Cell<Option<Modifiers>>,
     pub last_reported_capslock: Cell<Option<Capslock>>,
     pub hovered: Cell<bool>,
+    pub direct_manipulation: DirectManipulationHandler,
 
     pub renderer: RefCell<DirectXRenderer>,
 
@@ -131,6 +133,9 @@ impl WindowsWindowState {
         let fullscreen = None;
         let initial_placement = None;
 
+        let direct_manipulation = DirectManipulationHandler::new(hwnd, scale_factor)
+            .context("initializing Direct Manipulation")?;
+
         Ok(Self {
             origin: Cell::new(origin),
             logical_size: Cell::new(logical_size),
@@ -157,6 +162,7 @@ impl WindowsWindowState {
             initial_placement: Cell::new(initial_placement),
             hwnd,
             invalidate_devices,
+            direct_manipulation,
         })
     }
 

crates/image_viewer/src/image_viewer.rs 🔗

@@ -6,14 +6,12 @@ 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, Font, GlobalElementId, InspectorElementId, InteractiveElement,
     IntoElement, LayoutId, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent,
-    ParentElement, Pixels, Point, Render, ScrollDelta, ScrollWheelEvent, Style, Styled, Task,
-    WeakEntity, Window, actions, checkerboard, div, img, point, px, size,
+    ParentElement, PinchEvent, Pixels, Point, Render, ScrollDelta, ScrollWheelEvent, Style, Styled,
+    Task, WeakEntity, Window, actions, checkerboard, div, img, point, px, size,
 };
 use language::File as _;
 use persistence::ImageViewerDb;
@@ -263,7 +261,6 @@ impl ImageView {
         }
     }
 
-    #[cfg(any(target_os = "linux", target_os = "macos"))]
     fn handle_pinch(&mut self, event: &PinchEvent, _window: &mut Window, cx: &mut Context<Self>) {
         let zoom_factor = 1.0 + event.delta;
         self.set_zoom(self.zoom_level * zoom_factor, Some(event.position), cx);
@@ -685,7 +682,6 @@ impl Render for ImageView {
             .relative()
             .bg(cx.theme().colors().editor_background)
             .child({
-                #[cfg(any(target_os = "linux", target_os = "macos"))]
                 let container = div()
                     .id("image-container")
                     .size_full()
@@ -704,24 +700,6 @@ impl Render for ImageView {
                     .on_mouse_move(cx.listener(Self::handle_mouse_move))
                     .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
             })
     }