forked from 1b-agent/opencadstudio-upstream
feat(viewport): two-finger trackpad pan and pinch zoom
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.
This commit is contained in:
1 parent
baba5bccd3
commit
13b6a08e77
8 files changed
+250
-37
No files matched your search
@@ -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