diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 2e3dc9be0..f150cfc08 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -1255,6 +1255,39 @@ fn phase_dragstart(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, v.puppet_layers = puppet_layers; } +/// Auto-pan (#999): a mask vertex, tangent or Pen drag held at (or past) the edge of the view +/// pans it, as in After Effects, so the point moves on past what was visible. Returns the map +/// of the panned view. +fn edge_pan(app: &mut EffectcraftApp, ctx: &egui::Context, g: &Gesture, map: ViewerMap, pos: Pos2) -> ViewerMap { + if !matches!(g, Gesture::Vertices { .. } | Gesture::Tangent { .. } | Gesture::Pen { .. }) { + return map; + } + let d = edge_pan_speed(map.area, pos) * ctx.input(|i| i.stable_dt).min(0.1); + if d == egui::Vec2::ZERO { + return map; + } + app.ui.viewer.pan = [app.ui.viewer.pan[0] + d.x, app.ui.viewer.pan[1] + d.y]; + ctx.request_repaint(); + ViewerMap { origin: map.origin + d, ..map } +} + +/// How fast (points per second, per axis) the view pans under a drag at `pos`: within 24 points +/// of an edge of `area`, or past it, toward that edge and faster the further out. +fn edge_pan_speed(area: Rect, pos: Pos2) -> egui::Vec2 { + const ZONE: f32 = 24.0; + let axis = |p: f32, lo: f32, hi: f32| { + let depth = if p < lo + ZONE { + lo + ZONE - p + } else if p > hi - ZONE { + hi - ZONE - p + } else { + 0.0 + }; + (depth / ZONE).clamp(-3.0, 3.0) * 400.0 + }; + vec2(axis(pos.x, area.min.x, area.max.x), axis(pos.y, area.min.y, area.max.y)) +} + fn phase_drag(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, resp: &egui::Response, v: &mut Vw<'_>) { let t = v.t; let zoom = v.zoom; @@ -1265,6 +1298,7 @@ fn phase_drag(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, resp: let painter = v.painter.clone(); let gesture: Option = ui.data(|d| d.get_temp(gid)); if let (Some(g), Some(pos)) = (gesture.clone(), resp.interact_pointer_pos()) { + let map = edge_pan(app, &ctx, &g, map, pos); let cpt = map.to_comp(pos); let merge = format!("viewer-drag-{}", ui.data(|d| d.get_temp::(egui::Id::unique("viewer-drag-n")).unwrap_or(0))); match g { diff --git a/crates/ui-egui/src/panels/viewer/tests.rs b/crates/ui-egui/src/panels/viewer/tests.rs index cb5bffe65..f9d3ef2f4 100644 --- a/crates/ui-egui/src/panels/viewer/tests.rs +++ b/crates/ui-egui/src/panels/viewer/tests.rs @@ -139,3 +139,15 @@ fn gizmo_segments_clip_at_the_near_plane() { assert!(s[1][0] > 10.0 && s[1][0].is_finite(), "toward the camera it grows, never flips: {s:?}"); assert!(project_segment(&pc, vec3(0.0, 0.0, -200.0), vec3(0.0, 0.0, -300.0)).is_none()); } + +/// #999: a vertex drag pans the view only near or past the edge, toward it, faster further out. +#[test] +fn edge_pan_speed_moves_toward_the_edge() { + let area = Rect::from_min_max(pos2(100.0, 50.0), pos2(900.0, 650.0)); + assert_eq!(edge_pan_speed(area, pos2(500.0, 350.0)), egui::Vec2::ZERO); + let left = edge_pan_speed(area, pos2(105.0, 350.0)); + assert!(left.x > 0.0 && left.y == 0.0, "near the left edge the view moves right to show more: {left:?}"); + assert!(edge_pan_speed(area, pos2(20.0, 350.0)).x > left.x, "faster past the edge"); + let corner = edge_pan_speed(area, pos2(950.0, 700.0)); + assert!(corner.x < 0.0 && corner.y < 0.0, "{corner:?}"); +} diff --git a/crates/ui-egui/tests/it/ui_viewer.rs b/crates/ui-egui/tests/it/ui_viewer.rs index fb7557c83..41cf5fe43 100644 --- a/crates/ui-egui/tests/it/ui_viewer.rs +++ b/crates/ui-egui/tests/it/ui_viewer.rs @@ -831,6 +831,48 @@ fn shift_drag_keeps_a_mask_vertex_to_one_axis() { assert!(free[0] > shifted[0] && free[1] > shifted[1], "without Shift both axes move: {shifted:?} -> {free:?}"); } +/// #999: a mask vertex dragged against the edge of the Composition panel pans the view, and the +/// vertex keeps moving while the pointer is held there, as in After Effects. +#[test] +fn a_vertex_held_at_the_viewer_edge_pans_the_view() { + let mut h = harness(); + let plate = layer_id(&h, "Plate"); + let corners = [[100.0, 60.0], [500.0, 60.0], [500.0, 300.0], [100.0, 300.0]]; + let mask = { + let s = &mut h.state_mut().session; + s.execute("layer.select", json!({"layers": [plate]})).unwrap(); + let m = s.execute("mask.new", json!({"layer": plate, "vertices": corners, "closed": true})).unwrap()["mask"].as_u64().unwrap(); + s.execute("mask.selectVertices", json!({"vertices": [{"layer": plate, "mask": m, "index": 0}]})).unwrap(); + m + }; + h.run_steps(2); + let pan0 = h.state().ui.viewer.pan; + let from = rect(&h, &format!("viewer.mask.{mask}.vertex.0")).center(); + let edge = pos2(2.0, from.y); + h.input_mut().events.push(Event::PointerMoved(from)); + h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + for i in 1..=6 { + h.input_mut().events.push(Event::PointerMoved(from + (edge - from) * (i as f32 / 6.0))); + h.step(); + } + let reached = mask_path(&h, "Plate").vertices[0]; + for _ in 0..20 { + h.step(); + } + h.input_mut().events.push(Event::PointerButton { pos: edge, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); + let pan = h.state().ui.viewer.pan; + assert!(pan[0] > pan0[0] && pan[1] == pan0[1], "the view panned right to show more on the left: {pan0:?} -> {pan:?}"); + let after = mask_path(&h, "Plate").vertices[0]; + assert!(after[0] < reached[0], "the vertex kept moving left with the view: {reached:?} -> {after:?}"); + // Back inside the view, nothing pans. + let still = h.state().ui.viewer.pan; + let mid = rect(&h, "viewer.comp").center(); + drag(&mut h, mid, mid + vec2(5.0, 0.0)); + assert_eq!(h.state().ui.viewer.pan, still); +} + /// #1769: a hidden masked layer keeps its mask path and vertices, and they remain editable. #[test] fn hidden_masked_layer_keeps_its_mask_path_editable() {