gpui: Support Force Touch go-to-definition on macOS (#40399)

Aaro Luomanen , Anthony Eid , and Antonio Scandurra created

Closes #4644

Release Notes:

- Adds `MousePressureEvent`, an event that is sent anytime the touchpad
pressure changes, into `gpui`. MacOS only.
- Triggers go-to-defintion on force clicks in the editor.

This is my first contribution, let me know if I've missed something
here.

---------

Co-authored-by: Anthony Eid <anthony@zed.dev>
Co-authored-by: Antonio Scandurra <me@as-cii.com>

Change summary

crates/editor/src/editor.rs            | 11 ++-
crates/editor/src/element.rs           | 45 ++++++++++++++-
crates/editor/src/hover_links.rs       | 75 +++++++++++++++++++++++++++
crates/gpui/Cargo.toml                 |  4 +
crates/gpui/examples/mouse_pressure.rs | 66 ++++++++++++++++++++++++
crates/gpui/src/elements/div.rs        | 72 +++++++++++++++++++++++++-
crates/gpui/src/interactive.rs         | 38 ++++++++++++++
crates/gpui/src/platform/mac/events.rs | 25 ++++++++
crates/gpui/src/platform/mac/window.rs |  4 +
crates/gpui/src/window.rs              |  3 +
10 files changed, 328 insertions(+), 15 deletions(-)

Detailed changes

crates/editor/src/editor.rs 🔗

@@ -107,10 +107,11 @@ use gpui::{
     AvailableSpace, Background, Bounds, ClickEvent, ClipboardEntry, ClipboardItem, Context,
     DispatchPhase, Edges, Entity, EntityInputHandler, EventEmitter, FocusHandle, FocusOutEvent,
     Focusable, FontId, FontWeight, Global, HighlightStyle, Hsla, KeyContext, Modifiers,
-    MouseButton, MouseDownEvent, MouseMoveEvent, PaintQuad, ParentElement, Pixels, Render,
-    ScrollHandle, SharedString, Size, Stateful, Styled, Subscription, Task, TextRun, TextStyle,
-    TextStyleRefinement, UTF16Selection, UnderlineStyle, UniformListScrollHandle, WeakEntity,
-    WeakFocusHandle, Window, div, point, prelude::*, pulsating_between, px, relative, size,
+    MouseButton, MouseDownEvent, MouseMoveEvent, PaintQuad, ParentElement, Pixels, PressureStage,
+    Render, ScrollHandle, SharedString, Size, Stateful, Styled, Subscription, Task, TextRun,
+    TextStyle, TextStyleRefinement, UTF16Selection, UnderlineStyle, UniformListScrollHandle,
+    WeakEntity, WeakFocusHandle, Window, div, point, prelude::*, pulsating_between, px, relative,
+    size,
 };
 use hover_links::{HoverLink, HoveredLinkState, find_file};
 use hover_popover::{HoverState, hide_hover};
@@ -1121,6 +1122,7 @@ pub struct Editor {
     remote_id: Option<ViewId>,
     pub hover_state: HoverState,
     pending_mouse_down: Option<Rc<RefCell<Option<MouseDownEvent>>>>,
+    prev_pressure_stage: Option<PressureStage>,
     gutter_hovered: bool,
     hovered_link_state: Option<HoveredLinkState>,
     edit_prediction_provider: Option<RegisteredEditPredictionDelegate>,
@@ -2300,6 +2302,7 @@ impl Editor {
             remote_id: None,
             hover_state: HoverState::default(),
             pending_mouse_down: None,
+            prev_pressure_stage: None,
             hovered_link_state: None,
             edit_prediction_provider: None,
             active_edit_prediction: None,

crates/editor/src/element.rs 🔗

@@ -48,11 +48,11 @@ use gpui::{
     DispatchPhase, Edges, Element, ElementInputHandler, Entity, Focusable as _, FontId,
     GlobalElementId, Hitbox, HitboxBehavior, Hsla, InteractiveElement, IntoElement, IsZero,
     KeybindingKeystroke, Length, Modifiers, ModifiersChangedEvent, MouseButton, MouseClickEvent,
-    MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, ParentElement, Pixels, ScrollDelta,
-    ScrollHandle, ScrollWheelEvent, ShapedLine, SharedString, Size, StatefulInteractiveElement,
-    Style, Styled, TextRun, TextStyleRefinement, WeakEntity, Window, anchored, deferred, div, fill,
-    linear_color_stop, linear_gradient, outline, point, px, quad, relative, size, solid_background,
-    transparent_black,
+    MouseDownEvent, MouseMoveEvent, MousePressureEvent, MouseUpEvent, PaintQuad, ParentElement,
+    Pixels, PressureStage, ScrollDelta, ScrollHandle, ScrollWheelEvent, ShapedLine, SharedString,
+    Size, StatefulInteractiveElement, Style, Styled, TextRun, TextStyleRefinement, WeakEntity,
+    Window, anchored, deferred, div, fill, linear_color_stop, linear_gradient, outline, point, px,
+    quad, relative, size, solid_background, transparent_black,
 };
 use itertools::Itertools;
 use language::{IndentGuideSettings, language_settings::ShowWhitespaceSetting};
@@ -1035,6 +1035,28 @@ impl EditorElement {
         }
     }
 
+    fn pressure_click(
+        editor: &mut Editor,
+        event: &MousePressureEvent,
+        position_map: &PositionMap,
+        window: &mut Window,
+        cx: &mut Context<Editor>,
+    ) {
+        let text_hitbox = &position_map.text_hitbox;
+        let force_click_possible =
+            matches!(editor.prev_pressure_stage, Some(PressureStage::Normal))
+                && event.stage == PressureStage::Force;
+
+        editor.prev_pressure_stage = Some(event.stage);
+
+        if force_click_possible && text_hitbox.is_hovered(window) {
+            let point = position_map.point_for_position(event.position);
+            editor.handle_click_hovered_link(point, event.modifiers, window, cx);
+            editor.selection_drag_state = SelectionDragState::None;
+            cx.stop_propagation();
+        }
+    }
+
     fn mouse_dragged(
         editor: &mut Editor,
         event: &MouseMoveEvent,
@@ -7769,6 +7791,19 @@ impl EditorElement {
             }
         });
 
+        window.on_mouse_event({
+            let position_map = layout.position_map.clone();
+            let editor = self.editor.clone();
+
+            move |event: &MousePressureEvent, phase, window, cx| {
+                if phase == DispatchPhase::Bubble {
+                    editor.update(cx, |editor, cx| {
+                        Self::pressure_click(editor, &event, &position_map, window, cx);
+                    })
+                }
+            }
+        });
+
         window.on_mouse_event({
             let position_map = layout.position_map.clone();
             let editor = self.editor.clone();

crates/editor/src/hover_links.rs 🔗

@@ -735,7 +735,7 @@ mod tests {
         test::editor_lsp_test_context::EditorLspTestContext,
     };
     use futures::StreamExt;
-    use gpui::Modifiers;
+    use gpui::{Modifiers, MousePressureEvent, PressureStage};
     use indoc::indoc;
     use lsp::request::{GotoDefinition, GotoTypeDefinition};
     use multi_buffer::MultiBufferOffset;
@@ -1706,4 +1706,77 @@ mod tests {
         cx.simulate_click(screen_coord, Modifiers::secondary_key());
         cx.update_workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 1));
     }
+
+    #[gpui::test]
+    async fn test_pressure_links(cx: &mut gpui::TestAppContext) {
+        init_test(cx, |_| {});
+
+        let mut cx = EditorLspTestContext::new_rust(
+            lsp::ServerCapabilities {
+                hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
+                definition_provider: Some(lsp::OneOf::Left(true)),
+                ..Default::default()
+            },
+            cx,
+        )
+        .await;
+
+        cx.set_state(indoc! {"
+                    fn ˇtest() { do_work(); }
+                    fn do_work() { test(); }
+                "});
+
+        // Position the mouse over a symbol that has a definition
+        let hover_point = cx.pixel_position(indoc! {"
+                    fn test() { do_wˇork(); }
+                    fn do_work() { test(); }
+                "});
+        let symbol_range = cx.lsp_range(indoc! {"
+                    fn test() { «do_work»(); }
+                    fn do_work() { test(); }
+                "});
+        let target_range = cx.lsp_range(indoc! {"
+                    fn test() { do_work(); }
+                    fn «do_work»() { test(); }
+                "});
+
+        let mut requests =
+            cx.set_request_handler::<GotoDefinition, _, _>(move |url, _, _| async move {
+                Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
+                    lsp::LocationLink {
+                        origin_selection_range: Some(symbol_range),
+                        target_uri: url.clone(),
+                        target_range,
+                        target_selection_range: target_range,
+                    },
+                ])))
+            });
+
+        cx.simulate_mouse_move(hover_point, None, Modifiers::none());
+
+        // First simulate Normal pressure to set up the previous stage
+        cx.simulate_event(MousePressureEvent {
+            pressure: 0.5,
+            stage: PressureStage::Normal,
+            position: hover_point,
+            modifiers: Modifiers::none(),
+        });
+        cx.background_executor.run_until_parked();
+
+        // Now simulate Force pressure to trigger the force click and go-to definition
+        cx.simulate_event(MousePressureEvent {
+            pressure: 1.0,
+            stage: PressureStage::Force,
+            position: hover_point,
+            modifiers: Modifiers::none(),
+        });
+        requests.next().await;
+        cx.background_executor.run_until_parked();
+
+        // Assert that we navigated to the definition
+        cx.assert_editor_state(indoc! {"
+                    fn test() { do_work(); }
+                    fn «do_workˇ»() { test(); }
+                "});
+    }
 }

crates/gpui/Cargo.toml 🔗

@@ -330,3 +330,7 @@ path = "examples/window_shadow.rs"
 [[example]]
 name = "grid_layout"
 path = "examples/grid_layout.rs"
+
+[[example]]
+name = "mouse_pressure"
+path = "examples/mouse_pressure.rs"

crates/gpui/examples/mouse_pressure.rs 🔗

@@ -0,0 +1,66 @@
+use gpui::{
+    App, Application, Bounds, Context, MousePressureEvent, PressureStage, Window, WindowBounds,
+    WindowOptions, div, prelude::*, px, rgb, size,
+};
+
+struct MousePressureExample {
+    pressure_stage: PressureStage,
+    pressure_amount: f32,
+}
+
+impl Render for MousePressureExample {
+    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        div()
+            .flex()
+            .flex_col()
+            .gap_3()
+            .bg(rgb(0x505050))
+            .size(px(500.0))
+            .justify_center()
+            .items_center()
+            .shadow_lg()
+            .border_1()
+            .border_color(rgb(0x0000ff))
+            .text_xl()
+            .text_color(rgb(0xffffff))
+            .child(format!("Pressure stage: {:?}", &self.pressure_stage))
+            .child(format!("Pressure amount: {:.2}", &self.pressure_amount))
+            .on_mouse_pressure(cx.listener(Self::on_mouse_pressure))
+    }
+}
+
+impl MousePressureExample {
+    fn on_mouse_pressure(
+        &mut self,
+        pressure_event: &MousePressureEvent,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.pressure_amount = pressure_event.pressure;
+        self.pressure_stage = pressure_event.stage;
+
+        cx.notify();
+    }
+}
+
+fn main() {
+    Application::new().run(|cx: &mut App| {
+        let bounds = Bounds::centered(None, size(px(500.), px(500.0)), cx);
+
+        cx.open_window(
+            WindowOptions {
+                window_bounds: Some(WindowBounds::Windowed(bounds)),
+                ..Default::default()
+            },
+            |_, cx| {
+                cx.new(|_| MousePressureExample {
+                    pressure_stage: PressureStage::Zero,
+                    pressure_amount: 0.0,
+                })
+            },
+        )
+        .unwrap();
+
+        cx.activate(true);
+    });
+}

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

@@ -20,8 +20,8 @@ use crate::{
     DispatchPhase, Display, Element, ElementId, Entity, FocusHandle, Global, GlobalElementId,
     Hitbox, HitboxBehavior, HitboxId, InspectorElementId, IntoElement, IsZero, KeyContext,
     KeyDownEvent, KeyUpEvent, KeyboardButton, KeyboardClickEvent, LayoutId, ModifiersChangedEvent,
-    MouseButton, MouseClickEvent, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Overflow,
-    ParentElement, Pixels, Point, Render, ScrollWheelEvent, SharedString, Size, Style,
+    MouseButton, MouseClickEvent, MouseDownEvent, MouseMoveEvent, MousePressureEvent, MouseUpEvent,
+    Overflow, ParentElement, Pixels, Point, Render, ScrollWheelEvent, SharedString, Size, Style,
     StyleRefinement, Styled, Task, TooltipId, Visibility, Window, WindowControlArea, point, px,
     size,
 };
@@ -166,6 +166,38 @@ impl Interactivity {
             }));
     }
 
+    /// Bind the given callback to the mouse pressure event, during the bubble phase
+    /// the imperative API equivalent to [`InteractiveElement::on_mouse_pressure`].
+    ///
+    /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback.
+    pub fn on_mouse_pressure(
+        &mut self,
+        listener: impl Fn(&MousePressureEvent, &mut Window, &mut App) + 'static,
+    ) {
+        self.mouse_pressure_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 the mouse pressure event, during the capture phase
+    /// the imperative API equivalent to [`InteractiveElement::on_mouse_pressure`].
+    ///
+    /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback.
+    pub fn capture_mouse_pressure(
+        &mut self,
+        listener: impl Fn(&MousePressureEvent, &mut Window, &mut App) + 'static,
+    ) {
+        self.mouse_pressure_listeners
+            .push(Box::new(move |event, phase, hitbox, window, cx| {
+                if phase == DispatchPhase::Capture && hitbox.is_hovered(window) {
+                    (listener)(event, window, cx)
+                }
+            }));
+    }
+
     /// Bind the given callback to the mouse up event for the given button, during the bubble phase.
     /// The imperative API equivalent to [`InteractiveElement::on_mouse_up`].
     ///
@@ -769,6 +801,30 @@ pub trait InteractiveElement: Sized {
         self
     }
 
+    /// Bind the given callback to the mouse pressure event, during the bubble phase
+    /// the fluent API equivalent to [`Interactivity::on_mouse_pressure`]
+    ///
+    /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback.
+    fn on_mouse_pressure(
+        mut self,
+        listener: impl Fn(&MousePressureEvent, &mut Window, &mut App) + 'static,
+    ) -> Self {
+        self.interactivity().on_mouse_pressure(listener);
+        self
+    }
+
+    /// Bind the given callback to the mouse pressure event, during the capture phase
+    /// the fluent API equivalent to [`Interactivity::on_mouse_pressure`]
+    ///
+    /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback.
+    fn capture_mouse_pressure(
+        mut self,
+        listener: impl Fn(&MousePressureEvent, &mut Window, &mut App) + 'static,
+    ) -> Self {
+        self.interactivity().capture_mouse_pressure(listener);
+        self
+    }
+
     /// Bind the given callback to the mouse down event, on any button, during the capture phase,
     /// when the mouse is outside of the bounds of this element.
     /// The fluent API equivalent to [`Interactivity::on_mouse_down_out`].
@@ -1197,7 +1253,8 @@ pub(crate) type MouseDownListener =
     Box<dyn Fn(&MouseDownEvent, DispatchPhase, &Hitbox, &mut Window, &mut App) + 'static>;
 pub(crate) type MouseUpListener =
     Box<dyn Fn(&MouseUpEvent, DispatchPhase, &Hitbox, &mut Window, &mut App) + 'static>;
-
+pub(crate) type MousePressureListener =
+    Box<dyn Fn(&MousePressureEvent, DispatchPhase, &Hitbox, &mut Window, &mut App) + 'static>;
 pub(crate) type MouseMoveListener =
     Box<dyn Fn(&MouseMoveEvent, DispatchPhase, &Hitbox, &mut Window, &mut App) + 'static>;
 
@@ -1521,6 +1578,7 @@ pub struct Interactivity {
     pub(crate) group_drag_over_styles: Vec<(TypeId, GroupStyle)>,
     pub(crate) mouse_down_listeners: Vec<MouseDownListener>,
     pub(crate) mouse_up_listeners: Vec<MouseUpListener>,
+    pub(crate) mouse_pressure_listeners: Vec<MousePressureListener>,
     pub(crate) mouse_move_listeners: Vec<MouseMoveListener>,
     pub(crate) scroll_wheel_listeners: Vec<ScrollWheelListener>,
     pub(crate) key_down_listeners: Vec<KeyDownListener>,
@@ -1714,6 +1772,7 @@ impl Interactivity {
             || self.group_hover_style.is_some()
             || self.hover_listener.is_some()
             || !self.mouse_up_listeners.is_empty()
+            || !self.mouse_pressure_listeners.is_empty()
             || !self.mouse_down_listeners.is_empty()
             || !self.mouse_move_listeners.is_empty()
             || !self.click_listeners.is_empty()
@@ -2064,6 +2123,13 @@ impl Interactivity {
             })
         }
 
+        for listener in self.mouse_pressure_listeners.drain(..) {
+            let hitbox = hitbox.clone();
+            window.on_mouse_event(move |event: &MousePressureEvent, phase, window, cx| {
+                listener(event, phase, &hitbox, window, cx);
+            })
+        }
+
         for listener in self.mouse_move_listeners.drain(..) {
             let hitbox = hitbox.clone();
             window.on_mouse_event(move |event: &MouseMoveEvent, phase, window, cx| {

crates/gpui/src/interactive.rs 🔗

@@ -174,6 +174,40 @@ pub struct MouseClickEvent {
     pub up: MouseUpEvent,
 }
 
+/// The stage of a pressure click event.
+#[derive(Clone, Copy, Debug, Default, PartialEq)]
+pub enum PressureStage {
+    /// No pressure.
+    #[default]
+    Zero,
+    /// Normal click pressure.
+    Normal,
+    /// High pressure, enough to trigger a force click.
+    Force,
+}
+
+/// A mouse pressure event from the platform. Generated when a force-sensitive trackpad is pressed hard.
+/// Currently only implemented for macOS trackpads.
+#[derive(Debug, Clone, Default)]
+pub struct MousePressureEvent {
+    /// Pressure of the current stage as a float between 0 and 1
+    pub pressure: f32,
+    /// The pressure stage of the event.
+    pub stage: PressureStage,
+    /// The position of the mouse on the window.
+    pub position: Point<Pixels>,
+    /// The modifiers that were held down when the mouse pressure changed.
+    pub modifiers: Modifiers,
+}
+
+impl Sealed for MousePressureEvent {}
+impl InputEvent for MousePressureEvent {
+    fn to_platform_input(self) -> PlatformInput {
+        PlatformInput::MousePressure(self)
+    }
+}
+impl MouseEvent for MousePressureEvent {}
+
 /// A click event that was generated by a keyboard button being pressed and released.
 #[derive(Clone, Debug, Default)]
 pub struct KeyboardClickEvent {
@@ -571,6 +605,8 @@ pub enum PlatformInput {
     MouseDown(MouseDownEvent),
     /// The mouse was released.
     MouseUp(MouseUpEvent),
+    /// Mouse pressure.
+    MousePressure(MousePressureEvent),
     /// The mouse was moved.
     MouseMove(MouseMoveEvent),
     /// The mouse exited the window.
@@ -590,6 +626,7 @@ impl PlatformInput {
             PlatformInput::MouseDown(event) => Some(event),
             PlatformInput::MouseUp(event) => Some(event),
             PlatformInput::MouseMove(event) => Some(event),
+            PlatformInput::MousePressure(event) => Some(event),
             PlatformInput::MouseExited(event) => Some(event),
             PlatformInput::ScrollWheel(event) => Some(event),
             PlatformInput::FileDrop(event) => Some(event),
@@ -604,6 +641,7 @@ impl PlatformInput {
             PlatformInput::MouseDown(_) => None,
             PlatformInput::MouseUp(_) => None,
             PlatformInput::MouseMove(_) => None,
+            PlatformInput::MousePressure(_) => None,
             PlatformInput::MouseExited(_) => None,
             PlatformInput::ScrollWheel(_) => None,
             PlatformInput::FileDrop(_) => None,

crates/gpui/src/platform/mac/events.rs 🔗

@@ -1,7 +1,8 @@
 use crate::{
     Capslock, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton,
-    MouseDownEvent, MouseExitEvent, MouseMoveEvent, MouseUpEvent, NavigationDirection, Pixels,
-    PlatformInput, ScrollDelta, ScrollWheelEvent, TouchPhase,
+    MouseDownEvent, MouseExitEvent, MouseMoveEvent, MousePressureEvent, MouseUpEvent,
+    NavigationDirection, Pixels, PlatformInput, PressureStage, ScrollDelta, ScrollWheelEvent,
+    TouchPhase,
     platform::mac::{
         LMGetKbdType, NSStringExt, TISCopyCurrentKeyboardLayoutInputSource,
         TISGetInputSourceProperty, UCKeyTranslate, kTISPropertyUnicodeKeyLayoutData,
@@ -187,6 +188,26 @@ impl PlatformInput {
                         })
                     })
                 }
+                NSEventType::NSEventTypePressure => {
+                    let stage = native_event.stage();
+                    let pressure = native_event.pressure();
+
+                    window_height.map(|window_height| {
+                        Self::MousePressure(MousePressureEvent {
+                            stage: match stage {
+                                1 => PressureStage::Normal,
+                                2 => PressureStage::Force,
+                                _ => PressureStage::Zero,
+                            },
+                            pressure,
+                            modifiers: read_modifiers(native_event),
+                            position: point(
+                                px(native_event.locationInWindow().x as f32),
+                                window_height - px(native_event.locationInWindow().y as f32),
+                            ),
+                        })
+                    })
+                }
                 // Some mice (like Logitech MX Master) send navigation buttons as swipe events
                 NSEventType::NSEventTypeSwipe => {
                     let navigation_direction = match native_event.phase() {

crates/gpui/src/platform/mac/window.rs 🔗

@@ -153,6 +153,10 @@ unsafe fn build_classes() {
                     sel!(mouseMoved:),
                     handle_view_event as extern "C" fn(&Object, Sel, id),
                 );
+                decl.add_method(
+                    sel!(pressureChangeWithEvent:),
+                    handle_view_event as extern "C" fn(&Object, Sel, id),
+                );
                 decl.add_method(
                     sel!(mouseExited:),
                     handle_view_event as extern "C" fn(&Object, Sel, id),

crates/gpui/src/window.rs 🔗

@@ -3705,6 +3705,9 @@ impl Window {
                 self.modifiers = mouse_up.modifiers;
                 PlatformInput::MouseUp(mouse_up)
             }
+            PlatformInput::MousePressure(mouse_pressure) => {
+                PlatformInput::MousePressure(mouse_pressure)
+            }
             PlatformInput::MouseExited(mouse_exited) => {
                 self.modifiers = mouse_exited.modifiers;
                 PlatformInput::MouseExited(mouse_exited)