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/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. diff --git a/src/app/mod.rs b/src/app/mod.rs index be4da2bdd..9490a9977 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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), diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 7eded86aa..c638bb100 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 a94433ac7..2d013adcd 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 a66a96e88..962515415 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -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)) 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) + }; + }); + } +}