Merge pull request #1424 from swit33:feat/trackpad-pan-pinch

This commit is contained in:
Hakan Seven committed 2026-09-22 12:28:03 +03:00
commit ba8d828c3e
9 files changed
+320 -37

No files matched your search

Generated
+1
View File
@@ -10,6 +10,7 @@ dependencies = [
"ashpd",
"base64",
"bincode",
"block2 0.5.1",
"bytemuck",
"cadkernel",
"cadkernel-constraints",
+4 -1
View File
@@ -74,7 +74,10 @@ ashpd = { version = "0.13.13", default-features = false, features = ["async-io",
# here to the same versions Cargo already resolves for it.
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.5"
objc2-app-kit = { version = "0.2", features = ["NSApplication", "NSResponder", "NSRunningApplication"] }
# The `NSEvent`/`block2` features are for the trackpad pinch monitor in
# `src/input/trackpad.rs` — see that file for why the gesture is read there.
objc2-app-kit = { version = "0.2", features = ["NSApplication", "NSResponder", "NSRunningApplication", "NSEvent", "block2"] }
block2 = "0.5"
objc2-foundation = { version = "0.2", features = ["NSArray", "NSURL", "NSString", "NSObject", "NSNotification", "NSThread", "NSEnumerator"] }
[patch.crates-io]
+70
View File
@@ -0,0 +1,70 @@
# Trackpad
The macOS desktop app reads trackpad gestures directly: two fingers pan, a pinch
zooms. The web build and the Linux desktop build keep zooming on every scroll
delta and have no pinch; the reasons are in Implementation below.
A mouse is unaffected. The wheel zooms, the middle button pans, and Shift
together with the middle button orbits, exactly as before.
## Navigation
| Gesture | Behavior |
| --- | --- |
| Two-finger scroll | Pan the active view, both axes, the drawing following the fingers. |
| Pinch | Zoom about the cursor, at the scale of the fingers' own spread. |
| Wheel (mouse) | Zoom about the cursor. `ZOOMFACTOR` scales a notch; `ZOOMWHEEL` reverses it. |
| Middle-button drag | Pan. Shift together with the middle button orbits the model view. |
| `PAN` / `P` | Pan with the left button until Escape. |
Panning covers the same ground whichever way it starts: the active model tile,
a paper sheet, or the model view inside a floating viewport in MSPACE. A pan
dragged out of a two-finger gesture and one dragged with the middle button move
through the same code, so they agree on speed, on the tile they pan by, and on
keeping an open selection box attached to the drawing underneath.
A pinch is not affected by `ZOOMWHEEL`. The direction of a pinch is the
direction of the fingers, not a convention about a device.
On macOS a mouse that scrolls smoothly, such as a Magic Mouse, reports precise
pixel deltas like a trackpad does, so it pans instead of zooming. That follows
from the split described below; it is not a separate setting.
## Implementation
- `src/input/trackpad.rs`: the pinch. iced's winit layer converts only the
window events that have an iced equivalent, so winit's `PinchGesture` is
dropped before the app sees it. The gesture is read from AppKit with a local
`NSEvent` monitor instead, installed on the main thread where iced builds
subscriptions. The monitor's block runs on the main thread during AppKit's own
dispatch and only publishes the magnification into a sink; the app receives it
through an iced subscription shaped like the SpaceMouse bridge
(`src/input/spacemouse/`). The monitor token is never released, which does not
uninstall the monitor — `removeMonitor` does, and the app has one window for
its whole life.
- `src/app/update/viewport.rs`: `scroll_intent` decides what a scroll delta
means. A wheel reports notches and zooms; a precise-scrolling device reports
pixels and pans. There is no modifier and no setting in that decision.
`pan_active_view` and `zoom_view_at_cursor` are the bodies shared by the mouse
and the gestures, and they apply a pinch as `1 - step / 10 = 1 / (1 + m)` so a
pinch of `m` leaves the view exactly `1 + m` times closer.
- `src/app/mod.rs` carries one `Message::TrackpadPinch(f32)` per gesture event.
Pixel deltas pan only where they can only mean a trackpad. The web build
receives every wheel notch as a pixel delta from the browser, so there — and on
Linux, where a smooth-scrolling mouse reports them the same way — they keep
zooming. A pinch exists nowhere else either: winit reports `PinchGesture` on
macOS and iOS only, its `PanGesture` is iOS-only, and its Wayland backend does
not bind the pointer-gesture protocol at all.
## Validation
```sh
cargo test --locked --lib scroll_intent
```
The tests cover the decision between a notch and a pixel, and the 1:1 mapping of
a pinch onto the camera. The gestures themselves are checked by hand: macOS
offers no way to synthesize a magnify event, so a pinch cannot be driven by a
test. Two-finger pan and pinch are exercised in model space, in a tiled view, on
a paper sheet, and inside a floating viewport in MSPACE.
+2
View File
@@ -2023,6 +2023,8 @@ pub enum Message {
SpaceMousePreferences,
SpaceMouseDriverSettings,
SpaceMouseDetails,
/// One magnification delta from a trackpad pinch, positive zooming in.
TrackpadPinch(f32),
ControlRequest(control::Envelope),
PollWebControl,
ControlStep(String, Box<Message>),
+1
View File
@@ -410,6 +410,7 @@ impl OpenCADStudio {
fn update_inner(&mut self, msg: Message) -> Task<Message> {
match msg {
Message::SpaceMouseWake => self.on_spacemouse_wake(),
Message::TrackpadPinch(magnification) => self.on_pinch_zoom(magnification),
Message::SpaceMouseFrame(time) => {
self.spacemouse.frame(
time.saturating_duration_since(self.start).as_secs_f64() * 1000.,
+144 -36
View File
@@ -35,6 +35,47 @@ fn pt_pt_d2(a: Point, b: Point) -> f32 {
(a.x - b.x).powi(2) + (a.y - b.y).powi(2)
}
/// What one scroll delta asks the viewport to do.
#[derive(Clone, Copy, Debug, PartialEq)]
enum ScrollIntent {
/// Wheel notches to zoom by; positive zooms in.
Zoom { notches: f32 },
/// Trackpad movement to pan by, in screen pixels.
///
/// Built only where a pixel delta can only mean a trackpad, so it has no
/// constructor on the other targets.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
Pan { dx: f32, dy: f32 },
}
/// A wheel notch zooms; a trackpad's two fingers pan.
///
/// A wheel reports notches, a precise-scrolling device reports pixels, and that
/// is the whole distinction — there is no modifier to check and no setting to
/// read. The pixels only mean "trackpad" on macOS: the browser build reports
/// every wheel notch as pixels too, so everywhere else they keep zooming.
fn scroll_intent(delta: mouse::ScrollDelta) -> ScrollIntent {
match delta {
// Both axes go straight through: the two-finger gesture moves the
// drawing the same way the middle-button drag moves it. If a pan ever
// comes out mirrored, this is the line.
#[cfg(target_os = "macos")]
mouse::ScrollDelta::Pixels { x, y } => ScrollIntent::Pan { dx: x, dy: y },
#[cfg(not(target_os = "macos"))]
mouse::ScrollDelta::Pixels { y, .. } => ScrollIntent::Zoom { notches: y * 0.01 },
mouse::ScrollDelta::Lines { y, .. } => ScrollIntent::Zoom { notches: y },
}
}
/// The camera step that pinches the view 1:1 with the fingers, from AppKit's
/// magnification delta (0.05 = the fingers moved 5% further apart).
///
/// AppKit reports the increment of a scale factor, and a 5% wider pinch has to
/// leave the view 5% closer, so the step solves `1 - step / 10 = 1 / (1 + m)`.
fn pinch_zoom_steps(magnification: f32) -> f32 {
10.0 * magnification / (1.0 + magnification)
}
/// Whether a command point keeps the elevation of its snap instead of being
/// clamped to the world XY plane.
///
@@ -1145,36 +1186,9 @@ impl OpenCADStudio {
return Task::none();
}
}
// Pan scale uses the active tile's size (ortho size
// is relative to viewport height), so a tiled pane
// pans at the correct rate.
let bounds = self.tabs[i]
.scene
.active_model_tile_bounds(vp_size.0, vp_size.1);
// Drop `sel` before calling mutable scene methods.
drop(sel);
if self.tabs[i].scene.active_viewport.is_some() {
self.tabs[i].scene.pan_active_viewport(dx, dy, bounds);
// Bump so the GPU re-uploads the viewport's re-culled
// wire set — otherwise newly-revealed lines stay
// invisible until MSPACE is exited.
self.tabs[i].scene.camera_generation += 1;
} else {
// `bounds` is the active tile; pan by its height so
// the point under the cursor tracks correctly.
self.tabs[i]
.scene
.camera
.borrow_mut()
.pan_screen(dx, dy, bounds.height);
self.tabs[i].scene.camera_generation += 1;
// Keep an in-progress box selection pinned to the
// drawing as the view pans under it (#234).
self.reproject_box_anchor(i, vp_size.0, vp_size.1);
}
self.tabs[i]
.scene
.record_nav_perf(crate::scene::NavPerfOp::Pan, move_started);
self.pan_active_view(i, dx, dy, move_started);
self.tabs[i].scene.selection.borrow_mut().middle_last_pos = Some(p);
return Task::none();
}
@@ -5339,16 +5353,74 @@ properties={:.1}ms picked={}",
Task::none()
}
/// A wheel notch zooms; a trackpad's two fingers pan. Which one a delta
/// means is `scroll_intent`'s call.
pub(super) fn on_viewport_scroll(&mut self, delta: mouse::ScrollDelta) -> Task<Message> {
let nav_started = Instant::now();
let mut s = match delta {
mouse::ScrollDelta::Lines { y, .. } => y,
mouse::ScrollDelta::Pixels { y, .. } => y * 0.01,
};
s *= self.zoom_factor as f32 / 60.0;
if self.zoom_wheel_reversed {
s = -s;
match scroll_intent(delta) {
ScrollIntent::Zoom { notches } => {
let mut s = notches * self.zoom_factor as f32 / 60.0;
if self.zoom_wheel_reversed {
s = -s;
}
self.zoom_view_at_cursor(s)
}
ScrollIntent::Pan { dx, dy } => {
let i = self.active_tab;
self.tabs[i].scene.remember_current_view();
self.clear_navigation_hover(i);
self.pan_active_view(i, dx, dy, Instant::now());
self.arm_hover_after_navigation(i);
Task::none()
}
}
}
/// Pinch the active view to zoom, as reported by the trackpad monitor
/// (`src/input/trackpad.rs`). The pivot is the cursor, like the wheel, and
/// the direction is the fingers' own, so ZOOMWHEEL does not apply to it.
pub(super) fn on_pinch_zoom(&mut self, magnification: f32) -> Task<Message> {
if magnification == 0.0 {
return Task::none();
}
self.zoom_view_at_cursor(pinch_zoom_steps(magnification))
}
/// Pan the active view by `dx`/`dy` screen pixels: the body shared by the
/// middle-button drag and the two-finger gesture.
fn pan_active_view(&mut self, i: usize, dx: f32, dy: f32, started: Instant) {
// Pan scale uses the active tile's size (ortho size is relative to
// viewport height), so a tiled pane pans at the correct rate.
let (vw, vh) = self.tabs[i].scene.selection.borrow().vp_size;
let bounds = self.tabs[i].scene.active_model_tile_bounds(vw, vh);
if self.tabs[i].scene.active_viewport.is_some() {
self.tabs[i].scene.pan_active_viewport(dx, dy, bounds);
// Bump so the GPU re-uploads the viewport's re-culled wire set —
// otherwise newly-revealed lines stay invisible until MSPACE is
// exited.
self.tabs[i].scene.camera_generation += 1;
} else {
// `bounds` is the active tile; pan by its height so the point
// under the cursor tracks correctly.
self.tabs[i]
.scene
.camera
.borrow_mut()
.pan_screen(dx, dy, bounds.height);
self.tabs[i].scene.camera_generation += 1;
// Keep an in-progress box selection pinned to the drawing as the
// view pans under it (#234).
self.reproject_box_anchor(i, vw, vh);
}
self.tabs[i]
.scene
.record_nav_perf(crate::scene::NavPerfOp::Pan, started);
}
/// Zoom the active view by `s`, in the camera's own units
/// (`Camera::zoom`: `distance *= 1 - s / 10`). Shared by the wheel and
/// the pinch.
fn zoom_view_at_cursor(&mut self, s: f32) -> Task<Message> {
let nav_started = Instant::now();
let i = self.active_tab;
self.tabs[i].scene.remember_current_view();
self.clear_navigation_hover(i);
@@ -6304,6 +6376,42 @@ properties={:.1}ms picked={}",
}
#[cfg(test)]
mod scroll_intent_tests {
use super::{pinch_zoom_steps, scroll_intent, ScrollIntent};
use iced::mouse::ScrollDelta;
#[test]
fn wheel_notches_zoom() {
assert_eq!(
scroll_intent(ScrollDelta::Lines { x: 0.0, y: 1.0 }),
ScrollIntent::Zoom { notches: 1.0 }
);
}
/// Both axes of the gesture have to reach the pan, and the drawing has to
/// follow the fingers rather than run away from them.
#[cfg(target_os = "macos")]
#[test]
fn two_finger_scroll_pans() {
assert_eq!(
scroll_intent(ScrollDelta::Pixels { x: 4.0, y: 6.0 }),
ScrollIntent::Pan { dx: 4.0, dy: 6.0 }
);
}
/// A pinch is a scale factor, so the view has to end up exactly that much
/// closer: fingers 5% apart divide the camera distance by 1.05.
#[test]
fn pinch_is_one_to_one() {
let zoom_after = 1.0 - pinch_zoom_steps(0.05) / 10.0;
assert!((zoom_after - 1.0 / 1.05).abs() < 1e-6, "{zoom_after}");
assert!(pinch_zoom_steps(-0.05) < 0.0, "pinching in zooms out");
// No pinch, no step: a zero delta must not divide the view away.
assert_eq!(pinch_zoom_steps(0.0), 0.0);
}
}
#[cfg(test)]
mod snap_elevation_tests {
use super::snap_keeps_elevation;
+1
View File
@@ -2732,6 +2732,7 @@ impl OpenCADStudio {
Subscription::none()
},
self.spacemouse.subscription().map(|_| Message::SpaceMouseWake),
crate::input::trackpad::subscription().map(Message::TrackpadPinch),
event::listen_with(|event, _, id| match event {
iced::Event::Window(window::Event::Focused) => {
Some(Message::SpaceMouseFocus(id, true))
+1
View File
@@ -1,2 +1,3 @@
//! Device integrations. Camera ownership and CAD commands stay in the app.
pub(crate) mod spacemouse;
pub(crate) mod trackpad;
+96
View File
@@ -0,0 +1,96 @@
//! Trackpad gestures.
//!
//! A two-finger scroll already reaches the app as an iced scroll delta, so
//! panning with it is decided in the viewport (see `scroll_intent`). A pinch
//! does not: winit reports it as `WindowEvent::PinchGesture`, and iced's winit
//! layer converts only the window events it has an iced equivalent for — a
//! gesture is not one of them, so it is dropped before the app ever sees it.
//! The gesture is therefore read straight from AppKit with a local event
//! monitor and handed to the app through an iced subscription, the same shape
//! the SpaceMouse bridge uses.
/// Magnification deltas from a pinch, positive meaning zoom in.
///
/// Never fires off macOS: there is no pinch gesture to read.
pub(crate) fn subscription() -> iced::Subscription<f32> {
#[cfg(target_os = "macos")]
{
appkit::subscription()
}
#[cfg(not(target_os = "macos"))]
{
iced::Subscription::none()
}
}
#[cfg(target_os = "macos")]
mod appkit {
use iced::futures::{channel::mpsc, Stream};
use std::sync::{Mutex, OnceLock};
/// The subscription's output, parked where the event monitor — which runs
/// on the main thread, outside iced — can push into it. `None` until the
/// subscription is first built, and replaced if iced ever rebuilds it.
static SINK: OnceLock<Mutex<Option<mpsc::Sender<f32>>>> = OnceLock::new();
pub(super) fn subscription() -> iced::Subscription<f32> {
install_monitor();
iced::Subscription::run_with(SinkId, stream)
}
/// Distinguishes this subscription; the value never changes, so the stream
/// stays alive across updates.
#[derive(Hash)]
struct SinkId;
fn stream(_: &SinkId) -> impl Stream<Item = f32> + use<> {
iced::stream::channel(64, |output| async move {
sink().lock().unwrap().replace(output);
// Park forever: the app holds the live channel through `SINK`, so
// returning here would retire the subscription that feeds it.
std::future::pending::<()>().await;
})
}
fn sink() -> &'static Mutex<Option<mpsc::Sender<f32>>> {
SINK.get_or_init(|| Mutex::new(None))
}
/// Hand one magnification to the app. A full channel means the app is
/// thousands of events behind, so dropping the event is the right failure.
fn publish(magnification: f32) {
if let Some(slot) = SINK.get() {
if let Some(sender) = slot.lock().unwrap().as_mut() {
let _ = sender.try_send(magnification);
}
}
}
fn install_monitor() {
use block2::RcBlock;
use objc2_app_kit::{NSEvent, NSEventMask};
use std::ptr::NonNull;
use std::sync::Once;
static INSTALL: Once = Once::new();
// AppKit wants the monitor installed on the main thread, which is where
// iced builds subscriptions. The block itself also runs there, during
// AppKit's own dispatch, and only touches the sink — never app state.
INSTALL.call_once(|| {
let block = RcBlock::new(|event: NonNull<NSEvent>| -> *mut NSEvent {
// SAFETY: AppKit owns the event for the length of this call.
let magnification = unsafe { event.as_ref().magnification() } as f32;
publish(magnification);
// Pass the event along untouched; the pinch is not consumed.
event.as_ptr()
});
// The monitor token is deliberately dropped: the app has a single
// window for its whole life, and releasing the token does not
// uninstall the monitor anyway (`removeMonitor` does).
unsafe {
NSEvent::addLocalMonitorForEventsMatchingMask_handler(NSEventMask::Magnify, &block)
};
});
}
}