Viewer trackpad gestures: two-finger scroll pans, pinch zooms

The Composition and Layer viewers took every scroll as a zoom, so a trackpad
could not pan. A trackpad's two-finger scroll now pans; a pinch,
Ctrl/Cmd+scroll, a mouse wheel and Alt + a trackpad scroll zoom around the
pointer.

Where the platform names the device (egui's MouseWheelSource, macOS), that
decides. Elsewhere browsers and some high-resolution wheels report a mouse
wheel in points, as a trackpad does, so the unit alone doesn't decide. A
scroll in points pans only once it has shown it is a trackpad: 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 and the horizontal part pans.

ui.scroll gains unit (point|line), source (wheel|trackpad) and pinch so
agents can drive both gestures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
pravdomil
2026-10-29 20:21:10 +01:00
co-authored by Claude Opus 5.5
parent 5ed248bece
commit caedb0727a
10 changed files with 280 additions and 26 deletions
+20 -10
View File
@@ -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,
+50
View File
@@ -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),
+9 -5
View File
@@ -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.
+8 -5
View File
@@ -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);
+1
View File
@@ -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;
+2 -2
View File
@@ -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,
+32 -2
View File
@@ -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:?}");
}
@@ -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");
}
+1 -1
View File
@@ -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 |
+1 -1
View File
@@ -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 |