forked from 1b-agent/opencadstudio-upstream
Merge pull request #1424 from swit33:feat/trackpad-pan-pinch
This commit is contained in:
commit
ba8d828c3e
9 files changed
+320
-37
No files matched your search
Generated
+1
@@ -10,6 +10,7 @@ dependencies = [
|
||||
"ashpd",
|
||||
"base64",
|
||||
"bincode",
|
||||
"block2 0.5.1",
|
||||
"bytemuck",
|
||||
"cadkernel",
|
||||
"cadkernel-constraints",
|
||||
|
||||
+4
-1
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
@@ -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>),
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,2 +1,3 @@
|
||||
//! Device integrations. Camera ownership and CAD commands stay in the app.
|
||||
pub(crate) mod spacemouse;
|
||||
pub(crate) mod trackpad;
|
||||
@@ -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)
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user