use std::sync::Arc; use designcraft_compose::Cache; use designcraft_doc::build::NewDocument; use designcraft_doc::{Document, Guide, Item, ItemId, Orientation, ParaFormat, Selection, Shape, SpreadRef, Stroke}; use designcraft_geom::shapes; use designcraft_geom::{Point, Rect, Unit}; use super::*; fn ctx<'a>(d: &'a Document, s: &'a Selection, c: &'a Cache, l: &'a CanvasLayout) -> ToolContext<'a> { ToolContext { doc: d, selection: s, cache: c, layout: l, zoom: 1.0, layer: d.default_layer(), snap: SnapView::OFF, unit: Unit::Points, stroke_in_front: false, } } #[test] fn color_theme_tool_requests_a_palette_without_editing_the_image() { let mut d = Document::new(&NewDocument::default()); let id = add_rect(&mut d, Rect::new(100.0, 100.0, 200.0, 200.0)); d.item_mut(id).unwrap().content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic { asset: designcraft_doc::AssetId(1), size: (100.0, 100.0), xf: designcraft_geom::Affine::IDENTITY, clip: None, auto_fit: Default::default(), fit_align: 4, crop: [0.0; 4], wrap: Default::default(), fill: Default::default(), }); let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false)); let off = l.xf(SpreadRef::Doc(0)).translation(); let cx = ctx(&d, &s, &c, &l); let mut tool = create("colorTheme"); assert_eq!(tool.id(), "colorTheme"); assert_eq!( tool.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0 + off.x, 150.0 + off.y)), vec![Action::Dialog("colorTheme".into(), serde_json::json!({"id":id.0}))] ); assert!(tool.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0 + off.x, 150.0 + off.y)).is_empty()); } #[test] fn frame_tool_drag_emits_begin_preview_commit() { let d = Document::new(&NewDocument::default()); let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false)); let cx = ctx(&d, &s, &c, &l); let mut t = create("rectangleFrame"); assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)).is_empty()); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 110.0, 60.0)); assert!(matches!(&a[0], Action::Begin(_))); match &a[1] { Action::Preview(cmd, p) => { assert_eq!(cmd, "frame.create"); assert_eq!(p["rect"], serde_json::json!([10.0, 10.0, 110.0, 60.0])); assert_eq!(p["content"], "graphic"); } other => panic!("{other:?}"), } assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 110.0, 60.0)), vec![Action::Commit]); } #[test] fn selection_click_and_marquee() { let mut d = Document::new(&NewDocument::default()); let lid = d.default_layer(); let (fid, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(100.0, 100.0, 200.0, 200.0), lid, "x", ParaFormat::default()).unwrap(); let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false)); let cx = ctx(&d, &s, &c, &l); let mut t = create("selection"); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 150.0)); assert_eq!(a, vec![Action::Exec("selection.set".into(), serde_json::json!({"ids": [fid.0], "content": false}))]); t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 150.0)); // Marquee from empty space. t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 20.0, 20.0)); t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 300.0, 300.0)); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 300.0, 300.0)); assert_eq!(a, vec![Action::Exec("selection.set".into(), serde_json::json!({"ids": [fid.0], "add": false}))]); } #[test] fn resize_rect_modifiers() { let r = Rect::new(0.0, 0.0, 100.0, 50.0); assert_eq!(select::resize_rect(r, 4, designcraft_geom::Point::new(200.0, 80.0), Mods::default()), Rect::new(0.0, 0.0, 200.0, 80.0)); let p = select::resize_rect(r, 4, designcraft_geom::Point::new(200.0, 60.0), Mods { shift: true, ..Default::default() }); assert_eq!(p, Rect::new(0.0, 0.0, 200.0, 100.0)); let c = select::resize_rect(r, 3, designcraft_geom::Point::new(150.0, 25.0), Mods { alt: true, ..Default::default() }); assert_eq!(c, Rect::new(-50.0, 0.0, 150.0, 50.0)); } #[test] fn resize_side_shift_keeps_the_other_axis_centred() { let r = Rect::new(0.0, 0.0, 100.0, 50.0); let shift = Mods { shift: true, ..Default::default() }; assert_eq!(select::resize_rect(r, 3, Point::new(150.0, 25.0), shift), Rect::new(0.0, -12.5, 150.0, 62.5)); assert_eq!(select::resize_rect(r, 5, Point::new(50.0, 100.0), shift), Rect::new(-50.0, 0.0, 150.0, 100.0)); } #[test] fn resize_corner_alt_shift_scales_around_the_centre() { let r = Rect::new(0.0, 0.0, 100.0, 50.0); let mods = Mods { shift: true, alt: true, ..Default::default() }; // From the centre the pointer is at 0.2 by 0.6; the larger factor drives and the corner stays on y = 40. assert_eq!(select::resize_rect(r, 4, Point::new(60.0, 40.0), mods), Rect::new(20.0, 10.0, 80.0, 40.0)); assert_eq!(select::resize_rect(r, 0, Point::new(-50.0, 10.0), mods), Rect::new(-50.0, -25.0, 150.0, 75.0)); } #[test] fn resize_corner_alt_mirrors_the_opposite_side() { let r = Rect::new(0.0, 0.0, 100.0, 50.0); let alt = Mods { alt: true, ..Default::default() }; assert_eq!(select::resize_rect(r, 0, Point::new(10.0, 5.0), alt), Rect::new(10.0, 5.0, 90.0, 45.0)); assert_eq!(select::resize_rect(r, 4, Point::new(120.0, 60.0), alt), Rect::new(-20.0, -10.0, 120.0, 60.0)); } #[test] fn cmd_shift_click_overrides_parent_item() { let mut d = Document::new(&NewDocument::default()); let lid = d.default_layer(); // On the parent's right page, which shows behind document page 1 (a right page). let px = d.parents[0].pages.last().unwrap().x; let (pid, _) = d.add_text_frame(SpreadRef::Parent(0), Rect::new(px + 100.0, 100.0, px + 200.0, 200.0), lid, "folio", ParaFormat::default()).unwrap(); let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false)); let cx = ctx(&d, &s, &c, &l); let off = l.xf(SpreadRef::Doc(0)).translation(); let x = d.spreads[0].pages[0].x + 150.0 + off.x; let mut t = create("selection"); // A plain click doesn't reach parent items. let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, x, 150.0 + off.y)); assert_eq!(a, vec![Action::Exec("selection.set".into(), serde_json::json!({"ids": []}))]); t.pointer(&cx, &PointerEvent::new(PointerKind::Up, x, 150.0 + off.y)); let mut ev = PointerEvent::new(PointerKind::Down, x, 150.0 + off.y); ev.mods = Mods { cmd: true, shift: true, ..Default::default() }; let a = t.pointer(&cx, &ev); assert_eq!(a, vec![Action::Exec("layout.overrideParentItems".into(), serde_json::json!({"page": 0, "ids": [pid.0]}))]); } /// On a page whose parent B is based on A, A's items are hit too, B's win where they overlap, and /// nothing is hit when the page hides parent items. #[test] fn parent_hit_reaches_base_parent_items_topmost_first() { let mut d = Document::new(&NewDocument { pages: 1, facing_pages: false, ..Default::default() }); let lid = d.default_layer(); let a = d.parents[0].id; let b = d.add_parent("B", "Opening", 1, 12.0, designcraft_doc::Margins::uniform(36.0)); Arc::make_mut(&mut d.parents[1]).parent.as_mut().unwrap().based_on = Some(a); d.apply_parent(&[0], Some(b)).unwrap(); let (a_item, _) = d.add_text_frame(SpreadRef::Parent(0), Rect::new(100.0, 100.0, 200.0, 200.0), lid, "a", ParaFormat::default()).unwrap(); let (b_item, _) = d.add_text_frame(SpreadRef::Parent(1), Rect::new(150.0, 150.0, 250.0, 250.0), lid, "b", ParaFormat::default()).unwrap(); let hit = |d: &Document, x: f64, y: f64| { let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(d, false)); let off = l.xf(SpreadRef::Doc(0)).translation(); ctx(d, &s, &c, &l).hit_parent_on_page(Point::new(x + off.x, y + off.y)) }; assert_eq!(hit(&d, 120.0, 120.0), Some((0, a_item))); assert_eq!(hit(&d, 175.0, 175.0), Some((0, b_item)), "B's item is drawn over A's"); Arc::make_mut(&mut d.spreads[0]).pages[0].show_parent_items = false; assert_eq!(hit(&d, 120.0, 120.0), None); } #[test] fn gradient_tool_drag_sets_the_vector() { let mut d = Document::new(&NewDocument::default()); let lid = d.default_layer(); let (fid, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(100.0, 100.0, 300.0, 200.0), lid, "", ParaFormat::default()).unwrap(); let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false)); let cx = ctx(&d, &s, &c, &l); let off = l.xf(SpreadRef::Doc(0)).translation(); let mut t = create("gradientSwatch"); assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 120.0 + off.x, 150.0 + off.y)), vec![Action::Begin("Gradient".into())]); // Shift snaps a slightly tilted drag to horizontal. let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 220.0 + off.x, 153.0 + off.y).with_mods(Mods { shift: true, ..Default::default() })); match &a[0] { Action::Preview(cmd, p) => { assert_eq!(cmd, "object.gradient"); assert_eq!(p["ids"], serde_json::json!([fid.0])); assert_eq!(p["from"], serde_json::json!([120.0, 150.0])); let to = p["to"].as_array().unwrap(); assert!((to[1].as_f64().unwrap() - 150.0).abs() < 1e-9, "{to:?}"); } other => panic!("{other:?}"), } assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 220.0 + off.x, 153.0 + off.y)), vec![Action::Commit]); } #[test] fn anchor_tools_emit_path_commands() { let mut d = Document::new(&NewDocument::default()); let lid = d.default_layer(); let (fid, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(100.0, 100.0, 200.0, 200.0), lid, "", ParaFormat::default()).unwrap(); let (s, c, l) = (Selection::items(vec![fid]), Cache::new(), CanvasLayout::new(&d, false)); let cx = ctx(&d, &s, &c, &l); let off = l.xf(SpreadRef::Doc(0)).translation(); let at = |x: f64, y: f64| (x + off.x, y + off.y); let ev = |k, (x, y): (f64, f64)| PointerEvent::new(k, x, y); let mut add = create("addAnchor"); match &add.pointer(&cx, &ev(PointerKind::Down, at(150.0, 101.0)))[..] { [Action::Exec(cmd, p)] => { assert_eq!(cmd, "path.addAnchor"); assert_eq!(p["at"], serde_json::json!([150.0, 101.0])); } other => panic!("{other:?}"), } assert!(add.pointer(&cx, &ev(PointerKind::Down, at(150.0, 150.0))).is_empty(), "inside, away from the edge"); let mut del = create("deleteAnchor"); assert_eq!( del.pointer(&cx, &ev(PointerKind::Down, at(200.0, 100.0))), vec![Action::Exec("path.deleteAnchor".into(), serde_json::json!({"id": fid.0, "subpath": 0, "anchor": 1}))] ); // Convert: click toggles, drag pulls out handles. let mut conv = create("convertDirection"); conv.pointer(&cx, &ev(PointerKind::Down, at(100.0, 100.0))); assert_eq!( conv.pointer(&cx, &ev(PointerKind::Up, at(100.0, 100.0))), vec![Action::Exec("path.convertAnchor".into(), serde_json::json!({"id": fid.0, "subpath": 0, "anchor": 0}))] ); conv.pointer(&cx, &ev(PointerKind::Down, at(100.0, 100.0))); let a = conv.pointer(&cx, &ev(PointerKind::Drag, at(120.0, 90.0))); assert_eq!(a[0], Action::Begin("Convert Direction Point".into())); assert!(matches!(&a[1], Action::Preview(c, p) if c == "path.convertAnchor" && p["to"] == serde_json::json!([120.0, 90.0]))); assert_eq!(conv.pointer(&cx, &ev(PointerKind::Up, at(120.0, 90.0))), vec![Action::Commit]); } #[test] fn scissors_tool_cuts_where_clicked() { let mut d = Document::new(&NewDocument::default()); let lid = d.default_layer(); let (fid, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(100.0, 100.0, 200.0, 200.0), lid, "", ParaFormat::default()).unwrap(); let (s, c, l) = (Selection::items(vec![fid]), Cache::new(), CanvasLayout::new(&d, false)); let cx = ctx(&d, &s, &c, &l); let off = l.xf(SpreadRef::Doc(0)).translation(); let mut t = create("scissors"); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0 + off.x, 100.0 + off.y)); assert_eq!(a, vec![Action::Exec("path.split".into(), serde_json::json!({"id": fid.0, "at": [150.0, 100.0]}))]); } #[test] fn pencil_draws_a_smooth_path() { let d = Document::new(&NewDocument::default()); let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false)); let cx = ctx(&d, &s, &c, &l); let off = l.xf(SpreadRef::Doc(0)).translation(); let mut t = create("pencil"); t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0 + off.x, 100.0 + off.y)); for i in 1..=90 { let a = (i as f64 * 2.0).to_radians(); t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 100.0 + 50.0 * a.sin() + off.x, 100.0 + 50.0 * (1.0 - a.cos()) + off.y)); } assert!(t.busy()); // While drawing, the stroke so far is shown, in the colour of the layer it goes on. let layer = d.layer(cx.layer).unwrap().color; match &t.overlays(&cx)[..] { [crate::Overlay::Path { path, color, dashed: false }] => { assert_eq!(*color, layer); assert!(path.elements().len() > 40, "the stroke so far: {} points", path.elements().len()); assert_eq!(path.elements()[0], designcraft_geom::PathEl::MoveTo(designcraft_geom::Point::new(100.0 + off.x, 100.0 + off.y))); } other => panic!("{other:?}"), } let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0 + off.x, 150.0 + off.y)); assert!(t.overlays(&cx).is_empty(), "the preview ends with the stroke"); match &a[..] { [Action::Exec(cmd, p)] => { assert_eq!(cmd, "path.create"); let n = p["anchors"].as_array().unwrap().len(); assert!((3..30).contains(&n), "{n} anchors"); assert_eq!(p["anchors"][0]["p"], serde_json::json!([100.0, 100.0])); } other => panic!("{other:?}"), } } #[test] fn zoom_tool_click_and_scrub() { let d = Document::new(&NewDocument::default()); let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false)); let cx = ctx(&d, &s, &c, &l); let mut t = create("zoom"); t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); assert_eq!( t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 100.0, 100.0)), vec![Action::View(serde_json::json!({"zoomAt": [100.0, 100.0], "factor": 2.0}))] ); // Dragging 150 px right zooms in by e, anchored at the press point; no click zoom after. t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); match &t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 250.0, 100.0))[..] { [Action::View(v)] => { assert_eq!(v["zoomAt"], serde_json::json!([100.0, 100.0])); assert!((v["factor"].as_f64().unwrap() - std::f64::consts::E).abs() < 1e-9); } other => panic!("{other:?}"), } assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 100.0)).is_empty()); } #[test] fn hand_tool_alt_press_power_zooms() { let d = Document::new(&NewDocument::default()); let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false)); let cx = ctx(&d, &s, &c, &l); let mut t = create("hand"); let alt = Mods { alt: true, ..Default::default() }; let phase = |a: Vec| match &a[..] { [Action::View(v)] => v["powerZoom"].as_str().unwrap_or("").to_string(), other => panic!("{other:?}"), }; assert_eq!(phase(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0).with_mods(alt))), "start"); assert_eq!(phase(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 50.0, 60.0))), "move"); assert_eq!(phase(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 50.0, 60.0))), "end"); // A plain press pans as before. t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 10.0, 10.0)).is_empty()); } #[test] fn page_tool_selects_a_page_and_opens_page_size_on_double_click() { let d = Document::new(&NewDocument::default()); let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false)); let cx = ctx(&d, &s, &c, &l); let off = l.xf(SpreadRef::Doc(0)).translation(); let (x, y) = (200.0 + off.x + d.spreads[0].pages[0].x, 200.0 + off.y); let mut t = create("page"); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, x, y)); assert_eq!(a, vec![Action::Exec("layout.selectPage".into(), serde_json::json!({"page": 1, "ids": []}))]); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::DoubleClick, x, y)); assert_eq!(a, vec![Action::Dialog("cmd:layout.pageSize".into(), serde_json::json!({"pages": [1], "width": 612.0, "height": 792.0}))]); // The selected page shows its handles and its liquid page rule. let s = Selection { page: Some(0), ..Default::default() }; let cx = ctx(&d, &s, &c, &l); let o = t.overlays(&cx); assert!(matches!(o.as_slice(), [Overlay::PageHandles(_), Overlay::Tag { text, .. }] if text == "Off"), "{o:?}"); } #[test] fn type_on_path_tool_targets_paths() { let mut d = Document::new(&NewDocument::default()); let lid = d.default_layer(); let id = designcraft_doc::ItemId(d.alloc()); let mut it = designcraft_doc::Item::new( id, lid, designcraft_doc::Shape::GraphicLine, designcraft_geom::shapes::line(designcraft_geom::Point::new(100.0, 100.0), designcraft_geom::Point::new(300.0, 100.0)), ); it.stroke = designcraft_doc::Stroke::default(); d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false)); let cx = ctx(&d, &s, &c, &l); let off = l.xf(SpreadRef::Doc(0)).translation(); let mut t = create("typeOnPath"); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 200.0 + off.x, 100.0 + off.y)); assert_eq!(a, vec![Action::Exec("type.onPath".into(), serde_json::json!({"id": id.0})), Action::SwitchTool("type".into())]); } #[test] fn vertical_type_tool_draws_vertical_frames() { let d = Document::new(&NewDocument::default()); let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false)); let cx = ctx(&d, &s, &c, &l); let mut t = create("verticalType"); assert_eq!(t.id(), "verticalType"); t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 300.0)); let p = a.iter().find_map(|x| match x { Action::Preview(id, p) if id == "frame.create" => Some(p.clone()), _ => None, }); assert_eq!(p.unwrap()["vertical"], true); } #[test] fn turned_spread_maps_points_both_ways() { let mut d = Document::new(&NewDocument::default()); std::sync::Arc::make_mut(&mut d.spreads[0]).pages[0].view_rotation = 1; let l = CanvasLayout::new(&d, false); let slot = l.slots[0]; let b = d.spreads[0].bounds(); assert!((slot.bounds.width() - b.height()).abs() < 1e-6); let p = designcraft_geom::Point::new(b.x0 + 10.0, b.y0 + 20.0); let c = slot.to_canvas(p); let back = slot.to_spread(c); assert!((back - p).hypot() < 1e-9); // A quarter turn clockwise: the spread's top-left lands at the canvas top-right. assert!((c.x - (slot.bounds.x1 - 20.0)).abs() < 1e-6 && (c.y - (slot.bounds.y0 + 10.0)).abs() < 1e-6, "{c:?} {:?}", slot.bounds); let (sr, sp) = l.spread_at(c).unwrap(); assert_eq!(sr, SpreadRef::Doc(0)); assert!((sp - p).hypot() < 1e-9); // A canvas drag rightwards is a drag down the spread. let dv = slot.delta_to_spread(designcraft_geom::Vec2::new(10.0, 0.0)); assert!(dv.x.abs() < 1e-9 && (dv.y + 10.0).abs() < 1e-9 || (dv.y - 10.0).abs() < 1e-9, "{dv:?}"); } fn add_rect(doc: &mut Document, rect: Rect) -> ItemId { let layer = doc.default_layer(); let id = ItemId(doc.alloc()); let item = Item::new(id, layer, Shape::Rectangle, shapes::rectangle(rect)); doc.insert_item(SpreadRef::Doc(0), item, None).unwrap(); id } /// Dimensions on, ruler guides and the document grid off. Alignment is optional. fn dims_ctx<'a>(d: &'a Document, s: &'a Selection, c: &'a Cache, l: &'a CanvasLayout, align: bool) -> ToolContext<'a> { let mut cx = ctx(d, s, c, l); cx.snap = SnapView::FACTORY; cx.snap.snap_to_guides = false; cx.snap.snap_to_document_grid = false; cx.snap.align_edges = align; cx.snap.align_centers = align; cx } fn spread_pt(l: &CanvasLayout, x: f64, y: f64) -> Point { l.to_canvas(SpreadRef::Doc(0), Point::new(x, y)) } fn near(a: Point, b: Point) -> bool { (a.x - b.x).abs() < 1e-4 && (a.y - b.y).abs() < 1e-4 } fn guide_matches(overlays: &[Overlay], a: Point, b: Point) -> bool { overlays.iter().any(|o| match o { Overlay::Guide { a: ga, b: gb } => (near(*ga, a) && near(*gb, b)) || (near(*ga, b) && near(*gb, a)), _ => false, }) } fn guide_list(overlays: &[Overlay]) -> String { overlays .iter() .filter_map(|o| match o { Overlay::Guide { a, b } => Some(format!("({:.4},{:.4})-({:.4},{:.4})", a.x, a.y, b.x, b.y)), _ => None, }) .collect::>() .join("; ") } #[test] fn shift_alt_resize_keeps_aspect_and_center() { // 100 by 50, center (150, 125), aspect 2. Corner handle, Shift and Alt. // The centered width is 160. Another item's side is 163, inside the zone. let mut doc = Document::new(&NewDocument::default()); let moving = add_rect(&mut doc, Rect::new(100.0, 100.0, 200.0, 150.0)); add_rect(&mut doc, Rect::new(400.0, 400.0, 563.0, 430.0)); let sel = Selection::items(vec![moving]); let cache = Cache::new(); let layout = CanvasLayout::new(&doc, false); let cx = dims_ctx(&doc, &sel, &cache, &layout, false); let mut t = create("selection"); let mods = Mods { shift: true, alt: true, ..Mods::default() }; let down = spread_pt(&layout, 200.0, 150.0); let drag = spread_pt(&layout, 230.0, 155.0); t.pointer(&cx, &PointerEvent::new(PointerKind::Down, down.x, down.y).with_mods(mods)); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, drag.x, drag.y).with_mods(mods)); let params = match &a[..] { [Action::Preview(cmd, p)] => { assert_eq!(cmd, "transform.resize"); p } other => panic!("{other:?}"), }; let to = params["to"].as_array().unwrap(); let (x0, y0, x1, y1) = (to[0].as_f64().unwrap(), to[1].as_f64().unwrap(), to[2].as_f64().unwrap(), to[3].as_f64().unwrap()); let (w, h) = (x1 - x0, y1 - y0); let (cx0, cy0) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0); assert!((cx0 - 150.0).abs() < 1e-4 && (cy0 - 125.0).abs() < 1e-4, "center walked to ({cx0}, {cy0}) rect {to:?}"); assert!((w / h - 2.0).abs() < 1e-4, "aspect {} rect {to:?}", w / h); assert!((w - 163.0).abs() < 1e-4, "driving length {w}, want the matched 163, not twice the correction"); } #[test] fn line_length_keeps_the_crossed_guide() { // Diagonal from (100, 100) to (180, 140). Length is about 89.44, near a 92 side. // Endpoint y is 2 pt from the other item's top, so Y is a position snap and X is the length. let mut doc = Document::new(&NewDocument::default()); add_rect(&mut doc, Rect::new(400.0, 142.0, 492.0, 180.0)); let sel = Selection::default(); let cache = Cache::new(); let layout = CanvasLayout::new(&doc, false); let cx = dims_ctx(&doc, &sel, &cache, &layout, true); let mut t = create("line"); let a = spread_pt(&layout, 100.0, 100.0); let b = spread_pt(&layout, 180.0, 140.0); t.pointer(&cx, &PointerEvent::new(PointerKind::Down, a.x, a.y)); let actions = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, b.x, b.y)); let params = actions .iter() .find_map(|act| match act { Action::Preview(cmd, p) if cmd == "line.create" => Some(p), _ => None, }) .unwrap(); let end = params["b"].as_array().unwrap(); let (bx, by) = (end[0].as_f64().unwrap(), end[1].as_f64().unwrap()); let len = 80.0_f64.hypot(40.0); let target = 92.0_f64; let dy = 142.0 - 100.0; // Y stays on the guide. X is solved so the segment length is 92, not by adding the difference to x. let remain = target * target - dy * dy; let expect_x = 100.0 + remain.sqrt(); assert!((by - 142.0).abs() < 1e-4, "endpoint y {by} left the guide at 142 (x {bx})"); assert!((bx - expect_x).abs() < 1e-4, "endpoint x {bx}, want {expect_x}"); let got = (bx - 100.0).hypot(by - 100.0); assert!((got - target).abs() < 1e-4, "length {got}, raw was {len}"); assert!((bx - (180.0 + (target - len))).abs() > 0.1, "x still took the raw length difference"); } #[test] fn stroked_move_aligns_visible_edges() { // 2 pt centered strokes. The other path ends at 70, so its visible edge is 71. // A geometric move would land the path on 71. The visible edge must land there instead. let mut doc = Document::new(&NewDocument::default()); let layer = doc.default_layer(); let stroke = Stroke { weight: 2.0, ..Stroke::default() }; let make = |doc: &mut Document, rect: Rect| { let id = ItemId(doc.alloc()); let mut item = Item::new(id, layer, Shape::Rectangle, shapes::rectangle(rect)); item.stroke = stroke.clone(); doc.insert_item(SpreadRef::Doc(0), item, None).unwrap(); id }; let _other = make(&mut doc, Rect::new(40.0, 100.0, 70.0, 140.0)); let moving = make(&mut doc, Rect::new(100.0, 100.0, 160.0, 140.0)); let sel = Selection::items(vec![moving]); let cache = Cache::new(); let layout = CanvasLayout::new(&doc, false); let cx = dims_ctx(&doc, &sel, &cache, &layout, true); let mut t = create("selection"); let down = spread_pt(&layout, 130.0, 120.0); let drag = spread_pt(&layout, 103.0, 120.0); t.pointer(&cx, &PointerEvent::new(PointerKind::Down, down.x, down.y)); let actions = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, drag.x, drag.y)); let dx = match actions.last() { Some(Action::Preview(cmd, p)) => { assert_eq!(cmd, "transform.move"); p["dx"].as_f64().unwrap() } other => panic!("{other:?}"), }; assert!((dx - -28.0).abs() < 1e-4, "dx {dx}, want -28 so the visible edge meets 71"); } #[test] fn vertical_line_length_matches_along_the_segment() { // Vertical, length 80, near a 84 side. The match must move y, not add 4 to x. let mut doc = Document::new(&NewDocument::default()); add_rect(&mut doc, Rect::new(400.0, 400.0, 450.0, 484.0)); let sel = Selection::default(); let cache = Cache::new(); let layout = CanvasLayout::new(&doc, false); let cx = dims_ctx(&doc, &sel, &cache, &layout, false); let mut t = create("line"); let a = spread_pt(&layout, 100.0, 100.0); let b = spread_pt(&layout, 100.0, 180.0); t.pointer(&cx, &PointerEvent::new(PointerKind::Down, a.x, a.y)); let actions = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, b.x, b.y)); let params = actions .iter() .find_map(|act| match act { Action::Preview(cmd, p) if cmd == "line.create" => Some(p), _ => None, }) .unwrap(); let end = params["b"].as_array().unwrap(); let (bx, by) = (end[0].as_f64().unwrap(), end[1].as_f64().unwrap()); assert!((bx - 100.0).abs() < 1e-4, "x moved to {bx}"); assert!((by - 184.0).abs() < 1e-4, "y {by}, want 184"); let xf = layout.xf(SpreadRef::Doc(0)); let seg = (xf * Point::new(100.0, 100.0), xf * Point::new(100.0, 184.0)); let overlays = t.overlays(&cx); assert!(guide_matches(&overlays, seg.0, seg.1), "guide is not the segment [{}]", guide_list(&overlays)); } #[test] fn shift_frame_stays_square_on_the_driving_guide() { // Pointer dx is larger, so x drives. The square's right edge is 3 pt from the guide at 203. // Y must follow that snapped size, not stay at the unconstrained square. let mut doc = Document::new(&NewDocument::default()); doc.settings.baseline_grid.increment = 0.0; Arc::make_mut(&mut doc.spreads[0]).pages[0].guides.push(Guide { orientation: Orientation::Vertical, position: 203.0, spread: true, locked: false, layer: None, liquid: false, }); let sel = Selection::default(); let cache = Cache::new(); let layout = CanvasLayout::new(&doc, false); let mut cx = ctx(&doc, &sel, &cache, &layout); cx.snap = SnapView::FACTORY; cx.snap.snap_to_document_grid = false; cx.snap.smart_dimensions = false; let mut t = create("rectangle"); let mods = Mods { shift: true, ..Mods::default() }; let a = spread_pt(&layout, 120.0, 120.0); let b = spread_pt(&layout, 200.0, 150.0); t.pointer(&cx, &PointerEvent::new(PointerKind::Down, a.x, a.y).with_mods(mods)); let actions = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, b.x, b.y).with_mods(mods)); let rect = actions .iter() .find_map(|act| match act { Action::Preview(cmd, p) if cmd == "frame.create" => p["rect"].as_array(), _ => None, }) .unwrap(); let (x0, y0, x1, y1) = (rect[0].as_f64().unwrap(), rect[1].as_f64().unwrap(), rect[2].as_f64().unwrap(), rect[3].as_f64().unwrap()); assert!((x1 - 203.0).abs() < 1e-4, "driving edge {x1} left the guide"); assert!((y1 - y0 - (x1 - x0)).abs() < 1e-4, "not square: {x0} {y0} {x1} {y1}"); assert!((y1 - 203.0).abs() < 1e-4, "other edge {y1} did not follow the snapped size"); } #[test] fn drag_onto_the_next_spread_snaps_to_that_guide() { // The origin spread has a guide 4 pt from the unsnapped top. The destination guide is 2 pt away. // Snapping on the origin would move by 4. Snapping under the pointer moves by 2. let mut doc = Document::new(&NewDocument { pages: 2, facing_pages: false, ..NewDocument::default() }); doc.settings.baseline_grid.increment = 0.0; let id = add_rect(&mut doc, Rect::new(100.0, 100.0, 160.0, 140.0)); let guide = |position: f64| Guide { orientation: Orientation::Horizontal, position, spread: true, locked: false, layer: None, liquid: false }; Arc::make_mut(&mut doc.spreads[1]).pages[0].guides.push(guide(52.0)); let sel = Selection::items(vec![id]); let cache = Cache::new(); let layout = CanvasLayout::new(&doc, false); assert_eq!(layout.slots.len(), 2); let item = Rect::new(100.0, 100.0, 160.0, 140.0); let raw_top = 50.0; let corner0 = layout.to_canvas(SpreadRef::Doc(0), Point::new(item.x0, item.y0)); let corner_raw = layout.to_canvas(SpreadRef::Doc(1), Point::new(item.x0, raw_top)); let travel = designcraft_geom::Vec2::new(corner_raw.x - corner0.x, corner_raw.y - corner0.y); let origin_top = item.y0 + layout.delta_to_spread(SpreadRef::Doc(0), travel).y; Arc::make_mut(&mut doc.spreads[0]).pages[0].guides.push(guide(origin_top - 4.0)); let mut cx = ctx(&doc, &sel, &cache, &layout); cx.snap = SnapView::FACTORY; cx.snap.snap_to_document_grid = false; let down = layout.to_canvas(SpreadRef::Doc(0), item.center()); let drag_at = Point::new(down.x + (corner_raw.x - corner0.x), down.y + (corner_raw.y - corner0.y)); let mut t = create("selection"); t.pointer(&cx, &PointerEvent::new(PointerKind::Down, down.x, down.y)); let actions = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, drag_at.x, drag_at.y)); let params = match actions.last() { Some(Action::Preview(cmd, p)) => { assert_eq!(cmd, "transform.move"); p } other => panic!("{other:?}"), }; let dy = params["dy"].as_f64().unwrap(); assert_eq!(params["toSpread"], spread_json(SpreadRef::Doc(1))); assert!((dy - (52.0 - item.y0)).abs() < 1e-3, "dy {dy}, origin snap would not land on 52"); } #[test] fn dimension_guide_lies_on_the_committed_side() { let mut problems = Vec::new(); // Centered scale. Width 120 grows to the matched 123, so each side moves 1.5, not 3. let mut doc = Document::new(&NewDocument::default()); let moving = add_rect(&mut doc, Rect::new(100.0, 100.0, 200.0, 160.0)); add_rect(&mut doc, Rect::new(400.0, 400.0, 523.0, 440.0)); let sel = Selection::items(vec![moving]); let cache = Cache::new(); let layout = CanvasLayout::new(&doc, false); let cx = dims_ctx(&doc, &sel, &cache, &layout, false); let mut t = create("scale"); let center = spread_pt(&layout, 150.0, 130.0); t.pointer(&cx, &PointerEvent::new(PointerKind::Down, center.x + 50.0, center.y + 30.0)); let drag = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, center.x + 60.0, center.y + 40.0)); let (sx, sy) = match &drag[..] { [Action::Preview(cmd, p)] => { assert_eq!(cmd, "transform.scale"); (p["sx"].as_f64().unwrap(), p["sy"].as_f64().unwrap()) } other => panic!("{other:?}"), }; let committed = Rect::new(150.0 - 50.0 * sx.abs(), 130.0 - 30.0 * sy.abs(), 150.0 + 50.0 * sx.abs(), 130.0 + 30.0 * sy.abs()); let xf = layout.xf(SpreadRef::Doc(0)); let bottom = (xf * Point::new(committed.x0, committed.y1), xf * Point::new(committed.x1, committed.y1)); let overlays = t.overlays(&cx); let scale_guides = overlays.iter().filter(|o| matches!(o, Overlay::Guide { .. })).count(); if scale_guides != 1 || !guide_matches(&overlays, bottom.0, bottom.1) { problems.push(format!( "scale guide is not on the committed bottom ({:.4},{:.4})-({:.4},{:.4}), sx {sx} sy {sy}, {scale_guides} guides [{}]", bottom.0.x, bottom.0.y, bottom.1.x, bottom.1.y, guide_list(&overlays) )); } // Shift resize. Width snaps 130 to 133, then the height follows the aspect and the bottom moves. let mut doc = Document::new(&NewDocument::default()); let moving = add_rect(&mut doc, Rect::new(100.0, 100.0, 200.0, 150.0)); add_rect(&mut doc, Rect::new(400.0, 400.0, 533.0, 430.0)); let sel = Selection::items(vec![moving]); let cache = Cache::new(); let layout = CanvasLayout::new(&doc, false); let cx = dims_ctx(&doc, &sel, &cache, &layout, false); let mut t = create("selection"); let mods = Mods { shift: true, ..Mods::default() }; let down = spread_pt(&layout, 200.0, 150.0); let drag_at = spread_pt(&layout, 230.0, 155.0); t.pointer(&cx, &PointerEvent::new(PointerKind::Down, down.x, down.y)); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, drag_at.x, drag_at.y).with_mods(mods)); let to = match &a[..] { [Action::Preview(cmd, p)] => { assert_eq!(cmd, "transform.resize"); p["to"].as_array().unwrap() } other => panic!("{other:?}"), }; let rect = Rect::new(to[0].as_f64().unwrap(), to[1].as_f64().unwrap(), to[2].as_f64().unwrap(), to[3].as_f64().unwrap()); let xf = layout.xf(SpreadRef::Doc(0)); let y = rect.y0.max(rect.y1); let (x0, x1) = (rect.x0.min(rect.x1), rect.x0.max(rect.x1)); let side = (xf * Point::new(x0, y), xf * Point::new(x1, y)); let overlays = t.overlays(&cx); let shift_guides = overlays.iter().filter(|o| matches!(o, Overlay::Guide { .. })).count(); if shift_guides != 1 || !guide_matches(&overlays, side.0, side.1) { problems.push(format!( "shift guide is not on the committed side ({:.4},{:.4})-({:.4},{:.4}), rect {to:?}, {shift_guides} guides [{}]", side.0.x, side.0.y, side.1.x, side.1.y, guide_list(&overlays) )); } assert!(problems.is_empty(), "{}", problems.join(" | ")); } #[test] fn rotation_drag_lands_on_the_other_angle() { // Reference starts at 10. A raw command delta of 18 would show 28. // The other item is at 30, so the composed preview delta is 20, not 30. let mut doc = Document::new(&NewDocument::default()); let moving = add_rotated(&mut doc, Rect::new(100.0, 100.0, 180.0, 140.0), 10.0); add_rotated(&mut doc, Rect::new(300.0, 80.0, 380.0, 140.0), 30.0); let sel = Selection::items(vec![moving]); let cache = Cache::new(); let layout = CanvasLayout::new(&doc, false); let cx = dims_ctx(&doc, &sel, &cache, &layout, false); let center = cx.selection_bounds().unwrap().center(); let radius = 80.0; let down = Point::new(center.x + radius, center.y); let aim = (-18.0_f64).to_radians(); let drag_at = Point::new(center.x + radius * aim.cos(), center.y + radius * aim.sin()); let raw_command = -((drag_at - center).atan2() - (down - center).atan2()).to_degrees(); assert!((raw_command - 18.0).abs() < 1e-6, "unsnapped command {raw_command}"); let mut t = create("rotate"); t.pointer(&cx, &PointerEvent::new(PointerKind::Down, down.x, down.y)); let actions = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, drag_at.x, drag_at.y)); let angle = match &actions[..] { [Action::Preview(cmd, p)] => { assert_eq!(cmd, "transform.rotate"); p["angle"].as_f64().unwrap() } other => panic!("{other:?}"), }; assert!((angle - 20.0).abs() < 1e-4, "preview angle {angle}, want 20"); } fn add_rotated(doc: &mut Document, rect: Rect, degrees: f64) -> ItemId { let layer = doc.default_layer(); let id = ItemId(doc.alloc()); let mut item = Item::new(id, layer, Shape::Rectangle, shapes::rectangle(rect)); item.xf = designcraft_geom::Affine::rotate((-degrees).to_radians()); doc.insert_item(SpreadRef::Doc(0), item, None).unwrap(); id } #[test] fn pen_click_snaps_to_a_guide() { let mut d = Document::new(&NewDocument::default()); Arc::make_mut(&mut d.spreads[0]).pages[0].guides.push(Guide { orientation: Orientation::Vertical, position: 100.0, spread: true, locked: false, layer: None, liquid: false, }); let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false)); let mut cx = ctx(&d, &s, &c, &l); cx.snap = SnapView::FACTORY; let mut t = create("pen"); let click = |t: &mut Box, cx: &ToolContext, x: f64, y: f64| { t.pointer(cx, &PointerEvent::new(PointerKind::Down, x, y)); t.pointer(cx, &PointerEvent::new(PointerKind::Up, x, y)) }; assert!(click(&mut t, &cx, 102.0, 80.0).is_empty()); let up = click(&mut t, &cx, 180.0, 80.0); let Action::Exec(cmd, p) = &up[0] else { panic!("expected path.create, got {up:?}"); }; assert_eq!(cmd, "path.create"); let x = p["anchors"][0]["p"][0].as_f64().unwrap(); assert!((x - 100.0).abs() < 1e-6, "anchor x {x}"); } /// A text frame with overset text (selected), an empty frame and a threaded pair x → y, on page 1. struct ThreadDoc { d: Document, l: CanvasLayout, s: Selection, c: Cache, /// The selected text frame. a: ItemId, /// An empty (unassigned) frame. e: ItemId, x: ItemId, y: ItemId, } fn thread_doc() -> ThreadDoc { let mut d = Document::new(&NewDocument { pages: 2, ..Default::default() }); let lid = d.default_layer(); let (a, _) = d .add_text_frame(SpreadRef::Doc(0), Rect::new(100.0, 100.0, 250.0, 160.0), lid, &"Words flow on. ".repeat(40), ParaFormat::default()) .unwrap(); let e = ItemId(d.alloc()); d.insert_item(SpreadRef::Doc(0), Item::new(e, lid, Shape::Rectangle, shapes::rectangle(Rect::new(100.0, 400.0, 250.0, 500.0))), None).unwrap(); let (x, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(300.0, 100.0, 450.0, 160.0), lid, "x", ParaFormat::default()).unwrap(); let (y, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(300.0, 400.0, 450.0, 500.0), lid, "", ParaFormat::default()).unwrap(); d.thread(x, y).unwrap(); let l = CanvasLayout::new(&d, false); ThreadDoc { s: Selection::items(vec![a]), c: Cache::new(), l, d, a, e, x, y } } impl ThreadDoc { fn cx(&self) -> ToolContext<'_> { ctx(&self.d, &self.s, &self.c, &self.l) } /// Canvas point of a spread-0 point. fn at(&self, x: f64, y: f64) -> Point { self.l.to_canvas(SpreadRef::Doc(0), Point::new(x, y)) } fn port(&self, id: ItemId, out: bool) -> Point { thread::port_center(&self.d, &self.l, id, out, 1.0).unwrap() } } fn click(t: &mut Box, cx: &ToolContext, p: Point, mods: Mods) -> Vec { let mut v = t.pointer(cx, &PointerEvent::new(PointerKind::Down, p.x, p.y).with_mods(mods)); v.extend(t.pointer(cx, &PointerEvent::new(PointerKind::Up, p.x, p.y).with_mods(mods))); v } fn thread_exec(p: serde_json::Value) -> Vec { vec![Action::Exec("text.thread".into(), p)] } #[test] fn out_port_loads_text_and_a_click_threads_an_empty_frame() { let td = thread_doc(); let cx = td.cx(); let mut t = create("selection"); let port = td.port(td.a, true); // The out port sits on the right edge, 12 px above the bottom-right corner. assert!((port - td.at(250.0, 148.0)).hypot() < 1e-9, "{port:?}"); // A press just off the port's centre still loads (any zoom: tolerance is in pixels). assert!(click(&mut t, &cx, port + designcraft_geom::Vec2::new(3.0, -3.0), Mods::default()).is_empty()); assert_eq!(t.cursor(&cx, td.at(500.0, 700.0), Mods::default()), Cursor::LoadedText); let over_e = td.at(175.0, 450.0); assert_eq!(t.cursor(&cx, over_e, Mods::default()), Cursor::ThreadLink); // A frame something already threads into can't join; neither can the loaded frame itself. assert_eq!(t.cursor(&cx, td.at(375.0, 450.0), Mods::default()), Cursor::NotAllowed); assert_eq!(t.cursor(&cx, td.at(175.0, 130.0), Mods::default()), Cursor::LoadedText); assert_eq!(click(&mut t, &cx, over_e, Mods::default()), thread_exec(serde_json::json!({"from": td.a.0, "to": td.e.0}))); // Unloaded again: the cursor and clicks are back to normal. assert_eq!(t.cursor(&cx, over_e, Mods::default()), Cursor::Arrow); } #[test] fn ports_hit_at_any_zoom() { let td = thread_doc(); let mut cx = td.cx(); cx.zoom = 4.0; let port = thread::port_center(&td.d, &td.l, td.a, false, 4.0).unwrap(); assert!((port - td.at(100.0, 100.0 + 14.5 / 4.0)).hypot() < 1e-9); assert_eq!(thread::port_at(&cx, port + designcraft_geom::Vec2::new(1.5, 1.5)), Some((td.a, false))); assert_eq!(thread::port_at(&cx, port + designcraft_geom::Vec2::new(4.0, 0.0)), None, "8 screen px away at 4×"); } #[test] fn loaded_click_on_empty_page_makes_a_column_frame() { let td = thread_doc(); let cx = td.cx(); let mut t = create("selection"); click(&mut t, &cx, td.port(td.a, true), Mods::default()); let col = td.d.spreads[0].pages[0].column_rects()[0]; let a = click(&mut t, &cx, td.at(200.0, 600.0), Mods::default()); assert_eq!(a, thread_exec(serde_json::json!({"from": td.a.0, "rect": [col.x0, 600.0, col.x1, col.y1], "spread": {"kind": "doc", "index": 0}}))); } #[test] fn loaded_drag_makes_a_frame_of_that_size_and_shift_autoflows() { let td = thread_doc(); let cx = td.cx(); let mut t = create("selection"); click(&mut t, &cx, td.port(td.a, true), Mods::default()); let (p0, p1) = (td.at(320.0, 560.0), td.at(470.0, 700.0)); let shift = Mods { shift: true, ..Default::default() }; assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, p0.x, p0.y).with_mods(shift)).is_empty()); assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, p1.x, p1.y).with_mods(shift)).is_empty()); assert_eq!(t.overlays(&cx), vec![Overlay::Marquee(Rect::from_points(p0, p1))]); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, p1.x, p1.y).with_mods(shift)); assert_eq!( a, thread_exec( serde_json::json!({"from": td.a.0, "rect": [320.0, 560.0, 470.0, 700.0], "spread": {"kind": "doc", "index": 0}, "autoflow": true}) ) ); assert_eq!(t.cursor(&cx, td.at(500.0, 700.0), Mods::default()), Cursor::Arrow, "autoflow unloads"); } #[test] fn loaded_drag_snaps_like_frame_drawing() { let td = thread_doc(); let cx = ToolContext { snap: SnapView::FACTORY, ..td.cx() }; let mut t = create("selection"); click(&mut t, &cx, td.port(td.a, true), Mods::default()); let col = td.d.spreads[0].pages[0].column_rects()[0]; // Press and release a point or two inside the column's edges: both sides snap to them. let (p0, p1) = (td.at(col.x0 + 1.5, 560.0), td.at(col.x1 - 2.0, 650.0)); t.pointer(&cx, &PointerEvent::new(PointerKind::Down, p0.x, p0.y)); t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, p1.x, p1.y)); let Some(Overlay::Marquee(m)) = t.overlays(&cx).first().cloned() else { panic!("no marquee") }; assert!((m.x0 - td.at(col.x0, 0.0).x).abs() < 1e-9 && (m.x1 - td.at(col.x1, 0.0).x).abs() < 1e-9, "{m:?}"); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, p1.x, p1.y)); let [Action::Exec(id, p)] = a.as_slice() else { panic!("{a:?}") }; assert_eq!(id, "text.thread"); let r: Vec = p["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect(); assert_eq!((r[0], r[2]), (col.x0, col.x1), "{p}"); } #[test] fn alt_click_stays_loaded() { let mut td = thread_doc(); let original = td.d.clone(); let mut t = create("selection"); let alt = Mods { alt: true, ..Default::default() }; let col = td.d.spreads[0].pages[0].column_rects()[0]; let empty = td.at(200.0, 600.0); let new_frame_from = |f: ItemId| thread_exec(serde_json::json!({"from": f.0, "rect": [col.x0, 600.0, col.x1, col.y1], "spread": {"kind": "doc", "index": 0}})); { let cx = td.cx(); click(&mut t, &cx, td.port(td.a, true), Mods::default()); assert_eq!(click(&mut t, &cx, td.at(175.0, 450.0), alt), thread_exec(serde_json::json!({"from": td.a.0, "to": td.e.0}))); assert_eq!(t.cursor(&cx, td.at(500.0, 700.0), Mods::default()), Cursor::LoadedText); // The thread failed (the document is unchanged): still loaded from a. assert_eq!(click(&mut t, &cx, empty, alt), new_frame_from(td.a)); } // That click's command ran: the next click threads after the frame it added. let (n, _) = td.d.add_text_frame(SpreadRef::Doc(0), Rect::new(col.x0, 600.0, col.x1, col.y1), td.d.default_layer(), "", ParaFormat::default()).unwrap(); td.d.thread(td.a, n).unwrap(); { let cx = td.cx(); assert_eq!(click(&mut t, &cx, td.at(175.0, 450.0), alt), thread_exec(serde_json::json!({"from": n.0, "to": td.e.0}))); } // Undone: back on a, not on a's old neighbour or the frame the undo removed. td.d = original; { let cx = td.cx(); assert_eq!(click(&mut t, &cx, empty, Mods::default()), new_frame_from(td.a)); } // From x, which threads into y: when threading e in between fails, x stays loaded, not y. let td = ThreadDoc { s: Selection::items(vec![td.x]), ..td }; let cx = td.cx(); click(&mut t, &cx, td.port(td.x, true), Mods::default()); assert_eq!(click(&mut t, &cx, td.at(175.0, 450.0), alt), thread_exec(serde_json::json!({"from": td.x.0, "to": td.e.0}))); assert_eq!(click(&mut t, &cx, empty, Mods::default()), new_frame_from(td.x)); } #[test] fn escape_unloads_and_keeps_the_selection() { let td = thread_doc(); let cx = td.cx(); let mut t = create("selection"); click(&mut t, &cx, td.port(td.a, true), Mods::default()); assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel]); assert_eq!(t.cursor(&cx, td.at(500.0, 700.0), Mods::default()), Cursor::Arrow); // An ordinary click on empty space deselects again. let p = td.at(500.0, 700.0); assert_eq!( t.pointer(&cx, &PointerEvent::new(PointerKind::Down, p.x, p.y)), vec![Action::Exec("selection.set".into(), serde_json::json!({"ids": []}))] ); } #[test] fn loaded_click_on_the_linked_frame_unthreads() { // x is selected; its out port links to y. let td = thread_doc(); let td = ThreadDoc { s: Selection::items(vec![td.x]), ..td }; let cx = td.cx(); let mut t = create("selection"); click(&mut t, &cx, td.port(td.x, true), Mods::default()); let over_y = td.at(375.0, 450.0); assert_eq!(t.cursor(&cx, over_y, Mods::default()), Cursor::Unthread); assert_eq!( click(&mut t, &cx, over_y, Mods::default()), vec![Action::Exec("text.unthread".into(), serde_json::json!({"frame": td.x.0, "side": "after"}))] ); // From y's in port, x is the frame before. let td = ThreadDoc { s: Selection::items(vec![td.y]), ..td }; let cx = td.cx(); click(&mut t, &cx, td.port(td.y, false), Mods::default()); assert_eq!( click(&mut t, &cx, td.at(375.0, 130.0), Mods::default()), vec![Action::Exec("text.unthread".into(), serde_json::json!({"frame": td.y.0, "side": "before"}))] ); } #[test] fn in_port_threads_in_front() { let td = thread_doc(); let cx = td.cx(); let mut t = create("selection"); click(&mut t, &cx, td.port(td.a, false), Mods::default()); assert_eq!(click(&mut t, &cx, td.at(175.0, 450.0), Mods::default()), thread_exec(serde_json::json!({"from": td.e.0, "to": td.a.0}))); } #[test] fn double_click_on_a_port_breaks_its_link() { let td = thread_doc(); let td = ThreadDoc { s: Selection::items(vec![td.x]), ..td }; let cx = td.cx(); // A double click sent as such (control channel), and the canvas's second press. let second = |p: Point| [PointerEvent::new(PointerKind::DoubleClick, p.x, p.y), PointerEvent::new(PointerKind::Down, p.x, p.y).with_clicks(2)]; let p = td.port(td.x, true); for ev in second(p) { let mut t = create("selection"); click(&mut t, &cx, p, Mods::default()); let a = t.pointer(&cx, &ev); assert_eq!(a, vec![Action::Exec("text.unthread".into(), serde_json::json!({"frame": td.x.0, "side": "after"}))], "{ev:?}"); assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, p.x, p.y)).is_empty()); assert_eq!(t.cursor(&cx, td.at(500.0, 700.0), Mods::default()), Cursor::Arrow); // The in port of the first frame has no link: nothing to break. let q = td.port(td.x, false); for ev in second(q) { assert!(t.pointer(&cx, &ev).is_empty(), "{ev:?}"); } } } #[test] fn clicks_in_a_row_edit_and_select_text() { let mut d = Document::new(&NewDocument::default()); let lid = d.default_layer(); let (fid, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(100.0, 100.0, 300.0, 200.0), lid, "Two words", ParaFormat::default()).unwrap(); let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false)); let cx = ctx(&d, &s, &c, &l); let off = l.xf(SpreadRef::Doc(0)).translation(); let (x, y) = (150.0 + off.x, 110.0 + off.y); let press = |n: u8| PointerEvent::new(PointerKind::Down, x, y).with_clicks(n); // Selection tool: the second press of a double click edits the text there. let mut t = create("selection"); let a = t.pointer(&cx, &press(1)); assert!(matches!(&a[0], Action::Exec(id, _) if id == "selection.set"), "{a:?}"); t.pointer(&cx, &PointerEvent::new(PointerKind::Up, x, y)); let a = t.pointer(&cx, &press(2)); assert_eq!(a.first(), Some(&Action::SwitchTool("type".into()))); assert!(matches!(&a[1], Action::Exec(id, p) if id == "text.placeCaret" && p["frame"] == fid.0), "{a:?}"); // Type tool: word, line, paragraph, then the story. let mut t = create("type"); let unit = |a: Vec| match a.as_slice() { [Action::Exec(id, p)] if id == "text.selectAt" => p["unit"].as_str().map(str::to_string), [Action::Exec(id, _)] if id == "text.placeCaret" => None, other => panic!("{other:?}"), }; assert_eq!(unit(t.pointer(&cx, &press(1))), None); for (n, want) in [(2, "word"), (3, "line"), (4, "paragraph"), (5, "story"), (9, "story")] { assert_eq!(unit(t.pointer(&cx, &press(n))).as_deref(), Some(want), "{n} clicks"); } // A double click sent as such (control channel) selects a word too. assert_eq!(unit(t.pointer(&cx, &PointerEvent::new(PointerKind::DoubleClick, x, y))).as_deref(), Some("word")); // Moving while pressed extends the selection by the unit the clicks chose (#981). let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x + 40.0, y)); assert!(matches!(&a[..], [Action::Exec(id, p)] if id == "text.extendTo" && p["unit"] == "word" && p["frame"] == fid.0), "{a:?}"); t.pointer(&cx, &PointerEvent::new(PointerKind::Up, x + 40.0, y)); t.pointer(&cx, &press(3)); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x + 40.0, y)); assert!(matches!(&a[..], [Action::Exec(id, p)] if id == "text.extendTo" && p["unit"] == "line"), "{a:?}"); // One click, then a drag: a character at a time, as before. t.pointer(&cx, &PointerEvent::new(PointerKind::Up, x + 40.0, y)); t.pointer(&cx, &press(1)); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x + 40.0, y)); assert!(matches!(&a[..], [Action::Exec(id, p)] if id == "text.extendTo" && p.get("unit").is_none()), "{a:?}"); } /// A 100×100 frame at (100, 100) holding a 200×100 graphic that overhangs it. fn graphic_frame(doc: &mut Document) -> ItemId { let id = ItemId(doc.alloc()); let mut item = Item::new(id, doc.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0))); item.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic { asset: designcraft_doc::AssetId(1), size: (200.0, 100.0), xf: designcraft_geom::Affine::translate((50.0, 100.0)), clip: None, auto_fit: Default::default(), fit_align: 4, crop: [0.0; 4], wrap: Default::default(), fill: Default::default(), }); doc.insert_item(SpreadRef::Doc(0), item, None).unwrap(); id } fn preview_params(a: &[Action]) -> serde_json::Value { match a.last() { Some(Action::Preview(cmd, p)) if cmd == "transform.move" => p.clone(), other => panic!("{other:?}"), } } /// With text selected, the Eyedropper on a text frame picks up its text formatting instead of /// copying the frame's fill and stroke onto the selection (InDesign's Eyedropper). #[test] fn eyedropper_with_text_selected_picks_up_text_formatting() { let mut d = Document::new(&NewDocument::default()); let lid = d.default_layer(); let (head, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(100.0, 100.0, 300.0, 140.0), lid, "Header", ParaFormat::default()).unwrap(); let (_, body) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(100.0, 200.0, 300.0, 300.0), lid, "Body", ParaFormat::default()).unwrap(); let s = Selection::text(designcraft_doc::TextSel { story: body, anchor: 0, focus: 4, frame: None, cell: None }); let (c, l) = (Cache::new(), CanvasLayout::new(&d, false)); let off = l.xf(SpreadRef::Doc(0)).translation(); let cx = ctx(&d, &s, &c, &l); let mut t = create("eyedropper"); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0 + off.x, 120.0 + off.y)); assert_eq!(a, vec![Action::Exec("type.pickUpFormat".into(), serde_json::json!({"frame": head.0, "point": [150.0, 120.0]}))]); } #[test] fn direct_selection_drags_content_inside_its_frame() { let mut d = Document::new(&NewDocument::default()); let f = graphic_frame(&mut d); let (c, l) = (Cache::new(), CanvasLayout::new(&d, false)); let none = Selection::default(); let mut t = create("directSelection"); let at = spread_pt(&l, 120.0, 130.0); let a = t.pointer(&ctx(&d, &none, &c, &l), &PointerEvent::new(PointerKind::Down, at.x, at.y)); assert_eq!(a, vec![Action::Exec("selection.set".into(), serde_json::json!({"ids": [f.0], "content": true}))]); let sel = Selection { content: true, ..Selection::items(vec![f]) }; let cx = ctx(&d, &sel, &c, &l); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, at.x + 15.0, at.y - 4.0)); assert_eq!(a.first(), Some(&Action::Begin("Move Content".into()))); let p = preview_params(&a); assert_eq!( (p["dx"].as_f64(), p["dy"].as_f64(), &p["content"], &p["ids"]), (Some(15.0), Some(-4.0), &serde_json::json!(true), &serde_json::json!([f.0])) ); assert_eq!(t.cursor(&cx, at, Mods::default()), Cursor::HandGrab); assert!(t.overlays(&cx).contains(&Overlay::ContentGhost(f))); // Shift keeps the drag on one axis. let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, at.x + 15.0, at.y - 4.0).with_mods(Mods { shift: true, ..Default::default() })); let p = preview_params(&a); assert_eq!((p["dx"].as_f64(), p["dy"].as_f64()), (Some(15.0), Some(0.0))); assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, at.x + 15.0, at.y)), vec![Action::Commit]); } #[test] fn selection_tool_moves_selected_content_and_the_frame_otherwise() { let mut d = Document::new(&NewDocument::default()); let f = graphic_frame(&mut d); let (c, l) = (Cache::new(), CanvasLayout::new(&d, false)); let frame = Selection::items(vec![f]); let content = Selection { content: true, ..Selection::items(vec![f]) }; let centre = spread_pt(&l, 150.0, 150.0); let mut t = create("selection"); // Hovering the grabber shows the hand; it is there on hover before any click, too. assert_eq!(t.cursor(&ctx(&d, &Selection::default(), &c, &l), centre, Mods::default()), Cursor::Hand); let g = select::grabbers(&ctx(&d, &Selection::default(), &c, &l), Some(centre)); assert!(matches!(g.as_slice(), [(id, p)] if *id == f && near(*p, centre)), "{g:?}"); // Press on the grabber: the content is selected; the drag moves it. let a = t.pointer(&ctx(&d, &frame, &c, &l), &PointerEvent::new(PointerKind::Down, centre.x, centre.y)); assert_eq!(a, vec![Action::Exec("selection.set".into(), serde_json::json!({"ids": [f.0], "content": true}))]); let a = t.pointer(&ctx(&d, &content, &c, &l), &PointerEvent::new(PointerKind::Drag, centre.x - 20.0, centre.y + 10.0)); let p = preview_params(&a); assert_eq!((p["dx"].as_f64(), p["dy"].as_f64(), &p["content"]), (Some(-20.0), Some(10.0), &serde_json::json!(true))); assert_eq!(t.pointer(&ctx(&d, &content, &c, &l), &PointerEvent::new(PointerKind::Up, centre.x - 20.0, centre.y + 10.0)), vec![Action::Commit]); // With the content selected (the grabber, or a double-click), the pointer is a hand over it and // a drag anywhere on it moves it, as in InDesign (Dansky's Exclude Overlap tutorial, 7:13). let edge = spread_pt(&l, 115.0, 185.0); assert_eq!(t.cursor(&ctx(&d, &content, &c, &l), edge, Mods::default()), Cursor::Hand); assert!(t.pointer(&ctx(&d, &content, &c, &l), &PointerEvent::new(PointerKind::Down, edge.x, edge.y)).is_empty()); let a = t.pointer(&ctx(&d, &content, &c, &l), &PointerEvent::new(PointerKind::Drag, edge.x + 30.0, edge.y)); assert_eq!(a[0], Action::Begin("Move Content".into())); let p = preview_params(&a); assert_eq!((p["dx"].as_f64(), &p["content"]), (Some(30.0), &serde_json::json!(true))); t.pointer(&ctx(&d, &content, &c, &l), &PointerEvent::new(PointerKind::Up, edge.x + 30.0, edge.y)); // Shift keeps it on one axis; the content stays selected. let shift = Mods { shift: true, ..Default::default() }; t.pointer(&ctx(&d, &content, &c, &l), &PointerEvent::new(PointerKind::Down, edge.x, edge.y).with_mods(shift)); let a = t.pointer(&ctx(&d, &content, &c, &l), &PointerEvent::new(PointerKind::Drag, edge.x + 30.0, edge.y + 4.0).with_mods(shift)); let p = preview_params(&a); assert_eq!((p["dx"].as_f64(), p["dy"].as_f64(), &p["content"]), (Some(30.0), Some(0.0), &serde_json::json!(true))); assert_eq!( t.pointer(&ctx(&d, &content, &c, &l), &PointerEvent::new(PointerKind::Up, edge.x + 30.0, edge.y).with_mods(shift)), vec![Action::Commit] ); // Off the content (the graphic overhangs the frame's sides only), a press selects as before. assert_eq!(t.cursor(&ctx(&d, &content, &c, &l), spread_pt(&l, 150.0, 250.0), Mods::default()), Cursor::Arrow); // With the frame selected, a drag away from the grabber moves the frame. t.pointer(&ctx(&d, &frame, &c, &l), &PointerEvent::new(PointerKind::Down, edge.x, edge.y)); let a = t.pointer(&ctx(&d, &frame, &c, &l), &PointerEvent::new(PointerKind::Drag, edge.x, edge.y + 30.0)); assert_eq!(a[0], Action::Begin("Move".into())); assert_eq!(preview_params(&a).get("content"), None); } #[test] fn double_click_toggles_frame_and_content() { let mut d = Document::new(&NewDocument::default()); let f = graphic_frame(&mut d); let (c, l) = (Cache::new(), CanvasLayout::new(&d, false)); let at = spread_pt(&l, 120.0, 180.0); let mut t = create("selection"); let frame = Selection::items(vec![f]); let content = Selection { content: true, ..Selection::items(vec![f]) }; let set = |content: bool| vec![Action::Exec("selection.set".into(), serde_json::json!({"ids": [f.0], "content": content}))]; // The canvas's presses in a row: the first selects the frame, the second its content. let a = t.pointer(&ctx(&d, &Selection::default(), &c, &l), &PointerEvent::new(PointerKind::Down, at.x, at.y)); assert_eq!(a, vec![Action::Exec("selection.set".into(), serde_json::json!({"ids": [f.0], "content": false}))]); t.pointer(&ctx(&d, &frame, &c, &l), &PointerEvent::new(PointerKind::Up, at.x, at.y)); assert_eq!(t.pointer(&ctx(&d, &frame, &c, &l), &PointerEvent::new(PointerKind::Down, at.x, at.y).with_clicks(2)), set(true)); assert!(t.pointer(&ctx(&d, &content, &c, &l), &PointerEvent::new(PointerKind::Up, at.x, at.y)).is_empty()); // With the content selected, the first press keeps it and the second selects the frame. assert!(t.pointer(&ctx(&d, &content, &c, &l), &PointerEvent::new(PointerKind::Down, at.x, at.y)).is_empty()); t.pointer(&ctx(&d, &content, &c, &l), &PointerEvent::new(PointerKind::Up, at.x, at.y)); assert_eq!(t.pointer(&ctx(&d, &content, &c, &l), &PointerEvent::new(PointerKind::Down, at.x, at.y).with_clicks(2)), set(false)); // A double click sent as such (control channel) toggles too. let dbl = PointerEvent::new(PointerKind::DoubleClick, at.x, at.y); assert_eq!(t.pointer(&ctx(&d, &frame, &c, &l), &dbl), set(true)); assert_eq!(t.pointer(&ctx(&d, &content, &c, &l), &dbl), set(false)); } /// A 200 × 150 pt rectangle frame, selected. fn live_corner_doc() -> (Document, ItemId) { let mut d = Document::new(&NewDocument::default()); let id = add_rect(&mut d, Rect::new(100.0, 100.0, 300.0, 250.0)); (d, id) } fn corner_preview(a: &[Action]) -> Vec<(String, f64)> { let p = match a { [Action::Preview(cmd, p)] | [Action::Exec(cmd, p)] if cmd == "object.cornerOptions" => p, other => panic!("{other:?}"), }; p["corners"].as_array().unwrap().iter().map(|c| (c["shape"].as_str().unwrap().to_string(), c["size"].as_f64().unwrap())).collect() } fn diamonds(o: &[Overlay]) -> Vec { o.iter().filter_map(|o| if let Overlay::LiveCornerDiamond(p) = o { Some(*p) } else { None }).collect() } #[test] fn live_corners_click_widget_then_drag_rounds_all_corners() { let (d, id) = live_corner_doc(); let s = Selection { items: vec![id], ..Default::default() }; let (c, l) = (Cache::new(), CanvasLayout::new(&d, false)); let cx = ctx(&d, &s, &c, &l); let at = |x, y| spread_pt(&l, x, y); let mut t = create("selection"); // The widget sits on the right edge, a little below the top-right corner. let w = at(300.0, 111.5); assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::LiveCornerWidget(p) if near(*p, w)))); assert!(diamonds(&t.overlays(&cx)).is_empty()); assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, w.x, w.y)), vec![Action::Handled]); assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, w.x, w.y)).is_empty()); // Without corner sizes the diamonds sit 12 px along the top and bottom edges. let want = [at(112.0, 100.0), at(288.0, 100.0), at(288.0, 250.0), at(112.0, 250.0)]; let got = diamonds(&t.overlays(&cx)); assert!(got.len() == 4 && got.iter().zip(want).all(|(g, w)| near(*g, w)), "{got:?}"); assert!(!t.overlays(&cx).iter().any(|o| matches!(o, Overlay::LiveCornerWidget(_)))); // Drag the top-left diamond 20 pt toward the centre. let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, want[0].x, want[0].y)); assert_eq!(a, vec![Action::Begin("Corner Options".into())]); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, want[0].x + 20.0, want[0].y + 3.0)); assert_eq!(corner_preview(&a), vec![("rounded".to_string(), 20.0); 4]); // The size stops at half the shorter side. let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, want[0].x + 500.0, want[0].y)); assert_eq!(corner_preview(&a), vec![("rounded".to_string(), 75.0); 4]); assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, want[0].x + 500.0, want[0].y)), vec![Action::Commit]); } #[test] fn live_corners_shift_drag_and_alt_click_change_one_corner() { let (mut d, id) = live_corner_doc(); d.item_mut(id).unwrap().corners = designcraft_geom::corners::CornerOptions::uniform(designcraft_geom::corners::CornerShape::Rounded, 30.0); let s = Selection { items: vec![id], ..Default::default() }; let (c, l) = (Cache::new(), CanvasLayout::new(&d, false)); let cx = ctx(&d, &s, &c, &l); let at = |x, y| spread_pt(&l, x, y); let mut t = create("selection"); let w = at(300.0, 111.5); t.pointer(&cx, &PointerEvent::new(PointerKind::Down, w.x, w.y)); t.pointer(&cx, &PointerEvent::new(PointerKind::Up, w.x, w.y)); // With 30 pt corners the diamonds sit 30 pt along the edges. let tr = at(270.0, 100.0); assert!(diamonds(&t.overlays(&cx)).iter().any(|p| near(*p, tr))); let shift = Mods { shift: true, ..Default::default() }; t.pointer(&cx, &PointerEvent::new(PointerKind::Down, tr.x, tr.y).with_mods(shift)); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, tr.x - 10.0, tr.y).with_mods(shift)); let r = |n: f64| ("rounded".to_string(), n); assert_eq!(corner_preview(&a), vec![r(30.0), r(40.0), r(30.0), r(30.0)]); t.pointer(&cx, &PointerEvent::new(PointerKind::Up, tr.x - 10.0, tr.y)); // Alt-click steps the bottom-right corner's shape; Shift+Alt steps every corner. let br = at(270.0, 250.0); let alt = Mods { alt: true, ..Default::default() }; let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, br.x, br.y).with_mods(alt)); assert_eq!(corner_preview(&a), vec![r(30.0), r(30.0), ("inverseRounded".to_string(), 30.0), r(30.0)]); assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, br.x - 30.0, br.y).with_mods(alt)).is_empty()); assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, br.x - 30.0, br.y)).is_empty()); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, br.x, br.y).with_mods(Mods { alt: true, shift: true, ..Default::default() })); assert_eq!(corner_preview(&a), vec![("inverseRounded".to_string(), 30.0); 4]); } #[test] fn live_corners_end_on_escape_empty_click_and_other_selection() { let (mut d, id) = live_corner_doc(); let other = add_rect(&mut d, Rect::new(400.0, 100.0, 500.0, 200.0)); let s = Selection { items: vec![id], ..Default::default() }; let (c, l) = (Cache::new(), CanvasLayout::new(&d, false)); let cx = ctx(&d, &s, &c, &l); let w = spread_pt(&l, 300.0, 111.5); let mut t = create("selection"); let enter = |t: &mut Box| { t.pointer(&cx, &PointerEvent::new(PointerKind::Down, w.x, w.y)); t.pointer(&cx, &PointerEvent::new(PointerKind::Up, w.x, w.y)); assert_eq!(diamonds(&t.overlays(&cx)).len(), 4); }; // Escape leaves corner editing and keeps the selection; the next Escape deselects. enter(&mut t); assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Handled]); assert!(diamonds(&t.overlays(&cx)).is_empty()); assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Exec("selection.set".into(), serde_json::json!({"ids": []}))]); // A click on empty canvas. enter(&mut t); let e = spread_pt(&l, 350.0, 400.0); t.pointer(&cx, &PointerEvent::new(PointerKind::Down, e.x, e.y)); t.pointer(&cx, &PointerEvent::new(PointerKind::Up, e.x, e.y)); assert!(diamonds(&t.overlays(&cx)).is_empty()); // Another selection. enter(&mut t); let s2 = Selection { items: vec![other], ..Default::default() }; let cx2 = ctx(&d, &s2, &c, &l); assert!(diamonds(&t.overlays(&cx2)).is_empty()); t.pointer(&cx2, &PointerEvent::new(PointerKind::Move, 0.0, 0.0)); assert!(diamonds(&t.overlays(&cx)).is_empty()); // A frame too small on screen has no widget. let mut far = ctx(&d, &s, &c, &l); far.zoom = 0.1; assert!(!t.overlays(&far).iter().any(|o| matches!(o, Overlay::LiveCornerWidget(_)))); } #[test] fn live_corner_diamonds_follow_a_rotated_frame() { let mut d = Document::new(&NewDocument::default()); let id = add_rotated(&mut d, Rect::new(100.0, 100.0, 300.0, 250.0), 30.0); let s = Selection { items: vec![id], ..Default::default() }; let (c, l) = (Cache::new(), CanvasLayout::new(&d, false)); let cx = ctx(&d, &s, &c, &l); let m = l.xf(SpreadRef::Doc(0)) * d.item(id).unwrap().xf; let mut t = create("selection"); // The widget: 11.5 pt from the top-right corner along the turned right edge. let w = m * Point::new(300.0, 111.5); t.pointer(&cx, &PointerEvent::new(PointerKind::Down, w.x, w.y)); t.pointer(&cx, &PointerEvent::new(PointerKind::Up, w.x, w.y)); let tl = m * Point::new(112.0, 100.0); assert!(diamonds(&t.overlays(&cx)).iter().any(|p| near(*p, tl)), "{:?}", t.overlays(&cx)); // Dragging along the turned top edge sets the size. t.pointer(&cx, &PointerEvent::new(PointerKind::Down, tl.x, tl.y)); let to = m * Point::new(137.0, 100.0); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, to.x, to.y)); let got = corner_preview(&a); assert!(got.iter().all(|(shape, size)| shape == "rounded" && (size - 25.0).abs() < 1e-6), "{got:?}"); } #[test] fn pen_cursor_shows_add_delete_and_close() { let mut d = Document::new(&NewDocument::default()); let lid = d.default_layer(); let (fid, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(100.0, 100.0, 200.0, 200.0), lid, "", ParaFormat::default()).unwrap(); let (c, l) = (Cache::new(), CanvasLayout::new(&d, false)); let selected = Selection::items(vec![fid]); let none = Selection::default(); let off = l.xf(SpreadRef::Doc(0)).translation(); let at = |x: f64, y: f64| Point::new(x + off.x, y + off.y); let mut t = create("pen"); let m = Mods::default(); let cx = ctx(&d, &selected, &c, &l); assert_eq!(t.cursor(&cx, at(200.0, 100.0), m), Cursor::PenDelete, "on a selected path's anchor"); assert_eq!(t.cursor(&cx, at(150.0, 101.0), m), Cursor::PenAdd, "on a selected path's outline"); assert_eq!(t.cursor(&cx, at(150.0, 150.0), m), Cursor::Pen, "inside, away from the outline"); let click = |t: &mut Box, cx: &ToolContext, p: Point| { let down = t.pointer(cx, &PointerEvent::new(PointerKind::Down, p.x, p.y)); let up = t.pointer(cx, &PointerEvent::new(PointerKind::Up, p.x, p.y)); (down, up) }; let (down, up) = click(&mut t, &cx, at(200.0, 100.0)); assert_eq!(down, vec![Action::Exec("path.deleteAnchor".into(), serde_json::json!({"id": fid.0, "subpath": 0, "anchor": 1}))]); assert!(up.is_empty() && !t.busy(), "deleting starts no path"); let (down, _) = click(&mut t, &cx, at(150.0, 101.0)); assert!(matches!(&down[..], [Action::Exec(cmd, p)] if cmd == "path.addAnchor" && p["id"] == fid.0), "{down:?}"); // Unselected paths are drawn over; the first anchor of the path being drawn closes it. let cx = ctx(&d, &none, &c, &l); assert_eq!(t.cursor(&cx, at(200.0, 100.0), m), Cursor::Pen, "unselected path"); click(&mut t, &cx, at(300.0, 300.0)); assert_eq!(t.cursor(&cx, at(300.0, 300.0), m), Cursor::Pen, "one anchor cannot close yet"); click(&mut t, &cx, at(400.0, 300.0)); assert_eq!(t.cursor(&cx, at(301.0, 301.0), m), Cursor::PenClose, "on the first anchor"); assert_eq!(t.cursor(&cx, at(350.0, 350.0), m), Cursor::Pen, "elsewhere while drawing"); } #[test] fn anchor_tools_show_pen_state_cursors() { let d = Document::new(&NewDocument::default()); let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false)); let cx = ctx(&d, &s, &c, &l); let p = Point::new(10.0, 10.0); let cursor = |id: &str| create(id).cursor(&cx, p, Mods::default()); assert_eq!(cursor("addAnchor"), Cursor::PenAdd); assert_eq!(cursor("deleteAnchor"), Cursor::PenDelete); assert_eq!(cursor("convertDirection"), Cursor::PenConvert); } /// A text frame holding a 3×3 table of 80 pt columns, optionally rotated about its centre. fn table_frame(degrees: f64) -> (Document, ItemId) { let mut d = Document::new(&NewDocument::default()); let layer = d.default_layer(); let (id, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(100.0, 100.0, 400.0, 400.0), layer, "", ParaFormat::default()).unwrap(); let tid = d.alloc(); d.story_mut(sid).unwrap().insert_table(0, designcraft_doc::Table::new(tid, 3, 3, 0, 0, 240.0)); d.item_mut(id).unwrap().xf = designcraft_geom::Affine::rotate_about(degrees.to_radians(), Point::new(250.0, 250.0)); (d, id) } #[test] fn type_tool_drags_a_table_column_border() { for degrees in [0.0, 90.0] { let (d, id) = table_frame(degrees); let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false)); let cx = ctx(&d, &s, &c, &l); let it = d.item(id).unwrap(); let sid = it.text_frame().unwrap().story; let cs = c.get(&d, sid, None); let cell = cs.frames[0].tables[0].cell(1, 0).unwrap(); let xf = cx.item_canvas_xf(id).unwrap() * d.text_xf(it); let on_border = xf * Point::new(cell.rect.x1 + 1.0, (cell.rect.y0 + cell.rect.y1) / 2.0); let dragged = xf * Point::new(cell.rect.x1 + 21.0, (cell.rect.y0 + cell.rect.y1) / 2.0); let mut t = create("type"); let expected = if degrees == 0.0 { Cursor::ResizeH } else { Cursor::ResizeV }; assert_eq!(t.cursor(&cx, on_border, Mods::default()), expected); // A click with a 1 px wobble on the border changes nothing. assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, on_border.x, on_border.y)).is_empty()); assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, on_border.x + 1.0, on_border.y)).is_empty()); assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, on_border.x + 1.0, on_border.y)).is_empty()); // A press on the border starts a resize, not a text selection. assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, on_border.x, on_border.y)).is_empty()); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, dragged.x, dragged.y)); assert_eq!(a[0], Action::Begin("Resize Column".into())); let Action::Preview(cmd, p) = &a[1] else { panic!("{a:?}") }; assert_eq!( (cmd.as_str(), &p["axis"], &p["index"], &p["frame"]), ("table.resizeBorder", &serde_json::json!("column"), &serde_json::json!(0), &serde_json::json!(id.0)) ); assert!((p["delta"].as_f64().unwrap() - 20.0).abs() < 1e-6, "{p}"); assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, dragged.x, dragged.y)), vec![Action::Commit]); // Esc cancels a drag in progress. t.pointer(&cx, &PointerEvent::new(PointerKind::Down, on_border.x, on_border.y)); t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, dragged.x, dragged.y)); assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel]); assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, dragged.x, dragged.y)).is_empty()); // Inside the cell the Type tool places the caret as usual. let inside = xf * Point::new(cell.rect.x0 + 20.0, (cell.rect.y0 + cell.rect.y1) / 2.0); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, inside.x, inside.y)); assert!(matches!(&a[..], [Action::Exec(cmd, _)] if cmd == "text.placeCaret"), "{a:?}"); } } #[test] fn eyedropper_samples_a_placed_image_and_matches_other_objects() { let mut d = Document::new(&NewDocument::default()); let lid = d.default_layer(); let photo = ItemId(d.alloc()); let mut it = Item::new(photo, lid, Shape::Rectangle, shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0))); it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic { asset: designcraft_doc::AssetId(1), size: (100.0, 100.0), xf: designcraft_geom::Affine::translate((0.0, 0.0)), clip: None, auto_fit: Default::default(), fit_align: 4, crop: [0.0; 4], wrap: Default::default(), fill: Default::default(), }); d.assets.insert( designcraft_doc::AssetId(1), std::sync::Arc::new(designcraft_doc::Asset { id: designcraft_doc::AssetId(1), name: "photo.png".into(), mime: "image/png".into(), link: None, data: std::sync::Arc::new(vec![]), pixels: Some((100, 100)), page: 0, pdf_crop: Default::default(), pdf_box: None, }), ); d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); // An unfilled text frame over part of the photo lets it show through. d.add_text_frame(SpreadRef::Doc(0), Rect::new(150.0, 150.0, 250.0, 250.0), lid, "x", ParaFormat::default()).unwrap(); let swatch = add_rect(&mut d, Rect::new(300.0, 100.0, 350.0, 150.0)); d.item_mut(swatch).unwrap().fill.swatch = "[Black]".into(); let target = add_rect(&mut d, Rect::new(400.0, 100.0, 450.0, 150.0)); // The photo's layer is locked: sampling it still works. d.layers.iter_mut().for_each(|l| l.locked = true); let s = Selection { items: vec![target], ..Default::default() }; let (c, l) = (Cache::new(), CanvasLayout::new(&d, false)); let off = l.xf(SpreadRef::Doc(0)).translation(); let cx = ctx(&d, &s, &c, &l); let mut t = create("eyedropper"); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 120.0 + off.x, 130.0 + off.y)); let sample = |x: f64, y: f64| Action::Exec("color.sample".into(), serde_json::json!({"at": [x, y], "spread": {"kind": "doc", "index": 0}})); assert_eq!(a, vec![sample(120.0, 130.0)]); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 180.0 + off.x, 180.0 + off.y)); assert_eq!(a, vec![sample(180.0, 180.0)], "through the text frame"); // With the toolbox's Stroke box in front, the colour goes to the stroke (a line has no fill). let mut cx_stroke = ctx(&d, &s, &c, &l); cx_stroke.stroke_in_front = true; let a = t.pointer(&cx_stroke, &PointerEvent::new(PointerKind::Down, 120.0 + off.x, 130.0 + off.y)); assert_eq!( a, vec![Action::Exec( "color.sample".into(), serde_json::json!({"at": [120.0, 130.0], "spread": {"kind": "doc", "index": 0}, "target": "stroke"}) )] ); // A filled object is matched as before (here on a locked layer: not reachable, so nothing). assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 320.0 + off.x, 120.0 + off.y)).is_empty()); let mut d2 = d.clone(); d2.layers.iter_mut().for_each(|l| l.locked = false); let cx = ctx(&d2, &s, &c, &l); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 320.0 + off.x, 120.0 + off.y)); assert_eq!(a, vec![Action::Exec("object.matchAttributes".into(), serde_json::json!({"from": swatch.0}))]); // An image the pointer can reach names its frame, whose attributes apply where no pixel shows. let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 120.0 + off.x, 130.0 + off.y)); let sampled = serde_json::json!({"at": [120.0, 130.0], "spread": {"kind": "doc", "index": 0}, "matchFrom": photo.0}); assert_eq!(a, vec![Action::Exec("color.sample".into(), sampled)]); // Vector graphics and missing assets must retain the ordinary attribute-matching path. for mime in ["application/pdf", "application/postscript", "image/svg+xml", "application/octet-stream"] { std::sync::Arc::make_mut(d2.assets.get_mut(&designcraft_doc::AssetId(1)).unwrap()).mime = mime.into(); let cx = ctx(&d2, &s, &c, &l); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 120.0 + off.x, 130.0 + off.y)); assert_eq!(a, vec![Action::Exec("object.matchAttributes".into(), serde_json::json!({"from": photo.0}))], "{mime}"); } d2.assets.clear(); let cx = ctx(&d2, &s, &c, &l); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 120.0 + off.x, 130.0 + off.y)); assert_eq!(a, vec![Action::Exec("object.matchAttributes".into(), serde_json::json!({"from": photo.0}))]); } #[test] fn double_clicking_a_handle_fits_the_frame_to_its_content_along_that_side() { let mut d = Document::new(&NewDocument::default()); let id = add_rect(&mut d, Rect::new(100.0, 100.0, 300.0, 200.0)); let s = Selection { items: vec![id], ..Default::default() }; let (c, l) = (Cache::new(), CanvasLayout::new(&d, false)); let off = l.xf(SpreadRef::Doc(0)).translation(); let cx = ctx(&d, &s, &c, &l); let mut t = create("selection"); for ((x, y), axis) in [((300.0, 200.0), "both"), ((200.0, 200.0), "height"), ((300.0, 150.0), "width")] { let a = t.pointer(&cx, &PointerEvent::new(PointerKind::DoubleClick, x + off.x, y + off.y)); assert_eq!(a, vec![Action::Exec("object.fit".into(), serde_json::json!({"mode": "fitFrameToContent", "axis": axis}))], "{axis}"); } } #[test] fn fitting_handle_axes_follow_spread_and_frame_rotation() { for spread_turn in 0..4 { for frame_turn in 0..4 { let mut d = Document::new(&NewDocument::default()); let id = add_rect(&mut d, Rect::new(100.0, 100.0, 300.0, 200.0)); d.item_mut(id).unwrap().xf = designcraft_geom::Affine::rotate(f64::from(frame_turn) * std::f64::consts::FRAC_PI_2); Arc::make_mut(&mut d.spreads[0]).pages[0].view_rotation = spread_turn; let s = Selection { items: vec![id], ..Default::default() }; let (c, l) = (Cache::new(), CanvasLayout::new(&d, false)); let cx = ctx(&d, &s, &c, &l); let b = cx.selection_bounds().unwrap(); let mut t = create("selection"); let swapped = (spread_turn + frame_turn) % 2 == 1; for (index, axis) in [(1, if swapped { "width" } else { "height" }), (3, if swapped { "height" } else { "width" }), (4, "both")] { let p = select::handles(b)[index]; let a = t.pointer(&cx, &PointerEvent::new(PointerKind::DoubleClick, p.x, p.y)); assert_eq!( a, vec![Action::Exec("object.fit".into(), serde_json::json!({"mode": "fitFrameToContent", "axis": axis}))], "spread={spread_turn}, frame={frame_turn}, handle={index}" ); } } } } #[test] fn pen_continues_an_open_path_from_either_end_point() { let mut d = Document::new(&NewDocument::default()); let lid = d.default_layer(); let id = ItemId(d.alloc()); let anchors = vec![ designcraft_geom::Anchor::corner(Point::new(100.0, 100.0)), designcraft_geom::Anchor { p: Point::new(200.0, 100.0), h_in: Point::new(170.0, 80.0), h_out: Point::new(230.0, 120.0), kind: designcraft_geom::AnchorKind::Smooth, }, ]; let mut it = Item::new(id, lid, Shape::Path, designcraft_geom::PathData::single(designcraft_geom::SubPath::new(anchors, false))); it.stroke = Stroke::default(); d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false)); let cx = ctx(&d, &s, &c, &l); let off = l.xf(SpreadRef::Doc(0)).translation(); let at = |x: f64, y: f64| (x + off.x, y + off.y); let ev = |k: PointerKind, (x, y): (f64, f64)| PointerEvent::new(k, x, y); let mut t = create("pen"); // The cursor says the end point continues the path; elsewhere it's the plain pen. assert_eq!(t.cursor(&cx, Point::new(at(200.0, 100.0).0, at(200.0, 100.0).1), Mods::default()), Cursor::PenContinue); assert_eq!(t.cursor(&cx, Point::new(at(150.0, 300.0).0, at(150.0, 300.0).1), Mods::default()), Cursor::Pen); // Clicking the last anchor adds nothing yet; the next click appends to that path. assert!(t.pointer(&cx, &ev(PointerKind::Down, at(200.0, 100.0))).is_empty()); assert!(t.pointer(&cx, &ev(PointerKind::Up, at(200.0, 100.0))).is_empty(), "a click on the end point changes nothing"); t.pointer(&cx, &ev(PointerKind::Down, at(300.0, 200.0))); let a = t.pointer(&cx, &ev(PointerKind::Up, at(300.0, 200.0))); let [Action::Exec(cmd, p)] = a.as_slice() else { panic!("{a:?}") }; assert_eq!((cmd.as_str(), p["id"].as_u64(), p["subpath"].as_u64()), ("path.appendAnchor", Some(id.0), Some(0))); assert_eq!((p["anchor"]["p"][0].as_f64(), p["anchor"]["p"][1].as_f64()), (Some(300.0), Some(200.0))); // Clicking the first anchor of the other end closes it. let a = t.pointer(&cx, &ev(PointerKind::Down, at(100.0, 100.0))); assert_eq!(a, vec![Action::Exec("path.close".into(), serde_json::json!({"id": id.0, "subpath": 0}))]); assert!(t.pointer(&cx, &ev(PointerKind::Up, at(100.0, 100.0))).is_empty()); // From the first anchor the path keeps its direction (arrowheads stay on their ends, and a // press then Esc leaves nothing to undo): a drag there sets the first anchor's direction // lines, and new anchors go before it. let mut t = create("pen"); assert!(t.pointer(&cx, &ev(PointerKind::Down, at(100.0, 100.0))).is_empty()); t.pointer(&cx, &ev(PointerKind::Drag, at(80.0, 140.0))); let a = t.pointer(&cx, &ev(PointerKind::Up, at(80.0, 140.0))); assert_eq!( a, vec![Action::Exec( "path.setAnchor".into(), serde_json::json!({"id": id.0, "subpath": 0, "anchor": "first", "in": [80.0, 140.0], "out": [120.0, 60.0]}) )] ); t.pointer(&cx, &ev(PointerKind::Down, at(20.0, 200.0))); t.pointer(&cx, &ev(PointerKind::Drag, at(40.0, 220.0))); let a = t.pointer(&cx, &ev(PointerKind::Up, at(40.0, 220.0))); let [Action::Exec(cmd, p)] = a.as_slice() else { panic!("{a:?}") }; assert_eq!((cmd.as_str(), p["at"].as_str(), p["subpath"].as_u64()), ("path.appendAnchor", Some("start"), Some(0))); assert_eq!(p["anchor"], serde_json::json!({"p": [20.0, 200.0], "in": [40.0, 220.0], "out": [0.0, 180.0]})); let mut t = create("pen"); assert!(t.pointer(&cx, &ev(PointerKind::Down, at(100.0, 100.0))).is_empty()); assert!(t.pointer(&cx, &ev(PointerKind::Up, at(100.0, 100.0))).is_empty()); assert!(t.key(&cx, ToolKey::Escape, Mods::default()).iter().all(|a| !matches!(a, Action::Exec(c, _) if c.starts_with("path.")))); // Closed paths and locked paths aren't continued. let mut d2 = d.clone(); d2.item_mut(id).unwrap().locked = true; let cx = ctx(&d2, &s, &c, &l); let t = create("pen"); assert_eq!(t.cursor(&cx, Point::new(at(200.0, 100.0).0, at(200.0, 100.0).1), Mods::default()), Cursor::Pen); } #[test] fn pen_over_a_selected_open_paths_end_point_continues_it() { let mut d = Document::new(&NewDocument::default()); let lid = d.default_layer(); let id = ItemId(d.alloc()); let anchors = vec![ designcraft_geom::Anchor::corner(Point::new(100.0, 100.0)), designcraft_geom::Anchor::corner(Point::new(200.0, 100.0)), designcraft_geom::Anchor::corner(Point::new(200.0, 200.0)), ]; let mut it = Item::new(id, lid, Shape::Path, designcraft_geom::PathData::single(designcraft_geom::SubPath::new(anchors, false))); it.stroke = Stroke::default(); d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); let (s, c, l) = (Selection::items(vec![id]), Cache::new(), CanvasLayout::new(&d, false)); let cx = ctx(&d, &s, &c, &l); let off = l.xf(SpreadRef::Doc(0)).translation(); let at = |x: f64, y: f64| Point::new(x + off.x, y + off.y); let mut t = create("pen"); // The end point wins over deleting the selected path's anchor; a middle anchor still deletes. assert_eq!(t.cursor(&cx, at(200.0, 200.0), Mods::default()), Cursor::PenContinue); assert_eq!(t.cursor(&cx, at(200.0, 100.0), Mods::default()), Cursor::PenDelete); let p = at(200.0, 200.0); assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, p.x, p.y)).is_empty(), "no anchor is deleted"); assert!(t.busy(), "the path is being continued"); } #[test] fn the_selection_tool_drags_a_path_type_bracket_along_its_path() { let mut d = Document::new(&NewDocument::default()); let lid = d.default_layer(); let id = ItemId(d.alloc()); let line = designcraft_geom::PathData::single(designcraft_geom::SubPath::new( vec![designcraft_geom::Anchor::corner(Point::new(100.0, 300.0)), designcraft_geom::Anchor::corner(Point::new(500.0, 300.0))], false, )); let mut it = Item::new(id, lid, Shape::GraphicLine, line); let sid = designcraft_doc::StoryId(d.alloc()); let mut tf = designcraft_doc::TextFrame { story: sid, options: Default::default() }; tf.options.path = Some(designcraft_doc::PathType { start: 40.0, end: Some(300.0), ..Default::default() }); it.content = designcraft_doc::Content::Text(tf); let mut st = designcraft_doc::Story::with_text(sid, "On the line", Default::default()); st.frames = vec![id]; d.stories.insert(sid, Arc::new(st)); d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); let (c, l) = (Cache::new(), CanvasLayout::new(&d, false)); let off = l.xf(SpreadRef::Doc(0)).translation(); let at = |x: f64, y: f64| Point::new(x + off.x, y + off.y); let ev = |k: PointerKind, p: Point| PointerEvent::new(k, p.x, p.y); // Not selected: no brackets. let none = Selection::default(); assert!(create("selection").overlays(&ctx(&d, &none, &c, &l)).is_empty()); let s = Selection { items: vec![id], ..Default::default() }; let cx = ctx(&d, &s, &c, &l); let mut t = create("selection"); // Both brackets drawn across the path at 140 and 400 (a line and a tick each). let o = t.overlays(&cx); let xs: Vec = o.iter().filter_map(|o| if let Overlay::Line { a, b, .. } = o { (a.x == b.x).then_some(a.x - off.x) } else { None }).collect(); assert_eq!(xs, [140.0, 400.0], "{o:?}"); assert_eq!(t.cursor(&cx, at(140.0, 300.0), Mods::default()), Cursor::PathBracket); assert_ne!(t.cursor(&cx, at(250.0, 300.0), Mods::default()), Cursor::PathBracket); // Drag the start bracket: its distance along the path follows the pointer, off the path too. assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, at(140.0, 301.0))), vec![Action::Begin("Move Bracket".into())]); let a = t.pointer(&cx, &ev(PointerKind::Drag, at(220.0, 330.0))); let [Action::Preview(cmd, p)] = a.as_slice() else { panic!("{a:?}") }; assert_eq!((cmd.as_str(), p["id"].as_u64()), ("type.pathOptions", Some(id.0))); assert!((p["start"].as_f64().unwrap() - 120.0).abs() < 1e-6, "{p}"); assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, at(220.0, 330.0))), vec![Action::Commit]); // The end bracket. t.pointer(&cx, &ev(PointerKind::Down, at(400.0, 300.0))); let a = t.pointer(&cx, &ev(PointerKind::Drag, at(450.0, 290.0))); let [Action::Preview(_, p)] = a.as_slice() else { panic!("{a:?}") }; assert!((p["end"].as_f64().unwrap() - 350.0).abs() < 1e-6, "{p}"); } #[test] fn direct_selection_drags_the_end_anchors_of_type_on_a_path() { let mut d = Document::new(&NewDocument::default()); let lid = d.default_layer(); let id = ItemId(d.alloc()); let line = designcraft_geom::PathData::single(designcraft_geom::SubPath::new( vec![designcraft_geom::Anchor::corner(Point::new(100.0, 300.0)), designcraft_geom::Anchor::corner(Point::new(500.0, 300.0))], false, )); let mut it = Item::new(id, lid, Shape::GraphicLine, line); let sid = designcraft_doc::StoryId(d.alloc()); let mut tf = designcraft_doc::TextFrame { story: sid, options: Default::default() }; // The default brackets: on the first and last anchors. tf.options.path = Some(designcraft_doc::PathType::default()); it.content = designcraft_doc::Content::Text(tf); let mut st = designcraft_doc::Story::with_text(sid, "On the line", Default::default()); st.frames = vec![id]; d.stories.insert(sid, Arc::new(st)); d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); let (c, l) = (Cache::new(), CanvasLayout::new(&d, false)); let off = l.xf(SpreadRef::Doc(0)).translation(); let s = Selection { items: vec![id], content: true, ..Default::default() }; let cx = ctx(&d, &s, &c, &l); for x in [100.0, 500.0] { let p = Point::new(x + off.x, 300.0 + off.y); let mut direct = create("directSelection"); assert_ne!(direct.cursor(&cx, p, Mods::default()), Cursor::PathBracket, "anchor at {x}"); assert_eq!(direct.pointer(&cx, &PointerEvent::new(PointerKind::Down, p.x, p.y)), vec![Action::Begin("Move Anchor".into())]); } } #[test] fn a_selected_graphics_own_handles_resize_it_and_leave_the_frame() { let mut d = Document::new(&NewDocument::default()); let f = graphic_frame(&mut d); let (c, l) = (Cache::new(), CanvasLayout::new(&d, false)); let content = Selection { content: true, ..Selection::items(vec![f]) }; let set_params = |a: &[Action]| match a.last() { Some(Action::Preview(cmd, p)) if cmd == "transform.set" => p.clone(), other => panic!("{other:?}"), }; for tool in ["selection", "directSelection"] { let mut t = create(tool); let cx = ctx(&d, &content, &c, &l); // The graphic's bottom-right corner, (250, 200), outside the frame's (200, 200). let corner = spread_pt(&l, 250.0, 200.0); assert_ne!(t.cursor(&cx, corner, Mods::default()), Cursor::Arrow, "{tool}"); assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, corner.x, corner.y)), vec![Action::Begin("Resize Content".into())], "{tool}"); let to = spread_pt(&l, 300.0, 250.0); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, to.x, to.y)); let p = set_params(&a); assert_eq!( (&p["ids"], &p["content"], &p["bounds"]), (&serde_json::json!([f.0]), &serde_json::json!(true), &serde_json::json!([50.0, 100.0, 300.0, 250.0])) ); assert!(t.overlays(&cx).contains(&Overlay::ContentGhost(f))); // Shift keeps the graphic's proportions (2:1). let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, to.x, to.y).with_mods(Mods { shift: true, ..Default::default() })); let b = set_params(&a)["bounds"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect::>(); assert!(((b[2] - b[0]) / (b[3] - b[1]) - 2.0).abs() < 1e-6, "{b:?}"); assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, to.x, to.y)), vec![Action::Commit]); } // The frame's own handles don't resize the frame while its graphic is selected. let mut t = create("selection"); let frame_corner = spread_pt(&l, 200.0, 200.0); let a = t.pointer(&ctx(&d, &content, &c, &l), &PointerEvent::new(PointerKind::Down, frame_corner.x, frame_corner.y)); assert!(!a.contains(&Action::Begin("Resize".into())), "{a:?}"); } #[test] fn eyedropper_ignores_inactive_states_of_a_multi_state_object() { use designcraft_doc::{Asset, AssetId, Content, Graphic}; let mut d = Document::new(&NewDocument::default()); let lid = d.default_layer(); d.assets.insert( AssetId(1), std::sync::Arc::new(Asset { id: AssetId(1), name: "photo.png".into(), mime: "image/png".into(), link: None, data: std::sync::Arc::new(vec![]), pixels: Some((100, 100)), ..Default::default() }), ); let area = Rect::new(100.0, 100.0, 200.0, 200.0); let raster = { let mut it = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(area)); it.content = Content::Graphic(Graphic { asset: AssetId(1), size: (100.0, 100.0), xf: designcraft_geom::Affine::translate((0.0, 0.0)), clip: None, auto_fit: Default::default(), fit_align: 4, crop: [0.0; 4], wrap: Default::default(), fill: Default::default(), }); it }; let mut vector = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(area)); vector.fill.swatch = "[Black]".into(); let target = add_rect(&mut d, Rect::new(400.0, 100.0, 450.0, 150.0)); let s = Selection { items: vec![target], ..Default::default() }; let samples_image = |a: &[Action]| a.iter().any(|a| matches!(a, Action::Exec(c, _) if c == "color.sample")); // Children [vector, raster] with the raster on top: it samples only while it is the active state. for (active, samples) in [(1, true), (0, false)] { let mut group = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(area)); group.content = Content::Group { items: vec![std::sync::Arc::new(vector.clone()), std::sync::Arc::new(raster.clone())] }; group.states = vec!["a".into(), "b".into()]; group.active_state = active; let mut d2 = d.clone(); d2.insert_item(SpreadRef::Doc(0), group, None).unwrap(); let (c, l) = (Cache::new(), CanvasLayout::new(&d2, false)); let off = l.xf(SpreadRef::Doc(0)).translation(); let cx = ctx(&d2, &s, &c, &l); let a = create("eyedropper").pointer(&cx, &PointerEvent::new(PointerKind::Down, 120.0 + off.x, 130.0 + off.y)); assert_eq!(samples_image(&a), samples, "active state {active}: {a:?}"); } } #[test] fn loaded_text_cursor_threads_into_a_frame_inside_a_group() { // `hit` stops at the group, so the loaded cursor showed NotAllowed over a text frame inside // one and a click did nothing. InDesign threads into it (gaps 27). let mut td = thread_doc(); let lid = td.d.default_layer(); let rect = Rect::new(600.0, 100.0, 700.0, 200.0); // The frame, then the same frame moved inside a group. Both transforms are the identity, so // the child's coordinates stay the spread's. let (inner, _) = td.d.add_text_frame(SpreadRef::Doc(0), rect, lid, "", ParaFormat::default()).unwrap(); let frame_item = td.d.item(inner).cloned().unwrap(); td.d.remove_item(inner).unwrap(); let gid = ItemId(td.d.alloc()); let mut group = Item::new(gid, lid, Shape::Group, shapes::rectangle(rect)); group.content = designcraft_doc::item::Content::Group { items: vec![std::sync::Arc::new(frame_item)] }; td.d.insert_item(SpreadRef::Doc(0), group, None).unwrap(); td.l = CanvasLayout::new(&td.d, false); let mut t = create("selection"); let cx = td.cx(); click(&mut t, &cx, td.port(td.a, true), Mods::default()); let p = td.at(650.0, 150.0); // Selection still reaches the group, which is what a plain click there should pick. assert_eq!(cx.hit(p).map(|(_, i)| i), Some(gid)); // The loaded text cursor reaches the frame inside it. assert_eq!(cx.hit_text_frame(p).map(|(_, i)| i), Some(inner), "the frame in the group"); assert_eq!(t.cursor(&cx, p, Mods::default()), Cursor::ThreadLink); // And the click threads to that frame, not to the group. assert_eq!(click(&mut t, &cx, p, Mods::default()), thread_exec(serde_json::json!({"from": td.a.0, "to": inner.0}))); // A group with nothing threadable in it still says no. let mut td2 = thread_doc(); let r2 = Rect::new(600.0, 300.0, 700.0, 400.0); let plain = ItemId(td2.d.alloc()); let child = Item::new(plain, lid, Shape::Rectangle, shapes::rectangle(r2)); let g2 = ItemId(td2.d.alloc()); let mut group2 = Item::new(g2, lid, Shape::Group, shapes::rectangle(r2)); group2.content = designcraft_doc::item::Content::Group { items: vec![std::sync::Arc::new(child)] }; td2.d.insert_item(SpreadRef::Doc(0), group2, None).unwrap(); td2.l = CanvasLayout::new(&td2.d, false); let cx2 = td2.cx(); let mut t2 = create("selection"); click(&mut t2, &cx2, td2.port(td2.a, true), Mods::default()); let q = td2.at(650.0, 350.0); assert_eq!(cx2.hit_text_frame(q).map(|(_, i)| i), None, "no text frame in that group"); assert_eq!(t2.cursor(&cx2, q, Mods::default()), Cursor::NotAllowed); }