mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-14 22:35:26 +00:00
* Fix Links panel stalls on network paths * Fix Links review findings * Fix Links worker gating and wasm build A link check blocked on another document's unreachable share no longer holds back the document now shown: after 200 ms a fresh worker takes over (at most four alive at once) and the stale result is dropped. relinked_asset used the native-only fingerprint_from, breaking the wasm build; it now uses fingerprint_of. The EPS relink and IDML link reach tests build Windows paths correctly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Fix Links worker repaint and graphic read retry Switching documents before the 200 ms takeover now schedules a repaint for it: a blocked worker never asks for one, so without other input the panel kept waiting. A failed graphic read is no longer cached as missing, so the next check reads again. The legacy EPS fixture clears its source fingerprint, as documents from before fingerprints have none, so the test no longer depends on running within two seconds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Keep touched links OK and poll Links within seconds Only a new size now marks a graphic Modified from its fingerprint. A file with the same size but a new time (saved again, copied, restored or checked out) is compared byte for byte, once per new time, and stays OK when unchanged. External changes are polled every two seconds instead of every five minutes. The next check waits at least as long as the last one took (up to five minutes), so a slow share stays idle half the time. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Wake Links when a worker slot frees; translate check strings A worker thread asks for a repaint when it ends, so a document waiting at the four-worker limit gets a worker as soon as a superseded one returns from its blocked call, without other input. Rows from a check keep a weak reference to their document. The key names it by address, which a later document can't reuse while the allocation is kept. The Links panel's new strings ("Checking", "Checking links…", "Link status is unavailable.") use the UI's ellipsis and are translated in every interface language. The comments on Session::text_link_checks and on the byte comparison in links::status describe what they do now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Links: rows from the document, statuses from a check that can stop The panel lists links from the document on the UI thread (no file is looked at) and fills in the statuses a worker check last found, kept per document. An edit shows at once, and a check of an earlier edit still supplies statuses instead of being discarded. - A check of another document is told to stop and ends before its next file; only one blocked in a file system call is replaced, so blocked workers no longer hold their slots for a whole document. - Checks are timed on the worker, and what they remember passes to the worker that replaces them. - A fingerprint is kept only for a file whose time was settled when it was read, as for linked text. A file found unchanged by its bytes gets its current size and time stored with the document (remember_link_files), without marking a saved document as changed. - Asset::set_link keeps link and fingerprint together; copies made by Package and Copy Links To carry none. - The Session holds one LinkListChecks. Update Link lists files only when a linked story was edited in the layout. - The unused "Checking links..." caption is removed. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2419 lines
127 KiB
Rust
2419 lines
127 KiB
Rust
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,
|
||
loaded: None,
|
||
}
|
||
}
|
||
|
||
#[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 {
|
||
layer_options: None,
|
||
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]);
|
||
}
|
||
|
||
/// While a frame is drawn, its size shows in the document's units, as InDesign's readout does
|
||
/// ("W: 6.08 cm" over "H: 6.08 cm" in Zero2Graphic's page layout tutorial, 0:43).
|
||
#[test]
|
||
fn frame_tool_readout_shows_the_size_in_the_document_units() {
|
||
let mut d = Document::new(&NewDocument::default());
|
||
d.settings.horizontal_units = Unit::Centimeters;
|
||
d.settings.vertical_units = Unit::Centimeters;
|
||
let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false));
|
||
let cx = ctx(&d, &s, &c, &l);
|
||
let mut t = create("rectangleFrame");
|
||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0));
|
||
// 172.35 pt square: 6.08 cm.
|
||
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 182.35, 182.35));
|
||
let texts: Vec<String> = t
|
||
.overlays(&cx)
|
||
.into_iter()
|
||
.filter_map(|o| match o {
|
||
Overlay::Measure { text, .. } => Some(text),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
assert_eq!(texts, vec!["W: 6.08 cm\nH: 6.08 cm".to_string()]);
|
||
}
|
||
|
||
/// The Line tool's readout measures the line as drawn: Shift makes it level, so it has no
|
||
/// height, wherever the pointer is.
|
||
#[test]
|
||
fn line_tool_readout_measures_the_line_as_drawn() {
|
||
let mut d = Document::new(&NewDocument::default());
|
||
d.settings.horizontal_units = Unit::Centimeters;
|
||
d.settings.vertical_units = Unit::Centimeters;
|
||
let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false));
|
||
let cx = ctx(&d, &s, &c, &l);
|
||
let mut t = create("line");
|
||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0));
|
||
let mut drag = PointerEvent::new(PointerKind::Drag, 182.35, 15.0);
|
||
drag.mods.shift = true;
|
||
t.pointer(&cx, &drag);
|
||
let texts: Vec<String> = t
|
||
.overlays(&cx)
|
||
.into_iter()
|
||
.filter_map(|o| match o {
|
||
Overlay::Measure { text, .. } => Some(text),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
assert_eq!(texts.len(), 1, "{texts:?}");
|
||
assert_eq!(texts[0], "W: 6.08 cm\nH: 0 cm", "level, as drawn, not 5 pt down to the pointer");
|
||
}
|
||
|
||
#[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]}))]);
|
||
}
|
||
|
||
/// A parent page that overrode an item of its base and deleted the override hides it on the pages
|
||
/// that use it, so a click there finds nothing to override: the hit list is what the renderer
|
||
/// draws, and it looked only at the page's own overrides (#1469).
|
||
#[test]
|
||
fn an_item_a_parent_overrode_and_deleted_is_not_hit() {
|
||
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 hit = |d: &Document| {
|
||
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(150.0 + off.x, 150.0 + off.y))
|
||
};
|
||
assert_eq!(hit(&d).map(|(_, id)| id), Some(a_item), "A's item shows through B and is hit");
|
||
Arc::make_mut(&mut d.parents[1]).pages[0].overridden.push(a_item);
|
||
assert_eq!(hit(&d), None, "removed on B: nothing there to hit");
|
||
}
|
||
|
||
/// 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);
|
||
}
|
||
|
||
/// While editing parent pages, a click doesn't pick items on a hidden or locked layer (#2221).
|
||
#[test]
|
||
fn parent_mode_hit_skips_hidden_and_locked_layers() {
|
||
let mut d = Document::new(&NewDocument { pages: 1, facing_pages: false, ..Default::default() });
|
||
let lid = d.default_layer();
|
||
let (id, _) = d.add_text_frame(SpreadRef::Parent(0), Rect::new(100.0, 100.0, 200.0, 200.0), lid, "a", ParaFormat::default()).unwrap();
|
||
let hit = |d: &Document| {
|
||
let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(d, true));
|
||
let p = l.to_canvas(SpreadRef::Parent(0), Point::new(150.0, 150.0));
|
||
ctx(d, &s, &c, &l).hit(p)
|
||
};
|
||
assert_eq!(hit(&d), Some((SpreadRef::Parent(0), id)));
|
||
d.layer_mut(lid).unwrap().visible = false;
|
||
assert_eq!(hit(&d), None, "hidden layer");
|
||
d.layer_mut(lid).unwrap().visible = true;
|
||
d.layer_mut(lid).unwrap().locked = true;
|
||
assert_eq!(hit(&d), None, "locked layer");
|
||
}
|
||
|
||
#[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 gradient_tool_drag_follows_a_rotated_spread() {
|
||
let mut d = Document::new(&NewDocument::default());
|
||
let lid = d.default_layer();
|
||
d.add_text_frame(SpreadRef::Doc(0), Rect::new(100.0, 100.0, 300.0, 200.0), lid, "", ParaFormat::default()).unwrap();
|
||
Arc::make_mut(&mut d.spreads[0]).pages[0].view_rotation = 1;
|
||
let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false));
|
||
let cx = ctx(&d, &s, &c, &l);
|
||
let start = l.to_canvas(SpreadRef::Doc(0), Point::new(150.0, 150.0));
|
||
let mut t = create("gradientSwatch");
|
||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, start.x, start.y));
|
||
for shift in [false, true] {
|
||
let ev = PointerEvent::new(PointerKind::Drag, start.x + 100.0, start.y).with_mods(Mods { shift, ..Default::default() });
|
||
match &t.pointer(&cx, &ev)[0] {
|
||
Action::Preview(_, p) => {
|
||
let to: Vec<f64> = p["to"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
|
||
let want = l.delta_to_spread(SpreadRef::Doc(0), Vec2::new(100.0, 0.0));
|
||
assert!((to[0] - (150.0 + want.x)).abs() < 1e-6 && (to[1] - (150.0 + want.y)).abs() < 1e-6, "{to:?} want {want:?}");
|
||
assert!((to[0] - 150.0).abs() > 1e-6 || (to[1] - 150.0).abs() > 1e-6);
|
||
assert!((to[0] - 150.0).abs() < 1e-6, "{to:?}");
|
||
}
|
||
other => panic!("{other:?}"),
|
||
}
|
||
}
|
||
}
|
||
|
||
#[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<Action>| 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:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn place_gun_click_places_into_frames_but_not_onto_lines() {
|
||
let mut d = Document::new(&NewDocument::default());
|
||
let frame = add_rect(&mut d, Rect::new(100.0, 100.0, 200.0, 200.0));
|
||
let lid = d.default_layer();
|
||
let line = ItemId(d.alloc());
|
||
let mut it = Item::new(line, lid, Shape::GraphicLine, shapes::line(Point::new(100.0, 300.0), Point::new(300.0, 300.0)));
|
||
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 click = |t: &mut Box<dyn Tool>, x: f64, y: f64| {
|
||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, x + off.x, y + off.y));
|
||
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, x + off.x, y + off.y))
|
||
};
|
||
let mut t = create("placeGun");
|
||
assert_eq!(click(&mut t, 150.0, 150.0), vec![Action::Exec("place.drop".into(), serde_json::json!({"frame": frame.0}))]);
|
||
let a = click(&mut t, 200.0, 300.0);
|
||
assert!(matches!(&a[..], [Action::Exec(cmd, p)] if cmd == "place.drop" && p.get("frame").is_none()), "{a:?}");
|
||
}
|
||
|
||
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::<Vec<_>>()
|
||
.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<dyn Tool>, 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}");
|
||
}
|
||
|
||
/// #1438: dragging a smooth point shows the segment it shapes under the pointer (from the
|
||
/// previous anchor's out handle to the new anchor's in handle), and after a smooth point the
|
||
/// rubber band to the pointer curves out of its direction line; a corner keeps the straight one.
|
||
#[test]
|
||
fn pen_previews_the_curve_while_dragging_and_after_a_smooth_point() {
|
||
use designcraft_geom::{BezPath, PathEl};
|
||
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 (sr, _) = cx.layout.spread_at(Point::new(100.0, 100.0)).expect("the page is under the pointer");
|
||
let xf = cx.layout.xf(sr);
|
||
let sp = |x: f64, y: f64| cx.layout.to_spread(sr, Point::new(x, y));
|
||
let near = |a: Point, b: Point| (a - b).hypot() < 1e-6;
|
||
let paths = |t: &dyn Tool, dashed: bool| -> Vec<BezPath> {
|
||
t.overlays(&cx)
|
||
.into_iter()
|
||
.filter_map(|o| match o {
|
||
Overlay::Path { path, dashed: d, .. } if d == dashed => Some(path),
|
||
_ => None,
|
||
})
|
||
.collect()
|
||
};
|
||
let mut t = create("pen");
|
||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0));
|
||
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 100.0, 100.0));
|
||
// A corner's rubber band is a straight line: no curve.
|
||
t.pointer(&cx, &PointerEvent::new(PointerKind::Move, 150.0, 120.0));
|
||
assert!(paths(&*t, true).is_empty() && paths(&*t, false).is_empty(), "a corner's rubber band is a line");
|
||
// Dragging the second anchor out: the segment from the first anchor is previewed live.
|
||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 200.0, 100.0));
|
||
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 230.0, 130.0));
|
||
let [p] = &paths(&*t, false)[..] else { panic!("one solid curve preview: {:?}", t.overlays(&cx)) };
|
||
let in_handle = sp(200.0, 100.0) - (sp(230.0, 130.0) - sp(200.0, 100.0));
|
||
match p.elements() {
|
||
[PathEl::MoveTo(a), PathEl::CurveTo(c1, c2, b)] => {
|
||
assert!(near(*a, xf * sp(100.0, 100.0)) && near(*c1, xf * sp(100.0, 100.0)), "from the corner, whose out handle is itself: {a:?} {c1:?}");
|
||
assert!(near(*c2, xf * in_handle) && near(*b, xf * sp(200.0, 100.0)), "to the dragged anchor by its in handle: {c2:?} {b:?}");
|
||
}
|
||
other => panic!("{other:?}"),
|
||
}
|
||
// Let go: the anchor is committed and the segment preview goes; the rubber band now curves
|
||
// out of the smooth point's direction line towards the pointer.
|
||
let up = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 230.0, 130.0));
|
||
assert!(matches!(&up[..], [Action::Exec(cmd, _)] if cmd == "path.create"), "{up:?}");
|
||
assert!(paths(&*t, false).is_empty(), "no segment preview once let go");
|
||
t.pointer(&cx, &PointerEvent::new(PointerKind::Move, 300.0, 100.0));
|
||
let [band] = &paths(&*t, true)[..] else { panic!("a curved rubber band: {:?}", t.overlays(&cx)) };
|
||
match band.elements() {
|
||
[PathEl::MoveTo(a), PathEl::CurveTo(c1, c2, b)] => {
|
||
assert!(near(*a, xf * sp(200.0, 100.0)) && near(*c1, xf * sp(230.0, 130.0)), "{a:?} {c1:?}");
|
||
assert!(near(*c2, Point::new(300.0, 100.0)) && near(*b, Point::new(300.0, 100.0)), "{c2:?} {b:?}");
|
||
}
|
||
other => panic!("{other:?}"),
|
||
}
|
||
}
|
||
|
||
/// 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<dyn Tool>, cx: &ToolContext, p: Point, mods: Mods) -> Vec<Action> {
|
||
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<Action> {
|
||
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<f64> = 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:?}");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// With the Type tool, a drag that starts in an empty rectangle draws a new text frame over it
|
||
/// (the rectangle stays as it is), as InDesign's does; a click still turns the rectangle into a
|
||
/// text frame with the caret in it.
|
||
#[test]
|
||
fn type_tool_drag_over_an_empty_frame_draws_a_new_text_frame() {
|
||
let mut d = Document::new(&NewDocument::default());
|
||
let id = add_rect(&mut d, Rect::new(100.0, 100.0, 300.0, 300.0));
|
||
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 mut t = create("type");
|
||
let (x, y) = at(120.0, 120.0);
|
||
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, x, y)).is_empty());
|
||
let (x1, y1) = at(280.0, 200.0);
|
||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x1, y1));
|
||
assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(cmd, p)] if cmd == "frame.create" && p["content"] == "text"), "{a:?}");
|
||
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, x1, y1)), vec![Action::Commit]);
|
||
// A click: the caret goes into the rectangle, which becomes a text frame.
|
||
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, x, y)).is_empty());
|
||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, x, y));
|
||
assert!(matches!(&a[..], [Action::Exec(cmd, p)] if cmd == "text.placeCaret" && p["frame"] == id.0), "{a:?}");
|
||
// A release away from the press with no drag in between isn't a click: no caret, no conversion.
|
||
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, x, y)).is_empty());
|
||
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, x1, y1)).is_empty());
|
||
}
|
||
|
||
#[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<Action>| 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 {
|
||
layer_options: None,
|
||
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<Point> {
|
||
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<dyn Tool>| {
|
||
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<dyn Tool>, 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)
|
||
}
|
||
|
||
/// In a table cell Shift+Tab goes back a cell and Esc toggles cell selection, as InDesign; out of
|
||
/// a table Tab types a tab and Esc leaves the text.
|
||
#[test]
|
||
fn type_tool_keys_in_a_table_cell() {
|
||
let (d, id) = table_frame(0.0);
|
||
let sid = d.item(id).unwrap().text_frame().unwrap().story;
|
||
let table = *d.story(sid).unwrap().tables.keys().next().unwrap();
|
||
let (c, l) = (Cache::new(), CanvasLayout::new(&d, false));
|
||
let cell = designcraft_doc::CellAddr { table, row: 1, col: 1 };
|
||
let s = Selection::text(designcraft_doc::TextSel { cell: Some(cell), ..designcraft_doc::TextSel::caret(sid, 0) });
|
||
let cx = ctx(&d, &s, &c, &l);
|
||
let mut t = create("type");
|
||
let shift = Mods { shift: true, ..Default::default() };
|
||
let exec = |c: &str, p: serde_json::Value| vec![Action::Exec(c.into(), p)];
|
||
assert_eq!(t.key(&cx, ToolKey::Tab, shift), exec("table.prevCell", serde_json::json!({})));
|
||
assert_eq!(t.key(&cx, ToolKey::Tab, Mods::default()), exec("text.insert", serde_json::json!({"text": "\t"})));
|
||
assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), exec("table.toggleSelection", serde_json::json!({})));
|
||
// Out of a table, and in footnote text (addressed as a cell too).
|
||
for cell in [None, Some(designcraft_doc::CellAddr::footnote(1))] {
|
||
let s = Selection::text(designcraft_doc::TextSel { cell, ..designcraft_doc::TextSel::caret(sid, 0) });
|
||
let cx = ctx(&d, &s, &c, &l);
|
||
assert_eq!(t.key(&cx, ToolKey::Tab, shift), exec("text.insert", serde_json::json!({"text": "\t"})), "{cell:?}");
|
||
assert!(matches!(t.key(&cx, ToolKey::Escape, Mods::default()).first(), Some(Action::SwitchTool(_))), "{cell:?}");
|
||
}
|
||
}
|
||
|
||
#[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 {
|
||
layer_options: None,
|
||
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,
|
||
link_fingerprint: 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");
|
||
}
|
||
|
||
/// Continuing a path from an end point and dragging its direction lines previews the segment the
|
||
/// drag reshapes: from the anchor joined to that end point, not from the path's other end, from
|
||
/// either end (#1438).
|
||
#[test]
|
||
fn pen_previews_the_segment_joined_to_a_continued_end_point() {
|
||
use designcraft_geom::{BezPath, PathEl};
|
||
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 xf = l.xf(SpreadRef::Doc(0));
|
||
let near = |a: Point, b: Point| (a - b).hypot() < 1e-6;
|
||
let solid = |t: &dyn Tool| -> Vec<BezPath> {
|
||
t.overlays(&cx)
|
||
.into_iter()
|
||
.filter_map(|o| match o {
|
||
Overlay::Path { path, dashed: false, .. } => Some(path),
|
||
_ => None,
|
||
})
|
||
.collect()
|
||
};
|
||
for (end, joined, far) in [((200.0, 200.0), (200.0, 100.0), (100.0, 100.0)), ((100.0, 100.0), (200.0, 100.0), (200.0, 200.0))] {
|
||
let mut t = create("pen");
|
||
let p = xf * Point::new(end.0, end.1);
|
||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, p.x, p.y));
|
||
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, p.x + 30.0, p.y + 30.0));
|
||
let [path] = &solid(&*t)[..] else { panic!("one segment preview from {end:?}: {:?}", t.overlays(&cx)) };
|
||
match path.elements() {
|
||
[PathEl::MoveTo(a), PathEl::CurveTo(_, _, b)] => {
|
||
assert!(near(*a, xf * Point::new(joined.0, joined.1)), "from the anchor joined to {end:?}, not the other end {far:?}: {a:?}");
|
||
assert!(near(*b, p), "to the end point being reshaped: {b:?}");
|
||
}
|
||
other => panic!("{other:?}"),
|
||
}
|
||
}
|
||
}
|
||
|
||
#[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<f64> = 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}");
|
||
// The press selects that anchor point and starts moving it.
|
||
let a = direct.pointer(&cx, &PointerEvent::new(PointerKind::Down, p.x, p.y));
|
||
assert!(
|
||
matches!(a.first(), Some(Action::Exec(cmd, p)) if cmd == "selection.set" && p["anchors"].as_array().map(Vec::len) == Some(1)),
|
||
"{a:?}"
|
||
);
|
||
assert_eq!(a.last(), Some(&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::<Vec<_>>();
|
||
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 {
|
||
layer_options: None,
|
||
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);
|
||
}
|
||
|
||
#[test]
|
||
fn parent_resize_page_tool_targets_the_visible_parent() {
|
||
let d = Document::new(&NewDocument { width: 400.0, height: 600.0, pages: 3, facing_pages: true, ..Default::default() });
|
||
let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, true));
|
||
let cx = ctx(&d, &s, &c, &l);
|
||
let p = l.to_canvas(SpreadRef::Parent(0), d.parents[0].pages[0].bounds().center());
|
||
let mut t = create("page");
|
||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::DoubleClick, p.x, p.y));
|
||
assert_eq!(
|
||
a,
|
||
vec![Action::Dialog(
|
||
"cmd:layout.parentPageSize".into(),
|
||
serde_json::json!({"parent": d.parents[0].id.0, "page": 0, "width": 400.0, "height": 600.0})
|
||
)],
|
||
"double click must target the visible parent instead of document page zero"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn parent_resize_selection_tracks_ids_and_never_crosses_canvas_modes() {
|
||
let mut d = Document::new(&NewDocument::default());
|
||
let second = d.add_parent("B", "Parent", 1, 12.0, designcraft_doc::Margins::uniform(36.0));
|
||
let selection = Selection { page: Some(0), parent_page: Some((second, 1)), ..Default::default() };
|
||
let l = CanvasLayout::new(&d, true);
|
||
assert_eq!(l.selected_page(&d, &selection), Some((SpreadRef::Parent(1), 1)));
|
||
d.parents.swap(0, 1);
|
||
let l = CanvasLayout::new(&d, true);
|
||
assert_eq!(l.selected_page(&d, &selection), Some((SpreadRef::Parent(0), 1)));
|
||
assert_eq!(CanvasLayout::new(&d, false).selected_page(&d, &selection), Some((SpreadRef::Doc(0), 0)));
|
||
d.parents.remove(0);
|
||
assert_eq!(CanvasLayout::new(&d, true).selected_page(&d, &selection), None);
|
||
let selection = Selection { page: Some(0), ..Default::default() };
|
||
assert_eq!(CanvasLayout::new(&d, true).selected_page(&d, &selection), None);
|
||
}
|
||
|
||
#[test]
|
||
fn direct_selection_click_on_one_of_several_selected_frames_selects_only_it() {
|
||
let mut d = Document::new(&NewDocument::default());
|
||
let a = add_rect(&mut d, Rect::new(100.0, 100.0, 200.0, 200.0));
|
||
let b = add_rect(&mut d, Rect::new(300.0, 100.0, 400.0, 200.0));
|
||
let (s, c, l) = (Selection::items(vec![a, b]), Cache::new(), CanvasLayout::new(&d, false));
|
||
let cx = ctx(&d, &s, &c, &l);
|
||
let p = spread_pt(&l, 150.0, 150.0);
|
||
let mut t = create("directSelection");
|
||
let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, p.x, p.y));
|
||
assert_eq!(acts, vec![Action::Exec("selection.set".into(), serde_json::json!({"ids": [a.0], "content": true}))]);
|
||
}
|
||
|
||
#[test]
|
||
fn pulling_a_corner_of_an_unselected_frame_selects_the_frame_not_its_content() {
|
||
let mut d = Document::new(&NewDocument::default());
|
||
let a = add_rect(&mut d, Rect::new(100.0, 100.0, 200.0, 200.0));
|
||
let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false));
|
||
let cx = ctx(&d, &s, &c, &l);
|
||
let p = spread_pt(&l, 200.0, 100.0);
|
||
let mut t = create("directSelection");
|
||
let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, p.x, p.y));
|
||
assert_eq!(
|
||
acts,
|
||
vec![Action::Exec("selection.set".into(), serde_json::json!({"ids": [a.0], "content": false})), Action::Begin("Move Anchor".into())]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn anchor_drag_on_a_rotated_spread_sends_a_spread_space_delta() {
|
||
let mut d = Document::new(&NewDocument::default());
|
||
let a = add_rect(&mut d, Rect::new(100.0, 100.0, 200.0, 200.0));
|
||
Arc::make_mut(&mut d.spreads[0]).pages[0].view_rotation = 1;
|
||
let (s, c, l) = (Selection::items(vec![a]), Cache::new(), CanvasLayout::new(&d, false));
|
||
let cx = ctx(&d, &s, &c, &l);
|
||
let p = l.to_canvas(SpreadRef::Doc(0), Point::new(200.0, 100.0));
|
||
let mut t = create("directSelection");
|
||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, p.x, p.y));
|
||
let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, p.x + 50.0, p.y));
|
||
let [Action::Preview(cmd, params)] = acts.as_slice() else { panic!("{acts:?}") };
|
||
assert_eq!(cmd, "path.moveAnchors");
|
||
let want = l.delta_to_spread(SpreadRef::Doc(0), Vec2::new(50.0, 0.0));
|
||
assert!(want.y.abs() > 1.0, "the view rotation turns a canvas x move into a spread y move: {want:?}");
|
||
let (dx, dy) = (params["dx"].as_f64().unwrap(), params["dy"].as_f64().unwrap());
|
||
assert!((dx - want.x).abs() < 1e-6 && (dy - want.y).abs() < 1e-6, "{params} want {want:?}");
|
||
}
|
||
|
||
/// Direct Selection selects anchor points with a marquee across several frames, and dragging one of
|
||
/// them moves them all, as InDesign does when the top points of a row of frames are dragged to slant
|
||
/// them (Zero2Graphic's page layout tutorial, 1:15).
|
||
#[test]
|
||
fn direct_selection_marquee_selects_points_of_several_frames_and_drags_them_together() {
|
||
let mut d = Document::new(&NewDocument::default());
|
||
let a = add_rect(&mut d, Rect::new(100.0, 100.0, 200.0, 200.0));
|
||
let b = add_rect(&mut d, Rect::new(200.0, 100.0, 300.0, 200.0));
|
||
let (c, l) = (Cache::new(), CanvasLayout::new(&d, false));
|
||
let none = Selection::default();
|
||
let mut t = create("directSelection");
|
||
// A marquee around the top edges: the four top points, not the frames' bottom ones.
|
||
let (p0, p1) = (spread_pt(&l, 80.0, 90.0), spread_pt(&l, 320.0, 110.0));
|
||
t.pointer(&ctx(&d, &none, &c, &l), &PointerEvent::new(PointerKind::Down, p0.x, p0.y));
|
||
t.pointer(&ctx(&d, &none, &c, &l), &PointerEvent::new(PointerKind::Drag, p1.x, p1.y));
|
||
let up = t.pointer(&ctx(&d, &none, &c, &l), &PointerEvent::new(PointerKind::Up, p1.x, p1.y));
|
||
let Some(Action::Exec(cmd, p)) = up.last() else { panic!("{up:?}") };
|
||
assert_eq!(cmd, "selection.set");
|
||
assert_eq!(p["ids"], serde_json::json!([a.0, b.0]));
|
||
let mut got: Vec<(u64, u64, u64)> =
|
||
p["anchors"].as_array().unwrap().iter().map(|v| (v[0].as_u64().unwrap(), v[1].as_u64().unwrap(), v[2].as_u64().unwrap())).collect();
|
||
got.sort();
|
||
assert_eq!(got, vec![(a.0, 0, 0), (a.0, 0, 1), (b.0, 0, 0), (b.0, 0, 1)]);
|
||
// With another frame selected before, a marquee without Shift selects only the paths whose
|
||
// points it takes; with Shift the other frame stays selected.
|
||
let other = add_rect(&mut d, Rect::new(400.0, 400.0, 450.0, 450.0));
|
||
let l = CanvasLayout::new(&d, false);
|
||
let was = Selection { items: vec![other], ..Default::default() };
|
||
let shift = Mods { shift: true, ..Default::default() };
|
||
for (mods, want) in [(Mods::default(), serde_json::json!([a.0, b.0])), (shift, serde_json::json!([other.0, a.0, b.0]))] {
|
||
t.pointer(&ctx(&d, &was, &c, &l), &PointerEvent::new(PointerKind::Down, p0.x, p0.y).with_mods(mods));
|
||
t.pointer(&ctx(&d, &was, &c, &l), &PointerEvent::new(PointerKind::Drag, p1.x, p1.y).with_mods(mods));
|
||
let up = t.pointer(&ctx(&d, &was, &c, &l), &PointerEvent::new(PointerKind::Up, p1.x, p1.y).with_mods(mods));
|
||
let Some(Action::Exec(_, p)) = up.last() else { panic!("{up:?}") };
|
||
assert_eq!(p["ids"], want, "{mods:?}");
|
||
}
|
||
// Those points selected: dragging one moves all four, in one preview.
|
||
let sel = Selection { items: vec![a, b], anchors: vec![(a, 0, 0), (a, 0, 1), (b, 0, 0), (b, 0, 1)], ..Default::default() };
|
||
let cx = ctx(&d, &sel, &c, &l);
|
||
let at = spread_pt(&l, 200.0, 100.0);
|
||
let down = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, at.x, at.y));
|
||
assert_eq!(down, vec![Action::Begin("Move Anchors".into())]);
|
||
let drag = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, at.x + 36.0, at.y + 3.0).with_mods(shift));
|
||
let Some(Action::Preview(cmd, p)) = drag.last() else { panic!("{drag:?}") };
|
||
assert_eq!(cmd, "path.moveAnchors");
|
||
assert_eq!(p["points"].as_array().map(Vec::len), Some(4), "{p}");
|
||
// Shift keeps the move horizontal.
|
||
// Each point carries its own movement (in its spread): here all the same.
|
||
let moves = |p: &serde_json::Value| -> Vec<(f64, f64)> {
|
||
p["points"].as_array().unwrap().iter().map(|v| (v[3].as_f64().unwrap(), v[4].as_f64().unwrap())).collect()
|
||
};
|
||
assert!(moves(p).iter().all(|&(x, y)| (x - 36.0).abs() < 1e-9 && y.abs() < 1e-9), "{p}");
|
||
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, at.x + 36.0, at.y)), vec![Action::Commit]);
|
||
// A Shift-drag that starts on a selected point keeps it: all four move, constrained.
|
||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, at.x, at.y).with_mods(shift));
|
||
let drag = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, at.x + 3.0, at.y + 30.0).with_mods(shift));
|
||
let Some(Action::Preview(_, p)) = drag.last() else { panic!("{drag:?}") };
|
||
assert_eq!(p["points"].as_array().map(Vec::len), Some(4), "{p}");
|
||
assert!(moves(p).iter().all(|&(x, y)| x.abs() < 1e-9 && (y - 30.0).abs() < 1e-9), "vertical: {p}");
|
||
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, at.x, at.y + 30.0).with_mods(shift)), vec![Action::Commit]);
|
||
// Shift+click on a selected point takes it out of the selection, on release; the arrow keys
|
||
// move the points.
|
||
let out = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, at.x, at.y).with_mods(shift));
|
||
assert!(!out.iter().any(|a| matches!(a, Action::Exec(..))), "kept while it may become a drag: {out:?}");
|
||
// A jiggle inside the drag tolerance moves nothing: it is still a click.
|
||
let jiggle = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, at.x + 1.0, at.y + 1.0).with_mods(shift));
|
||
assert!(jiggle.is_empty(), "{jiggle:?}");
|
||
let out = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, at.x + 1.0, at.y + 1.0).with_mods(shift));
|
||
let Some(Action::Exec(cmd, p)) = out.last() else { panic!("{out:?}") };
|
||
assert_eq!(cmd, "selection.set");
|
||
assert_eq!(p["anchors"].as_array().map(Vec::len), Some(3), "{p}");
|
||
let keys = t.key(&cx, ToolKey::Right, Mods::default());
|
||
assert!(
|
||
matches!(keys.as_slice(), [Action::Exec(cmd, p)] if cmd == "path.moveAnchors" && p["points"].as_array().map(Vec::len) == Some(4)),
|
||
"{keys:?}"
|
||
);
|
||
// Delete deletes the selected points, not their paths.
|
||
assert_eq!(t.key(&cx, ToolKey::Delete, Mods::default()), vec![Action::Exec("path.deletePoints".into(), serde_json::json!({}))]);
|
||
// On a spread turned a quarter on screen, → moves the points right on screen: up or down in
|
||
// the spread, not across it.
|
||
let mut turned = d.clone();
|
||
std::sync::Arc::make_mut(&mut turned.spreads[0]).pages[0].view_rotation = 1;
|
||
let lt = CanvasLayout::new(&turned, false);
|
||
let cxt = ctx(&turned, &sel, &c, <);
|
||
let keys = t.key(&cxt, ToolKey::Right, Mods::default());
|
||
let [Action::Exec(_, p)] = keys.as_slice() else { panic!("{keys:?}") };
|
||
let inc = turned.settings.keyboard_increment;
|
||
let want = lt.delta_to_spread(SpreadRef::Doc(0), designcraft_geom::Vec2::new(inc, 0.0));
|
||
assert!(want.x.abs() < 1e-9 && (want.y.abs() - inc).abs() < 1e-9, "a quarter turn: {want:?}");
|
||
assert!(moves(p).iter().all(|&(x, y)| (x - want.x).abs() < 1e-9 && (y - want.y).abs() < 1e-9), "{p}");
|
||
}
|