diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index e535cc723..f67591bee 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -9,7 +9,7 @@ //! - `ui.inspect`: UI state + summary; `ui.elements {prefix?}`: registered widgets (id, label, rect) //! - `ui.set {tool?, workspace?, theme?, focused?, viewer?: {zoom, res, pan, grid, …}, timeline?: {pps, start, graphEditor, showModes}}` //! - `ui.panel.show {panel}` / `ui.panel.close {panel}` -//! - `ui.click {id | x,y, button?, count?, modifiers?}` / `ui.move {x,y}` / `ui.scroll {x,y,dx,dy}` +//! - `ui.click {id | x,y, button?, count?, modifiers?}` / `ui.move {x,y}` / `ui.scroll {x,y,dx,dy,unit?,source?,pinch?}` //! - `ui.drag {from:{id|x,y}, to:{id|x,y}, steps?, modifiers?}`: synthetic press-move-release //! - `ui.key {key, command?, shift?, alt?, ctrl?}` / `ui.type {text}` //! - `ui.viewer.locate {x, y}`: screen point of a comp pixel; `ui.viewer.hit {x, y}`: comp pixel at a screen point @@ -500,8 +500,9 @@ fn drag(app: &mut EffectcraftApp, p: &Value) -> Outcome { Outcome::AfterInput } -/// Queues a move to the target point, a wheel event of `dx`, `dy` points with the request's -/// modifiers, and a released Extra1 button. Replies [`Outcome::AfterInput`]. +/// Queues a move to the target point, a wheel event of `dx`, `dy` (`unit`: points or lines) with +/// the request's modifiers, a pinch's zoom factor, and a released Extra1 button. Replies +/// [`Outcome::AfterInput`]. fn scroll(app: &mut EffectcraftApp, p: &Value) -> Outcome { let pos = match point(app, p) { Ok(p) => p, @@ -509,14 +510,23 @@ fn scroll(app: &mut EffectcraftApp, p: &Value) -> Outcome { }; let dx = p.get("dx").and_then(Value::as_f64).unwrap_or(0.0) as f32; let dy = p.get("dy").and_then(Value::as_f64).unwrap_or(0.0) as f32; + let line = p.get("unit").and_then(Value::as_str) == Some("line"); + let unit = if line { egui::MouseWheelUnit::Line } else { egui::MouseWheelUnit::Point }; + // Without a `source`, a scroll in lines is a mouse wheel and one in points comes from an + // unnamed device, as on Windows, Linux and the web. + let source = match p.get("source").and_then(Value::as_str) { + Some("trackpad") => egui::MouseWheelSource::Trackpad, + Some("wheel") => egui::MouseWheelSource::Wheel, + _ if line => egui::MouseWheelSource::Wheel, + _ => egui::MouseWheelSource::Unknown, + }; app.synthetic.push(egui::Event::PointerMoved(pos)); - app.synthetic.push(egui::Event::MouseWheel { - unit: egui::MouseWheelUnit::Point, - delta: egui::vec2(dx, dy), - modifiers: modifiers(p), - phase: egui::TouchPhase::Move, - source: egui::MouseWheelSource::Wheel, - }); + if dx != 0.0 || dy != 0.0 { + app.synthetic.push(egui::Event::MouseWheel { unit, delta: egui::vec2(dx, dy), modifiers: modifiers(p), phase: egui::TouchPhase::Move, source }); + } + if let Some(pinch) = p.get("pinch").and_then(Value::as_f64).filter(|z| z.is_finite() && *z > 0.0) { + app.synthetic.push(egui::Event::Zoom(pinch as f32)); + } app.synthetic.push(egui::Event::PointerButton { pos: egui::pos2(-100.0, -100.0), button: egui::PointerButton::Extra1, diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 9a8fd2a54..d386d9784 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -175,6 +175,56 @@ pub(crate) fn space_hand(ctx: &egui::Context) -> bool { ctx.input(|i| i.key_down(egui::Key::Space)) && !ctx.egui_wants_keyboard_input() } +/// What a scroll gesture over a viewer (Composition, Layer) does this frame. +pub(crate) struct ViewerScroll { + /// Points to pan by: a two-finger trackpad scroll. + pub pan: egui::Vec2, + /// Factor to zoom by around the pointer: a pinch, Ctrl/Cmd+scroll, a mouse wheel, or + /// Alt + a trackpad scroll. + pub zoom: f32, +} + +/// Splits the frame's scroll input into a viewer pan and a zoom: a mouse wheel zooms and a +/// trackpad's two-finger scroll pans. +/// +/// Where the platform names the device (`MouseWheelSource`, macOS), that decides. Elsewhere a +/// wheel scrolls in lines, but in a browser and with some high-resolution wheels it scrolls in +/// points like a trackpad does, so the unit alone doesn't tell them apart. A scroll in points is +/// a trackpad's once it has shown what a wheel can't do: a touch phase, movement on both axes at +/// once, or a pinch. That holds until a wheel scrolls in lines. Until then the vertical part +/// zooms; the horizontal part always pans. +pub(crate) fn viewer_scroll(ctx: &egui::Context) -> ViewerScroll { + let id = egui::Id::unique("viewer-scroll-trackpad"); + // The scroll is smoothed over the frames after its event, so the device is remembered. + let was: bool = ctx.data(|d| d.get_temp(id)).unwrap_or(false); + let trackpad = ctx.input(|i| { + i.events.iter().fold(was, |t, e| match e { + egui::Event::MouseWheel { source: egui::MouseWheelSource::Trackpad | egui::MouseWheelSource::Momentum, .. } => true, + egui::Event::MouseWheel { source: egui::MouseWheelSource::Wheel, .. } => false, + egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta, phase, modifiers, .. } => { + // Shift turns a wheel's scroll sideways, which is still one axis. + t || *phase == egui::TouchPhase::Start || (delta.x != 0.0 && delta.y != 0.0 && !modifiers.shift) + } + egui::Event::MouseWheel { .. } => false, + egui::Event::Zoom(_) => true, + _ => t, + }) + }); + if trackpad != was { + ctx.data_mut(|d| d.insert_temp(id, trackpad)); + } + let (scroll, pinch, alt) = ctx.input(|i| (i.smooth_scroll_delta, i.zoom_delta(), i.modifiers.alt)); + let pinch = if pinch.is_finite() && pinch > 0.0 { pinch } else { 1.0 }; + let scroll = if scroll.x.is_finite() && scroll.y.is_finite() { scroll } else { egui::Vec2::ZERO }; + if trackpad && !alt { + ViewerScroll { pan: scroll, zoom: pinch } + } else if alt { + ViewerScroll { pan: egui::Vec2::ZERO, zoom: pinch * (scroll.y / 300.0).exp() } + } else { + ViewerScroll { pan: egui::vec2(scroll.x, 0.0), zoom: pinch * (scroll.y / 300.0).exp() } + } +} + pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rect) { match p { PanelKind::Composition => viewers::show(app, ui, 0, rect), diff --git a/crates/ui-egui/src/panels/layer_panel.rs b/crates/ui-egui/src/panels/layer_panel.rs index 5fb4d703c..e982d5bde 100644 --- a/crates/ui-egui/src/panels/layer_panel.rs +++ b/crates/ui-egui/src/panels/layer_panel.rs @@ -310,13 +310,17 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.auto.add("layerPanel.canvas", frame, &layer.name); app.auto.add("layerPanel.area", area, "Layer panel"); - // Navigation (as the Composition panel): the wheel / pinch zooms around the pointer with any - // tool; the Hand tool or Space-drag pans and the Zoom tool clicks zoom in (Alt: out). + // Navigation (as the Composition panel): the wheel / pinch zooms around the pointer and a + // two-finger trackpad scroll pans with any tool; the Hand tool or Space-drag pans and the + // Zoom tool clicks zoom in (Alt: out). let hovered = ui.rect_contains_pointer(area); if let Some(hp) = ui.input(|i| i.pointer.hover_pos()).filter(|_| hovered) { - let scroll = ui.input(|i| i.smooth_scroll_delta.y + i.zoom_delta().ln() * 300.0); - if scroll.is_finite() && scroll.abs() > 0.1 { - zoom_at(app, area, [lw, lh], hp, to_layer(hp), zoom as f32 * (scroll / 300.0).exp()); + let scroll = super::viewer_scroll(&ctx); + if (scroll.zoom - 1.0).abs() > 3e-4 { + zoom_at(app, area, [lw, lh], hp, to_layer(hp), zoom as f32 * scroll.zoom); + } else if scroll.pan != egui::Vec2::ZERO { + app.ui.layer_zoom.get_or_insert(zoom as f32); + app.ui.layer_pan = [pan.x + scroll.pan.x, pan.y + scroll.pan.y]; } } // A middle-drag pans with any tool; painting and Roto Brush strokes don't run meanwhile. diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index bcae99bc5..2884ab171 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -935,11 +935,14 @@ fn phase_hover(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, resp } }; ctx.set_cursor_icon(cursor); - // Scroll wheel zooms around the pointer. - let scroll = ui.input(|i| i.smooth_scroll_delta.y + i.zoom_delta().ln() * 300.0); - if scroll.abs() > 0.1 && area.contains(hp) { - let k = (scroll / 300.0).exp(); - let nz = (zoom * k).clamp(0.01, 32.0); + // The mouse wheel and a trackpad pinch zoom around the pointer; a two-finger trackpad + // scroll pans. + let scroll = super::viewer_scroll(&ctx); + if scroll.pan != egui::Vec2::ZERO && area.contains(hp) { + app.ui.viewer.pan = [app.ui.viewer.pan[0] + scroll.pan.x, app.ui.viewer.pan[1] + scroll.pan.y]; + } + if (scroll.zoom - 1.0).abs() > 3e-4 && area.contains(hp) { + let nz = (zoom * scroll.zoom).clamp(0.01, 32.0); let before = map.to_comp(hp); let new_origin = hp - vec2(before[0] as f32 * nz, before[1] as f32 * nz); let new_center = new_origin + vec2(cw * nz / 2.0, ch * nz / 2.0); diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 1dcf02da0..d60c78df0 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -80,6 +80,7 @@ mod ui_tracker; mod ui_unreadable_footage; mod ui_video_preview_off; mod ui_viewer; +mod ui_viewer_trackpad; mod ui_viewers; mod ui_visual_review; mod ui_web_gpu_storage; diff --git a/crates/ui-egui/tests/it/ui_camera_tracker.rs b/crates/ui-egui/tests/it/ui_camera_tracker.rs index 2ab27fd6e..f7d349342 100644 --- a/crates/ui-egui/tests/it/ui_camera_tracker.rs +++ b/crates/ui-egui/tests/it/ui_camera_tracker.rs @@ -171,8 +171,8 @@ fn viewer_zooms_and_pans_over_the_track_points() { let zoom = h.state().ui.viewer.zoom; h.input_mut().events.push(egui::Event::PointerMoved(at)); h.input_mut().events.push(egui::Event::MouseWheel { - unit: egui::MouseWheelUnit::Point, - delta: egui::vec2(0.0, 120.0), + unit: egui::MouseWheelUnit::Line, + delta: egui::vec2(0.0, 3.0), modifiers: Default::default(), phase: egui::TouchPhase::Move, source: egui::MouseWheelSource::Wheel, diff --git a/crates/ui-egui/tests/it/ui_layer_panel_zoom.rs b/crates/ui-egui/tests/it/ui_layer_panel_zoom.rs index 1aabdd2bc..73ed95cbd 100644 --- a/crates/ui-egui/tests/it/ui_layer_panel_zoom.rs +++ b/crates/ui-egui/tests/it/ui_layer_panel_zoom.rs @@ -107,8 +107,8 @@ fn wheel_zooms_around_the_pointer_and_hand_tool_pans() { let at = canvas.min + canvas.size() * 0.25; h.input_mut().events.push(Event::PointerMoved(at)); h.input_mut().events.push(Event::MouseWheel { - unit: egui::MouseWheelUnit::Point, - delta: vec2(0.0, 120.0), + unit: egui::MouseWheelUnit::Line, + delta: vec2(0.0, 3.0), modifiers: Default::default(), phase: egui::TouchPhase::Move, source: egui::MouseWheelSource::Wheel, @@ -176,3 +176,33 @@ fn roto_brush_in_the_composition_panel_says_where_it_works() { assert_eq!(h.state().ui.layer_panel, Some(box_id(&h))); assert_eq!(h.state().ui.focused, PanelKind::Layer); } + +/// A two-finger trackpad scroll pans the layer with any tool and a pinch zooms around the pointer. +#[test] +fn trackpad_scroll_pans_and_pinch_zooms_around_the_pointer() { + let mut h = harness(); + let canvas = rect(&h, "layerPanel.canvas"); + let at = canvas.min + canvas.size() * 0.25; + h.input_mut().events.push(Event::PointerMoved(at)); + h.input_mut().events.push(Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(60.0, -30.0), + modifiers: Default::default(), + phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Unknown, + }); + h.run_steps(30); + let panned = rect(&h, "layerPanel.canvas"); + let moved = panned.min - canvas.min; + assert!((moved.x - 60.0).abs() < 1.5 && (moved.y + 30.0).abs() < 1.5, "the scroll pans: {moved:?}"); + assert!((panned.width() - canvas.width()).abs() < 0.5, "a scroll doesn't zoom: {} → {}", canvas.width(), panned.width()); + // Pinch: zooms, keeping the point under the pointer. + let at = panned.min + panned.size() * 0.25; + h.input_mut().events.push(Event::PointerMoved(at)); + h.input_mut().events.push(Event::Zoom(1.5)); + h.run_steps(3); + let zoomed = rect(&h, "layerPanel.canvas"); + assert!((zoomed.width() / panned.width() - 1.5).abs() < 0.01, "the pinch zooms: {} → {}", panned.width(), zoomed.width()); + let still = zoomed.min + zoomed.size() * 0.25; + assert!(still.distance(at) < 2.0, "the point under the pointer stays put: {at:?} vs {still:?}"); +} diff --git a/crates/ui-egui/tests/it/ui_viewer_trackpad.rs b/crates/ui-egui/tests/it/ui_viewer_trackpad.rs new file mode 100644 index 000000000..55466371a --- /dev/null +++ b/crates/ui-egui/tests/it/ui_viewer_trackpad.rs @@ -0,0 +1,156 @@ +//! Trackpad gestures in the Composition viewer: a two-finger scroll pans, a pinch (and the mouse +//! wheel, also one that scrolls in pixels) zooms around the pointer (egui_kittest, UI logic only). + +use effectcraft_engine::Session; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::dock::PanelKind; +use egui::{Event, Rect, pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +fn harness() -> Harness<'static, EffectcraftApp> { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "View", "width": 640, "height": 360, "duration": 4})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.state_mut().show_panel(PanelKind::Composition); + h.run_steps(4); + h +} + +fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")); + Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3])) +} + +fn wheel(h: &mut Harness<'_, EffectcraftApp>, unit: egui::MouseWheelUnit, delta: egui::Vec2, modifiers: egui::Modifiers) { + h.input_mut().events.push(Event::MouseWheel { unit, delta, modifiers, phase: egui::TouchPhase::Move, source: egui::MouseWheelSource::Unknown }); + h.run_steps(30); +} + +#[test] +fn trackpad_scroll_pans_the_composition() { + let mut h = harness(); + let comp = rect(&h, "viewer.comp"); + h.input_mut().events.push(Event::PointerMoved(comp.center())); + wheel(&mut h, egui::MouseWheelUnit::Point, vec2(80.0, -40.0), Default::default()); + let after = rect(&h, "viewer.comp"); + let moved = after.min - comp.min; + assert!((moved.x - 80.0).abs() < 1.5 && (moved.y + 40.0).abs() < 1.5, "the scroll pans: {moved:?}"); + assert!((after.width() - comp.width()).abs() < 0.5, "a scroll doesn't zoom: {} → {}", comp.width(), after.width()); +} + +#[test] +fn pinch_and_mouse_wheel_zoom_around_the_pointer() { + let mut h = harness(); + let comp = rect(&h, "viewer.comp"); + let at = comp.min + comp.size() * 0.25; + h.input_mut().events.push(Event::PointerMoved(at)); + h.input_mut().events.push(Event::Zoom(1.5)); + h.run_steps(3); + let zoomed = rect(&h, "viewer.comp"); + assert!((zoomed.width() / comp.width() - 1.5).abs() < 0.01, "the pinch zooms: {} → {}", comp.width(), zoomed.width()); + let still = zoomed.min + zoomed.size() * 0.25; + assert!(still.distance(at) < 2.0, "the point under the pointer stays put: {at:?} vs {still:?}"); + // A mouse wheel (it scrolls in lines) zooms too, and so does Alt + a trackpad scroll. + wheel(&mut h, egui::MouseWheelUnit::Line, vec2(0.0, 3.0), Default::default()); + let wheeled = rect(&h, "viewer.comp"); + assert!(wheeled.width() > zoomed.width() * 1.1, "the wheel zooms in: {} → {}", zoomed.width(), wheeled.width()); + h.input_mut().events.push(Event::ModifiersChanged(egui::Modifiers::ALT)); + wheel(&mut h, egui::MouseWheelUnit::Point, vec2(0.0, -120.0), egui::Modifiers::ALT); + let out = rect(&h, "viewer.comp"); + assert!(out.width() < wheeled.width() * 0.9, "Alt + scroll zooms out: {} → {}", wheeled.width(), out.width()); +} + +/// Browsers and some high-resolution wheels report a mouse wheel in pixels, as a trackpad does: +/// it still zooms. +#[test] +fn a_pixel_unit_mouse_wheel_zooms() { + let mut h = harness(); + let comp = rect(&h, "viewer.comp"); + let at = comp.min + comp.size() * 0.25; + h.input_mut().events.push(Event::PointerMoved(at)); + wheel(&mut h, egui::MouseWheelUnit::Point, vec2(0.0, 120.0), Default::default()); + let zoomed = rect(&h, "viewer.comp"); + assert!(zoomed.width() > comp.width() * 1.1, "a pixel wheel zooms in: {} → {}", comp.width(), zoomed.width()); + let still = zoomed.min + zoomed.size() * 0.25; + assert!(still.distance(at) < 2.0, "the point under the pointer stays put: {at:?} vs {still:?}"); + // Shift turns the wheel's scroll sideways: still a wheel, and the next notch zooms. + h.input_mut().events.push(Event::ModifiersChanged(egui::Modifiers::SHIFT)); + wheel(&mut h, egui::MouseWheelUnit::Point, vec2(0.0, 120.0), egui::Modifiers::SHIFT); + h.input_mut().events.push(Event::ModifiersChanged(egui::Modifiers::NONE)); + let before = rect(&h, "viewer.comp"); + wheel(&mut h, egui::MouseWheelUnit::Point, vec2(0.0, -120.0), Default::default()); + let out = rect(&h, "viewer.comp"); + assert!(out.width() < before.width() * 0.9, "the wheel still zooms: {} → {}", before.width(), out.width()); +} + +/// A straight vertical trackpad scroll pans when the touch phase says it is a trackpad, +/// and after any diagonal scroll; a mouse wheel in lines takes the zoom back. +#[test] +fn a_vertical_trackpad_scroll_pans_once_the_trackpad_is_known() { + let mut h = harness(); + let comp = rect(&h, "viewer.comp"); + h.input_mut().events.push(Event::PointerMoved(comp.center())); + h.input_mut().events.push(Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::Vec2::ZERO, + modifiers: Default::default(), + source: egui::MouseWheelSource::Unknown, + phase: egui::TouchPhase::Start, + }); + h.input_mut().events.push(Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, 50.0), + modifiers: Default::default(), + source: egui::MouseWheelSource::Unknown, + phase: egui::TouchPhase::Move, + }); + h.step(); + h.input_mut().events.push(Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::Vec2::ZERO, + modifiers: Default::default(), + source: egui::MouseWheelSource::Unknown, + phase: egui::TouchPhase::End, + }); + h.run_steps(30); + let panned = rect(&h, "viewer.comp"); + assert!((panned.min.y - comp.min.y - 50.0).abs() < 1.5, "the scroll pans: {:?}", panned.min - comp.min); + assert!((panned.width() - comp.width()).abs() < 0.5, "a trackpad scroll doesn't zoom"); + // Without phases (Windows, Linux, the web) it stays a trackpad. + wheel(&mut h, egui::MouseWheelUnit::Point, vec2(0.0, 30.0), Default::default()); + let again = rect(&h, "viewer.comp"); + assert!((again.min.y - panned.min.y - 30.0).abs() < 1.5 && (again.width() - comp.width()).abs() < 0.5, "{again:?}"); + // A wheel that scrolls in lines is a mouse: it zooms, and so does a pixel wheel after it. + wheel(&mut h, egui::MouseWheelUnit::Line, vec2(0.0, 3.0), Default::default()); + let zoomed = rect(&h, "viewer.comp"); + assert!(zoomed.width() > again.width() * 1.1, "the wheel zooms: {} → {}", again.width(), zoomed.width()); + wheel(&mut h, egui::MouseWheelUnit::Point, vec2(0.0, 120.0), Default::default()); + assert!(rect(&h, "viewer.comp").width() > zoomed.width() * 1.1, "a pixel wheel zooms again"); +} + +/// Where the platform names the device (macOS), a straight vertical trackpad scroll pans at once +/// and a wheel in points zooms. +#[test] +fn the_wheel_source_decides_where_the_platform_reports_it() { + let mut h = harness(); + let comp = rect(&h, "viewer.comp"); + let at = comp.min + comp.size() * 0.25; + h.input_mut().events.push(Event::PointerMoved(at)); + let scroll = |h: &mut Harness<'_, EffectcraftApp>, source, dy| { + h.input_mut().events.push(Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, dy), + modifiers: Default::default(), + phase: egui::TouchPhase::Move, + source, + }); + h.run_steps(30); + }; + scroll(&mut h, egui::MouseWheelSource::Trackpad, 40.0); + let panned = rect(&h, "viewer.comp"); + assert!((panned.min.y - comp.min.y - 40.0).abs() < 1.5, "the trackpad pans: {:?}", panned.min - comp.min); + assert!((panned.width() - comp.width()).abs() < 0.5, "a trackpad scroll doesn't zoom"); + scroll(&mut h, egui::MouseWheelSource::Wheel, 120.0); + assert!(rect(&h, "viewer.comp").width() > panned.width() * 1.1, "the wheel zooms"); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index c68711ea5..d9049306c 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -103,7 +103,7 @@ Points are logical window coordinates. A target is `{id}` (the element's centre; | `ui.click` | `{id \| x,y, button?: left\|right\|middle, count?, modifiers?}` | | `ui.move` | `{id \| x,y}` | | `ui.drag` | `{from: target, to: target, steps? (12), modifiers?}`: press, move, release | -| `ui.scroll` | `{id \| x,y, dx, dy}` | +| `ui.scroll` | `{id \| x,y, dx?, dy?, unit?: point\|line, source?: wheel\|trackpad, pinch?, modifiers?}`: `line` is a mouse wheel (zooms the viewers); `point` (default) is a pixel-precise scroll; `source: trackpad` pans the viewers (as on macOS); with no `source` the viewers take a scroll in points as a wheel until one moves on both axes (a trackpad: from then on it pans); `pinch` is a trackpad pinch's zoom factor | | `ui.key` | `{key}`, for example `Space`, `F9`, `Cmd+Z`, `Cmd+Shift+Z`, `PageDown`, `Escape` | | `ui.type` | `{text}`: text input into the focused field | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 0a0a85a97..c25da4928 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -23,7 +23,7 @@ monitors are missing, and two GPU start-up failures are open. | HDR display | HDR monitoring on EDR / HDR displays | tone-mapped SDR view; no EDR output | no HDR output | no | no | Colour engine handles HDR maths (Rec. 2100 PQ/HLG output files) | | Pen tablets (pressure, tilt) | yes, for Paint and Roto | Brushes panel has pressure options, but no tablet pressure is read | same | same | same | Paint strokes store pressure per vertex; input never fills it | | Multiple monitors | panels and windows across screens | panels move into their own native windows on any screen (Move Panel to New Window; desktop builds) | same | same (#486) | — | gap 20 | -| Touch / trackpad gestures | pinch zoom in viewers | egui pointer and scroll; no pinch-to-zoom audited | — | — | — | — | +| Touch / trackpad gestures | pinch zoom in viewers | Composition and Layer viewers: two-finger scroll pans, pinch zooms around the pointer (Alt + scroll and the mouse wheel zoom too); Timeline pinch zooms time | two-finger scroll pans once it moves diagonally (a pixel-precise wheel keeps zooming); pinch arrives as Ctrl + scroll and zooms | same | same | No rotate or swipe gestures; tested in `ui_viewer_trackpad.rs` (UI logic, not on hardware) | | Audio hardware | Core Audio / ASIO / WASAPI, device choice, latency | cpal output; preview sound late after a start offset (#504 open; #554 closed) | cpal | cpal (ALSA) | Web Audio | No input or device latency settings | | Machine learning acceleration | Apple Neural Engine / GPU for Object Matte, Roto Brush 3 | CPU inference (pure Rust, rayon) | CPU | CPU | CPU | No GPU inference for MobileSAM or MediaPipe | | Memory management | RAM reserve, disk cache | RAM Reserved, Reduce Cache When Low on Memory, disk cache | same | same | OPFS disk cache | good |