diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index 737f19983..77a96e8b2 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -1076,1272 +1076,4 @@ impl DirectSelectionTool { } #[cfg(test)] -mod tests { - use super::*; - use crate::testutil::*; - use vectorcraft_doc::Selection; - - /// #414: a ruler guide is picked over the art, but an anchor on it is picked first. - #[test] - fn direct_and_group_selection_pick_ruler_guides() { - let (mut d, id) = doc_with_rect(); - d.guides.push(vectorcraft_doc::Guide::new(true, 100.0)); - let (s, p) = (Selection::default(), paint()); - let c = cx(&d, &s, &p); - let down = |t: &mut DirectSelectionTool, y: f64| t.pointer(&c, &PointerEvent::new(PointerKind::Down, 100.0, y)); - let pick = vec![Action::Exec("guide.select".into(), json!({"indexes": [0]}))]; - for group in [false, true] { - let mut t = DirectSelectionTool::new(group); - assert_eq!(down(&mut t, 150.0), pick, "on the rect's edge (group: {group})"); - assert!(t.busy()); - t.pointer(&c, &PointerEvent::new(PointerKind::Up, 100.0, 150.0)); - assert_eq!(t.cursor(&c, Point::new(101.0, 300.0), Mods::default()), Cursor::ResizeH); - } - let mut t = DirectSelectionTool::new(false); - assert_eq!(down(&mut t, 100.0), vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "set"}))]); - } - - /// A Cmd/Ctrl+Shift double-click with Direct Selection or Group Selection releases the ruler - /// guide or the guide path under the pointer; a plain double-click on a ruler guide, or one while - /// guides are hidden or locked, releases nothing. - #[test] - fn a_cmd_shift_double_click_releases_the_guide_under_the_pointer() { - let (mut d, id) = doc_with_rect(); - d.guides.push(vectorcraft_doc::Guide::new(false, 300.0)); - let Some(NodeKind::Path { guide, .. }) = d.node_mut(id).map(|n| &mut n.kind) else { panic!("the rectangle is a path") }; - *guide = true; - let (s, p) = (Selection::default(), paint()); - let both = Mods { cmd: true, shift: true, ..Mods::default() }; - let index = vec![Action::Exec("view.guides.release".into(), json!({"index": 0}))]; - let ids = vec![Action::Exec("view.guides.release".into(), json!({"ids": [id.0]}))]; - let locked = ToolContext { guides: false, ..cx(&d, &s, &p) }; - for group in [false, true] { - let dbl = |c: &ToolContext, x: f64, y: f64, m: Mods| { - DirectSelectionTool::new(group).pointer(c, &PointerEvent::new(PointerKind::DoubleClick, x, y).with_mods(m)) - }; - let c = cx(&d, &s, &p); - assert_eq!(dbl(&c, 350.0, 301.0, both), index, "on the ruler guide (group: {group})"); - assert_eq!(dbl(&c, 200.0, 150.0, both), ids, "on the guide path (group: {group})"); - assert!(dbl(&c, 350.0, 301.0, Mods::default()).is_empty(), "a plain double-click (group: {group})"); - assert!(dbl(&c, 350.0, 301.0, Mods { cmd: true, ..Mods::default() }).is_empty(), "Cmd/Ctrl alone (group: {group})"); - assert!(dbl(&locked, 350.0, 301.0, both).is_empty(), "guides locked (group: {group})"); - assert!(dbl(&locked, 200.0, 150.0, both).is_empty(), "guides locked (group: {group})"); - } - } - - /// An anchor is picked from 2 px past its drawn square even where its segment is nearer, and - /// further with a larger anchor Size or Tolerance (#593); away from it the segment is. - #[test] - fn anchors_are_picked_before_the_segments_through_them() { - let (d, id) = doc_with_rect(); - let (s, p) = (Selection::default(), paint()); - let corner = vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "set"}))]; - let press = |cx: &ToolContext, x: f64, y: f64| DirectSelectionTool::new(false).pointer(cx, &PointerEvent::new(PointerKind::Down, x, y)); - let c = cx(&d, &s, &p); - // 4.3 px from the corner, 0.5 px from the top edge. - assert_eq!(press(&c, 104.2, 100.5), corner); - assert_ne!(press(&c, 110.0, 100.5), corner, "the segment, away from the anchor"); - assert_ne!(press(&c, 105.0, 100.5), corner, "past the default reach (4.5 px)"); - let large = ToolContext { anchor_size: 7, ..cx(&d, &s, &p) }; - assert_eq!(press(&large, 106.0, 100.5), corner, "Size 7 draws them 9 px wide"); - let loose = ToolContext { selection_tolerance: 8.0, ..cx(&d, &s, &p) }; - assert_eq!(press(&loose, 107.5, 100.5), corner); - // On screen: at 200% the reach is half as far in the document. - let zoomed = ToolContext { zoom: 2.0, ..cx(&d, &s, &p) }; - assert_eq!(press(&zoomed, 102.1, 100.2), corner); - assert_ne!(press(&zoomed, 102.6, 100.2), corner); - } - - /// A press on a clip group's clipping path, where the art it clips lies over its edge, takes - /// the clipping path's segment, so the mask can be reshaped on the canvas; inside the mask the - /// art is taken, as before. - #[test] - fn a_press_on_a_clipping_path_takes_its_segment_over_the_art() { - let (d, _, clip, art) = doc_with_masked_square(); - let (s, p) = (Selection::default(), paint()); - let c = cx(&d, &s, &p); - let press = |x: f64, y: f64| DirectSelectionTool::new(false).pointer(&c, &PointerEvent::new(PointerKind::Down, x, y)); - let names = |actions: &[Action], id: NodeId| { - actions.iter().any(|a| matches!(a, Action::Exec(_, v) if v["id"] == json!(id.0) || v["ids"] == json!([id.0]))) - }; - // The middle of the right edge, on it and just inside it. - for x in [500.0, 498.5] { - let a = press(x, 450.0); - assert!(names(&a, clip), "at {x}: {a:?}"); - } - let a = press(450.0, 450.0); - assert!(names(&a, art) && !names(&a, clip), "{a:?}"); - } - - /// A blend of two 20 pt squares centred on (110, 310) and (210, 310), selected. - fn blend_doc() -> (vectorcraft_doc::Document, NodeId, NodeId) { - let (mut d, _) = doc_with_rect(); - let l = d.layers[0].id; - let key = |id: NodeId, x: f64| { - let r = vectorcraft_geom::shapes::rectangle(Rect::new(x, 300.0, x + 20.0, 320.0)); - std::sync::Arc::new(vectorcraft_doc::Node::path(id, r, vectorcraft_doc::Appearance::default_art())) - }; - let (g, k1, k2) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); - let b = vectorcraft_doc::Node::new(g, NodeKind::Blend { children: vec![key(k1, 100.0), key(k2, 200.0)], spec: Default::default() }); - d.insert(Some(l), 1, b).unwrap(); - (d, g, k1) - } - - /// Highlight anchors on mouse over (#394): the anchor under the pointer, within the selection - /// tolerance, shows enlarged; none with the preference off or away from anchors. - #[test] - fn the_anchor_under_the_pointer_is_highlighted() { - let (d, id) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let big = |t: &DirectSelectionTool, cx: &ToolContext| { - t.overlays(cx).into_iter().find_map(|o| match o { - Overlay::Anchor { p, size, .. } if size > 5.0 => Some(p), - _ => None, - }) - }; - let mut t = DirectSelectionTool::new(false); - let on = cx(&d, &s, &p); - t.pointer(&on, &PointerEvent::new(PointerKind::Move, 102.0, 101.0)); - assert_eq!(big(&t, &on), Some(Point::new(100.0, 100.0)), "the corner of {id:?}"); - t.pointer(&on, &PointerEvent::new(PointerKind::Move, 150.0, 100.0)); - assert_eq!(big(&t, &on), None, "mid-segment: no anchor"); - let off = ToolContext { highlight_anchors: false, ..cx(&d, &s, &p) }; - t.pointer(&off, &PointerEvent::new(PointerKind::Move, 102.0, 101.0)); - assert_eq!(big(&t, &off), None, "off"); - } - - #[test] - fn dragging_a_spine_point_moves_it() { - let (d, g, _) = blend_doc(); - let mut s = Selection::default(); - s.set([g]); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = DirectSelectionTool::new(false); - assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 211.0, 310.0)), vec![]); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 221.0, 350.0)); - assert_eq!(a[0], Action::Begin("Reshape Spine".into())); - assert_eq!(a[1], Action::Preview("object.blend.spine.moveAnchor".into(), json!({"id": g.0, "anchor": 1, "x": 220.0, "y": 350.0}))); - assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 221.0, 350.0)), vec![Action::Commit]); - assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Anchor { filled: true, .. })), "the clicked point shows"); - } - - /// #1111: the Anchor Point tool bends a selected blend's straight spine; Direct Selection - /// reshapes a curved spine segment and leaves a straight one alone, as it does a path's. - #[test] - fn spine_segments_bend_with_the_anchor_point_tool_and_direct_selection() { - let (mut d, g, _) = blend_doc(); - let mut s = Selection::default(); - s.set([g]); - let p = paint(); - let drag = |t: &mut dyn crate::Tool, cx: &ToolContext| { - t.pointer(cx, &PointerEvent::new(PointerKind::Down, 160.0, 311.0)); - let a = t.pointer(cx, &PointerEvent::new(PointerKind::Drag, 160.0, 351.0)); - t.pointer(cx, &PointerEvent::new(PointerKind::Up, 160.0, 351.0)); - a - }; - let straight = cx(&d, &s, &p); - let a = drag(crate::create("anchorPoint").as_mut(), &straight); - assert!( - matches!(a.last(), Some(Action::Preview(c, v)) if c == "object.blend.spine.reshapeSegment" && v["id"] == g.0 && v["segment"] == 0 && v["dy"] == 40.0), - "{a:?}" - ); - let a = drag(&mut DirectSelectionTool::new(false), &straight); - assert!(!a.iter().any(|x| matches!(x, Action::Preview(c, _) if c == "object.blend.spine.reshapeSegment")), "straight: {a:?}"); - // The spine bent upward: Direct Selection takes it from where it passes. - let mut sp = vectorcraft_geom::SubPath::polyline(&[Point::new(110.0, 310.0), Point::new(210.0, 310.0)], false); - sp.bend_segment(0, 0.5, vectorcraft_geom::Vec2::new(0.0, 20.0)); - let Some(NodeKind::Blend { spec, .. }) = d.node_mut(g).map(|n| &mut n.kind) else { panic!("blend") }; - (spec.spine, spec.key_anchors) = (Some(PathData::single(sp)), vec![0, 1]); - let mut t = DirectSelectionTool::new(false); - let curved = cx(&d, &s, &p); - t.pointer(&curved, &PointerEvent::new(PointerKind::Down, 160.0, 330.0)); - let a = t.pointer(&curved, &PointerEvent::new(PointerKind::Drag, 160.0, 345.0)); - assert_eq!(a[0], Action::Begin("Reshape Spine".into())); - assert!( - matches!(&a[1], Action::Preview(c, v) if c == "object.blend.spine.reshapeSegment" && v["segment"] == 0 && v["dx"] == 0.0 && v["dy"] == 15.0), - "{a:?}" - ); - assert_eq!(t.pointer(&curved, &PointerEvent::new(PointerKind::Up, 160.0, 345.0)), vec![Action::Commit]); - } - - /// Shift held while a curved segment is dragged sends `perpendicular`. The test drags a path's - /// segment with Direct Selection, and a blend spine's segment with Direct Selection and with the - /// Anchor Point tool. - #[test] - fn shift_reshapes_a_segment_perpendicular() { - let shift = Mods { shift: true, ..Mods::default() }; - let p = paint(); - let drag = |t: &mut dyn crate::Tool, cx: &ToolContext, from: (f64, f64), mods: Mods| { - t.pointer(cx, &PointerEvent::new(PointerKind::Down, from.0, from.1)); - let a = t.pointer(cx, &PointerEvent::new(PointerKind::Drag, from.0 + 10.0, from.1 - 20.0).with_mods(mods)); - t.pointer(cx, &PointerEvent::new(PointerKind::Up, from.0 + 10.0, from.1 - 20.0).with_mods(mods)); - a - }; - let sent = |a: &[Action], cmd: &str| match a.last() { - Some(Action::Preview(c, v)) if c == cmd => v["perpendicular"].as_bool(), - _ => None, - }; - let (d, _) = arch_doc(); - let s = Selection::default(); - let arch = cx(&d, &s, &p); - let (mut b, g, _) = blend_doc(); - let mut sp = vectorcraft_geom::SubPath::polyline(&[Point::new(110.0, 310.0), Point::new(210.0, 310.0)], false); - sp.bend_segment(0, 0.5, vectorcraft_geom::Vec2::new(0.0, 20.0)); - let Some(NodeKind::Blend { spec, .. }) = b.node_mut(g).map(|n| &mut n.kind) else { panic!("blend") }; - (spec.spine, spec.key_anchors) = (Some(PathData::single(sp)), vec![0, 1]); - let mut bs = Selection::default(); - bs.set([g]); - let spine = cx(&b, &bs, &p); - for (mods, perpendicular) in [(Mods::default(), false), (shift, true)] { - let a = drag(&mut DirectSelectionTool::new(false), &arch, (150.0, 262.5), mods); - assert_eq!(sent(&a, "path.reshapeSegment"), Some(perpendicular), "{a:?}"); - let a = drag(&mut DirectSelectionTool::new(false), &spine, (160.0, 330.0), mods); - assert_eq!(sent(&a, "object.blend.spine.reshapeSegment"), Some(perpendicular), "{a:?}"); - let a = drag(crate::create("anchorPoint").as_mut(), &spine, (160.0, 330.0), mods); - assert_eq!(sent(&a, "object.blend.spine.reshapeSegment"), Some(perpendicular), "{a:?}"); - } - } - - #[test] - fn direct_and_group_selection_pick_blend_keys() { - let (d, g, k1) = blend_doc(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let a = DirectSelectionTool::new(false).pointer(&cx, &PointerEvent::new(PointerKind::Down, 104.0, 304.0)); - assert_eq!(a, vec![Action::Exec("select.set".into(), json!({"ids": [k1.0]}))]); - let a = DirectSelectionTool::new(true).pointer(&cx, &PointerEvent::new(PointerKind::Down, 104.0, 304.0)); - assert_eq!(a, vec![Action::Exec("select.set".into(), json!({"ids": [k1.0]}))]); - // A click on a step (between the keys) still selects the blend. - let a = DirectSelectionTool::new(true).pointer(&cx, &PointerEvent::new(PointerKind::Down, 160.0, 304.0)); - assert_eq!(a, vec![Action::Exec("select.set".into(), json!({"ids": [g.0]}))]); - } - - #[test] - fn dragging_a_frame_corner_or_edge_reshapes_area_type() { - let (d, text) = doc_with_area_type(); - let p = paint(); - let s = Selection::default(); - let cx1 = cx(&d, &s, &p); - let mut t = DirectSelectionTool::new(false); - // The bottom-right corner of the 120 × 40 frame at (300, 300) selects the text... - assert_eq!( - t.pointer(&cx1, &PointerEvent::new(PointerKind::Down, 421.0, 339.0)), - vec![Action::Exec("select.set".into(), json!({"ids": [text.0]}))] - ); - // ...and drags that corner. - let a = t.pointer(&cx1, &PointerEvent::new(PointerKind::Drag, 461.0, 399.0)); - assert_eq!(a[0], Action::Begin("Reshape Type Area".into())); - assert_eq!(a[1], Action::Preview("text.reshapeArea".into(), json!({"id": text.0, "anchors": [[0, 2]], "dx": 40.0, "dy": 60.0}))); - assert_eq!(t.pointer(&cx1, &PointerEvent::new(PointerKind::Up, 461.0, 399.0)), vec![Action::Commit]); - // The top edge drags both its ends; a click alone changes nothing. - let mut s = Selection::default(); - s.add(text); - let cx2 = cx(&d, &s, &p); - assert!(t.pointer(&cx2, &PointerEvent::new(PointerKind::Down, 350.0, 301.0)).is_empty()); - let a = t.pointer(&cx2, &PointerEvent::new(PointerKind::Drag, 350.0, 281.0)); - assert_eq!(a[1], Action::Preview("text.reshapeArea".into(), json!({"id": text.0, "anchors": [[0, 0], [0, 1]], "dx": 0.0, "dy": -20.0}))); - t.pointer(&cx2, &PointerEvent::new(PointerKind::Up, 350.0, 281.0)); - assert!(t.pointer(&cx2, &PointerEvent::new(PointerKind::Down, 300.0, 340.0)).is_empty()); - assert!(t.pointer(&cx2, &PointerEvent::new(PointerKind::Up, 300.0, 340.0)).is_empty()); - // The selected frame's corners show. - assert_eq!(t.overlays(&cx2).iter().filter(|o| matches!(o, Overlay::Anchor { filled: false, .. })).count(), 4); - } - - /// #3980: type on a path shows its path's anchors and a drag of one reshapes the path, the last - /// one too although the end bracket stands on it; the bracket's stem above it drags the bracket. - #[test] - fn dragging_an_anchor_of_type_on_a_path_reshapes_its_path() { - let (d, text) = doc_with_path_type(); - let p = paint(); - let s = Selection::default(); - let cx1 = cx(&d, &s, &p); - let mut t = DirectSelectionTool::new(false); - assert_eq!( - t.pointer(&cx1, &PointerEvent::new(PointerKind::Down, 101.0, 301.0)), - vec![Action::Exec("select.set".into(), json!({"ids": [text.0]}))] - ); - let a = t.pointer(&cx1, &PointerEvent::new(PointerKind::Drag, 121.0, 261.0)); - assert_eq!(a[0], Action::Begin("Reshape Type Path".into())); - assert_eq!(a[1], Action::Preview("text.reshapePath".into(), json!({"id": text.0, "anchors": [[0, 0]], "dx": 20.0, "dy": -40.0}))); - assert_eq!(t.pointer(&cx1, &PointerEvent::new(PointerKind::Up, 121.0, 261.0)), vec![Action::Commit]); - let mut s = Selection::default(); - s.add(text); - let cx2 = cx(&d, &s, &p); - let anchors: Vec = - t.overlays(&cx2).iter().filter_map(|o| if let Overlay::Anchor { p, filled: false, .. } = o { Some(*p) } else { None }).collect(); - assert_eq!(anchors, vec![Point::new(100.0, 300.0), Point::new(400.0, 300.0)]); - assert!(t.pointer(&cx2, &PointerEvent::new(PointerKind::Down, 400.0, 300.0)).is_empty()); - let a = t.pointer(&cx2, &PointerEvent::new(PointerKind::Drag, 400.0, 260.0)); - assert_eq!(a.last(), Some(&Action::Preview("text.reshapePath".into(), json!({"id": text.0, "anchors": [[0, 1]], "dx": 0.0, "dy": -40.0})))); - t.pointer(&cx2, &PointerEvent::new(PointerKind::Up, 400.0, 260.0)); - t.pointer(&cx2, &PointerEvent::new(PointerKind::Down, 400.0, 290.0)); - let a = t.pointer(&cx2, &PointerEvent::new(PointerKind::Drag, 340.0, 290.0)); - assert!(matches!(a.last(), Some(Action::Preview(c, _)) if c == "type.pathOptions"), "{a:?}"); - } - - #[test] - fn click_anchor_selects_it() { - let (d, id) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = DirectSelectionTool::new(false); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); - assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "set"}))]); - } - - /// Pressing an anchor of a path whose anchors are all selected (stored as the whole path) - /// keeps them all selected, so the drag moves them all; Shift still deselects that anchor. - #[test] - fn pressing_an_anchor_of_a_fully_selected_path_keeps_the_selection() { - let (d, id) = doc_with_rect(); - let mut s = Selection::default(); - s.set([id]); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = DirectSelectionTool::new(false); - assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)).is_empty()); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 120.0, 100.0)); - assert!(matches!(a.as_slice(), [Action::Begin(_), Action::Preview(c, _)] if c == "object.transform"), "{a:?}"); - t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 120.0, 100.0)); - let shift = Mods { shift: true, ..Default::default() }; - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0).with_mods(shift)); - assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "toggle"}))]); - } - - /// A click (no drag) on an anchor that is selected with others, whether the whole path is or - /// some of its anchors, selects that anchor alone, as in Illustrator: a press keeps the - /// selection so a drag moves it all, the release without a drag narrows it. Shift still - /// toggles, and a click on a path's only selected anchor changes nothing visible. - #[test] - fn a_click_on_a_selected_anchor_selects_it_alone() { - let (d, id) = doc_with_rect(); - let p = paint(); - let alone = vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "set"}))]; - let mut whole = Selection::default(); - whole.set([id]); - for s in [whole, anchors_of(id, &[(0, 0), (0, 1)])] { - let cx = cx(&d, &s, &p); - let mut t = DirectSelectionTool::new(false); - assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)).is_empty(), "{s:?}"); - assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 100.0, 100.0)), alone, "{s:?}"); - // Dragged instead: moved, selection kept. - assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)).is_empty()); - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 120.0, 100.0)); - assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 120.0, 100.0)), vec![Action::Commit], "{s:?}"); - } - // An anchor not yet selected is selected on the press, and the release adds nothing. - let none = Selection::default(); - let cx = cx(&d, &none, &p); - let mut t = DirectSelectionTool::new(false); - assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)), alone); - assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 100.0, 100.0)).is_empty()); - } - - /// Alt-pressing an anchor picks the whole path, as Alt on a segment does, and the drag copies - /// it as the Selection tool's Alt-drag does. - #[test] - fn alt_dragging_an_anchor_copies_the_whole_path() { - let (d, id) = doc_with_rect(); - let alt = Mods { alt: true, ..Default::default() }; - let mut whole = Selection::default(); - whole.set([id]); - let p = paint(); - for (s, pick) in [(Selection::default(), true), (anchors_of(id, &[(0, 0)]), true), (whole, false)] { - let cx = cx(&d, &s, &p); - let mut t = DirectSelectionTool::new(false); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0).with_mods(alt)); - let set = vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]; - assert_eq!(a, if pick { set } else { vec![] }, "{s:?}"); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 120.0, 100.0).with_mods(alt)); - assert!(matches!(a.as_slice(), [Action::Begin(_), Action::Preview(c, v)] if c == "object.transform" && v["copy"] == true), "{a:?}"); - } - } - - #[test] - fn marquee_selects_anchors() { - let (d, id) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = DirectSelectionTool::new(false); - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 50.0)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 250.0, 150.0)); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 150.0)); - assert_eq!(a, vec![Action::Exec("select.anchorsMany".into(), json!({"items": [{"id": id.0, "anchors": [[0, 1]]}], "mode": "set"}))]); - } - - #[test] - fn marquee_selects_ruler_guides() { - let (mut d, id) = doc_with_rect(); - d.guides.push(vectorcraft_doc::Guide::new(false, 80.0)); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = DirectSelectionTool::new(false); - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 50.0)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 250.0, 150.0)); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 150.0)); - assert_eq!( - a, - vec![ - Action::Exec("select.anchorsMany".into(), json!({"items": [{"id": id.0, "anchors": [[0, 1]]}], "mode": "set"})), - Action::Exec("guide.select".into(), json!({"indexes": [0], "toggle": true})), - ] - ); - } - - /// #2441: Group Selection picks one path of a compound path (the outer shape or a hole); - /// a second click on it adds the compound path, like a group around a member. - #[test] - fn group_selection_picks_a_path_of_a_compound_path() { - let mut d = vectorcraft_doc::Document::new(500.0, 500.0); - let l = d.layers[0].id; - let (outer, hole, compound) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); - let rect = |id, a: f64, b: f64| { - std::sync::Arc::new(vectorcraft_doc::Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(a, a, b, b)), Default::default())) - }; - let mut c = vectorcraft_doc::Node::new( - compound, - vectorcraft_doc::NodeKind::Compound { - children: vec![rect(outer, 0.0, 200.0), rect(hole, 50.0, 150.0)], - rule: vectorcraft_geom::FillRule::EvenOdd, - }, - ); - c.appearance = vectorcraft_doc::Appearance::default_art().into(); - d.insert(Some(l), 0, c).unwrap(); - let p = paint(); - let none = Selection::default(); - let click = |s: &Selection, x, y| DirectSelectionTool::new(true).pointer(&cx(&d, s, &p), &PointerEvent::new(PointerKind::Down, x, y)); - let set = |id: NodeId| vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]; - assert_eq!(click(&none, 25.0, 100.0), set(outer), "the fill between the outline and the hole"); - assert_eq!(click(&none, 50.0, 100.0), set(hole), "the hole's outline"); - assert!(click(&none, 100.0, 100.0).is_empty(), "inside the hole nothing is hit"); - let mut s = Selection::default(); - s.set([outer]); - assert_eq!(click(&s, 25.0, 100.0), vec![Action::Exec("select.add".into(), json!({"ids": [compound.0]}))]); - } - - /// Group Selection Shift-click on a selected compound-path member deselects it on release. - #[test] - fn group_selection_shift_click_deselects_a_compound_path_member() { - let mut d = vectorcraft_doc::Document::new(500.0, 500.0); - let l = d.layers[0].id; - let (outer, hole, compound) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); - let rect = |id, a: f64, b: f64| { - std::sync::Arc::new(vectorcraft_doc::Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(a, a, b, b)), Default::default())) - }; - let mut c = vectorcraft_doc::Node::new( - compound, - vectorcraft_doc::NodeKind::Compound { - children: vec![rect(outer, 0.0, 200.0), rect(hole, 50.0, 150.0)], - rule: vectorcraft_geom::FillRule::EvenOdd, - }, - ); - c.appearance = vectorcraft_doc::Appearance::default_art().into(); - d.insert(Some(l), 0, c).unwrap(); - let p = paint(); - let mut s = Selection::default(); - s.set([outer]); - let shift = Mods { shift: true, ..Mods::default() }; - let cx = cx(&d, &s, &p); - let mut t = DirectSelectionTool::new(true); - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 25.0, 100.0).with_mods(shift)); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 25.0, 100.0).with_mods(shift)); - assert_eq!(a, vec![Action::Exec("select.toggle".into(), json!({"id": outer.0}))]); - } - - /// A path inside an inner group, a sibling path outside it, and the groups' ids. - /// The inside path is the rectangle at (100, 100); the outside one at (300, 300). - fn nested_group_doc() -> (vectorcraft_doc::Document, NodeId, NodeId, NodeId, NodeId) { - let mut d = vectorcraft_doc::Document::new(500.0, 500.0); - let l = d.layers[0].id; - let (inside, outside, inner, outer) = (d.alloc_id(), d.alloc_id(), d.alloc_id(), d.alloc_id()); - let path = |id, x| { - std::sync::Arc::new(vectorcraft_doc::Node::path( - id, - vectorcraft_geom::shapes::rectangle(Rect::new(x, x, x + 100.0, x + 100.0)), - vectorcraft_doc::Appearance::default_art(), - )) - }; - let inner_g = vectorcraft_doc::Node::group(inner, vec![path(inside, 100.0)]); - let outer_g = vectorcraft_doc::Node::group(outer, vec![std::sync::Arc::new(inner_g), path(outside, 300.0)]); - d.insert(Some(l), 0, outer_g).unwrap(); - (d, inside, outside, inner, outer) - } - - /// Isolation mode: a marquee and an anchor click take the path inside the isolated group and - /// leave the path beside it. Group Selection stops at the isolated group instead of adding - /// the group around it. - #[test] - fn isolation_mode_leaves_art_outside_the_isolated_group() { - let (d, inside, outside, inner, outer) = nested_group_doc(); - let p = paint(); - let none = Selection::default(); - let isolated = ToolContext { isolation: Some(inner), ..cx(&d, &none, &p) }; - let mut t = DirectSelectionTool::new(false); - t.pointer(&isolated, &PointerEvent::new(PointerKind::Down, 50.0, 50.0)); - let a = t.pointer(&isolated, &PointerEvent::new(PointerKind::Up, 450.0, 450.0)); - let items = a.first().and_then(|act| match act { - Action::Exec(c, v) if c == "select.anchorsMany" => v.get("items").cloned(), - _ => None, - }); - let items = items.unwrap(); - assert!(items.as_array().is_some_and(|a| a.iter().all(|item| item["id"] == inside.0)), "marquee: {a:?}"); - assert!(items.as_array().is_some_and(|a| !a.is_empty()), "the path inside is still selected: {a:?}"); - - let a = DirectSelectionTool::new(false).pointer(&isolated, &PointerEvent::new(PointerKind::Down, 300.0, 300.0)); - assert!(a.is_empty(), "a click on {outside:?}, outside isolation, starts a marquee: {a:?}"); - let a = DirectSelectionTool::new(false).pointer(&isolated, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); - assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": inside.0, "anchors": [[0, 0]], "mode": "set"}))]); - - let mut s = Selection::default(); - s.set([inside, inner]); - let climbed = ToolContext { isolation: Some(inner), ..cx(&d, &s, &p) }; - let a = DirectSelectionTool::new(true).pointer(&climbed, &PointerEvent::new(PointerKind::Down, 150.0, 150.0)); - assert!(a.is_empty(), "does not add the group outside isolation ({outer:?}): {a:?}"); - } - - /// #927: a marquee across type selects the type object whole, beside the anchors it holds. - #[test] - fn marquee_selects_type_it_touches() { - // The rectangle at (100, 100)–(200, 200) and area type at (300, 300). - let (d, text) = doc_with_area_type(); - let rect = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = DirectSelectionTool::new(false); - // From empty canvas across the type to inside the rectangle (its bottom-right corner). - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 450.0, 450.0)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 150.0)); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 150.0)); - let items = json!([{"id": rect.0, "anchors": [[0, 2]]}, {"id": text.0, "anchors": []}]); - assert_eq!(a, vec![Action::Exec("select.anchorsMany".into(), json!({"items": items, "mode": "set"}))]); - // Away from the type, only anchors. - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 50.0)); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 150.0)); - assert_eq!(a, vec![Action::Exec("select.anchorsMany".into(), json!({"items": [{"id": rect.0, "anchors": [[0, 1]]}], "mode": "set"}))]); - } - - /// A marquee across a member of an Intertwine group selects the member's anchors, as in a - /// plain group, and not the Intertwine group whole. - #[test] - fn marquee_selects_the_anchors_of_intertwine_members() { - let mut d = vectorcraft_doc::Document::new(500.0, 500.0); - let l = d.layers[0].id; - let (a, b, g) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); - let path = |id, x| { - std::sync::Arc::new(vectorcraft_doc::Node::path( - id, - vectorcraft_geom::shapes::rectangle(Rect::new(x, 100.0, x + 100.0, 200.0)), - vectorcraft_doc::Appearance::default_art(), - )) - }; - let intertwine = vectorcraft_doc::Node::new(g, vectorcraft_doc::NodeKind::Intertwine { children: vec![path(a, 100.0), path(b, 300.0)] }); - d.insert(Some(l), 0, intertwine).unwrap(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - for group in [false, true] { - let mut t = DirectSelectionTool::new(group); - // Around the first member's top-right corner. - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 50.0)); - let actions = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 150.0)); - let items = json!([{"id": a.0, "anchors": [[0, 1]]}]); - assert_eq!(actions, vec![Action::Exec("select.anchorsMany".into(), json!({"items": items, "mode": "set"}))], "group: {group}"); - } - } - - /// Shift-drag a marquee: the anchors inside toggle (#483), with Group Selection too. - #[test] - fn shift_marquee_toggles_anchors() { - let (d, id) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let shift = Mods { shift: true, ..Default::default() }; - for group in [false, true] { - let mut t = DirectSelectionTool::new(group); - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 50.0).with_mods(shift)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 250.0, 150.0).with_mods(shift)); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 150.0).with_mods(shift)); - assert_eq!(a, vec![Action::Exec("select.anchorsMany".into(), json!({"items": [{"id": id.0, "anchors": [[0, 1]]}], "mode": "toggle"}))]); - } - } - - /// Group Selection: E pressed during a marquee drag makes it select the objects entirely inside - /// it whole, a group's member without its group. Direct Selection does not take E. - #[test] - fn e_during_a_group_selection_marquee_selects_the_objects_inside() { - use vectorcraft_doc::{Appearance, Node}; - let (mut d, id) = doc_with_rect(); - let l = d.layers[0].id; - let rect = |id, r| Node::path(id, vectorcraft_geom::shapes::rectangle(r), Appearance::default_art()); - let (g, a, b) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); - d.insert(Some(l), 1, Node::group(g, vec![])).unwrap(); - d.insert(Some(g), 0, rect(a, Rect::new(100.0, 220.0, 120.0, 240.0))).unwrap(); - d.insert(Some(g), 1, rect(b, Rect::new(100.0, 300.0, 120.0, 320.0))).unwrap(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let e = ToolKey::Letter('e'); - let drag = |group: bool, mods: Mods| { - let mut t = DirectSelectionTool::new(group); - assert!(!t.claims_key(&cx, e), "no marquee yet"); - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 50.0).with_mods(mods)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 300.0, 250.0).with_mods(mods)); - assert_eq!(t.claims_key(&cx, e), group); - t.key(&cx, e, Mods::default()); - t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 300.0, 250.0).with_mods(mods)) - }; - assert_eq!(drag(true, Mods::default()), vec![Action::Exec("select.set".into(), json!({"ids": [id.0, a.0]}))]); - let shift = Mods { shift: true, ..Default::default() }; - assert_eq!(drag(true, shift), vec![Action::Exec("select.toggle".into(), json!({"ids": [id.0, a.0]}))]); - // Direct Selection's marquee still selects the anchors inside it. - let anchors = |id: NodeId, list: Value| json!({"id": id.0, "anchors": list}); - let all = json!([[0, 0], [0, 1], [0, 2], [0, 3]]); - assert_eq!( - drag(false, Mods::default()), - vec![Action::Exec("select.anchorsMany".into(), json!({"items": [anchors(id, all.clone()), anchors(a, all)], "mode": "set"}))] - ); - } - - /// An open path from (100, 300) to (200, 300) arching up through (150, 262.5). - fn arch_doc() -> (vectorcraft_doc::Document, NodeId) { - let (mut d, _) = doc_with_rect(); - let l = d.layers[0].id; - let id = d.alloc_id(); - let mut sp = vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 300.0), Point::new(200.0, 300.0)], false); - sp.anchors[0].h_out = Point::new(100.0, 250.0); - sp.anchors[1].h_in = Point::new(200.0, 250.0); - d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, PathData::single(sp), vectorcraft_doc::Appearance::default_art())).unwrap(); - (d, id) - } - - /// #1743: Escape mid-drag cancels it; the release after it does nothing more. - #[test] - fn escape_cancels_a_drag() { - let (d, _) = arch_doc(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = DirectSelectionTool::new(false); - t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 262.5)); - t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 240.0)); - assert!(t.busy()); - assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel]); - assert!(!t.busy()); - assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 220.0)), vec![]); - assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 220.0)), vec![]); - assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![], "nothing to cancel"); - } - - #[test] - fn dragging_a_curved_segment_reshapes_it_and_a_straight_one_moves_its_anchors() { - let (d, arch) = arch_doc(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = DirectSelectionTool::new(false); - // The arch: its segment gets selected, and the drag bends it. - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 262.5)); - assert_eq!(a, vec![Action::Exec("select.segments".into(), json!({"id": arch.0, "segments": [[0, 0]]}))]); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 242.5)); - assert_eq!(a[0], Action::Begin("Reshape".into())); - let Action::Preview(cmd, v) = &a[1] else { panic!("{a:?}") }; - assert_eq!( - (cmd.as_str(), v["id"].clone(), v["segment"].clone(), v["dx"].clone(), v["dy"].clone()), - ("path.reshapeSegment", json!(arch.0), json!(0), json!(0.0), json!(-20.0)) - ); - assert!((v["t"].as_f64().unwrap() - 0.5).abs() < 0.01, "{v}"); - assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 242.5)), vec![Action::Commit]); - // A straight edge of the rectangle: its two anchors get selected and move. - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 101.0)); - assert!(matches!(&a[..], [Action::Exec(c, v)] if c == "select.segments" && v["segments"] == json!([[0, 0]])), "{a:?}"); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 81.0)); - assert_eq!(a[1], Action::Preview("path.moveAnchors".into(), json!({"dx": 0.0, "dy": -20.0}))); - t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 81.0)); - // Alt on the stroke picks the whole path, and Alt-drag copies it. - let alt = Mods { alt: true, ..Default::default() }; - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 101.0).with_mods(alt)); - assert!(matches!(&a[..], [Action::Exec(c, _)] if c == "select.set"), "{a:?}"); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 81.0).with_mods(alt)); - assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, v)] if c == "object.transform" && v["copy"] == true), "{a:?}"); - t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 81.0).with_mods(alt)); - // The fill still selects the whole path, and a path selected as a whole still moves whole. - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 150.0)); - assert!(matches!(&a[..], [Action::Exec(c, _)] if c == "select.set"), "{a:?}"); - t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 150.0)); - let rect = d.layers[0].children().unwrap()[0].id; - let mut whole = Selection::default(); - whole.set([rect]); - let cx = crate::testutil::cx(&d, &whole, &p); - assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 101.0)), vec![]); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 81.0)); - assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, _)] if c == "object.transform"), "{a:?}"); - } - - /// #494: the handles shown are the handles dragged. A path selected as a whole (the one the - /// Pen just drew) shows all its handles, and they drag; the direct-selected anchors' handles - /// only otherwise; none with Show handles when multiple anchors are selected off and several - /// anchors selected. A handle shorter than the tolerance leaves its anchor to be picked, and - /// the tolerance is the preference's, in screen pixels at any zoom. - #[test] - fn the_handles_shown_are_the_handles_dragged() { - let (d, arch) = arch_doc(); - let p = paint(); - // Does a press at (x, y) take a handle? - let handle = |c: &ToolContext, x: f64, y: f64| { - DirectSelectionTool::new(false).pointer(c, &PointerEvent::new(PointerKind::Down, x, y)) == vec![Action::Begin("Reshape".into())] - }; - let mut whole = Selection::default(); - whole.set([arch]); - let c = cx(&d, &whole, &p); - assert!(handle(&c, 101.0, 251.0), "the first anchor's handle, the path selected whole"); - let mut t = DirectSelectionTool::new(false); - t.pointer(&c, &PointerEvent::new(PointerKind::Down, 101.0, 251.0)); - let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 120.0, 240.0)); - assert!(matches!(&a[..], [Action::Preview(c, v)] if c == "path.setHandle" && v["anchor"] == 0 && v["which"] == "out"), "{a:?}"); - // Only the second anchor direct-selected: the first one's handle is hidden. - let second = anchors_of(arch, &[(0, 1)]); - assert!(!handle(&cx(&d, &second, &p), 101.0, 251.0)); - assert!(handle(&cx(&d, &second, &p), 201.0, 251.0)); - // Show handles when multiple anchors are selected off: the whole path's are hidden. - assert!(!handle(&ToolContext { handles_multiple: false, ..cx(&d, &whole, &p) }, 101.0, 251.0)); - assert!(handle(&ToolContext { handles_multiple: false, ..cx(&d, &second, &p) }, 201.0, 251.0)); - // The tolerance is the preference's, in screen pixels: 3 px is 0.75 pt at 400%. - let zoomed = ToolContext { zoom: 4.0, ..cx(&d, &whole, &p) }; - assert!(!handle(&zoomed, 101.0, 251.0)); - assert!(handle(&zoomed, 100.5, 250.5)); - assert!(handle(&ToolContext { selection_tolerance: 8.0, ..cx(&d, &whole, &p) }, 106.0, 254.0)); - } - - /// #494: a handle 2 pt long, inside the tolerance round its anchor, doesn't hide the anchor. - #[test] - fn a_short_handle_leaves_its_anchor_to_be_picked() { - let (mut d, _) = doc_with_rect(); - let l = d.layers[0].id; - let id = d.alloc_id(); - let mut sp = vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 300.0), Point::new(200.0, 300.0)], false); - sp.anchors[0].h_out = Point::new(102.0, 300.0); - d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, PathData::single(sp), vectorcraft_doc::Appearance::default_art())).unwrap(); - let s = anchors_of(id, &[(0, 0)]); - let p = paint(); - let c = cx(&d, &s, &p); - let mut t = DirectSelectionTool::new(false); - assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 100.0, 300.0)), vec![], "the anchor, already selected"); - let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 90.0, 290.0)); - assert_eq!(a[0], Action::Begin("Move".into())); - t.pointer(&c, &PointerEvent::new(PointerKind::Up, 90.0, 290.0)); - assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 102.5, 300.0)), vec![Action::Begin("Reshape".into())], "the handle"); - } - - /// Direct Selection's cursors from Illustrator's manual: the white arrow at rest; over an anchor - /// a square, hollow on an unselected path and filled on a selected one; the Reshape Segment - /// cursor over a curved segment a drag reshapes, not over a straight one (which moves) or a - /// path selected whole (which moves whole); Group Selection's plus with Alt and as its tool. - #[test] - fn hovering_shows_the_anchor_square_reshape_and_group_cursors() { - let (d, arch) = arch_doc(); - let p = paint(); - let m = Mods::default(); - let pt = Point::new; - let none = Selection::default(); - let c = cx(&d, &none, &p); - let t = DirectSelectionTool::new(false); - assert_eq!(t.cursor(&c, pt(20.0, 20.0), m), Cursor::ArrowHollow); - assert_eq!(t.cursor(&c, pt(100.0, 300.0), m), Cursor::DirectAnchor); - assert_eq!(t.cursor(&c, pt(150.0, 262.5), m), Cursor::ReshapeSegment); - assert_eq!(t.cursor(&c, pt(150.0, 100.0), m), Cursor::ArrowHollow, "a straight segment moves"); - assert_eq!(t.cursor(&c, pt(150.0, 150.0), m), Cursor::ArrowHollow, "the fill"); - let alt = Mods { alt: true, ..m }; - assert_eq!(t.cursor(&c, pt(100.0, 300.0), alt), Cursor::GroupSelection); - assert_eq!(DirectSelectionTool::new(true).cursor(&c, pt(100.0, 300.0), m), Cursor::GroupSelection); - let mut whole = Selection::default(); - whole.set([arch]); - let c = cx(&d, &whole, &p); - assert_eq!(t.cursor(&c, pt(200.0, 300.0), m), Cursor::DirectAnchorSelected); - assert_eq!(t.cursor(&c, pt(150.0, 262.5), m), Cursor::ArrowHollow, "a path selected whole moves whole"); - // One anchor selected: its handle end shows and keeps the plain arrow, Alt or not (a press - // takes the handle). - let s = anchors_of(arch, &[(0, 0)]); - let c = cx(&d, &s, &p); - assert_eq!(t.cursor(&c, pt(100.0, 250.0), m), Cursor::ArrowHollow, "a handle end"); - assert_eq!(t.cursor(&c, pt(100.0, 250.0), alt), Cursor::ArrowHollow, "a handle end with Alt"); - assert_eq!(t.cursor(&c, pt(200.0, 300.0), m), Cursor::DirectAnchorSelected); - // Bending the curve keeps the Reshape Segment cursor. - let mut t = DirectSelectionTool::new(false); - let c = cx(&d, &none, &p); - t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 262.5)); - t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 150.0, 230.0)); - assert_eq!(t.cursor(&c, pt(150.0, 230.0), m), Cursor::ReshapeSegment); - } - - /// A drag shows the filled arrow, hollow while it snaps to a point; an Alt-drag copy shows the - /// double arrowhead. - #[test] - fn drags_show_the_filled_arrow_hollow_on_a_snap_and_alt_the_copy_cursor() { - let (d, _, b) = two_squares(); - let p = paint(); - let none = Mods::default(); - let at = |kind, x, y, m: Mods| PointerEvent::new(kind, x, y).with_mods(m); - let one = anchors_of(b, &[(0, 0)]); - let c = cx(&d, &one, &p); - let mut t = DirectSelectionTool::new(false); - t.pointer(&c, &at(PointerKind::Down, 300.0, 300.0, none)); - t.pointer(&c, &at(PointerKind::Drag, 250.0, 260.0, none)); - assert_eq!(t.cursor(&c, Point::new(250.0, 260.0), none), Cursor::Arrow); - // Onto A's bottom-right corner. - t.pointer(&c, &at(PointerKind::Drag, 203.0, 199.0, none)); - assert_eq!(t.cursor(&c, Point::new(203.0, 199.0), none), Cursor::ArrowHollow); - t.pointer(&c, &at(PointerKind::Up, 203.0, 199.0, none)); - // Shift with a Constrain Angle of 10°: the constraint alone isn't a snap. - let shift = Mods { shift: true, ..none }; - let tilted = ToolContext { constrain_angle: 10.0, ..cx(&d, &one, &p) }; - t.pointer(&tilted, &at(PointerKind::Down, 300.0, 300.0, shift)); - t.pointer(&tilted, &at(PointerKind::Drag, 380.0, 262.0, shift)); - assert_eq!(t.cursor(&tilted, Point::new(380.0, 262.0), shift), Cursor::Arrow); - t.pointer(&tilted, &at(PointerKind::Up, 380.0, 262.0, shift)); - let mut whole = Selection::default(); - whole.set([b]); - let c = cx(&d, &whole, &p); - let alt = Mods { alt: true, ..none }; - t.pointer(&c, &at(PointerKind::Down, 300.0, 300.0, alt)); - t.pointer(&c, &at(PointerKind::Drag, 250.0, 260.0, alt)); - assert_eq!(t.cursor(&c, Point::new(250.0, 260.0), alt), Cursor::SelectionCopy); - } - - /// [`doc_with_rect`] (A, 100..200) and a second square B, 300..400: (A, B). - fn two_squares() -> (vectorcraft_doc::Document, NodeId, NodeId) { - let (mut d, a) = doc_with_rect(); - let l = d.layers[0].id; - let b = d.alloc_id(); - let sq = vectorcraft_geom::shapes::rectangle(Rect::new(300.0, 300.0, 400.0, 400.0)); - d.insert(Some(l), 1, vectorcraft_doc::Node::path(b, sq, vectorcraft_doc::Appearance::default_art())).unwrap(); - (d, a, b) - } - - /// An anchor of a selected path is picked before the one of an unselected path on a layer - /// above that covers it, as Highlight anchors on mouse over already marks it (#1508); with - /// nothing selected, the topmost path's anchor is picked. - #[test] - fn a_selected_path_s_anchor_is_picked_before_one_above_it() { - let (mut d, a) = doc_with_rect(); - let top = d.add_layer(None); - let b = d.alloc_id(); - let sq = vectorcraft_geom::shapes::rectangle(Rect::new(101.0, 100.0, 201.0, 200.0)); - d.insert(Some(top), 0, vectorcraft_doc::Node::path(b, sq, vectorcraft_doc::Appearance::default_art())).unwrap(); - let p = paint(); - let pick = |id: NodeId| vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "set"}))]; - let s = anchors_of(a, &[(0, 2)]); - let mut t = DirectSelectionTool::new(false); - assert_eq!(t.pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, 101.0, 100.0)), pick(a)); - let mut whole = Selection::default(); - whole.set([a]); - let mut t = DirectSelectionTool::new(false); - let cx2 = cx(&d, &whole, &p); - assert!(t.pointer(&cx2, &PointerEvent::new(PointerKind::Down, 101.0, 100.0)).is_empty()); - assert_eq!(t.pointer(&cx2, &PointerEvent::new(PointerKind::Up, 101.0, 100.0)), pick(a)); - let none = Selection::default(); - let mut t = DirectSelectionTool::new(false); - assert_eq!(t.pointer(&cx(&d, &none, &p), &PointerEvent::new(PointerKind::Down, 101.0, 100.0)), pick(b)); - } - - /// The anchors `anchors` of path `id` direct-selected. - fn anchors_of(id: NodeId, anchors: &[AnchorRef]) -> Selection { - let mut s = Selection::default(); - s.set([id]); - s.anchors.insert(id, anchors.iter().copied().collect()); - s - } - - /// Press at `from`, drag to `to`: the move the drag previews, and the smart guide labels shown. - fn drag(t: &mut DirectSelectionTool, cx: &ToolContext, from: (f64, f64), to: (f64, f64), mods: Mods) -> (Value, Vec) { - t.pointer(cx, &PointerEvent::new(PointerKind::Down, from.0, from.1).with_mods(mods)); - let a = t.pointer(cx, &PointerEvent::new(PointerKind::Drag, to.0, to.1).with_mods(mods)); - let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") }; - let labels = t - .overlays(cx) - .into_iter() - .filter_map(|o| match o { - Overlay::Label { text, .. } => Some(text), - _ => None, - }) - .collect(); - assert_eq!(t.pointer(cx, &PointerEvent::new(PointerKind::Up, to.0, to.1).with_mods(mods)), vec![Action::Commit]); - assert!(t.overlays(cx).iter().all(|o| !matches!(o, Overlay::Label { .. })), "the guides go with the drag"); - (v.clone(), labels) - } - - /// Alt-dragging a path by an anchor copies it, and that anchor snaps as a dragged anchor does: - /// onto another path's anchor, or onto an anchor of the original the copy leaves behind. - #[test] - fn alt_dragged_copies_snap_by_the_anchor_pressed() { - let (d, _, b) = two_squares(); - let p = paint(); - let alt = Mods { alt: true, ..Default::default() }; - let mut whole = Selection::default(); - whole.set([b]); - let c = cx(&d, &whole, &p); - let mut t = DirectSelectionTool::new(false); - let copy = - |dx: f64, dy: f64| json!({"matrix": matrix_json(vectorcraft_geom::Affine::translate(vectorcraft_geom::Vec2::new(dx, dy))), "copy": true}); - // B's top-left corner, pressed 1 pt off, onto A's bottom-right one. - let (v, labels) = drag(&mut t, &c, (301.0, 301.0), (203.0, 199.0), alt); - assert_eq!((v, labels), (copy(-100.0, -100.0), vec!["anchor".to_string()])); - // B's top-left corner onto the original B's top-right one: the copy sits beside it. - let (v, labels) = drag(&mut t, &c, (300.0, 300.0), (402.0, 301.0), alt); - assert_eq!((v, labels), (copy(100.0, 0.0), vec!["anchor".to_string()])); - // The history says what the drag did. - t.pointer(&c, &PointerEvent::new(PointerKind::Down, 300.0, 300.0).with_mods(alt)); - let out = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 250.0, 250.0).with_mods(alt)); - assert_eq!(out.first(), Some(&Action::Begin("Copy".into()))); - } - - /// Dragging a fully selected path by an anchor moves it whole, so Alt pressed or released - /// during the drag turns the copy on and off, as with the Selection tool; the anchor pressed - /// snaps all along, onto the original only while it is left behind. - #[test] - fn alt_during_a_drag_by_an_anchor_toggles_the_copy() { - let (d, _, b) = two_squares(); - let p = paint(); - let (none, alt) = (Mods::default(), Mods { alt: true, ..Default::default() }); - let mut whole = Selection::default(); - whole.set([b]); - let c = cx(&d, &whole, &p); - let mut t = DirectSelectionTool::new(false); - let preview = |dx: f64, dy: f64, copy: bool| { - Action::Preview( - "object.transform".into(), - json!({"matrix": matrix_json(vectorcraft_geom::Affine::translate(vectorcraft_geom::Vec2::new(dx, dy))), "copy": copy}), - ) - }; - assert!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 300.0, 300.0)).is_empty()); - assert_eq!( - t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 250.0, 250.0)), - vec![Action::Begin("Move".into()), preview(-50.0, -50.0, false)] - ); - // The tool sees the document as previewed: B moved along, not where the copy leaves it. - let (mut moved, _, b2) = two_squares(); - let l = moved.layers[0].id; - let sq = vectorcraft_geom::shapes::rectangle(Rect::new(250.0, 250.0, 350.0, 350.0)); - moved.remove(b2).unwrap(); - moved.insert(Some(l), 1, vectorcraft_doc::Node::path(b, sq, vectorcraft_doc::Appearance::default_art())).unwrap(); - let c = cx(&moved, &whole, &p); - // Alt down mid-drag: a copy, whose pressed corner can land on the original's top-right one. - assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 402.0, 301.0).with_mods(alt)), vec![preview(100.0, 0.0, true)]); - // Alt up: a move again, which leaves nothing behind to snap to. - assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 402.0, 301.0).with_mods(none)), vec![preview(102.0, 1.0, false)]); - assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 402.0, 301.0)), vec![Action::Commit]); - // With only some anchors selected, the drag still moves just those. - let one = anchors_of(b, &[(0, 0)]); - let c = cx(&d, &one, &p); - t.pointer(&c, &PointerEvent::new(PointerKind::Down, 300.0, 300.0)); - let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 250.0, 260.0).with_mods(alt)); - assert!(matches!(a.last(), Some(Action::Preview(c, _)) if c == "path.moveAnchors"), "{a:?}"); - } - - /// A dragged anchor lands on another path's anchor or segment, lines up with the other anchors - /// of its own path, and with several selected the one pressed on snaps, the others following. - #[test] - fn dragged_anchors_snap_to_points_segments_and_smart_guides() { - let (d, _, b) = two_squares(); - let p = paint(); - let none = Mods::default(); - let one = anchors_of(b, &[(0, 0)]); - let c = cx(&d, &one, &p); - let mut t = DirectSelectionTool::new(false); - // B's top-left corner, pressed 1 pt off, onto A's bottom-right one. - let (v, labels) = drag(&mut t, &c, (301.0, 301.0), (203.0, 199.0), none); - assert_eq!((v, labels), (json!({"dx": -100.0, "dy": -100.0}), vec!["anchor".to_string()])); - // Onto A's right side. - let (v, labels) = drag(&mut t, &c, (300.0, 300.0), (203.0, 150.0), none); - assert_eq!((v, labels), (json!({"dx": -100.0, "dy": -150.0}), vec!["path".to_string()])); - // In line with B's own bottom-left corner, which stays put. - let (v, labels) = drag(&mut t, &c, (300.0, 300.0), (303.0, 330.0), none); - assert_eq!((v, labels), (json!({"dx": 0.0, "dy": 30.0}), vec!["align".to_string()])); - // Shift keeps the move at 45° steps and still snaps along that line (#886): on the - // diagonal from B's corner, onto A's bottom-right corner… - let shift = Mods { shift: true, ..none }; - let (v, labels) = drag(&mut t, &c, (300.0, 300.0), (203.0, 199.0), shift); - let near = |v: &serde_json::Value, (x, y): (f64, f64)| (v["dx"].as_f64().unwrap() - x).hypot(v["dy"].as_f64().unwrap() - y) < 1e-9; - assert!(near(&v, (-100.0, -100.0)) && labels == ["anchor"], "{v} {labels:?}"); - // …and with nothing in reach, at the pointer's distance along its angle. - let (v, labels) = drag(&mut t, &c, (300.0, 300.0), (380.0, 302.0), shift); - let s = move_delta(Point::new(300.0, 300.0), Point::new(380.0, 302.0), true, 0.0); - assert!(near(&v, (s.x, s.y)) && labels.is_empty(), "{v} {labels:?}"); - // Two anchors: the one pressed on (B's top-right) lands on A's corner. - let two = anchors_of(b, &[(0, 0), (0, 1)]); - let c = cx(&d, &two, &p); - let (v, _) = drag(&mut t, &c, (400.0, 300.0), (202.0, 198.0), none); - assert_eq!(v, json!({"dx": -200.0, "dy": -100.0})); - // Smart Guides off: Snap to Point pulls within its distance (2 px) only. - let c = ToolContext { smart_guides: false, ..cx(&d, &one, &p) }; - assert_eq!(drag(&mut t, &c, (300.0, 300.0), (201.0, 199.0), none).0, json!({"dx": -100.0, "dy": -100.0})); - assert_eq!(drag(&mut t, &c, (300.0, 300.0), (203.0, 199.0), none).0, json!({"dx": -97.0, "dy": -101.0})); - let c = ToolContext { smart_guides: false, snap_to_point: false, ..cx(&d, &one, &p) }; - assert_eq!(drag(&mut t, &c, (300.0, 300.0), (201.0, 199.0), none).0, json!({"dx": -99.0, "dy": -101.0})); - } - - /// #2120: the end of a slanted straight line, dragged roughly along it, stays on its direction - /// (Line Extension) and the guide runs along it from the other end. - #[test] - fn dragged_end_of_a_slanted_line_stays_on_its_direction() { - let (mut d, _) = doc_with_rect(); - let l = d.layers[0].id; - let id = d.alloc_id(); - let line = vectorcraft_geom::shapes::line(Point::new(300.0, 400.0), Point::new(380.0, 340.0)); - d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, line, vectorcraft_doc::Appearance::default_art())).unwrap(); - let p = paint(); - let end = anchors_of(id, &[(0, 1)]); - let c = cx(&d, &end, &p); - let mut t = DirectSelectionTool::new(false); - t.pointer(&c, &PointerEvent::new(PointerKind::Down, 380.0, 340.0)); - let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 421.5, 312.0)); - let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") }; - let (dx, dy) = (v["dx"].as_f64().unwrap(), v["dy"].as_f64().unwrap()); - assert!((dx - 40.0).abs() < 1e-9 && (dy + 30.0).abs() < 1e-9, "{v}"); - let guides: Vec<(Point, Point)> = - t.overlays(&c).into_iter().filter_map(|o| if let Overlay::Line { a, b, .. } = o { Some((a, b)) } else { None }).collect(); - assert!(matches!(guides[..], [(a, b)] if a == Point::new(300.0, 400.0) && (b - Point::new(420.0, 310.0)).hypot() < 1e-9), "{guides:?}"); - } - - /// Group Selection (and Direct Selection on a whole path) moves objects snapping as the - /// Selection tool does: B's left side lines up with A's right one. - #[test] - fn moved_objects_snap_with_smart_guides() { - let (d, _, b) = two_squares(); - let p = paint(); - let mut s = Selection::default(); - s.set([b]); - let c = cx(&d, &s, &p); - for group in [true, false] { - let mut t = DirectSelectionTool::new(group); - let (v, _) = drag(&mut t, &c, (350.0, 350.0), (253.0, 351.0), Mods::default()); - assert_eq!(v["matrix"], json!([1.0, 0.0, 0.0, 1.0, -100.0, 1.0]), "group: {group}"); - } - } - - /// #1816: Group Selection and Direct Selection move a whole path with Snap to Grid as the - /// Selection tool does, landing an edge on the grid; the center does not snap. A 45 pt square at - /// 90–135 on the 9 pt grid, dragged right by 22 to 112–157, lands its left edge on the gridline - /// 108 and moves by 18. Its center, at 134.5 after the drag, is nearer the gridline 135 and - /// does not snap. - #[test] - fn a_move_of_objects_lands_an_edge_on_the_grid() { - let mut d = vectorcraft_doc::Document::new(500.0, 500.0); - let l = d.layers[0].id; - let id = d.alloc_id(); - let sq = vectorcraft_geom::shapes::rectangle(Rect::new(90.0, 90.0, 135.0, 135.0)); - d.insert(Some(l), 0, vectorcraft_doc::Node::path(id, sq, vectorcraft_doc::Appearance::default_art())).unwrap(); - let p = paint(); - let mut s = Selection::default(); - s.set([id]); - let c = ToolContext { snap_to_grid: true, ..cx(&d, &s, &p) }; - for group in [true, false] { - let mut t = DirectSelectionTool::new(group); - let (v, _) = drag(&mut t, &c, (110.0, 110.0), (132.0, 110.0), Mods::default()); - assert_eq!(v["matrix"], json!([1.0, 0.0, 0.0, 1.0, 18.0, 0.0]), "group: {group}"); - } - } - - /// Group Selection and Direct Selection on a whole path keep a Shift-constrained move on its - /// line when it snaps, as the Selection tool does: level by 13.04 with Snap to Grid on, B's - /// right side (413.04) lands on the gridline 414, and its top stays at 300, between 297 and - /// 306. - #[test] - fn a_shift_move_of_objects_snaps_along_its_line() { - let (d, _, b) = two_squares(); - let p = paint(); - let mut s = Selection::default(); - s.set([b]); - let c = ToolContext { snap_to_grid: true, ..cx(&d, &s, &p) }; - let shift = Mods { shift: true, ..Mods::default() }; - for group in [true, false] { - let mut t = DirectSelectionTool::new(group); - let (v, _) = drag(&mut t, &c, (350.0, 350.0), (363.0, 351.0), shift); - let (x, y) = (v["matrix"][4].as_f64().unwrap(), v["matrix"][5].as_f64().unwrap()); - assert!((x - 14.0).abs() < 1e-9 && y.abs() < 1e-9, "group: {group}: {v}"); - } - } - - /// Direct Selection's double-click on type where a layer's clipping mask edge runs through it - /// edits the type, though a press there takes the mask's segment. - #[test] - fn a_double_click_on_type_at_a_layer_masks_edge_edits_it() { - let (d, _, at) = doc_with_type_on_a_layer_masks_edge(); - let (s, p) = (Selection::default(), paint()); - let acts = DirectSelectionTool::new(false).pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::DoubleClick, at.x, at.y)); - assert_eq!(acts, vec![Action::SwitchTool("type".into())]); - } - - #[test] - fn double_clicking_type_switches_to_type_tool() { - use std::sync::Arc; - let (mut d, id) = doc_with_area_type(); - let (s, p) = (Selection::default(), paint()); - let c = cx(&d, &s, &p); - let b = d.node(id).unwrap().geometric_bounds().unwrap(); - let mid = b.center(); - // Direct Selection (shortcut A, group == false) switches to the Type tool on double click. - let mut t = DirectSelectionTool::new(false); - let acts = t.pointer(&c, &PointerEvent::new(PointerKind::DoubleClick, mid.x, mid.y)); - assert_eq!(acts, vec![Action::SwitchTool("type".into())]); - - // Group Selection (group == true) does not switch to Type tool. - let mut gt = DirectSelectionTool::new(true); - let acts_group = gt.pointer(&c, &PointerEvent::new(PointerKind::DoubleClick, mid.x, mid.y)); - assert!(acts_group.is_empty(), "group selection shouldn't switch: {acts_group:?}"); - - // Text inside a group also switches to Type tool with Direct Selection. - let gid = d.alloc_id(); - let text_id = d.alloc_id(); - let text_node = Node::new( - text_id, - vectorcraft_doc::NodeKind::Text(Box::new(vectorcraft_doc::TextObject::point(Point::new(50.0, 50.0), "In group", Default::default()))), - ); - d.insert(Some(d.layers[0].id), 2, Node::group(gid, vec![Arc::new(text_node)])).unwrap(); - let in_group_b = d.node(gid).unwrap().geometric_bounds().unwrap(); - let c2 = cx(&d, &s, &p); - let acts_group_text = t.pointer(&c2, &PointerEvent::new(PointerKind::DoubleClick, in_group_b.center().x, in_group_b.center().y)); - assert_eq!(acts_group_text, vec![Action::SwitchTool("type".into())]); - } - - #[test] - fn direct_selection_click_and_drag_member_of_compound_path() { - let mut d = vectorcraft_doc::Document::new(500.0, 500.0); - let l = d.layers[0].id; - let (outer, hole, compound) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); - let rect = |id, a: f64, b: f64| { - std::sync::Arc::new(vectorcraft_doc::Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(a, a, b, b)), Default::default())) - }; - let mut c = vectorcraft_doc::Node::new( - compound, - vectorcraft_doc::NodeKind::Compound { - children: vec![rect(outer, 0.0, 200.0), rect(hole, 50.0, 150.0)], - rule: vectorcraft_geom::FillRule::EvenOdd, - }, - ); - c.appearance = vectorcraft_doc::Appearance::default_art().into(); - d.insert(Some(l), 0, c).unwrap(); - let p = paint(); - let none = Selection::default(); - let mut t = DirectSelectionTool::new(false); - let ctx = cx(&d, &none, &p); - let down = t.pointer(&ctx, &PointerEvent::new(PointerKind::Down, 25.0, 100.0)); - assert_eq!( - down, - vec![Action::Exec("select.set".into(), json!({"ids": [outer.0]}))], - "Direct Selection selects child member, not compound parent" - ); - let mut s = Selection::default(); - s.set([outer]); - let ctx_sel = cx(&d, &s, &p); - let drag = t.pointer(&ctx_sel, &PointerEvent::new(PointerKind::Drag, 35.0, 110.0)); - assert!(drag.iter().any(|a| matches!(a, Action::Preview(cmd, _) if cmd == "object.transform")), "drags the selected child member: {drag:?}"); - let up = t.pointer(&ctx_sel, &PointerEvent::new(PointerKind::Up, 35.0, 110.0)); - assert_eq!(up, vec![Action::Commit]); - } - - #[test] - fn shift_clicking_anchor_after_segment_pick_toggles_or_adds() { - let (d, id) = doc_with_rect(); - let p = paint(); - let mut s = Selection::default(); - s.set([id]); - s.segments.insert(id, [(0, 0)].into()); - s.anchors.insert(id, [(0, 0), (0, 1)].into()); - let cx = cx(&d, &s, &p); - let mut t = DirectSelectionTool::new(false); - let shift = Mods { shift: true, ..Default::default() }; - // Shift-click on anchor (0, 0) (top-left at 100, 100) toggles it off - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0).with_mods(shift)); - assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "toggle"}))]); - // Shift-click on anchor (0, 2) (bottom-right at 200, 200) toggles it on (adds it) - let a2 = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 200.0, 200.0).with_mods(shift)); - assert_eq!(a2, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 2]], "mode": "toggle"}))]); - } - - #[test] - fn shift_clicking_segment_toggles_segment() { - let (d, id) = doc_with_rect(); - let p = paint(); - let s = Selection::default(); - let cx = cx(&d, &s, &p); - let mut t = DirectSelectionTool::new(false); - let shift = Mods { shift: true, ..Default::default() }; - // The rectangle is at 100..200. Segment 0 is the top edge (y=100, x between 100 and 200). - // Shift-click at (150, 100) selects segment 0 with mode "toggle". - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 100.0).with_mods(shift)); - assert_eq!(a, vec![Action::Exec("select.segments".into(), json!({"id": id.0, "segments": [[0, 0]], "mode": "toggle"}))]); - } - - /// In normal viewmode, clicking the fill of an open path in front selects the open path's shape - /// and anchors, and NEVER clicks through to select or grab a path behind it (even if the path - /// behind has an anchor directly under the click). - #[test] - fn clicking_fill_of_open_path_in_front_never_selects_path_behind() { - use vectorcraft_geom::{Anchor, SubPath}; - let (mut d, _behind) = doc_with_rect(); - // `behind` has an anchor at (100.0, 100.0). - // Create an open path in front (id `front`), with a fill, covering (100.0, 100.0). - let front = d.alloc_id(); - let l = d.layers[0].id; - // Triangle from (50, 50) to (150, 50) to (150, 150), open (implicit closing edge from (150, 150) to (50, 50)). - // (100.0, 100.0) is inside this filled triangle, but `front` has NO anchor at (100.0, 100.0). - let anchors = vec![Anchor::corner(Point::new(50.0, 50.0)), Anchor::corner(Point::new(150.0, 50.0)), Anchor::corner(Point::new(150.0, 150.0))]; - let open_path = PathData::single(SubPath::new(anchors, false)); - let ap = vectorcraft_doc::Appearance::default_art(); // has solid white fill - d.insert(Some(l), 1, vectorcraft_doc::Node::path(front, open_path, ap)).unwrap(); - - let (s, p) = (Selection::default(), paint()); - let c = cx(&d, &s, &p); - - // Hovering over (100.0, 100.0) does NOT hover the hidden anchor of `behind`. - assert_eq!(hovered_anchor(&c, Point::new(100.0, 100.0)), None); - - // Clicking at (100.0, 100.0) selects `front` (the open path in front), NOT `behind`. - let mut t = DirectSelectionTool::new(false); - let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); - assert_eq!(acts, vec![Action::Exec("select.set".into(), json!({"ids": [front.0]}))]); - } - - /// A selected path's Bézier direction handle positioned over a background filled rectangle - /// can be hit and dragged without being occluded by the background fill. - #[test] - fn selected_path_handle_over_background_rect_is_draggable() { - use vectorcraft_geom::{Anchor, SubPath}; - let mut d = vectorcraft_doc::Document::new(500.0, 500.0); - let l = d.layers[0].id; - // Background rectangle covering 0..300 with default white fill - let bg = d.alloc_id(); - let bg_rect = vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 300.0, 300.0)); - d.insert(Some(l), 0, vectorcraft_doc::Node::path(bg, bg_rect, vectorcraft_doc::Appearance::default_art())).unwrap(); - - // Selected path with a Bézier curve whose out-handle is at (150, 150) - let fg = d.alloc_id(); - let mut a0 = Anchor::corner(Point::new(100.0, 150.0)); - a0.h_out = Point::new(150.0, 150.0); // handle over the background rect - let a1 = Anchor::corner(Point::new(200.0, 150.0)); - let curve = PathData::single(SubPath::new(vec![a0, a1], false)); - d.insert(Some(l), 1, vectorcraft_doc::Node::path(fg, curve, vectorcraft_doc::Appearance::default_art())).unwrap(); - - let mut s = Selection::default(); - s.set([fg]); - s.anchors.insert(fg, [(0, 0)].into()); - let p = paint(); - let c = cx(&d, &s, &p); - - // hit_handle on the handle at (150, 150) must find the handle of `fg` - assert_eq!(hit_handle(&c, Point::new(150.0, 150.0), c.point_tol()), Some((fg, 0, 0, true))); - - // Direct Selection tool clicking on the handle initiates Reshape - let mut t = DirectSelectionTool::new(false); - let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 150.0)); - assert_eq!(acts, vec![Action::Begin("Reshape".into())]); - } -} +mod tests; diff --git a/crates/tools/src/direct/tests.rs b/crates/tools/src/direct/tests.rs new file mode 100644 index 000000000..06a940ac6 --- /dev/null +++ b/crates/tools/src/direct/tests.rs @@ -0,0 +1,1267 @@ +use super::*; +use crate::testutil::*; +use vectorcraft_doc::Selection; + +/// #414: a ruler guide is picked over the art, but an anchor on it is picked first. +#[test] +fn direct_and_group_selection_pick_ruler_guides() { + let (mut d, id) = doc_with_rect(); + d.guides.push(vectorcraft_doc::Guide::new(true, 100.0)); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let down = |t: &mut DirectSelectionTool, y: f64| t.pointer(&c, &PointerEvent::new(PointerKind::Down, 100.0, y)); + let pick = vec![Action::Exec("guide.select".into(), json!({"indexes": [0]}))]; + for group in [false, true] { + let mut t = DirectSelectionTool::new(group); + assert_eq!(down(&mut t, 150.0), pick, "on the rect's edge (group: {group})"); + assert!(t.busy()); + t.pointer(&c, &PointerEvent::new(PointerKind::Up, 100.0, 150.0)); + assert_eq!(t.cursor(&c, Point::new(101.0, 300.0), Mods::default()), Cursor::ResizeH); + } + let mut t = DirectSelectionTool::new(false); + assert_eq!(down(&mut t, 100.0), vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "set"}))]); +} + +/// A Cmd/Ctrl+Shift double-click with Direct Selection or Group Selection releases the ruler +/// guide or the guide path under the pointer; a plain double-click on a ruler guide, or one while +/// guides are hidden or locked, releases nothing. +#[test] +fn a_cmd_shift_double_click_releases_the_guide_under_the_pointer() { + let (mut d, id) = doc_with_rect(); + d.guides.push(vectorcraft_doc::Guide::new(false, 300.0)); + let Some(NodeKind::Path { guide, .. }) = d.node_mut(id).map(|n| &mut n.kind) else { panic!("the rectangle is a path") }; + *guide = true; + let (s, p) = (Selection::default(), paint()); + let both = Mods { cmd: true, shift: true, ..Mods::default() }; + let index = vec![Action::Exec("view.guides.release".into(), json!({"index": 0}))]; + let ids = vec![Action::Exec("view.guides.release".into(), json!({"ids": [id.0]}))]; + let locked = ToolContext { guides: false, ..cx(&d, &s, &p) }; + for group in [false, true] { + let dbl = |c: &ToolContext, x: f64, y: f64, m: Mods| { + DirectSelectionTool::new(group).pointer(c, &PointerEvent::new(PointerKind::DoubleClick, x, y).with_mods(m)) + }; + let c = cx(&d, &s, &p); + assert_eq!(dbl(&c, 350.0, 301.0, both), index, "on the ruler guide (group: {group})"); + assert_eq!(dbl(&c, 200.0, 150.0, both), ids, "on the guide path (group: {group})"); + assert!(dbl(&c, 350.0, 301.0, Mods::default()).is_empty(), "a plain double-click (group: {group})"); + assert!(dbl(&c, 350.0, 301.0, Mods { cmd: true, ..Mods::default() }).is_empty(), "Cmd/Ctrl alone (group: {group})"); + assert!(dbl(&locked, 350.0, 301.0, both).is_empty(), "guides locked (group: {group})"); + assert!(dbl(&locked, 200.0, 150.0, both).is_empty(), "guides locked (group: {group})"); + } +} + +/// An anchor is picked from 2 px past its drawn square even where its segment is nearer, and +/// further with a larger anchor Size or Tolerance (#593); away from it the segment is. +#[test] +fn anchors_are_picked_before_the_segments_through_them() { + let (d, id) = doc_with_rect(); + let (s, p) = (Selection::default(), paint()); + let corner = vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "set"}))]; + let press = |cx: &ToolContext, x: f64, y: f64| DirectSelectionTool::new(false).pointer(cx, &PointerEvent::new(PointerKind::Down, x, y)); + let c = cx(&d, &s, &p); + // 4.3 px from the corner, 0.5 px from the top edge. + assert_eq!(press(&c, 104.2, 100.5), corner); + assert_ne!(press(&c, 110.0, 100.5), corner, "the segment, away from the anchor"); + assert_ne!(press(&c, 105.0, 100.5), corner, "past the default reach (4.5 px)"); + let large = ToolContext { anchor_size: 7, ..cx(&d, &s, &p) }; + assert_eq!(press(&large, 106.0, 100.5), corner, "Size 7 draws them 9 px wide"); + let loose = ToolContext { selection_tolerance: 8.0, ..cx(&d, &s, &p) }; + assert_eq!(press(&loose, 107.5, 100.5), corner); + // On screen: at 200% the reach is half as far in the document. + let zoomed = ToolContext { zoom: 2.0, ..cx(&d, &s, &p) }; + assert_eq!(press(&zoomed, 102.1, 100.2), corner); + assert_ne!(press(&zoomed, 102.6, 100.2), corner); +} + +/// A press on a clip group's clipping path, where the art it clips lies over its edge, takes +/// the clipping path's segment, so the mask can be reshaped on the canvas; inside the mask the +/// art is taken, as before. +#[test] +fn a_press_on_a_clipping_path_takes_its_segment_over_the_art() { + let (d, _, clip, art) = doc_with_masked_square(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let press = |x: f64, y: f64| DirectSelectionTool::new(false).pointer(&c, &PointerEvent::new(PointerKind::Down, x, y)); + let names = |actions: &[Action], id: NodeId| { + actions.iter().any(|a| matches!(a, Action::Exec(_, v) if v["id"] == json!(id.0) || v["ids"] == json!([id.0]))) + }; + // The middle of the right edge, on it and just inside it. + for x in [500.0, 498.5] { + let a = press(x, 450.0); + assert!(names(&a, clip), "at {x}: {a:?}"); + } + let a = press(450.0, 450.0); + assert!(names(&a, art) && !names(&a, clip), "{a:?}"); +} + +/// A blend of two 20 pt squares centred on (110, 310) and (210, 310), selected. +fn blend_doc() -> (vectorcraft_doc::Document, NodeId, NodeId) { + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let key = |id: NodeId, x: f64| { + let r = vectorcraft_geom::shapes::rectangle(Rect::new(x, 300.0, x + 20.0, 320.0)); + std::sync::Arc::new(vectorcraft_doc::Node::path(id, r, vectorcraft_doc::Appearance::default_art())) + }; + let (g, k1, k2) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); + let b = vectorcraft_doc::Node::new(g, NodeKind::Blend { children: vec![key(k1, 100.0), key(k2, 200.0)], spec: Default::default() }); + d.insert(Some(l), 1, b).unwrap(); + (d, g, k1) +} + +/// Highlight anchors on mouse over (#394): the anchor under the pointer, within the selection +/// tolerance, shows enlarged; none with the preference off or away from anchors. +#[test] +fn the_anchor_under_the_pointer_is_highlighted() { + let (d, id) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let big = |t: &DirectSelectionTool, cx: &ToolContext| { + t.overlays(cx).into_iter().find_map(|o| match o { + Overlay::Anchor { p, size, .. } if size > 5.0 => Some(p), + _ => None, + }) + }; + let mut t = DirectSelectionTool::new(false); + let on = cx(&d, &s, &p); + t.pointer(&on, &PointerEvent::new(PointerKind::Move, 102.0, 101.0)); + assert_eq!(big(&t, &on), Some(Point::new(100.0, 100.0)), "the corner of {id:?}"); + t.pointer(&on, &PointerEvent::new(PointerKind::Move, 150.0, 100.0)); + assert_eq!(big(&t, &on), None, "mid-segment: no anchor"); + let off = ToolContext { highlight_anchors: false, ..cx(&d, &s, &p) }; + t.pointer(&off, &PointerEvent::new(PointerKind::Move, 102.0, 101.0)); + assert_eq!(big(&t, &off), None, "off"); +} + +#[test] +fn dragging_a_spine_point_moves_it() { + let (d, g, _) = blend_doc(); + let mut s = Selection::default(); + s.set([g]); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = DirectSelectionTool::new(false); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 211.0, 310.0)), vec![]); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 221.0, 350.0)); + assert_eq!(a[0], Action::Begin("Reshape Spine".into())); + assert_eq!(a[1], Action::Preview("object.blend.spine.moveAnchor".into(), json!({"id": g.0, "anchor": 1, "x": 220.0, "y": 350.0}))); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 221.0, 350.0)), vec![Action::Commit]); + assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Anchor { filled: true, .. })), "the clicked point shows"); +} + +/// #1111: the Anchor Point tool bends a selected blend's straight spine; Direct Selection +/// reshapes a curved spine segment and leaves a straight one alone, as it does a path's. +#[test] +fn spine_segments_bend_with_the_anchor_point_tool_and_direct_selection() { + let (mut d, g, _) = blend_doc(); + let mut s = Selection::default(); + s.set([g]); + let p = paint(); + let drag = |t: &mut dyn crate::Tool, cx: &ToolContext| { + t.pointer(cx, &PointerEvent::new(PointerKind::Down, 160.0, 311.0)); + let a = t.pointer(cx, &PointerEvent::new(PointerKind::Drag, 160.0, 351.0)); + t.pointer(cx, &PointerEvent::new(PointerKind::Up, 160.0, 351.0)); + a + }; + let straight = cx(&d, &s, &p); + let a = drag(crate::create("anchorPoint").as_mut(), &straight); + assert!( + matches!(a.last(), Some(Action::Preview(c, v)) if c == "object.blend.spine.reshapeSegment" && v["id"] == g.0 && v["segment"] == 0 && v["dy"] == 40.0), + "{a:?}" + ); + let a = drag(&mut DirectSelectionTool::new(false), &straight); + assert!(!a.iter().any(|x| matches!(x, Action::Preview(c, _) if c == "object.blend.spine.reshapeSegment")), "straight: {a:?}"); + // The spine bent upward: Direct Selection takes it from where it passes. + let mut sp = vectorcraft_geom::SubPath::polyline(&[Point::new(110.0, 310.0), Point::new(210.0, 310.0)], false); + sp.bend_segment(0, 0.5, vectorcraft_geom::Vec2::new(0.0, 20.0)); + let Some(NodeKind::Blend { spec, .. }) = d.node_mut(g).map(|n| &mut n.kind) else { panic!("blend") }; + (spec.spine, spec.key_anchors) = (Some(PathData::single(sp)), vec![0, 1]); + let mut t = DirectSelectionTool::new(false); + let curved = cx(&d, &s, &p); + t.pointer(&curved, &PointerEvent::new(PointerKind::Down, 160.0, 330.0)); + let a = t.pointer(&curved, &PointerEvent::new(PointerKind::Drag, 160.0, 345.0)); + assert_eq!(a[0], Action::Begin("Reshape Spine".into())); + assert!( + matches!(&a[1], Action::Preview(c, v) if c == "object.blend.spine.reshapeSegment" && v["segment"] == 0 && v["dx"] == 0.0 && v["dy"] == 15.0), + "{a:?}" + ); + assert_eq!(t.pointer(&curved, &PointerEvent::new(PointerKind::Up, 160.0, 345.0)), vec![Action::Commit]); +} + +/// Shift held while a curved segment is dragged sends `perpendicular`. The test drags a path's +/// segment with Direct Selection, and a blend spine's segment with Direct Selection and with the +/// Anchor Point tool. +#[test] +fn shift_reshapes_a_segment_perpendicular() { + let shift = Mods { shift: true, ..Mods::default() }; + let p = paint(); + let drag = |t: &mut dyn crate::Tool, cx: &ToolContext, from: (f64, f64), mods: Mods| { + t.pointer(cx, &PointerEvent::new(PointerKind::Down, from.0, from.1)); + let a = t.pointer(cx, &PointerEvent::new(PointerKind::Drag, from.0 + 10.0, from.1 - 20.0).with_mods(mods)); + t.pointer(cx, &PointerEvent::new(PointerKind::Up, from.0 + 10.0, from.1 - 20.0).with_mods(mods)); + a + }; + let sent = |a: &[Action], cmd: &str| match a.last() { + Some(Action::Preview(c, v)) if c == cmd => v["perpendicular"].as_bool(), + _ => None, + }; + let (d, _) = arch_doc(); + let s = Selection::default(); + let arch = cx(&d, &s, &p); + let (mut b, g, _) = blend_doc(); + let mut sp = vectorcraft_geom::SubPath::polyline(&[Point::new(110.0, 310.0), Point::new(210.0, 310.0)], false); + sp.bend_segment(0, 0.5, vectorcraft_geom::Vec2::new(0.0, 20.0)); + let Some(NodeKind::Blend { spec, .. }) = b.node_mut(g).map(|n| &mut n.kind) else { panic!("blend") }; + (spec.spine, spec.key_anchors) = (Some(PathData::single(sp)), vec![0, 1]); + let mut bs = Selection::default(); + bs.set([g]); + let spine = cx(&b, &bs, &p); + for (mods, perpendicular) in [(Mods::default(), false), (shift, true)] { + let a = drag(&mut DirectSelectionTool::new(false), &arch, (150.0, 262.5), mods); + assert_eq!(sent(&a, "path.reshapeSegment"), Some(perpendicular), "{a:?}"); + let a = drag(&mut DirectSelectionTool::new(false), &spine, (160.0, 330.0), mods); + assert_eq!(sent(&a, "object.blend.spine.reshapeSegment"), Some(perpendicular), "{a:?}"); + let a = drag(crate::create("anchorPoint").as_mut(), &spine, (160.0, 330.0), mods); + assert_eq!(sent(&a, "object.blend.spine.reshapeSegment"), Some(perpendicular), "{a:?}"); + } +} + +#[test] +fn direct_and_group_selection_pick_blend_keys() { + let (d, g, k1) = blend_doc(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let a = DirectSelectionTool::new(false).pointer(&cx, &PointerEvent::new(PointerKind::Down, 104.0, 304.0)); + assert_eq!(a, vec![Action::Exec("select.set".into(), json!({"ids": [k1.0]}))]); + let a = DirectSelectionTool::new(true).pointer(&cx, &PointerEvent::new(PointerKind::Down, 104.0, 304.0)); + assert_eq!(a, vec![Action::Exec("select.set".into(), json!({"ids": [k1.0]}))]); + // A click on a step (between the keys) still selects the blend. + let a = DirectSelectionTool::new(true).pointer(&cx, &PointerEvent::new(PointerKind::Down, 160.0, 304.0)); + assert_eq!(a, vec![Action::Exec("select.set".into(), json!({"ids": [g.0]}))]); +} + +#[test] +fn dragging_a_frame_corner_or_edge_reshapes_area_type() { + let (d, text) = doc_with_area_type(); + let p = paint(); + let s = Selection::default(); + let cx1 = cx(&d, &s, &p); + let mut t = DirectSelectionTool::new(false); + // The bottom-right corner of the 120 × 40 frame at (300, 300) selects the text... + assert_eq!( + t.pointer(&cx1, &PointerEvent::new(PointerKind::Down, 421.0, 339.0)), + vec![Action::Exec("select.set".into(), json!({"ids": [text.0]}))] + ); + // ...and drags that corner. + let a = t.pointer(&cx1, &PointerEvent::new(PointerKind::Drag, 461.0, 399.0)); + assert_eq!(a[0], Action::Begin("Reshape Type Area".into())); + assert_eq!(a[1], Action::Preview("text.reshapeArea".into(), json!({"id": text.0, "anchors": [[0, 2]], "dx": 40.0, "dy": 60.0}))); + assert_eq!(t.pointer(&cx1, &PointerEvent::new(PointerKind::Up, 461.0, 399.0)), vec![Action::Commit]); + // The top edge drags both its ends; a click alone changes nothing. + let mut s = Selection::default(); + s.add(text); + let cx2 = cx(&d, &s, &p); + assert!(t.pointer(&cx2, &PointerEvent::new(PointerKind::Down, 350.0, 301.0)).is_empty()); + let a = t.pointer(&cx2, &PointerEvent::new(PointerKind::Drag, 350.0, 281.0)); + assert_eq!(a[1], Action::Preview("text.reshapeArea".into(), json!({"id": text.0, "anchors": [[0, 0], [0, 1]], "dx": 0.0, "dy": -20.0}))); + t.pointer(&cx2, &PointerEvent::new(PointerKind::Up, 350.0, 281.0)); + assert!(t.pointer(&cx2, &PointerEvent::new(PointerKind::Down, 300.0, 340.0)).is_empty()); + assert!(t.pointer(&cx2, &PointerEvent::new(PointerKind::Up, 300.0, 340.0)).is_empty()); + // The selected frame's corners show. + assert_eq!(t.overlays(&cx2).iter().filter(|o| matches!(o, Overlay::Anchor { filled: false, .. })).count(), 4); +} + +/// #3980: type on a path shows its path's anchors and a drag of one reshapes the path, the last +/// one too although the end bracket stands on it; the bracket's stem above it drags the bracket. +#[test] +fn dragging_an_anchor_of_type_on_a_path_reshapes_its_path() { + let (d, text) = doc_with_path_type(); + let p = paint(); + let s = Selection::default(); + let cx1 = cx(&d, &s, &p); + let mut t = DirectSelectionTool::new(false); + assert_eq!( + t.pointer(&cx1, &PointerEvent::new(PointerKind::Down, 101.0, 301.0)), + vec![Action::Exec("select.set".into(), json!({"ids": [text.0]}))] + ); + let a = t.pointer(&cx1, &PointerEvent::new(PointerKind::Drag, 121.0, 261.0)); + assert_eq!(a[0], Action::Begin("Reshape Type Path".into())); + assert_eq!(a[1], Action::Preview("text.reshapePath".into(), json!({"id": text.0, "anchors": [[0, 0]], "dx": 20.0, "dy": -40.0}))); + assert_eq!(t.pointer(&cx1, &PointerEvent::new(PointerKind::Up, 121.0, 261.0)), vec![Action::Commit]); + let mut s = Selection::default(); + s.add(text); + let cx2 = cx(&d, &s, &p); + let anchors: Vec = + t.overlays(&cx2).iter().filter_map(|o| if let Overlay::Anchor { p, filled: false, .. } = o { Some(*p) } else { None }).collect(); + assert_eq!(anchors, vec![Point::new(100.0, 300.0), Point::new(400.0, 300.0)]); + assert!(t.pointer(&cx2, &PointerEvent::new(PointerKind::Down, 400.0, 300.0)).is_empty()); + let a = t.pointer(&cx2, &PointerEvent::new(PointerKind::Drag, 400.0, 260.0)); + assert_eq!(a.last(), Some(&Action::Preview("text.reshapePath".into(), json!({"id": text.0, "anchors": [[0, 1]], "dx": 0.0, "dy": -40.0})))); + t.pointer(&cx2, &PointerEvent::new(PointerKind::Up, 400.0, 260.0)); + t.pointer(&cx2, &PointerEvent::new(PointerKind::Down, 400.0, 290.0)); + let a = t.pointer(&cx2, &PointerEvent::new(PointerKind::Drag, 340.0, 290.0)); + assert!(matches!(a.last(), Some(Action::Preview(c, _)) if c == "type.pathOptions"), "{a:?}"); +} + +#[test] +fn click_anchor_selects_it() { + let (d, id) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = DirectSelectionTool::new(false); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); + assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "set"}))]); +} + +/// Pressing an anchor of a path whose anchors are all selected (stored as the whole path) +/// keeps them all selected, so the drag moves them all; Shift still deselects that anchor. +#[test] +fn pressing_an_anchor_of_a_fully_selected_path_keeps_the_selection() { + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.set([id]); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = DirectSelectionTool::new(false); + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)).is_empty()); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 120.0, 100.0)); + assert!(matches!(a.as_slice(), [Action::Begin(_), Action::Preview(c, _)] if c == "object.transform"), "{a:?}"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 120.0, 100.0)); + let shift = Mods { shift: true, ..Default::default() }; + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0).with_mods(shift)); + assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "toggle"}))]); +} + +/// A click (no drag) on an anchor that is selected with others, whether the whole path is or +/// some of its anchors, selects that anchor alone, as in Illustrator: a press keeps the +/// selection so a drag moves it all, the release without a drag narrows it. Shift still +/// toggles, and a click on a path's only selected anchor changes nothing visible. +#[test] +fn a_click_on_a_selected_anchor_selects_it_alone() { + let (d, id) = doc_with_rect(); + let p = paint(); + let alone = vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "set"}))]; + let mut whole = Selection::default(); + whole.set([id]); + for s in [whole, anchors_of(id, &[(0, 0), (0, 1)])] { + let cx = cx(&d, &s, &p); + let mut t = DirectSelectionTool::new(false); + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)).is_empty(), "{s:?}"); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 100.0, 100.0)), alone, "{s:?}"); + // Dragged instead: moved, selection kept. + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)).is_empty()); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 120.0, 100.0)); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 120.0, 100.0)), vec![Action::Commit], "{s:?}"); + } + // An anchor not yet selected is selected on the press, and the release adds nothing. + let none = Selection::default(); + let cx = cx(&d, &none, &p); + let mut t = DirectSelectionTool::new(false); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)), alone); + assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 100.0, 100.0)).is_empty()); +} + +/// Alt-pressing an anchor picks the whole path, as Alt on a segment does, and the drag copies +/// it as the Selection tool's Alt-drag does. +#[test] +fn alt_dragging_an_anchor_copies_the_whole_path() { + let (d, id) = doc_with_rect(); + let alt = Mods { alt: true, ..Default::default() }; + let mut whole = Selection::default(); + whole.set([id]); + let p = paint(); + for (s, pick) in [(Selection::default(), true), (anchors_of(id, &[(0, 0)]), true), (whole, false)] { + let cx = cx(&d, &s, &p); + let mut t = DirectSelectionTool::new(false); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0).with_mods(alt)); + let set = vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]; + assert_eq!(a, if pick { set } else { vec![] }, "{s:?}"); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 120.0, 100.0).with_mods(alt)); + assert!(matches!(a.as_slice(), [Action::Begin(_), Action::Preview(c, v)] if c == "object.transform" && v["copy"] == true), "{a:?}"); + } +} + +#[test] +fn marquee_selects_anchors() { + let (d, id) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = DirectSelectionTool::new(false); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 50.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 250.0, 150.0)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 150.0)); + assert_eq!(a, vec![Action::Exec("select.anchorsMany".into(), json!({"items": [{"id": id.0, "anchors": [[0, 1]]}], "mode": "set"}))]); +} + +#[test] +fn marquee_selects_ruler_guides() { + let (mut d, id) = doc_with_rect(); + d.guides.push(vectorcraft_doc::Guide::new(false, 80.0)); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = DirectSelectionTool::new(false); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 50.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 250.0, 150.0)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 150.0)); + assert_eq!( + a, + vec![ + Action::Exec("select.anchorsMany".into(), json!({"items": [{"id": id.0, "anchors": [[0, 1]]}], "mode": "set"})), + Action::Exec("guide.select".into(), json!({"indexes": [0], "toggle": true})), + ] + ); +} + +/// #2441: Group Selection picks one path of a compound path (the outer shape or a hole); +/// a second click on it adds the compound path, like a group around a member. +#[test] +fn group_selection_picks_a_path_of_a_compound_path() { + let mut d = vectorcraft_doc::Document::new(500.0, 500.0); + let l = d.layers[0].id; + let (outer, hole, compound) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); + let rect = |id, a: f64, b: f64| { + std::sync::Arc::new(vectorcraft_doc::Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(a, a, b, b)), Default::default())) + }; + let mut c = vectorcraft_doc::Node::new( + compound, + vectorcraft_doc::NodeKind::Compound { + children: vec![rect(outer, 0.0, 200.0), rect(hole, 50.0, 150.0)], + rule: vectorcraft_geom::FillRule::EvenOdd, + }, + ); + c.appearance = vectorcraft_doc::Appearance::default_art().into(); + d.insert(Some(l), 0, c).unwrap(); + let p = paint(); + let none = Selection::default(); + let click = |s: &Selection, x, y| DirectSelectionTool::new(true).pointer(&cx(&d, s, &p), &PointerEvent::new(PointerKind::Down, x, y)); + let set = |id: NodeId| vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]; + assert_eq!(click(&none, 25.0, 100.0), set(outer), "the fill between the outline and the hole"); + assert_eq!(click(&none, 50.0, 100.0), set(hole), "the hole's outline"); + assert!(click(&none, 100.0, 100.0).is_empty(), "inside the hole nothing is hit"); + let mut s = Selection::default(); + s.set([outer]); + assert_eq!(click(&s, 25.0, 100.0), vec![Action::Exec("select.add".into(), json!({"ids": [compound.0]}))]); +} + +/// Group Selection Shift-click on a selected compound-path member deselects it on release. +#[test] +fn group_selection_shift_click_deselects_a_compound_path_member() { + let mut d = vectorcraft_doc::Document::new(500.0, 500.0); + let l = d.layers[0].id; + let (outer, hole, compound) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); + let rect = |id, a: f64, b: f64| { + std::sync::Arc::new(vectorcraft_doc::Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(a, a, b, b)), Default::default())) + }; + let mut c = vectorcraft_doc::Node::new( + compound, + vectorcraft_doc::NodeKind::Compound { + children: vec![rect(outer, 0.0, 200.0), rect(hole, 50.0, 150.0)], + rule: vectorcraft_geom::FillRule::EvenOdd, + }, + ); + c.appearance = vectorcraft_doc::Appearance::default_art().into(); + d.insert(Some(l), 0, c).unwrap(); + let p = paint(); + let mut s = Selection::default(); + s.set([outer]); + let shift = Mods { shift: true, ..Mods::default() }; + let cx = cx(&d, &s, &p); + let mut t = DirectSelectionTool::new(true); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 25.0, 100.0).with_mods(shift)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 25.0, 100.0).with_mods(shift)); + assert_eq!(a, vec![Action::Exec("select.toggle".into(), json!({"id": outer.0}))]); +} + +/// A path inside an inner group, a sibling path outside it, and the groups' ids. +/// The inside path is the rectangle at (100, 100); the outside one at (300, 300). +fn nested_group_doc() -> (vectorcraft_doc::Document, NodeId, NodeId, NodeId, NodeId) { + let mut d = vectorcraft_doc::Document::new(500.0, 500.0); + let l = d.layers[0].id; + let (inside, outside, inner, outer) = (d.alloc_id(), d.alloc_id(), d.alloc_id(), d.alloc_id()); + let path = |id, x| { + std::sync::Arc::new(vectorcraft_doc::Node::path( + id, + vectorcraft_geom::shapes::rectangle(Rect::new(x, x, x + 100.0, x + 100.0)), + vectorcraft_doc::Appearance::default_art(), + )) + }; + let inner_g = vectorcraft_doc::Node::group(inner, vec![path(inside, 100.0)]); + let outer_g = vectorcraft_doc::Node::group(outer, vec![std::sync::Arc::new(inner_g), path(outside, 300.0)]); + d.insert(Some(l), 0, outer_g).unwrap(); + (d, inside, outside, inner, outer) +} + +/// Isolation mode: a marquee and an anchor click take the path inside the isolated group and +/// leave the path beside it. Group Selection stops at the isolated group instead of adding +/// the group around it. +#[test] +fn isolation_mode_leaves_art_outside_the_isolated_group() { + let (d, inside, outside, inner, outer) = nested_group_doc(); + let p = paint(); + let none = Selection::default(); + let isolated = ToolContext { isolation: Some(inner), ..cx(&d, &none, &p) }; + let mut t = DirectSelectionTool::new(false); + t.pointer(&isolated, &PointerEvent::new(PointerKind::Down, 50.0, 50.0)); + let a = t.pointer(&isolated, &PointerEvent::new(PointerKind::Up, 450.0, 450.0)); + let items = a.first().and_then(|act| match act { + Action::Exec(c, v) if c == "select.anchorsMany" => v.get("items").cloned(), + _ => None, + }); + let items = items.unwrap(); + assert!(items.as_array().is_some_and(|a| a.iter().all(|item| item["id"] == inside.0)), "marquee: {a:?}"); + assert!(items.as_array().is_some_and(|a| !a.is_empty()), "the path inside is still selected: {a:?}"); + + let a = DirectSelectionTool::new(false).pointer(&isolated, &PointerEvent::new(PointerKind::Down, 300.0, 300.0)); + assert!(a.is_empty(), "a click on {outside:?}, outside isolation, starts a marquee: {a:?}"); + let a = DirectSelectionTool::new(false).pointer(&isolated, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); + assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": inside.0, "anchors": [[0, 0]], "mode": "set"}))]); + + let mut s = Selection::default(); + s.set([inside, inner]); + let climbed = ToolContext { isolation: Some(inner), ..cx(&d, &s, &p) }; + let a = DirectSelectionTool::new(true).pointer(&climbed, &PointerEvent::new(PointerKind::Down, 150.0, 150.0)); + assert!(a.is_empty(), "does not add the group outside isolation ({outer:?}): {a:?}"); +} + +/// #927: a marquee across type selects the type object whole, beside the anchors it holds. +#[test] +fn marquee_selects_type_it_touches() { + // The rectangle at (100, 100)–(200, 200) and area type at (300, 300). + let (d, text) = doc_with_area_type(); + let rect = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = DirectSelectionTool::new(false); + // From empty canvas across the type to inside the rectangle (its bottom-right corner). + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 450.0, 450.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 150.0)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 150.0)); + let items = json!([{"id": rect.0, "anchors": [[0, 2]]}, {"id": text.0, "anchors": []}]); + assert_eq!(a, vec![Action::Exec("select.anchorsMany".into(), json!({"items": items, "mode": "set"}))]); + // Away from the type, only anchors. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 50.0)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 150.0)); + assert_eq!(a, vec![Action::Exec("select.anchorsMany".into(), json!({"items": [{"id": rect.0, "anchors": [[0, 1]]}], "mode": "set"}))]); +} + +/// A marquee across a member of an Intertwine group selects the member's anchors, as in a +/// plain group, and not the Intertwine group whole. +#[test] +fn marquee_selects_the_anchors_of_intertwine_members() { + let mut d = vectorcraft_doc::Document::new(500.0, 500.0); + let l = d.layers[0].id; + let (a, b, g) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); + let path = |id, x| { + std::sync::Arc::new(vectorcraft_doc::Node::path( + id, + vectorcraft_geom::shapes::rectangle(Rect::new(x, 100.0, x + 100.0, 200.0)), + vectorcraft_doc::Appearance::default_art(), + )) + }; + let intertwine = vectorcraft_doc::Node::new(g, vectorcraft_doc::NodeKind::Intertwine { children: vec![path(a, 100.0), path(b, 300.0)] }); + d.insert(Some(l), 0, intertwine).unwrap(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + for group in [false, true] { + let mut t = DirectSelectionTool::new(group); + // Around the first member's top-right corner. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 50.0)); + let actions = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 150.0)); + let items = json!([{"id": a.0, "anchors": [[0, 1]]}]); + assert_eq!(actions, vec![Action::Exec("select.anchorsMany".into(), json!({"items": items, "mode": "set"}))], "group: {group}"); + } +} + +/// Shift-drag a marquee: the anchors inside toggle (#483), with Group Selection too. +#[test] +fn shift_marquee_toggles_anchors() { + let (d, id) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let shift = Mods { shift: true, ..Default::default() }; + for group in [false, true] { + let mut t = DirectSelectionTool::new(group); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 50.0).with_mods(shift)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 250.0, 150.0).with_mods(shift)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 150.0).with_mods(shift)); + assert_eq!(a, vec![Action::Exec("select.anchorsMany".into(), json!({"items": [{"id": id.0, "anchors": [[0, 1]]}], "mode": "toggle"}))]); + } +} + +/// Group Selection: E pressed during a marquee drag makes it select the objects entirely inside +/// it whole, a group's member without its group. Direct Selection does not take E. +#[test] +fn e_during_a_group_selection_marquee_selects_the_objects_inside() { + use vectorcraft_doc::{Appearance, Node}; + let (mut d, id) = doc_with_rect(); + let l = d.layers[0].id; + let rect = |id, r| Node::path(id, vectorcraft_geom::shapes::rectangle(r), Appearance::default_art()); + let (g, a, b) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); + d.insert(Some(l), 1, Node::group(g, vec![])).unwrap(); + d.insert(Some(g), 0, rect(a, Rect::new(100.0, 220.0, 120.0, 240.0))).unwrap(); + d.insert(Some(g), 1, rect(b, Rect::new(100.0, 300.0, 120.0, 320.0))).unwrap(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let e = ToolKey::Letter('e'); + let drag = |group: bool, mods: Mods| { + let mut t = DirectSelectionTool::new(group); + assert!(!t.claims_key(&cx, e), "no marquee yet"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 50.0).with_mods(mods)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 300.0, 250.0).with_mods(mods)); + assert_eq!(t.claims_key(&cx, e), group); + t.key(&cx, e, Mods::default()); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 300.0, 250.0).with_mods(mods)) + }; + assert_eq!(drag(true, Mods::default()), vec![Action::Exec("select.set".into(), json!({"ids": [id.0, a.0]}))]); + let shift = Mods { shift: true, ..Default::default() }; + assert_eq!(drag(true, shift), vec![Action::Exec("select.toggle".into(), json!({"ids": [id.0, a.0]}))]); + // Direct Selection's marquee still selects the anchors inside it. + let anchors = |id: NodeId, list: Value| json!({"id": id.0, "anchors": list}); + let all = json!([[0, 0], [0, 1], [0, 2], [0, 3]]); + assert_eq!( + drag(false, Mods::default()), + vec![Action::Exec("select.anchorsMany".into(), json!({"items": [anchors(id, all.clone()), anchors(a, all)], "mode": "set"}))] + ); +} + +/// An open path from (100, 300) to (200, 300) arching up through (150, 262.5). +fn arch_doc() -> (vectorcraft_doc::Document, NodeId) { + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let id = d.alloc_id(); + let mut sp = vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 300.0), Point::new(200.0, 300.0)], false); + sp.anchors[0].h_out = Point::new(100.0, 250.0); + sp.anchors[1].h_in = Point::new(200.0, 250.0); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, PathData::single(sp), vectorcraft_doc::Appearance::default_art())).unwrap(); + (d, id) +} + +/// #1743: Escape mid-drag cancels it; the release after it does nothing more. +#[test] +fn escape_cancels_a_drag() { + let (d, _) = arch_doc(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = DirectSelectionTool::new(false); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 262.5)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 240.0)); + assert!(t.busy()); + assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel]); + assert!(!t.busy()); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 220.0)), vec![]); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 220.0)), vec![]); + assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![], "nothing to cancel"); +} + +#[test] +fn dragging_a_curved_segment_reshapes_it_and_a_straight_one_moves_its_anchors() { + let (d, arch) = arch_doc(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = DirectSelectionTool::new(false); + // The arch: its segment gets selected, and the drag bends it. + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 262.5)); + assert_eq!(a, vec![Action::Exec("select.segments".into(), json!({"id": arch.0, "segments": [[0, 0]]}))]); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 242.5)); + assert_eq!(a[0], Action::Begin("Reshape".into())); + let Action::Preview(cmd, v) = &a[1] else { panic!("{a:?}") }; + assert_eq!( + (cmd.as_str(), v["id"].clone(), v["segment"].clone(), v["dx"].clone(), v["dy"].clone()), + ("path.reshapeSegment", json!(arch.0), json!(0), json!(0.0), json!(-20.0)) + ); + assert!((v["t"].as_f64().unwrap() - 0.5).abs() < 0.01, "{v}"); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 242.5)), vec![Action::Commit]); + // A straight edge of the rectangle: its two anchors get selected and move. + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 101.0)); + assert!(matches!(&a[..], [Action::Exec(c, v)] if c == "select.segments" && v["segments"] == json!([[0, 0]])), "{a:?}"); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 81.0)); + assert_eq!(a[1], Action::Preview("path.moveAnchors".into(), json!({"dx": 0.0, "dy": -20.0}))); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 81.0)); + // Alt on the stroke picks the whole path, and Alt-drag copies it. + let alt = Mods { alt: true, ..Default::default() }; + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 101.0).with_mods(alt)); + assert!(matches!(&a[..], [Action::Exec(c, _)] if c == "select.set"), "{a:?}"); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 81.0).with_mods(alt)); + assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, v)] if c == "object.transform" && v["copy"] == true), "{a:?}"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 81.0).with_mods(alt)); + // The fill still selects the whole path, and a path selected as a whole still moves whole. + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 150.0)); + assert!(matches!(&a[..], [Action::Exec(c, _)] if c == "select.set"), "{a:?}"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 150.0)); + let rect = d.layers[0].children().unwrap()[0].id; + let mut whole = Selection::default(); + whole.set([rect]); + let cx = crate::testutil::cx(&d, &whole, &p); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 101.0)), vec![]); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 81.0)); + assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, _)] if c == "object.transform"), "{a:?}"); +} + +/// #494: the handles shown are the handles dragged. A path selected as a whole (the one the +/// Pen just drew) shows all its handles, and they drag; the direct-selected anchors' handles +/// only otherwise; none with Show handles when multiple anchors are selected off and several +/// anchors selected. A handle shorter than the tolerance leaves its anchor to be picked, and +/// the tolerance is the preference's, in screen pixels at any zoom. +#[test] +fn the_handles_shown_are_the_handles_dragged() { + let (d, arch) = arch_doc(); + let p = paint(); + // Does a press at (x, y) take a handle? + let handle = |c: &ToolContext, x: f64, y: f64| { + DirectSelectionTool::new(false).pointer(c, &PointerEvent::new(PointerKind::Down, x, y)) == vec![Action::Begin("Reshape".into())] + }; + let mut whole = Selection::default(); + whole.set([arch]); + let c = cx(&d, &whole, &p); + assert!(handle(&c, 101.0, 251.0), "the first anchor's handle, the path selected whole"); + let mut t = DirectSelectionTool::new(false); + t.pointer(&c, &PointerEvent::new(PointerKind::Down, 101.0, 251.0)); + let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 120.0, 240.0)); + assert!(matches!(&a[..], [Action::Preview(c, v)] if c == "path.setHandle" && v["anchor"] == 0 && v["which"] == "out"), "{a:?}"); + // Only the second anchor direct-selected: the first one's handle is hidden. + let second = anchors_of(arch, &[(0, 1)]); + assert!(!handle(&cx(&d, &second, &p), 101.0, 251.0)); + assert!(handle(&cx(&d, &second, &p), 201.0, 251.0)); + // Show handles when multiple anchors are selected off: the whole path's are hidden. + assert!(!handle(&ToolContext { handles_multiple: false, ..cx(&d, &whole, &p) }, 101.0, 251.0)); + assert!(handle(&ToolContext { handles_multiple: false, ..cx(&d, &second, &p) }, 201.0, 251.0)); + // The tolerance is the preference's, in screen pixels: 3 px is 0.75 pt at 400%. + let zoomed = ToolContext { zoom: 4.0, ..cx(&d, &whole, &p) }; + assert!(!handle(&zoomed, 101.0, 251.0)); + assert!(handle(&zoomed, 100.5, 250.5)); + assert!(handle(&ToolContext { selection_tolerance: 8.0, ..cx(&d, &whole, &p) }, 106.0, 254.0)); +} + +/// #494: a handle 2 pt long, inside the tolerance round its anchor, doesn't hide the anchor. +#[test] +fn a_short_handle_leaves_its_anchor_to_be_picked() { + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let id = d.alloc_id(); + let mut sp = vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 300.0), Point::new(200.0, 300.0)], false); + sp.anchors[0].h_out = Point::new(102.0, 300.0); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, PathData::single(sp), vectorcraft_doc::Appearance::default_art())).unwrap(); + let s = anchors_of(id, &[(0, 0)]); + let p = paint(); + let c = cx(&d, &s, &p); + let mut t = DirectSelectionTool::new(false); + assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 100.0, 300.0)), vec![], "the anchor, already selected"); + let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 90.0, 290.0)); + assert_eq!(a[0], Action::Begin("Move".into())); + t.pointer(&c, &PointerEvent::new(PointerKind::Up, 90.0, 290.0)); + assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 102.5, 300.0)), vec![Action::Begin("Reshape".into())], "the handle"); +} + +/// Direct Selection's cursors from Illustrator's manual: the white arrow at rest; over an anchor +/// a square, hollow on an unselected path and filled on a selected one; the Reshape Segment +/// cursor over a curved segment a drag reshapes, not over a straight one (which moves) or a +/// path selected whole (which moves whole); Group Selection's plus with Alt and as its tool. +#[test] +fn hovering_shows_the_anchor_square_reshape_and_group_cursors() { + let (d, arch) = arch_doc(); + let p = paint(); + let m = Mods::default(); + let pt = Point::new; + let none = Selection::default(); + let c = cx(&d, &none, &p); + let t = DirectSelectionTool::new(false); + assert_eq!(t.cursor(&c, pt(20.0, 20.0), m), Cursor::ArrowHollow); + assert_eq!(t.cursor(&c, pt(100.0, 300.0), m), Cursor::DirectAnchor); + assert_eq!(t.cursor(&c, pt(150.0, 262.5), m), Cursor::ReshapeSegment); + assert_eq!(t.cursor(&c, pt(150.0, 100.0), m), Cursor::ArrowHollow, "a straight segment moves"); + assert_eq!(t.cursor(&c, pt(150.0, 150.0), m), Cursor::ArrowHollow, "the fill"); + let alt = Mods { alt: true, ..m }; + assert_eq!(t.cursor(&c, pt(100.0, 300.0), alt), Cursor::GroupSelection); + assert_eq!(DirectSelectionTool::new(true).cursor(&c, pt(100.0, 300.0), m), Cursor::GroupSelection); + let mut whole = Selection::default(); + whole.set([arch]); + let c = cx(&d, &whole, &p); + assert_eq!(t.cursor(&c, pt(200.0, 300.0), m), Cursor::DirectAnchorSelected); + assert_eq!(t.cursor(&c, pt(150.0, 262.5), m), Cursor::ArrowHollow, "a path selected whole moves whole"); + // One anchor selected: its handle end shows and keeps the plain arrow, Alt or not (a press + // takes the handle). + let s = anchors_of(arch, &[(0, 0)]); + let c = cx(&d, &s, &p); + assert_eq!(t.cursor(&c, pt(100.0, 250.0), m), Cursor::ArrowHollow, "a handle end"); + assert_eq!(t.cursor(&c, pt(100.0, 250.0), alt), Cursor::ArrowHollow, "a handle end with Alt"); + assert_eq!(t.cursor(&c, pt(200.0, 300.0), m), Cursor::DirectAnchorSelected); + // Bending the curve keeps the Reshape Segment cursor. + let mut t = DirectSelectionTool::new(false); + let c = cx(&d, &none, &p); + t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 262.5)); + t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 150.0, 230.0)); + assert_eq!(t.cursor(&c, pt(150.0, 230.0), m), Cursor::ReshapeSegment); +} + +/// A drag shows the filled arrow, hollow while it snaps to a point; an Alt-drag copy shows the +/// double arrowhead. +#[test] +fn drags_show_the_filled_arrow_hollow_on_a_snap_and_alt_the_copy_cursor() { + let (d, _, b) = two_squares(); + let p = paint(); + let none = Mods::default(); + let at = |kind, x, y, m: Mods| PointerEvent::new(kind, x, y).with_mods(m); + let one = anchors_of(b, &[(0, 0)]); + let c = cx(&d, &one, &p); + let mut t = DirectSelectionTool::new(false); + t.pointer(&c, &at(PointerKind::Down, 300.0, 300.0, none)); + t.pointer(&c, &at(PointerKind::Drag, 250.0, 260.0, none)); + assert_eq!(t.cursor(&c, Point::new(250.0, 260.0), none), Cursor::Arrow); + // Onto A's bottom-right corner. + t.pointer(&c, &at(PointerKind::Drag, 203.0, 199.0, none)); + assert_eq!(t.cursor(&c, Point::new(203.0, 199.0), none), Cursor::ArrowHollow); + t.pointer(&c, &at(PointerKind::Up, 203.0, 199.0, none)); + // Shift with a Constrain Angle of 10°: the constraint alone isn't a snap. + let shift = Mods { shift: true, ..none }; + let tilted = ToolContext { constrain_angle: 10.0, ..cx(&d, &one, &p) }; + t.pointer(&tilted, &at(PointerKind::Down, 300.0, 300.0, shift)); + t.pointer(&tilted, &at(PointerKind::Drag, 380.0, 262.0, shift)); + assert_eq!(t.cursor(&tilted, Point::new(380.0, 262.0), shift), Cursor::Arrow); + t.pointer(&tilted, &at(PointerKind::Up, 380.0, 262.0, shift)); + let mut whole = Selection::default(); + whole.set([b]); + let c = cx(&d, &whole, &p); + let alt = Mods { alt: true, ..none }; + t.pointer(&c, &at(PointerKind::Down, 300.0, 300.0, alt)); + t.pointer(&c, &at(PointerKind::Drag, 250.0, 260.0, alt)); + assert_eq!(t.cursor(&c, Point::new(250.0, 260.0), alt), Cursor::SelectionCopy); +} + +/// [`doc_with_rect`] (A, 100..200) and a second square B, 300..400: (A, B). +fn two_squares() -> (vectorcraft_doc::Document, NodeId, NodeId) { + let (mut d, a) = doc_with_rect(); + let l = d.layers[0].id; + let b = d.alloc_id(); + let sq = vectorcraft_geom::shapes::rectangle(Rect::new(300.0, 300.0, 400.0, 400.0)); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(b, sq, vectorcraft_doc::Appearance::default_art())).unwrap(); + (d, a, b) +} + +/// An anchor of a selected path is picked before the one of an unselected path on a layer +/// above that covers it, as Highlight anchors on mouse over already marks it (#1508); with +/// nothing selected, the topmost path's anchor is picked. +#[test] +fn a_selected_path_s_anchor_is_picked_before_one_above_it() { + let (mut d, a) = doc_with_rect(); + let top = d.add_layer(None); + let b = d.alloc_id(); + let sq = vectorcraft_geom::shapes::rectangle(Rect::new(101.0, 100.0, 201.0, 200.0)); + d.insert(Some(top), 0, vectorcraft_doc::Node::path(b, sq, vectorcraft_doc::Appearance::default_art())).unwrap(); + let p = paint(); + let pick = |id: NodeId| vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "set"}))]; + let s = anchors_of(a, &[(0, 2)]); + let mut t = DirectSelectionTool::new(false); + assert_eq!(t.pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, 101.0, 100.0)), pick(a)); + let mut whole = Selection::default(); + whole.set([a]); + let mut t = DirectSelectionTool::new(false); + let cx2 = cx(&d, &whole, &p); + assert!(t.pointer(&cx2, &PointerEvent::new(PointerKind::Down, 101.0, 100.0)).is_empty()); + assert_eq!(t.pointer(&cx2, &PointerEvent::new(PointerKind::Up, 101.0, 100.0)), pick(a)); + let none = Selection::default(); + let mut t = DirectSelectionTool::new(false); + assert_eq!(t.pointer(&cx(&d, &none, &p), &PointerEvent::new(PointerKind::Down, 101.0, 100.0)), pick(b)); +} + +/// The anchors `anchors` of path `id` direct-selected. +fn anchors_of(id: NodeId, anchors: &[AnchorRef]) -> Selection { + let mut s = Selection::default(); + s.set([id]); + s.anchors.insert(id, anchors.iter().copied().collect()); + s +} + +/// Press at `from`, drag to `to`: the move the drag previews, and the smart guide labels shown. +fn drag(t: &mut DirectSelectionTool, cx: &ToolContext, from: (f64, f64), to: (f64, f64), mods: Mods) -> (Value, Vec) { + t.pointer(cx, &PointerEvent::new(PointerKind::Down, from.0, from.1).with_mods(mods)); + let a = t.pointer(cx, &PointerEvent::new(PointerKind::Drag, to.0, to.1).with_mods(mods)); + let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") }; + let labels = t + .overlays(cx) + .into_iter() + .filter_map(|o| match o { + Overlay::Label { text, .. } => Some(text), + _ => None, + }) + .collect(); + assert_eq!(t.pointer(cx, &PointerEvent::new(PointerKind::Up, to.0, to.1).with_mods(mods)), vec![Action::Commit]); + assert!(t.overlays(cx).iter().all(|o| !matches!(o, Overlay::Label { .. })), "the guides go with the drag"); + (v.clone(), labels) +} + +/// Alt-dragging a path by an anchor copies it, and that anchor snaps as a dragged anchor does: +/// onto another path's anchor, or onto an anchor of the original the copy leaves behind. +#[test] +fn alt_dragged_copies_snap_by_the_anchor_pressed() { + let (d, _, b) = two_squares(); + let p = paint(); + let alt = Mods { alt: true, ..Default::default() }; + let mut whole = Selection::default(); + whole.set([b]); + let c = cx(&d, &whole, &p); + let mut t = DirectSelectionTool::new(false); + let copy = + |dx: f64, dy: f64| json!({"matrix": matrix_json(vectorcraft_geom::Affine::translate(vectorcraft_geom::Vec2::new(dx, dy))), "copy": true}); + // B's top-left corner, pressed 1 pt off, onto A's bottom-right one. + let (v, labels) = drag(&mut t, &c, (301.0, 301.0), (203.0, 199.0), alt); + assert_eq!((v, labels), (copy(-100.0, -100.0), vec!["anchor".to_string()])); + // B's top-left corner onto the original B's top-right one: the copy sits beside it. + let (v, labels) = drag(&mut t, &c, (300.0, 300.0), (402.0, 301.0), alt); + assert_eq!((v, labels), (copy(100.0, 0.0), vec!["anchor".to_string()])); + // The history says what the drag did. + t.pointer(&c, &PointerEvent::new(PointerKind::Down, 300.0, 300.0).with_mods(alt)); + let out = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 250.0, 250.0).with_mods(alt)); + assert_eq!(out.first(), Some(&Action::Begin("Copy".into()))); +} + +/// Dragging a fully selected path by an anchor moves it whole, so Alt pressed or released +/// during the drag turns the copy on and off, as with the Selection tool; the anchor pressed +/// snaps all along, onto the original only while it is left behind. +#[test] +fn alt_during_a_drag_by_an_anchor_toggles_the_copy() { + let (d, _, b) = two_squares(); + let p = paint(); + let (none, alt) = (Mods::default(), Mods { alt: true, ..Default::default() }); + let mut whole = Selection::default(); + whole.set([b]); + let c = cx(&d, &whole, &p); + let mut t = DirectSelectionTool::new(false); + let preview = |dx: f64, dy: f64, copy: bool| { + Action::Preview( + "object.transform".into(), + json!({"matrix": matrix_json(vectorcraft_geom::Affine::translate(vectorcraft_geom::Vec2::new(dx, dy))), "copy": copy}), + ) + }; + assert!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 300.0, 300.0)).is_empty()); + assert_eq!( + t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 250.0, 250.0)), + vec![Action::Begin("Move".into()), preview(-50.0, -50.0, false)] + ); + // The tool sees the document as previewed: B moved along, not where the copy leaves it. + let (mut moved, _, b2) = two_squares(); + let l = moved.layers[0].id; + let sq = vectorcraft_geom::shapes::rectangle(Rect::new(250.0, 250.0, 350.0, 350.0)); + moved.remove(b2).unwrap(); + moved.insert(Some(l), 1, vectorcraft_doc::Node::path(b, sq, vectorcraft_doc::Appearance::default_art())).unwrap(); + let c = cx(&moved, &whole, &p); + // Alt down mid-drag: a copy, whose pressed corner can land on the original's top-right one. + assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 402.0, 301.0).with_mods(alt)), vec![preview(100.0, 0.0, true)]); + // Alt up: a move again, which leaves nothing behind to snap to. + assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 402.0, 301.0).with_mods(none)), vec![preview(102.0, 1.0, false)]); + assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 402.0, 301.0)), vec![Action::Commit]); + // With only some anchors selected, the drag still moves just those. + let one = anchors_of(b, &[(0, 0)]); + let c = cx(&d, &one, &p); + t.pointer(&c, &PointerEvent::new(PointerKind::Down, 300.0, 300.0)); + let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 250.0, 260.0).with_mods(alt)); + assert!(matches!(a.last(), Some(Action::Preview(c, _)) if c == "path.moveAnchors"), "{a:?}"); +} + +/// A dragged anchor lands on another path's anchor or segment, lines up with the other anchors +/// of its own path, and with several selected the one pressed on snaps, the others following. +#[test] +fn dragged_anchors_snap_to_points_segments_and_smart_guides() { + let (d, _, b) = two_squares(); + let p = paint(); + let none = Mods::default(); + let one = anchors_of(b, &[(0, 0)]); + let c = cx(&d, &one, &p); + let mut t = DirectSelectionTool::new(false); + // B's top-left corner, pressed 1 pt off, onto A's bottom-right one. + let (v, labels) = drag(&mut t, &c, (301.0, 301.0), (203.0, 199.0), none); + assert_eq!((v, labels), (json!({"dx": -100.0, "dy": -100.0}), vec!["anchor".to_string()])); + // Onto A's right side. + let (v, labels) = drag(&mut t, &c, (300.0, 300.0), (203.0, 150.0), none); + assert_eq!((v, labels), (json!({"dx": -100.0, "dy": -150.0}), vec!["path".to_string()])); + // In line with B's own bottom-left corner, which stays put. + let (v, labels) = drag(&mut t, &c, (300.0, 300.0), (303.0, 330.0), none); + assert_eq!((v, labels), (json!({"dx": 0.0, "dy": 30.0}), vec!["align".to_string()])); + // Shift keeps the move at 45° steps and still snaps along that line (#886): on the + // diagonal from B's corner, onto A's bottom-right corner… + let shift = Mods { shift: true, ..none }; + let (v, labels) = drag(&mut t, &c, (300.0, 300.0), (203.0, 199.0), shift); + let near = |v: &serde_json::Value, (x, y): (f64, f64)| (v["dx"].as_f64().unwrap() - x).hypot(v["dy"].as_f64().unwrap() - y) < 1e-9; + assert!(near(&v, (-100.0, -100.0)) && labels == ["anchor"], "{v} {labels:?}"); + // …and with nothing in reach, at the pointer's distance along its angle. + let (v, labels) = drag(&mut t, &c, (300.0, 300.0), (380.0, 302.0), shift); + let s = move_delta(Point::new(300.0, 300.0), Point::new(380.0, 302.0), true, 0.0); + assert!(near(&v, (s.x, s.y)) && labels.is_empty(), "{v} {labels:?}"); + // Two anchors: the one pressed on (B's top-right) lands on A's corner. + let two = anchors_of(b, &[(0, 0), (0, 1)]); + let c = cx(&d, &two, &p); + let (v, _) = drag(&mut t, &c, (400.0, 300.0), (202.0, 198.0), none); + assert_eq!(v, json!({"dx": -200.0, "dy": -100.0})); + // Smart Guides off: Snap to Point pulls within its distance (2 px) only. + let c = ToolContext { smart_guides: false, ..cx(&d, &one, &p) }; + assert_eq!(drag(&mut t, &c, (300.0, 300.0), (201.0, 199.0), none).0, json!({"dx": -100.0, "dy": -100.0})); + assert_eq!(drag(&mut t, &c, (300.0, 300.0), (203.0, 199.0), none).0, json!({"dx": -97.0, "dy": -101.0})); + let c = ToolContext { smart_guides: false, snap_to_point: false, ..cx(&d, &one, &p) }; + assert_eq!(drag(&mut t, &c, (300.0, 300.0), (201.0, 199.0), none).0, json!({"dx": -99.0, "dy": -101.0})); +} + +/// #2120: the end of a slanted straight line, dragged roughly along it, stays on its direction +/// (Line Extension) and the guide runs along it from the other end. +#[test] +fn dragged_end_of_a_slanted_line_stays_on_its_direction() { + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let id = d.alloc_id(); + let line = vectorcraft_geom::shapes::line(Point::new(300.0, 400.0), Point::new(380.0, 340.0)); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, line, vectorcraft_doc::Appearance::default_art())).unwrap(); + let p = paint(); + let end = anchors_of(id, &[(0, 1)]); + let c = cx(&d, &end, &p); + let mut t = DirectSelectionTool::new(false); + t.pointer(&c, &PointerEvent::new(PointerKind::Down, 380.0, 340.0)); + let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 421.5, 312.0)); + let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") }; + let (dx, dy) = (v["dx"].as_f64().unwrap(), v["dy"].as_f64().unwrap()); + assert!((dx - 40.0).abs() < 1e-9 && (dy + 30.0).abs() < 1e-9, "{v}"); + let guides: Vec<(Point, Point)> = + t.overlays(&c).into_iter().filter_map(|o| if let Overlay::Line { a, b, .. } = o { Some((a, b)) } else { None }).collect(); + assert!(matches!(guides[..], [(a, b)] if a == Point::new(300.0, 400.0) && (b - Point::new(420.0, 310.0)).hypot() < 1e-9), "{guides:?}"); +} + +/// Group Selection (and Direct Selection on a whole path) moves objects snapping as the +/// Selection tool does: B's left side lines up with A's right one. +#[test] +fn moved_objects_snap_with_smart_guides() { + let (d, _, b) = two_squares(); + let p = paint(); + let mut s = Selection::default(); + s.set([b]); + let c = cx(&d, &s, &p); + for group in [true, false] { + let mut t = DirectSelectionTool::new(group); + let (v, _) = drag(&mut t, &c, (350.0, 350.0), (253.0, 351.0), Mods::default()); + assert_eq!(v["matrix"], json!([1.0, 0.0, 0.0, 1.0, -100.0, 1.0]), "group: {group}"); + } +} + +/// #1816: Group Selection and Direct Selection move a whole path with Snap to Grid as the +/// Selection tool does, landing an edge on the grid; the center does not snap. A 45 pt square at +/// 90–135 on the 9 pt grid, dragged right by 22 to 112–157, lands its left edge on the gridline +/// 108 and moves by 18. Its center, at 134.5 after the drag, is nearer the gridline 135 and +/// does not snap. +#[test] +fn a_move_of_objects_lands_an_edge_on_the_grid() { + let mut d = vectorcraft_doc::Document::new(500.0, 500.0); + let l = d.layers[0].id; + let id = d.alloc_id(); + let sq = vectorcraft_geom::shapes::rectangle(Rect::new(90.0, 90.0, 135.0, 135.0)); + d.insert(Some(l), 0, vectorcraft_doc::Node::path(id, sq, vectorcraft_doc::Appearance::default_art())).unwrap(); + let p = paint(); + let mut s = Selection::default(); + s.set([id]); + let c = ToolContext { snap_to_grid: true, ..cx(&d, &s, &p) }; + for group in [true, false] { + let mut t = DirectSelectionTool::new(group); + let (v, _) = drag(&mut t, &c, (110.0, 110.0), (132.0, 110.0), Mods::default()); + assert_eq!(v["matrix"], json!([1.0, 0.0, 0.0, 1.0, 18.0, 0.0]), "group: {group}"); + } +} + +/// Group Selection and Direct Selection on a whole path keep a Shift-constrained move on its +/// line when it snaps, as the Selection tool does: level by 13.04 with Snap to Grid on, B's +/// right side (413.04) lands on the gridline 414, and its top stays at 300, between 297 and +/// 306. +#[test] +fn a_shift_move_of_objects_snaps_along_its_line() { + let (d, _, b) = two_squares(); + let p = paint(); + let mut s = Selection::default(); + s.set([b]); + let c = ToolContext { snap_to_grid: true, ..cx(&d, &s, &p) }; + let shift = Mods { shift: true, ..Mods::default() }; + for group in [true, false] { + let mut t = DirectSelectionTool::new(group); + let (v, _) = drag(&mut t, &c, (350.0, 350.0), (363.0, 351.0), shift); + let (x, y) = (v["matrix"][4].as_f64().unwrap(), v["matrix"][5].as_f64().unwrap()); + assert!((x - 14.0).abs() < 1e-9 && y.abs() < 1e-9, "group: {group}: {v}"); + } +} + +/// Direct Selection's double-click on type where a layer's clipping mask edge runs through it +/// edits the type, though a press there takes the mask's segment. +#[test] +fn a_double_click_on_type_at_a_layer_masks_edge_edits_it() { + let (d, _, at) = doc_with_type_on_a_layer_masks_edge(); + let (s, p) = (Selection::default(), paint()); + let acts = DirectSelectionTool::new(false).pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::DoubleClick, at.x, at.y)); + assert_eq!(acts, vec![Action::SwitchTool("type".into())]); +} + +#[test] +fn double_clicking_type_switches_to_type_tool() { + use std::sync::Arc; + let (mut d, id) = doc_with_area_type(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let b = d.node(id).unwrap().geometric_bounds().unwrap(); + let mid = b.center(); + // Direct Selection (shortcut A, group == false) switches to the Type tool on double click. + let mut t = DirectSelectionTool::new(false); + let acts = t.pointer(&c, &PointerEvent::new(PointerKind::DoubleClick, mid.x, mid.y)); + assert_eq!(acts, vec![Action::SwitchTool("type".into())]); + + // Group Selection (group == true) does not switch to Type tool. + let mut gt = DirectSelectionTool::new(true); + let acts_group = gt.pointer(&c, &PointerEvent::new(PointerKind::DoubleClick, mid.x, mid.y)); + assert!(acts_group.is_empty(), "group selection shouldn't switch: {acts_group:?}"); + + // Text inside a group also switches to Type tool with Direct Selection. + let gid = d.alloc_id(); + let text_id = d.alloc_id(); + let text_node = Node::new( + text_id, + vectorcraft_doc::NodeKind::Text(Box::new(vectorcraft_doc::TextObject::point(Point::new(50.0, 50.0), "In group", Default::default()))), + ); + d.insert(Some(d.layers[0].id), 2, Node::group(gid, vec![Arc::new(text_node)])).unwrap(); + let in_group_b = d.node(gid).unwrap().geometric_bounds().unwrap(); + let c2 = cx(&d, &s, &p); + let acts_group_text = t.pointer(&c2, &PointerEvent::new(PointerKind::DoubleClick, in_group_b.center().x, in_group_b.center().y)); + assert_eq!(acts_group_text, vec![Action::SwitchTool("type".into())]); +} + +#[test] +fn direct_selection_click_and_drag_member_of_compound_path() { + let mut d = vectorcraft_doc::Document::new(500.0, 500.0); + let l = d.layers[0].id; + let (outer, hole, compound) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); + let rect = |id, a: f64, b: f64| { + std::sync::Arc::new(vectorcraft_doc::Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(a, a, b, b)), Default::default())) + }; + let mut c = vectorcraft_doc::Node::new( + compound, + vectorcraft_doc::NodeKind::Compound { + children: vec![rect(outer, 0.0, 200.0), rect(hole, 50.0, 150.0)], + rule: vectorcraft_geom::FillRule::EvenOdd, + }, + ); + c.appearance = vectorcraft_doc::Appearance::default_art().into(); + d.insert(Some(l), 0, c).unwrap(); + let p = paint(); + let none = Selection::default(); + let mut t = DirectSelectionTool::new(false); + let ctx = cx(&d, &none, &p); + let down = t.pointer(&ctx, &PointerEvent::new(PointerKind::Down, 25.0, 100.0)); + assert_eq!( + down, + vec![Action::Exec("select.set".into(), json!({"ids": [outer.0]}))], + "Direct Selection selects child member, not compound parent" + ); + let mut s = Selection::default(); + s.set([outer]); + let ctx_sel = cx(&d, &s, &p); + let drag = t.pointer(&ctx_sel, &PointerEvent::new(PointerKind::Drag, 35.0, 110.0)); + assert!(drag.iter().any(|a| matches!(a, Action::Preview(cmd, _) if cmd == "object.transform")), "drags the selected child member: {drag:?}"); + let up = t.pointer(&ctx_sel, &PointerEvent::new(PointerKind::Up, 35.0, 110.0)); + assert_eq!(up, vec![Action::Commit]); +} + +#[test] +fn shift_clicking_anchor_after_segment_pick_toggles_or_adds() { + let (d, id) = doc_with_rect(); + let p = paint(); + let mut s = Selection::default(); + s.set([id]); + s.segments.insert(id, [(0, 0)].into()); + s.anchors.insert(id, [(0, 0), (0, 1)].into()); + let cx = cx(&d, &s, &p); + let mut t = DirectSelectionTool::new(false); + let shift = Mods { shift: true, ..Default::default() }; + // Shift-click on anchor (0, 0) (top-left at 100, 100) toggles it off + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0).with_mods(shift)); + assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "toggle"}))]); + // Shift-click on anchor (0, 2) (bottom-right at 200, 200) toggles it on (adds it) + let a2 = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 200.0, 200.0).with_mods(shift)); + assert_eq!(a2, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 2]], "mode": "toggle"}))]); +} + +#[test] +fn shift_clicking_segment_toggles_segment() { + let (d, id) = doc_with_rect(); + let p = paint(); + let s = Selection::default(); + let cx = cx(&d, &s, &p); + let mut t = DirectSelectionTool::new(false); + let shift = Mods { shift: true, ..Default::default() }; + // The rectangle is at 100..200. Segment 0 is the top edge (y=100, x between 100 and 200). + // Shift-click at (150, 100) selects segment 0 with mode "toggle". + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 100.0).with_mods(shift)); + assert_eq!(a, vec![Action::Exec("select.segments".into(), json!({"id": id.0, "segments": [[0, 0]], "mode": "toggle"}))]); +} + +/// In normal viewmode, clicking the fill of an open path in front selects the open path's shape +/// and anchors, and NEVER clicks through to select or grab a path behind it (even if the path +/// behind has an anchor directly under the click). +#[test] +fn clicking_fill_of_open_path_in_front_never_selects_path_behind() { + use vectorcraft_geom::{Anchor, SubPath}; + let (mut d, _behind) = doc_with_rect(); + // `behind` has an anchor at (100.0, 100.0). + // Create an open path in front (id `front`), with a fill, covering (100.0, 100.0). + let front = d.alloc_id(); + let l = d.layers[0].id; + // Triangle from (50, 50) to (150, 50) to (150, 150), open (implicit closing edge from (150, 150) to (50, 50)). + // (100.0, 100.0) is inside this filled triangle, but `front` has NO anchor at (100.0, 100.0). + let anchors = vec![Anchor::corner(Point::new(50.0, 50.0)), Anchor::corner(Point::new(150.0, 50.0)), Anchor::corner(Point::new(150.0, 150.0))]; + let open_path = PathData::single(SubPath::new(anchors, false)); + let ap = vectorcraft_doc::Appearance::default_art(); // has solid white fill + d.insert(Some(l), 1, vectorcraft_doc::Node::path(front, open_path, ap)).unwrap(); + + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + + // Hovering over (100.0, 100.0) does NOT hover the hidden anchor of `behind`. + assert_eq!(hovered_anchor(&c, Point::new(100.0, 100.0)), None); + + // Clicking at (100.0, 100.0) selects `front` (the open path in front), NOT `behind`. + let mut t = DirectSelectionTool::new(false); + let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); + assert_eq!(acts, vec![Action::Exec("select.set".into(), json!({"ids": [front.0]}))]); +} + +/// A selected path's Bézier direction handle positioned over a background filled rectangle +/// can be hit and dragged without being occluded by the background fill. +#[test] +fn selected_path_handle_over_background_rect_is_draggable() { + use vectorcraft_geom::{Anchor, SubPath}; + let mut d = vectorcraft_doc::Document::new(500.0, 500.0); + let l = d.layers[0].id; + // Background rectangle covering 0..300 with default white fill + let bg = d.alloc_id(); + let bg_rect = vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 300.0, 300.0)); + d.insert(Some(l), 0, vectorcraft_doc::Node::path(bg, bg_rect, vectorcraft_doc::Appearance::default_art())).unwrap(); + + // Selected path with a Bézier curve whose out-handle is at (150, 150) + let fg = d.alloc_id(); + let mut a0 = Anchor::corner(Point::new(100.0, 150.0)); + a0.h_out = Point::new(150.0, 150.0); // handle over the background rect + let a1 = Anchor::corner(Point::new(200.0, 150.0)); + let curve = PathData::single(SubPath::new(vec![a0, a1], false)); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(fg, curve, vectorcraft_doc::Appearance::default_art())).unwrap(); + + let mut s = Selection::default(); + s.set([fg]); + s.anchors.insert(fg, [(0, 0)].into()); + let p = paint(); + let c = cx(&d, &s, &p); + + // hit_handle on the handle at (150, 150) must find the handle of `fg` + assert_eq!(hit_handle(&c, Point::new(150.0, 150.0), c.point_tol()), Some((fg, 0, 0, true))); + + // Direct Selection tool clicking on the handle initiates Reshape + let mut t = DirectSelectionTool::new(false); + let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 150.0)); + assert_eq!(acts, vec![Action::Begin("Reshape".into())]); +}