From 13b6a08e77a4eac6143b61bff6b6e333f9a297e9 Mon Sep 17 00:00:00 2001 From: Alexey Bagno Date: Mon, 21 Sep 2026 19:15:36 +0300 Subject: [PATCH 1/2] feat(viewport): two-finger trackpad pan and pinch zoom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A trackpad could only zoom: every scroll delta, wheel notch or not, went into the same zoom step, and a pinch never arrived at all. iced's winit layer converts only the window events it has an iced equivalent for, so winit's `PinchGesture` was dropped before the app saw it. Scroll deltas now split by what produced them: a wheel reports notches and still zooms, a precise-scrolling device reports pixels and pans. The same pixel split keeps working everywhere else — the browser build reports every wheel notch as pixels, so there they keep zooming. The pinch is read straight from AppKit with a local `NSEvent` monitor, since the app cannot see the gesture through iced at all, and handed to the app through a subscription shaped like the SpaceMouse bridge. It maps 1:1 onto the camera: fingers 5% further apart leave the view 5% closer. The pan and zoom bodies are now shared by the mouse and the gestures, so the middle-button drag, the wheel, two fingers and a pinch all move the view through one path — in model space, in a tiled pane and inside a floating viewport alike. --- Cargo.lock | 1 + Cargo.toml | 5 +- src/app/mod.rs | 2 + src/app/update/mod.rs | 1 + src/app/update/viewport.rs | 180 +++++++++++++++++++++++++++++-------- src/app/view/mod.rs | 1 + src/input/mod.rs | 1 + src/input/trackpad.rs | 96 ++++++++++++++++++++ 8 files changed, 250 insertions(+), 37 deletions(-) create mode 100644 src/input/trackpad.rs diff --git a/Cargo.lock b/Cargo.lock index 4da766abd..3678736c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10,6 +10,7 @@ dependencies = [ "ashpd", "base64", "bincode", + "block2 0.5.1", "bytemuck", "cadkernel", "cadkernel-constraints", diff --git a/Cargo.toml b/Cargo.toml index 6e78631dc..47c591518 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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] diff --git a/src/app/mod.rs b/src/app/mod.rs index 674622972..2e45d9059 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -2016,6 +2016,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), diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index e93a218a8..4d6092a21 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -410,6 +410,7 @@ impl OpenCADStudio { fn update_inner(&mut self, msg: Message) -> Task { 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., diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 4e9d2006e..54c9bae42 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -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 { - 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 { + 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 { + 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; diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index dc6679743..626df18a2 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -2719,6 +2719,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)) diff --git a/src/input/mod.rs b/src/input/mod.rs index c1131bdeb..a9634889b 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -1,2 +1,3 @@ //! Device integrations. Camera ownership and CAD commands stay in the app. pub(crate) mod spacemouse; +pub(crate) mod trackpad; diff --git a/src/input/trackpad.rs b/src/input/trackpad.rs new file mode 100644 index 000000000..0329f5799 --- /dev/null +++ b/src/input/trackpad.rs @@ -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 { + #[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>>> = OnceLock::new(); + + pub(super) fn subscription() -> iced::Subscription { + 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 + 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>> { + 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| -> *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) + }; + }); + } +} From 279d77a950a854338b40a4c0852819e8927bf518 Mon Sep 17 00:00:00 2001 From: Alexey Bagno Date: Mon, 21 Sep 2026 23:34:08 +0300 Subject: [PATCH 2/2] docs(trackpad): document the gestures and why a pinch bypasses iced The pinch is read from AppKit rather than through iced, which is the kind of decision that reads as arbitrary without the reason next to it. The file also records what the pixel/notch split costs: a smooth-scrolling mouse pans with a trackpad. --- docs/trackpad.md | 70 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 docs/trackpad.md diff --git a/docs/trackpad.md b/docs/trackpad.md new file mode 100644 index 000000000..f697a3eff --- /dev/null +++ b/docs/trackpad.md @@ -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.