Merge pull request #4379 from dotCooCoo/fix/shape-builder-merge-off-ui-thread

Shape Builder: a large selection merges on another thread
This commit is contained in:
BFlat
2026-11-09 13:10:54 +08:00
committed by GitHub
40 changed files with 2215 additions and 154 deletions
+480
View File
@@ -607,6 +607,486 @@ fn shape_builder_merge_uses_the_map_the_tool_has() {
assert_eq!(maps_built(), built + 1, "another gap");
}
/// 40 squares 20 pt wide in a row, each 10 pt right of the one before (160 segments, which the
/// Shape Builder merges on another thread), all selected.
fn square_row(s: &mut Session) -> Vec<NodeId> {
let ids: Vec<NodeId> = (0..40).map(|k| rect(s, f64::from(k) * 10.0, 0.0, 20.0, 20.0)).collect();
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
ids
}
/// The active document of `s` without its dates, so documents made in two sessions at different
/// times compare equal when their art is.
fn undated(s: &Session) -> vectorcraft_doc::Document {
let mut d = (*s.doc().unwrap().doc).clone();
(d.metadata.created, d.metadata.modified) = (None, None);
d
}
/// The Shape Builder over [`square_row`], its map built, after a click at (15, 10): the merge is
/// being computed on another thread, and the document is as it was.
fn click_row(s: &mut Session, v: ViewInfo) {
s.select_tool("shapeBuilder", v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Move, 15.0, 10.0), v).unwrap();
s.tool_poll(v, true).unwrap();
let revision = s.doc().unwrap().revision;
s.pointer(&PointerEvent::new(PointerKind::Down, 15.0, 10.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 15.0, 10.0), v).unwrap();
assert!(s.tool_editing() && s.tool_waiting());
assert_eq!(s.doc().unwrap().revision, revision, "the release returns before the merge is applied");
}
/// A click of the Shape Builder on a large selection merges on another thread, and the merge the
/// next poll applies is the command's own, as one undo step.
#[test]
fn shape_builder_merges_a_large_selection_on_another_thread() {
use vectorcraft_tools::builder::merges_computed;
let v = ViewInfo::default();
let mut s = session();
square_row(&mut s);
click_row(&mut s, v);
let computed = merges_computed();
s.tool_poll(v, true).unwrap();
assert!(!s.tool_editing());
assert_eq!(merges_computed(), computed, "the command takes the merge computed on another thread");
assert_eq!(s.journal.last().unwrap().0, "shapeBuilder.merge");
assert_eq!(layer_children(&s), 41, "the overlap and what is left of the two squares over it");
let mut direct = session();
square_row(&mut direct);
direct.execute("shapeBuilder.merge", &json!({"points": [[15, 10]]})).unwrap();
assert!(undated(&s) == undated(&direct), "the same document as the command gives");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(layer_children(&s), 40, "one undo step");
}
/// What comes after a click whose merge is being computed on another thread waits for the merge
/// and runs on the merged art, as when the merge ran on the calling thread: a command (an edit,
/// an undo, a selection, a save, a batch, Lock Guides, the proof views, a preference, Add Font
/// Files), an option change, another tool, a save made outside a command, preferences applied
/// outside a command, a dialog's preview, a scrubbed field's undo scope, a Cmd-click or Cmd-drag
/// with the selection tool. The merge is the one computed on the other thread.
#[test]
fn shape_builder_applies_a_merge_before_what_comes_next() {
use vectorcraft_tools::builder::merges_computed;
type Next = (&'static str, fn(&mut Session, &[NodeId], &std::path::Path));
let v = ViewInfo::default();
let next: Vec<Next> = vec![
("Select All Instances", |s, _, _| {
s.execute("symbol.selectInstances", &json!({"name": "S"})).unwrap();
assert_eq!(s.doc().unwrap().selection.objects.len(), 1);
}),
("an edit", |s, _, _| {
s.execute("object.move", &json!({"dx": 0, "dy": 100})).unwrap();
}),
("an undo", |s, _, _| {
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(layer_children(s), 40, "the undo takes the merge back");
s.execute("edit.redo", &json!({})).unwrap();
}),
("another selection", |s, _, _| {
s.execute("select.inverse", &json!({})).unwrap();
}),
("Select All", |s, _, _| {
s.execute("select.all", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().selection.objects.len(), 41);
}),
("a save", |s, _, dir| {
s.execute("file.saveAs", &json!({"path": dir.join("a.vectorcraft").to_string_lossy()})).unwrap();
assert!(!s.doc().unwrap().is_dirty(), "the file holds the merge");
}),
("a save outside a command", |s, _, dir| {
let p = json!({"path": dir.join("b.vectorcraft").to_string_lossy()});
let plan = cmd::fileio::save_plan(s, cmd::fileio::SaveMode::SaveAs, &p).unwrap();
assert_eq!(layer_children(s), 40, "planning a save leaves the document");
let job = cmd::fileio::save_job(s, plan).unwrap();
assert_eq!(layer_children(s), 41, "the snapshot is taken after the merge");
drop(job);
}),
("a batch", |s, _, _| {
s.execute("command.batch", &json!({"commands": [{"command": "object.move", "params": {"dx": 0, "dy": 50}}]})).unwrap();
}),
("an option change", |s, _, _| {
s.set_tool_option_cmd(&json!({"key": "gapDetection", "value": true})).unwrap();
}),
("another tool", |s, _, _| {
s.select_tool("selection", ViewInfo::default()).unwrap();
}),
("a closed document", |s, _, _| {
s.execute("file.close", &json!({})).unwrap();
}),
("Lock Guides", |s, _, _| {
s.execute("view.guides.lock", &json!({"locked": true})).unwrap();
}),
("an option set from a panel", |s, _, _| {
s.set_tool_option("gapDetection", &json!(true));
}),
("a dialog's preview, canceled", |s, _, _| {
s.begin_interaction("Move").unwrap();
s.preview("object.move", &json!({"dx": 7, "dy": 0})).unwrap();
s.preview("object.move", &json!({"dx": 9, "dy": 0})).unwrap();
s.cancel_interaction().unwrap();
assert_eq!(layer_children(s), 41, "canceling the preview keeps the merge");
}),
("a dialog's preview, kept", |s, _, _| {
s.begin_interaction("Move").unwrap();
s.preview("object.move", &json!({"dx": 7, "dy": 0})).unwrap();
s.commit_interaction().unwrap();
let labels: Vec<&str> = s.doc().unwrap().history.undo.iter().rev().take(2).map(|e| e.label.as_str()).collect();
assert_eq!(labels, ["Move", "Shape Builder"]);
}),
("a scrubbed field, canceled", |s, _, _| {
let scope = s.begin_undo_scope().unwrap();
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
s.end_undo_scope(scope, true);
assert_eq!(
s.doc().unwrap().history.undo.last().map(|e| e.label.as_str()),
Some("Shape Builder"),
"the scrub's step is undone, not the merge"
);
}),
("a held click, then another tool", |s, _, _| {
let v = ViewInfo::default();
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(kind, 55.0, 10.0), v).unwrap();
}
assert!(s.tool_editing(), "the click is held");
s.select_tool("selection", v).unwrap();
let labels: Vec<&str> = s.doc().unwrap().history.undo.iter().rev().take(2).map(|e| e.label.as_str()).collect();
assert_eq!(labels, ["Shape Builder", "Shape Builder"], "the held click merges with the Shape Builder first");
}),
("a held click, then another document", |s, _, _| {
let v = ViewInfo::default();
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(kind, 55.0, 10.0), v).unwrap();
}
let first = s.active_index().unwrap();
s.add_document(vectorcraft_doc::Document::new(100.0, 100.0), None);
assert!(!s.tool_editing());
let labels: Vec<&str> = s.documents()[first].history.undo.iter().rev().take(2).map(|e| e.label.as_str()).collect();
assert_eq!(labels, ["Shape Builder", "Shape Builder"], "the held click merges in its own document first");
s.set_active(first);
}),
("a Cmd-click", |s, _, _| {
let v = ViewInfo::default();
let cmd = Mods { cmd: true, ..Default::default() };
assert_eq!(s.cursor(Point::new(500.0, 300.0), cmd, v), vectorcraft_tools::Cursor::Busy, "Cmd shows the merge is computed");
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(kind, 500.0, 300.0).with_mods(cmd), v).unwrap();
}
assert!(s.tool_editing(), "the click is held");
while s.tool_waiting() {
s.tool_poll(v, true).unwrap();
}
assert!(s.doc().unwrap().selection.is_empty(), "the lent selection tool deselects after the merge");
assert_eq!(s.tool_id(), "shapeBuilder");
}),
("a Cmd-drag", |s, _, _| {
let v = ViewInfo::default();
let cmd = Mods { cmd: true, ..Default::default() };
for (kind, y) in [(PointerKind::Down, 10.0), (PointerKind::Drag, 60.0), (PointerKind::Drag, 110.0), (PointerKind::Up, 110.0)] {
s.pointer(&PointerEvent::new(kind, 305.0, y).with_mods(cmd), v).unwrap();
}
assert!(s.tool_editing(), "the drag is held");
while s.tool_waiting() {
s.tool_poll(v, true).unwrap();
}
let labels: Vec<&str> = s.doc().unwrap().history.undo.iter().rev().take(2).map(|e| e.label.as_str()).collect();
assert_eq!(labels, ["Move", "Shape Builder"]);
}),
// The proof views redraw every document (a new revision); they are left off, as the
// other tests of the process-wide proof view expect.
("Separations Preview", |s, _, _| {
s.execute("view.separationsPreview", &json!({"on": false})).unwrap();
}),
("Overprint Preview", |s, _, _| {
s.execute("view.overprintPreview", &json!({"on": false})).unwrap();
}),
("Proof Colors", |s, _, _| {
s.execute("view.proofColors", &json!({"on": false})).unwrap();
}),
("Proof Setup", |s, _, _| {
s.execute("view.proofSetup", &json!({})).unwrap();
}),
("a preference", |s, _, _| {
let every = s.prefs.gridline_every;
s.execute("prefs.set", &json!({"key": "gridlineEvery", "value": every + 10.0})).unwrap();
}),
("Reset Preferences", |s, _, _| {
s.execute("prefs.reset", &json!({})).unwrap();
}),
("preferences applied outside a command", |s, _, _| {
let mut p = s.prefs.clone();
p.gridline_every += 10.0;
s.apply_prefs(p);
}),
("Add Font Files", |s, _, _| {
// No files: the command fails after the merge.
assert!(s.execute("text.addFontFiles", &json!({"files": []})).is_err());
}),
];
let _proof = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let dir = vectorcraft_testkit::temp_dir("shape-builder-merge-first");
for (what, then) in next {
let mut s = session();
if what == "Select All Instances" {
let far = rect(&mut s, 500.0, 500.0, 20.0, 20.0);
s.execute("symbol.new", &json!({"name": "S", "ids": [far.0]})).unwrap();
}
let ids = square_row(&mut s);
click_row(&mut s, v);
let computed = merges_computed();
let journal = s.journal.len();
then(&mut s, &ids, &dir);
assert!(!s.tool_editing(), "{what}");
assert_eq!(merges_computed(), computed, "{what}: the merge computed on another thread");
assert_eq!(s.journal.since(journal).first().map(|j| j.0.as_str()), Some("shapeBuilder.merge"), "{what}: the merge comes first");
if what != "a closed document" {
assert!(layer_children(&s) >= 41, "{what}: the merge stays");
}
}
}
/// Gestures and arrow keys that come while a merge is computed on another thread are held and
/// made after it, in order: the document is the one they make one after the other, and the
/// events count as made once they are and the merge they start is applied.
#[test]
fn shape_builder_holds_gestures_made_during_a_merge() {
type Gesture = fn(&mut Session, ViewInfo);
let cases: [(&str, bool, Gesture); 6] = [
("a second click", false, |s, v| {
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(kind, 55.0, 10.0), v).unwrap();
}
}),
("a drag", false, |s, v| {
for (kind, x) in [(PointerKind::Down, 75.0), (PointerKind::Drag, 85.0), (PointerKind::Drag, 95.0), (PointerKind::Up, 95.0)] {
s.pointer(&PointerEvent::new(kind, x, 10.0), v).unwrap();
}
}),
("two clicks", false, |s, v| {
for x in [55.0, 135.0] {
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(kind, x, 10.0), v).unwrap();
}
}
}),
("a Cmd-click", false, |s, v| {
let cmd = Mods { cmd: true, ..Default::default() };
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(kind, 500.0, 300.0).with_mods(cmd), v).unwrap();
}
}),
("a Cmd-drag and a click", false, |s, v| {
let cmd = Mods { cmd: true, ..Default::default() };
for (kind, y) in [(PointerKind::Down, 10.0), (PointerKind::Drag, 110.0), (PointerKind::Up, 110.0)] {
s.pointer(&PointerEvent::new(kind, 305.0, y).with_mods(cmd), v).unwrap();
}
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(kind, 55.0, 10.0), v).unwrap();
}
}),
("an arrow key", true, |s, v| {
s.tool_key(ToolKey::Right, Mods::default(), v).unwrap();
}),
];
let v = ViewInfo::default();
let start = |swatches: bool| {
let mut s = session();
if swatches {
s.select_tool("shapeBuilder", v).unwrap();
s.set_tool_option_cmd(&json!({"key": "pickColorFrom", "value": "swatches"})).unwrap();
}
square_row(&mut s);
click_row(&mut s, v);
s
};
for (what, swatches, send) in cases {
// One after the other: the merge applied before the gesture, and each merge after its own.
let mut one = start(swatches);
while one.tool_waiting() {
one.tool_poll(v, true).unwrap();
}
send(&mut one, v);
while one.tool_waiting() {
one.tool_poll(v, true).unwrap();
}
// While the merge is computed.
let mut s = start(swatches);
let revision = s.doc().unwrap().revision;
let from = s.input_count();
send(&mut s, v);
let to = s.input_count();
assert!(s.tool_editing() && !s.input_applied(from, to), "{what}: held");
assert_eq!(s.doc().unwrap().revision, revision, "{what}: nothing made before the merge");
while s.tool_waiting() {
s.tool_poll(v, true).unwrap();
}
assert!(!s.tool_editing() && s.input_applied(from, to), "{what}");
assert!(undated(&s) == undated(&one), "{what}: the document the gestures make one after the other");
assert_eq!(s.doc().unwrap().selection, one.doc().unwrap().selection, "{what}");
assert_eq!(s.paint.fill, one.paint.fill, "{what}");
let labels = |s: &Session| s.doc().unwrap().history.undo.iter().map(|e| e.label.clone()).collect::<Vec<_>>();
assert_eq!(labels(&s), labels(&one), "{what}");
// The click's merge alone.
let mut merged = start(swatches);
while merged.tool_waiting() {
merged.tool_poll(v, true).unwrap();
}
let changed = labels(&s).len() > labels(&merged).len()
|| s.paint.fill != merged.paint.fill
|| s.doc().unwrap().selection != merged.doc().unwrap().selection;
assert!(changed, "{what}: the gesture did something");
}
}
/// A press held while a merge is computed on another thread, then dropped (a long touch, a
/// canceled touch, a second finger), is never made: once the merge is applied, the tool is at
/// rest and the document is the one the same input makes, dropped the same way, after the merge.
/// A held click before the press is still made, and so is a perspective plane key held after it.
#[test]
fn shape_builder_drops_a_press_held_during_a_merge() {
use vectorcraft_tools::distort::perspective::{PerspectiveGrid, Plane};
type Gesture = fn(&mut Session, ViewInfo);
fn press(s: &mut Session, v: ViewInfo, x: f64, mods: Mods) {
s.pointer(&PointerEvent::new(PointerKind::Down, x, 10.0).with_mods(mods), v).unwrap();
}
fn click(s: &mut Session, v: ViewInfo) {
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(kind, 55.0, 10.0), v).unwrap();
}
}
// What a case sends, and whether the perspective grid is shown.
let cases: [(&str, bool, Gesture); 7] = [
("a press", false, |s, v| press(s, v, 75.0, Mods::default())),
("a drag", false, |s, v| {
press(s, v, 75.0, Mods::default());
s.pointer(&PointerEvent::new(PointerKind::Drag, 95.0, 10.0), v).unwrap();
}),
("a Cmd-press", false, |s, v| press(s, v, 305.0, Mods { cmd: true, ..Default::default() })),
("a press and a key", false, |s, v| {
press(s, v, 75.0, Mods::default());
s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap();
}),
("a press and an arrow key", false, |s, v| {
press(s, v, 75.0, Mods::default());
s.tool_key(ToolKey::Right, Mods::default(), v).unwrap();
}),
("a press and a plane key", true, |s, v| {
press(s, v, 75.0, Mods::default());
s.tool_key(ToolKey::Digit(3), Mods::default(), v).unwrap();
}),
("a click, then a press", false, |s, v| {
click(s, v);
press(s, v, 135.0, Mods::default());
}),
];
let v = ViewInfo::default();
let start = |grid: bool| {
let mut s = session();
s.select_tool("shapeBuilder", v).unwrap();
s.set_tool_option_cmd(&json!({"key": "pickColorFrom", "value": "swatches"})).unwrap();
if grid {
s.execute("perspective.grid.preset", &json!({"kind": 2})).unwrap();
}
square_row(&mut s);
click_row(&mut s, v);
s
};
// A tool left in the middle of a gesture keeps waiting: the polls stop after a few.
let settle = |s: &mut Session| {
for _ in 0..8 {
if !s.tool_waiting() {
return;
}
s.tool_poll(v, true).unwrap();
}
};
let labels = |s: &Session| s.doc().unwrap().history.undo.iter().map(|e| e.label.clone()).collect::<Vec<_>>();
let plane = |s: &Session| PerspectiveGrid::from_doc(&s.doc().unwrap().doc).unwrap().plane;
for (what, grid, send) in cases {
// The same input after the merge, dropped the same way.
let mut one = start(grid);
settle(&mut one);
send(&mut one, v);
one.drop_tool_press(v).unwrap();
settle(&mut one);
let mut s = start(grid);
if grid {
assert_ne!(plane(&s), Plane::Right, "{what}: the plane before the key");
}
let from = s.input_count();
send(&mut s, v);
let to = s.input_count();
assert!(s.tool_editing() && !s.input_applied(from, to), "{what}: held");
s.drop_tool_press(v).unwrap();
settle(&mut s);
assert!(!s.tool_waiting() && !s.tool_editing() && s.input_applied(from, to), "{what}");
assert!(!s.tool_busy(), "{what}: the tool is at rest");
assert_eq!(s.tool_id(), "shapeBuilder", "{what}: no tool is lent");
assert!(undated(&s) == undated(&one), "{what}: the document the input makes after the merge");
assert_eq!(labels(&s), labels(&one), "{what}");
assert_eq!(s.doc().unwrap().selection, one.doc().unwrap().selection, "{what}");
assert_eq!(s.paint.fill, one.paint.fill, "{what}");
if grid {
assert_eq!(plane(&one), Plane::Right, "{what}: the plane key is made after the merge");
assert_eq!(plane(&s), Plane::Right, "{what}: the plane key is made");
}
}
}
/// A new revision of the document made outside a command while a merge is computed on another
/// thread keeps the merge when the selected art is as it was (the next poll applies it, as the
/// command computes it), and drops it when the art changed.
#[test]
fn shape_builder_keeps_a_merge_over_a_new_revision_of_the_same_art() {
use vectorcraft_tools::builder::merges_computed;
let v = ViewInfo::default();
let mut s = session();
square_row(&mut s);
click_row(&mut s, v);
let computed = merges_computed();
s.active_mut().unwrap().revision += 1;
s.tool_poll(v, true).unwrap();
assert!(!s.tool_editing());
assert_eq!(merges_computed(), computed, "the merge computed on another thread");
assert_eq!(s.journal.last().unwrap().0, "shapeBuilder.merge");
let mut direct = session();
square_row(&mut direct);
direct.execute("shapeBuilder.merge", &json!({"points": [[15, 10]]})).unwrap();
assert!(undated(&s) == undated(&direct), "the same document as the command gives");
// The art changed: the merge is dropped.
let mut s = session();
let ids = square_row(&mut s);
click_row(&mut s, v);
let journal = s.journal.len();
let st = s.active_mut().unwrap();
Arc::make_mut(&mut st.doc).node_mut(ids[1]).unwrap().transform(Affine::translate((0.0, 50.0)), false);
st.revision += 1;
s.tool_poll(v, true).unwrap();
assert!(!s.tool_editing());
assert_eq!(s.journal.len(), journal, "nothing merged");
assert_eq!(layer_children(&s), 40);
}
/// A merge computed for the tool Cmd lent the selection tool's drag away from is applied before
/// a command too.
#[test]
fn shape_builder_merge_of_the_lending_tool_comes_first() {
let v = ViewInfo::default();
let mut s = session();
square_row(&mut s);
click_row(&mut s, v);
let lent = s.make_tool("selection");
s.lender = Some(std::mem::replace(&mut s.tool, lent));
assert!(s.tool_editing());
let journal = s.journal.len();
s.execute("object.move", &json!({"dx": 0, "dy": 100})).unwrap();
assert!(!s.tool_editing());
assert_eq!(s.journal.since(journal).first().map(|j| j.0.as_str()), Some("shapeBuilder.merge"));
assert_eq!(s.tool_id(), "selection", "the lent tool stays active until the release");
assert_eq!(s.lender.as_ref().map(|t| t.id()), Some("shapeBuilder"));
}
#[test]
fn shape_builder_tool_merges_the_narrow_shape_it_highlights_in_a_wide_selection() {
let mut s = session();