NativeDesktop

Gestures

Tap, double tap, long press and drag, trackpad pinch, rotate and page swipes, momentum scrolling, haptics, and the focus rules that go with them.

Gestures are handlers on elements: .on_tap(..), .on_pinch(..) and so on. Recognition is native where the OS has it (macOS) and done by nd-gesture elsewhere. Your handler gets the same event type either way, and always on the UI thread, never from inside a native callback.

Example: Notes. A multi-file app: source list sidebar, toolbar, a virtual list of notes, an editor, the command palette, gestures and settings. Source in examples/notes.

Pointer gestures

HandlerEventNotes
.on_tapTapEvent { position, count }a press released in place
.on_double_tapDoubleTapEventwith it set, a single tap waits out the double-click interval
.on_long_pressLongPressEvent { position, phase }after 0.5 s without moving
.on_dragDragEventany direction; .on_drag_axis(axis, ..) for one

A press is a tap until it moves more than 4 points, then a drag can claim it. Positions are in window coordinates.

Phased events carry a GesturePhase: Began, Changed, Ended or Cancelled. Treat Cancelled as undo. It means another recognizer won the press, the OS took the pointer, or a swipe fell short, so whatever you previewed on Began and Changed has to go. A drag's Ended carries velocity in points per second, for a fling.

fn tapped(&mut self, _: &TapEvent, _: &mut Window, cx: &mut Context<Self>) {
    self.taps += 1;
    cx.notify();
}

fn reset(&mut self, _: &DoubleTapEvent, _: &mut Window, cx: &mut Context<Self>) {
    self.taps = 0;
    self.offset = Point::default();
    cx.notify();
}

fn held(&mut self, event: &LongPressEvent, _: &mut Window, cx: &mut Context<Self>) {
    match event.phase {
        GesturePhase::Began => {
            self.pressed = true;
            cx.haptic(Haptic::LevelChange);
        }
        _ => self.pressed = false,
    }
    cx.notify();
}

fn dragged(&mut self, event: &DragEvent, _: &mut Window, cx: &mut Context<Self>) {
    match event.phase {
        GesturePhase::Began | GesturePhase::Changed => self.offset = event.translation,
        // A fling ends with a velocity in points per second; this pad just stays put.
        GesturePhase::Ended => {}
        // Another recognizer won the press: undo the preview.
        GesturePhase::Cancelled => self.offset = Point::default(),
    }
    cx.notify();
}

From examples/scroll/src/gestures.rs, line 17.

Wiring is one handler per gesture:

div()
    .id("pad")
    .size_full()
    .on_tap(cx.listener(Self::tapped))
    .on_double_tap(cx.listener(Self::reset))
    .on_long_press(cx.listener(Self::held))
    .on_drag(cx.listener(Self::dragged))
    .child(text(format!("{} taps, offset {:.0}, {:.0}", self.taps, self.offset.x, self.offset.y)))

From examples/scroll/src/gestures.rs, line 55.

This Pad lives in the scroll example so that it compiles with CI. The benchmark window does not open it.

Nested recognizers

Handlers on nested elements do not both fire for one press. The innermost recognizer to claim the press wins, and ties go to the innermost. An inner tap and an outer drag never both fire for one press: the tap is claimed on release, the drag once the pointer passes the slop.

Trackpad gestures

.on_pinch delivers PinchEvent { scale, phase, position }. scale multiplies, so the handler is zoom *= event.scale. The notes editor zooms its text this way and plays a haptic when the zoom crosses 100%:

fn pinch(&mut self, event: &PinchEvent, _window: &mut Window, cx: &mut Context<Self>) {
    let before = self.zoom;
    self.zoom = (self.zoom * event.scale).clamp(0.5, 3.0);
    // A detent at 100%, felt on Force Touch trackpads: passing through it lands on it.
    if (before - 1.0) * (self.zoom - 1.0) < 0.0 {
        self.zoom = 1.0;
        cx.haptic(Haptic::Alignment);
    }
    cx.notify();
}

From examples/notes/src/editor.rs, line 44.

Where these arrive:

macOSLinux, Windows, web
PinchNSResponder magnify eventsforwarded from GPUI where its platform reports one
Rotate, force click, smart magnifyyesnever delivered
Page swipetrackSwipeEventnot delivered yet

Windows DirectManipulation and Wayland swipe and hold gestures need patches to the GPUI fork that have not landed. Finger counts the compositor owns, such as Hyprland's gesture = rules, are never promised. Build the same action behind a button or a key as well.

Scrolling

div().overflow_y_scroll() (and overflow_x_scroll, overflow_scroll) makes a scroll view. On macOS it is an NSScrollView with native momentum and elastic overscroll. On the GPUI backends the framework does both:

Lists scroll the same way; see virtualization.

Who gets a scroll

A two-finger scroll locks to an axis after 8 points. After the lock the innermost consumer of that axis wins: a scroller with room, a row swipe, or a page swipe. A row swipe inside a vertical list therefore never fights the list, and a horizontal scroller inside a vertical one keeps its own axis.

Row actions

Swipe actions on list rows come from the list (list(..).row_actions(|key, cx| ..)), not from a gesture handler, because macOS builds them from the table's delegate and a misuse should not compile. The example is in virtualization.

Haptics

cx.haptic(Haptic::Alignment) plays a trackpad haptic timed to the frame being drawn.

HapticUse
Alignmentsomething snapped: a guide, a detent, a zoom reaching 100%
LevelChangea discrete threshold: a full-swipe point, a stepper
Genericanything else

macOS has no custom waveforms, so these map onto the three NSHapticFeedbackManager patterns. It is a no-op on every other OS and on trackpads without Force Touch, so call it without checking.

Overlays and gestures

Gesture hit testing starts inside the topmost overlay, or hits nothing. A tap on a popover never reaches a handler underneath it. See overlays.

Focus and keyboard

These rules are the framework's, not yours to implement:

Text fields are NSTextField and NSTextView on macOS and GPUI's text input elsewhere, so IME composition goes through the platform's own path on each.