diff --git a/crates/tools/src/draw2/gesture.rs b/crates/tools/src/draw2/gesture.rs index 82b512143..bdb8e4d84 100644 --- a/crates/tools/src/draw2/gesture.rs +++ b/crates/tools/src/draw2/gesture.rs @@ -692,1041 +692,4 @@ impl Tool for GestureTool { } #[cfg(test)] -mod tests { - use super::*; - use crate::testutil::*; - use vectorcraft_doc::Selection; - - fn drag(t: &mut GestureTool, cx: &ToolContext, pts: &[(f64, f64)], m: Mods) -> Vec> { - let mut v = vec![t.pointer(cx, &PointerEvent::new(PointerKind::Down, pts[0].0, pts[0].1))]; - for &(x, y) in &pts[1..] { - v.push(t.pointer(cx, &PointerEvent::new(PointerKind::Drag, x, y))); - } - let l = pts[pts.len() - 1]; - v.push(t.pointer(cx, &PointerEvent::new(PointerKind::Up, l.0, l.1).with_mods(m))); - v - } - - fn drag_held(t: &mut GestureTool, cx: &ToolContext, pts: &[(f64, f64)], m: Mods) -> Vec> { - let mut v = vec![t.pointer(cx, &PointerEvent::new(PointerKind::Down, pts[0].0, pts[0].1).with_mods(m))]; - for &(x, y) in &pts[1..] { - v.push(t.pointer(cx, &PointerEvent::new(PointerKind::Drag, x, y).with_mods(m))); - } - let l = pts[pts.len() - 1]; - v.push(t.pointer(cx, &PointerEvent::new(PointerKind::Up, l.0, l.1).with_mods(m))); - v - } - - /// The Pencil's new path starts and ends on Smart Guides (#506), the points between where - /// the hand went. - #[test] - fn pencil_ends_snap_to_smart_guides() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = GestureTool::new("pencil"); - let acts = drag(&mut t, &cx, &[(102.0, 98.0), (151.0, 300.0), (250.0, 320.0), (198.0, 203.0)], Mods::default()); - let Some(Action::Preview(_, v)) = acts.last().and_then(|a| a.first()) else { panic!("{acts:?}") }; - assert_eq!(v["points"], json!([[100.0, 100.0], [151.0, 300.0], [250.0, 320.0], [200.0, 200.0]])); - // The Paintbrush follows the hand all the way. - let mut t = GestureTool::new("paintbrush"); - let acts = drag(&mut t, &cx, &[(102.0, 98.0), (151.0, 300.0)], Mods::default()); - let Some(Action::Preview(_, v)) = acts.last().and_then(|a| a.first()) else { panic!("{acts:?}") }; - assert_eq!(v["points"][0], json!([102.0, 98.0])); - } - - /// #852: the Paintbrush sends each point's pen pressure; a mouse (always fully pressed) and - /// the Pencil send plain points. - #[test] - fn paintbrush_sends_pen_pressure() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let stroke = |id: &str, pressures: [f32; 3]| { - let mut t = GestureTool::new(id); - let kinds = [PointerKind::Down, PointerKind::Drag, PointerKind::Up]; - let mut last = vec![]; - for ((k, x), f) in kinds.into_iter().zip([0.0, 40.0, 80.0]).zip(pressures) { - last = t.pointer(&cx, &PointerEvent { pressure: f, ..PointerEvent::new(k, x, 0.0) }); - } - let Some(Action::Preview(_, v)) = last.first() else { panic!("{last:?}") }; - v["points"].clone() - }; - assert_eq!(stroke("paintbrush", [0.25, 0.5, 1.0]), json!([[0.0, 0.0, 0.25], [40.0, 0.0, 0.5], [80.0, 0.0, 1.0]])); - assert_eq!(stroke("paintbrush", [1.0; 3]), json!([[0.0, 0.0], [40.0, 0.0], [80.0, 0.0]])); - assert_eq!(stroke("pencil", [0.25, 0.5, 1.0]), json!([[0.0, 0.0], [40.0, 0.0], [80.0, 0.0]])); - } - - #[test] - fn pencil_previews_and_commits() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = GestureTool::new("pencil"); - let acts = drag(&mut t, &cx, &[(0.0, 0.0), (10.0, 5.0), (20.0, 0.0)], Mods::default()); - assert!(acts[0].is_empty()); - assert_eq!(acts[1][0], Action::Begin("Pencil".into())); - assert!(matches!(&acts[1][1], Action::Preview(c, v) if c == "path.freehand" && v["points"].as_array().unwrap().len() == 2)); - let last = acts.last().unwrap(); - assert!(matches!(&last[0], Action::Preview(_, v) if v["closed"] == false && v["style"] == "pencil")); - assert_eq!(last[1], Action::Commit); - assert!(!t.busy()); - } - - /// Live curve fitting is the Pencil's, on by default. Off, the previews while a stroke is drawn - /// ask for the points unfitted (`fit: false`), and the release's preview is fitted as before. - #[test] - fn pencil_with_live_curve_fitting_off_previews_the_stroke_unfitted() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let stroke = [(0.0, 0.0), (10.0, 5.0), (20.0, 0.0), (30.0, 6.0), (40.0, 0.0)]; - // The `fit` each preview sends: those while drawn, then the release's. - let fits = |t: &mut GestureTool| { - let acts = drag(t, &cx, &stroke, Mods::default()); - let fit = |a: &Action| if let Action::Preview(_, v) = a { Some(v.get("fit").cloned()) } else { None }; - let (drawn, release) = acts.split_at(acts.len() - 1); - (drawn.iter().flatten().filter_map(fit).collect::>(), release[0].iter().filter_map(fit).collect::>()) - }; - let mut t = GestureTool::new("pencil"); - assert_eq!(t.options()["liveFit"], json!(true)); - let (drawn, release) = fits(&mut t); - assert!(!drawn.is_empty() && drawn.iter().all(Option::is_none), "{drawn:?}"); - assert_eq!(release, [None]); - t.set_option("liveFit", &json!(false)); - assert_eq!(t.options()["liveFit"], json!(false)); - let (drawn, release) = fits(&mut t); - assert!(!drawn.is_empty() && drawn.iter().all(|f| *f == Some(json!(false))), "{drawn:?}"); - assert_eq!(release, [None], "the release fits the stroke"); - // Choosing another tool while the stroke is drawn keeps it fitted, too. - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0)); - for &(x, y) in &stroke[1..] { - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x, y)); - } - let acts = t.deactivate(&cx); - assert!(matches!(acts.as_slice(), [Action::Preview(_, v), Action::Commit] if v.get("fit").is_none()), "{acts:?}"); - // The Paintbrush has no such option, and its previews are fitted. - let mut t = GestureTool::new("paintbrush"); - t.set_option("liveFit", &json!(false)); - assert!(t.options().get("liveFit").is_none()); - let (drawn, _) = fits(&mut t); - assert!(!drawn.is_empty() && drawn.iter().all(Option::is_none), "{drawn:?}"); - } - - /// Keep selected is on by default, and every Pencil and Paintbrush preview sends it. - #[test] - fn pencil_and_paintbrush_send_keep_selected() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - for id in ["pencil", "paintbrush"] { - let mut t = GestureTool::new(id); - assert_eq!(t.options()["keepSelected"], json!(true), "{id}"); - for keep in [true, false] { - t.set_option("keepSelected", &json!(keep)); - assert_eq!(t.options()["keepSelected"], json!(keep), "{id}"); - let acts = drag(&mut t, &cx, &[(0.0, 0.0), (10.0, 5.0), (20.0, 0.0)], Mods::default()); - assert!(acts[1..].iter().all(|a| a.iter().all(|a| !matches!(a, Action::Preview(_, v) if v["keepSelected"] != keep))), "{id}"); - assert!(matches!(&acts.last().unwrap()[0], Action::Preview(_, v) if v["keepSelected"] == keep), "{id}"); - } - } - } - - /// With the Pencil's Live preview off, a stroke previews nothing while it is drawn: a thin line - /// follows the pen (an overlay), and the release draws the path with its fill and stroke. On (the - /// default), every step previews the path and no line is shown. The Paintbrush has no such option. - #[test] - fn pencil_with_live_preview_off_shows_a_thin_line_until_the_release() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let previews = |acts: &[Action]| acts.iter().filter(|a| matches!(a, Action::Preview(..))).count(); - let line = |t: &GestureTool| t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Path { width, .. } if *width == 1.0)); - let pts = [(0.0, 0.0), (10.0, 5.0), (20.0, 0.0), (30.0, 5.0)]; - for live in [true, false] { - let mut t = GestureTool::new("pencil"); - assert_eq!(t.options()["livePreview"], json!(true), "on by default"); - t.set_option("livePreview", &json!(live)); - assert_eq!(t.options()["livePreview"], json!(live)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, pts[0].0, pts[0].1)); - let mut during = vec![]; - for &(x, y) in &pts[1..] { - during.extend(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x, y))); - } - assert!(matches!(during.first(), Some(Action::Begin(_))), "live {live}: {during:?}"); - assert_eq!(previews(&during), if live { 3 } else { 0 }, "live {live}: {during:?}"); - assert_eq!(line(&t), !live, "live {live}: the thin line shows only with Live preview off"); - let up = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 30.0, 5.0)); - assert!(matches!(up.as_slice(), [Action::Preview(c, _), Action::Commit] if c == "path.freehand"), "live {live}: {up:?}"); - assert!(!line(&t), "live {live}: no line after the release"); - } - // Escape mid-stroke with Live preview off cancels the stroke it began. - let mut t = GestureTool::new("pencil"); - t.set_option("livePreview", &json!(false)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 10.0, 5.0)); - assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel]); - // The Paintbrush keeps previewing: Live preview isn't one of its options. - let mut b = GestureTool::new("paintbrush"); - assert!(b.options().get("livePreview").is_none()); - b.set_option("livePreview", &json!(false)); - b.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0)); - let acts = b.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 10.0, 5.0)); - assert_eq!(previews(&acts), 1, "{acts:?}"); - } - - /// Holding Alt shows the Paintbrush's closing cursor before the press, and the Pencil's and the - /// Paintbrush's while drawing (before a press, Alt gives the Pencil its straight-line cursor); a release - /// with Alt still held closes the path, and Alt let go before the release leaves it open. - /// The freehand tools take every position the pointer passed (`wants_samples`); the Join tool - /// doesn't need them. - #[test] - fn the_freehand_tools_want_every_sample() { - for id in ["pencil", "paintbrush", "blobBrush", "eraser", "knife", "smooth", "pathEraser"] { - assert!(GestureTool::new(id).wants_samples(), "{id}"); - } - assert!(!GestureTool::new("join").wants_samples()); - } - - #[test] - fn alt_shows_the_closing_cursor_and_a_release_with_alt_closes() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let alt = Mods { alt: true, ..Default::default() }; - let at = Point::new(10.0, 5.0); - for (id, own, closing, style) in - [("pencil", Cursor::Pencil, Cursor::PencilClose, "pencil"), ("paintbrush", Cursor::Paintbrush, Cursor::PaintbrushClose, "brush")] - { - let mut t = GestureTool::new(id); - assert_eq!(t.cursor(&cx, at, Mods::default()), if id == "pencil" { Cursor::PencilNew } else { own }, "{id}"); - assert_eq!(t.cursor(&cx, at, alt), if id == "pencil" { Cursor::PencilStraight } else { closing }, "{id}"); - for alt_at_release in [true, false] { - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 10.0, 5.0).with_mods(alt)); - assert_eq!(t.cursor(&cx, at, alt), closing, "{id} while drawing"); - assert_eq!(t.cursor(&cx, at, Mods::default()), own, "{id} while drawing"); - let up = Mods { alt: alt_at_release, ..Default::default() }; - let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 20.0, 0.0).with_mods(up)); - assert!( - matches!(&acts[0], Action::Preview(_, v) if v["closed"] == alt_at_release && v["style"] == style), - "{id}, Alt at the release {alt_at_release}: {acts:?}" - ); - } - } - // With Alt key toggles to Smooth Tool on, Alt lends the Pencil the Smooth tool instead, until - // a stroke has begun to draw: Alt then neither smooths nor closes, and the cursor stays the Pencil's. - let mut t = GestureTool::new("pencil"); - t.set_option("altSmooth", &json!(true)); - assert_eq!(t.cursor(&cx, at, alt), Cursor::Smooth); - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 10.0, 5.0)); - assert_eq!(t.cursor(&cx, at, alt), Cursor::Pencil, "a stroke that has begun"); - let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 20.0, 0.0).with_mods(alt)); - assert!(matches!(&acts[0], Action::Preview(_, v) if v["closed"] == false), "{acts:?}"); - // A smoothing drag keeps the Smooth cursor when Alt is let go. - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0).with_mods(alt)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 10.0, 5.0).with_mods(alt)); - assert_eq!(t.cursor(&cx, at, Mods::default()), Cursor::Smooth, "a smoothing drag"); - } - - #[test] - fn blob_brush_drag_shows_scrub_length_measurement_labels() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let on = ToolContext { measurement_labels: true, ..cx(&d, &s, &p) }; - let off = ToolContext { measurement_labels: false, ..cx(&d, &s, &p) }; - let mut t = GestureTool::new("blobBrush"); - t.pointer(&on, &PointerEvent::new(PointerKind::Down, 50.0, 150.0)); - t.pointer(&on, &PointerEvent::new(PointerKind::Drag, 150.0, 150.0)); - let want = format!("L: {}", on.len(100.0)); - assert!(t.overlays(&on).iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == &want))); - let mut quiet = GestureTool::new("blobBrush"); - quiet.pointer(&off, &PointerEvent::new(PointerKind::Down, 50.0, 150.0)); - quiet.pointer(&off, &PointerEvent::new(PointerKind::Drag, 150.0, 150.0)); - assert!(!quiet.overlays(&off).iter().any(|o| matches!(o, Overlay::Measure { .. }))); - } - - /// The Blob Brush has Keep selected and Merge only with selection, both off by default, and its - /// stroke sends both. - #[test] - fn blob_brush_sends_keep_selected_and_merge_only_with_selection() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = GestureTool::new("blobBrush"); - assert_eq!((t.options()["keepSelected"].clone(), t.options()["mergeOnlySelection"].clone()), (json!(false), json!(false))); - for (keep, only) in [(false, false), (true, false), (false, true)] { - t.set_option("keepSelected", &json!(keep)); - t.set_option("mergeOnlySelection", &json!(only)); - let acts = drag(&mut t, &cx, &[(50.0, 150.0), (250.0, 150.0)], Mods::default()); - assert!( - matches!(&acts[2][0], Action::Exec(c, v) if c == "path.blob" && v["keepSelected"] == keep && v["mergeOnlySelection"] == only), - "{acts:?}" - ); - } - // The Eraser keeps Size, Angle and Roundness: a round tip by default. - assert_eq!(GestureTool::new("eraser").options(), json!({"size": 10.0, "angle": 0.0, "roundness": 100.0})); - } - - /// Shift keeps a Blob Brush stroke on the horizontal or vertical line through the press point, - /// whichever is nearer to the pointer, at 90° steps from the Constrain Angle; the points - /// dragged with Shift held move to the other line together, and each point follows the Shift - /// state of its own event. - #[test] - fn blob_brush_shift_keeps_the_stroke_horizontal_or_vertical() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let mut cx = cx(&d, &s, &p); - let shift = Mods { shift: true, ..Default::default() }; - let blob = |acts: &[Vec]| -> Vec<(f64, f64)> { - let Some(Action::Exec(c, v)) = acts.last().and_then(|a| a.first()) else { panic!("{acts:?}") }; - assert_eq!(c, "path.blob"); - v["points"].as_array().unwrap().iter().map(|q| (q[0].as_f64().unwrap(), q[1].as_f64().unwrap())).collect() - }; - let mut t = GestureTool::new("blobBrush"); - let pts = blob(&drag_held(&mut t, &cx, &[(0.0, 0.0), (30.0, 5.0), (50.0, 8.0)], shift)); - assert_eq!(pts.len(), 3); - assert!(pts.iter().all(|q| q.1 == 0.0 && q.0 >= 0.0), "{pts:?}"); - let pts = blob(&drag_held(&mut t, &cx, &[(0.0, 0.0), (5.0, 30.0), (8.0, 50.0)], shift)); - assert!(pts.iter().all(|q| q.0.abs() < 1e-9 && q.1 >= 0.0), "{pts:?}"); - // A pointer that crosses to the vertical line takes the points before it along, each on - // its own side of the press point and at its own distance. - let pts = blob(&drag_held(&mut t, &cx, &[(0.0, 0.0), (-40.0, -2.0), (100.0, 99.0), (90.0, 120.0)], shift)); - assert_eq!(pts.len(), 4, "{pts:?}"); - assert!(pts.iter().all(|q| q.0.abs() < 1e-9), "{pts:?}"); - assert!((pts[1].1 + 1604f64.sqrt()).abs() < 1e-9 && (pts[2].1 - 19801f64.sqrt()).abs() < 1e-9 && (pts[3].1 - 150.0).abs() < 1e-9, "{pts:?}"); - // Without Shift the stroke follows the pointer. - let pts = blob(&drag_held(&mut t, &cx, &[(0.0, 0.0), (30.0, 5.0), (50.0, 8.0)], Mods::default())); - assert_eq!(pts, vec![(0.0, 0.0), (30.0, 5.0), (50.0, 8.0)]); - // Shift pressed and let go mid-stroke constrains only the points dragged while it is down, - // and a later press of Shift leaves the points of the earlier one where they are. - let ev = |k, x, y, m| PointerEvent::new(k, x, y).with_mods(m); - t.pointer(&cx, &ev(PointerKind::Down, 0.0, 0.0, Mods::default())); - t.pointer(&cx, &ev(PointerKind::Drag, 20.0, 10.0, Mods::default())); - t.pointer(&cx, &ev(PointerKind::Drag, 40.0, 10.0, shift)); - t.pointer(&cx, &ev(PointerKind::Drag, 60.0, 10.0, Mods::default())); - let pts = blob(&[t.pointer(&cx, &ev(PointerKind::Up, 10.0, 80.0, shift))]); - assert_eq!((pts.len(), pts[1], pts[3]), (5, (20.0, 10.0), (60.0, 10.0)), "{pts:?}"); - assert!(pts[2].1 == 0.0 && (pts[2].0 - 1700f64.sqrt()).abs() < 1e-9, "{pts:?}"); - assert!(pts[4].0.abs() < 1e-9 && (pts[4].1 - 6500f64.sqrt()).abs() < 1e-9, "{pts:?}"); - // The axes turn with the Constrain Angle. - cx.constrain_angle = 30.0; - let pts = blob(&drag_held(&mut t, &cx, &[(0.0, 0.0), (40.0, -20.0)], shift)); - let a = (-pts[1].1).atan2(pts[1].0).to_degrees(); - assert!((a - 30.0).abs() < 1e-9, "{pts:?}"); - // A pointer back at the press point leaves the stroke on its line. - let pts = blob(&drag_held(&mut t, &cx, &[(0.0, 0.0), (40.0, -20.0), (0.0, 0.0)], shift)); - let a = (-pts[1].1).atan2(pts[1].0).to_degrees(); - assert!(pts.len() == 3 && pts[2] == (0.0, 0.0) && (a - 30.0).abs() < 1e-9, "{pts:?}"); - } - - /// #3799: Shift held at the press draws a straight Pencil line from the press point, at 45° - /// steps from the Constrain Angle, previewed as it is dragged; the Paintbrush keeps following - /// the hand. - #[test] - fn pencil_shift_draws_a_straight_line_at_45_degree_steps() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let mut cx = cx(&d, &s, &p); - cx.smart_guides = false; - let shift = Mods { shift: true, ..Default::default() }; - let pts = |acts: &[Vec]| -> Vec> { - acts.iter() - .filter_map(|a| a.iter().find_map(|a| if let Action::Preview(_, v) = a { Some(v["points"].clone()) } else { None })) - .map(|v| v.as_array().unwrap().iter().map(|q| (q[0].as_f64().unwrap(), q[1].as_f64().unwrap())).collect()) - .collect() - }; - let angle = |q: (f64, f64), from: (f64, f64)| (-(q.1 - from.1)).atan2(q.0 - from.0).to_degrees(); - let mut t = GestureTool::new("pencil"); - // A wavy drag heading up and right at about 40°: a line at 45°, as long as the drag. - let acts = drag_held(&mut t, &cx, &[(0.0, 0.0), (30.0, -10.0), (50.0, -60.0), (100.0, -84.0)], shift); - let all = pts(&acts); - assert!(all.iter().all(|v| v.len() == 2 && v[0] == (0.0, 0.0)), "{all:?}"); - let end = *all.last().unwrap().last().unwrap(); - assert!((angle(end, (0.0, 0.0)) - 45.0).abs() < 1e-9, "{end:?}"); - assert!((end.0.hypot(end.1) - 100f64.hypot(84.0)).abs() < 1e-9, "{end:?}"); - let last = acts.last().unwrap(); - assert!(matches!(&last[0], Action::Preview(_, v) if v["closed"] == false) && last[1] == Action::Commit, "{last:?}"); - // The steps turn with the Constrain Angle: at 30°, a drag at 40° gives 30°, one at 60° gives 75°. - cx.constrain_angle = 30.0; - for (to, want) in [((100.0, -84.0), 30.0), ((50.0, -86.6), 75.0)] { - let all = pts(&drag_held(&mut t, &cx, &[(10.0, 10.0), (to.0 + 10.0, to.1 + 10.0)], shift)); - let end = *all.last().unwrap().last().unwrap(); - assert!((angle(end, (10.0, 10.0)) - want).abs() < 1e-9, "{to:?}: {all:?}"); - } - // Without Shift, and with the Paintbrush, the stroke follows the hand. - for (id, m) in [("pencil", Mods::default()), ("paintbrush", shift)] { - let mut t = GestureTool::new(id); - let all = pts(&drag_held(&mut t, &cx, &[(0.0, 0.0), (30.0, -10.0), (50.0, -60.0)], m)); - assert_eq!(all.last().unwrap().len(), 3, "{id}: {all:?}"); - } - } - - /// #3799: a Shift line leaves no Smart Guides of its press on screen while it is dragged - /// (its end doesn't snap), and Alt at its release leaves it open. - #[test] - fn pencil_shift_line_drops_the_press_guides_and_stays_open_with_alt() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let shift = Mods { shift: true, ..Default::default() }; - let mut t = GestureTool::new("pencil"); - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 102.0, 98.0).with_mods(shift)); - assert!(!t.overlays(&cx).is_empty(), "the press snaps to the corner and shows it"); - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 300.0, 140.0).with_mods(shift)); - assert!(t.overlays(&cx).is_empty(), "{:?}", t.overlays(&cx)); - let shift_alt = Mods { shift: true, alt: true, ..Default::default() }; - let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 300.0, 140.0).with_mods(shift_alt)); - assert!(matches!(&acts[0], Action::Preview(_, v) if v["closed"] == false && v["points"].as_array().unwrap().len() == 2), "{acts:?}"); - } - - /// Before a press, the Pencil shows its straight-line cursor exactly when the press would draw a - /// straight line; once the press is down, and with the Paintbrush, Shift leaves the tool's own - /// cursor. - #[test] - fn pencil_shows_the_straight_line_cursor_when_a_press_draws_a_line() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let mut cx = cx(&d, &s, &p); - cx.smart_guides = false; - let at = Point::new(0.0, 0.0); - let shift = Mods { shift: true, ..Default::default() }; - let alt = Mods { alt: true, ..Default::default() }; - let shift_alt = Mods { shift: true, alt: true, ..Default::default() }; - // The last drag's preview: two points for a line, all three for a freehand stroke, none - // for a smoothing drag. - let straight = |acts: &[Vec]| { - acts[acts.len() - 2].iter().any(|a| matches!(a, Action::Preview(_, v) if v["points"].as_array().is_some_and(|q| q.len() == 2))) - }; - for alt_smooth in [false, true] { - for m in [Mods::default(), shift, alt, shift_alt] { - let mut t = GestureTool::new("pencil"); - t.set_option("altSmooth", &json!(alt_smooth)); - let shown = t.cursor(&cx, at, m) == Cursor::PencilStraight; - let drawn = straight(&drag_held(&mut t, &cx, &[(0.0, 0.0), (30.0, -10.0), (50.0, -60.0)], m)); - assert_eq!(shown, drawn, "altSmooth {alt_smooth}, {m:?}"); - } - } - let mut t = GestureTool::new("pencil"); - assert_eq!(t.cursor(&cx, at, Mods::default()), Cursor::PencilNew); - assert_eq!(t.cursor(&cx, at, shift), Cursor::PencilStraight); - assert_eq!(t.cursor(&cx, at, alt), Cursor::PencilStraight, "Alt alone draws a line at any angle"); - assert_eq!(t.cursor(&cx, at, shift_alt), Cursor::PencilStraight, "Alt at the release leaves a straight line open"); - // While the line is drawn, the Pencil's own cursor; after the release, Shift shows it again. - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0).with_mods(shift)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 40.0, -10.0).with_mods(shift)); - assert_eq!(t.cursor(&cx, at, shift), Cursor::Pencil, "while the line is drawn"); - t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 40.0, -10.0).with_mods(shift)); - assert_eq!(t.cursor(&cx, at, shift), Cursor::PencilStraight, "after the release"); - // Shift pressed during a freehand stroke doesn't make it a line. - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 40.0, -10.0).with_mods(shift)); - assert_eq!(t.cursor(&cx, at, shift), Cursor::Pencil, "a freehand stroke"); - assert_eq!(GestureTool::new("paintbrush").cursor(&cx, at, shift), Cursor::Paintbrush); - assert_eq!(Cursor::PencilStraight.precise(), Cursor::Crosshair, "Use Precise Cursors"); - } - - /// Before a press, the Pencil shows its new-path cursor (the star) exactly when the press draws - /// a new path: over an end of a selected open path, which the stroke continues, and over a - /// selected path, which it redraws, it does not. With nothing selected, with Edit Selected Paths - /// off and away from the paths, it does; while a stroke is drawn, it does not. - #[test] - fn pencil_shows_the_star_before_a_press_that_draws_a_new_path() { - let (mut d, _) = doc_with_rect(); - let l = d.layers[0].id; - let id = d.alloc_id(); - let line = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_vec(vec![ - vectorcraft_geom::PathEl::MoveTo(Point::new(300.0, 300.0)), - vectorcraft_geom::PathEl::LineTo(Point::new(400.0, 300.0)), - ])); - d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, line, vectorcraft_doc::Appearance::default_art())).unwrap(); - let mut s = Selection::default(); - s.set([id]); - let p = paint(); - let mut cx = cx(&d, &s, &p); - cx.smart_guides = false; - // The stroke edits the selected path when its previews continue or redraw it. - let edits = |acts: &[Vec]| { - acts.iter().flatten().any(|a| matches!(a, Action::Preview(_, v) if v.get("extend").is_some() || v.get("redraw").is_some())) - }; - let (end, middle, away) = (Point::new(405.0, 303.0), Point::new(350.0, 304.0), Point::new(350.0, 360.0)); - for edit_within in [12.0, 0.0] { - for at in [end, Point::new(296.0, 302.0), middle, away, Point::new(200.0, 200.0)] { - let mut t = GestureTool::new("pencil"); - t.set_option("editWithin", &json!(edit_within)); - let shown = t.cursor(&cx, at, Mods::default()) == Cursor::PencilNew; - let acts = drag(&mut t, &cx, &[(at.x, at.y), (360.0, 330.0), (420.0, 350.0)], Mods::default()); - assert_eq!(shown, !edits(&acts), "editWithin {edit_within} at {at:?}"); - } - } - let mut t = GestureTool::new("pencil"); - assert_eq!(t.cursor(&cx, end, Mods::default()), Cursor::Pencil, "over an end the stroke continues"); - assert_eq!(t.cursor(&cx, middle, Mods::default()), Cursor::Pencil, "over the path the stroke redraws"); - assert_eq!(t.cursor(&cx, away, Mods::default()), Cursor::PencilNew, "away from the path"); - // While a stroke is drawn, the Pencil's own cursor. - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, away.x, away.y)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 380.0, 380.0)); - assert_eq!(t.cursor(&cx, Point::new(380.0, 380.0), Mods::default()), Cursor::Pencil, "while a stroke is drawn"); - t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 380.0, 380.0)); - assert_eq!(t.cursor(&cx, away, Mods::default()), Cursor::PencilNew, "after the release"); - // With nothing selected, every press draws a new path. - let none = Selection::default(); - let cx_none = crate::testutil::cx(&d, &none, &p); - assert_eq!(GestureTool::new("pencil").cursor(&cx_none, middle, Mods::default()), Cursor::PencilNew, "nothing selected"); - // Shift held: a straight line, with its own cursor; the Paintbrush keeps its cursor. - let shift = Mods { shift: true, ..Default::default() }; - assert_eq!(GestureTool::new("pencil").cursor(&cx, away, shift), Cursor::PencilStraight); - assert_eq!(GestureTool::new("paintbrush").cursor(&cx, away, Mods::default()), Cursor::Paintbrush); - } - - /// #3799: a Shift line started on an end of a selected open path continues the path from that - /// end, constrained from the end itself. - #[test] - fn pencil_shift_line_continues_a_selected_path_from_its_end() { - let (mut d, _) = doc_with_rect(); - let l = d.layers[0].id; - let id = d.alloc_id(); - let line = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_vec(vec![ - vectorcraft_geom::PathEl::MoveTo(Point::new(300.0, 300.0)), - vectorcraft_geom::PathEl::LineTo(Point::new(400.0, 300.0)), - ])); - d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, line, vectorcraft_doc::Appearance::default_art())).unwrap(); - let mut s = Selection::default(); - s.set([id]); - let p = paint(); - let mut cx = cx(&d, &s, &p); - cx.smart_guides = false; - let mut t = GestureTool::new("pencil"); - // Pressed 5 pt off the end, released 100 pt right and 4 pt above it. - let acts = drag_held(&mut t, &cx, &[(405.0, 303.0), (450.0, 290.0), (500.0, 296.0)], Mods { shift: true, ..Default::default() }); - let Some(Action::Preview(_, v)) = acts.last().and_then(|a| a.first()) else { panic!("{acts:?}") }; - assert_eq!(v["extend"], json!({"id": id.0, "end": "end"})); - assert_eq!(v["points"], json!([[400.0, 300.0], [400.0 + 100f64.hypot(4.0), 300.0]])); - } - - /// Before a press over an end of a selected open path, the Pencil shows its continuation cursor - /// exactly when the press would draw a straight line that continues the path; away from the - /// ends, with nothing selected, with Edit Selected Paths off, once the press is down and with - /// the Paintbrush, it does not. - #[test] - fn pencil_shows_the_continuation_cursor_over_an_end_a_line_continues() { - let (mut d, _) = doc_with_rect(); - let l = d.layers[0].id; - let id = d.alloc_id(); - let line = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_vec(vec![ - vectorcraft_geom::PathEl::MoveTo(Point::new(300.0, 300.0)), - vectorcraft_geom::PathEl::LineTo(Point::new(400.0, 300.0)), - ])); - d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, line, vectorcraft_doc::Appearance::default_art())).unwrap(); - let mut s = Selection::default(); - s.set([id]); - let p = paint(); - let mut cx = cx(&d, &s, &p); - cx.smart_guides = false; - let shift = Mods { shift: true, ..Default::default() }; - let alt = Mods { alt: true, ..Default::default() }; - let shift_alt = Mods { shift: true, alt: true, ..Default::default() }; - // The release's stroke: a straight line that continues the path. - let continues = |acts: &[Vec]| { - acts.last().and_then(|a| a.first()).is_some_and( - |a| matches!(a, Action::Preview(_, v) if v.get("extend").is_some() && v["points"].as_array().is_some_and(|q| q.len() == 2)), - ) - }; - for alt_smooth in [false, true] { - for m in [Mods::default(), shift, alt, shift_alt] { - for (at, other_end) in [(Point::new(405.0, 303.0), (500.0, 296.0)), (Point::new(296.0, 302.0), (200.0, 310.0))] { - let mut t = GestureTool::new("pencil"); - t.set_option("altSmooth", &json!(alt_smooth)); - let shown = t.cursor(&cx, at, m) == Cursor::PencilContinue; - let acts = drag_held(&mut t, &cx, &[(at.x, at.y), (450.0, 250.0), other_end], m); - assert_eq!(shown, continues(&acts), "altSmooth {alt_smooth}, {m:?} at {at:?}"); - } - } - } - let mut t = GestureTool::new("pencil"); - let end = Point::new(405.0, 303.0); - assert_eq!(t.cursor(&cx, end, shift), Cursor::PencilContinue); - assert_eq!(t.cursor(&cx, end, alt), Cursor::PencilContinue, "Alt alone continues at any angle"); - assert_eq!(t.cursor(&cx, end, Mods::default()), Cursor::Pencil, "without Shift or Alt the stroke is freehand"); - assert_eq!(t.cursor(&cx, Point::new(350.0, 360.0), shift), Cursor::PencilStraight, "away from the ends"); - // While the line is drawn, the Pencil's own cursor. - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, end.x, end.y).with_mods(shift)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 450.0, 303.0).with_mods(shift)); - assert_eq!(t.cursor(&cx, end, shift), Cursor::Pencil, "while the line is drawn"); - t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 450.0, 303.0).with_mods(shift)); - // Edit Selected Paths off: the press draws a new line. - t.set_option("editWithin", &json!(0.0)); - assert_eq!(t.cursor(&cx, end, shift), Cursor::PencilStraight, "Edit Selected Paths off"); - let none = Selection::default(); - let cx_none = crate::testutil::cx(&d, &none, &p); - assert_eq!(GestureTool::new("pencil").cursor(&cx_none, end, shift), Cursor::PencilStraight, "nothing selected"); - assert_eq!(GestureTool::new("paintbrush").cursor(&cx, end, shift), Cursor::Paintbrush); - assert_eq!(Cursor::PencilContinue.precise(), Cursor::Crosshair, "Use Precise Cursors"); - } - - /// #3846: Alt during a Shift line doesn't promise a close the release can't do, and with Alt - /// Toggles to Smooth, Alt pressed after the press doesn't turn the line into a smoothing drag. - #[test] - fn pencil_shift_line_ignores_alt_for_cursor_and_smoothing() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let mut cx = cx(&d, &s, &p); - cx.smart_guides = false; - let shift = Mods { shift: true, ..Default::default() }; - let shift_alt = Mods { shift: true, alt: true, ..Default::default() }; - let mut t = GestureTool::new("pencil"); - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0).with_mods(shift)); - assert_eq!(t.cursor(&cx, Point::new(0.0, 0.0), shift_alt), Cursor::Pencil); - t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 0.0, 0.0).with_mods(shift)); - - let mut t = GestureTool::new("pencil"); - t.alt_smooth = true; - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0).with_mods(shift)); - let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 80.0, 0.0).with_mods(shift_alt)); - assert!(matches!(acts.as_slice(), [Action::Begin(_), Action::Preview(..)]), "{acts:?}"); - let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 80.0, 0.0).with_mods(shift_alt)); - assert!(matches!(&acts[0], Action::Preview(_, v) if v["points"].as_array().unwrap().len() == 2), "{acts:?}"); - } - - /// #3846: a Shift line dragged back to its press point has no line: the preview is cancelled - /// and the release commits nothing (not the previous end of the line). - #[test] - fn pencil_shift_line_back_at_the_press_point_draws_nothing() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let mut cx = cx(&d, &s, &p); - cx.smart_guides = false; - let shift = Mods { shift: true, ..Default::default() }; - // Released back at the press point, and dragged back there first. - for back_in_drag in [false, true] { - let mut t = GestureTool::new("pencil"); - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0).with_mods(shift)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 80.0, 0.0).with_mods(shift)); - let mut acts = vec![]; - if back_in_drag { - // The shown line goes as soon as the pointer is back, not only at the release. - let back = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 0.2, 0.0).with_mods(shift)); - assert_eq!(back, vec![Action::Cancel]); - acts.extend(back); - } - acts.extend(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 0.2, 0.0).with_mods(shift))); - assert!(acts.contains(&Action::Cancel), "{back_in_drag}: {acts:?}"); - assert!(!acts.contains(&Action::Commit) && !acts.iter().any(|a| matches!(a, Action::Preview(..))), "{back_in_drag}: {acts:?}"); - } - } - - /// #3799: Alt held at the press draws a straight Pencil line at any angle from the press point - /// to the pointer, previewed as it is dragged, and left open by a release with Alt still held; - /// with Shift too it keeps Shift's 45° steps. Alt pressed after the press keeps the stroke - /// freehand and closes it at the release, and the Paintbrush keeps following the hand. - #[test] - fn pencil_alt_at_the_press_draws_a_straight_line_at_any_angle() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let mut cx = cx(&d, &s, &p); - cx.smart_guides = false; - let alt = Mods { alt: true, ..Default::default() }; - let pts = |acts: &[Vec]| -> Vec> { - acts.iter() - .filter_map(|a| a.iter().find_map(|a| if let Action::Preview(_, v) = a { Some(v["points"].clone()) } else { None })) - .map(|v| v.as_array().unwrap().iter().map(|q| (q[0].as_f64().unwrap(), q[1].as_f64().unwrap())).collect()) - .collect() - }; - let wavy = [(0.0, 0.0), (30.0, -10.0), (50.0, -60.0), (100.0, -84.0)]; - let mut t = GestureTool::new("pencil"); - assert_eq!(t.cursor(&cx, Point::new(0.0, 0.0), alt), Cursor::PencilStraight, "before the press"); - let acts = drag_held(&mut t, &cx, &wavy, alt); - let all = pts(&acts); - assert_eq!(all.len(), 4, "{all:?}"); - assert!(all.iter().all(|v| v.len() == 2 && v[0] == (0.0, 0.0)), "{all:?}"); - assert_eq!(all[1][1], (50.0, -60.0), "the preview follows the pointer at its own angle"); - assert_eq!(all[3][1], (100.0, -84.0), "{all:?}"); - let last = acts.last().unwrap(); - assert!(matches!(&last[0], Action::Preview(_, v) if v["closed"] == false) && last[1] == Action::Commit, "{last:?}"); - // While the line is drawn, Alt shows no closing ring. - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0).with_mods(alt)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 40.0, 10.0).with_mods(alt)); - assert_eq!(t.cursor(&cx, Point::new(40.0, 10.0), alt), Cursor::Pencil); - t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 40.0, 10.0).with_mods(alt)); - // Shift with Alt: 45° steps. - let all = pts(&drag_held(&mut t, &cx, &wavy, Mods { shift: true, alt: true, ..Default::default() })); - let end = *all.last().unwrap().last().unwrap(); - assert!(((-end.1).atan2(end.0).to_degrees() - 45.0).abs() < 1e-9, "{end:?}"); - // Alt pressed after the press: freehand, closed at the release. - let acts = drag(&mut t, &cx, &wavy, alt); - assert_eq!(pts(&acts).last().unwrap().len(), 4, "{acts:?}"); - assert!(matches!(&acts.last().unwrap()[0], Action::Preview(_, v) if v["closed"] == true), "{acts:?}"); - // The Paintbrush with Alt held follows the hand and closes. - let mut b = GestureTool::new("paintbrush"); - let acts = drag_held(&mut b, &cx, &wavy, alt); - assert_eq!(pts(&acts).last().unwrap().len(), 4, "{acts:?}"); - assert!(matches!(&acts.last().unwrap()[0], Action::Preview(_, v) if v["closed"] == true), "{acts:?}"); - } - - /// #3799: with Alt key toggles to Smooth Tool on, Alt held at the press smooths as before and - /// draws no line. - #[test] - fn pencil_alt_with_alt_to_smooth_on_smooths_instead_of_drawing_a_line() { - let (d, id) = doc_with_rect(); - let mut s = Selection::default(); - s.set([id]); - let p = paint(); - let mut cx = cx(&d, &s, &p); - cx.smart_guides = false; - let alt = Mods { alt: true, ..Default::default() }; - let mut t = GestureTool::new("pencil"); - t.set_option("altSmooth", &json!(true)); - let acts = drag_held(&mut t, &cx, &[(50.0, 150.0), (150.0, 150.0), (250.0, 160.0)], alt); - assert!(acts[..acts.len() - 1].iter().all(|a| a.is_empty()), "{acts:?}"); - assert!(matches!(&acts.last().unwrap()[0], Action::Exec(c, v) if c == "path.smoothRegion" && v["points"].as_array().unwrap().len() == 3)); - } - - /// #3799: Alt's line starts and ends on Smart Guides as a stroke does, and pressed on an end of - /// a selected open path it continues the path from that end to the pointer. - #[test] - fn pencil_alt_line_snaps_its_ends_and_continues_a_selected_path() { - let (mut d, _) = doc_with_rect(); - let alt = Mods { alt: true, ..Default::default() }; - let p = paint(); - { - let s = Selection::default(); - let cx = cx(&d, &s, &p); - let mut t = GestureTool::new("pencil"); - let acts = drag_held(&mut t, &cx, &[(102.0, 98.0), (151.0, 300.0), (198.0, 203.0)], alt); - let Some(Action::Preview(_, v)) = acts.last().and_then(|a| a.first()) else { panic!("{acts:?}") }; - assert_eq!(v["points"], json!([[100.0, 100.0], [200.0, 200.0]])); - } - let l = d.layers[0].id; - let id = d.alloc_id(); - let line = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_vec(vec![ - vectorcraft_geom::PathEl::MoveTo(Point::new(300.0, 300.0)), - vectorcraft_geom::PathEl::LineTo(Point::new(400.0, 300.0)), - ])); - d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, line, vectorcraft_doc::Appearance::default_art())).unwrap(); - let mut s = Selection::default(); - s.set([id]); - let mut cx = cx(&d, &s, &p); - cx.smart_guides = false; - let mut t = GestureTool::new("pencil"); - let acts = drag_held(&mut t, &cx, &[(405.0, 303.0), (450.0, 340.0), (470.0, 330.0)], alt); - let Some(Action::Preview(_, v)) = acts.last().and_then(|a| a.first()) else { panic!("{acts:?}") }; - assert_eq!(v["extend"], json!({"id": id.0, "end": "end"})); - assert_eq!(v["points"], json!([[400.0, 300.0], [470.0, 330.0]])); - } - - #[test] - fn pencil_alt_closes_and_continues_selected_path() { - let (d, id) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = GestureTool::new("paintbrush"); - let acts = drag(&mut t, &cx, &[(0.0, 0.0), (10.0, 5.0), (20.0, 0.0)], Mods { alt: true, ..Default::default() }); - assert!(matches!(&acts.last().unwrap()[0], Action::Preview(_, v) if v["closed"] == true && v["style"] == "brush")); - // A selected closed rect is not continued; an open path would be (see engine tests). - let mut s2 = Selection::default(); - s2.set([id]); - let cx2 = crate::testutil::cx(&d, &s2, &p); - let acts = drag(&mut t, &cx2, &[(100.0, 100.0), (90.0, 90.0)], Mods::default()); - assert!(matches!(&acts[1][1], Action::Preview(_, v) if v.get("extend").is_none())); - } - - /// Edit selected paths within 0 turns editing off: a stroke that starts exactly on an end of a - /// selected open path draws a new path; at 12 px it continues the path from that end. - #[test] - fn edit_selected_paths_within_0_draws_a_new_path_from_an_end() { - let (mut d, _) = doc_with_rect(); - let l = d.layers[0].id; - let id = d.alloc_id(); - let line = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_vec(vec![ - vectorcraft_geom::PathEl::MoveTo(Point::new(300.0, 300.0)), - vectorcraft_geom::PathEl::LineTo(Point::new(400.0, 300.0)), - ])); - d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, line, vectorcraft_doc::Appearance::default_art())).unwrap(); - let mut s = Selection::default(); - s.set([id]); - let p = paint(); - let cx = cx(&d, &s, &p); - for tool in ["pencil", "paintbrush"] { - let mut t = GestureTool::new(tool); - for (within, extends) in [(12.0, true), (0.0, false)] { - t.set_option("editWithin", &json!(within)); - for (x, end) in [(400.0, "end"), (300.0, "start")] { - let acts = drag(&mut t, &cx, &[(x, 300.0), (x + 20.0, 350.0), (x, 380.0)], Mods::default()); - let Some(Action::Preview(_, v)) = acts.last().and_then(|a| a.first()) else { panic!("{tool}: {acts:?}") }; - if extends { - assert_eq!(v["extend"], json!({"id": id.0, "end": end}), "{tool} from the {end}"); - } else { - assert!(v.get("extend").is_none() && v.get("redraw").is_none(), "{tool} from the {end}: {v}"); - } - } - } - } - } - - /// A Pencil stroke from an end of a selected open path to an end of another selected open path - /// connects the two; it does not when the other path is not selected, and the Paintbrush does - /// not connect. - #[test] - fn pencil_connects_a_stroke_from_one_selected_path_to_an_end_of_another() { - let (mut d, _) = doc_with_rect(); - let l = d.layers[0].id; - let add = |d: &mut vectorcraft_doc::Document, a: (f64, f64), b: (f64, f64)| { - let id = d.alloc_id(); - let line = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_vec(vec![ - vectorcraft_geom::PathEl::MoveTo(Point::new(a.0, a.1)), - vectorcraft_geom::PathEl::LineTo(Point::new(b.0, b.1)), - ])); - d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, line, vectorcraft_doc::Appearance::default_art())).unwrap(); - id - }; - let a = add(&mut d, (300.0, 300.0), (400.0, 300.0)); - let b = add(&mut d, (500.0, 400.0), (600.0, 400.0)); - let p = paint(); - let stroke = [(400.0, 300.0), (450.0, 350.0), (499.0, 401.0)]; - let mut both = Selection::default(); - both.set([a, b]); - let mut one = Selection::default(); - one.set([a]); - // The previews select the path continued: after the press only it is selected. - let last = |tool: &str, at_press: &Selection| { - let mut t = GestureTool::new(tool); - t.pointer(&cx(&d, at_press, &p), &PointerEvent::new(PointerKind::Down, stroke[0].0, stroke[0].1)); - let after = cx(&d, &one, &p); - t.pointer(&after, &PointerEvent::new(PointerKind::Drag, stroke[1].0, stroke[1].1)); - t.pointer(&after, &PointerEvent::new(PointerKind::Drag, stroke[2].0, stroke[2].1)); - let acts = t.pointer(&after, &PointerEvent::new(PointerKind::Up, stroke[2].0, stroke[2].1)); - let Some(Action::Preview(_, v)) = acts.first() else { panic!("{acts:?}") }; - v.clone() - }; - let v = last("pencil", &both); - assert_eq!((v["extend"].clone(), v["connect"].clone()), (json!({"id": a.0, "end": "end"}), json!({"id": b.0, "end": "start"}))); - assert!(last("paintbrush", &both).get("connect").is_none(), "the Paintbrush"); - let v = last("pencil", &one); - assert!(v.get("extend").is_some() && v.get("connect").is_none(), "{v}"); - } - - /// Close paths when ends are within 0 turns closing off: a stroke that ends exactly where it - /// started stays open; at 15 px it is closed. - #[test] - fn close_paths_within_0_leaves_a_stroke_that_ends_at_its_start_open() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - for tool in ["pencil", "paintbrush"] { - let mut t = GestureTool::new(tool); - for (within, closes) in [(15.0, true), (0.0, false)] { - t.set_option("closeWithin", &json!(within)); - let acts = drag(&mut t, &cx, &[(310.0, 330.0), (360.0, 350.0), (330.0, 390.0), (310.0, 330.0)], Mods::default()); - let Some(Action::Preview(_, v)) = acts.last().and_then(|a| a.first()) else { panic!("{tool}: {acts:?}") }; - assert_eq!(v["points"][0], v["points"][3], "{tool}"); - assert_eq!(v["closed"], json!(closes), "{tool} at {within} px"); - } - } - } - - /// While a stroke is drawn back near its start, the cursor shows the closing ring when a - /// release there closes the path (Close Paths When Ends Are Within), and the tool's own cursor - /// away from the start, at 0 pixels and after the release. - #[test] - fn a_stroke_near_its_start_shows_the_closing_cursor() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let none = Mods::default(); - let tools = [("pencil", Cursor::Pencil, Cursor::PencilClose), ("paintbrush", Cursor::Paintbrush, Cursor::PaintbrushClose)]; - for (tool, own, closing) in tools { - for within in [15.0, 0.0] { - let near = if within > 0.0 { closing } else { own }; - let mut t = GestureTool::new(tool); - t.set_option("closeWithin", &json!(within)); - let mut at = |k, x, y| { - let acts = t.pointer(&cx, &PointerEvent::new(k, x, y)); - (acts, t.cursor(&cx, Point::new(x, y), none)) - }; - assert_eq!(at(PointerKind::Down, 310.0, 330.0).1, own, "{tool} at the press"); - assert_eq!(at(PointerKind::Drag, 360.0, 350.0).1, own, "{tool} away from the start"); - assert_eq!(at(PointerKind::Drag, 330.0, 390.0).1, own, "{tool} away from the start"); - assert_eq!(at(PointerKind::Drag, 318.0, 335.0).1, near, "{tool} near the start at {within} px"); - assert_eq!(at(PointerKind::Drag, 345.0, 340.0).1, own, "{tool} away from the start again"); - assert_eq!(at(PointerKind::Drag, 318.0, 335.0).1, near, "{tool} back near the start at {within} px"); - let (acts, after) = at(PointerKind::Up, 318.0, 335.0); - let Some(Action::Preview(_, v)) = acts.first() else { panic!("{tool}: {acts:?}") }; - assert_eq!(v["closed"], json!(within > 0.0), "{tool}: the release does what the cursor showed"); - assert_eq!(after, if tool == "pencil" { Cursor::PencilNew } else { own }, "{tool} after the release"); - } - } - // A straight Pencil line (Shift or Alt held at the press) stays open, so it shows no ring - // near its start. - for held in [Mods { shift: true, ..Mods::default() }, Mods { alt: true, ..Mods::default() }] { - let mut t = GestureTool::new("pencil"); - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 310.0, 330.0).with_mods(held)); - for (x, y) in [(360.0, 330.0), (330.0, 390.0), (318.0, 335.0)] { - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x, y).with_mods(held)); - } - for m in [held, none] { - assert_eq!(t.cursor(&cx, Point::new(318.0, 335.0), m), Cursor::Pencil, "a straight line, {held:?}, {m:?}"); - } - } - } - - /// A Paintbrush sample under a pixel from the last point is dropped, and a release there keeps - /// the last point: the closing cursor follows that point, not the pointer. - #[test] - fn the_paintbrush_closing_cursor_follows_the_point_a_release_keeps() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let none = Mods::default(); - let mut t = GestureTool::new("paintbrush"); - t.set_option("closeWithin", &json!(15.0)); - for (k, x, y) in [(PointerKind::Down, 310.0, 330.0), (PointerKind::Drag, 360.0, 350.0), (PointerKind::Drag, 330.0, 390.0)] { - t.pointer(&cx, &PointerEvent::new(k, x, y)); - } - // The last point kept is 15.5 from the start, outside Close Paths When Ends Are Within. - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 325.5, 330.0)); - assert_eq!(t.cursor(&cx, Point::new(325.5, 330.0), none), Cursor::Paintbrush, "outside the closing distance"); - // The pointer moves 0.7 to 14.8 from the start; the sample is dropped. - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 324.8, 330.0)); - assert_eq!(t.cursor(&cx, Point::new(324.8, 330.0), none), Cursor::Paintbrush, "the dropped sample shows no ring"); - let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 324.8, 330.0)); - let Some(Action::Preview(_, v)) = acts.first() else { panic!("{acts:?}") }; - assert_eq!(v["closed"], json!(false), "the release keeps the last point and leaves the path open"); - } - - #[test] - fn eraser_and_knife_exec_on_release() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = GestureTool::new("eraser"); - let acts = drag(&mut t, &cx, &[(50.0, 150.0), (250.0, 150.0)], Mods::default()); - assert!(acts[1].is_empty()); - assert!(matches!(&acts[2][0], Action::Exec(c, v) if c == "path.eraseRegion" && v["size"] == 10.0)); - let mut k = GestureTool::new("knife"); - k.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 150.0)); - k.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 150.0)); - assert_eq!(k.overlays(&cx).len(), 1); - let a = k.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 150.0)); - assert!(matches!(&a[0], Action::Exec(c, v) if c == "path.knife" && v["points"].as_array().unwrap().len() == 3)); - } - - /// The Eraser's Angle and Roundness (Eraser Tool Options) go with its stroke, kept within - /// −180–180° and 1–100%; while it drags, a tip that is not round shows the outline of the area - /// it covers, and a round one the drag as wide as the tip. - #[test] - fn the_eraser_sends_its_tip_and_shows_the_area_it_covers() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = GestureTool::new("eraser"); - t.set_option("size", &json!(20)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 150.0)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 150.0)); - let round = t.overlays(&cx); - assert!(matches!(&round[..], [Overlay::Path { width, .. }] if *width == 20.0), "{round:?}"); - t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 150.0)); - for (angle, roundness, sent) in [(90.0, 25.0, (90.0, 25.0)), (400.0, 0.0, (180.0, 1.0)), (-400.0, 250.0, (-180.0, 100.0))] { - t.set_option("angle", &json!(angle)); - t.set_option("roundness", &json!(roundness)); - assert_eq!((t.options()["angle"].as_f64(), t.options()["roundness"].as_f64()), (Some(sent.0), Some(sent.1))); - let acts = drag(&mut t, &cx, &[(50.0, 150.0), (250.0, 150.0)], Mods::default()); - assert!( - matches!(&acts[2][0], Action::Exec(c, v) if c == "path.eraseRegion" && v["size"] == 20.0 && v["angle"] == sent.0 && v["roundness"] == sent.1), - "{acts:?}" - ); - } - // A tip 20 pt long and 5 pt wide, standing upright: along a horizontal drag from x 50 to - // x 150 the outline reaches 2.5 pt either side of the drag and 10 pt above and below - // its ends. - t.set_option("angle", &json!(90)); - t.set_option("roundness", &json!(25)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 150.0)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 150.0)); - let o = t.overlays(&cx); - let [Overlay::Path { path, width, .. }] = &o[..] else { panic!("{o:?}") }; - assert_eq!(*width, 1.0); - let b = vectorcraft_geom::Shape::bounding_box(path); - assert!((b.x0 - 47.5).abs() < 0.1 && (b.x1 - 152.5).abs() < 0.1, "{b:?}"); - assert!((b.y0 - 140.0).abs() < 0.1 && (b.y1 - 160.0).abs() < 0.1, "{b:?}"); - } - - #[test] - fn smooth_needs_selection_and_brackets_resize() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = GestureTool::new("smooth"); - let acts = drag(&mut t, &cx, &[(50.0, 150.0), (250.0, 150.0)], Mods::default()); - assert!(acts.iter().all(|a| a.is_empty())); - let mut b = GestureTool::new("blobBrush"); - b.key(&cx, ToolKey::BracketRight, Mods::default()); - assert_eq!(b.options()["size"], 11.0); - } - - #[test] - fn pencil_alt_toggles_to_smooth_when_enabled_and_closes_when_disabled() { - let (d, id) = doc_with_rect(); - let mut s = Selection::default(); - s.set([id]); - let p = paint(); - let cx = cx(&d, &s, &p); - - // Off by default, as Pencil Tool Options ships it; on, Alt lends the Smooth tool. - let mut t = GestureTool::new("pencil"); - assert_eq!(t.options()["altSmooth"], json!(false)); - t.set_option("altSmooth", &json!(true)); - - // When alt is pressed, cursor shows smooth tool. - let alt_mod = Mods { alt: true, ..Default::default() }; - assert_eq!(t.cursor(&cx, Point::new(50.0, 50.0), alt_mod), Cursor::Smooth); - assert_eq!(t.cursor(&cx, Point::new(50.0, 50.0), Mods::default()), Cursor::PencilNew); - - // Alt-drag over selected path smooths it. - let acts = drag_held(&mut t, &cx, &[(50.0, 150.0), (150.0, 150.0), (250.0, 150.0)], alt_mod); - assert!(acts[1].is_empty(), "drag does not preview freehand path while smoothing"); - let last = acts.last().unwrap(); - assert!( - matches!(&last[0], Action::Exec(c, v) if c == "path.smoothRegion" && v["points"].as_array().unwrap().len() == 3), - "expected path.smoothRegion, got: {last:?}" - ); - - // When altSmooth is disabled: - t.set_option("altSmooth", &json!(false)); - assert_eq!(t.options()["altSmooth"], json!(false)); - // Before a press, Alt gives a straight line rather than a closed path. - assert_eq!(t.cursor(&cx, Point::new(50.0, 50.0), alt_mod), Cursor::PencilStraight); - - // A drag released with Alt held draws a closed pencil path. - let empty_sel = Selection::default(); - let cx_empty = crate::testutil::cx(&d, &empty_sel, &p); - let acts = drag(&mut t, &cx_empty, &[(0.0, 0.0), (10.0, 5.0), (20.0, 0.0)], alt_mod); - assert_eq!(acts[1][0], Action::Begin("Pencil".into())); - let last = acts.last().unwrap(); - assert!(matches!(&last[0], Action::Preview(_, v) if v["closed"] == true && v["style"] == "pencil")); - assert_eq!(last[1], Action::Commit); - } -} +mod tests; diff --git a/crates/tools/src/draw2/gesture/tests.rs b/crates/tools/src/draw2/gesture/tests.rs new file mode 100644 index 000000000..5cc7684ea --- /dev/null +++ b/crates/tools/src/draw2/gesture/tests.rs @@ -0,0 +1,1036 @@ +use super::*; +use crate::testutil::*; +use vectorcraft_doc::Selection; + +fn drag(t: &mut GestureTool, cx: &ToolContext, pts: &[(f64, f64)], m: Mods) -> Vec> { + let mut v = vec![t.pointer(cx, &PointerEvent::new(PointerKind::Down, pts[0].0, pts[0].1))]; + for &(x, y) in &pts[1..] { + v.push(t.pointer(cx, &PointerEvent::new(PointerKind::Drag, x, y))); + } + let l = pts[pts.len() - 1]; + v.push(t.pointer(cx, &PointerEvent::new(PointerKind::Up, l.0, l.1).with_mods(m))); + v +} + +fn drag_held(t: &mut GestureTool, cx: &ToolContext, pts: &[(f64, f64)], m: Mods) -> Vec> { + let mut v = vec![t.pointer(cx, &PointerEvent::new(PointerKind::Down, pts[0].0, pts[0].1).with_mods(m))]; + for &(x, y) in &pts[1..] { + v.push(t.pointer(cx, &PointerEvent::new(PointerKind::Drag, x, y).with_mods(m))); + } + let l = pts[pts.len() - 1]; + v.push(t.pointer(cx, &PointerEvent::new(PointerKind::Up, l.0, l.1).with_mods(m))); + v +} + +/// The Pencil's new path starts and ends on Smart Guides (#506), the points between where +/// the hand went. +#[test] +fn pencil_ends_snap_to_smart_guides() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = GestureTool::new("pencil"); + let acts = drag(&mut t, &cx, &[(102.0, 98.0), (151.0, 300.0), (250.0, 320.0), (198.0, 203.0)], Mods::default()); + let Some(Action::Preview(_, v)) = acts.last().and_then(|a| a.first()) else { panic!("{acts:?}") }; + assert_eq!(v["points"], json!([[100.0, 100.0], [151.0, 300.0], [250.0, 320.0], [200.0, 200.0]])); + // The Paintbrush follows the hand all the way. + let mut t = GestureTool::new("paintbrush"); + let acts = drag(&mut t, &cx, &[(102.0, 98.0), (151.0, 300.0)], Mods::default()); + let Some(Action::Preview(_, v)) = acts.last().and_then(|a| a.first()) else { panic!("{acts:?}") }; + assert_eq!(v["points"][0], json!([102.0, 98.0])); +} + +/// #852: the Paintbrush sends each point's pen pressure; a mouse (always fully pressed) and +/// the Pencil send plain points. +#[test] +fn paintbrush_sends_pen_pressure() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let stroke = |id: &str, pressures: [f32; 3]| { + let mut t = GestureTool::new(id); + let kinds = [PointerKind::Down, PointerKind::Drag, PointerKind::Up]; + let mut last = vec![]; + for ((k, x), f) in kinds.into_iter().zip([0.0, 40.0, 80.0]).zip(pressures) { + last = t.pointer(&cx, &PointerEvent { pressure: f, ..PointerEvent::new(k, x, 0.0) }); + } + let Some(Action::Preview(_, v)) = last.first() else { panic!("{last:?}") }; + v["points"].clone() + }; + assert_eq!(stroke("paintbrush", [0.25, 0.5, 1.0]), json!([[0.0, 0.0, 0.25], [40.0, 0.0, 0.5], [80.0, 0.0, 1.0]])); + assert_eq!(stroke("paintbrush", [1.0; 3]), json!([[0.0, 0.0], [40.0, 0.0], [80.0, 0.0]])); + assert_eq!(stroke("pencil", [0.25, 0.5, 1.0]), json!([[0.0, 0.0], [40.0, 0.0], [80.0, 0.0]])); +} + +#[test] +fn pencil_previews_and_commits() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = GestureTool::new("pencil"); + let acts = drag(&mut t, &cx, &[(0.0, 0.0), (10.0, 5.0), (20.0, 0.0)], Mods::default()); + assert!(acts[0].is_empty()); + assert_eq!(acts[1][0], Action::Begin("Pencil".into())); + assert!(matches!(&acts[1][1], Action::Preview(c, v) if c == "path.freehand" && v["points"].as_array().unwrap().len() == 2)); + let last = acts.last().unwrap(); + assert!(matches!(&last[0], Action::Preview(_, v) if v["closed"] == false && v["style"] == "pencil")); + assert_eq!(last[1], Action::Commit); + assert!(!t.busy()); +} + +/// Live curve fitting is the Pencil's, on by default. Off, the previews while a stroke is drawn +/// ask for the points unfitted (`fit: false`), and the release's preview is fitted as before. +#[test] +fn pencil_with_live_curve_fitting_off_previews_the_stroke_unfitted() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let stroke = [(0.0, 0.0), (10.0, 5.0), (20.0, 0.0), (30.0, 6.0), (40.0, 0.0)]; + // The `fit` each preview sends: those while drawn, then the release's. + let fits = |t: &mut GestureTool| { + let acts = drag(t, &cx, &stroke, Mods::default()); + let fit = |a: &Action| if let Action::Preview(_, v) = a { Some(v.get("fit").cloned()) } else { None }; + let (drawn, release) = acts.split_at(acts.len() - 1); + (drawn.iter().flatten().filter_map(fit).collect::>(), release[0].iter().filter_map(fit).collect::>()) + }; + let mut t = GestureTool::new("pencil"); + assert_eq!(t.options()["liveFit"], json!(true)); + let (drawn, release) = fits(&mut t); + assert!(!drawn.is_empty() && drawn.iter().all(Option::is_none), "{drawn:?}"); + assert_eq!(release, [None]); + t.set_option("liveFit", &json!(false)); + assert_eq!(t.options()["liveFit"], json!(false)); + let (drawn, release) = fits(&mut t); + assert!(!drawn.is_empty() && drawn.iter().all(|f| *f == Some(json!(false))), "{drawn:?}"); + assert_eq!(release, [None], "the release fits the stroke"); + // Choosing another tool while the stroke is drawn keeps it fitted, too. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0)); + for &(x, y) in &stroke[1..] { + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x, y)); + } + let acts = t.deactivate(&cx); + assert!(matches!(acts.as_slice(), [Action::Preview(_, v), Action::Commit] if v.get("fit").is_none()), "{acts:?}"); + // The Paintbrush has no such option, and its previews are fitted. + let mut t = GestureTool::new("paintbrush"); + t.set_option("liveFit", &json!(false)); + assert!(t.options().get("liveFit").is_none()); + let (drawn, _) = fits(&mut t); + assert!(!drawn.is_empty() && drawn.iter().all(Option::is_none), "{drawn:?}"); +} + +/// Keep selected is on by default, and every Pencil and Paintbrush preview sends it. +#[test] +fn pencil_and_paintbrush_send_keep_selected() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + for id in ["pencil", "paintbrush"] { + let mut t = GestureTool::new(id); + assert_eq!(t.options()["keepSelected"], json!(true), "{id}"); + for keep in [true, false] { + t.set_option("keepSelected", &json!(keep)); + assert_eq!(t.options()["keepSelected"], json!(keep), "{id}"); + let acts = drag(&mut t, &cx, &[(0.0, 0.0), (10.0, 5.0), (20.0, 0.0)], Mods::default()); + assert!(acts[1..].iter().all(|a| a.iter().all(|a| !matches!(a, Action::Preview(_, v) if v["keepSelected"] != keep))), "{id}"); + assert!(matches!(&acts.last().unwrap()[0], Action::Preview(_, v) if v["keepSelected"] == keep), "{id}"); + } + } +} + +/// With the Pencil's Live preview off, a stroke previews nothing while it is drawn: a thin line +/// follows the pen (an overlay), and the release draws the path with its fill and stroke. On (the +/// default), every step previews the path and no line is shown. The Paintbrush has no such option. +#[test] +fn pencil_with_live_preview_off_shows_a_thin_line_until_the_release() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let previews = |acts: &[Action]| acts.iter().filter(|a| matches!(a, Action::Preview(..))).count(); + let line = |t: &GestureTool| t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Path { width, .. } if *width == 1.0)); + let pts = [(0.0, 0.0), (10.0, 5.0), (20.0, 0.0), (30.0, 5.0)]; + for live in [true, false] { + let mut t = GestureTool::new("pencil"); + assert_eq!(t.options()["livePreview"], json!(true), "on by default"); + t.set_option("livePreview", &json!(live)); + assert_eq!(t.options()["livePreview"], json!(live)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, pts[0].0, pts[0].1)); + let mut during = vec![]; + for &(x, y) in &pts[1..] { + during.extend(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x, y))); + } + assert!(matches!(during.first(), Some(Action::Begin(_))), "live {live}: {during:?}"); + assert_eq!(previews(&during), if live { 3 } else { 0 }, "live {live}: {during:?}"); + assert_eq!(line(&t), !live, "live {live}: the thin line shows only with Live preview off"); + let up = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 30.0, 5.0)); + assert!(matches!(up.as_slice(), [Action::Preview(c, _), Action::Commit] if c == "path.freehand"), "live {live}: {up:?}"); + assert!(!line(&t), "live {live}: no line after the release"); + } + // Escape mid-stroke with Live preview off cancels the stroke it began. + let mut t = GestureTool::new("pencil"); + t.set_option("livePreview", &json!(false)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 10.0, 5.0)); + assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel]); + // The Paintbrush keeps previewing: Live preview isn't one of its options. + let mut b = GestureTool::new("paintbrush"); + assert!(b.options().get("livePreview").is_none()); + b.set_option("livePreview", &json!(false)); + b.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0)); + let acts = b.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 10.0, 5.0)); + assert_eq!(previews(&acts), 1, "{acts:?}"); +} + +/// Holding Alt shows the Paintbrush's closing cursor before the press, and the Pencil's and the +/// Paintbrush's while drawing (before a press, Alt gives the Pencil its straight-line cursor); a release +/// with Alt still held closes the path, and Alt let go before the release leaves it open. +/// The freehand tools take every position the pointer passed (`wants_samples`); the Join tool +/// doesn't need them. +#[test] +fn the_freehand_tools_want_every_sample() { + for id in ["pencil", "paintbrush", "blobBrush", "eraser", "knife", "smooth", "pathEraser"] { + assert!(GestureTool::new(id).wants_samples(), "{id}"); + } + assert!(!GestureTool::new("join").wants_samples()); +} + +#[test] +fn alt_shows_the_closing_cursor_and_a_release_with_alt_closes() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let alt = Mods { alt: true, ..Default::default() }; + let at = Point::new(10.0, 5.0); + for (id, own, closing, style) in + [("pencil", Cursor::Pencil, Cursor::PencilClose, "pencil"), ("paintbrush", Cursor::Paintbrush, Cursor::PaintbrushClose, "brush")] + { + let mut t = GestureTool::new(id); + assert_eq!(t.cursor(&cx, at, Mods::default()), if id == "pencil" { Cursor::PencilNew } else { own }, "{id}"); + assert_eq!(t.cursor(&cx, at, alt), if id == "pencil" { Cursor::PencilStraight } else { closing }, "{id}"); + for alt_at_release in [true, false] { + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 10.0, 5.0).with_mods(alt)); + assert_eq!(t.cursor(&cx, at, alt), closing, "{id} while drawing"); + assert_eq!(t.cursor(&cx, at, Mods::default()), own, "{id} while drawing"); + let up = Mods { alt: alt_at_release, ..Default::default() }; + let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 20.0, 0.0).with_mods(up)); + assert!( + matches!(&acts[0], Action::Preview(_, v) if v["closed"] == alt_at_release && v["style"] == style), + "{id}, Alt at the release {alt_at_release}: {acts:?}" + ); + } + } + // With Alt key toggles to Smooth Tool on, Alt lends the Pencil the Smooth tool instead, until + // a stroke has begun to draw: Alt then neither smooths nor closes, and the cursor stays the Pencil's. + let mut t = GestureTool::new("pencil"); + t.set_option("altSmooth", &json!(true)); + assert_eq!(t.cursor(&cx, at, alt), Cursor::Smooth); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 10.0, 5.0)); + assert_eq!(t.cursor(&cx, at, alt), Cursor::Pencil, "a stroke that has begun"); + let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 20.0, 0.0).with_mods(alt)); + assert!(matches!(&acts[0], Action::Preview(_, v) if v["closed"] == false), "{acts:?}"); + // A smoothing drag keeps the Smooth cursor when Alt is let go. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0).with_mods(alt)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 10.0, 5.0).with_mods(alt)); + assert_eq!(t.cursor(&cx, at, Mods::default()), Cursor::Smooth, "a smoothing drag"); +} + +#[test] +fn blob_brush_drag_shows_scrub_length_measurement_labels() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let on = ToolContext { measurement_labels: true, ..cx(&d, &s, &p) }; + let off = ToolContext { measurement_labels: false, ..cx(&d, &s, &p) }; + let mut t = GestureTool::new("blobBrush"); + t.pointer(&on, &PointerEvent::new(PointerKind::Down, 50.0, 150.0)); + t.pointer(&on, &PointerEvent::new(PointerKind::Drag, 150.0, 150.0)); + let want = format!("L: {}", on.len(100.0)); + assert!(t.overlays(&on).iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == &want))); + let mut quiet = GestureTool::new("blobBrush"); + quiet.pointer(&off, &PointerEvent::new(PointerKind::Down, 50.0, 150.0)); + quiet.pointer(&off, &PointerEvent::new(PointerKind::Drag, 150.0, 150.0)); + assert!(!quiet.overlays(&off).iter().any(|o| matches!(o, Overlay::Measure { .. }))); +} + +/// The Blob Brush has Keep selected and Merge only with selection, both off by default, and its +/// stroke sends both. +#[test] +fn blob_brush_sends_keep_selected_and_merge_only_with_selection() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = GestureTool::new("blobBrush"); + assert_eq!((t.options()["keepSelected"].clone(), t.options()["mergeOnlySelection"].clone()), (json!(false), json!(false))); + for (keep, only) in [(false, false), (true, false), (false, true)] { + t.set_option("keepSelected", &json!(keep)); + t.set_option("mergeOnlySelection", &json!(only)); + let acts = drag(&mut t, &cx, &[(50.0, 150.0), (250.0, 150.0)], Mods::default()); + assert!( + matches!(&acts[2][0], Action::Exec(c, v) if c == "path.blob" && v["keepSelected"] == keep && v["mergeOnlySelection"] == only), + "{acts:?}" + ); + } + // The Eraser keeps Size, Angle and Roundness: a round tip by default. + assert_eq!(GestureTool::new("eraser").options(), json!({"size": 10.0, "angle": 0.0, "roundness": 100.0})); +} + +/// Shift keeps a Blob Brush stroke on the horizontal or vertical line through the press point, +/// whichever is nearer to the pointer, at 90° steps from the Constrain Angle; the points +/// dragged with Shift held move to the other line together, and each point follows the Shift +/// state of its own event. +#[test] +fn blob_brush_shift_keeps_the_stroke_horizontal_or_vertical() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let mut cx = cx(&d, &s, &p); + let shift = Mods { shift: true, ..Default::default() }; + let blob = |acts: &[Vec]| -> Vec<(f64, f64)> { + let Some(Action::Exec(c, v)) = acts.last().and_then(|a| a.first()) else { panic!("{acts:?}") }; + assert_eq!(c, "path.blob"); + v["points"].as_array().unwrap().iter().map(|q| (q[0].as_f64().unwrap(), q[1].as_f64().unwrap())).collect() + }; + let mut t = GestureTool::new("blobBrush"); + let pts = blob(&drag_held(&mut t, &cx, &[(0.0, 0.0), (30.0, 5.0), (50.0, 8.0)], shift)); + assert_eq!(pts.len(), 3); + assert!(pts.iter().all(|q| q.1 == 0.0 && q.0 >= 0.0), "{pts:?}"); + let pts = blob(&drag_held(&mut t, &cx, &[(0.0, 0.0), (5.0, 30.0), (8.0, 50.0)], shift)); + assert!(pts.iter().all(|q| q.0.abs() < 1e-9 && q.1 >= 0.0), "{pts:?}"); + // A pointer that crosses to the vertical line takes the points before it along, each on + // its own side of the press point and at its own distance. + let pts = blob(&drag_held(&mut t, &cx, &[(0.0, 0.0), (-40.0, -2.0), (100.0, 99.0), (90.0, 120.0)], shift)); + assert_eq!(pts.len(), 4, "{pts:?}"); + assert!(pts.iter().all(|q| q.0.abs() < 1e-9), "{pts:?}"); + assert!((pts[1].1 + 1604f64.sqrt()).abs() < 1e-9 && (pts[2].1 - 19801f64.sqrt()).abs() < 1e-9 && (pts[3].1 - 150.0).abs() < 1e-9, "{pts:?}"); + // Without Shift the stroke follows the pointer. + let pts = blob(&drag_held(&mut t, &cx, &[(0.0, 0.0), (30.0, 5.0), (50.0, 8.0)], Mods::default())); + assert_eq!(pts, vec![(0.0, 0.0), (30.0, 5.0), (50.0, 8.0)]); + // Shift pressed and let go mid-stroke constrains only the points dragged while it is down, + // and a later press of Shift leaves the points of the earlier one where they are. + let ev = |k, x, y, m| PointerEvent::new(k, x, y).with_mods(m); + t.pointer(&cx, &ev(PointerKind::Down, 0.0, 0.0, Mods::default())); + t.pointer(&cx, &ev(PointerKind::Drag, 20.0, 10.0, Mods::default())); + t.pointer(&cx, &ev(PointerKind::Drag, 40.0, 10.0, shift)); + t.pointer(&cx, &ev(PointerKind::Drag, 60.0, 10.0, Mods::default())); + let pts = blob(&[t.pointer(&cx, &ev(PointerKind::Up, 10.0, 80.0, shift))]); + assert_eq!((pts.len(), pts[1], pts[3]), (5, (20.0, 10.0), (60.0, 10.0)), "{pts:?}"); + assert!(pts[2].1 == 0.0 && (pts[2].0 - 1700f64.sqrt()).abs() < 1e-9, "{pts:?}"); + assert!(pts[4].0.abs() < 1e-9 && (pts[4].1 - 6500f64.sqrt()).abs() < 1e-9, "{pts:?}"); + // The axes turn with the Constrain Angle. + cx.constrain_angle = 30.0; + let pts = blob(&drag_held(&mut t, &cx, &[(0.0, 0.0), (40.0, -20.0)], shift)); + let a = (-pts[1].1).atan2(pts[1].0).to_degrees(); + assert!((a - 30.0).abs() < 1e-9, "{pts:?}"); + // A pointer back at the press point leaves the stroke on its line. + let pts = blob(&drag_held(&mut t, &cx, &[(0.0, 0.0), (40.0, -20.0), (0.0, 0.0)], shift)); + let a = (-pts[1].1).atan2(pts[1].0).to_degrees(); + assert!(pts.len() == 3 && pts[2] == (0.0, 0.0) && (a - 30.0).abs() < 1e-9, "{pts:?}"); +} + +/// #3799: Shift held at the press draws a straight Pencil line from the press point, at 45° +/// steps from the Constrain Angle, previewed as it is dragged; the Paintbrush keeps following +/// the hand. +#[test] +fn pencil_shift_draws_a_straight_line_at_45_degree_steps() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.smart_guides = false; + let shift = Mods { shift: true, ..Default::default() }; + let pts = |acts: &[Vec]| -> Vec> { + acts.iter() + .filter_map(|a| a.iter().find_map(|a| if let Action::Preview(_, v) = a { Some(v["points"].clone()) } else { None })) + .map(|v| v.as_array().unwrap().iter().map(|q| (q[0].as_f64().unwrap(), q[1].as_f64().unwrap())).collect()) + .collect() + }; + let angle = |q: (f64, f64), from: (f64, f64)| (-(q.1 - from.1)).atan2(q.0 - from.0).to_degrees(); + let mut t = GestureTool::new("pencil"); + // A wavy drag heading up and right at about 40°: a line at 45°, as long as the drag. + let acts = drag_held(&mut t, &cx, &[(0.0, 0.0), (30.0, -10.0), (50.0, -60.0), (100.0, -84.0)], shift); + let all = pts(&acts); + assert!(all.iter().all(|v| v.len() == 2 && v[0] == (0.0, 0.0)), "{all:?}"); + let end = *all.last().unwrap().last().unwrap(); + assert!((angle(end, (0.0, 0.0)) - 45.0).abs() < 1e-9, "{end:?}"); + assert!((end.0.hypot(end.1) - 100f64.hypot(84.0)).abs() < 1e-9, "{end:?}"); + let last = acts.last().unwrap(); + assert!(matches!(&last[0], Action::Preview(_, v) if v["closed"] == false) && last[1] == Action::Commit, "{last:?}"); + // The steps turn with the Constrain Angle: at 30°, a drag at 40° gives 30°, one at 60° gives 75°. + cx.constrain_angle = 30.0; + for (to, want) in [((100.0, -84.0), 30.0), ((50.0, -86.6), 75.0)] { + let all = pts(&drag_held(&mut t, &cx, &[(10.0, 10.0), (to.0 + 10.0, to.1 + 10.0)], shift)); + let end = *all.last().unwrap().last().unwrap(); + assert!((angle(end, (10.0, 10.0)) - want).abs() < 1e-9, "{to:?}: {all:?}"); + } + // Without Shift, and with the Paintbrush, the stroke follows the hand. + for (id, m) in [("pencil", Mods::default()), ("paintbrush", shift)] { + let mut t = GestureTool::new(id); + let all = pts(&drag_held(&mut t, &cx, &[(0.0, 0.0), (30.0, -10.0), (50.0, -60.0)], m)); + assert_eq!(all.last().unwrap().len(), 3, "{id}: {all:?}"); + } +} + +/// #3799: a Shift line leaves no Smart Guides of its press on screen while it is dragged +/// (its end doesn't snap), and Alt at its release leaves it open. +#[test] +fn pencil_shift_line_drops_the_press_guides_and_stays_open_with_alt() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let shift = Mods { shift: true, ..Default::default() }; + let mut t = GestureTool::new("pencil"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 102.0, 98.0).with_mods(shift)); + assert!(!t.overlays(&cx).is_empty(), "the press snaps to the corner and shows it"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 300.0, 140.0).with_mods(shift)); + assert!(t.overlays(&cx).is_empty(), "{:?}", t.overlays(&cx)); + let shift_alt = Mods { shift: true, alt: true, ..Default::default() }; + let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 300.0, 140.0).with_mods(shift_alt)); + assert!(matches!(&acts[0], Action::Preview(_, v) if v["closed"] == false && v["points"].as_array().unwrap().len() == 2), "{acts:?}"); +} + +/// Before a press, the Pencil shows its straight-line cursor exactly when the press would draw a +/// straight line; once the press is down, and with the Paintbrush, Shift leaves the tool's own +/// cursor. +#[test] +fn pencil_shows_the_straight_line_cursor_when_a_press_draws_a_line() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.smart_guides = false; + let at = Point::new(0.0, 0.0); + let shift = Mods { shift: true, ..Default::default() }; + let alt = Mods { alt: true, ..Default::default() }; + let shift_alt = Mods { shift: true, alt: true, ..Default::default() }; + // The last drag's preview: two points for a line, all three for a freehand stroke, none + // for a smoothing drag. + let straight = |acts: &[Vec]| { + acts[acts.len() - 2].iter().any(|a| matches!(a, Action::Preview(_, v) if v["points"].as_array().is_some_and(|q| q.len() == 2))) + }; + for alt_smooth in [false, true] { + for m in [Mods::default(), shift, alt, shift_alt] { + let mut t = GestureTool::new("pencil"); + t.set_option("altSmooth", &json!(alt_smooth)); + let shown = t.cursor(&cx, at, m) == Cursor::PencilStraight; + let drawn = straight(&drag_held(&mut t, &cx, &[(0.0, 0.0), (30.0, -10.0), (50.0, -60.0)], m)); + assert_eq!(shown, drawn, "altSmooth {alt_smooth}, {m:?}"); + } + } + let mut t = GestureTool::new("pencil"); + assert_eq!(t.cursor(&cx, at, Mods::default()), Cursor::PencilNew); + assert_eq!(t.cursor(&cx, at, shift), Cursor::PencilStraight); + assert_eq!(t.cursor(&cx, at, alt), Cursor::PencilStraight, "Alt alone draws a line at any angle"); + assert_eq!(t.cursor(&cx, at, shift_alt), Cursor::PencilStraight, "Alt at the release leaves a straight line open"); + // While the line is drawn, the Pencil's own cursor; after the release, Shift shows it again. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0).with_mods(shift)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 40.0, -10.0).with_mods(shift)); + assert_eq!(t.cursor(&cx, at, shift), Cursor::Pencil, "while the line is drawn"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 40.0, -10.0).with_mods(shift)); + assert_eq!(t.cursor(&cx, at, shift), Cursor::PencilStraight, "after the release"); + // Shift pressed during a freehand stroke doesn't make it a line. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 40.0, -10.0).with_mods(shift)); + assert_eq!(t.cursor(&cx, at, shift), Cursor::Pencil, "a freehand stroke"); + assert_eq!(GestureTool::new("paintbrush").cursor(&cx, at, shift), Cursor::Paintbrush); + assert_eq!(Cursor::PencilStraight.precise(), Cursor::Crosshair, "Use Precise Cursors"); +} + +/// Before a press, the Pencil shows its new-path cursor (the star) exactly when the press draws +/// a new path: over an end of a selected open path, which the stroke continues, and over a +/// selected path, which it redraws, it does not. With nothing selected, with Edit Selected Paths +/// off and away from the paths, it does; while a stroke is drawn, it does not. +#[test] +fn pencil_shows_the_star_before_a_press_that_draws_a_new_path() { + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let id = d.alloc_id(); + let line = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_vec(vec![ + vectorcraft_geom::PathEl::MoveTo(Point::new(300.0, 300.0)), + vectorcraft_geom::PathEl::LineTo(Point::new(400.0, 300.0)), + ])); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, line, vectorcraft_doc::Appearance::default_art())).unwrap(); + let mut s = Selection::default(); + s.set([id]); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.smart_guides = false; + // The stroke edits the selected path when its previews continue or redraw it. + let edits = |acts: &[Vec]| { + acts.iter().flatten().any(|a| matches!(a, Action::Preview(_, v) if v.get("extend").is_some() || v.get("redraw").is_some())) + }; + let (end, middle, away) = (Point::new(405.0, 303.0), Point::new(350.0, 304.0), Point::new(350.0, 360.0)); + for edit_within in [12.0, 0.0] { + for at in [end, Point::new(296.0, 302.0), middle, away, Point::new(200.0, 200.0)] { + let mut t = GestureTool::new("pencil"); + t.set_option("editWithin", &json!(edit_within)); + let shown = t.cursor(&cx, at, Mods::default()) == Cursor::PencilNew; + let acts = drag(&mut t, &cx, &[(at.x, at.y), (360.0, 330.0), (420.0, 350.0)], Mods::default()); + assert_eq!(shown, !edits(&acts), "editWithin {edit_within} at {at:?}"); + } + } + let mut t = GestureTool::new("pencil"); + assert_eq!(t.cursor(&cx, end, Mods::default()), Cursor::Pencil, "over an end the stroke continues"); + assert_eq!(t.cursor(&cx, middle, Mods::default()), Cursor::Pencil, "over the path the stroke redraws"); + assert_eq!(t.cursor(&cx, away, Mods::default()), Cursor::PencilNew, "away from the path"); + // While a stroke is drawn, the Pencil's own cursor. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, away.x, away.y)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 380.0, 380.0)); + assert_eq!(t.cursor(&cx, Point::new(380.0, 380.0), Mods::default()), Cursor::Pencil, "while a stroke is drawn"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 380.0, 380.0)); + assert_eq!(t.cursor(&cx, away, Mods::default()), Cursor::PencilNew, "after the release"); + // With nothing selected, every press draws a new path. + let none = Selection::default(); + let cx_none = crate::testutil::cx(&d, &none, &p); + assert_eq!(GestureTool::new("pencil").cursor(&cx_none, middle, Mods::default()), Cursor::PencilNew, "nothing selected"); + // Shift held: a straight line, with its own cursor; the Paintbrush keeps its cursor. + let shift = Mods { shift: true, ..Default::default() }; + assert_eq!(GestureTool::new("pencil").cursor(&cx, away, shift), Cursor::PencilStraight); + assert_eq!(GestureTool::new("paintbrush").cursor(&cx, away, Mods::default()), Cursor::Paintbrush); +} + +/// #3799: a Shift line started on an end of a selected open path continues the path from that +/// end, constrained from the end itself. +#[test] +fn pencil_shift_line_continues_a_selected_path_from_its_end() { + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let id = d.alloc_id(); + let line = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_vec(vec![ + vectorcraft_geom::PathEl::MoveTo(Point::new(300.0, 300.0)), + vectorcraft_geom::PathEl::LineTo(Point::new(400.0, 300.0)), + ])); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, line, vectorcraft_doc::Appearance::default_art())).unwrap(); + let mut s = Selection::default(); + s.set([id]); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.smart_guides = false; + let mut t = GestureTool::new("pencil"); + // Pressed 5 pt off the end, released 100 pt right and 4 pt above it. + let acts = drag_held(&mut t, &cx, &[(405.0, 303.0), (450.0, 290.0), (500.0, 296.0)], Mods { shift: true, ..Default::default() }); + let Some(Action::Preview(_, v)) = acts.last().and_then(|a| a.first()) else { panic!("{acts:?}") }; + assert_eq!(v["extend"], json!({"id": id.0, "end": "end"})); + assert_eq!(v["points"], json!([[400.0, 300.0], [400.0 + 100f64.hypot(4.0), 300.0]])); +} + +/// Before a press over an end of a selected open path, the Pencil shows its continuation cursor +/// exactly when the press would draw a straight line that continues the path; away from the +/// ends, with nothing selected, with Edit Selected Paths off, once the press is down and with +/// the Paintbrush, it does not. +#[test] +fn pencil_shows_the_continuation_cursor_over_an_end_a_line_continues() { + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let id = d.alloc_id(); + let line = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_vec(vec![ + vectorcraft_geom::PathEl::MoveTo(Point::new(300.0, 300.0)), + vectorcraft_geom::PathEl::LineTo(Point::new(400.0, 300.0)), + ])); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, line, vectorcraft_doc::Appearance::default_art())).unwrap(); + let mut s = Selection::default(); + s.set([id]); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.smart_guides = false; + let shift = Mods { shift: true, ..Default::default() }; + let alt = Mods { alt: true, ..Default::default() }; + let shift_alt = Mods { shift: true, alt: true, ..Default::default() }; + // The release's stroke: a straight line that continues the path. + let continues = |acts: &[Vec]| { + acts.last().and_then(|a| a.first()).is_some_and( + |a| matches!(a, Action::Preview(_, v) if v.get("extend").is_some() && v["points"].as_array().is_some_and(|q| q.len() == 2)), + ) + }; + for alt_smooth in [false, true] { + for m in [Mods::default(), shift, alt, shift_alt] { + for (at, other_end) in [(Point::new(405.0, 303.0), (500.0, 296.0)), (Point::new(296.0, 302.0), (200.0, 310.0))] { + let mut t = GestureTool::new("pencil"); + t.set_option("altSmooth", &json!(alt_smooth)); + let shown = t.cursor(&cx, at, m) == Cursor::PencilContinue; + let acts = drag_held(&mut t, &cx, &[(at.x, at.y), (450.0, 250.0), other_end], m); + assert_eq!(shown, continues(&acts), "altSmooth {alt_smooth}, {m:?} at {at:?}"); + } + } + } + let mut t = GestureTool::new("pencil"); + let end = Point::new(405.0, 303.0); + assert_eq!(t.cursor(&cx, end, shift), Cursor::PencilContinue); + assert_eq!(t.cursor(&cx, end, alt), Cursor::PencilContinue, "Alt alone continues at any angle"); + assert_eq!(t.cursor(&cx, end, Mods::default()), Cursor::Pencil, "without Shift or Alt the stroke is freehand"); + assert_eq!(t.cursor(&cx, Point::new(350.0, 360.0), shift), Cursor::PencilStraight, "away from the ends"); + // While the line is drawn, the Pencil's own cursor. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, end.x, end.y).with_mods(shift)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 450.0, 303.0).with_mods(shift)); + assert_eq!(t.cursor(&cx, end, shift), Cursor::Pencil, "while the line is drawn"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 450.0, 303.0).with_mods(shift)); + // Edit Selected Paths off: the press draws a new line. + t.set_option("editWithin", &json!(0.0)); + assert_eq!(t.cursor(&cx, end, shift), Cursor::PencilStraight, "Edit Selected Paths off"); + let none = Selection::default(); + let cx_none = crate::testutil::cx(&d, &none, &p); + assert_eq!(GestureTool::new("pencil").cursor(&cx_none, end, shift), Cursor::PencilStraight, "nothing selected"); + assert_eq!(GestureTool::new("paintbrush").cursor(&cx, end, shift), Cursor::Paintbrush); + assert_eq!(Cursor::PencilContinue.precise(), Cursor::Crosshair, "Use Precise Cursors"); +} + +/// #3846: Alt during a Shift line doesn't promise a close the release can't do, and with Alt +/// Toggles to Smooth, Alt pressed after the press doesn't turn the line into a smoothing drag. +#[test] +fn pencil_shift_line_ignores_alt_for_cursor_and_smoothing() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.smart_guides = false; + let shift = Mods { shift: true, ..Default::default() }; + let shift_alt = Mods { shift: true, alt: true, ..Default::default() }; + let mut t = GestureTool::new("pencil"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0).with_mods(shift)); + assert_eq!(t.cursor(&cx, Point::new(0.0, 0.0), shift_alt), Cursor::Pencil); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 0.0, 0.0).with_mods(shift)); + + let mut t = GestureTool::new("pencil"); + t.alt_smooth = true; + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0).with_mods(shift)); + let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 80.0, 0.0).with_mods(shift_alt)); + assert!(matches!(acts.as_slice(), [Action::Begin(_), Action::Preview(..)]), "{acts:?}"); + let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 80.0, 0.0).with_mods(shift_alt)); + assert!(matches!(&acts[0], Action::Preview(_, v) if v["points"].as_array().unwrap().len() == 2), "{acts:?}"); +} + +/// #3846: a Shift line dragged back to its press point has no line: the preview is cancelled +/// and the release commits nothing (not the previous end of the line). +#[test] +fn pencil_shift_line_back_at_the_press_point_draws_nothing() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.smart_guides = false; + let shift = Mods { shift: true, ..Default::default() }; + // Released back at the press point, and dragged back there first. + for back_in_drag in [false, true] { + let mut t = GestureTool::new("pencil"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0).with_mods(shift)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 80.0, 0.0).with_mods(shift)); + let mut acts = vec![]; + if back_in_drag { + // The shown line goes as soon as the pointer is back, not only at the release. + let back = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 0.2, 0.0).with_mods(shift)); + assert_eq!(back, vec![Action::Cancel]); + acts.extend(back); + } + acts.extend(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 0.2, 0.0).with_mods(shift))); + assert!(acts.contains(&Action::Cancel), "{back_in_drag}: {acts:?}"); + assert!(!acts.contains(&Action::Commit) && !acts.iter().any(|a| matches!(a, Action::Preview(..))), "{back_in_drag}: {acts:?}"); + } +} + +/// #3799: Alt held at the press draws a straight Pencil line at any angle from the press point +/// to the pointer, previewed as it is dragged, and left open by a release with Alt still held; +/// with Shift too it keeps Shift's 45° steps. Alt pressed after the press keeps the stroke +/// freehand and closes it at the release, and the Paintbrush keeps following the hand. +#[test] +fn pencil_alt_at_the_press_draws_a_straight_line_at_any_angle() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.smart_guides = false; + let alt = Mods { alt: true, ..Default::default() }; + let pts = |acts: &[Vec]| -> Vec> { + acts.iter() + .filter_map(|a| a.iter().find_map(|a| if let Action::Preview(_, v) = a { Some(v["points"].clone()) } else { None })) + .map(|v| v.as_array().unwrap().iter().map(|q| (q[0].as_f64().unwrap(), q[1].as_f64().unwrap())).collect()) + .collect() + }; + let wavy = [(0.0, 0.0), (30.0, -10.0), (50.0, -60.0), (100.0, -84.0)]; + let mut t = GestureTool::new("pencil"); + assert_eq!(t.cursor(&cx, Point::new(0.0, 0.0), alt), Cursor::PencilStraight, "before the press"); + let acts = drag_held(&mut t, &cx, &wavy, alt); + let all = pts(&acts); + assert_eq!(all.len(), 4, "{all:?}"); + assert!(all.iter().all(|v| v.len() == 2 && v[0] == (0.0, 0.0)), "{all:?}"); + assert_eq!(all[1][1], (50.0, -60.0), "the preview follows the pointer at its own angle"); + assert_eq!(all[3][1], (100.0, -84.0), "{all:?}"); + let last = acts.last().unwrap(); + assert!(matches!(&last[0], Action::Preview(_, v) if v["closed"] == false) && last[1] == Action::Commit, "{last:?}"); + // While the line is drawn, Alt shows no closing ring. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0).with_mods(alt)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 40.0, 10.0).with_mods(alt)); + assert_eq!(t.cursor(&cx, Point::new(40.0, 10.0), alt), Cursor::Pencil); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 40.0, 10.0).with_mods(alt)); + // Shift with Alt: 45° steps. + let all = pts(&drag_held(&mut t, &cx, &wavy, Mods { shift: true, alt: true, ..Default::default() })); + let end = *all.last().unwrap().last().unwrap(); + assert!(((-end.1).atan2(end.0).to_degrees() - 45.0).abs() < 1e-9, "{end:?}"); + // Alt pressed after the press: freehand, closed at the release. + let acts = drag(&mut t, &cx, &wavy, alt); + assert_eq!(pts(&acts).last().unwrap().len(), 4, "{acts:?}"); + assert!(matches!(&acts.last().unwrap()[0], Action::Preview(_, v) if v["closed"] == true), "{acts:?}"); + // The Paintbrush with Alt held follows the hand and closes. + let mut b = GestureTool::new("paintbrush"); + let acts = drag_held(&mut b, &cx, &wavy, alt); + assert_eq!(pts(&acts).last().unwrap().len(), 4, "{acts:?}"); + assert!(matches!(&acts.last().unwrap()[0], Action::Preview(_, v) if v["closed"] == true), "{acts:?}"); +} + +/// #3799: with Alt key toggles to Smooth Tool on, Alt held at the press smooths as before and +/// draws no line. +#[test] +fn pencil_alt_with_alt_to_smooth_on_smooths_instead_of_drawing_a_line() { + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.set([id]); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.smart_guides = false; + let alt = Mods { alt: true, ..Default::default() }; + let mut t = GestureTool::new("pencil"); + t.set_option("altSmooth", &json!(true)); + let acts = drag_held(&mut t, &cx, &[(50.0, 150.0), (150.0, 150.0), (250.0, 160.0)], alt); + assert!(acts[..acts.len() - 1].iter().all(|a| a.is_empty()), "{acts:?}"); + assert!(matches!(&acts.last().unwrap()[0], Action::Exec(c, v) if c == "path.smoothRegion" && v["points"].as_array().unwrap().len() == 3)); +} + +/// #3799: Alt's line starts and ends on Smart Guides as a stroke does, and pressed on an end of +/// a selected open path it continues the path from that end to the pointer. +#[test] +fn pencil_alt_line_snaps_its_ends_and_continues_a_selected_path() { + let (mut d, _) = doc_with_rect(); + let alt = Mods { alt: true, ..Default::default() }; + let p = paint(); + { + let s = Selection::default(); + let cx = cx(&d, &s, &p); + let mut t = GestureTool::new("pencil"); + let acts = drag_held(&mut t, &cx, &[(102.0, 98.0), (151.0, 300.0), (198.0, 203.0)], alt); + let Some(Action::Preview(_, v)) = acts.last().and_then(|a| a.first()) else { panic!("{acts:?}") }; + assert_eq!(v["points"], json!([[100.0, 100.0], [200.0, 200.0]])); + } + let l = d.layers[0].id; + let id = d.alloc_id(); + let line = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_vec(vec![ + vectorcraft_geom::PathEl::MoveTo(Point::new(300.0, 300.0)), + vectorcraft_geom::PathEl::LineTo(Point::new(400.0, 300.0)), + ])); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, line, vectorcraft_doc::Appearance::default_art())).unwrap(); + let mut s = Selection::default(); + s.set([id]); + let mut cx = cx(&d, &s, &p); + cx.smart_guides = false; + let mut t = GestureTool::new("pencil"); + let acts = drag_held(&mut t, &cx, &[(405.0, 303.0), (450.0, 340.0), (470.0, 330.0)], alt); + let Some(Action::Preview(_, v)) = acts.last().and_then(|a| a.first()) else { panic!("{acts:?}") }; + assert_eq!(v["extend"], json!({"id": id.0, "end": "end"})); + assert_eq!(v["points"], json!([[400.0, 300.0], [470.0, 330.0]])); +} + +#[test] +fn pencil_alt_closes_and_continues_selected_path() { + let (d, id) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = GestureTool::new("paintbrush"); + let acts = drag(&mut t, &cx, &[(0.0, 0.0), (10.0, 5.0), (20.0, 0.0)], Mods { alt: true, ..Default::default() }); + assert!(matches!(&acts.last().unwrap()[0], Action::Preview(_, v) if v["closed"] == true && v["style"] == "brush")); + // A selected closed rect is not continued; an open path would be (see engine tests). + let mut s2 = Selection::default(); + s2.set([id]); + let cx2 = crate::testutil::cx(&d, &s2, &p); + let acts = drag(&mut t, &cx2, &[(100.0, 100.0), (90.0, 90.0)], Mods::default()); + assert!(matches!(&acts[1][1], Action::Preview(_, v) if v.get("extend").is_none())); +} + +/// Edit selected paths within 0 turns editing off: a stroke that starts exactly on an end of a +/// selected open path draws a new path; at 12 px it continues the path from that end. +#[test] +fn edit_selected_paths_within_0_draws_a_new_path_from_an_end() { + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let id = d.alloc_id(); + let line = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_vec(vec![ + vectorcraft_geom::PathEl::MoveTo(Point::new(300.0, 300.0)), + vectorcraft_geom::PathEl::LineTo(Point::new(400.0, 300.0)), + ])); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, line, vectorcraft_doc::Appearance::default_art())).unwrap(); + let mut s = Selection::default(); + s.set([id]); + let p = paint(); + let cx = cx(&d, &s, &p); + for tool in ["pencil", "paintbrush"] { + let mut t = GestureTool::new(tool); + for (within, extends) in [(12.0, true), (0.0, false)] { + t.set_option("editWithin", &json!(within)); + for (x, end) in [(400.0, "end"), (300.0, "start")] { + let acts = drag(&mut t, &cx, &[(x, 300.0), (x + 20.0, 350.0), (x, 380.0)], Mods::default()); + let Some(Action::Preview(_, v)) = acts.last().and_then(|a| a.first()) else { panic!("{tool}: {acts:?}") }; + if extends { + assert_eq!(v["extend"], json!({"id": id.0, "end": end}), "{tool} from the {end}"); + } else { + assert!(v.get("extend").is_none() && v.get("redraw").is_none(), "{tool} from the {end}: {v}"); + } + } + } + } +} + +/// A Pencil stroke from an end of a selected open path to an end of another selected open path +/// connects the two; it does not when the other path is not selected, and the Paintbrush does +/// not connect. +#[test] +fn pencil_connects_a_stroke_from_one_selected_path_to_an_end_of_another() { + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let add = |d: &mut vectorcraft_doc::Document, a: (f64, f64), b: (f64, f64)| { + let id = d.alloc_id(); + let line = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_vec(vec![ + vectorcraft_geom::PathEl::MoveTo(Point::new(a.0, a.1)), + vectorcraft_geom::PathEl::LineTo(Point::new(b.0, b.1)), + ])); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, line, vectorcraft_doc::Appearance::default_art())).unwrap(); + id + }; + let a = add(&mut d, (300.0, 300.0), (400.0, 300.0)); + let b = add(&mut d, (500.0, 400.0), (600.0, 400.0)); + let p = paint(); + let stroke = [(400.0, 300.0), (450.0, 350.0), (499.0, 401.0)]; + let mut both = Selection::default(); + both.set([a, b]); + let mut one = Selection::default(); + one.set([a]); + // The previews select the path continued: after the press only it is selected. + let last = |tool: &str, at_press: &Selection| { + let mut t = GestureTool::new(tool); + t.pointer(&cx(&d, at_press, &p), &PointerEvent::new(PointerKind::Down, stroke[0].0, stroke[0].1)); + let after = cx(&d, &one, &p); + t.pointer(&after, &PointerEvent::new(PointerKind::Drag, stroke[1].0, stroke[1].1)); + t.pointer(&after, &PointerEvent::new(PointerKind::Drag, stroke[2].0, stroke[2].1)); + let acts = t.pointer(&after, &PointerEvent::new(PointerKind::Up, stroke[2].0, stroke[2].1)); + let Some(Action::Preview(_, v)) = acts.first() else { panic!("{acts:?}") }; + v.clone() + }; + let v = last("pencil", &both); + assert_eq!((v["extend"].clone(), v["connect"].clone()), (json!({"id": a.0, "end": "end"}), json!({"id": b.0, "end": "start"}))); + assert!(last("paintbrush", &both).get("connect").is_none(), "the Paintbrush"); + let v = last("pencil", &one); + assert!(v.get("extend").is_some() && v.get("connect").is_none(), "{v}"); +} + +/// Close paths when ends are within 0 turns closing off: a stroke that ends exactly where it +/// started stays open; at 15 px it is closed. +#[test] +fn close_paths_within_0_leaves_a_stroke_that_ends_at_its_start_open() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + for tool in ["pencil", "paintbrush"] { + let mut t = GestureTool::new(tool); + for (within, closes) in [(15.0, true), (0.0, false)] { + t.set_option("closeWithin", &json!(within)); + let acts = drag(&mut t, &cx, &[(310.0, 330.0), (360.0, 350.0), (330.0, 390.0), (310.0, 330.0)], Mods::default()); + let Some(Action::Preview(_, v)) = acts.last().and_then(|a| a.first()) else { panic!("{tool}: {acts:?}") }; + assert_eq!(v["points"][0], v["points"][3], "{tool}"); + assert_eq!(v["closed"], json!(closes), "{tool} at {within} px"); + } + } +} + +/// While a stroke is drawn back near its start, the cursor shows the closing ring when a +/// release there closes the path (Close Paths When Ends Are Within), and the tool's own cursor +/// away from the start, at 0 pixels and after the release. +#[test] +fn a_stroke_near_its_start_shows_the_closing_cursor() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let none = Mods::default(); + let tools = [("pencil", Cursor::Pencil, Cursor::PencilClose), ("paintbrush", Cursor::Paintbrush, Cursor::PaintbrushClose)]; + for (tool, own, closing) in tools { + for within in [15.0, 0.0] { + let near = if within > 0.0 { closing } else { own }; + let mut t = GestureTool::new(tool); + t.set_option("closeWithin", &json!(within)); + let mut at = |k, x, y| { + let acts = t.pointer(&cx, &PointerEvent::new(k, x, y)); + (acts, t.cursor(&cx, Point::new(x, y), none)) + }; + assert_eq!(at(PointerKind::Down, 310.0, 330.0).1, own, "{tool} at the press"); + assert_eq!(at(PointerKind::Drag, 360.0, 350.0).1, own, "{tool} away from the start"); + assert_eq!(at(PointerKind::Drag, 330.0, 390.0).1, own, "{tool} away from the start"); + assert_eq!(at(PointerKind::Drag, 318.0, 335.0).1, near, "{tool} near the start at {within} px"); + assert_eq!(at(PointerKind::Drag, 345.0, 340.0).1, own, "{tool} away from the start again"); + assert_eq!(at(PointerKind::Drag, 318.0, 335.0).1, near, "{tool} back near the start at {within} px"); + let (acts, after) = at(PointerKind::Up, 318.0, 335.0); + let Some(Action::Preview(_, v)) = acts.first() else { panic!("{tool}: {acts:?}") }; + assert_eq!(v["closed"], json!(within > 0.0), "{tool}: the release does what the cursor showed"); + assert_eq!(after, if tool == "pencil" { Cursor::PencilNew } else { own }, "{tool} after the release"); + } + } + // A straight Pencil line (Shift or Alt held at the press) stays open, so it shows no ring + // near its start. + for held in [Mods { shift: true, ..Mods::default() }, Mods { alt: true, ..Mods::default() }] { + let mut t = GestureTool::new("pencil"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 310.0, 330.0).with_mods(held)); + for (x, y) in [(360.0, 330.0), (330.0, 390.0), (318.0, 335.0)] { + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x, y).with_mods(held)); + } + for m in [held, none] { + assert_eq!(t.cursor(&cx, Point::new(318.0, 335.0), m), Cursor::Pencil, "a straight line, {held:?}, {m:?}"); + } + } +} + +/// A Paintbrush sample under a pixel from the last point is dropped, and a release there keeps +/// the last point: the closing cursor follows that point, not the pointer. +#[test] +fn the_paintbrush_closing_cursor_follows_the_point_a_release_keeps() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let none = Mods::default(); + let mut t = GestureTool::new("paintbrush"); + t.set_option("closeWithin", &json!(15.0)); + for (k, x, y) in [(PointerKind::Down, 310.0, 330.0), (PointerKind::Drag, 360.0, 350.0), (PointerKind::Drag, 330.0, 390.0)] { + t.pointer(&cx, &PointerEvent::new(k, x, y)); + } + // The last point kept is 15.5 from the start, outside Close Paths When Ends Are Within. + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 325.5, 330.0)); + assert_eq!(t.cursor(&cx, Point::new(325.5, 330.0), none), Cursor::Paintbrush, "outside the closing distance"); + // The pointer moves 0.7 to 14.8 from the start; the sample is dropped. + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 324.8, 330.0)); + assert_eq!(t.cursor(&cx, Point::new(324.8, 330.0), none), Cursor::Paintbrush, "the dropped sample shows no ring"); + let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 324.8, 330.0)); + let Some(Action::Preview(_, v)) = acts.first() else { panic!("{acts:?}") }; + assert_eq!(v["closed"], json!(false), "the release keeps the last point and leaves the path open"); +} + +#[test] +fn eraser_and_knife_exec_on_release() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = GestureTool::new("eraser"); + let acts = drag(&mut t, &cx, &[(50.0, 150.0), (250.0, 150.0)], Mods::default()); + assert!(acts[1].is_empty()); + assert!(matches!(&acts[2][0], Action::Exec(c, v) if c == "path.eraseRegion" && v["size"] == 10.0)); + let mut k = GestureTool::new("knife"); + k.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 150.0)); + k.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 150.0)); + assert_eq!(k.overlays(&cx).len(), 1); + let a = k.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 150.0)); + assert!(matches!(&a[0], Action::Exec(c, v) if c == "path.knife" && v["points"].as_array().unwrap().len() == 3)); +} + +/// The Eraser's Angle and Roundness (Eraser Tool Options) go with its stroke, kept within +/// −180–180° and 1–100%; while it drags, a tip that is not round shows the outline of the area +/// it covers, and a round one the drag as wide as the tip. +#[test] +fn the_eraser_sends_its_tip_and_shows_the_area_it_covers() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = GestureTool::new("eraser"); + t.set_option("size", &json!(20)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 150.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 150.0)); + let round = t.overlays(&cx); + assert!(matches!(&round[..], [Overlay::Path { width, .. }] if *width == 20.0), "{round:?}"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 150.0)); + for (angle, roundness, sent) in [(90.0, 25.0, (90.0, 25.0)), (400.0, 0.0, (180.0, 1.0)), (-400.0, 250.0, (-180.0, 100.0))] { + t.set_option("angle", &json!(angle)); + t.set_option("roundness", &json!(roundness)); + assert_eq!((t.options()["angle"].as_f64(), t.options()["roundness"].as_f64()), (Some(sent.0), Some(sent.1))); + let acts = drag(&mut t, &cx, &[(50.0, 150.0), (250.0, 150.0)], Mods::default()); + assert!( + matches!(&acts[2][0], Action::Exec(c, v) if c == "path.eraseRegion" && v["size"] == 20.0 && v["angle"] == sent.0 && v["roundness"] == sent.1), + "{acts:?}" + ); + } + // A tip 20 pt long and 5 pt wide, standing upright: along a horizontal drag from x 50 to + // x 150 the outline reaches 2.5 pt either side of the drag and 10 pt above and below + // its ends. + t.set_option("angle", &json!(90)); + t.set_option("roundness", &json!(25)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 150.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 150.0)); + let o = t.overlays(&cx); + let [Overlay::Path { path, width, .. }] = &o[..] else { panic!("{o:?}") }; + assert_eq!(*width, 1.0); + let b = vectorcraft_geom::Shape::bounding_box(path); + assert!((b.x0 - 47.5).abs() < 0.1 && (b.x1 - 152.5).abs() < 0.1, "{b:?}"); + assert!((b.y0 - 140.0).abs() < 0.1 && (b.y1 - 160.0).abs() < 0.1, "{b:?}"); +} + +#[test] +fn smooth_needs_selection_and_brackets_resize() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = GestureTool::new("smooth"); + let acts = drag(&mut t, &cx, &[(50.0, 150.0), (250.0, 150.0)], Mods::default()); + assert!(acts.iter().all(|a| a.is_empty())); + let mut b = GestureTool::new("blobBrush"); + b.key(&cx, ToolKey::BracketRight, Mods::default()); + assert_eq!(b.options()["size"], 11.0); +} + +#[test] +fn pencil_alt_toggles_to_smooth_when_enabled_and_closes_when_disabled() { + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.set([id]); + let p = paint(); + let cx = cx(&d, &s, &p); + + // Off by default, as Pencil Tool Options ships it; on, Alt lends the Smooth tool. + let mut t = GestureTool::new("pencil"); + assert_eq!(t.options()["altSmooth"], json!(false)); + t.set_option("altSmooth", &json!(true)); + + // When alt is pressed, cursor shows smooth tool. + let alt_mod = Mods { alt: true, ..Default::default() }; + assert_eq!(t.cursor(&cx, Point::new(50.0, 50.0), alt_mod), Cursor::Smooth); + assert_eq!(t.cursor(&cx, Point::new(50.0, 50.0), Mods::default()), Cursor::PencilNew); + + // Alt-drag over selected path smooths it. + let acts = drag_held(&mut t, &cx, &[(50.0, 150.0), (150.0, 150.0), (250.0, 150.0)], alt_mod); + assert!(acts[1].is_empty(), "drag does not preview freehand path while smoothing"); + let last = acts.last().unwrap(); + assert!( + matches!(&last[0], Action::Exec(c, v) if c == "path.smoothRegion" && v["points"].as_array().unwrap().len() == 3), + "expected path.smoothRegion, got: {last:?}" + ); + + // When altSmooth is disabled: + t.set_option("altSmooth", &json!(false)); + assert_eq!(t.options()["altSmooth"], json!(false)); + // Before a press, Alt gives a straight line rather than a closed path. + assert_eq!(t.cursor(&cx, Point::new(50.0, 50.0), alt_mod), Cursor::PencilStraight); + + // A drag released with Alt held draws a closed pencil path. + let empty_sel = Selection::default(); + let cx_empty = crate::testutil::cx(&d, &empty_sel, &p); + let acts = drag(&mut t, &cx_empty, &[(0.0, 0.0), (10.0, 5.0), (20.0, 0.0)], alt_mod); + assert_eq!(acts[1][0], Action::Begin("Pencil".into())); + let last = acts.last().unwrap(); + assert!(matches!(&last[0], Action::Preview(_, v) if v["closed"] == true && v["style"] == "pencil")); + assert_eq!(last[1], Action::Commit); +}