diff --git a/crates/tools/src/xform/artboard.rs b/crates/tools/src/xform/artboard.rs index e165f2449..ecdb79d43 100644 --- a/crates/tools/src/xform/artboard.rs +++ b/crates/tools/src/xform/artboard.rs @@ -651,569 +651,4 @@ impl Tool for ArtboardTool { } #[cfg(test)] -mod tests { - use super::*; - use crate::testutil::*; - use vectorcraft_doc::Selection; - - fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent { - PointerEvent::new(kind, x, y) - } - - /// [`doc_with_rect`] (artboard 1 at 0–500, a rectangle at 100–200) plus Artboard 2 at x 600–800, - /// y 0–200. - fn two_boards() -> vectorcraft_doc::Document { - let (mut d, _) = doc_with_rect(); - let mut ab = d.artboards[0].clone(); - ab.rect = Rect::new(600.0, 0.0, 800.0, 200.0); - ab.name = "Artboard 2".into(); - d.artboards.push(ab); - d - } - - fn preview_params(a: &[Action]) -> Value { - a.iter() - .find_map(|a| match a { - Action::Preview(_, p) => Some(p.clone()), - _ => None, - }) - .unwrap() - } - - /// Drag artboard 2 from (700, 100) by (dx, dy) and return the preview's params. - fn drag_board2(cx: &ToolContext, t: &mut ArtboardTool, dx: f64, dy: f64) -> Value { - t.pointer(cx, &ev(PointerKind::Down, 700.0, 100.0)); - preview_params(&t.pointer(cx, &ev(PointerKind::Drag, 700.0 + dx, 100.0 + dy))) - } - - #[test] - fn artboard_resize_readout_honors_transform_tools_guides() { - let d = two_boards(); - let (s, p) = (Selection::default(), paint()); - let measures = |t: &ArtboardTool, c: &ToolContext| t.overlays(c).iter().filter(|o| matches!(o, Overlay::Measure { .. })).count(); - let mut t = ArtboardTool { active: 1, ..Default::default() }; - for on in [true, false] { - let c = ToolContext { transform_tools_guides: on, ..cx(&d, &s, &p) }; - t.pointer(&c, &ev(PointerKind::Down, 800.0, 100.0)); - t.pointer(&c, &ev(PointerKind::Drag, 820.0, 100.0)); - assert_eq!(measures(&t, &c), usize::from(on), "transform tools {on}"); - t.pointer(&c, &ev(PointerKind::Up, 820.0, 100.0)); - } - let mut t = ArtboardTool::default(); - let create = ToolContext { measurement_labels: true, transform_tools_guides: false, ..cx(&d, &s, &p) }; - t.pointer(&create, &ev(PointerKind::Down, 900.0, 300.0)); - t.pointer(&create, &ev(PointerKind::Drag, 1000.0, 400.0)); - assert_eq!(measures(&t, &create), 1, "new artboard drag still uses measurement labels"); - } - - #[test] - fn bounds_follow_the_artboard_while_the_document_shows_the_preview() { - let mut d = two_boards(); - let (s, p) = (Selection::default(), paint()); - let mut t = ArtboardTool::default(); - assert_eq!(drag_board2(&cx(&d, &s, &p), &mut t, 10.0, 0.0)["dx"], 10.0); - // The engine applies the preview to the document, as it does during a drag. - d.artboards[1].rect = Rect::new(610.0, 0.0, 810.0, 200.0); - let cx = cx(&d, &s, &p); - assert_eq!(preview_params(&t.pointer(&cx, &ev(PointerKind::Drag, 720.0, 100.0)))["dx"], 20.0); - let corners: Vec = t - .overlays(&cx) - .iter() - .filter_map(|o| match o { - Overlay::Anchor { p, .. } => Some(*p), - _ => None, - }) - .collect(); - assert_eq!(corners[0], Point::new(620.0, 0.0), "the handles sit on the moved artboard, not 10 pt past it"); - } - - #[test] - fn moving_snaps_to_other_artboards_with_smart_guides() { - let d = two_boards(); - let (s, p) = (Selection::default(), paint()); - let mut t = ArtboardTool::default(); - let mut c = cx(&d, &s, &p); - // Left edge 3 pt right of artboard 1's right edge (500) → lands on it. - assert_eq!(drag_board2(&c, &mut t, -97.0, 0.0)["dx"], -100.0); - assert!(t.overlays(&c).iter().any(|o| matches!(o, Overlay::Line { color, .. } if *color == crate::guides::MAGENTA))); - t.pointer(&c, &ev(PointerKind::Up, 603.0, 100.0)); - assert!(!t.overlays(&c).iter().any(|o| matches!(o, Overlay::Line { .. })), "guides go away on release"); - c.smart_guides = false; - assert_eq!(drag_board2(&c, &mut t, -97.0, 0.0)["dx"], -97.0); - } - - #[test] - fn moving_never_snaps_to_itself_or_the_art_it_carries() { - let mut d = two_boards(); - let l = d.layers[0].id; - let id = d.alloc_id(); - let art = vectorcraft_geom::shapes::rectangle(Rect::new(650.0, 50.0, 660.0, 60.0)); - d.insert(Some(l), 0, vectorcraft_doc::Node::path(id, art, vectorcraft_doc::Appearance::default_art())).unwrap(); - let (s, p) = (Selection::default(), paint()); - let c = cx(&d, &s, &p); - let mut t = ArtboardTool::default(); - // Its own old left edge (600) isn't a target. - assert_eq!(drag_board2(&c, &mut t, 4.0, 0.0)["dx"], 4.0); - t.pointer(&c, &ev(PointerKind::Up, 704.0, 100.0)); - // The rectangle moves along: its left edge (650) isn't a target either… - assert_eq!(drag_board2(&c, &mut t, 52.0, 0.0)["dx"], 52.0); - t.pointer(&c, &ev(PointerKind::Up, 752.0, 100.0)); - // …unless it stays behind. - t.move_art = false; - assert_eq!(drag_board2(&c, &mut t, 52.0, 0.0)["dx"], 50.0); - } - - /// Art that is also on a locked artboard stays behind when its artboard moves, so the move - /// snaps to it, as to any art that stays. - #[test] - fn moving_snaps_to_the_art_a_locked_artboard_keeps() { - let mut d = two_boards(); - let mut locked = d.artboards[0].clone(); - locked.rect = Rect::new(640.0, 40.0, 700.0, 70.0); - locked.locked = true; - d.artboards.push(locked); - let l = d.layers[0].id; - let id = d.alloc_id(); - let art = vectorcraft_geom::shapes::rectangle(Rect::new(650.0, 50.0, 660.0, 60.0)); - d.insert(Some(l), 0, vectorcraft_doc::Node::path(id, art, vectorcraft_doc::Appearance::default_art())).unwrap(); - let (s, p) = (Selection::default(), paint()); - let c = cx(&d, &s, &p); - let mut t = ArtboardTool::default(); - // Artboard 2's left edge, 600 + 52, snaps to the rectangle's at 650. - assert_eq!(drag_board2(&c, &mut t, 52.0, 0.0)["dx"], 50.0); - } - - #[test] - fn moving_snaps_to_grid_and_pixels() { - let d = two_boards(); - let (s, p) = (Selection::default(), paint()); - let mut t = ArtboardTool::default(); - let mut c = cx(&d, &s, &p); - c.snap_to_grid = true; - // Grid step 72 / 8 = 9: x 610 → 612, y 4 → 0. - let v = drag_board2(&c, &mut t, 10.0, 4.0); - assert_eq!((v["dx"].as_f64(), v["dy"].as_f64()), (Some(12.0), Some(0.0))); - t.pointer(&c, &ev(PointerKind::Up, 710.0, 104.0)); - c.snap_to_grid = false; - c.snap_to_pixel = true; - let v = drag_board2(&c, &mut t, 10.4, 3.6); - assert_eq!((v["dx"].as_f64(), v["dy"].as_f64()), (Some(10.0), Some(4.0))); - } - - #[test] - fn resizing_snaps_the_dragged_handle() { - let d = two_boards(); - let (s, p) = (Selection::default(), paint()); - let mut t = ArtboardTool { active: 1, ..Default::default() }; - let c = cx(&d, &s, &p); - // Left handle to 3 pt off artboard 1's right edge. - t.pointer(&c, &ev(PointerKind::Down, 600.0, 100.0)); - let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 503.0, 100.0))); - assert_eq!((v["x"].as_f64(), v["width"].as_f64()), (Some(500.0), Some(300.0))); - t.pointer(&c, &ev(PointerKind::Up, 503.0, 100.0)); - // The handle, not the pointer, follows: grabbed 2 pt left of it, dragged 10 pt. - t.pointer(&c, &ev(PointerKind::Down, 798.0, 100.0)); - let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 808.0, 100.0))); - assert_eq!(v["width"].as_f64(), Some(210.0)); - } - - /// In a view turned 90° the handles' cursors turn with the view. - #[test] - fn handle_cursors_turn_with_the_view() { - let d = two_boards(); - let (s, p) = (Selection::default(), paint()); - let t = ArtboardTool { active: 1, ..Default::default() }; - let mut c = cx(&d, &s, &p); - let (top, right) = (Point::new(700.0, 0.0), Point::new(800.0, 100.0)); - assert_eq!(t.cursor(&c, top, Mods::default()), Cursor::ResizeV); - c.screen = turned_view(90.0); - assert_eq!(t.cursor(&c, top, Mods::default()), Cursor::ResizeH); - assert_eq!(t.cursor(&c, right, Mods::default()), Cursor::ResizeV); - } - - /// Shift-click selects several artboards (#1139): a drag moves them all, Shift-click takes one - /// out again, a plain click on an unselected one selects just it, and Delete removes all the - /// selected ones (one artboard is always left). - #[test] - fn shift_click_selects_several_artboards_that_move_and_delete_together() { - let mut d = two_boards(); - let mut third = d.artboards[0].clone(); - third.rect = Rect::new(0.0, 600.0, 200.0, 800.0); - d.artboards.push(third); - let (s, p) = (Selection::default(), paint()); - let c = cx(&d, &s, &p); - let shift = Mods { shift: true, ..Default::default() }; - let click = |t: &mut ArtboardTool, x: f64, y: f64, m: Mods| { - t.pointer(&c, &ev(PointerKind::Down, x, y).with_mods(m)); - t.pointer(&c, &ev(PointerKind::Up, x, y).with_mods(m)); - }; - let mut t = ArtboardTool::default(); - click(&mut t, 450.0, 450.0, Mods::default()); - click(&mut t, 700.0, 100.0, shift); - assert_eq!(t.options()["selected"], json!([1, 0]), "the Shift-clicked one is active"); - // A drag from either moves both. - t.pointer(&c, &ev(PointerKind::Down, 450.0, 450.0)); - let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 470.0, 450.0))); - assert_eq!((v["indices"].clone(), v["dx"].as_f64()), (json!([0, 1]), Some(20.0))); - assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 470.0, 450.0)), vec![Action::Commit]); - // Delete removes both; with all three selected the keys retain the original first board. - assert_eq!(t.key(&c, ToolKey::Delete, Mods::default()), vec![Action::Exec("artboard.delete".into(), json!({"indices": [0, 1]}))]); - for key in [ToolKey::Delete, ToolKey::Backspace] { - t.set_option("selected", &json!([2, 0, 1])); - assert!(t.claims_key(&c, key)); - assert_eq!(t.key(&c, key, Mods::default()), vec![Action::Exec("artboard.delete".into(), json!({"indices": [1, 2]}))]); - assert_eq!(t.options()["selected"], json!([0])); - } - t.set_option("selected", &json!([0, 1, 2])); - // Shift-click takes one out; a plain click on an unselected one selects just it. - click(&mut t, 100.0, 700.0, shift); - assert_eq!(t.options()["selected"], json!([0, 1])); - click(&mut t, 100.0, 700.0, Mods::default()); - assert_eq!(t.options()["selected"], json!([2])); - } - - /// The arrow keys move the selected artboards by the keyboard increment, Shift ten times as far, - /// with their art when Move Artwork with Artboard is on (#2103): the tool claims them, so they - /// no longer fall through to object.nudge and move only the selected art. A locked artboard - /// stays, and during a drag the arrows do nothing. - #[test] - fn arrow_keys_nudge_the_selected_artboards() { - let mut d = two_boards(); - let mut third = d.artboards[0].clone(); - third.rect = Rect::new(0.0, 600.0, 200.0, 800.0); - third.locked = true; - d.artboards.push(third); - let (s, p) = (Selection::default(), paint()); - let mut c = cx(&d, &s, &p); - c.keyboard_increment = 2.0; - let shift = Mods { shift: true, ..Default::default() }; - let mut t = ArtboardTool { active: 1, ..Default::default() }; - let moved = |p: Value| vec![Action::Exec("artboard.move".into(), p)]; - for key in [ToolKey::Left, ToolKey::Right, ToolKey::Up, ToolKey::Down] { - assert!(t.claims_key(&c, key), "{key:?}"); - } - assert_eq!(t.key(&c, ToolKey::Right, Mods::default()), moved(json!({"index": 1, "dx": 2.0, "dy": 0.0, "moveArt": true}))); - assert_eq!(t.key(&c, ToolKey::Up, shift), moved(json!({"index": 1, "dx": 0.0, "dy": -20.0, "moveArt": true}))); - t.move_art = false; - t.set_option("selected", &json!([0, 1, 2])); - assert_eq!( - t.key(&c, ToolKey::Left, Mods::default()), - moved(json!({"indices": [0, 1], "dx": -2.0, "dy": 0.0, "moveArt": false})), - "the locked artboard stays" - ); - t.set_option("selected", &json!([2])); - assert_eq!(t.key(&c, ToolKey::Down, Mods::default()), vec![], "only a locked artboard selected"); - t.set_option("selected", &json!([1])); - t.pointer(&c, &ev(PointerKind::Down, 700.0, 100.0)); - assert_eq!(t.key(&c, ToolKey::Down, Mods::default()), vec![], "not during a drag"); - } - - /// A click selects a locked artboard, but a drag neither moves it nor copies it (Alt), a press - /// on its edge doesn't resize it, it shows no handles, the pointer over it is the arrow, and - /// Delete leaves it. With an unlocked artboard selected too, a drag and Delete act on that one. - /// With Shift the pointer over it is the crosshair, a Shift-click toggles it, and a Shift-drag - /// inside it draws a new artboard, as over any artboard. - #[test] - fn a_locked_artboard_is_not_moved_resized_or_deleted() { - let mut d = two_boards(); - d.artboards[1].locked = true; - let (s, p) = (Selection::default(), paint()); - let mut c = cx(&d, &s, &p); - c.smart_guides = false; - let mut t = ArtboardTool::default(); - for m in [Mods::default(), Mods { alt: true, ..Default::default() }] { - t.pointer(&c, &ev(PointerKind::Down, 700.0, 100.0).with_mods(m)); - assert!(t.pointer(&c, &ev(PointerKind::Drag, 750.0, 100.0).with_mods(m)).is_empty()); - assert!(t.pointer(&c, &ev(PointerKind::Up, 750.0, 100.0).with_mods(m)).is_empty()); - } - assert_eq!(t.options()["selected"], json!([1]), "the click selects it"); - assert!(!t.overlays(&c).iter().any(|o| matches!(o, Overlay::Anchor { .. })), "no handles"); - // A press on its left edge, where a handle would be. - t.pointer(&c, &ev(PointerKind::Down, 600.0, 100.0)); - assert!(t.pointer(&c, &ev(PointerKind::Drag, 550.0, 100.0)).is_empty()); - t.pointer(&c, &ev(PointerKind::Up, 550.0, 100.0)); - for x in [600.0, 700.0] { - assert_eq!(t.cursor(&c, Point::new(x, 100.0), Mods::default()), Cursor::Arrow); - } - assert!(t.key(&c, ToolKey::Delete, Mods::default()).is_empty()); - t.set_option("selected", &json!([0, 1])); - t.pointer(&c, &ev(PointerKind::Down, 450.0, 450.0)); - assert_eq!(preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 470.0, 450.0)))["indices"], json!([0])); - t.pointer(&c, &ev(PointerKind::Up, 470.0, 450.0)); - t.set_option("selected", &json!([0, 1])); - assert_eq!(t.key(&c, ToolKey::Delete, Mods::default()), vec![Action::Exec("artboard.delete".into(), json!({"index": 0}))]); - let shift = Mods { shift: true, ..Default::default() }; - assert_eq!(t.cursor(&c, Point::new(700.0, 100.0), shift), Cursor::Crosshair); - t.set_option("selected", &json!([0, 1])); - t.pointer(&c, &ev(PointerKind::Down, 700.0, 100.0).with_mods(shift)); - assert!(t.pointer(&c, &ev(PointerKind::Up, 700.0, 100.0).with_mods(shift)).is_empty()); - assert_eq!(t.options()["selected"], json!([0]), "the Shift-click takes it out"); - t.pointer(&c, &ev(PointerKind::Down, 650.0, 50.0).with_mods(shift)); - assert!(t.pointer(&c, &ev(PointerKind::Drag, 690.0, 80.0).with_mods(shift)).is_empty(), "no move preview"); - assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 690.0, 80.0).with_mods(shift)), create_artboard(Rect::new(650.0, 50.0, 690.0, 80.0))); - } - - /// A Shift-click toggles its artboard when the button comes up, so the press alone leaves the - /// selection as it was. - #[test] - fn a_shift_click_toggles_its_artboard_on_release() { - let d = two_boards(); - let (s, p) = (Selection::default(), paint()); - let c = cx(&d, &s, &p); - let shift = Mods { shift: true, ..Default::default() }; - let mut t = ArtboardTool::default(); - assert_eq!(t.pointer(&c, &ev(PointerKind::Down, 700.0, 100.0).with_mods(shift)), vec![]); - assert_eq!(t.options()["selected"], json!([0]), "the press alone selects nothing"); - assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 701.0, 100.0).with_mods(shift)), vec![]); - assert_eq!(t.options()["selected"], json!([1, 0])); - for kind in [PointerKind::Down, PointerKind::Up] { - t.pointer(&c, &ev(kind, 700.0, 100.0).with_mods(shift)); - } - assert_eq!(t.options()["selected"], json!([0]), "a second Shift-click takes it out"); - } - - /// Shift-drag inside an artboard draws a new artboard there, neither square nor moving the - /// artboard it starts in, and the cursor shows a crosshair while Shift is held. - #[test] - fn a_shift_drag_inside_an_artboard_draws_a_new_one() { - let d = two_boards(); - let (s, p) = (Selection::default(), paint()); - let mut c = cx(&d, &s, &p); - c.smart_guides = false; - let shift = Mods { shift: true, ..Default::default() }; - let mut t = ArtboardTool::default(); - assert_eq!(t.cursor(&c, Point::new(300.0, 300.0), Mods::default()), Cursor::Move); - assert_eq!(t.cursor(&c, Point::new(300.0, 300.0), shift), Cursor::Crosshair); - assert_eq!(t.pointer(&c, &ev(PointerKind::Down, 300.0, 300.0).with_mods(shift)), vec![]); - assert_eq!(t.pointer(&c, &ev(PointerKind::Drag, 420.0, 380.0).with_mods(shift)), vec![], "no move preview"); - assert!(matches!(t.overlays(&c)[0], Overlay::Marquee(_))); - let a = t.pointer(&c, &ev(PointerKind::Up, 420.0, 380.0).with_mods(shift)); - assert_eq!(a, create_artboard(Rect::new(300.0, 300.0, 420.0, 380.0))); - assert_eq!(t.options()["selected"], json!([0]), "the artboard drawn in is not toggled"); - } - - /// Shift-drag on the pasteboard is a marquee: the artboards it touches leave the selection - /// or join it, the last one joining becoming active, and no artboard is drawn. - #[test] - fn a_shift_drag_on_the_pasteboard_selects_the_artboards_it_touches() { - let mut d = two_boards(); - let mut third = d.artboards[0].clone(); - third.rect = Rect::new(0.0, 600.0, 200.0, 800.0); - d.artboards.push(third); - let (s, p) = (Selection::default(), paint()); - let c = cx(&d, &s, &p); - let shift = Mods { shift: true, ..Default::default() }; - let mut t = ArtboardTool::default(); - let marquee = |t: &mut ArtboardTool, from: (f64, f64), to: (f64, f64)| { - assert_eq!(t.pointer(&c, &ev(PointerKind::Down, from.0, from.1).with_mods(shift)), vec![]); - assert_eq!(t.pointer(&c, &ev(PointerKind::Drag, to.0, to.1).with_mods(shift)), vec![]); - assert!(t.overlays(&c).iter().any(|o| matches!(o, Overlay::Marquee(_)))); - t.pointer(&c, &ev(PointerKind::Up, to.0, to.1).with_mods(shift)) - }; - // From between the artboards across Artboard 1 (selected) and Artboard 3: 1 leaves, 3 joins. - assert_eq!(marquee(&mut t, (550.0, 150.0), (150.0, 650.0)), vec![]); - assert_eq!(t.options()["selected"], json!([2])); - // Right of Artboard 2 across its corner: it joins and becomes active. - assert_eq!(marquee(&mut t, (850.0, -50.0), (700.0, 100.0)), vec![]); - assert_eq!(t.options()["selected"], json!([1, 2])); - // A Shift-click on the pasteboard changes nothing. - assert_eq!(marquee(&mut t, (550.0, 150.0), (551.0, 150.0)), vec![]); - assert_eq!(t.options()["selected"], json!([1, 2])); - } - - /// A drag begun without Shift still moves the artboard, and on the pasteboard still draws one; - /// Shift pressed during either no longer keeps the move at 45° or the new artboard square. - #[test] - fn a_plain_drag_still_moves_and_shift_no_longer_constrains_it() { - let d = two_boards(); - let (s, p) = (Selection::default(), paint()); - let mut c = cx(&d, &s, &p); - c.smart_guides = false; - let shift = Mods { shift: true, ..Default::default() }; - let mut t = ArtboardTool::default(); - t.pointer(&c, &ev(PointerKind::Down, 700.0, 100.0)); - let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 730.0, 110.0).with_mods(shift))); - assert_eq!((v["index"].as_u64(), v["dx"].as_f64(), v["dy"].as_f64()), (Some(1), Some(30.0), Some(10.0))); - assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 730.0, 110.0).with_mods(shift)), vec![Action::Commit]); - t.pointer(&c, &ev(PointerKind::Down, 1000.0, 1000.0)); - t.pointer(&c, &ev(PointerKind::Drag, 1100.0, 1050.0).with_mods(shift)); - let a = t.pointer(&c, &ev(PointerKind::Up, 1100.0, 1050.0).with_mods(shift)); - assert_eq!(a, create_artboard(Rect::new(1000.0, 1000.0, 1100.0, 1050.0))); - } - - /// Escape during a marquee or while a new artboard is being drawn ends the gesture without - /// selecting or drawing anything and keeps the tool; the next Escape leaves it. - #[test] - fn escape_cancels_a_marquee_or_a_new_artboard_and_keeps_the_tool() { - let d = two_boards(); - let (s, p) = (Selection::default(), paint()); - let mut c = cx(&d, &s, &p); - c.smart_guides = false; - let shift = Mods { shift: true, ..Default::default() }; - let mut t = ArtboardTool::default(); - // A Shift-drag marquee on the pasteboard, a Shift-drag inside Artboard 1 and a plain drag on - // the pasteboard. - let drags = - [((850.0, -50.0), (700.0, 100.0), shift), ((300.0, 300.0), (420.0, 380.0), shift), ((1000.0, 1000.0), (1100.0, 1050.0), Mods::default())]; - for (from, to, mods) in drags { - t.pointer(&c, &ev(PointerKind::Down, from.0, from.1).with_mods(mods)); - t.pointer(&c, &ev(PointerKind::Drag, to.0, to.1).with_mods(mods)); - assert_eq!(t.key(&c, ToolKey::Escape, Mods::default()), vec![], "Escape from {from:?} only ends the gesture"); - assert!(!t.busy()); - assert_eq!(t.pointer(&c, &ev(PointerKind::Up, to.0, to.1).with_mods(mods)), vec![], "the release draws nothing"); - assert_eq!(t.options()["selected"], json!([0]), "the release selects nothing"); - } - assert_eq!(t.key(&c, ToolKey::Escape, Mods::default()), vec![Action::SwitchTool("selection".into())]); - } - - /// Scale Artwork with Artboard (#602): the resize asks for the art to scale, keeps the artboard's - /// proportions, and never snaps to the art scaling with it. - #[test] - fn scale_art_resizes_proportionally_without_snapping_to_its_art() { - let mut d = two_boards(); - let l = d.layers[0].id; - let id = d.alloc_id(); - let art = vectorcraft_geom::shapes::rectangle(Rect::new(700.0, 50.0, 710.0, 60.0)); - d.insert(Some(l), 0, vectorcraft_doc::Node::path(id, art, vectorcraft_doc::Appearance::default_art())).unwrap(); - let (s, p) = (Selection::default(), paint()); - let c = cx(&d, &s, &p); - // Off: the right handle 3 pt past the art's right edge (710) lands on it; the height stays. - let mut t = ArtboardTool { active: 1, ..Default::default() }; - t.pointer(&c, &ev(PointerKind::Down, 800.0, 100.0)); - let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 713.0, 100.0))); - assert_eq!((v["width"].as_f64(), v["height"].as_f64(), v.get("scaleArt")), (Some(110.0), Some(200.0), None)); - t.pointer(&c, &ev(PointerKind::Up, 713.0, 100.0)); - // On: the art scales along, so it's no target, and the height follows the width. - t.set_option("scaleArt", &json!(true)); - assert_eq!(t.options()["scaleArt"], true); - t.pointer(&c, &ev(PointerKind::Down, 800.0, 100.0)); - let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 713.0, 100.0))); - assert_eq!((v["width"].as_f64(), v["scaleArt"].as_bool()), (Some(113.0), Some(true))); - assert!((v["height"].as_f64().unwrap() - 113.0).abs() < 1e-9); - } - - /// Over an artboard's right or bottom edge the cursor shows Move and a click selects that - /// artboard, as on its left or top edge. - #[test] - fn a_click_on_an_artboards_right_or_bottom_edge_selects_it() { - let d = two_boards(); - let (s, p) = (Selection::default(), paint()); - let c = cx(&d, &s, &p); - for at in [Point::new(800.0, 100.0), Point::new(700.0, 200.0)] { - let mut t = ArtboardTool::default(); - assert_eq!(t.cursor(&c, at, Mods::default()), Cursor::Move, "{at:?}"); - t.pointer(&c, &ev(PointerKind::Down, at.x, at.y)); - t.pointer(&c, &ev(PointerKind::Up, at.x, at.y)); - assert_eq!(t.options()["selected"], json!([1]), "{at:?}"); - } - } - - /// A proportional resize never asks for a side under 1 pt, which the command would lengthen - /// alone (scaling the art unevenly): both sides grow by one factor instead. - #[test] - fn a_proportional_resize_keeps_its_proportions_at_the_smallest_size() { - let mut d = two_boards(); - d.artboards[1].rect = Rect::new(600.0, 0.0, 800.0, 20.0); - let (s, p) = (Selection::default(), paint()); - let mut c = cx(&d, &s, &p); - c.smart_guides = false; - let mut t = ArtboardTool { active: 1, scale_art: true, ..Default::default() }; - // The right handle to 2.5 % of the width: 5 × 0.5, grown to 10 × 1 about the left middle. - t.pointer(&c, &ev(PointerKind::Down, 800.0, 10.0)); - let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 605.0, 10.0))); - let got = ["x", "y", "width", "height"].map(|k| v[k].as_f64().unwrap()); - assert!(got.iter().zip([600.0, 9.5, 10.0, 1.0]).all(|(a, b)| (a - b).abs() < 1e-9), "{got:?}"); - } - - #[test] - fn bleed_edges_snap_to_bleed_edges() { - let mut d = two_boards(); - d.setup.bleed = [10.0; 4]; - let (s, p) = (Selection::default(), paint()); - let c = cx(&d, &s, &p); - let mut t = ArtboardTool::default(); - // Artboard 2's bleed (left edge 590) lands on artboard 1's bleed (right edge 510): dragged - // 77 pt left it's 3 pt off, nothing else is within reach. - assert_eq!(drag_board2(&c, &mut t, -77.0, 0.0)["dx"], -80.0); - t.pointer(&c, &ev(PointerKind::Up, 623.0, 100.0)); - // Resizing: the left handle's bleed edge snaps the same way. - t.pointer(&c, &ev(PointerKind::Down, 600.0, 100.0)); - let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 523.0, 100.0))); - assert_eq!((v["x"].as_f64(), v["width"].as_f64()), (Some(520.0), Some(280.0))); - } - - #[test] - fn move_resize_create_delete() { - let (mut d, _) = doc_with_rect(); - let mut ab = d.artboards[0].clone(); - ab.rect = Rect::new(600.0, 0.0, 800.0, 200.0); - ab.name = "Artboard 2".into(); - d.artboards.push(ab); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let mut t = ArtboardTool::default(); - // Click the second artboard → active; drag moves it. - t.pointer(&cx, &ev(PointerKind::Down, 700.0, 100.0)); - assert_eq!(t.active, 1); - let a = t.pointer(&cx, &ev(PointerKind::Drag, 710.0, 120.0)); - assert_eq!(a[0], Action::Begin("Move Artboard".into())); - assert_eq!(a[1], Action::Preview("artboard.move".into(), json!({"index": 1, "dx": 10.0, "dy": 20.0, "moveArt": true, "copy": false}))); - assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 710.0, 120.0)), vec![Action::Commit]); - assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Label { text, .. } if text == "02 - Artboard 2"))); - // Handle drag resizes. - t.pointer(&cx, &ev(PointerKind::Down, 800.0, 100.0)); - let a = t.pointer(&cx, &ev(PointerKind::Drag, 900.0, 100.0)); - assert_eq!(a[1], Action::Preview("artboard.setProps".into(), json!({"index": 1, "x": 600.0, "y": 0.0, "width": 300.0, "height": 200.0}))); - t.pointer(&cx, &ev(PointerKind::Up, 900.0, 100.0)); - // Drag on the pasteboard creates one. - t.pointer(&cx, &ev(PointerKind::Down, 1000.0, 1000.0)); - t.pointer(&cx, &ev(PointerKind::Drag, 1100.0, 1050.0)); - assert!(matches!(t.overlays(&cx)[0], Overlay::Marquee(_))); - let a = t.pointer(&cx, &ev(PointerKind::Up, 1100.0, 1050.0)); - assert_eq!(a[0], Action::Exec("artboard.new".into(), json!({"x": 1000.0, "y": 1000.0, "width": 100.0, "height": 50.0}))); - assert_eq!(a[1], Action::Notify("created".into())); - // Delete removes the active artboard, ahead of the Clear shortcut. - assert!(t.claims_key(&cx, ToolKey::Delete) && t.claims_key(&cx, ToolKey::Backspace)); - assert_eq!(t.key(&cx, ToolKey::Delete, Mods::default()), vec![Action::Exec("artboard.delete".into(), json!({"index": 1}))]); - assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::SwitchTool("selection".into())]); - } - - #[test] - fn an_alt_drag_copy_snaps_to_the_artboard_it_leaves_behind() { - let d = two_boards(); - let (s, p) = (Selection::default(), paint()); - let c = cx(&d, &s, &p); - let alt = Mods { alt: true, ..Mods::default() }; - let mut t = ArtboardTool::default(); - // A copy of artboard 2 dragged 203 pt right: its left edge 3 pt off the original's right - // edge (800), which stays put and is a target. - t.pointer(&c, &ev(PointerKind::Down, 700.0, 100.0).with_mods(alt)); - let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 903.0, 100.0).with_mods(alt))); - assert_eq!((v["dx"].as_f64(), v["copy"].as_bool()), (Some(200.0), Some(true))); - // Released Alt: a plain move, and the artboard's own old edge is no target. - let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 903.0, 100.0))); - assert_eq!((v["dx"].as_f64(), v["copy"].as_bool()), (Some(203.0), Some(false))); - } - - #[test] - fn alt_drag_moves_a_copy_of_the_artboard() { - let (d, _) = doc_with_rect(); - let s = Selection::default(); - let p = paint(); - let cx = cx(&d, &s, &p); - let alt = Mods { alt: true, ..Mods::default() }; - let moved = - |dx: f64, copy: bool| Action::Preview("artboard.move".into(), json!({"index": 0, "dx": dx, "dy": 0.0, "moveArt": true, "copy": copy})); - let mut t = ArtboardTool::default(); - t.pointer(&cx, &ev(PointerKind::Down, 100.0, 100.0).with_mods(alt)); - let a = t.pointer(&cx, &ev(PointerKind::Drag, 150.0, 100.0).with_mods(alt)); - assert_eq!(a, vec![Action::Begin("Duplicate Artboard".into()), moved(50.0, true)]); - // Alt released mid-drag: a plain move after all, and back. - let a = t.pointer(&cx, &ev(PointerKind::Drag, 160.0, 100.0)); - assert_eq!(a, vec![Action::Cancel, Action::Begin("Move Artboard".into()), moved(60.0, false)]); - let a = t.pointer(&cx, &ev(PointerKind::Drag, 170.0, 100.0).with_mods(alt)); - assert_eq!(a, vec![Action::Cancel, Action::Begin("Duplicate Artboard".into()), moved(70.0, true)]); - assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 170.0, 100.0).with_mods(alt)), vec![Action::Commit]); - } -} +mod tests; diff --git a/crates/tools/src/xform/artboard/tests.rs b/crates/tools/src/xform/artboard/tests.rs new file mode 100644 index 000000000..3c8900ff5 --- /dev/null +++ b/crates/tools/src/xform/artboard/tests.rs @@ -0,0 +1,564 @@ +use super::*; +use crate::testutil::*; +use vectorcraft_doc::Selection; + +fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent { + PointerEvent::new(kind, x, y) +} + +/// [`doc_with_rect`] (artboard 1 at 0–500, a rectangle at 100–200) plus Artboard 2 at x 600–800, +/// y 0–200. +fn two_boards() -> vectorcraft_doc::Document { + let (mut d, _) = doc_with_rect(); + let mut ab = d.artboards[0].clone(); + ab.rect = Rect::new(600.0, 0.0, 800.0, 200.0); + ab.name = "Artboard 2".into(); + d.artboards.push(ab); + d +} + +fn preview_params(a: &[Action]) -> Value { + a.iter() + .find_map(|a| match a { + Action::Preview(_, p) => Some(p.clone()), + _ => None, + }) + .unwrap() +} + +/// Drag artboard 2 from (700, 100) by (dx, dy) and return the preview's params. +fn drag_board2(cx: &ToolContext, t: &mut ArtboardTool, dx: f64, dy: f64) -> Value { + t.pointer(cx, &ev(PointerKind::Down, 700.0, 100.0)); + preview_params(&t.pointer(cx, &ev(PointerKind::Drag, 700.0 + dx, 100.0 + dy))) +} + +#[test] +fn artboard_resize_readout_honors_transform_tools_guides() { + let d = two_boards(); + let (s, p) = (Selection::default(), paint()); + let measures = |t: &ArtboardTool, c: &ToolContext| t.overlays(c).iter().filter(|o| matches!(o, Overlay::Measure { .. })).count(); + let mut t = ArtboardTool { active: 1, ..Default::default() }; + for on in [true, false] { + let c = ToolContext { transform_tools_guides: on, ..cx(&d, &s, &p) }; + t.pointer(&c, &ev(PointerKind::Down, 800.0, 100.0)); + t.pointer(&c, &ev(PointerKind::Drag, 820.0, 100.0)); + assert_eq!(measures(&t, &c), usize::from(on), "transform tools {on}"); + t.pointer(&c, &ev(PointerKind::Up, 820.0, 100.0)); + } + let mut t = ArtboardTool::default(); + let create = ToolContext { measurement_labels: true, transform_tools_guides: false, ..cx(&d, &s, &p) }; + t.pointer(&create, &ev(PointerKind::Down, 900.0, 300.0)); + t.pointer(&create, &ev(PointerKind::Drag, 1000.0, 400.0)); + assert_eq!(measures(&t, &create), 1, "new artboard drag still uses measurement labels"); +} + +#[test] +fn bounds_follow_the_artboard_while_the_document_shows_the_preview() { + let mut d = two_boards(); + let (s, p) = (Selection::default(), paint()); + let mut t = ArtboardTool::default(); + assert_eq!(drag_board2(&cx(&d, &s, &p), &mut t, 10.0, 0.0)["dx"], 10.0); + // The engine applies the preview to the document, as it does during a drag. + d.artboards[1].rect = Rect::new(610.0, 0.0, 810.0, 200.0); + let cx = cx(&d, &s, &p); + assert_eq!(preview_params(&t.pointer(&cx, &ev(PointerKind::Drag, 720.0, 100.0)))["dx"], 20.0); + let corners: Vec = t + .overlays(&cx) + .iter() + .filter_map(|o| match o { + Overlay::Anchor { p, .. } => Some(*p), + _ => None, + }) + .collect(); + assert_eq!(corners[0], Point::new(620.0, 0.0), "the handles sit on the moved artboard, not 10 pt past it"); +} + +#[test] +fn moving_snaps_to_other_artboards_with_smart_guides() { + let d = two_boards(); + let (s, p) = (Selection::default(), paint()); + let mut t = ArtboardTool::default(); + let mut c = cx(&d, &s, &p); + // Left edge 3 pt right of artboard 1's right edge (500) → lands on it. + assert_eq!(drag_board2(&c, &mut t, -97.0, 0.0)["dx"], -100.0); + assert!(t.overlays(&c).iter().any(|o| matches!(o, Overlay::Line { color, .. } if *color == crate::guides::MAGENTA))); + t.pointer(&c, &ev(PointerKind::Up, 603.0, 100.0)); + assert!(!t.overlays(&c).iter().any(|o| matches!(o, Overlay::Line { .. })), "guides go away on release"); + c.smart_guides = false; + assert_eq!(drag_board2(&c, &mut t, -97.0, 0.0)["dx"], -97.0); +} + +#[test] +fn moving_never_snaps_to_itself_or_the_art_it_carries() { + let mut d = two_boards(); + let l = d.layers[0].id; + let id = d.alloc_id(); + let art = vectorcraft_geom::shapes::rectangle(Rect::new(650.0, 50.0, 660.0, 60.0)); + d.insert(Some(l), 0, vectorcraft_doc::Node::path(id, art, vectorcraft_doc::Appearance::default_art())).unwrap(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let mut t = ArtboardTool::default(); + // Its own old left edge (600) isn't a target. + assert_eq!(drag_board2(&c, &mut t, 4.0, 0.0)["dx"], 4.0); + t.pointer(&c, &ev(PointerKind::Up, 704.0, 100.0)); + // The rectangle moves along: its left edge (650) isn't a target either… + assert_eq!(drag_board2(&c, &mut t, 52.0, 0.0)["dx"], 52.0); + t.pointer(&c, &ev(PointerKind::Up, 752.0, 100.0)); + // …unless it stays behind. + t.move_art = false; + assert_eq!(drag_board2(&c, &mut t, 52.0, 0.0)["dx"], 50.0); +} + +/// Art that is also on a locked artboard stays behind when its artboard moves, so the move +/// snaps to it, as to any art that stays. +#[test] +fn moving_snaps_to_the_art_a_locked_artboard_keeps() { + let mut d = two_boards(); + let mut locked = d.artboards[0].clone(); + locked.rect = Rect::new(640.0, 40.0, 700.0, 70.0); + locked.locked = true; + d.artboards.push(locked); + let l = d.layers[0].id; + let id = d.alloc_id(); + let art = vectorcraft_geom::shapes::rectangle(Rect::new(650.0, 50.0, 660.0, 60.0)); + d.insert(Some(l), 0, vectorcraft_doc::Node::path(id, art, vectorcraft_doc::Appearance::default_art())).unwrap(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let mut t = ArtboardTool::default(); + // Artboard 2's left edge, 600 + 52, snaps to the rectangle's at 650. + assert_eq!(drag_board2(&c, &mut t, 52.0, 0.0)["dx"], 50.0); +} + +#[test] +fn moving_snaps_to_grid_and_pixels() { + let d = two_boards(); + let (s, p) = (Selection::default(), paint()); + let mut t = ArtboardTool::default(); + let mut c = cx(&d, &s, &p); + c.snap_to_grid = true; + // Grid step 72 / 8 = 9: x 610 → 612, y 4 → 0. + let v = drag_board2(&c, &mut t, 10.0, 4.0); + assert_eq!((v["dx"].as_f64(), v["dy"].as_f64()), (Some(12.0), Some(0.0))); + t.pointer(&c, &ev(PointerKind::Up, 710.0, 104.0)); + c.snap_to_grid = false; + c.snap_to_pixel = true; + let v = drag_board2(&c, &mut t, 10.4, 3.6); + assert_eq!((v["dx"].as_f64(), v["dy"].as_f64()), (Some(10.0), Some(4.0))); +} + +#[test] +fn resizing_snaps_the_dragged_handle() { + let d = two_boards(); + let (s, p) = (Selection::default(), paint()); + let mut t = ArtboardTool { active: 1, ..Default::default() }; + let c = cx(&d, &s, &p); + // Left handle to 3 pt off artboard 1's right edge. + t.pointer(&c, &ev(PointerKind::Down, 600.0, 100.0)); + let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 503.0, 100.0))); + assert_eq!((v["x"].as_f64(), v["width"].as_f64()), (Some(500.0), Some(300.0))); + t.pointer(&c, &ev(PointerKind::Up, 503.0, 100.0)); + // The handle, not the pointer, follows: grabbed 2 pt left of it, dragged 10 pt. + t.pointer(&c, &ev(PointerKind::Down, 798.0, 100.0)); + let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 808.0, 100.0))); + assert_eq!(v["width"].as_f64(), Some(210.0)); +} + +/// In a view turned 90° the handles' cursors turn with the view. +#[test] +fn handle_cursors_turn_with_the_view() { + let d = two_boards(); + let (s, p) = (Selection::default(), paint()); + let t = ArtboardTool { active: 1, ..Default::default() }; + let mut c = cx(&d, &s, &p); + let (top, right) = (Point::new(700.0, 0.0), Point::new(800.0, 100.0)); + assert_eq!(t.cursor(&c, top, Mods::default()), Cursor::ResizeV); + c.screen = turned_view(90.0); + assert_eq!(t.cursor(&c, top, Mods::default()), Cursor::ResizeH); + assert_eq!(t.cursor(&c, right, Mods::default()), Cursor::ResizeV); +} + +/// Shift-click selects several artboards (#1139): a drag moves them all, Shift-click takes one +/// out again, a plain click on an unselected one selects just it, and Delete removes all the +/// selected ones (one artboard is always left). +#[test] +fn shift_click_selects_several_artboards_that_move_and_delete_together() { + let mut d = two_boards(); + let mut third = d.artboards[0].clone(); + third.rect = Rect::new(0.0, 600.0, 200.0, 800.0); + d.artboards.push(third); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let shift = Mods { shift: true, ..Default::default() }; + let click = |t: &mut ArtboardTool, x: f64, y: f64, m: Mods| { + t.pointer(&c, &ev(PointerKind::Down, x, y).with_mods(m)); + t.pointer(&c, &ev(PointerKind::Up, x, y).with_mods(m)); + }; + let mut t = ArtboardTool::default(); + click(&mut t, 450.0, 450.0, Mods::default()); + click(&mut t, 700.0, 100.0, shift); + assert_eq!(t.options()["selected"], json!([1, 0]), "the Shift-clicked one is active"); + // A drag from either moves both. + t.pointer(&c, &ev(PointerKind::Down, 450.0, 450.0)); + let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 470.0, 450.0))); + assert_eq!((v["indices"].clone(), v["dx"].as_f64()), (json!([0, 1]), Some(20.0))); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 470.0, 450.0)), vec![Action::Commit]); + // Delete removes both; with all three selected the keys retain the original first board. + assert_eq!(t.key(&c, ToolKey::Delete, Mods::default()), vec![Action::Exec("artboard.delete".into(), json!({"indices": [0, 1]}))]); + for key in [ToolKey::Delete, ToolKey::Backspace] { + t.set_option("selected", &json!([2, 0, 1])); + assert!(t.claims_key(&c, key)); + assert_eq!(t.key(&c, key, Mods::default()), vec![Action::Exec("artboard.delete".into(), json!({"indices": [1, 2]}))]); + assert_eq!(t.options()["selected"], json!([0])); + } + t.set_option("selected", &json!([0, 1, 2])); + // Shift-click takes one out; a plain click on an unselected one selects just it. + click(&mut t, 100.0, 700.0, shift); + assert_eq!(t.options()["selected"], json!([0, 1])); + click(&mut t, 100.0, 700.0, Mods::default()); + assert_eq!(t.options()["selected"], json!([2])); +} + +/// The arrow keys move the selected artboards by the keyboard increment, Shift ten times as far, +/// with their art when Move Artwork with Artboard is on (#2103): the tool claims them, so they +/// no longer fall through to object.nudge and move only the selected art. A locked artboard +/// stays, and during a drag the arrows do nothing. +#[test] +fn arrow_keys_nudge_the_selected_artboards() { + let mut d = two_boards(); + let mut third = d.artboards[0].clone(); + third.rect = Rect::new(0.0, 600.0, 200.0, 800.0); + third.locked = true; + d.artboards.push(third); + let (s, p) = (Selection::default(), paint()); + let mut c = cx(&d, &s, &p); + c.keyboard_increment = 2.0; + let shift = Mods { shift: true, ..Default::default() }; + let mut t = ArtboardTool { active: 1, ..Default::default() }; + let moved = |p: Value| vec![Action::Exec("artboard.move".into(), p)]; + for key in [ToolKey::Left, ToolKey::Right, ToolKey::Up, ToolKey::Down] { + assert!(t.claims_key(&c, key), "{key:?}"); + } + assert_eq!(t.key(&c, ToolKey::Right, Mods::default()), moved(json!({"index": 1, "dx": 2.0, "dy": 0.0, "moveArt": true}))); + assert_eq!(t.key(&c, ToolKey::Up, shift), moved(json!({"index": 1, "dx": 0.0, "dy": -20.0, "moveArt": true}))); + t.move_art = false; + t.set_option("selected", &json!([0, 1, 2])); + assert_eq!( + t.key(&c, ToolKey::Left, Mods::default()), + moved(json!({"indices": [0, 1], "dx": -2.0, "dy": 0.0, "moveArt": false})), + "the locked artboard stays" + ); + t.set_option("selected", &json!([2])); + assert_eq!(t.key(&c, ToolKey::Down, Mods::default()), vec![], "only a locked artboard selected"); + t.set_option("selected", &json!([1])); + t.pointer(&c, &ev(PointerKind::Down, 700.0, 100.0)); + assert_eq!(t.key(&c, ToolKey::Down, Mods::default()), vec![], "not during a drag"); +} + +/// A click selects a locked artboard, but a drag neither moves it nor copies it (Alt), a press +/// on its edge doesn't resize it, it shows no handles, the pointer over it is the arrow, and +/// Delete leaves it. With an unlocked artboard selected too, a drag and Delete act on that one. +/// With Shift the pointer over it is the crosshair, a Shift-click toggles it, and a Shift-drag +/// inside it draws a new artboard, as over any artboard. +#[test] +fn a_locked_artboard_is_not_moved_resized_or_deleted() { + let mut d = two_boards(); + d.artboards[1].locked = true; + let (s, p) = (Selection::default(), paint()); + let mut c = cx(&d, &s, &p); + c.smart_guides = false; + let mut t = ArtboardTool::default(); + for m in [Mods::default(), Mods { alt: true, ..Default::default() }] { + t.pointer(&c, &ev(PointerKind::Down, 700.0, 100.0).with_mods(m)); + assert!(t.pointer(&c, &ev(PointerKind::Drag, 750.0, 100.0).with_mods(m)).is_empty()); + assert!(t.pointer(&c, &ev(PointerKind::Up, 750.0, 100.0).with_mods(m)).is_empty()); + } + assert_eq!(t.options()["selected"], json!([1]), "the click selects it"); + assert!(!t.overlays(&c).iter().any(|o| matches!(o, Overlay::Anchor { .. })), "no handles"); + // A press on its left edge, where a handle would be. + t.pointer(&c, &ev(PointerKind::Down, 600.0, 100.0)); + assert!(t.pointer(&c, &ev(PointerKind::Drag, 550.0, 100.0)).is_empty()); + t.pointer(&c, &ev(PointerKind::Up, 550.0, 100.0)); + for x in [600.0, 700.0] { + assert_eq!(t.cursor(&c, Point::new(x, 100.0), Mods::default()), Cursor::Arrow); + } + assert!(t.key(&c, ToolKey::Delete, Mods::default()).is_empty()); + t.set_option("selected", &json!([0, 1])); + t.pointer(&c, &ev(PointerKind::Down, 450.0, 450.0)); + assert_eq!(preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 470.0, 450.0)))["indices"], json!([0])); + t.pointer(&c, &ev(PointerKind::Up, 470.0, 450.0)); + t.set_option("selected", &json!([0, 1])); + assert_eq!(t.key(&c, ToolKey::Delete, Mods::default()), vec![Action::Exec("artboard.delete".into(), json!({"index": 0}))]); + let shift = Mods { shift: true, ..Default::default() }; + assert_eq!(t.cursor(&c, Point::new(700.0, 100.0), shift), Cursor::Crosshair); + t.set_option("selected", &json!([0, 1])); + t.pointer(&c, &ev(PointerKind::Down, 700.0, 100.0).with_mods(shift)); + assert!(t.pointer(&c, &ev(PointerKind::Up, 700.0, 100.0).with_mods(shift)).is_empty()); + assert_eq!(t.options()["selected"], json!([0]), "the Shift-click takes it out"); + t.pointer(&c, &ev(PointerKind::Down, 650.0, 50.0).with_mods(shift)); + assert!(t.pointer(&c, &ev(PointerKind::Drag, 690.0, 80.0).with_mods(shift)).is_empty(), "no move preview"); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 690.0, 80.0).with_mods(shift)), create_artboard(Rect::new(650.0, 50.0, 690.0, 80.0))); +} + +/// A Shift-click toggles its artboard when the button comes up, so the press alone leaves the +/// selection as it was. +#[test] +fn a_shift_click_toggles_its_artboard_on_release() { + let d = two_boards(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let shift = Mods { shift: true, ..Default::default() }; + let mut t = ArtboardTool::default(); + assert_eq!(t.pointer(&c, &ev(PointerKind::Down, 700.0, 100.0).with_mods(shift)), vec![]); + assert_eq!(t.options()["selected"], json!([0]), "the press alone selects nothing"); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 701.0, 100.0).with_mods(shift)), vec![]); + assert_eq!(t.options()["selected"], json!([1, 0])); + for kind in [PointerKind::Down, PointerKind::Up] { + t.pointer(&c, &ev(kind, 700.0, 100.0).with_mods(shift)); + } + assert_eq!(t.options()["selected"], json!([0]), "a second Shift-click takes it out"); +} + +/// Shift-drag inside an artboard draws a new artboard there, neither square nor moving the +/// artboard it starts in, and the cursor shows a crosshair while Shift is held. +#[test] +fn a_shift_drag_inside_an_artboard_draws_a_new_one() { + let d = two_boards(); + let (s, p) = (Selection::default(), paint()); + let mut c = cx(&d, &s, &p); + c.smart_guides = false; + let shift = Mods { shift: true, ..Default::default() }; + let mut t = ArtboardTool::default(); + assert_eq!(t.cursor(&c, Point::new(300.0, 300.0), Mods::default()), Cursor::Move); + assert_eq!(t.cursor(&c, Point::new(300.0, 300.0), shift), Cursor::Crosshair); + assert_eq!(t.pointer(&c, &ev(PointerKind::Down, 300.0, 300.0).with_mods(shift)), vec![]); + assert_eq!(t.pointer(&c, &ev(PointerKind::Drag, 420.0, 380.0).with_mods(shift)), vec![], "no move preview"); + assert!(matches!(t.overlays(&c)[0], Overlay::Marquee(_))); + let a = t.pointer(&c, &ev(PointerKind::Up, 420.0, 380.0).with_mods(shift)); + assert_eq!(a, create_artboard(Rect::new(300.0, 300.0, 420.0, 380.0))); + assert_eq!(t.options()["selected"], json!([0]), "the artboard drawn in is not toggled"); +} + +/// Shift-drag on the pasteboard is a marquee: the artboards it touches leave the selection +/// or join it, the last one joining becoming active, and no artboard is drawn. +#[test] +fn a_shift_drag_on_the_pasteboard_selects_the_artboards_it_touches() { + let mut d = two_boards(); + let mut third = d.artboards[0].clone(); + third.rect = Rect::new(0.0, 600.0, 200.0, 800.0); + d.artboards.push(third); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let shift = Mods { shift: true, ..Default::default() }; + let mut t = ArtboardTool::default(); + let marquee = |t: &mut ArtboardTool, from: (f64, f64), to: (f64, f64)| { + assert_eq!(t.pointer(&c, &ev(PointerKind::Down, from.0, from.1).with_mods(shift)), vec![]); + assert_eq!(t.pointer(&c, &ev(PointerKind::Drag, to.0, to.1).with_mods(shift)), vec![]); + assert!(t.overlays(&c).iter().any(|o| matches!(o, Overlay::Marquee(_)))); + t.pointer(&c, &ev(PointerKind::Up, to.0, to.1).with_mods(shift)) + }; + // From between the artboards across Artboard 1 (selected) and Artboard 3: 1 leaves, 3 joins. + assert_eq!(marquee(&mut t, (550.0, 150.0), (150.0, 650.0)), vec![]); + assert_eq!(t.options()["selected"], json!([2])); + // Right of Artboard 2 across its corner: it joins and becomes active. + assert_eq!(marquee(&mut t, (850.0, -50.0), (700.0, 100.0)), vec![]); + assert_eq!(t.options()["selected"], json!([1, 2])); + // A Shift-click on the pasteboard changes nothing. + assert_eq!(marquee(&mut t, (550.0, 150.0), (551.0, 150.0)), vec![]); + assert_eq!(t.options()["selected"], json!([1, 2])); +} + +/// A drag begun without Shift still moves the artboard, and on the pasteboard still draws one; +/// Shift pressed during either no longer keeps the move at 45° or the new artboard square. +#[test] +fn a_plain_drag_still_moves_and_shift_no_longer_constrains_it() { + let d = two_boards(); + let (s, p) = (Selection::default(), paint()); + let mut c = cx(&d, &s, &p); + c.smart_guides = false; + let shift = Mods { shift: true, ..Default::default() }; + let mut t = ArtboardTool::default(); + t.pointer(&c, &ev(PointerKind::Down, 700.0, 100.0)); + let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 730.0, 110.0).with_mods(shift))); + assert_eq!((v["index"].as_u64(), v["dx"].as_f64(), v["dy"].as_f64()), (Some(1), Some(30.0), Some(10.0))); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 730.0, 110.0).with_mods(shift)), vec![Action::Commit]); + t.pointer(&c, &ev(PointerKind::Down, 1000.0, 1000.0)); + t.pointer(&c, &ev(PointerKind::Drag, 1100.0, 1050.0).with_mods(shift)); + let a = t.pointer(&c, &ev(PointerKind::Up, 1100.0, 1050.0).with_mods(shift)); + assert_eq!(a, create_artboard(Rect::new(1000.0, 1000.0, 1100.0, 1050.0))); +} + +/// Escape during a marquee or while a new artboard is being drawn ends the gesture without +/// selecting or drawing anything and keeps the tool; the next Escape leaves it. +#[test] +fn escape_cancels_a_marquee_or_a_new_artboard_and_keeps_the_tool() { + let d = two_boards(); + let (s, p) = (Selection::default(), paint()); + let mut c = cx(&d, &s, &p); + c.smart_guides = false; + let shift = Mods { shift: true, ..Default::default() }; + let mut t = ArtboardTool::default(); + // A Shift-drag marquee on the pasteboard, a Shift-drag inside Artboard 1 and a plain drag on + // the pasteboard. + let drags = + [((850.0, -50.0), (700.0, 100.0), shift), ((300.0, 300.0), (420.0, 380.0), shift), ((1000.0, 1000.0), (1100.0, 1050.0), Mods::default())]; + for (from, to, mods) in drags { + t.pointer(&c, &ev(PointerKind::Down, from.0, from.1).with_mods(mods)); + t.pointer(&c, &ev(PointerKind::Drag, to.0, to.1).with_mods(mods)); + assert_eq!(t.key(&c, ToolKey::Escape, Mods::default()), vec![], "Escape from {from:?} only ends the gesture"); + assert!(!t.busy()); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, to.0, to.1).with_mods(mods)), vec![], "the release draws nothing"); + assert_eq!(t.options()["selected"], json!([0]), "the release selects nothing"); + } + assert_eq!(t.key(&c, ToolKey::Escape, Mods::default()), vec![Action::SwitchTool("selection".into())]); +} + +/// Scale Artwork with Artboard (#602): the resize asks for the art to scale, keeps the artboard's +/// proportions, and never snaps to the art scaling with it. +#[test] +fn scale_art_resizes_proportionally_without_snapping_to_its_art() { + let mut d = two_boards(); + let l = d.layers[0].id; + let id = d.alloc_id(); + let art = vectorcraft_geom::shapes::rectangle(Rect::new(700.0, 50.0, 710.0, 60.0)); + d.insert(Some(l), 0, vectorcraft_doc::Node::path(id, art, vectorcraft_doc::Appearance::default_art())).unwrap(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + // Off: the right handle 3 pt past the art's right edge (710) lands on it; the height stays. + let mut t = ArtboardTool { active: 1, ..Default::default() }; + t.pointer(&c, &ev(PointerKind::Down, 800.0, 100.0)); + let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 713.0, 100.0))); + assert_eq!((v["width"].as_f64(), v["height"].as_f64(), v.get("scaleArt")), (Some(110.0), Some(200.0), None)); + t.pointer(&c, &ev(PointerKind::Up, 713.0, 100.0)); + // On: the art scales along, so it's no target, and the height follows the width. + t.set_option("scaleArt", &json!(true)); + assert_eq!(t.options()["scaleArt"], true); + t.pointer(&c, &ev(PointerKind::Down, 800.0, 100.0)); + let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 713.0, 100.0))); + assert_eq!((v["width"].as_f64(), v["scaleArt"].as_bool()), (Some(113.0), Some(true))); + assert!((v["height"].as_f64().unwrap() - 113.0).abs() < 1e-9); +} + +/// Over an artboard's right or bottom edge the cursor shows Move and a click selects that +/// artboard, as on its left or top edge. +#[test] +fn a_click_on_an_artboards_right_or_bottom_edge_selects_it() { + let d = two_boards(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + for at in [Point::new(800.0, 100.0), Point::new(700.0, 200.0)] { + let mut t = ArtboardTool::default(); + assert_eq!(t.cursor(&c, at, Mods::default()), Cursor::Move, "{at:?}"); + t.pointer(&c, &ev(PointerKind::Down, at.x, at.y)); + t.pointer(&c, &ev(PointerKind::Up, at.x, at.y)); + assert_eq!(t.options()["selected"], json!([1]), "{at:?}"); + } +} + +/// A proportional resize never asks for a side under 1 pt, which the command would lengthen +/// alone (scaling the art unevenly): both sides grow by one factor instead. +#[test] +fn a_proportional_resize_keeps_its_proportions_at_the_smallest_size() { + let mut d = two_boards(); + d.artboards[1].rect = Rect::new(600.0, 0.0, 800.0, 20.0); + let (s, p) = (Selection::default(), paint()); + let mut c = cx(&d, &s, &p); + c.smart_guides = false; + let mut t = ArtboardTool { active: 1, scale_art: true, ..Default::default() }; + // The right handle to 2.5 % of the width: 5 × 0.5, grown to 10 × 1 about the left middle. + t.pointer(&c, &ev(PointerKind::Down, 800.0, 10.0)); + let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 605.0, 10.0))); + let got = ["x", "y", "width", "height"].map(|k| v[k].as_f64().unwrap()); + assert!(got.iter().zip([600.0, 9.5, 10.0, 1.0]).all(|(a, b)| (a - b).abs() < 1e-9), "{got:?}"); +} + +#[test] +fn bleed_edges_snap_to_bleed_edges() { + let mut d = two_boards(); + d.setup.bleed = [10.0; 4]; + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let mut t = ArtboardTool::default(); + // Artboard 2's bleed (left edge 590) lands on artboard 1's bleed (right edge 510): dragged + // 77 pt left it's 3 pt off, nothing else is within reach. + assert_eq!(drag_board2(&c, &mut t, -77.0, 0.0)["dx"], -80.0); + t.pointer(&c, &ev(PointerKind::Up, 623.0, 100.0)); + // Resizing: the left handle's bleed edge snaps the same way. + t.pointer(&c, &ev(PointerKind::Down, 600.0, 100.0)); + let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 523.0, 100.0))); + assert_eq!((v["x"].as_f64(), v["width"].as_f64()), (Some(520.0), Some(280.0))); +} + +#[test] +fn move_resize_create_delete() { + let (mut d, _) = doc_with_rect(); + let mut ab = d.artboards[0].clone(); + ab.rect = Rect::new(600.0, 0.0, 800.0, 200.0); + ab.name = "Artboard 2".into(); + d.artboards.push(ab); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = ArtboardTool::default(); + // Click the second artboard → active; drag moves it. + t.pointer(&cx, &ev(PointerKind::Down, 700.0, 100.0)); + assert_eq!(t.active, 1); + let a = t.pointer(&cx, &ev(PointerKind::Drag, 710.0, 120.0)); + assert_eq!(a[0], Action::Begin("Move Artboard".into())); + assert_eq!(a[1], Action::Preview("artboard.move".into(), json!({"index": 1, "dx": 10.0, "dy": 20.0, "moveArt": true, "copy": false}))); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 710.0, 120.0)), vec![Action::Commit]); + assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Label { text, .. } if text == "02 - Artboard 2"))); + // Handle drag resizes. + t.pointer(&cx, &ev(PointerKind::Down, 800.0, 100.0)); + let a = t.pointer(&cx, &ev(PointerKind::Drag, 900.0, 100.0)); + assert_eq!(a[1], Action::Preview("artboard.setProps".into(), json!({"index": 1, "x": 600.0, "y": 0.0, "width": 300.0, "height": 200.0}))); + t.pointer(&cx, &ev(PointerKind::Up, 900.0, 100.0)); + // Drag on the pasteboard creates one. + t.pointer(&cx, &ev(PointerKind::Down, 1000.0, 1000.0)); + t.pointer(&cx, &ev(PointerKind::Drag, 1100.0, 1050.0)); + assert!(matches!(t.overlays(&cx)[0], Overlay::Marquee(_))); + let a = t.pointer(&cx, &ev(PointerKind::Up, 1100.0, 1050.0)); + assert_eq!(a[0], Action::Exec("artboard.new".into(), json!({"x": 1000.0, "y": 1000.0, "width": 100.0, "height": 50.0}))); + assert_eq!(a[1], Action::Notify("created".into())); + // Delete removes the active artboard, ahead of the Clear shortcut. + assert!(t.claims_key(&cx, ToolKey::Delete) && t.claims_key(&cx, ToolKey::Backspace)); + assert_eq!(t.key(&cx, ToolKey::Delete, Mods::default()), vec![Action::Exec("artboard.delete".into(), json!({"index": 1}))]); + assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::SwitchTool("selection".into())]); +} + +#[test] +fn an_alt_drag_copy_snaps_to_the_artboard_it_leaves_behind() { + let d = two_boards(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let alt = Mods { alt: true, ..Mods::default() }; + let mut t = ArtboardTool::default(); + // A copy of artboard 2 dragged 203 pt right: its left edge 3 pt off the original's right + // edge (800), which stays put and is a target. + t.pointer(&c, &ev(PointerKind::Down, 700.0, 100.0).with_mods(alt)); + let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 903.0, 100.0).with_mods(alt))); + assert_eq!((v["dx"].as_f64(), v["copy"].as_bool()), (Some(200.0), Some(true))); + // Released Alt: a plain move, and the artboard's own old edge is no target. + let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 903.0, 100.0))); + assert_eq!((v["dx"].as_f64(), v["copy"].as_bool()), (Some(203.0), Some(false))); +} + +#[test] +fn alt_drag_moves_a_copy_of_the_artboard() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let alt = Mods { alt: true, ..Mods::default() }; + let moved = + |dx: f64, copy: bool| Action::Preview("artboard.move".into(), json!({"index": 0, "dx": dx, "dy": 0.0, "moveArt": true, "copy": copy})); + let mut t = ArtboardTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, 100.0, 100.0).with_mods(alt)); + let a = t.pointer(&cx, &ev(PointerKind::Drag, 150.0, 100.0).with_mods(alt)); + assert_eq!(a, vec![Action::Begin("Duplicate Artboard".into()), moved(50.0, true)]); + // Alt released mid-drag: a plain move after all, and back. + let a = t.pointer(&cx, &ev(PointerKind::Drag, 160.0, 100.0)); + assert_eq!(a, vec![Action::Cancel, Action::Begin("Move Artboard".into()), moved(60.0, false)]); + let a = t.pointer(&cx, &ev(PointerKind::Drag, 170.0, 100.0).with_mods(alt)); + assert_eq!(a, vec![Action::Cancel, Action::Begin("Duplicate Artboard".into()), moved(70.0, true)]); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 170.0, 100.0).with_mods(alt)), vec![Action::Commit]); +}