mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-09 08:27:31 +00:00
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:
@@ -1,5 +1,5 @@
|
||||
use super::{
|
||||
Beside, BuilderMap, EngineError, FillRule, Node, NodeId, PathData, Point, Rect, Result, Session, SubPath, Touched, Value, b, bad, check_numbers,
|
||||
Arc, Beside, BuilderMap, EngineError, FillRule, Node, NodeId, PathData, Point, Rect, Result, Session, Touched, Value, b, bad, check_numbers,
|
||||
f64_or, fill_param, finite, gap_param, ids_json, json, marquee_param, po, points_param, remnant, replace_leaves, replace_with_pieces, root_ids,
|
||||
shape_node, switch_or,
|
||||
};
|
||||
@@ -41,6 +41,7 @@ pub(super) fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let gap = gap_param(p);
|
||||
// The map the Shape Builder tool already has, which a click or drag of it is made over.
|
||||
let shared = s.tool.shape_builder_map().filter(|(_, g)| *g == gap);
|
||||
let prepared = s.tool.shape_builder_merge();
|
||||
let label = if erase { "Shape Builder Delete" } else { "Shape Builder" };
|
||||
let (ids, merged, touched, split) = s.edit(label, |d, sel| {
|
||||
let (shapes, leaves) = b::shape_builder_shapes(d, &roots, open_filled_as_closed);
|
||||
@@ -89,16 +90,28 @@ pub(super) fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
touched.add(map.hit(q, Some(tol)));
|
||||
return Ok((ids, None, touched, Some(split)));
|
||||
}
|
||||
let mut touched = Touched { regions: marquee.map(|(a, b)| map.regions_in_marquee(a, b)).unwrap_or_default(), ..Default::default() };
|
||||
touched.extend(&map.builder_hits_along(&pts, piece_tol));
|
||||
touched.edges = map.erased_edges(&touched);
|
||||
let touched = map.merge_touched(&pts, marquee, piece_tol);
|
||||
if touched.is_empty() {
|
||||
return Err(EngineError::Other("Shape Builder: no region under the pointer".into()));
|
||||
}
|
||||
let cuts: Vec<&SubPath> = touched.edges.iter().filter_map(|&k| map.edges.get(k)?.0.path.subpaths.first()).collect();
|
||||
let inputs = b::merge_inputs(&leaves.iter().collect::<Vec<_>>(), open_filled_as_closed);
|
||||
// The tool computed this merge on another thread when it would come out the same.
|
||||
let ready = prepared.as_ref().filter(|m| {
|
||||
shared.as_ref().is_some_and(|(s, _)| Arc::ptr_eq(s, &m.map) && std::ptr::eq(s.as_ref(), map))
|
||||
&& m.gap == gap
|
||||
&& m.touched == touched
|
||||
&& m.inputs == inputs
|
||||
});
|
||||
let computed;
|
||||
let geometry = match ready {
|
||||
Some(m) => &m.geometry,
|
||||
None => {
|
||||
computed = b::merge_geometry(map, &touched, gap, &inputs);
|
||||
&computed
|
||||
}
|
||||
};
|
||||
let picked: Vec<&po::Region> = touched.regions.iter().filter_map(|&i| map.regions.get(i).map(|r| &r.0)).collect();
|
||||
let faces = po::FaceMerger::with_gap(&map.shapes, gap_param(p));
|
||||
let union = if picked.is_empty() { PathData::default() } else { faces.merge(&picked) };
|
||||
let union = &geometry.union;
|
||||
// Pick Color From Artwork: the merged shape takes the look of the front-most object under the
|
||||
// drag's first point, else under its last point, else of the front-most object. Color
|
||||
// Swatches: the current fill, with the rest of the look of the front-most object over the
|
||||
@@ -115,7 +128,7 @@ pub(super) fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// What goes in place of each object the map was made from (none: it stays as it is).
|
||||
let mut outs: Vec<Option<Vec<Node>>> = vec![];
|
||||
let mut merged_id = None;
|
||||
for (i, (sh, leaf)) in map.shapes.iter().zip(&leaves).enumerate() {
|
||||
for (i, leaf) in leaves.iter().take(map.shapes.len()).enumerate() {
|
||||
let mut out = None;
|
||||
let pieces: Vec<usize> = map.lines.iter().enumerate().filter(|(_, (l, _))| l.key == i as u64).map(|(k, _)| k).collect();
|
||||
if !pieces.is_empty() {
|
||||
@@ -131,41 +144,15 @@ pub(super) fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
out = Some(kept);
|
||||
}
|
||||
} else {
|
||||
} else if let Some(Some(rest)) = geometry.rests.get(i) {
|
||||
// A shape loses the regions deleted or merged, then the edges deleted (each one on
|
||||
// its outline, shared edges from every shape along them).
|
||||
let in_union = picked.iter().any(|r| r.sources.contains(&i));
|
||||
let mut rest = if in_union {
|
||||
// What is left of it: its faces not merged or deleted, united. They share their
|
||||
// edges with the merged faces exactly, where subtracting the merged shape left
|
||||
// slivers of the outline along them (#569). A sliver goes in only where the
|
||||
// shape covers it and the merged shape does not take it in.
|
||||
let kept: Vec<&po::Region> = map
|
||||
.regions
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(k, (r, ..))| r.sources.contains(&i) && !touched.regions.contains(k))
|
||||
.map(|(_, (r, ..))| r)
|
||||
.collect();
|
||||
if kept.is_empty() { PathData::default() } else { faces.merge_rest(i, &kept, &picked) }
|
||||
// its outline, shared edges from every shape along them). What is left of its own
|
||||
// outline keeps its fill rule; united faces fill with non-zero.
|
||||
out = Some(if rest.path.is_empty() {
|
||||
vec![]
|
||||
} else {
|
||||
b::node_outline(leaf).map_or_else(|| sh.path.clone(), |o| o.0)
|
||||
};
|
||||
let fill_closes = open_filled_as_closed && !leaf.appearance.fill_paint().is_none();
|
||||
// Edges come back from the planar map within a few hundredths of the outlines they
|
||||
// follow, whatever else is selected.
|
||||
let cut_tol = b::outline_snap(&sh.path);
|
||||
let mut cut = false;
|
||||
for piece in &cuts {
|
||||
if let Some(r) = po::cut_out(&rest, piece, fill_closes, cut_tol) {
|
||||
rest = r;
|
||||
cut = true;
|
||||
}
|
||||
}
|
||||
if in_union || cut {
|
||||
// What is left of its own outline keeps its fill rule; united faces fill with non-zero.
|
||||
out = Some(if rest.is_empty() { vec![] } else { vec![remnant(d, leaf, rest, in_union.then_some(FillRule::NonZero))] });
|
||||
}
|
||||
vec![remnant(d, leaf, rest.path.clone(), rest.united.then_some(FillRule::NonZero))]
|
||||
});
|
||||
}
|
||||
// The merged shape goes in place of the object it takes its look from, so it stays in
|
||||
// that object's group (a clip group too).
|
||||
|
||||
@@ -752,6 +752,8 @@ pub(super) fn encode_ai(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Res
|
||||
/// file becomes the document's own; the live document gets them in [`SaveJob::finish`]). Bad
|
||||
/// options fail here, before anything is encoded.
|
||||
pub fn save_job(s: &mut Session, plan: SavePlan) -> Result<SaveJob> {
|
||||
// A Shape Builder merge still being computed for a click is saved with the document.
|
||||
s.finish_tool_edit_later()?;
|
||||
// The web build, without a clock, leaves the dates unless given one.
|
||||
let at = if plan.path.is_some() && plan.retargets() { clock_date(s, "modified", plan.modified) } else { None };
|
||||
job_for_stamped(s, s.doc()?, plan, at)
|
||||
|
||||
@@ -678,6 +678,12 @@ impl Session {
|
||||
for e in self.prefs.keep_folders(&mut p) {
|
||||
log::warn!("{e}");
|
||||
}
|
||||
// The edit the active tool computes on another thread is applied first (the Preferences
|
||||
// dialog applies its settings here, outside a command). A failure to apply it was the
|
||||
// edit's own, and the preferences apply all the same.
|
||||
if self.depth == 0 {
|
||||
let _ = self.finish_tool_edit_later();
|
||||
}
|
||||
let grid_changed = p.gridline_every != self.prefs.gridline_every || p.grid_subdivisions != self.prefs.grid_subdivisions;
|
||||
let history_changed = p.history_states != self.prefs.history_states;
|
||||
let tile_edge_changed = p.pattern_tile_edge_color != self.prefs.pattern_tile_edge_color;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{NodeId, Value, cmd};
|
||||
use super::{NodeId, Value, cmd, find_command};
|
||||
|
||||
/// The commands that act on the active artboard (#693), which the app passes as their `artboard`:
|
||||
/// Paste in Place, in Front and in Back paste onto it, Align to Artboard aligns to it, Distribute
|
||||
@@ -78,6 +78,34 @@ pub(crate) fn strip_replay_only(id: &str, p: &mut Value) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Queries that apply the edit the active tool is computing before they run, as edits do
|
||||
/// ([`Session::finish_tool_edit`]): a batch runs edits, Find Objects with `select`, Select All
|
||||
/// Instances and Lock Guides change the selection, and the proof views, the preferences and added
|
||||
/// fonts give the documents new revisions (they draw again, and type lays out again).
|
||||
const WAIT_FOR_TOOL_EDIT: &[&str] = &[
|
||||
"command.batch",
|
||||
"document.find",
|
||||
"symbol.selectInstances",
|
||||
"view.guides.lock",
|
||||
"view.proofSetup",
|
||||
"view.proofColors",
|
||||
"view.overprintPreview",
|
||||
"view.separationsPreview",
|
||||
"prefs.set",
|
||||
"prefs.reset",
|
||||
"text.addFontFiles",
|
||||
];
|
||||
|
||||
/// Does command `id`, run on its own, first apply the edit the active tool is computing on
|
||||
/// another thread and the input held for it ([`Session::finish_tool_edit`])? Every command that
|
||||
/// can edit, select, save or give a document a new revision does; queries that only read do not
|
||||
/// (`commands_that_do_not_wait_for_a_tool_edit_change_no_document` in the command sweep checks
|
||||
/// them). A frontend that adds params from
|
||||
/// its own state (the active artboard) applies them first itself.
|
||||
pub fn waits_for_tool_edit(id: &str) -> bool {
|
||||
find_command(id).is_some_and(|spec| spec.journal || WAIT_FOR_TOOL_EDIT.contains(&id))
|
||||
}
|
||||
|
||||
/// Journaled commands that write a file and change nothing in the document: with an object of a
|
||||
/// dynamic symbol instance's art selected, they read the instance as queries do
|
||||
/// ([`crate::Session::execute`]).
|
||||
|
||||
@@ -62,6 +62,19 @@ pub struct Session {
|
||||
/// While Cmd lends `tool` (a selection tool) for a drag: the tool it was lent to, which comes
|
||||
/// back as it was at the release ([`Session::pointer`]).
|
||||
pub(crate) lender: Option<Box<dyn Tool>>,
|
||||
/// Pointer and key input that came while the tool computed an edit on another thread, each with
|
||||
/// its number and view, made in order once the edit is applied.
|
||||
held_input: std::collections::VecDeque<(u64, tooling::HeldInput, ViewInfo)>,
|
||||
/// The number the next pointer or key event handed to the session gets.
|
||||
input_count: u64,
|
||||
/// The number of the event whose handling started the edit the tool is computing.
|
||||
edit_started_by: Option<u64>,
|
||||
/// Set while the tool's edit and the input held for it are applied: the commands that run then
|
||||
/// do not start applying them again.
|
||||
settling_tool_edit: bool,
|
||||
/// Requests for the UI from making held input where nothing could take them (a command, a tool
|
||||
/// switch), which the next [`Session::tool_poll`] returns.
|
||||
held_ui: Vec<UiRequest>,
|
||||
/// The active document (its uid) and its [`History::step`] when the tool last handled a pointer
|
||||
/// event or drew its overlays: when they differ at the next one, the tool is told first
|
||||
/// ([`vectorcraft_tools::Tool::history_moved`]). None until the first.
|
||||
@@ -308,7 +321,7 @@ mod session;
|
||||
pub(crate) use command_sets::SELECTS_GIVEN_IDS;
|
||||
#[cfg(test)]
|
||||
use command_sets::given_ids;
|
||||
pub use command_sets::{FIT_ACTIVE_ARTBOARD, ON_ACTIVE_ARTBOARD};
|
||||
pub use command_sets::{FIT_ACTIVE_ARTBOARD, ON_ACTIVE_ARTBOARD, waits_for_tool_edit};
|
||||
pub use doc_state::{DocState, OpenRows, RecalledSelection};
|
||||
pub use error::{EngineError, Result};
|
||||
pub use history::{History, HistoryEntry, NamedStep, default_history_budget};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::command_sets::{JOURNALED_READS, SELECTS_GIVEN_IDS, given_ids, strip_replay_only};
|
||||
use super::command_sets::{JOURNALED_READS, SELECTS_GIVEN_IDS, given_ids, strip_replay_only, waits_for_tool_edit};
|
||||
use super::doc_state::DocState;
|
||||
use super::error::{EngineError, Result};
|
||||
use super::history::NamedStep;
|
||||
|
||||
@@ -46,6 +46,11 @@ impl Session {
|
||||
journal: Journal::default(),
|
||||
tool: vectorcraft_tools::create("selection"),
|
||||
lender: None,
|
||||
held_input: Default::default(),
|
||||
input_count: 0,
|
||||
edit_started_by: None,
|
||||
settling_tool_edit: false,
|
||||
held_ui: vec![],
|
||||
tool_step: None,
|
||||
selection_at_lend: None,
|
||||
lent_selection: None,
|
||||
@@ -160,6 +165,9 @@ impl Session {
|
||||
}
|
||||
self.press_type_selection = None;
|
||||
let view = self.last_view;
|
||||
// The edit the tool computes on another thread and the input held for it belong to the
|
||||
// document being left. The switch goes on whatever they did, as for the deactivation.
|
||||
let _ = self.finish_tool_edit_later();
|
||||
// The switch goes on whatever the lent tool's last actions did, as for the deactivation.
|
||||
let _ = self.give_back_tool(view);
|
||||
let acts = self.with_tool_cx(view, |t, cx| t.deactivate(cx));
|
||||
|
||||
@@ -230,6 +230,11 @@ impl Session {
|
||||
if label != "Typing" && cmd::typing_in_progress(self) {
|
||||
cmd::settle_interaction(self)?;
|
||||
}
|
||||
// An edit the tool computes on another thread (a Shape Builder merge) is applied first too,
|
||||
// so the snapshot the interaction previews from and goes back to holds it.
|
||||
if !self.in_interaction() {
|
||||
self.finish_tool_edit_later()?;
|
||||
}
|
||||
self.clear_deferred_overlay_cache();
|
||||
let paint = self.paint.clone();
|
||||
let st = self.doc_mut()?;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::{
|
||||
CommandSpec, EngineError, JOURNALED_READS, NodeId, Result, SELECTS_GIVEN_IDS, Selection, Session, Value, cmd, find_command, given_ids, guard,
|
||||
strip_replay_only,
|
||||
strip_replay_only, waits_for_tool_edit,
|
||||
};
|
||||
|
||||
impl Session {
|
||||
@@ -39,6 +39,12 @@ impl Session {
|
||||
/// Execute a command by id. This is THE entry point for every frontend.
|
||||
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
|
||||
let spec = find_command(id).ok_or_else(|| cmd::params::unknown_command(id))?;
|
||||
// An edit the active tool is computing on another thread (a Shape Builder merge) is
|
||||
// applied before a command that may edit, select, save or give a document a new revision,
|
||||
// as the click came first. Queries that only read do not wait for it.
|
||||
if self.depth == 0 && waits_for_tool_edit(id) {
|
||||
self.finish_tool_edit_later()?;
|
||||
}
|
||||
let outer = std::mem::replace(&mut self.reading, !spec.journal || JOURNALED_READS.contains(&id));
|
||||
let r = self.execute_spec(spec, id, params);
|
||||
self.reading = outer;
|
||||
|
||||
@@ -207,6 +207,10 @@ impl Session {
|
||||
/// [`Session::end_undo_scope`]: open an undo group there or, when one is open already, join it,
|
||||
/// so that the edits are part of its step. `None` without a document.
|
||||
pub fn begin_undo_scope(&mut self) -> Option<UndoScope> {
|
||||
// An edit the tool computes on another thread is a step before the scope, so the scope's
|
||||
// snapshot holds it. A failure to apply it was the edit's own, and the scope begins all the
|
||||
// same.
|
||||
let _ = self.finish_tool_edit_later();
|
||||
let journal = self.journal.len();
|
||||
let st = self.active()?;
|
||||
let uid = st.uid;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -83,6 +83,23 @@ pub enum UiRequest {
|
||||
/// Place a file with these `file.place` params: a click or drag of the place cursor, when
|
||||
/// [`Session::place_cursor_requests`] is set.
|
||||
Place(Value),
|
||||
/// A press held while the tool computed an edit on another thread was made now, at `pos` in the
|
||||
/// document with [`crate::DocState::uid`] `doc`, with tool `tool` active (the selection tool Cmd
|
||||
/// lent): the frontend does what it does after a press it hands over itself.
|
||||
HeldPress {
|
||||
doc: Option<u64>,
|
||||
pos: Point,
|
||||
tool: &'static str,
|
||||
},
|
||||
}
|
||||
|
||||
/// Pointer or key input that came while the active tool computed an edit on another thread
|
||||
/// ([`Tool::editing`]), made in order once the edit is applied ([`Session::tool_poll`],
|
||||
/// [`Session::finish_tool_edit`]).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) enum HeldInput {
|
||||
Pointer(PointerEvent),
|
||||
Key(ToolKey, Mods),
|
||||
}
|
||||
|
||||
impl Session {
|
||||
@@ -106,6 +123,7 @@ impl Session {
|
||||
|
||||
/// Choose a tool (the toolbar, its shortcut, MCP), finishing any pending tool work first.
|
||||
pub fn select_tool(&mut self, id: &str, view: ViewInfo) -> Result<()> {
|
||||
self.finish_tool_edit_later()?;
|
||||
// Choosing a perspective tool shows the document's perspective grid; Hide Grid hides it
|
||||
// again, the tool staying chosen.
|
||||
if matches!(id, "perspectiveGrid" | "perspectiveSelection")
|
||||
@@ -129,6 +147,8 @@ impl Session {
|
||||
/// Switch to tool `id` without choosing it afresh (back from a temporary tool: a hidden
|
||||
/// perspective grid stays hidden), finishing any pending tool work first.
|
||||
pub fn switch_tool(&mut self, id: &str, view: ViewInfo) -> Result<()> {
|
||||
// The edit the tool computes on another thread and the input held for it come first.
|
||||
self.finish_tool_edit_later()?;
|
||||
self.give_back_tool(view)?;
|
||||
if self.tool.id() == id {
|
||||
return Ok(());
|
||||
@@ -430,8 +450,85 @@ impl Session {
|
||||
self.tool_step = now;
|
||||
}
|
||||
|
||||
/// Feed a pointer event to the active tool. Returns requests for the UI.
|
||||
/// Feed a pointer event to the active tool. Returns requests for the UI. While the tool computes
|
||||
/// an edit on another thread, a press and what follows it are held and made once the edit is
|
||||
/// applied, in order ([`Session::tool_poll`]); moves of the pointer with no button held go to
|
||||
/// the tool at once.
|
||||
pub fn pointer(&mut self, ev: &PointerEvent, view: ViewInfo) -> Result<Vec<UiRequest>> {
|
||||
let n = self.next_input();
|
||||
if ev.kind != PointerKind::Move && (!self.held_input.is_empty() || self.tool_edit_pending()) {
|
||||
self.held_input.push_back((n, HeldInput::Pointer(*ev), view));
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let r = self.pointer_now(ev, view);
|
||||
self.note_edit_start(n);
|
||||
r
|
||||
}
|
||||
|
||||
/// The number of the next pointer or key event handed to the session.
|
||||
fn next_input(&mut self) -> u64 {
|
||||
let n = self.input_count;
|
||||
self.input_count = n.wrapping_add(1);
|
||||
n
|
||||
}
|
||||
|
||||
/// How many pointer and key events have been handed to the session ([`Session::pointer`],
|
||||
/// [`Session::tool_key`]); with [`Session::input_applied`], a frontend tells when the events it
|
||||
/// handed over have been made.
|
||||
pub fn input_count(&self) -> u64 {
|
||||
self.input_count
|
||||
}
|
||||
|
||||
/// Have the events numbered `from..to` ([`Session::input_count`] before and after handing them
|
||||
/// over) been made, and the edit they started on another thread applied?
|
||||
pub fn input_applied(&self, from: u64, to: u64) -> bool {
|
||||
let ours = |n: &u64| (from..to).contains(n);
|
||||
!self.held_input.iter().any(|(n, ..)| ours(n)) && !(self.tool_edit_pending() && self.edit_started_by.as_ref().is_some_and(ours))
|
||||
}
|
||||
|
||||
/// After event `n` was made: the event that started the edit being computed, if one is.
|
||||
fn note_edit_start(&mut self, n: u64) {
|
||||
if !self.tool_edit_pending() {
|
||||
self.edit_started_by = None;
|
||||
} else if self.edit_started_by.is_none() {
|
||||
self.edit_started_by = Some(n);
|
||||
}
|
||||
}
|
||||
|
||||
/// Is the active tool, or the tool Cmd lent the selection tool's drag away from, computing an
|
||||
/// edit on another thread?
|
||||
fn tool_edit_pending(&self) -> bool {
|
||||
self.tool.editing() || self.lender.as_ref().is_some_and(|t| t.editing())
|
||||
}
|
||||
|
||||
/// Makes the input held while the tool computed an edit, in order, until a held gesture starts
|
||||
/// another such edit.
|
||||
fn replay_held_input(&mut self) -> Result<Vec<UiRequest>> {
|
||||
let mut ui = vec![];
|
||||
let mut failed = None;
|
||||
while !self.tool_edit_pending()
|
||||
&& let Some((n, input, view)) = self.held_input.pop_front()
|
||||
{
|
||||
let r = match input {
|
||||
HeldInput::Pointer(ev) => self.pointer_now(&ev, view).map(|mut r| {
|
||||
if ev.kind == PointerKind::Down {
|
||||
r.push(UiRequest::HeldPress { doc: self.active().map(|d| d.uid), pos: ev.pos, tool: self.tool.id() });
|
||||
}
|
||||
r
|
||||
}),
|
||||
HeldInput::Key(key, mods) => self.tool_key_now(key, mods, view),
|
||||
};
|
||||
self.note_edit_start(n);
|
||||
match r {
|
||||
Ok(r) => ui.extend(r),
|
||||
// The input after it is still made, as it was when each event came on its own.
|
||||
Err(e) => failed = failed.or(Some(e)),
|
||||
}
|
||||
}
|
||||
failed.map_or(Ok(ui), Err)
|
||||
}
|
||||
|
||||
fn pointer_now(&mut self, ev: &PointerEvent, view: ViewInfo) -> Result<Vec<UiRequest>> {
|
||||
self.pointer_event(ev, view, true)
|
||||
}
|
||||
|
||||
@@ -442,8 +539,25 @@ impl Session {
|
||||
/// nothing and a click opens no size dialog. The tool gets back the persistent options it had
|
||||
/// when the press began, and none of the press's changes to them are stored. A dropped Type
|
||||
/// press restores the prior selection. The freeform gradient point selected once the tool
|
||||
/// took the press is selected again.
|
||||
/// took the press is selected again. A press held while the tool computes an edit on another
|
||||
/// thread has not reached the tool: it is removed from the held input with the pointer events
|
||||
/// and tool keys held after it, and the tool is left as it is. A perspective plane key held
|
||||
/// after it stays held, since the plane it picks outlasts a dropped press. Pointer events held
|
||||
/// after the last held release belong to the press the tool has, and are removed with it.
|
||||
pub fn drop_tool_press(&mut self, view: ViewInfo) -> Result<()> {
|
||||
let (from, held) = self.held_press();
|
||||
let keep: Vec<bool> = (self.held_input.iter().enumerate())
|
||||
.map(|(j, (_, input, _))| match input {
|
||||
_ if j < from => true,
|
||||
HeldInput::Pointer(_) => false,
|
||||
HeldInput::Key(key, _) => !held || self.plane_key(*key).is_some(),
|
||||
})
|
||||
.collect();
|
||||
let mut keep = keep.into_iter();
|
||||
self.held_input.retain(|_| keep.next().unwrap_or(true));
|
||||
if held {
|
||||
return Ok(());
|
||||
}
|
||||
// Nothing to roll back without a document; the tool still drops the press and gets the release.
|
||||
let _ = self.cancel_interaction();
|
||||
let before = self.press_options.take();
|
||||
@@ -485,6 +599,19 @@ impl Session {
|
||||
r.map(drop)
|
||||
}
|
||||
|
||||
/// Where the input of the press in progress starts in the held input, and whether the press
|
||||
/// itself is held: the last held press when no release is held after it; otherwise the input
|
||||
/// after the last held release (all of it when none is held), which follows a press the tool
|
||||
/// already has.
|
||||
fn held_press(&self) -> (usize, bool) {
|
||||
let ends = |kind: PointerKind| matches!(kind, PointerKind::Down | PointerKind::Up);
|
||||
match self.held_input.iter().rposition(|(_, input, _)| matches!(input, HeldInput::Pointer(ev) if ends(ev.kind))) {
|
||||
Some(i) if matches!(self.held_input.get(i), Some((_, HeldInput::Pointer(ev), _)) if ev.kind == PointerKind::Down) => (i, true),
|
||||
Some(i) => (i + 1, false),
|
||||
None => (0, false),
|
||||
}
|
||||
}
|
||||
|
||||
/// The options of `t` that [`Self::store_tool_settings`] stores.
|
||||
fn persistent_options(t: &dyn Tool) -> Map<String, Value> {
|
||||
let opts = t.options();
|
||||
@@ -619,19 +746,148 @@ impl Session {
|
||||
self.tool.wants_samples()
|
||||
}
|
||||
|
||||
/// Is the active tool waiting for work it runs on another thread ([`Tool::waiting`])?
|
||||
/// Is the active tool waiting for work it runs on another thread ([`Tool::waiting`]), is input
|
||||
/// held until an edit it computes is applied, or are requests for the UI left from making it?
|
||||
pub fn tool_waiting(&self) -> bool {
|
||||
self.tool.waiting()
|
||||
self.tool.waiting() || !self.held_input.is_empty() || !self.held_ui.is_empty()
|
||||
}
|
||||
|
||||
/// Lets the active tool check on the work [`Session::tool_waiting`] reports, and with `wait`
|
||||
/// wait for it to finish ([`Tool::poll`]).
|
||||
pub fn tool_poll(&mut self, view: ViewInfo, wait: bool) -> Result<()> {
|
||||
self.with_tool_cx(view, |t, cx| t.poll(cx, wait));
|
||||
self.take_tool_panic()
|
||||
/// wait for it to finish ([`Tool::poll`]); an edit the work made is applied, and then the input
|
||||
/// held for it is made. Returns requests for the UI, those left from making held input where
|
||||
/// nothing could take them first ([`Session::finish_tool_edit_later`]).
|
||||
pub fn tool_poll(&mut self, view: ViewInfo, wait: bool) -> Result<Vec<UiRequest>> {
|
||||
if self.settling_tool_edit {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
self.settling_tool_edit = true;
|
||||
let r = self.tool_poll_now(view, wait);
|
||||
self.settling_tool_edit = false;
|
||||
// On an error the requests left wait for the next poll.
|
||||
let r = r?;
|
||||
let mut ui = std::mem::take(&mut self.held_ui);
|
||||
ui.extend(r);
|
||||
Ok(ui)
|
||||
}
|
||||
|
||||
fn tool_poll_now(&mut self, view: ViewInfo, wait: bool) -> Result<Vec<UiRequest>> {
|
||||
let acts = self.with_tool_cx(view, |t, cx| t.poll(cx, wait));
|
||||
self.take_tool_panic()?;
|
||||
let applied = !acts.is_empty();
|
||||
let mut ui = self.apply_actions(acts)?;
|
||||
if !self.tool_edit_pending() {
|
||||
self.edit_started_by = None;
|
||||
// The input held for an edit applied now is made at the next poll, so what waits for the
|
||||
// edit alone (a `ui.pointer` reply) sees the document as the edit left it.
|
||||
if !applied {
|
||||
ui.extend(self.replay_held_input()?);
|
||||
}
|
||||
}
|
||||
Ok(ui)
|
||||
}
|
||||
|
||||
/// Is the active tool computing an edit on another thread ([`Tool::editing`]), or is input held
|
||||
/// until it is applied?
|
||||
pub fn tool_editing(&self) -> bool {
|
||||
self.tool_edit_pending() || !self.held_input.is_empty()
|
||||
}
|
||||
|
||||
/// Waits for the edit the active tool computes on another thread and applies it
|
||||
/// ([`Tool::finish_edit`]), then makes the input held for it, waiting in turn for an edit that
|
||||
/// starts, so what runs next sees the document as the gestures left it. Commands, saves, option
|
||||
/// changes, interactions, undo scopes and tool or document switches call it first. Returns
|
||||
/// requests for the UI, with those left from earlier ([`Session::finish_tool_edit_later`]); on
|
||||
/// an error, the next [`Session::tool_poll`] returns them.
|
||||
pub fn finish_tool_edit(&mut self) -> Result<Vec<UiRequest>> {
|
||||
if self.settling_tool_edit {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let mut ui = std::mem::take(&mut self.held_ui);
|
||||
if self.tool_editing() {
|
||||
self.settling_tool_edit = true;
|
||||
let r = self.finish_tool_edit_now();
|
||||
self.settling_tool_edit = false;
|
||||
match r {
|
||||
Ok(r) => ui.extend(r),
|
||||
Err(e) => {
|
||||
ui.append(&mut self.held_ui);
|
||||
self.held_ui = ui;
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(ui)
|
||||
}
|
||||
|
||||
/// [`Session::finish_tool_edit`] for a caller that cannot hand requests to the UI (a command, a
|
||||
/// tool switch): the next [`Session::tool_poll`] returns them, and [`Session::tool_waiting`]
|
||||
/// holds until then.
|
||||
pub(crate) fn finish_tool_edit_later(&mut self) -> Result<()> {
|
||||
let ui = self.finish_tool_edit()?;
|
||||
self.held_ui.extend(ui);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn finish_tool_edit_now(&mut self) -> Result<Vec<UiRequest>> {
|
||||
let mut ui = vec![];
|
||||
let mut failed = None;
|
||||
loop {
|
||||
match self.finish_pending_edit() {
|
||||
Ok(r) => ui.extend(r),
|
||||
Err(e) => failed = failed.or(Some(e)),
|
||||
}
|
||||
// A tool that cannot finish its edit keeps the input held.
|
||||
if self.tool_edit_pending() {
|
||||
break;
|
||||
}
|
||||
self.edit_started_by = None;
|
||||
if self.held_input.is_empty() {
|
||||
break;
|
||||
}
|
||||
match self.replay_held_input() {
|
||||
Ok(r) => ui.extend(r),
|
||||
Err(e) => failed = failed.or(Some(e)),
|
||||
}
|
||||
}
|
||||
match failed {
|
||||
None => Ok(ui),
|
||||
Some(e) => {
|
||||
self.held_ui.extend(ui);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Waits for the edit being computed and applies it: the active tool's, or that of the tool
|
||||
/// Cmd lent the selection tool's drag away from, which is active meanwhile.
|
||||
fn finish_pending_edit(&mut self) -> Result<Vec<UiRequest>> {
|
||||
if self.tool.editing() {
|
||||
let acts = self.with_tool_cx(self.last_view, |t, cx| t.finish_edit(cx));
|
||||
self.take_tool_panic()?;
|
||||
return self.apply_actions(acts);
|
||||
}
|
||||
let Some(lender) = self.lender.take_if(|t| t.editing()) else { return Ok(vec![]) };
|
||||
let lent = std::mem::replace(&mut self.tool, lender);
|
||||
let acts = self.with_tool_cx(self.last_view, |t, cx| t.finish_edit(cx));
|
||||
let r = self.take_tool_panic().and_then(|()| self.apply_actions(acts));
|
||||
self.lender = Some(std::mem::replace(&mut self.tool, lent));
|
||||
r
|
||||
}
|
||||
|
||||
/// A key for the active tool. While the tool computes an edit on another thread, the key is held
|
||||
/// and made once the edit is applied, after the input held before it.
|
||||
pub fn tool_key(&mut self, key: ToolKey, mods: Mods, view: ViewInfo) -> Result<Vec<UiRequest>> {
|
||||
let n = self.next_input();
|
||||
if self.tool_editing() {
|
||||
self.held_input.push_back((n, HeldInput::Key(key, mods), view));
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let r = self.tool_key_now(key, mods, view);
|
||||
self.note_edit_start(n);
|
||||
r
|
||||
}
|
||||
|
||||
fn tool_key_now(&mut self, key: ToolKey, mods: Mods, view: ViewInfo) -> Result<Vec<UiRequest>> {
|
||||
// 1–4 pick the active perspective plane while the grid is shown.
|
||||
if let Some(plane) = self.plane_key(key) {
|
||||
self.execute("perspective.plane.set", &serde_json::json!({ "plane": plane.id() }))?;
|
||||
@@ -837,7 +1093,8 @@ impl Session {
|
||||
/// it is on, makes the drawing tools' a crosshair, [`Cursor::precise`]). With Cmd held, the pointer of the selection tool a
|
||||
/// press would borrow ([`Self::lend_selection_tool`]).
|
||||
pub fn cursor(&mut self, p: Point, mods: Mods, view: ViewInfo) -> Cursor {
|
||||
let lent = if mods.cmd && self.lender.is_none() { self.lent_tool(self.tool.id(), &mods) } else { None };
|
||||
// While an edit is computed, a Cmd press is held for the active tool, which shows it is busy.
|
||||
let lent = if mods.cmd && self.lender.is_none() && !self.tool_editing() { self.lent_tool(self.tool.id(), &mods) } else { None };
|
||||
let c = match lent {
|
||||
Some(id) => {
|
||||
let lent = self.make_tool(id);
|
||||
@@ -860,6 +1117,9 @@ impl Session {
|
||||
self.tool.options()
|
||||
}
|
||||
pub fn set_tool_option(&mut self, key: &str, v: &Value) {
|
||||
// The active tool's edit being computed is applied with the options it was started with. A
|
||||
// failure to apply it was the edit's own, and the option is set all the same.
|
||||
let _ = self.finish_tool_edit_later();
|
||||
if key == "queue" && self.tool.id() == "place" {
|
||||
self.note_place_queue_roots();
|
||||
}
|
||||
@@ -934,6 +1194,10 @@ impl Session {
|
||||
{
|
||||
return Err(format!("unknown tool `{t}`"));
|
||||
}
|
||||
// The active tool's merge being computed is applied with the options it was started with.
|
||||
if tool.is_none_or(|t| t == self.tool.id()) {
|
||||
self.finish_tool_edit_later().map_err(|e| e.to_string())?;
|
||||
}
|
||||
let mut values = p.get("values").and_then(Value::as_object).cloned().unwrap_or_default();
|
||||
if let Some(k) = p.get("key").and_then(Value::as_str) {
|
||||
values.insert(k.to_string(), p.get("value").cloned().unwrap_or(Value::Null));
|
||||
|
||||
@@ -219,6 +219,56 @@ fn every_command_with_junk_params() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Each document's uid, revision and selected ids, the active document and the current paint.
|
||||
type ToolEditState = (Vec<(u64, u64, Vec<u64>)>, Option<usize>, vectorcraft_tools::PaintDefaults);
|
||||
|
||||
/// What a command that runs while the active tool computes an edit on another thread must leave
|
||||
/// as it was: every document's revision and selection, which document is active, and the current
|
||||
/// paint.
|
||||
fn tool_edit_state(s: &Session) -> ToolEditState {
|
||||
let docs = s.documents().iter().map(|d| (d.uid, d.revision, d.selection.objects.iter().map(|i| i.0).collect())).collect();
|
||||
(docs, s.active_index(), s.paint.clone())
|
||||
}
|
||||
|
||||
/// Every command that runs at once while the active tool computes an edit on another thread (a
|
||||
/// Shape Builder merge), rather than applying the edit first (`waits_for_tool_edit`), leaves the
|
||||
/// documents' revisions and selections, the active document and the current paint as they were,
|
||||
/// with `{}`, junk params and params that turn its options on and off: the edit, computed for the
|
||||
/// document as it was, still applies after it.
|
||||
#[test]
|
||||
fn commands_that_do_not_wait_for_a_tool_edit_change_no_document() {
|
||||
let path = safe_path();
|
||||
let mut failures = vec![];
|
||||
for fx in [Fixture::Single, Fixture::Multi] {
|
||||
for c in command_specs().iter().filter(|c| !vectorcraft_engine::waits_for_tool_edit(c.id)) {
|
||||
let mut cases = junk_params(c.params, &path);
|
||||
if heavy(c.id) {
|
||||
cases.truncate(12);
|
||||
}
|
||||
for k in param_keys(c.params) {
|
||||
for v in [json!(true), json!(false), json!(1), json!("on")] {
|
||||
cases.push(json!({ k.as_str(): v }));
|
||||
}
|
||||
}
|
||||
// Find Objects selects only with a filter, which one key at a time does not give it.
|
||||
if c.id == "document.find" {
|
||||
cases.extend([json!({"kind": "Path", "select": true}), json!({"name": "no such name", "select": true})]);
|
||||
}
|
||||
for p in &cases {
|
||||
let mut s = fx.session();
|
||||
let before = tool_edit_state(&s);
|
||||
let _ = catch_quiet(|| no_panic(s.execute(c.id, p)));
|
||||
if tool_edit_state(&s) != before {
|
||||
failures.push(format!("{} {p} [{fx:?}]", c.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
failures.sort();
|
||||
failures.dedup_by(|a, b| a.split(' ').next() == b.split(' ').next());
|
||||
assert!(failures.is_empty(), "{} commands change a document without waiting for a tool edit:\n{}", failures.len(), failures.join("\n"));
|
||||
}
|
||||
|
||||
/// Junk inside the obvious structured params: ids arrays, anchors, matrices, colours, stops.
|
||||
#[test]
|
||||
fn structured_junk() {
|
||||
|
||||
@@ -173,6 +173,8 @@ impl Headless {
|
||||
UiRequest::Place(p) => {
|
||||
self.session.execute("file.place", &p).map_err(|e| e.to_string())?;
|
||||
}
|
||||
// The host keeps its view's artboard on a press, as for the presses it hands over.
|
||||
UiRequest::HeldPress { .. } => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -204,10 +206,11 @@ impl Headless {
|
||||
};
|
||||
let reqs = self.session.pointer(&ev, self.view).map_err(|e| e.to_string())?;
|
||||
self.apply_ui_requests(reqs, &mut requests)?;
|
||||
// With no frames to show the highlight in later, a hover waits for the work the tool
|
||||
// runs on another thread.
|
||||
if kind == PointerKind::Move && self.session.tool_waiting() {
|
||||
self.session.tool_poll(self.view, true).map_err(|e| e.to_string())?;
|
||||
// With no frames to show the highlight or apply an edit in later, each event waits for
|
||||
// the work the tool runs on another thread (a hover's map, a release's merge).
|
||||
if self.session.tool_waiting() {
|
||||
let reqs = self.session.tool_poll(self.view, true).map_err(|e| e.to_string())?;
|
||||
self.apply_ui_requests(reqs, &mut requests)?;
|
||||
}
|
||||
let hold = PointerEvent::json_hold(e);
|
||||
if hold > 0.0 {
|
||||
|
||||
@@ -498,6 +498,15 @@ fn headless_hover_waits_for_the_shape_builder_map() {
|
||||
assert!(!r.is_error, "{r:?}");
|
||||
assert!(!h.session.tool_waiting());
|
||||
assert!(!h.session.overlays(h.view).is_empty(), "the region under the pointer is highlighted");
|
||||
// A click's reply comes once the merge the tool computes on another thread is applied, with
|
||||
// the selection after it.
|
||||
let click = json!({"events": [{"kind": "down", "x": 115, "y": 110}, {"kind": "up", "x": 115, "y": 110}]});
|
||||
let r = crate::call_tool(&mut h, "pointer_gesture", &click);
|
||||
assert!(!r.is_error, "{r:?}");
|
||||
assert!(!h.session.tool_waiting() && !h.session.tool_editing());
|
||||
let text: String = r.content.iter().filter_map(|c| c["text"].as_str()).collect();
|
||||
let reply: Value = serde_json::from_str(&text).unwrap();
|
||||
assert_eq!(reply["selection"].as_array().map(Vec::len), Some(41), "the overlap and the 40 squares, two of them cut: {reply}");
|
||||
}
|
||||
|
||||
/// inspect_document reads back the paint type's characters show, as add_text and set_paint give it.
|
||||
|
||||
+261
-16
@@ -32,7 +32,7 @@ mod live_paint;
|
||||
mod lookup;
|
||||
mod map;
|
||||
|
||||
use cache::RegionCache;
|
||||
use cache::{BACKGROUND_SEGMENTS, CacheKey, RegionCache};
|
||||
#[cfg(test)]
|
||||
use live_paint::LpHover;
|
||||
use live_paint::lp_color;
|
||||
@@ -45,7 +45,10 @@ pub use lookup::{
|
||||
is_live_paint, leaf_shapes, live_paint_at, live_paint_takes, node_outline, outline_snap, path_contains, shape_builder_shapes, shapes_for,
|
||||
sources, visible_edge_near, visible_face_at,
|
||||
};
|
||||
pub use map::{BuilderMap, HATCH, HIGHLIGHT_RED, Hit, Touched, hatch, maps_built, sample_polyline, sorted_roots};
|
||||
pub use map::{
|
||||
BuilderMap, HATCH, HIGHLIGHT_RED, Hit, MergeGeometry, MergeInput, PreparedMerge, Rest, Touched, hatch, maps_built, merge_geometry, merge_inputs,
|
||||
merges_computed, sample_polyline, sorted_roots,
|
||||
};
|
||||
/// Small, Medium and Large gap lengths in points.
|
||||
pub const GAP_LENGTHS: [f64; 3] = [3.0, 6.0, 12.0];
|
||||
|
||||
@@ -169,6 +172,52 @@ pub struct ShapeBuilderTool {
|
||||
/// The pointer went more than [`CLICK_TOL_PX`] from the press during the drag, so the gesture
|
||||
/// is not a click.
|
||||
far: bool,
|
||||
/// The merge of the last gesture over a large selection, being computed on another thread.
|
||||
merging: Option<PendingMerge>,
|
||||
/// That merge once computed, for `shapeBuilder.merge` ([`Tool::shape_builder_merge`]).
|
||||
prepared: Option<Arc<PreparedMerge>>,
|
||||
/// A gesture made while the map of a large selection is being built on another thread.
|
||||
deferred: Option<Deferred>,
|
||||
}
|
||||
|
||||
/// A Shape Builder gesture made while the map of the selection was being built on another
|
||||
/// thread: the map key and the inputs it was made over, its pointer events, which [`Tool::poll`]
|
||||
/// replays once the map is there, and whether the release has come.
|
||||
struct Deferred {
|
||||
key: CacheKey,
|
||||
shapes: Vec<po::Shape>,
|
||||
events: Vec<PointerEvent>,
|
||||
released: bool,
|
||||
}
|
||||
|
||||
/// A Shape Builder merge being computed on another thread: the map key it is for (the document,
|
||||
/// its revision and the selection), the `shapeBuilder.merge` params that apply it, what it was
|
||||
/// computed from, and the channel the result arrives on.
|
||||
struct PendingMerge {
|
||||
key: CacheKey,
|
||||
params: Value,
|
||||
map: Arc<BuilderMap>,
|
||||
touched: Touched,
|
||||
gap: f64,
|
||||
inputs: Vec<MergeInput>,
|
||||
done: Receiver<MergeGeometry>,
|
||||
}
|
||||
|
||||
/// Computes [`merge_geometry`] on a new thread; None when no thread starts.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn spawn_merge(map: Arc<BuilderMap>, touched: Touched, gap: f64, inputs: Vec<MergeInput>) -> Option<Receiver<MergeGeometry>> {
|
||||
let (tx, done) = std::sync::mpsc::channel();
|
||||
let spawned = std::thread::Builder::new().name("vectorcraft-shape-builder-merge".into()).spawn(move || {
|
||||
// The tool may have dropped the receiver meanwhile (another tool, another document).
|
||||
let _ = tx.send(merge_geometry(&map, &touched, gap, &inputs));
|
||||
});
|
||||
spawned.is_ok().then_some(done)
|
||||
}
|
||||
|
||||
/// The web build has no threads: the merge runs in the command.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn spawn_merge(_map: Arc<BuilderMap>, _touched: Touched, _gap: f64, _inputs: Vec<MergeInput>) -> Option<Receiver<MergeGeometry>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// How close (screen px) the pointer must come to a line piece or an edge to delete it.
|
||||
@@ -213,6 +262,132 @@ impl ShapeBuilderTool {
|
||||
alt.then(|| cx.tol(LINE_TOL_PX))
|
||||
}
|
||||
|
||||
/// Starts computing the merge `params` describe on another thread when the map of the
|
||||
/// selection is current and has more than [`BACKGROUND_SEGMENTS`] segments; `false` when it
|
||||
/// did not (small selections, the web build), and the command computes it.
|
||||
fn start_merge(&mut self, cx: &ToolContext, params: &Value) -> bool {
|
||||
let key = RegionCache::key_for(cx, self.opts.open_filled_as_closed);
|
||||
if cfg!(target_arch = "wasm32") || params.get("splitStroke").is_some() || self.cache.key.as_ref() != Some(&key) {
|
||||
return false;
|
||||
}
|
||||
let map = Arc::clone(&self.cache.map);
|
||||
let segments: usize = map.shapes.iter().map(|s| s.path.subpaths.iter().map(|sp| sp.segment_count()).sum::<usize>()).sum();
|
||||
if segments <= BACKGROUND_SEGMENTS {
|
||||
return false;
|
||||
}
|
||||
// What the command reads from its params, read the same way.
|
||||
let num = |v: &Value| v.as_f64().unwrap_or(f64::NAN);
|
||||
let pts: Vec<Point> = params["points"].as_array().map(|a| a.iter().map(|q| Point::new(num(&q[0]), num(&q[1]))).collect()).unwrap_or_default();
|
||||
let marquee = params["rect"].as_array().filter(|r| r.len() == 4).map(|r| {
|
||||
let (x, y) = (num(&r[0]), num(&r[1]));
|
||||
(Point::new(x, y), Point::new(x + num(&r[2]), y + num(&r[3])))
|
||||
});
|
||||
let piece_tol = (params["erase"] == json!(true)).then(|| params["tolerance"].as_f64().unwrap_or(2.0).clamp(0.0, 1000.0));
|
||||
let touched = map.merge_touched(&pts, marquee, piece_tol);
|
||||
if touched.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let roots = sorted_roots(cx.doc, &cx.selection.objects);
|
||||
let (_, leaves) = shape_builder_shapes(cx.doc, &roots, self.opts.open_filled_as_closed);
|
||||
let inputs = merge_inputs(&leaves, self.opts.open_filled_as_closed);
|
||||
let gap = self.gap();
|
||||
let Some(done) = spawn_merge(Arc::clone(&map), touched.clone(), gap, inputs.clone()) else { return false };
|
||||
self.merging = Some(PendingMerge { key, params: params.clone(), map, touched, gap, inputs, done });
|
||||
true
|
||||
}
|
||||
|
||||
/// Takes the merge being computed on another thread once it has arrived, or with `wait` once
|
||||
/// it arrives: the action that applies it, while the document, the selection and its inputs are
|
||||
/// those it was computed for. When one of them changed (an edit or a selection made other than
|
||||
/// by a command, which applies the merge first), the merge is dropped at once, without waiting
|
||||
/// for the thread. A copy of the document (a save stamping its dates) and a new revision that leaves
|
||||
/// the inputs as they were keep the merge. When the thread failed, the command computes the
|
||||
/// merge and reports what went wrong.
|
||||
fn poll_merge(&mut self, cx: &ToolContext, wait: bool) -> Vec<Action> {
|
||||
let Some(mut m) = self.merging.take() else { return vec![] };
|
||||
let now = RegionCache::key_for(cx, m.key.3);
|
||||
if (now.1.0, &now.2) != (m.key.1.0, &m.key.2) {
|
||||
return vec![];
|
||||
}
|
||||
// A new revision that leaves the selection's inputs as they were (a view setting or a
|
||||
// preference that redraws the document) keeps the merge.
|
||||
if now.1 != m.key.1 {
|
||||
if !self.inputs_are(cx, m.key.3, &m.map.shapes) {
|
||||
return vec![];
|
||||
}
|
||||
m.key = now;
|
||||
}
|
||||
let got = if wait { m.done.recv().map_err(|_| TryRecvError::Disconnected) } else { m.done.try_recv() };
|
||||
let geometry = match got {
|
||||
Ok(g) => Some(g),
|
||||
Err(TryRecvError::Empty) => {
|
||||
self.merging = Some(m);
|
||||
return vec![];
|
||||
}
|
||||
Err(TryRecvError::Disconnected) => None,
|
||||
};
|
||||
self.prepared = geometry.map(|geometry| Arc::new(PreparedMerge { map: m.map, touched: m.touched, gap: m.gap, inputs: m.inputs, geometry }));
|
||||
vec![Action::Exec("shapeBuilder.merge".into(), m.params)]
|
||||
}
|
||||
|
||||
/// Is the map of the selection being built on another thread (it is not current yet)?
|
||||
fn map_building(&mut self, cx: &ToolContext) -> bool {
|
||||
self.cache.ready(cx, self.opts.open_filled_as_closed).is_none() && self.cache.pending.is_some()
|
||||
}
|
||||
|
||||
/// Are the inputs of the selection in `cx` `shapes`? The map, or the map being built, is filed
|
||||
/// under the key of `cx` first when they are its inputs ([`RegionCache::rekey`]).
|
||||
fn inputs_are(&mut self, cx: &ToolContext, open_filled_as_closed: bool, shapes: &[po::Shape]) -> bool {
|
||||
let _ = self.cache.rekey(cx, open_filled_as_closed);
|
||||
let key = RegionCache::key_for(cx, open_filled_as_closed);
|
||||
if self.cache.key.as_ref() == Some(&key) {
|
||||
return self.cache.shapes == shapes;
|
||||
}
|
||||
if let Some(p) = self.cache.pending.as_ref().filter(|p| p.key == key) {
|
||||
return p.shapes == shapes;
|
||||
}
|
||||
let roots = sorted_roots(cx.doc, &cx.selection.objects);
|
||||
shape_builder_shapes(cx.doc, &roots, open_filled_as_closed).0 == shapes
|
||||
}
|
||||
|
||||
/// Is a gesture made while the map was being built released and waiting for the map?
|
||||
fn deferred_released(&self) -> bool {
|
||||
self.deferred.as_ref().is_some_and(|d| d.released)
|
||||
}
|
||||
|
||||
/// Replays the released gesture made while the map was being built once the map is there, or
|
||||
/// with `wait` once it is built, and with `wait` also waits for the merge it starts: the
|
||||
/// actions the gesture gives. When the document, the selection or its inputs changed since the
|
||||
/// gesture, it is dropped; a new revision that leaves them as they were keeps it.
|
||||
fn replay_deferred(&mut self, cx: &ToolContext, wait: bool) -> Vec<Action> {
|
||||
if !self.deferred_released() {
|
||||
return vec![];
|
||||
}
|
||||
let ofac = self.opts.open_filled_as_closed;
|
||||
let now = RegionCache::key_for(cx, ofac);
|
||||
if let Some(mut d) = self.deferred.take() {
|
||||
if (d.key.1.0, &d.key.2) != (now.1.0, &now.2) || (d.key.1 != now.1 && !self.inputs_are(cx, ofac, &d.shapes)) {
|
||||
return vec![];
|
||||
}
|
||||
d.key = now;
|
||||
self.deferred = Some(d);
|
||||
}
|
||||
if wait {
|
||||
self.cache.get(cx, self.opts.open_filled_as_closed);
|
||||
} else if self.map_building(cx) {
|
||||
return vec![];
|
||||
}
|
||||
let Some(d) = self.deferred.take() else { return vec![] };
|
||||
let mut acts = vec![];
|
||||
for ev in &d.events {
|
||||
acts.extend(self.pointer(cx, ev));
|
||||
}
|
||||
if wait {
|
||||
acts.extend(self.poll_merge(cx, true));
|
||||
}
|
||||
acts
|
||||
}
|
||||
|
||||
/// Adds what the drag from `from` to `to` touches, picked as `shapeBuilder.merge` picks it.
|
||||
fn touch(&mut self, cx: &ToolContext, from: Option<Point>, to: Point) {
|
||||
let pts: Vec<Point> = from.into_iter().chain([to]).collect();
|
||||
@@ -274,6 +449,8 @@ impl Tool for ShapeBuilderTool {
|
||||
|
||||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||||
let p = ev.pos;
|
||||
// The command has taken the merge computed for the last gesture by now.
|
||||
self.prepared = None;
|
||||
match ev.kind {
|
||||
PointerKind::Move => {
|
||||
self.alt = ev.mods.alt;
|
||||
@@ -282,7 +459,8 @@ impl Tool for ShapeBuilderTool {
|
||||
vec![]
|
||||
}
|
||||
PointerKind::Down => {
|
||||
if cx.selection.is_empty() {
|
||||
// While a merge is being computed, a press starts no gesture.
|
||||
if cx.selection.is_empty() || self.editing() {
|
||||
return vec![];
|
||||
}
|
||||
self.dragging = true;
|
||||
@@ -291,11 +469,35 @@ impl Tool for ShapeBuilderTool {
|
||||
self.points = vec![p];
|
||||
self.far = false;
|
||||
self.touched = Touched::default();
|
||||
// While the map is being built on another thread, the gesture is recorded and
|
||||
// replayed once the map is there.
|
||||
if self.map_building(cx) {
|
||||
let key = RegionCache::key_for(cx, self.opts.open_filled_as_closed);
|
||||
let shapes = match self.cache.pending.as_ref().filter(|p| p.key == key) {
|
||||
Some(p) => p.shapes.clone(),
|
||||
None => self.cache.inputs(cx, self.opts.open_filled_as_closed).0,
|
||||
};
|
||||
self.deferred = Some(Deferred { key, shapes, events: vec![*ev], released: false });
|
||||
return vec![];
|
||||
}
|
||||
self.touch(cx, None, p);
|
||||
vec![]
|
||||
}
|
||||
PointerKind::Drag if self.dragging => {
|
||||
self.at = Some(p);
|
||||
if let Some(d) = &mut self.deferred {
|
||||
// The drag's line or marquee shows meanwhile.
|
||||
d.events.push(*ev);
|
||||
if let Some((a, _)) = self.marquee {
|
||||
self.marquee = Some((a, p));
|
||||
} else if self.opts.straight_line {
|
||||
self.points.truncate(1);
|
||||
self.points.push(p);
|
||||
} else if self.points.last().is_none_or(|q| q.distance(p) >= cx.tol(1.0)) {
|
||||
self.points.push(p);
|
||||
}
|
||||
return vec![];
|
||||
}
|
||||
if self.points.first().is_some_and(|s| s.distance(p) > cx.tol(CLICK_TOL_PX)) {
|
||||
self.far = true;
|
||||
}
|
||||
@@ -316,6 +518,13 @@ impl Tool for ShapeBuilderTool {
|
||||
}
|
||||
PointerKind::Up if self.dragging => {
|
||||
self.dragging = false;
|
||||
if let Some(d) = &mut self.deferred {
|
||||
d.events.push(*ev);
|
||||
d.released = true;
|
||||
self.points.clear();
|
||||
self.marquee = None;
|
||||
return vec![];
|
||||
}
|
||||
if self.marquee.is_some() {
|
||||
self.marquee_to(cx, p);
|
||||
} else if self.opts.straight_line {
|
||||
@@ -361,6 +570,10 @@ impl Tool for ShapeBuilderTool {
|
||||
if self.opts.swatches {
|
||||
params["pickColorFrom"] = json!("swatches");
|
||||
}
|
||||
// A large selection's merge is computed on another thread; `poll` applies it.
|
||||
if self.start_merge(cx, ¶ms) {
|
||||
return vec![];
|
||||
}
|
||||
vec![Action::Exec("shapeBuilder.merge".into(), params)]
|
||||
}
|
||||
_ => vec![],
|
||||
@@ -369,10 +582,13 @@ impl Tool for ShapeBuilderTool {
|
||||
|
||||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
let mut out = vec![];
|
||||
let map = &self.cache.map;
|
||||
let color = if self.alt { HIGHLIGHT_RED } else { HATCH };
|
||||
let show: Vec<Hit> = if self.dragging {
|
||||
let t = &self.touched;
|
||||
// While a merge is being computed, what its gesture touched stays highlighted.
|
||||
let map = self.merging.as_ref().map_or(&self.cache.map, |m| &m.map);
|
||||
// The mode of the gesture being merged, else the drag's or the hover's.
|
||||
let alt = self.merging.as_ref().filter(|_| !self.dragging).map_or(self.alt, |m| m.params["erase"] == json!(true));
|
||||
let color = if alt { HIGHLIGHT_RED } else { HATCH };
|
||||
let touched = if self.dragging { Some(&self.touched) } else { self.merging.as_ref().map(|m| &m.touched) };
|
||||
let show: Vec<Hit> = if let Some(t) = touched {
|
||||
let regions = t.regions.iter().map(|&i| Hit::Region(i));
|
||||
regions.chain(t.lines.iter().map(|&i| Hit::Line(i))).chain(map.erased_edges(t).into_iter().map(Hit::Edge)).collect()
|
||||
} else {
|
||||
@@ -381,7 +597,7 @@ impl Tool for ShapeBuilderTool {
|
||||
for hit in show {
|
||||
match hit {
|
||||
// Highlight Fill off: merge mode leaves the regions as they are.
|
||||
Hit::Region(_) if !self.alt && !self.opts.highlight_fill => {}
|
||||
Hit::Region(_) if !alt && !self.opts.highlight_fill => {}
|
||||
Hit::Region(i) => {
|
||||
if let Some((_, bp, _)) = map.regions.get(i) {
|
||||
out.push(Overlay::Path { path: hatch(bp, cx.tol(4.0)), color, width: 1.0, dashed: false });
|
||||
@@ -424,7 +640,10 @@ impl Tool for ShapeBuilderTool {
|
||||
fn cursor(&self, _cx: &ToolContext, _p: Point, m: Mods) -> Cursor {
|
||||
// A drag keeps the mode it started in; hovering follows the key.
|
||||
let erase = if self.dragging { self.alt } else { m.alt };
|
||||
if erase {
|
||||
if self.editing() {
|
||||
// The merge of the last gesture, or the map it waits for, is being computed.
|
||||
Cursor::Busy
|
||||
} else if erase {
|
||||
Cursor::ShapeBuilderErase
|
||||
} else if self.stroke.is_some() && !self.dragging {
|
||||
// Over a stroke a click splits.
|
||||
@@ -439,30 +658,53 @@ impl Tool for ShapeBuilderTool {
|
||||
}
|
||||
|
||||
fn waiting(&self) -> bool {
|
||||
self.cache.waiting()
|
||||
self.cache.waiting() || self.merging.is_some() || self.deferred.is_some()
|
||||
}
|
||||
|
||||
fn poll(&mut self, cx: &ToolContext, wait: bool) {
|
||||
if !self.cache.waiting() || self.dragging {
|
||||
return;
|
||||
fn editing(&self) -> bool {
|
||||
self.merging.is_some() || self.deferred_released()
|
||||
}
|
||||
|
||||
fn finish_edit(&mut self, cx: &ToolContext) -> Vec<Action> {
|
||||
let mut acts = self.replay_deferred(cx, true);
|
||||
acts.extend(self.poll_merge(cx, true));
|
||||
acts
|
||||
}
|
||||
|
||||
/// Choosing another tool or another document applies the merge being computed first.
|
||||
fn deactivate(&mut self, cx: &ToolContext) -> Vec<Action> {
|
||||
self.finish_edit(cx)
|
||||
}
|
||||
|
||||
fn poll(&mut self, cx: &ToolContext, wait: bool) -> Vec<Action> {
|
||||
let mut acts = self.poll_merge(cx, wait);
|
||||
if !acts.is_empty() || self.dragging {
|
||||
return acts;
|
||||
}
|
||||
// The map that arrives may be for an earlier revision; the hover then starts the current
|
||||
// one's, which `wait` waits for too.
|
||||
loop {
|
||||
while self.cache.waiting() {
|
||||
self.cache.settle(wait);
|
||||
if let Some(p) = self.at {
|
||||
self.hover_at(cx, p);
|
||||
}
|
||||
if !wait || !self.cache.waiting() {
|
||||
if !wait {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// A gesture made while the map was being built goes on once it is there.
|
||||
acts.extend(self.replay_deferred(cx, wait));
|
||||
acts
|
||||
}
|
||||
|
||||
fn shape_builder_map(&self) -> Option<(Arc<BuilderMap>, f64)> {
|
||||
self.cache.key.is_some().then(|| (Arc::clone(&self.cache.map), self.cache.gap))
|
||||
}
|
||||
|
||||
fn shape_builder_merge(&self) -> Option<Arc<PreparedMerge>> {
|
||||
self.prepared.clone()
|
||||
}
|
||||
|
||||
/// With Pick Color From Color Swatches, the Left and Right arrow keys pick the color swatch
|
||||
/// before or after the current fill. Escape during a drag cancels it: the release after it
|
||||
/// merges and deletes nothing.
|
||||
@@ -471,13 +713,16 @@ impl Tool for ShapeBuilderTool {
|
||||
}
|
||||
|
||||
fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
|
||||
if !self.claims_key(cx, key) {
|
||||
// While a merge is being computed, the arrows leave the current fill it takes as it was.
|
||||
if !self.claims_key(cx, key) || self.editing() {
|
||||
return vec![];
|
||||
}
|
||||
if key == ToolKey::Escape {
|
||||
self.dragging = false;
|
||||
self.points.clear();
|
||||
self.touched = Touched::default();
|
||||
// A gesture recorded while the map is being built is canceled with it.
|
||||
self.deferred = None;
|
||||
(self.marquee, self.hover, self.far) = (None, None, false);
|
||||
return vec![];
|
||||
}
|
||||
|
||||
@@ -8,18 +8,21 @@ pub(super) type CacheKey = (usize, (u64, u64), Vec<NodeId>, bool);
|
||||
|
||||
/// Hovering builds the map of inputs with more segments than this on another thread
|
||||
/// ([`RegionCache::ready`]); smaller maps are built on the calling thread.
|
||||
const BACKGROUND_SEGMENTS: usize = 128;
|
||||
pub(super) const BACKGROUND_SEGMENTS: usize = 128;
|
||||
|
||||
/// A map being built on another thread: the key it is for, the drawn outlines of its inputs, and
|
||||
/// the channel it arrives on.
|
||||
struct Pending {
|
||||
key: CacheKey,
|
||||
/// A map being built on another thread: the key it is for, its inputs and their drawn outlines,
|
||||
/// and the channel it arrives on.
|
||||
pub(super) struct Pending {
|
||||
pub(super) key: CacheKey,
|
||||
pub(super) shapes: Vec<po::Shape>,
|
||||
outlines: Vec<PathData>,
|
||||
done: Receiver<BuilderMap>,
|
||||
}
|
||||
|
||||
/// The planar map of the current selection, cached by document identity, revision, selection and
|
||||
/// whether filled open paths count as closed.
|
||||
/// whether filled open paths count as closed. When only the document's identity or revision
|
||||
/// changed and the selection's inputs did not (a view setting, a preference, a copy of the
|
||||
/// document), the map is kept under the new key ([`RegionCache::rekey`]).
|
||||
#[derive(Default)]
|
||||
pub(super) struct RegionCache {
|
||||
pub(super) key: Option<CacheKey>,
|
||||
@@ -31,8 +34,12 @@ pub(super) struct RegionCache {
|
||||
pub(super) gap: f64,
|
||||
/// The outlines of the Shape Builder's inputs as they are drawn (see [`BuilderMap::stroke_at`]).
|
||||
pub(super) outlines: Vec<PathData>,
|
||||
/// The inputs the map was built from.
|
||||
pub(super) shapes: Vec<po::Shape>,
|
||||
/// The last key whose inputs [`Self::rekey`] compared with the map's.
|
||||
compared: Option<CacheKey>,
|
||||
/// The map being built on another thread, if any.
|
||||
pending: Option<Pending>,
|
||||
pub(super) pending: Option<Pending>,
|
||||
/// The key of the last map whose build on another thread failed.
|
||||
failed: Option<CacheKey>,
|
||||
/// How many times the map has been built.
|
||||
@@ -76,13 +83,13 @@ impl RegionCache {
|
||||
Self { live: true, ..Default::default() }
|
||||
}
|
||||
|
||||
fn key_for(cx: &ToolContext, open_filled_as_closed: bool) -> CacheKey {
|
||||
pub(super) fn key_for(cx: &ToolContext, open_filled_as_closed: bool) -> CacheKey {
|
||||
(cx.doc as *const Document as usize, cx.revision, cx.selection.objects.clone(), open_filled_as_closed)
|
||||
}
|
||||
|
||||
/// The inputs of the selection's map, back → front, and their drawn outlines (Shape Builder
|
||||
/// only).
|
||||
fn inputs(&self, cx: &ToolContext, open_filled_as_closed: bool) -> (Vec<po::Shape>, Vec<PathData>) {
|
||||
pub(super) fn inputs(&self, cx: &ToolContext, open_filled_as_closed: bool) -> (Vec<po::Shape>, Vec<PathData>) {
|
||||
let roots = sorted_roots(cx.doc, &cx.selection.objects);
|
||||
if self.live {
|
||||
(shapes_for(cx.doc, &roots).0, vec![])
|
||||
@@ -93,8 +100,9 @@ impl RegionCache {
|
||||
}
|
||||
}
|
||||
|
||||
fn install(&mut self, key: CacheKey, map: BuilderMap, outlines: Vec<PathData>) {
|
||||
fn install(&mut self, key: CacheKey, map: BuilderMap, shapes: Vec<po::Shape>, outlines: Vec<PathData>) {
|
||||
self.map = Arc::new(map);
|
||||
self.shapes = shapes;
|
||||
self.outlines = outlines;
|
||||
self.key = Some(key);
|
||||
#[cfg(test)]
|
||||
@@ -122,8 +130,12 @@ impl RegionCache {
|
||||
pub(super) fn get(&mut self, cx: &ToolContext, open_filled_as_closed: bool) -> &BuilderMap {
|
||||
let key = Self::key_for(cx, open_filled_as_closed);
|
||||
if self.key.as_ref() != Some(&key) {
|
||||
let built = self.pending.take().filter(|p| p.key == key).and_then(|p| Some((p.done.recv().ok()?, p.outlines)));
|
||||
let (map, outlines) = match built {
|
||||
let inputs = self.rekey(cx, open_filled_as_closed);
|
||||
if self.key.as_ref() == Some(&key) {
|
||||
return &self.map;
|
||||
}
|
||||
let built = self.pending.take().filter(|p| p.key == key).and_then(|p| Some((p.done.recv().ok()?, p.shapes, p.outlines)));
|
||||
let (map, shapes, outlines) = match built {
|
||||
Some(b) => {
|
||||
#[cfg(test)]
|
||||
{
|
||||
@@ -132,15 +144,45 @@ impl RegionCache {
|
||||
b
|
||||
}
|
||||
None => {
|
||||
let (shapes, outlines) = self.inputs(cx, open_filled_as_closed);
|
||||
(build_map(shapes, self.live, self.gap), outlines)
|
||||
let (shapes, outlines) = inputs.unwrap_or_else(|| self.inputs(cx, open_filled_as_closed));
|
||||
(build_map(shapes.clone(), self.live, self.gap), shapes, outlines)
|
||||
}
|
||||
};
|
||||
self.install(key, map, outlines);
|
||||
self.install(key, map, shapes, outlines);
|
||||
}
|
||||
&self.map
|
||||
}
|
||||
|
||||
/// When the map, or the map being built, is for the selection of `cx` but for another revision
|
||||
/// or address of the document, and the selection's inputs and their outlines are those it is
|
||||
/// made from, files it under the key of `cx`: nothing it is made from changed. Returns the
|
||||
/// inputs it computed when that did not file a map (they changed), for the caller to build
|
||||
/// from.
|
||||
pub(super) fn rekey(&mut self, cx: &ToolContext, open_filled_as_closed: bool) -> Option<(Vec<po::Shape>, Vec<PathData>)> {
|
||||
let key = Self::key_for(cx, open_filled_as_closed);
|
||||
let other = |k: &CacheKey| *k != key && (&k.2, k.3) == (&key.2, key.3);
|
||||
if self.pending.as_ref().is_some_and(|p| p.key == key) {
|
||||
return None;
|
||||
}
|
||||
let installed = self.key.as_ref().is_some_and(other);
|
||||
let building = self.pending.as_ref().is_some_and(|p| other(&p.key));
|
||||
// The inputs are compared once for each key.
|
||||
if (!installed && !building) || self.compared.as_ref() == Some(&key) {
|
||||
return None;
|
||||
}
|
||||
self.compared = Some(key.clone());
|
||||
let (shapes, outlines) = self.inputs(cx, open_filled_as_closed);
|
||||
if installed && self.shapes == shapes && self.outlines == outlines {
|
||||
self.key = Some(key);
|
||||
return None;
|
||||
}
|
||||
if let Some(p) = self.pending.as_mut().filter(|p| other(&p.key) && p.shapes == shapes && p.outlines == outlines) {
|
||||
p.key = key;
|
||||
return None;
|
||||
}
|
||||
Some((shapes, outlines))
|
||||
}
|
||||
|
||||
/// Takes the map being built on another thread once it has arrived, or with `wait` once it
|
||||
/// arrives, as the map for the key it was built for.
|
||||
pub(super) fn settle(&mut self, wait: bool) {
|
||||
@@ -152,7 +194,7 @@ impl RegionCache {
|
||||
{
|
||||
self.background += 1;
|
||||
}
|
||||
self.install(p.key, map, p.outlines);
|
||||
self.install(p.key, map, p.shapes, p.outlines);
|
||||
}
|
||||
// The build failed (a bug in the planar map): hovering does not build it again, and a
|
||||
// gesture builds it on the calling thread, which reports the failure.
|
||||
@@ -171,21 +213,25 @@ impl RegionCache {
|
||||
if self.key.as_ref() == Some(&key) {
|
||||
return Some(&self.map);
|
||||
}
|
||||
let inputs = self.rekey(cx, open_filled_as_closed);
|
||||
if self.key.as_ref() == Some(&key) {
|
||||
return Some(&self.map);
|
||||
}
|
||||
if self.pending.is_some() || self.failed.as_ref() == Some(&key) {
|
||||
return None;
|
||||
}
|
||||
let (shapes, outlines) = self.inputs(cx, open_filled_as_closed);
|
||||
let (shapes, outlines) = inputs.unwrap_or_else(|| self.inputs(cx, open_filled_as_closed));
|
||||
let segments: usize = shapes.iter().map(|s| s.path.subpaths.iter().map(|sp| sp.segment_count()).sum::<usize>()).sum();
|
||||
if segments > BACKGROUND_SEGMENTS && cfg!(not(target_arch = "wasm32")) {
|
||||
if let Some(done) = spawn_build(shapes, self.live, self.gap) {
|
||||
self.pending = Some(Pending { key, outlines, done });
|
||||
if let Some(done) = spawn_build(shapes.clone(), self.live, self.gap) {
|
||||
self.pending = Some(Pending { key, shapes, outlines, done });
|
||||
return None;
|
||||
}
|
||||
// No thread started: build here.
|
||||
return Some(self.get(cx, open_filled_as_closed));
|
||||
}
|
||||
let map = build_map(shapes, self.live, self.gap);
|
||||
self.install(key, map, outlines);
|
||||
let map = build_map(shapes.clone(), self.live, self.gap);
|
||||
self.install(key, map, shapes, outlines);
|
||||
Some(&self.map)
|
||||
}
|
||||
|
||||
|
||||
@@ -282,9 +282,9 @@ impl Tool for LivePaintBucketTool {
|
||||
self.cache.waiting()
|
||||
}
|
||||
|
||||
fn poll(&mut self, cx: &ToolContext, wait: bool) {
|
||||
fn poll(&mut self, cx: &ToolContext, wait: bool) -> Vec<Action> {
|
||||
if !self.cache.waiting() {
|
||||
return;
|
||||
return vec![];
|
||||
}
|
||||
// As the Shape Builder's: the map that arrives may be for an earlier revision. Over a part
|
||||
// of a Live Paint group the hover takes no map.
|
||||
@@ -297,6 +297,7 @@ impl Tool for LivePaintBucketTool {
|
||||
break;
|
||||
}
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -284,4 +284,6 @@ pub(super) fn faces_of(regions: Vec<po::Region>) -> Vec<Face> {
|
||||
thread_local! {
|
||||
/// The Shape Builder maps built on this thread ([`maps_built`]).
|
||||
pub(super) static MAPS_BUILT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
/// The Shape Builder merges computed on this thread ([`merges_computed`]).
|
||||
pub(super) static MERGES_COMPUTED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::lookup::{Face, MAPS_BUILT, faces_of, outline_snap, shape_builder_shapes};
|
||||
use super::lookup::{Face, MAPS_BUILT, MERGES_COMPUTED, faces_of, node_outline, outline_snap, shape_builder_shapes};
|
||||
use super::*;
|
||||
|
||||
/// Shape Builder hover hatch colour (neutral grey) and Live Paint highlight red.
|
||||
@@ -12,6 +12,12 @@ pub fn maps_built() -> usize {
|
||||
MAPS_BUILT.with(std::cell::Cell::get)
|
||||
}
|
||||
|
||||
/// How many Shape Builder merges ([`merge_geometry`]) this thread has computed, for tests that
|
||||
/// count them.
|
||||
pub fn merges_computed() -> usize {
|
||||
MERGES_COMPUTED.with(std::cell::Cell::get)
|
||||
}
|
||||
|
||||
/// The Shape Builder's planar map of a selection.
|
||||
#[derive(Default)]
|
||||
pub struct BuilderMap {
|
||||
@@ -345,6 +351,112 @@ impl BuilderMap {
|
||||
let bounds_one = |(e, _): &(po::Shape, Rect)| self.sides(e).iter().any(|r| r.is_some_and(|r| touched.regions.contains(&r)));
|
||||
touched.edges.iter().copied().filter(|&k| self.edges.get(k).is_some_and(|e| !bounds_one(e))).collect()
|
||||
}
|
||||
|
||||
/// What `shapeBuilder.merge` touches: the regions the marquee from `marquee.0` to `marquee.1`
|
||||
/// reaches, then what the drag along `pts` touches ([`Self::builder_hits_along`]), and the
|
||||
/// edges that deletes ([`Self::erased_edges`]).
|
||||
pub fn merge_touched(&self, pts: &[Point], marquee: Option<(Point, Point)>, piece_tol: Option<f64>) -> Touched {
|
||||
let mut touched = Touched { regions: marquee.map(|(a, b)| self.regions_in_marquee(a, b)).unwrap_or_default(), ..Default::default() };
|
||||
touched.extend(&self.builder_hits_along(pts, piece_tol));
|
||||
touched.edges = self.erased_edges(&touched);
|
||||
touched
|
||||
}
|
||||
}
|
||||
|
||||
/// What a merge needs of each input besides the map: its outline as drawn ([`node_outline`]) and
|
||||
/// whether its fill closes its open subpaths (Consider Open Filled Paths as Closed, and a fill).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct MergeInput {
|
||||
pub outline: Option<PathData>,
|
||||
pub fill_closes: bool,
|
||||
}
|
||||
|
||||
/// The [`MergeInput`] of each of `leaves`.
|
||||
pub fn merge_inputs(leaves: &[&Node], open_filled_as_closed: bool) -> Vec<MergeInput> {
|
||||
leaves
|
||||
.iter()
|
||||
.map(|l| MergeInput { outline: node_outline(l).map(|o| o.0), fill_closes: open_filled_as_closed && !l.appearance.fill_paint().is_none() })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// What is left of an input a merge or a delete changes: its faces not touched, united
|
||||
/// (`united`), or its own outline, with the deleted edges cut out of it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Rest {
|
||||
pub path: PathData,
|
||||
pub united: bool,
|
||||
}
|
||||
|
||||
/// The new shapes of a Shape Builder merge or delete ([`merge_geometry`]).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct MergeGeometry {
|
||||
/// The union of the touched regions (empty when none is touched).
|
||||
pub union: PathData,
|
||||
/// For each closed input, what goes in its place when the gesture changes it.
|
||||
pub rests: Vec<Option<Rest>>,
|
||||
}
|
||||
|
||||
/// The new shapes of a gesture over `map` that touched `touched`, closing gaps up to `gap`: the
|
||||
/// union of the touched regions, and for each closed input that has a touched region or a deleted
|
||||
/// edge, what is left of it. An input a touched region covers keeps its other faces, united
|
||||
/// (they share their edges with the merged faces exactly, where subtracting the merged shape left
|
||||
/// slivers of the outline along them, #569; a sliver goes in only where the input covers it and
|
||||
/// the merged shape does not take it in); then each deleted edge is cut out of it. Inputs with
|
||||
/// pieces of open paths (`map.lines`) are left to the caller.
|
||||
pub fn merge_geometry(map: &BuilderMap, touched: &Touched, gap: f64, inputs: &[MergeInput]) -> MergeGeometry {
|
||||
MERGES_COMPUTED.with(|n| n.set(n.get() + 1));
|
||||
let cuts: Vec<&SubPath> = touched.edges.iter().filter_map(|&k| map.edges.get(k)?.0.path.subpaths.first()).collect();
|
||||
let picked: Vec<&po::Region> = touched.regions.iter().filter_map(|&i| map.regions.get(i).map(|r| &r.0)).collect();
|
||||
let faces = po::FaceMerger::with_gap(&map.shapes, gap);
|
||||
let union = if picked.is_empty() { PathData::default() } else { faces.merge(&picked) };
|
||||
let rests = map
|
||||
.shapes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, sh)| {
|
||||
let input = inputs.get(i)?;
|
||||
let in_union = picked.iter().any(|r| r.sources.contains(&i));
|
||||
if map.lines.iter().any(|(l, _)| l.key == i as u64) || (!in_union && cuts.is_empty()) {
|
||||
return None;
|
||||
}
|
||||
let mut rest = if in_union {
|
||||
let kept: Vec<&po::Region> = map
|
||||
.regions
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(k, (r, ..))| r.sources.contains(&i) && !touched.regions.contains(k))
|
||||
.map(|(_, (r, ..))| r)
|
||||
.collect();
|
||||
if kept.is_empty() { PathData::default() } else { faces.merge_rest(i, &kept, &picked) }
|
||||
} else {
|
||||
input.outline.clone().unwrap_or_else(|| sh.path.clone())
|
||||
};
|
||||
// Edges come back from the planar map within a few hundredths of the outlines they
|
||||
// follow, whatever else is selected.
|
||||
let cut_tol = outline_snap(&sh.path);
|
||||
let mut cut = false;
|
||||
for piece in &cuts {
|
||||
if let Some(r) = po::cut_out(&rest, piece, input.fill_closes, cut_tol) {
|
||||
rest = r;
|
||||
cut = true;
|
||||
}
|
||||
}
|
||||
(in_union || cut).then_some(Rest { path: rest, united: in_union })
|
||||
})
|
||||
.collect();
|
||||
MergeGeometry { union, rests }
|
||||
}
|
||||
|
||||
/// A merge computed on another thread for a Shape Builder click or drag
|
||||
/// ([`Tool::shape_builder_merge`]): the map, what the gesture touched and the inputs it was
|
||||
/// computed from, and the result. `shapeBuilder.merge` takes the result when it would compute the
|
||||
/// same.
|
||||
pub struct PreparedMerge {
|
||||
pub map: Arc<BuilderMap>,
|
||||
pub touched: Touched,
|
||||
pub gap: f64,
|
||||
pub inputs: Vec<MergeInput>,
|
||||
pub geometry: MergeGeometry,
|
||||
}
|
||||
|
||||
/// The regions, line pieces and edges a Shape Builder gesture has touched, in the order touched.
|
||||
|
||||
@@ -132,20 +132,34 @@ fn shape_builder_builds_its_map_once_for_hover_alt_hover_and_a_merge() {
|
||||
// The merge left the document as it was (it failed, say): the map still fits it.
|
||||
t.pointer(&cx1, &ev(PointerKind::Move, 75.0, 75.0));
|
||||
assert_eq!(t.cache.builds, 1, "a hover after a merge that changed nothing");
|
||||
// A new revision of the same art (a view setting, a preference) keeps the map.
|
||||
let cx2 = ToolContext { revision: (1, 2), ..cx(&d, &s, &p) };
|
||||
t.pointer(&cx2, &ev(PointerKind::Move, 75.0, 75.0));
|
||||
assert_eq!(t.cache.builds, 2, "a hover on a new revision");
|
||||
assert_eq!(t.cache.builds, 1, "a hover on a new revision of the same art");
|
||||
assert!(matches!(t.hover, Some(Hit::Region(_))), "{:?}", t.hover);
|
||||
// The same objects, one of them moved.
|
||||
let mut moved = d.clone();
|
||||
let second = s.objects[1];
|
||||
moved.node_mut(second).unwrap().transform(vectorcraft_geom::Affine::translate((10.0, 0.0)), false);
|
||||
let cx3 = ToolContext { revision: (1, 3), ..cx(&moved, &s, &p) };
|
||||
t.pointer(&cx3, &ev(PointerKind::Move, 75.0, 75.0));
|
||||
assert_eq!(t.cache.builds, 2, "a hover on a new revision of other art");
|
||||
}
|
||||
|
||||
/// A row of `n` selected squares 20 pt wide, each overlapping the next by half: 4 segments each.
|
||||
fn square_row(n: usize) -> (Document, Selection) {
|
||||
square_row_at(n, 0.0)
|
||||
}
|
||||
|
||||
/// [`square_row`] with the squares' tops at `y`: the same objects, moved.
|
||||
fn square_row_at(n: usize, y: f64) -> (Document, Selection) {
|
||||
let mut d = Document::new(500.0, 500.0);
|
||||
let l = d.layers[0].id;
|
||||
let mut s = Selection::default();
|
||||
for k in 0..n {
|
||||
let x = k as f64 * 10.0;
|
||||
let id = d.alloc_id();
|
||||
d.insert(Some(l), k, Node::path(id, shapes::rectangle(Rect::new(x, 0.0, x + 20.0, 20.0)), Appearance::default_art())).unwrap();
|
||||
d.insert(Some(l), k, Node::path(id, shapes::rectangle(Rect::new(x, y, x + 20.0, y + 20.0)), Appearance::default_art())).unwrap();
|
||||
s.add(id);
|
||||
}
|
||||
(d, s)
|
||||
@@ -176,9 +190,10 @@ fn shape_builder_hover_builds_a_large_map_on_another_thread() {
|
||||
assert_eq!((t.cache.builds, t.cache.background), (1, 1));
|
||||
assert_eq!(t.overlays(&cx1).len(), 2, "the region's hatch and outline");
|
||||
// A new revision while the map of the one before it is being built: only the newer one's
|
||||
// map is used.
|
||||
let cx2 = ToolContext { revision: (1, 2), ..cx(&d, &s, &p) };
|
||||
let cx3 = ToolContext { revision: (1, 3), ..cx(&d, &s, &p) };
|
||||
// map is used. Each revision moves the squares down 1 pt.
|
||||
let rows: Vec<Document> = (0..7).map(|k| square_row_at(40, f64::from(k)).0).collect();
|
||||
let cx2 = ToolContext { revision: (1, 2), ..cx(&rows[2], &s, &p) };
|
||||
let cx3 = ToolContext { revision: (1, 3), ..cx(&rows[3], &s, &p) };
|
||||
t.pointer(&cx2, &hover);
|
||||
t.pointer(&cx3, &hover);
|
||||
poll_until_ready(&mut t, &cx3);
|
||||
@@ -186,29 +201,265 @@ fn shape_builder_hover_builds_a_large_map_on_another_thread() {
|
||||
assert_eq!((t.cache.builds, t.cache.background), (3, 3), "the earlier revision's map, then the newer one's");
|
||||
assert_eq!(t.cache.key.as_ref().map(|k| k.1), Some((1, 3)));
|
||||
// A host without frames waits for the map.
|
||||
let cx4 = ToolContext { revision: (1, 4), ..cx(&d, &s, &p) };
|
||||
let cx4 = ToolContext { revision: (1, 4), ..cx(&rows[4], &s, &p) };
|
||||
t.pointer(&cx4, &hover);
|
||||
assert!(t.waiting() && t.hover.is_none());
|
||||
t.poll(&cx4, true);
|
||||
assert!(!t.waiting());
|
||||
assert!(matches!(t.hover, Some(Hit::Region(_))), "{:?}", t.hover);
|
||||
assert_eq!((t.cache.builds, t.cache.background), (4, 4));
|
||||
// A press while the map is being built waits for it, and the drag merges.
|
||||
let cx5 = ToolContext { revision: (1, 5), ..cx(&d, &s, &p) };
|
||||
// A drag while the map is being built is replayed once it is there, and merges (on
|
||||
// another thread, see `shape_builder_merges_a_large_selection_on_another_thread` and
|
||||
// `shape_builder_gesture_waits_for_the_map_without_holding_the_caller`).
|
||||
let cx5 = ToolContext { revision: (1, 5), ..cx(&rows[5], &s, &p) };
|
||||
t.pointer(&cx5, &hover);
|
||||
t.pointer(&cx5, &ev(PointerKind::Down, 15.0, 10.0));
|
||||
t.pointer(&cx5, &ev(PointerKind::Drag, 35.0, 10.0));
|
||||
let acts = t.pointer(&cx5, &ev(PointerKind::Up, 35.0, 10.0));
|
||||
assert!(matches!(acts.as_slice(), [Action::Exec(id, _)] if id == "shapeBuilder.merge"), "{acts:?}");
|
||||
assert!(t.pointer(&cx5, &ev(PointerKind::Up, 35.0, 10.0)).is_empty());
|
||||
let acts = t.poll(&cx5, true);
|
||||
assert_eq!((t.cache.builds, t.cache.background), (5, 5));
|
||||
assert!(matches!(acts.as_slice(), [Action::Exec(id, _)] if id == "shapeBuilder.merge"), "{acts:?}");
|
||||
assert!(!t.waiting());
|
||||
// Changing an option forgets a map being built with the old one.
|
||||
let cx6 = ToolContext { revision: (1, 6), ..cx(&d, &s, &p) };
|
||||
let cx6 = ToolContext { revision: (1, 6), ..cx(&rows[6], &s, &p) };
|
||||
t.pointer(&cx6, &hover);
|
||||
t.set_option("gapDetection", &json!(true));
|
||||
assert!(!t.waiting());
|
||||
}
|
||||
|
||||
/// Polls `t` until it stops waiting, as the desktop app does each frame, and returns the
|
||||
/// actions the polls gave.
|
||||
fn poll_actions(t: &mut dyn Tool, cx: &ToolContext) -> Vec<Action> {
|
||||
let start = std::time::Instant::now();
|
||||
let mut acts = vec![];
|
||||
while t.waiting() {
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(60), "the tool is still waiting");
|
||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||
acts.extend(t.poll(cx, false));
|
||||
}
|
||||
acts
|
||||
}
|
||||
|
||||
/// A Shape Builder over `square_row(40)` (160 segments) that has merged nothing, with its map,
|
||||
/// after a click at (15, 10) that started a merge on another thread.
|
||||
fn clicked_row(cx: &ToolContext) -> ShapeBuilderTool {
|
||||
let mut t = ShapeBuilderTool::default();
|
||||
t.pointer(cx, &ev(PointerKind::Move, 15.0, 10.0));
|
||||
poll_until_ready(&mut t, cx);
|
||||
t.pointer(cx, &ev(PointerKind::Down, 15.0, 10.0));
|
||||
assert!(t.pointer(cx, &ev(PointerKind::Up, 15.0, 10.0)).is_empty(), "the release returns before the merge is computed");
|
||||
assert!(t.waiting() && t.editing());
|
||||
t
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_builder_merges_a_large_selection_on_another_thread() {
|
||||
let (d, s) = square_row(40);
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let computed = merges_computed();
|
||||
let mut t = clicked_row(&cx);
|
||||
assert_eq!(t.cursor(&cx, Point::new(15.0, 10.0), Mods::default()), Cursor::Busy);
|
||||
assert_eq!(t.overlays(&cx).len(), 2, "the clicked region's hatch and outline stay");
|
||||
// A press while the merge is being computed starts no gesture.
|
||||
assert!(t.pointer(&cx, &ev(PointerKind::Down, 35.0, 10.0)).is_empty());
|
||||
assert!(!t.busy());
|
||||
assert!(t.pointer(&cx, &ev(PointerKind::Up, 35.0, 10.0)).is_empty());
|
||||
let acts = poll_actions(&mut t, &cx);
|
||||
assert_eq!(merges_computed(), computed, "the merge was computed on another thread");
|
||||
let [Action::Exec(id, params)] = acts.as_slice() else { panic!("{acts:?}") };
|
||||
assert_eq!((id.as_str(), ¶ms["points"]), ("shapeBuilder.merge", &json!([[15.0, 10.0]])));
|
||||
assert!(!t.waiting() && !t.editing());
|
||||
assert_eq!(t.cursor(&cx, Point::new(15.0, 10.0), Mods::default()), Cursor::ShapeBuilder);
|
||||
let m = t.shape_builder_merge().expect("the merge the command takes");
|
||||
assert!(Arc::ptr_eq(&m.map, &t.cache.map));
|
||||
assert_eq!((m.touched.regions.len(), m.touched.lines.len(), m.touched.edges.len()), (1, 0, 0));
|
||||
assert!(!m.geometry.union.is_empty());
|
||||
assert_eq!(m.geometry, merge_geometry(&m.map, &m.touched, m.gap, &m.inputs));
|
||||
assert_eq!(m.geometry.rests.iter().flatten().count(), 2, "the two squares over the region change");
|
||||
// The next event lets it go.
|
||||
t.pointer(&cx, &ev(PointerKind::Move, 15.0, 10.0));
|
||||
assert!(t.shape_builder_merge().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_builder_drops_a_merge_the_art_changed_under() {
|
||||
let (d, s) = square_row(40);
|
||||
let p = paint();
|
||||
let mut fewer = Selection::default();
|
||||
fewer.set(s.objects.iter().skip(1).copied());
|
||||
let cx1 = ToolContext { revision: (1, 1), ..cx(&d, &s, &p) };
|
||||
let (moved, _) = square_row_at(40, 5.0);
|
||||
let later = [
|
||||
// An edit or an undo: the squares moved.
|
||||
ToolContext { revision: (1, 2), ..cx(&moved, &s, &p) },
|
||||
// Another document.
|
||||
ToolContext { revision: (2, 1), ..cx(&d, &s, &p) },
|
||||
// Another selection.
|
||||
ToolContext { revision: (1, 1), ..cx(&d, &fewer, &p) },
|
||||
];
|
||||
for (k, cx2) in later.iter().enumerate() {
|
||||
let mut t = clicked_row(&cx1);
|
||||
// Dropped at the first poll, before the thread is done.
|
||||
assert!(t.poll(cx2, false).is_empty(), "change {k}: the merge is not applied");
|
||||
assert!(!t.editing() && !t.waiting() && t.shape_builder_merge().is_none(), "change {k}");
|
||||
}
|
||||
// Unchanged, the same wait applies it.
|
||||
let mut t = clicked_row(&cx1);
|
||||
assert_eq!(t.poll(&cx1, true).len(), 1);
|
||||
// A copy of the document at the same revision (a save stamps its dates on a copy), and
|
||||
// finishing the edit before a command, apply it too.
|
||||
let copy = d.clone();
|
||||
let cx3 = ToolContext { revision: (1, 1), ..cx(©, &s, &p) };
|
||||
let mut t = clicked_row(&cx1);
|
||||
assert_eq!(t.finish_edit(&cx3).len(), 1);
|
||||
assert!(!t.editing() && t.shape_builder_merge().is_some());
|
||||
// Switching away applies it.
|
||||
let mut t = clicked_row(&cx1);
|
||||
assert_eq!(t.deactivate(&cx1).len(), 1);
|
||||
assert!(!t.editing());
|
||||
}
|
||||
|
||||
/// A new revision of the document that leaves the selection's inputs as they were (a view
|
||||
/// setting or a preference that redraws it) keeps the merge being computed, the gesture made
|
||||
/// while the map is being built and the map itself: the merge applies with the map it was
|
||||
/// computed over, and no map is built again.
|
||||
#[test]
|
||||
fn shape_builder_keeps_its_work_over_a_new_revision_of_the_same_art() {
|
||||
let (d, s) = square_row(40);
|
||||
let p = paint();
|
||||
let cx1 = ToolContext { revision: (1, 1), ..cx(&d, &s, &p) };
|
||||
let cx2 = ToolContext { revision: (1, 2), ..cx(&d, &s, &p) };
|
||||
// A merge being computed.
|
||||
let computed = merges_computed();
|
||||
let mut t = clicked_row(&cx1);
|
||||
let acts = poll_actions(&mut t, &cx2);
|
||||
assert!(matches!(acts.as_slice(), [Action::Exec(id, _)] if id == "shapeBuilder.merge"), "{acts:?}");
|
||||
assert_eq!(merges_computed(), computed, "the merge computed on another thread");
|
||||
let m = t.shape_builder_merge().expect("the merge the command takes");
|
||||
assert!(Arc::ptr_eq(&m.map, &t.cache.map), "the map the merge was computed over");
|
||||
assert_eq!(t.cache.key.as_ref().map(|k| k.1), Some((1, 2)));
|
||||
assert_eq!(t.cache.builds, 1, "no map built again");
|
||||
// Finishing it before a command too.
|
||||
let mut t = clicked_row(&cx1);
|
||||
assert_eq!(t.finish_edit(&cx2).len(), 1);
|
||||
// A hover over the new revision builds no map.
|
||||
let mut t = ShapeBuilderTool::default();
|
||||
let hover = ev(PointerKind::Move, 15.0, 10.0);
|
||||
t.pointer(&cx1, &hover);
|
||||
poll_until_ready(&mut t, &cx1);
|
||||
t.pointer(&cx2, &hover);
|
||||
assert!(!t.waiting() && matches!(t.hover, Some(Hit::Region(_))), "{:?}", t.hover);
|
||||
assert_eq!(t.cache.builds, 1);
|
||||
// A map being built, and a gesture made meanwhile.
|
||||
let drag = [ev(PointerKind::Down, 15.0, 10.0), ev(PointerKind::Drag, 35.0, 10.0), ev(PointerKind::Up, 35.0, 10.0)];
|
||||
let mut ready = ShapeBuilderTool::default();
|
||||
ready.pointer(&cx1, &hover);
|
||||
poll_until_ready(&mut ready, &cx1);
|
||||
for e in &drag {
|
||||
ready.pointer(&cx1, e);
|
||||
}
|
||||
let want = poll_actions(&mut ready, &cx1);
|
||||
let mut t = ShapeBuilderTool::default();
|
||||
t.pointer(&cx1, &hover);
|
||||
for e in &drag {
|
||||
assert!(t.pointer(&cx1, e).is_empty());
|
||||
}
|
||||
assert!(t.editing() && t.cache.builds == 0, "the gesture waits for the map");
|
||||
assert_eq!(poll_actions(&mut t, &cx2), want);
|
||||
assert_eq!((t.cache.builds, t.cache.background), (1, 1), "the map built for the first revision");
|
||||
// Finishing it before a command waits for the map being built rather than building it again.
|
||||
let mut t = ShapeBuilderTool::default();
|
||||
t.pointer(&cx1, &hover);
|
||||
for e in &drag {
|
||||
t.pointer(&cx1, e);
|
||||
}
|
||||
assert_eq!(t.finish_edit(&cx2), want);
|
||||
assert_eq!((t.cache.builds, t.cache.background), (1, 1), "the map built on the other thread");
|
||||
// Another document with the same art drops the merge.
|
||||
let cx3 = ToolContext { revision: (2, 1), ..cx(&d, &s, &p) };
|
||||
let mut t = clicked_row(&cx1);
|
||||
assert!(t.poll(&cx3, false).is_empty());
|
||||
assert!(!t.editing());
|
||||
}
|
||||
|
||||
/// A press while the map of a large selection is being built on another thread does not wait
|
||||
/// for it: the gesture is recorded, each event returns at once, and the polls replay it once
|
||||
/// the map is there, giving the merge the same gesture gives over the map. Finishing the edit
|
||||
/// waits for the map and the merge; another revision of other art drops the gesture.
|
||||
#[test]
|
||||
fn shape_builder_gesture_waits_for_the_map_without_holding_the_caller() {
|
||||
let (d, s) = square_row(200);
|
||||
let p = paint();
|
||||
let cx1 = ToolContext { revision: (1, 1), ..cx(&d, &s, &p) };
|
||||
let hover = ev(PointerKind::Move, 15.0, 10.0);
|
||||
let drag = [ev(PointerKind::Down, 15.0, 10.0), ev(PointerKind::Drag, 35.0, 10.0), ev(PointerKind::Up, 35.0, 10.0)];
|
||||
let mut ready = ShapeBuilderTool::default();
|
||||
ready.pointer(&cx1, &hover);
|
||||
poll_until_ready(&mut ready, &cx1);
|
||||
for e in &drag {
|
||||
ready.pointer(&cx1, e);
|
||||
}
|
||||
let want = poll_actions(&mut ready, &cx1);
|
||||
assert!(matches!(want.as_slice(), [Action::Exec(id, _)] if id == "shapeBuilder.merge"), "{want:?}");
|
||||
let made_while_building = || {
|
||||
let mut t = ShapeBuilderTool::default();
|
||||
t.pointer(&cx1, &hover);
|
||||
for e in &drag {
|
||||
assert!(t.pointer(&cx1, e).is_empty());
|
||||
}
|
||||
assert_eq!(t.cache.builds, 0, "the gesture did not wait for the map");
|
||||
assert!(t.editing() && t.waiting());
|
||||
t
|
||||
};
|
||||
let mut t = made_while_building();
|
||||
assert_eq!(t.cursor(&cx1, Point::new(35.0, 10.0), Mods::default()), Cursor::Busy);
|
||||
// A press before the gesture is replayed starts nothing.
|
||||
assert!(t.pointer(&cx1, &ev(PointerKind::Down, 55.0, 10.0)).is_empty());
|
||||
assert!(t.pointer(&cx1, &ev(PointerKind::Up, 55.0, 10.0)).is_empty());
|
||||
assert_eq!(poll_actions(&mut t, &cx1), want);
|
||||
assert!(!t.editing() && t.shape_builder_merge().is_some());
|
||||
// Finishing the edit before a command waits for both.
|
||||
let mut t = made_while_building();
|
||||
assert_eq!(t.finish_edit(&cx1), want);
|
||||
// Another revision of other art before the map is there drops the gesture.
|
||||
let mut t = made_while_building();
|
||||
let (moved, _) = square_row_at(200, 5.0);
|
||||
let cx2 = ToolContext { revision: (1, 2), ..cx(&moved, &s, &p) };
|
||||
assert!(t.poll(&cx2, false).is_empty());
|
||||
assert!(!t.editing());
|
||||
}
|
||||
|
||||
/// Escape during a drag made while the map of a large selection is being built cancels the
|
||||
/// recorded gesture too: the release and the polls after it merge nothing, the tool stops
|
||||
/// waiting once the map is there, and the next drag merges on its own.
|
||||
#[test]
|
||||
fn shape_builder_escape_cancels_a_gesture_made_while_the_map_is_built() {
|
||||
let (d, s) = square_row(200);
|
||||
let p = paint();
|
||||
let cx1 = ToolContext { revision: (1, 1), ..cx(&d, &s, &p) };
|
||||
let mut t = ShapeBuilderTool::default();
|
||||
t.pointer(&cx1, &ev(PointerKind::Move, 15.0, 10.0));
|
||||
assert!(t.pointer(&cx1, &ev(PointerKind::Down, 15.0, 10.0)).is_empty());
|
||||
assert!(t.pointer(&cx1, &ev(PointerKind::Drag, 35.0, 10.0)).is_empty());
|
||||
assert_eq!(t.cache.builds, 0, "the gesture did not wait for the map");
|
||||
assert!(t.claims_key(&cx1, ToolKey::Escape));
|
||||
assert!(t.key(&cx1, ToolKey::Escape, Mods::default()).is_empty());
|
||||
assert!(t.pointer(&cx1, &ev(PointerKind::Up, 35.0, 10.0)).is_empty());
|
||||
assert!(poll_actions(&mut t, &cx1).is_empty(), "the canceled gesture merges nothing");
|
||||
assert!(!t.waiting() && !t.editing());
|
||||
// The next drag merges what it touches, with no event of the canceled one.
|
||||
let mut fresh = ShapeBuilderTool::default();
|
||||
fresh.pointer(&cx1, &ev(PointerKind::Move, 55.0, 10.0));
|
||||
poll_until_ready(&mut fresh, &cx1);
|
||||
let next = [ev(PointerKind::Down, 55.0, 10.0), ev(PointerKind::Drag, 75.0, 10.0), ev(PointerKind::Up, 75.0, 10.0)];
|
||||
for e in &next {
|
||||
fresh.pointer(&cx1, e);
|
||||
t.pointer(&cx1, e);
|
||||
}
|
||||
assert_eq!(poll_actions(&mut t, &cx1), poll_actions(&mut fresh, &cx1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_builder_hover_builds_a_small_map_at_once() {
|
||||
let (d, s) = square_row(3);
|
||||
@@ -1110,9 +1361,15 @@ fn bucket_keeps_its_map_while_the_document_is_unchanged() {
|
||||
// The fill left the document as it was: the map still fits it.
|
||||
t.pointer(&cx1, &ev(PointerKind::Move, 25.0, 50.0));
|
||||
assert_eq!(t.cache.builds, 1, "a hover after a fill that changed nothing");
|
||||
// A new revision of the same art keeps the map; one with an object moved builds it again.
|
||||
let cx2 = ToolContext { revision: (1, 2), ..cx(&d, &s, &p) };
|
||||
t.pointer(&cx2, &ev(PointerKind::Move, 25.0, 50.0));
|
||||
assert_eq!(t.cache.builds, 2, "a hover on a new revision");
|
||||
assert_eq!(t.cache.builds, 1, "a hover on a new revision of the same art");
|
||||
let mut moved = d.clone();
|
||||
moved.node_mut(b).unwrap().transform(vectorcraft_geom::Affine::translate((10.0, 0.0)), false);
|
||||
let cx3 = ToolContext { revision: (1, 3), ..cx(&moved, &s, &p) };
|
||||
t.pointer(&cx3, &ev(PointerKind::Move, 25.0, 50.0));
|
||||
assert_eq!(t.cache.builds, 2, "a hover on a new revision of other art");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+26
-6
@@ -267,21 +267,41 @@ pub trait Tool: Send {
|
||||
false
|
||||
}
|
||||
/// Is the tool waiting for work it runs on another thread (the planar map of a large selection
|
||||
/// for the Shape Builder and the Live Paint Bucket)? It highlights no region under the pointer
|
||||
/// until the work is done. While this holds, the desktop app calls [`Tool::poll`] each frame,
|
||||
/// and hosts without frames call it with `wait` after a [`PointerKind::Move`].
|
||||
/// for the Shape Builder and the Live Paint Bucket, a Shape Builder merge of one)? It
|
||||
/// highlights nothing under the pointer until a map is done. While this holds, the desktop app
|
||||
/// calls [`Tool::poll`] each frame, and hosts without frames call it with `wait` after each
|
||||
/// pointer event.
|
||||
fn waiting(&self) -> bool {
|
||||
false
|
||||
}
|
||||
/// Checks on the work [`Tool::waiting`] reports, waiting for it to finish with `wait`. Once it
|
||||
/// is done, the tool shows the highlight under the pointer where it last moved.
|
||||
fn poll(&mut self, _cx: &ToolContext, _wait: bool) {}
|
||||
/// Is the tool computing an edit on another thread, which [`Tool::poll`] applies once it is
|
||||
/// done (a Shape Builder merge of a large selection)?
|
||||
fn editing(&self) -> bool {
|
||||
false
|
||||
}
|
||||
/// Checks on the work [`Tool::waiting`] reports, waiting for it to finish with `wait`. Once a
|
||||
/// map is done, the tool shows the highlight under the pointer where it last moved; once an
|
||||
/// edit is done, the actions returned apply it.
|
||||
fn poll(&mut self, _cx: &ToolContext, _wait: bool) -> Vec<Action> {
|
||||
vec![]
|
||||
}
|
||||
/// Waits for the edit [`Tool::editing`] reports and returns the actions that apply it, before
|
||||
/// a command runs on the document; none when the document's revision or the selection changed
|
||||
/// since the gesture.
|
||||
fn finish_edit(&mut self, _cx: &ToolContext) -> Vec<Action> {
|
||||
vec![]
|
||||
}
|
||||
/// The Shape Builder's planar map of the selection, with the outline edges, and the gap it
|
||||
/// closes, when the tool has one. `shapeBuilder.merge` uses it in place of a map of its own
|
||||
/// when the map's inputs are the merge's inputs.
|
||||
fn shape_builder_map(&self) -> Option<(std::sync::Arc<builder::BuilderMap>, f64)> {
|
||||
None
|
||||
}
|
||||
/// The Shape Builder merge the tool computed on another thread for its last gesture, which
|
||||
/// `shapeBuilder.merge` takes when it would compute the same ([`builder::PreparedMerge`]).
|
||||
fn shape_builder_merge(&self) -> Option<std::sync::Arc<builder::PreparedMerge>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a tool by id. Unknown or not-yet-implemented tools fall back to a no-op tool that keeps the id.
|
||||
|
||||
@@ -110,6 +110,7 @@ impl VectorcraftApp {
|
||||
deferred_control: None,
|
||||
synthetic_dropped_before: 0,
|
||||
pending_screenshots: vec![],
|
||||
pointer_replies: vec![],
|
||||
queued_screenshots: vec![],
|
||||
screenshot_token: 0,
|
||||
screenshot_reserved: 0,
|
||||
@@ -337,8 +338,9 @@ impl VectorcraftApp {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||
}
|
||||
// The window's close button (or the system quitting the app) asks about unsaved documents,
|
||||
// once the saves running in the background are done. Not while a file dialog's sheet is on
|
||||
// the window: what asked for it runs again when it answers.
|
||||
// once the saves running in the background are done and a Shape Builder merge being
|
||||
// computed is applied. Not while a file dialog's sheet is on the window: what asked for it
|
||||
// runs again when it answers.
|
||||
if ctx.input(|i| i.viewport().close_requested()) {
|
||||
// This native viewport is going away: release its IME context. No request is
|
||||
// allocated here: the close prompt or file sheet publishes no custom descriptor, so a
|
||||
@@ -346,7 +348,7 @@ impl VectorcraftApp {
|
||||
// fresh generation on the next pass (never inherits a composition across the prompt).
|
||||
crate::ime::close_viewport(self, ctx);
|
||||
self.ime_context.end_viewport(ctx.viewport_id());
|
||||
background::wait_all(self);
|
||||
unsaved::finish_pending(self);
|
||||
if self.file_sheet_open() {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
||||
self.status(picks::busy());
|
||||
@@ -374,6 +376,7 @@ impl VectorcraftApp {
|
||||
crate::chrome::sync_window_title(self, ctx);
|
||||
crate::chrome::sync_fullscreen(self, ctx);
|
||||
self.drain_control(ctx);
|
||||
self.answer_pointer_replies(ctx);
|
||||
// An egui `PasteImage` with a rich system clipboard: run the paste command now, once. It
|
||||
// goes through `run`, whose `edit.paste` branch calls `adopt_system_clipboard` (which clears
|
||||
// the request) to read the formats and then pastes them. A text paste in the same UI pass
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::fonts::MAX_RECENT_FONTS;
|
||||
use super::{Value, VectorcraftApp, dialogs, fileio, io, menus, panels, picks, toolbar};
|
||||
use super::{Value, VectorcraftApp, canvas, dialogs, fileio, io, menus, panels, picks, toolbar};
|
||||
use crate::placing;
|
||||
use crate::state::ReplaceFontWarning;
|
||||
|
||||
@@ -94,6 +94,17 @@ impl VectorcraftApp {
|
||||
result
|
||||
}
|
||||
|
||||
/// A command that waits for the edit the tool computes on another thread and the input held
|
||||
/// for it waits before the app adds the view's active artboard to its params: a held press
|
||||
/// may change it. The app's own commands (its dialogs, panels and view) wait too.
|
||||
fn wait_for_tool_edit(&mut self, id: &str) {
|
||||
let ui_command = vectorcraft_engine::find_command(id).is_none();
|
||||
if (ui_command || vectorcraft_engine::waits_for_tool_edit(id)) && self.session.tool_waiting() {
|
||||
let r = self.session.finish_tool_edit();
|
||||
canvas::apply_requests(self, r);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::run`] it, inside what asks for file dialogs.
|
||||
fn run_now(&mut self, id: &str, params: Value) -> Result<Value, String> {
|
||||
crate::state::sync_zoom_stops_display_print_size(self.session.prefs.display_print_size);
|
||||
@@ -104,6 +115,7 @@ impl VectorcraftApp {
|
||||
if cfg!(debug_assertions) && !self.session.replaying() {
|
||||
menus::check_params(&self.session, id, ¶ms)?;
|
||||
}
|
||||
self.wait_for_tool_edit(id);
|
||||
// A `.ai` file a command writes (a save, an export, a timestamped copy, a batch's save) names
|
||||
// the view's active artboard.
|
||||
io::remember_active_artboard(self);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::{
|
||||
MAX_PENDING_SCREENSHOTS, MAX_SCREENSHOT_BYTES, MAX_SCREENSHOT_TOTAL_BYTES, PendingScreenshot, QueuedScreenshot, SCREENSHOT_TIMEOUT_MS,
|
||||
VectorcraftApp, file_access, json, now_ms,
|
||||
VectorcraftApp, canvas, file_access, json, now_ms,
|
||||
};
|
||||
use crate::control;
|
||||
|
||||
@@ -112,6 +112,9 @@ impl VectorcraftApp {
|
||||
}
|
||||
}
|
||||
}
|
||||
control::Outcome::AfterToolEdit { from, to, document } => {
|
||||
self.pointer_replies.push(control::PendingPointerReply { from, to, document, reply });
|
||||
}
|
||||
}
|
||||
}
|
||||
if handled == control::MAX_CONTROL_REQUESTS_PER_FRAME {
|
||||
@@ -121,6 +124,38 @@ impl VectorcraftApp {
|
||||
self.control_rx = Some(rx);
|
||||
}
|
||||
|
||||
/// Sends the `ui.pointer` replies that wait for the tool's edit once their events have been
|
||||
/// made and the edit is applied. The canvas checks on the tool each frame it is drawn; this
|
||||
/// checks too, for frames without it.
|
||||
pub(crate) fn answer_pointer_replies(&mut self, ctx: &egui::Context) {
|
||||
// Those whose events the canvas's check made go first, before this check makes the input
|
||||
// held after them.
|
||||
self.send_pointer_replies();
|
||||
if self.pointer_replies.is_empty() {
|
||||
return;
|
||||
}
|
||||
if self.session.tool_editing() {
|
||||
let r = self.session.tool_poll(self.view_info(), false);
|
||||
canvas::apply_requests(self, r);
|
||||
}
|
||||
self.send_pointer_replies();
|
||||
if !self.pointer_replies.is_empty() {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(16));
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends the waiting `ui.pointer` replies whose events have been made.
|
||||
fn send_pointer_replies(&mut self) {
|
||||
for pending in std::mem::take(&mut self.pointer_replies) {
|
||||
let control::PendingPointerReply { from, to, document, reply } = pending;
|
||||
if self.session.input_applied(from, to) {
|
||||
let _ = reply.send(control::pointer_reply_for(self, document));
|
||||
} else {
|
||||
self.pointer_replies.push(control::PendingPointerReply { from, to, document, reply });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The active document's identity: its process-unique uid and revision. A capture records it
|
||||
/// at admission and issuance and refuses to answer with a different document or undo state.
|
||||
pub(super) fn document_identity(&self) -> Option<(u64, u64)> {
|
||||
|
||||
@@ -376,19 +376,29 @@ pub fn dispatch(app: &mut VectorcraftApp, ev: &PointerEvent, view: vectorcraft_e
|
||||
{
|
||||
v.artboard = i;
|
||||
}
|
||||
// A press with a selection tool inside an artboard makes it the active one (#693), as the
|
||||
// Artboard tool's does: Paste in Place and in Front or Back then paste onto it, Align to
|
||||
// Artboard aligns to it, and All on Active Artboard selects on it.
|
||||
if ev.kind == PointerKind::Down
|
||||
&& vectorcraft_tools::catalog::is_selection_tool(app.session.tool_id())
|
||||
&& let Some(i) = app.session.active().and_then(|d| d.doc.artboard_at(ev.pos))
|
||||
&& let Some(v) = app.view_mut()
|
||||
{
|
||||
v.artboard = i;
|
||||
if ev.kind == PointerKind::Down {
|
||||
pressed_with(app, app.session.active_index(), ev.pos, app.session.tool_id());
|
||||
}
|
||||
apply_requests(app, r);
|
||||
}
|
||||
|
||||
/// A press at `pos` in document `index` with tool `tool` (a selection tool) inside an artboard
|
||||
/// makes it that document's active one (#693), as the Artboard tool's does: Paste in Place and in
|
||||
/// Front or Back then paste onto it, Align to Artboard aligns to it, and All on Active Artboard
|
||||
/// selects on it.
|
||||
fn pressed_with(app: &mut VectorcraftApp, index: Option<usize>, pos: Point, tool: &str) {
|
||||
if !vectorcraft_tools::catalog::is_selection_tool(tool) {
|
||||
return;
|
||||
}
|
||||
app.sync_views();
|
||||
if let Some(i) = index
|
||||
&& let Some(board) = app.session.documents().get(i).and_then(|d| d.doc.artboard_at(pos))
|
||||
&& let Some(v) = app.views.get_mut(i)
|
||||
{
|
||||
v.artboard = board;
|
||||
}
|
||||
}
|
||||
|
||||
/// Act on what a tool event asked the UI for (dialogs, tool switches), or show its error.
|
||||
pub fn apply_requests(app: &mut VectorcraftApp, r: vectorcraft_engine::Result<Vec<vectorcraft_engine::UiRequest>>) {
|
||||
match r {
|
||||
@@ -399,6 +409,15 @@ pub fn apply_requests(app: &mut VectorcraftApp, r: vectorcraft_engine::Result<Ve
|
||||
vectorcraft_engine::UiRequest::SwitchTool(t) => app.select_tool(&t),
|
||||
vectorcraft_engine::UiRequest::Status(msg) => app.status(msg),
|
||||
vectorcraft_engine::UiRequest::Place(p) => crate::place::place_from_cursor(app, &p),
|
||||
// In the document the press was made in, which may no longer be the active one.
|
||||
vectorcraft_engine::UiRequest::HeldPress { doc, pos, tool } => {
|
||||
let index = doc.and_then(|uid| app.session.documents().iter().position(|d| d.uid == uid));
|
||||
pressed_with(app, index, pos, tool);
|
||||
// The selection tool Cmd lent has the press now, so its drag can carry art to another tab.
|
||||
if let Some(ctx) = app.ui_ctx.clone() {
|
||||
crate::document_drag::begin(app, &ctx, pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,12 +147,12 @@ pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Respo
|
||||
app.session.tool_hover_ended(view);
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
// The tool works on another thread (a large selection's planar map): it checks each frame
|
||||
// whether the work is done, and then shows the highlight under the pointer.
|
||||
// The tool works on another thread (a large selection's planar map or merge): it checks each
|
||||
// frame whether the work is done, and then shows the highlight under the pointer or applies
|
||||
// the edit.
|
||||
if app.session.tool_waiting() {
|
||||
if let Err(e) = app.session.tool_poll(view, false) {
|
||||
app.status(e.to_string());
|
||||
}
|
||||
let r = app.session.tool_poll(view, false);
|
||||
apply_requests(app, r);
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(16));
|
||||
}
|
||||
if pointer.primary_released()
|
||||
|
||||
@@ -96,6 +96,13 @@ fn reserve_synthetic(app: &mut VectorcraftApp, additional: usize) -> Result<(),
|
||||
|
||||
pub type ControlResponse = Value;
|
||||
|
||||
pub(crate) struct PendingPointerReply {
|
||||
pub from: u64,
|
||||
pub to: u64,
|
||||
pub document: Option<u64>,
|
||||
pub reply: Sender<ControlResponse>,
|
||||
}
|
||||
|
||||
pub struct ControlRequest {
|
||||
pub method: String,
|
||||
pub params: Value,
|
||||
@@ -167,6 +174,34 @@ pub enum Outcome {
|
||||
path: Option<String>,
|
||||
data: bool,
|
||||
},
|
||||
/// A `ui.pointer` reply ([`pointer_reply`]), sent once the events numbered `from..to`
|
||||
/// ([`vectorcraft_engine::Session::input_applied`]) have been made and the edit they started
|
||||
/// on another thread is applied, so it shows the selection after the edit.
|
||||
AfterToolEdit {
|
||||
from: u64,
|
||||
to: u64,
|
||||
document: Option<u64>,
|
||||
},
|
||||
}
|
||||
|
||||
/// The reply to `ui.pointer`: the selection, the key object and the active tool.
|
||||
pub(crate) fn pointer_reply(app: &mut VectorcraftApp) -> Value {
|
||||
pointer_reply_for(app, app.session.active().map(|d| d.uid))
|
||||
}
|
||||
|
||||
pub(crate) fn pointer_reply_for(app: &VectorcraftApp, document: Option<u64>) -> Value {
|
||||
let state = document.and_then(|uid| app.session.documents().iter().find(|d| d.uid == uid));
|
||||
match state {
|
||||
Some(state) => json!({
|
||||
"ok": true,
|
||||
"result": {
|
||||
"selection": state.selection.objects.iter().map(|id| id.0).collect::<Vec<_>>(),
|
||||
"key": state.selection.key.map(|id| id.0),
|
||||
"tool": app.session.tool_id(),
|
||||
}
|
||||
}),
|
||||
None => json!({"ok": false, "error": "the gesture's document is no longer open"}),
|
||||
}
|
||||
}
|
||||
|
||||
fn ok(v: Value) -> Outcome {
|
||||
@@ -580,6 +615,7 @@ fn pointer_events(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRe
|
||||
let base_mods: Mods = p.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or_default();
|
||||
let view = app.view_info();
|
||||
let xf = app.canvas_rect.zip(app.view().copied()).map(|(rect, v)| Xf::new(rect, &v));
|
||||
let from = app.session.input_count();
|
||||
let held =
|
||||
PointerEvent::json_backquote(p, false).and_then(|d| events.iter().map(|e| PointerEvent::json_backquote(e, d)).collect::<Result<Vec<_>, _>>());
|
||||
let held = match held {
|
||||
@@ -620,7 +656,15 @@ fn pointer_events(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRe
|
||||
}
|
||||
}
|
||||
ctx.request_repaint();
|
||||
wrap(app.run("document.inspect", json!({})).map(|d| json!({"selection": d["selection"], "key": d["key"], "tool": app.session.tool_id()})))
|
||||
// A Shape Builder merge of a large selection is computed on another thread, and the
|
||||
// reply to the events that started it waits for it without holding the frames up. A
|
||||
// gesture sent while it is computed is made after it, and its reply waits until then;
|
||||
// a hover is answered at once.
|
||||
let to = app.session.input_count();
|
||||
if !app.session.input_applied(from, to) {
|
||||
return Outcome::AfterToolEdit { from, to, document: app.session.active().map(|d| d.uid) };
|
||||
}
|
||||
Outcome::Done(pointer_reply(app))
|
||||
}
|
||||
|
||||
/// `ui.click` and `ui.drag`: pointer input through egui itself.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
|
||||
|
||||
use super::{ControlRequest, MAX_DIALOG_FIELDS, Outcome, handle};
|
||||
use crate::state::Dialog;
|
||||
@@ -11,6 +12,27 @@ fn input_request(app: &mut VectorcraftApp, method: &str, params: Value) -> Value
|
||||
r
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deferred_pointer_reply_uses_the_gesture_document_after_file_new() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Services::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||||
let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("select.set", json!({"ids": [id]})).unwrap();
|
||||
let (reply, received) = std::sync::mpsc::channel();
|
||||
let at = app.session.input_count();
|
||||
app.pointer_replies.push(super::PendingPointerReply { from: at, to: at, document: app.session.active().map(|d| d.uid), reply });
|
||||
|
||||
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||||
app.run("tool.select", json!({"tool": "rectangle"})).unwrap();
|
||||
app.answer_pointer_replies(&egui::Context::default());
|
||||
|
||||
let result = received.try_recv().unwrap();
|
||||
assert_eq!(result["ok"], true, "{result}");
|
||||
assert_eq!(result["result"]["selection"], json!([id]));
|
||||
assert_eq!(result["result"]["key"], Value::Null);
|
||||
assert_eq!(result["result"]["tool"], "rectangle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_lists_and_selects_optional_addon_tools() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Services::default());
|
||||
@@ -338,6 +360,263 @@ fn pointer_replies_name_the_key_object() {
|
||||
assert_eq!(click(&mut app), Some(Value::Null), "a click on the key again lets it go");
|
||||
}
|
||||
|
||||
/// A Shape Builder click on a large selection merges on another thread: the frames go on, and
|
||||
/// the `ui.pointer` reply comes once the merge is applied, with the selection after it.
|
||||
#[test]
|
||||
fn pointer_reply_waits_for_a_merge_on_another_thread() {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let mut app = VectorcraftApp::new(Session::new(), Services::default()).with_control(rx);
|
||||
app.run("file.new", json!({"width": 800, "height": 200})).unwrap();
|
||||
for k in 0..40 {
|
||||
app.run("shape.rectangle", json!({"x": k * 10, "y": 0, "width": 20, "height": 20})).unwrap();
|
||||
}
|
||||
app.run("select.all", json!({})).unwrap();
|
||||
app.run("tool.select", json!({"tool": "shapeBuilder"})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let send = |app: &mut VectorcraftApp, events: Value| {
|
||||
let (req, reply) = ControlRequest::new("ui.pointer", json!({"events": events}));
|
||||
tx.send(req).unwrap();
|
||||
app.drain_control(&ctx);
|
||||
reply
|
||||
};
|
||||
let hover = send(&mut app, json!([{"kind": "move", "x": 15, "y": 10}]));
|
||||
assert_eq!(hover.try_recv().unwrap()["ok"], true, "a hover is answered at once");
|
||||
app.session.tool_poll(app.view_info(), true).unwrap();
|
||||
let revision = app.session.active().unwrap().revision;
|
||||
let click = send(&mut app, json!([{"kind": "down", "x": 15, "y": 10}, {"kind": "up", "x": 15, "y": 10}]));
|
||||
assert!(click.try_recv().is_err() && app.session.tool_editing(), "the click is not answered before the merge is applied");
|
||||
// A hover sent meanwhile is answered at once; a click is made after the merge, and its
|
||||
// reply waits until then.
|
||||
let hover = send(&mut app, json!([{"kind": "move", "x": 35, "y": 10}]));
|
||||
assert_eq!(hover.try_recv().map(|r| r["ok"].clone()), Ok(json!(true)), "a hover during the merge");
|
||||
let cmd = json!({"cmd": true});
|
||||
let press = send(&mut app, json!([{"kind": "down", "x": 500, "y": 150, "mods": cmd}, {"kind": "up", "x": 500, "y": 150, "mods": cmd}]));
|
||||
assert!(press.try_recv().is_err(), "a Cmd-click during the merge waits for it");
|
||||
let hover = send(&mut app, json!([{"kind": "move", "x": 45, "y": 10}]));
|
||||
assert_eq!(hover.try_recv().map(|r| r["ok"].clone()), Ok(json!(true)), "a hover after the held Cmd-click");
|
||||
let start = std::time::Instant::now();
|
||||
let reply = loop {
|
||||
if let Ok(r) = click.try_recv() {
|
||||
break r;
|
||||
}
|
||||
assert!(app.session.tool_editing(), "the merge is being computed until the reply goes");
|
||||
assert_eq!(app.session.active().unwrap().revision, revision);
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(60), "no reply");
|
||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||
// A frame: the canvas checks on the tool, then the replies are answered.
|
||||
let r = app.session.tool_poll(app.view_info(), false);
|
||||
crate::canvas::apply_requests(&mut app, r);
|
||||
app.answer_pointer_replies(&ctx);
|
||||
};
|
||||
assert_eq!(reply["ok"], true, "{reply}");
|
||||
assert_eq!(reply["result"]["selection"].as_array().map(Vec::len), Some(41), "{reply}: the selection after the merge");
|
||||
assert_ne!(app.session.active().unwrap().revision, revision);
|
||||
let press = loop {
|
||||
if let Ok(r) = press.try_recv() {
|
||||
break r;
|
||||
}
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(60), "no reply to the Cmd-click");
|
||||
let r = app.session.tool_poll(app.view_info(), false);
|
||||
crate::canvas::apply_requests(&mut app, r);
|
||||
app.answer_pointer_replies(&ctx);
|
||||
};
|
||||
assert_eq!(press["result"]["selection"].as_array().map(Vec::len), Some(0), "{press}: the lent selection tool deselected after the merge");
|
||||
assert_eq!(press["result"]["tool"], "shapeBuilder", "{press}");
|
||||
}
|
||||
|
||||
/// A Cmd-click held while a Shape Builder merge is computed on another thread makes the artboard
|
||||
/// it lands in active once it is made, as a Cmd-click made at once does: by the frames' checks,
|
||||
/// by choosing another tool first, or before a command that acts on the active artboard takes
|
||||
/// it.
|
||||
#[test]
|
||||
fn a_held_cmd_click_makes_its_artboard_active() {
|
||||
for then in ["frames", "another tool", "All on Active Artboard", "another document"] {
|
||||
held_cmd_click(then);
|
||||
}
|
||||
}
|
||||
|
||||
fn held_cmd_click(then: &str) {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Services::default());
|
||||
if then == "another document" {
|
||||
// A document with an artboard where the press is, whose view keeps its own.
|
||||
app.run("file.new", json!({"width": 1400, "height": 200})).unwrap();
|
||||
app.run("artboard.new", json!({"x": 1000, "y": 0, "width": 300, "height": 200})).unwrap();
|
||||
app.view_mut().unwrap().artboard = 0;
|
||||
}
|
||||
app.run("file.new", json!({"width": 800, "height": 200})).unwrap();
|
||||
app.run("artboard.new", json!({"x": 1000, "y": 0, "width": 300, "height": 200})).unwrap();
|
||||
for k in 0..40 {
|
||||
app.run("shape.rectangle", json!({"x": k * 10, "y": 0, "width": 20, "height": 20})).unwrap();
|
||||
}
|
||||
app.run("select.all", json!({})).unwrap();
|
||||
app.run("tool.select", json!({"tool": "shapeBuilder"})).unwrap();
|
||||
app.view_mut().unwrap().artboard = 0;
|
||||
let view = app.view_info();
|
||||
let at = |kind, x, mods| PointerEvent { kind, pos: vectorcraft_geom::Point::new(x, 10.0), mods, pressure: 1.0, backquote: false, pen: None };
|
||||
crate::canvas::dispatch(&mut app, &at(PointerKind::Move, 15.0, Mods::default()), view);
|
||||
app.session.tool_poll(view, true).unwrap();
|
||||
crate::canvas::dispatch(&mut app, &at(PointerKind::Down, 15.0, Mods::default()), view);
|
||||
crate::canvas::dispatch(&mut app, &at(PointerKind::Up, 15.0, Mods::default()), view);
|
||||
let cmd = Mods { cmd: true, ..Default::default() };
|
||||
crate::canvas::dispatch(&mut app, &at(PointerKind::Down, 1100.0, cmd), view);
|
||||
crate::canvas::dispatch(&mut app, &at(PointerKind::Up, 1100.0, cmd), view);
|
||||
assert_eq!(app.view().unwrap().artboard, 0, "{then}: the Cmd-click is held");
|
||||
if then == "another tool" {
|
||||
app.select_tool("rectangle");
|
||||
assert!(!app.session.tool_editing(), "{then}");
|
||||
}
|
||||
if then == "All on Active Artboard" {
|
||||
app.run("select.allOnArtboard", json!({})).unwrap();
|
||||
assert!(app.session.doc().unwrap().selection.is_empty(), "{then}: the second artboard, which holds no art");
|
||||
}
|
||||
let doc = app.session.active_index().unwrap();
|
||||
if then == "another document" {
|
||||
// A document tab chosen.
|
||||
app.session.set_active(0);
|
||||
}
|
||||
while app.session.tool_waiting() {
|
||||
let r = app.session.tool_poll(view, true);
|
||||
crate::canvas::apply_requests(&mut app, r);
|
||||
}
|
||||
app.sync_views();
|
||||
assert_eq!(app.views[doc].artboard, 1, "{then}: the Cmd-click made the artboard it landed in active");
|
||||
if then == "another document" {
|
||||
assert_eq!(app.views[0].artboard, 0, "{then}: the other document's view keeps its artboard");
|
||||
}
|
||||
}
|
||||
|
||||
/// A dialog chosen from the menu, or with `ui.menuDialog`, while a Shape Builder merge is
|
||||
/// computed on another thread opens after the merge is applied, on the merged art. So do the
|
||||
/// app's own dialogs (Recolor Artwork), from the menu or a panel's button.
|
||||
#[test]
|
||||
fn menu_dialogs_open_after_a_merge_on_another_thread() {
|
||||
for route in ["menu", "ui.menuDialog", "app dialog from the menu", "app dialog from a panel"] {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Services::default());
|
||||
app.run("file.new", json!({"width": 800, "height": 200})).unwrap();
|
||||
for k in 0..40 {
|
||||
app.run("shape.rectangle", json!({"x": k * 10, "y": 0, "width": 20, "height": 20})).unwrap();
|
||||
}
|
||||
app.run("select.all", json!({})).unwrap();
|
||||
app.run("tool.select", json!({"tool": "shapeBuilder"})).unwrap();
|
||||
let view = app.view_info();
|
||||
let at = |kind| PointerEvent {
|
||||
kind,
|
||||
pos: vectorcraft_geom::Point::new(15.0, 10.0),
|
||||
mods: Mods::default(),
|
||||
pressure: 1.0,
|
||||
backquote: false,
|
||||
pen: None,
|
||||
};
|
||||
crate::canvas::dispatch(&mut app, &at(PointerKind::Move), view);
|
||||
app.session.tool_poll(view, true).unwrap();
|
||||
crate::canvas::dispatch(&mut app, &at(PointerKind::Down), view);
|
||||
crate::canvas::dispatch(&mut app, &at(PointerKind::Up), view);
|
||||
assert!(app.session.tool_editing(), "{route}");
|
||||
match route {
|
||||
"menu" => crate::menus::invoke(&mut app, "object.move", json!({})),
|
||||
"ui.menuDialog" => {
|
||||
app.run("ui.menuDialog", json!({"command": "object.move"})).unwrap();
|
||||
}
|
||||
"app dialog from the menu" => crate::menus::invoke(&mut app, "ui.recolorDialog", json!({})),
|
||||
_ => {
|
||||
app.run("ui.recolorDialog", json!({})).unwrap();
|
||||
}
|
||||
}
|
||||
assert!(app.ui.dialog.is_some(), "{route}: the dialog is open");
|
||||
assert!(!app.session.tool_editing(), "{route}");
|
||||
let merged = app.session.doc().unwrap().history.undo.last().map(|e| e.label.clone());
|
||||
assert_eq!(merged.as_deref(), Some("Shape Builder"), "{route}");
|
||||
assert_eq!(app.session.doc().unwrap().selection.objects.len(), 41, "{route}: the dialog acts on the merged art");
|
||||
}
|
||||
}
|
||||
|
||||
/// The Separations panel's checkboxes and a preference set while a Shape Builder merge is
|
||||
/// computed on another thread give the documents new revisions: the merge is applied first,
|
||||
/// and the document keeps it.
|
||||
#[test]
|
||||
fn panels_that_redraw_apply_a_merge_on_another_thread_first() {
|
||||
let cases = [
|
||||
("view.separationsPreview", json!({"on": false})),
|
||||
("view.overprintPreview", json!({"on": false})),
|
||||
("prefs.set", json!({"values": {"gridlineEvery": 90}})),
|
||||
];
|
||||
for (id, params) in cases {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Services::default());
|
||||
app.run("file.new", json!({"width": 800, "height": 200})).unwrap();
|
||||
for k in 0..40 {
|
||||
app.run("shape.rectangle", json!({"x": k * 10, "y": 0, "width": 20, "height": 20})).unwrap();
|
||||
}
|
||||
app.run("select.all", json!({})).unwrap();
|
||||
app.run("tool.select", json!({"tool": "shapeBuilder"})).unwrap();
|
||||
let view = app.view_info();
|
||||
let at = |kind| PointerEvent {
|
||||
kind,
|
||||
pos: vectorcraft_geom::Point::new(15.0, 10.0),
|
||||
mods: Mods::default(),
|
||||
pressure: 1.0,
|
||||
backquote: false,
|
||||
pen: None,
|
||||
};
|
||||
crate::canvas::dispatch(&mut app, &at(PointerKind::Move), view);
|
||||
app.session.tool_poll(view, true).unwrap();
|
||||
crate::canvas::dispatch(&mut app, &at(PointerKind::Down), view);
|
||||
crate::canvas::dispatch(&mut app, &at(PointerKind::Up), view);
|
||||
assert!(app.session.tool_editing(), "{id}");
|
||||
app.run(id, params).unwrap();
|
||||
assert!(!app.session.tool_editing(), "{id}");
|
||||
for _ in 0..3 {
|
||||
app.session.tool_poll(view, false).unwrap();
|
||||
}
|
||||
let merged = app.session.doc().unwrap().history.undo.last().map(|e| e.label.clone());
|
||||
assert_eq!(merged.as_deref(), Some("Shape Builder"), "{id}");
|
||||
assert_eq!(app.session.doc().unwrap().selection.objects.len(), 41, "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Saving, exporting or closing while a Shape Builder merge is computed on another thread
|
||||
/// applies the merge first: the file holds it, and closing asks to save it.
|
||||
#[test]
|
||||
fn save_and_close_apply_a_merge_on_another_thread_first() {
|
||||
let dir = vectorcraft_testkit::temp_dir("control-merge-save");
|
||||
for then in ["file.saveAs", "file.exportAs", "file.close"] {
|
||||
let services = Services { write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))), ..Default::default() };
|
||||
let mut app = VectorcraftApp::new(Session::new(), services);
|
||||
app.run("file.new", json!({"width": 800, "height": 200})).unwrap();
|
||||
for k in 0..40 {
|
||||
app.run("shape.rectangle", json!({"x": k * 10, "y": 0, "width": 20, "height": 20})).unwrap();
|
||||
}
|
||||
app.run("select.all", json!({})).unwrap();
|
||||
app.run("tool.select", json!({"tool": "shapeBuilder"})).unwrap();
|
||||
// As if just saved: only the merge modifies it.
|
||||
app.session.doc_mut().unwrap().mark_saved();
|
||||
let view = app.view_info();
|
||||
let at = |kind| PointerEvent {
|
||||
kind,
|
||||
pos: vectorcraft_geom::Point::new(15.0, 10.0),
|
||||
mods: Mods::default(),
|
||||
pressure: 1.0,
|
||||
backquote: false,
|
||||
pen: None,
|
||||
};
|
||||
crate::canvas::dispatch(&mut app, &at(PointerKind::Move), view);
|
||||
app.session.tool_poll(view, true).unwrap();
|
||||
crate::canvas::dispatch(&mut app, &at(PointerKind::Down), view);
|
||||
crate::canvas::dispatch(&mut app, &at(PointerKind::Up), view);
|
||||
assert!(app.session.tool_editing() && !app.session.doc().unwrap().is_dirty(), "{then}");
|
||||
let path = dir.join(if then == "file.exportAs" { "a.svg" } else { "a.vectorcraft" });
|
||||
let p = if then == "file.close" { json!({}) } else { json!({"path": path.to_string_lossy()}) };
|
||||
let r = app.run(then, p).unwrap();
|
||||
assert!(!app.session.tool_editing(), "{then}");
|
||||
let merged = app.session.doc().unwrap().history.undo.last().map(|e| e.label.clone());
|
||||
assert_eq!(merged.as_deref(), Some("Shape Builder"), "{then}");
|
||||
match then {
|
||||
"file.saveAs" => assert!(!app.session.doc().unwrap().is_dirty(), "the file holds the merge"),
|
||||
"file.exportAs" => assert!(app.session.doc().unwrap().is_dirty(), "an export leaves the document modified"),
|
||||
_ => assert_eq!(r["pending"], "saveChanges", "closing asks to save the merge"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `ui.dialog.set` of a field the open dialog does not have is an error that lists its fields,
|
||||
/// and the dialog stays as it was. Fields a dialog may not hold yet are still set: `discard`
|
||||
/// with a discard button, `preview` with a live preview, `__` fields and the spec's `inputs`.
|
||||
|
||||
@@ -33,6 +33,9 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option<Strin
|
||||
// encoder (an exporter plug-in is told the file it writes).
|
||||
let params = &if params.is_object() { params.clone() } else { json!({}) };
|
||||
let f = fileio::writable_format(format, path.as_deref())?;
|
||||
// A Shape Builder merge still being computed for a click is exported with the document.
|
||||
let r = app.session.finish_tool_edit();
|
||||
crate::canvas::apply_requests(app, r);
|
||||
// A `.ai` file names the view's active artboard.
|
||||
remember_active_artboard(app);
|
||||
let st = app.session.active().ok_or("no document")?;
|
||||
|
||||
@@ -94,7 +94,7 @@ use tests::{
|
||||
mod tests;
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc::{Receiver, Sender};
|
||||
use std::sync::mpsc::Receiver;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
@@ -161,6 +161,9 @@ pub struct VectorcraftApp {
|
||||
synthetic_dropped_before: u64,
|
||||
/// Window captures asked for and not answered yet.
|
||||
pending_screenshots: Vec<PendingScreenshot>,
|
||||
/// `ui.pointer` replies waiting for their events and the edit the tool computes on another
|
||||
/// thread ([`control::Outcome::AfterToolEdit`]), with the numbers of those events.
|
||||
pointer_replies: Vec<control::PendingPointerReply>,
|
||||
/// Admitted captures whose settled frame has not come yet (issued one at a time).
|
||||
queued_screenshots: Vec<QueuedScreenshot>,
|
||||
screenshot_token: u64,
|
||||
|
||||
@@ -138,6 +138,11 @@ pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) {
|
||||
if waits_for_ime(app, id) {
|
||||
return;
|
||||
}
|
||||
// A command chosen while the tool computes an edit on another thread (a Shape Builder merge)
|
||||
// comes after it: the edit is applied first, so the command's dialog, or the app's own dialog
|
||||
// (Recolor Artwork), opens on the art as the gesture left it.
|
||||
let r = app.session.finish_tool_edit();
|
||||
crate::canvas::apply_requests(app, r);
|
||||
// A shortcut or a palette pick on a filter plug-in does nothing while its menu item is dimmed.
|
||||
if id == "plugin.dialog" && !enabled(app, id) {
|
||||
return;
|
||||
|
||||
@@ -820,16 +820,21 @@ fn run_ui_part10(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result
|
||||
"ui.svgFilterDialog" => crate::dialogs::svg_filter::open(app, p),
|
||||
"ui.importSvgFilter" => crate::dialogs::svg_filter::import_file(app),
|
||||
"ui.spotColors" => crate::dialogs::spot_colors::open(app),
|
||||
"ui.menuDialog" => match s("command").as_deref() {
|
||||
Some(c) if crate::dialogs::envelope::opens(c) => crate::dialogs::envelope::open(app, c),
|
||||
c => match c.and_then(|c| menu_dialog(app, c)) {
|
||||
Some((kind, fields)) => {
|
||||
app.ui.dialog = Some(crate::state::Dialog::new(kind, fields));
|
||||
Ok(Value::Null)
|
||||
}
|
||||
None => Err("`command` must be a command whose menu item opens a dialog (see ui.menuDialog)".into()),
|
||||
},
|
||||
},
|
||||
"ui.menuDialog" => {
|
||||
// As from the menu ([`invoke`]), the edit the tool computes on another thread first.
|
||||
let r = app.session.finish_tool_edit();
|
||||
crate::canvas::apply_requests(app, r);
|
||||
match s("command").as_deref() {
|
||||
Some(c) if crate::dialogs::envelope::opens(c) => crate::dialogs::envelope::open(app, c),
|
||||
c => match c.and_then(|c| menu_dialog(app, c)) {
|
||||
Some((kind, fields)) => {
|
||||
app.ui.dialog = Some(crate::state::Dialog::new(kind, fields));
|
||||
Ok(Value::Null)
|
||||
}
|
||||
None => Err("`command` must be a command whose menu item opens a dialog (see ui.menuDialog)".into()),
|
||||
},
|
||||
}
|
||||
}
|
||||
"ui.widthPointEdit" => crate::dialogs::width_point::open(app, p),
|
||||
"ui.corners" => crate::dialogs::corners::open(app, p),
|
||||
"ui.hyphenation" => crate::dialogs::hyphenation::open(app),
|
||||
|
||||
@@ -192,6 +192,10 @@ pub fn available(app: &VectorcraftApp) -> bool {
|
||||
/// checked here first, so a bad one is an error at once. When no thread could start, the file is
|
||||
/// placed at once → `file.place`'s reply.
|
||||
pub fn start(app: &mut VectorcraftApp, p: &Value, dropped: bool) -> Result<Value, String> {
|
||||
// The edit the tool computes on another thread comes first: the object Replace swaps is the
|
||||
// one selected after it.
|
||||
let r = app.session.finish_tool_edit();
|
||||
crate::canvas::apply_requests(app, r);
|
||||
let job = app.session.prepare_place(p).map_err(|e| e.to_string())?;
|
||||
let name = job.name();
|
||||
let started = spawn(app, name.clone(), name, dropped, move || job.read().map(|r| Read::Place(Box::new(r))).map_err(|e| e.to_string()))?;
|
||||
|
||||
@@ -1166,6 +1166,46 @@ fn a_replace_whose_object_was_deleted_meanwhile_places_nothing() {
|
||||
assert!(state(&app) == cleared, "nothing placed");
|
||||
}
|
||||
|
||||
/// A Replace started while a Shape Builder merge is computed on another thread swaps the object
|
||||
/// as the merge leaves it: the merge is applied first.
|
||||
#[test]
|
||||
fn a_replace_during_a_merge_on_another_thread_swaps_the_merged_object() {
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
|
||||
let path = saved("replace-merge", "photo.tif", &picture(Tiff, 60, 40));
|
||||
let mut app = app(true);
|
||||
for k in 0..40 {
|
||||
app.run("shape.rectangle", json!({"x": k * 10, "y": 0, "width": 20, "height": 20})).unwrap();
|
||||
}
|
||||
app.run("select.all", json!({})).unwrap();
|
||||
app.run("object.group", json!({})).unwrap();
|
||||
app.run("tool.select", json!({"tool": "shapeBuilder"})).unwrap();
|
||||
let view = app.view_info();
|
||||
let at = |kind, x| PointerEvent {
|
||||
kind,
|
||||
pos: vectorcraft_geom::Point::new(x, 10.0),
|
||||
mods: Mods::default(),
|
||||
pressure: 1.0,
|
||||
backquote: false,
|
||||
pen: None,
|
||||
};
|
||||
crate::canvas::dispatch(&mut app, &at(PointerKind::Move, 5.0), view);
|
||||
app.session.tool_poll(view, true).unwrap();
|
||||
// A drag across the row merges every square into one object, the one Replace swaps.
|
||||
for (kind, x) in [(PointerKind::Down, 5.0), (PointerKind::Drag, 200.0), (PointerKind::Drag, 405.0), (PointerKind::Up, 405.0)] {
|
||||
crate::canvas::dispatch(&mut app, &at(kind, x), view);
|
||||
}
|
||||
assert!(app.session.tool_editing());
|
||||
start(&mut app, &json!({"path": path, "replace": true}), false).unwrap();
|
||||
assert!(!app.session.tool_editing(), "the merge is applied first");
|
||||
placed(&mut app);
|
||||
assert!(app.ui.status.is_empty(), "{}", app.ui.status);
|
||||
let mut placed_names = vec![];
|
||||
app.session.doc().unwrap().doc.walk(|n| placed_names.extend(n.name.clone()));
|
||||
assert!(placed_names.iter().any(|n| n == "photo.tif"), "the merged art is replaced: {placed_names:?}");
|
||||
let labels: Vec<String> = app.session.doc().unwrap().history.undo.iter().rev().take(2).map(|e| e.label.clone()).collect();
|
||||
assert_eq!(labels.last().map(String::as_str), Some("Shape Builder"), "{labels:?}");
|
||||
}
|
||||
|
||||
/// A file read while a dialog is open waits for the dialog to close, as a document that opens
|
||||
/// does (#1694): the dialog's OK acts on the active document.
|
||||
#[test]
|
||||
|
||||
@@ -74,8 +74,9 @@ pub const ROTATE_VIEW_ANGLES: [(&str, f64); 12] = [
|
||||
("-90°", -90.0),
|
||||
];
|
||||
|
||||
use super::{ControlResponse, Sender};
|
||||
use super::ControlResponse;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
/// Zoom stop stepping reads this (set each UI frame and before commands) so canvas wheel zoom
|
||||
/// matches the status bar when Display Print Size at 100% Zoom is on.
|
||||
|
||||
@@ -4,6 +4,7 @@ use egui::{Event, PointerButton, Pos2};
|
||||
use serde_json::json;
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_geom::Point;
|
||||
use vectorcraft_tools::{PointerEvent, PointerKind};
|
||||
|
||||
use crate::{VectorcraftApp, canvas, chrome};
|
||||
|
||||
@@ -142,6 +143,57 @@ fn drag_fixture_modifiers(modifiers: egui::Modifiers) -> (VectorcraftApp, egui::
|
||||
(app, ctx, grab, tabs)
|
||||
}
|
||||
|
||||
/// A Cmd-press held while a Shape Builder merge is computed reaches the lent Selection tool once
|
||||
/// the merge is applied, and its drag carries the artwork over another tab.
|
||||
#[test]
|
||||
fn a_cmd_drag_held_during_a_merge_activates_another_tab() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width":800,"height":200,"name":"Source"})).unwrap();
|
||||
for k in 0..40 {
|
||||
app.run("shape.rectangle", json!({"x": k * 10, "y": 0, "width": 20, "height": 20})).unwrap();
|
||||
}
|
||||
app.run("select.all", json!({})).unwrap();
|
||||
app.run("file.new", json!({"width":800,"height":200,"name":"Target"})).unwrap();
|
||||
app.session.set_active(0);
|
||||
app.select_tool("shapeBuilder");
|
||||
app.ui.task_bar = false;
|
||||
app.ui.view.rulers = false;
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
app.ui_ctx = Some(ctx.clone());
|
||||
frame(&mut app, &ctx, 0.0, vec![]);
|
||||
let tabs = frame(&mut app, &ctx, 0.01, vec![]);
|
||||
let view = app.view_info();
|
||||
app.session.pointer(&PointerEvent::new(PointerKind::Move, 15.0, 10.0), view).unwrap();
|
||||
app.session.tool_poll(view, true).unwrap();
|
||||
app.session.pointer(&PointerEvent::new(PointerKind::Down, 15.0, 10.0), view).unwrap();
|
||||
app.session.pointer(&PointerEvent::new(PointerKind::Up, 15.0, 10.0), view).unwrap();
|
||||
assert!(app.session.tool_editing(), "the click starts a merge on another thread");
|
||||
let grab = canvas::Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(Point::new(355.0, 10.0));
|
||||
let modifiers = egui::Modifiers::COMMAND;
|
||||
frame(
|
||||
&mut app,
|
||||
&ctx,
|
||||
0.05,
|
||||
vec![
|
||||
Event::ModifiersChanged(modifiers),
|
||||
Event::PointerMoved(grab),
|
||||
Event::PointerButton { pos: grab, button: PointerButton::Primary, pressed: true, modifiers },
|
||||
],
|
||||
);
|
||||
assert_eq!(app.session.tool_id(), "shapeBuilder", "the press waits for the merge");
|
||||
let r = app.session.tool_poll(view, true);
|
||||
canvas::apply_requests(&mut app, r);
|
||||
frame(&mut app, &ctx, 0.08, vec![]);
|
||||
assert_eq!(app.session.tool_id(), "selection", "the held press borrowed the Selection tool");
|
||||
frame(&mut app, &ctx, 0.1, vec![Event::PointerMoved(grab + egui::vec2(20.0, 10.0))]);
|
||||
let tab = tabs[1].center();
|
||||
frame(&mut app, &ctx, 0.2, vec![Event::PointerMoved(tab)]);
|
||||
frame(&mut app, &ctx, 0.25, vec![]);
|
||||
frame(&mut app, &ctx, 0.8, vec![]);
|
||||
assert_eq!(app.session.active_index(), Some(1), "holding the artwork over the tab activates it");
|
||||
}
|
||||
|
||||
/// Verify that leaving a tab restarts its dwell timer and hovering its close button never activates it.
|
||||
#[test]
|
||||
fn tab_hover_delay_restarts_after_leaving_and_close_button_does_not_activate() {
|
||||
|
||||
@@ -23,11 +23,19 @@ pub(crate) fn refuse_while_picking(app: &VectorcraftApp) -> Result<(), String> {
|
||||
if app.file_sheet_open() { Err(crate::picks::busy().into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// Finishes the work that decides whether there is anything left to save: the saves running in
|
||||
/// the background, and an edit the active tool is computing on another thread (a Shape Builder
|
||||
/// merge), which is applied.
|
||||
pub fn finish_pending(app: &mut VectorcraftApp) {
|
||||
crate::background::wait_all(app);
|
||||
let r = app.session.finish_tool_edit();
|
||||
crate::canvas::apply_requests(app, r);
|
||||
}
|
||||
|
||||
/// File → Close (or a tab's ×) for document `i`.
|
||||
pub fn close(app: &mut VectorcraftApp, i: usize) -> Result<Value, String> {
|
||||
refuse_while_picking(app)?;
|
||||
// A save still running decides whether there is anything left to save.
|
||||
crate::background::wait_all(app);
|
||||
finish_pending(app);
|
||||
let dirty = app.session.documents().get(i).ok_or("no such document")?.is_dirty();
|
||||
if dirty { ask(app, i, "close") } else { close_now(app, i) }
|
||||
}
|
||||
@@ -36,7 +44,7 @@ pub fn close(app: &mut VectorcraftApp, i: usize) -> Result<Value, String> {
|
||||
/// or finish once none is left.
|
||||
pub fn close_all(app: &mut VectorcraftApp, then: &str) -> Result<Value, String> {
|
||||
refuse_while_picking(app)?;
|
||||
crate::background::wait_all(app);
|
||||
finish_pending(app);
|
||||
if let Some(i) = app.session.documents().iter().position(|d| d.is_dirty()) {
|
||||
return ask(app, i, then);
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ the two can still win that race.
|
||||
| `ui.menu.list` / `ui.menu.invoke` | `{command, params}` | the full menu tree / invoke an item |
|
||||
| `ui.contextMenu.list` | | the canvas context menu for the current selection, flattened like `ui.menu.list` (`path` holds its submenus). `ui.click {x, y, button: "right"}` on the canvas opens it, after selecting the object there unless it is already selected. With the Artboard tool it lists the artboard entries (see `docs/mcp.md`), after selecting the artboard there unless it is already selected. While a menu opened over locked objects is open, it has an `Unlock` submenu with one item for each locked object under the pointer, topmost first and at most 20, labeled with the object's name, which runs `layer.setProps {ids: [id], locked: false}`. A locked group gets one item, and the locked objects inside it get none. Hidden objects, objects on hidden, locked or template layers, and in isolation mode objects outside the isolated group get no item |
|
||||
| `ui.tool.select` / `ui.tool.list` | `{tool}` | |
|
||||
| `ui.pointer` | `{events:[{kind: down|drag|up|move|doubleclick, x, y, space?: "doc"|"screen", mods?}]}` | drive the active tool exactly like the mouse: `mods.cmd` held at a press with any tool but a selection tool drags with the selection tool chosen last (Direct Selection with the Pen until one is), and the release gives the tool back as it was; the reply lists `selection`, `key` (the key object, null without one) and `tool`. With the Shape Builder or the Live Paint Bucket and a selection of more than 128 segments, the app finds the regions off the UI thread, so a `move` replies before the region under the pointer is highlighted, and the highlight shows in the first frame after the regions are found |
|
||||
| `ui.pointer` | `{events:[{kind: down|drag|up|move|doubleclick, x, y, space?: "doc"|"screen", mods?}]}` | drive the active tool exactly like the mouse: `mods.cmd` held at a press with any tool but a selection tool drags with the selection tool chosen last (Direct Selection with the Pen until one is), and the release gives the tool back as it was; the reply lists `selection`, `key` (the key object, null without one) and `tool`. With the Shape Builder or the Live Paint Bucket and a selection of more than 128 segments, the app finds the regions off the UI thread, so a `move` replies before the region under the pointer is highlighted, and the highlight shows in the first frame after the regions are found. With the Shape Builder and such a selection, the app also merges off the UI thread: the reply to the events that end a click or drag comes once the merge is applied. A click or drag made before the regions are found does not wait for them; the app records it and makes it once they are found. Until then the canvas keeps drawing, with the touched regions highlighted and the busy pointer. A click or drag sent meanwhile, with `mods.cmd` too, and the Shape Builder's arrow keys are made after the merge in the order sent, and the reply to such a request comes once its events are made and any merge they start is applied; a request of `move` events alone is answered at once. A command that can edit, select, save or redraw the documents (an edit, an undo, a selection, a save or an export, a tool option, another tool, another document, closing, a dialog's preview, a menu item, a command of the app's own such as `ui.recolorDialog`, the proof and separations views, a preference, added fonts) waits for the merge and the input sent before it, and runs after them; queries that only read, such as `document.inspect`, run at once on the document before the merge |
|
||||
| `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | synthetic keyboard input |
|
||||
| `ui.move` | `{x, y}` | screen-point pointer move |
|
||||
| `ui.click` / `ui.drag` | `{x, y, button?, count?, shift?, alt?, ctrl?, cmd?}` / `{x, y, toX, toY, steps?, count?, button?, shift?, alt?, ctrl?, cmd?}` | egui pointer input; see synthetic input budgets above |
|
||||
|
||||
+2
-2
@@ -451,8 +451,8 @@ opens the picker through `run_command` with the remote backend.
|
||||
| `inspect_document` | `{depth?, childLimit?, ids?, kind?, limit?, offset?}` | Artboards (with `color`, `#rrggbb`, for one with a background color), layer tree (ids, kinds, bounds, paint), selection, history, tool. A type node's `fill`, `stroke` and `strokeWidth` are the paint its characters show (its first run's); fills and strokes of the type object itself come as `objectFill`, `objectStroke` and `objectStrokeWidth`. Paint comes as a label (`"#ff0000"`, `"None"`, `"G1 40% (#ff9999)"` for a swatch at a tint); `paint.proxies` gives it as an object. |
|
||||
| `inspect_ui` | `{}` | UI state. Remote mode only. |
|
||||
| `select_tool` | `{tool}` | `selection`, `directSelection`, `pen`, `rectangle`, `ellipse`, `polygon`, `star`, `lineSegment`, … |
|
||||
| `pointer_gesture` | `{events:[{kind,x,y,mods?,backquote?,pressure?,tiltX?,tiltY?,rotation?,holdMs?}], tool?, mods?, backquote?}` | `kind` is one of `down`, `drag`, `up`, `move`, `doubleclick`. `backquote` is the held state for each event; the top-level value supplies its default, and a `backquote` that is not a boolean is an error. Events go through the same path as the mouse. Headless, a `move` replies after the tool's highlight under the pointer is ready, including the Shape Builder's and the Live Paint Bucket's regions of a large selection, which the desktop app finds off the UI thread and highlights after the reply. |
|
||||
| `pointer_gesture` | `{events:[{kind,x,y,mods?,backquote?,pressure?,tiltX?,tiltY?,rotation?,holdMs?}], tool?, mods?, backquote?}` | `kind` is one of `down`, `drag`, `up`, `move`, `doubleclick`. `backquote` is the held state for each event; the top-level value supplies its default. Events go through the same path as the mouse. Headless, a `move` replies after the tool's highlight under the pointer is ready, including the Shape Builder's and the Live Paint Bucket's regions of a large selection, which the desktop app finds off the UI thread and highlights after the reply. Remote, the events wait until the keys and other input sent before them (`press_key`, `type_text`) have reached the app, so a key pressed between two calls of one drag applies before the release. When that input is dropped because the document, a dialog or the focus changed first, the call fails and its events are not sent. |
|
||||
| `pointer_gesture` | `{events:[{kind,x,y,mods?,backquote?,pressure?,tiltX?,tiltY?,rotation?,holdMs?}], tool?, mods?, backquote?}` | `kind` is one of `down`, `drag`, `up`, `move`, `doubleclick`. `backquote` is the held state for each event; the top-level value supplies its default, and a `backquote` that is not a boolean is an error. Events go through the same path as the mouse. Headless, a `move` replies after the tool's highlight under the pointer is ready, including the Shape Builder's and the Live Paint Bucket's regions of a large selection, which the desktop app finds off the UI thread and highlights after the reply. The release of a Shape Builder click or drag over such a selection replies once the merge is applied, headless and in the desktop app, which computes the merge off the UI thread. In the desktop app, a gesture sent before then is made after the merge, and its reply comes once it is made; a command that can edit, select, save or redraw the documents (a preference, the proof and separations views), sent before then, waits for the merge and runs after it. |
|
||||
| `pointer_gesture` | `{events:[{kind,x,y,mods?,backquote?,pressure?,tiltX?,tiltY?,rotation?,holdMs?}], tool?, mods?, backquote?}` | `kind` is one of `down`, `drag`, `up`, `move`, `doubleclick`. `backquote` is the held state for each event; the top-level value supplies its default. Events go through the same path as the mouse. Headless, a `move` replies after the tool's highlight under the pointer is ready, including the Shape Builder's and the Live Paint Bucket's regions of a large selection, which the desktop app finds off the UI thread and highlights after the reply. Remote, the events wait until the keys and other input sent before them (`press_key`, `type_text`) have reached the app, so a key pressed between two calls of one drag applies before the release. When that input is dropped because the document, a dialog or the focus changed first, the call fails and its events are not sent. The release of a Shape Builder click or drag over such a selection replies once the merge is applied, headless and in the desktop app, which computes the merge off the UI thread. In the desktop app, a gesture sent before then is made after the merge, and its reply comes once it is made; a command that can edit, select, save or redraw the documents (a preference, the proof and separations views), sent before then, waits for the merge and runs after it. |
|
||||
| `draw_path` | `{points \| d, closed?, fill?, stroke?, strokeWidth?, dash?, cap?, join?, opacity?, name?, effects?}` | `points` is `[[x,y],…]` or `[{x,y,in?,out?,smooth?},…]`. `d` is SVG path data. The options are below. |
|
||||
| `draw_shape` | `{shape, …geometry, fill?, stroke?, strokeWidth?, dash?, cap?, join?, opacity?, name?, effects?}` | `rectangle`/`ellipse`: `x,y,width,height` (plus `radius` for corners). `polygon`: `cx,cy,radius,sides`. `star`: `cx,cy,radius1,radius2,points`. `line`: `x1,y1,x2,y2`. |
|
||||
| `set_paint` | `{fill?, stroke?, strokeWidth?, ids?}` | Applies to the selection (or `ids`) and becomes the default for new art. Returns `{applied, objects}`: `objects` is the number of objects painted, as the `objects` of the `paint.setFill`, `paint.setStroke` and `stroke.set` replies (the largest of them): a selected group counts the objects inside it. When it is 0, a `note` says that only the paint for new art changed. |
|
||||
|
||||
Reference in New Issue
Block a user