Merge remote-tracking branch 'origin/main' into K-1397ff

This commit is contained in:
bflatastic
2026-10-12 20:05:33 +07:00
74 changed files with 2767 additions and 225 deletions
+64 -13
View File
@@ -33,8 +33,24 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("shape.line", "Line Segment", [], None, "{x1, y1, x2, y2} → {id}", has_doc, line),
cmd!("shape.spiral", "Spiral", [], None, "{cx, cy, radius, decay=80 (%), segments=10, clockwise?} → {id}", has_doc, spiral),
cmd!("shape.arc", "Arc", [], None, "{x1, y1, x2, y2, closed?} → {id}", has_doc, arc),
cmd!("shape.rectangularGrid", "Rectangular Grid", [], None, "{x, y, width, height, rows=5, columns=5} → {id}", has_doc, rect_grid),
cmd!("shape.polarGrid", "Polar Grid", [], None, "{x, y, width, height, concentric=5, radial=5} → {id}", has_doc, polar_grid),
cmd!(
"shape.rectangularGrid",
"Rectangular Grid",
[],
None,
"{x, y, width, height, rows=5, columns=5, rowSkew=0, columnSkew=0 (−500…500 %: + crowds the horizontal dividers toward the top, the vertical ones toward the right), frame=true (the outside as one rectangle, else its four sides), fill=false (the current fill, else none)} → {id}",
has_doc,
rect_grid
),
cmd!(
"shape.polarGrid",
"Polar Grid",
[],
None,
"{x, y, width, height, concentric=5, radial=5, concentricSkew=0, radialSkew=0 (−500…500 %: + crowds the ellipses toward the outside, the radial dividers clockwise of the top one), compound=false (the ellipses pair up into compound-path rings), fill=false (the current fill, else none)} → {id}",
has_doc,
polar_grid
),
cmd!(
"path.create",
"Create Path",
@@ -351,14 +367,24 @@ fn arc(s: &mut Session, p: &Value) -> Result<Value> {
add_art(s, "Arc", path_kind(shapes::arc(a, b, 0.0, bool_or(p, "closed", false)), None), None)
}
fn grid_group(s: &mut Session, label: &str, paths: Vec<PathData>) -> Result<Value> {
/// The grid `paths` (bottom first) as one selected group in the new-art stroke, filled with the
/// current fill when `fill`; a path of several subpaths becomes a compound path of them.
fn grid_group(s: &mut Session, label: &str, paths: Vec<PathData>, fill: bool) -> Result<Value> {
let parent = s.doc()?.target_parent()?;
let look = s.new_art_look(vectorcraft_color::Paint::None, s.paint.stroke.clone(), s.paint.stroke_width);
let fill = if fill { s.paint.fill.clone() } else { vectorcraft_color::Paint::None };
let look = s.new_art_look(fill, s.paint.stroke.clone(), s.paint.stroke_width);
let id = s.edit(label, |d, sel| {
let children = paths
.into_iter()
.map(|pd| {
let mut n = Node::path(d.alloc_id(), pd, Appearance::default());
let mut n = if pd.subpaths.len() > 1 {
let members =
pd.subpaths.into_iter().map(|sp| std::sync::Arc::new(Node::path(d.alloc_id(), PathData::single(sp), Appearance::default())));
let children = members.collect();
Node::new(d.alloc_id(), NodeKind::Compound { children, rule: FillRule::NonZero })
} else {
Node::path(d.alloc_id(), pd, Appearance::default())
};
look.apply(d, &mut n);
std::sync::Arc::new(n)
})
@@ -371,18 +397,43 @@ fn grid_group(s: &mut Session, label: &str, paths: Vec<PathData>) -> Result<Valu
Ok(json!({ "id": id.0 }))
}
/// A grid's divider count `count` with its skew `key` in percent (0 when absent, clamped to
/// ±[`shapes::GRID_SKEW_MAX`]).
fn dividers(p: &Value, count: &str, key: &str, cmd: &str) -> Result<(u32, f64)> {
let n = p.get(count).and_then(Value::as_u64).unwrap_or(5).min(999) as u32;
let skew = match p.get(key) {
None | Some(Value::Null) => 0.0,
Some(v) => v.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad(cmd, format!("`{key}` must be a number")))?,
};
Ok((n, skew.clamp(-shapes::GRID_SKEW_MAX, shapes::GRID_SKEW_MAX)))
}
/// A grid's on/off option `key` (`default` when absent).
fn switch(p: &Value, key: &str, default: bool, cmd: &str) -> Result<bool> {
match p.get(key) {
None | Some(Value::Null) => Ok(default),
Some(v) => v.as_bool().ok_or_else(|| bad(cmd, format!("`{key}` must be true or false"))),
}
}
fn rect_grid(s: &mut Session, p: &Value) -> Result<Value> {
let r = rect_of(p, "shape.rectangularGrid")?;
let rows = p.get("rows").and_then(Value::as_u64).unwrap_or(5).min(999) as u32;
let cols = p.get("columns").and_then(Value::as_u64).unwrap_or(5).min(999) as u32;
grid_group(s, "Rectangular Grid", shapes::rectangular_grid(r, rows, cols, true))
const CMD: &str = "shape.rectangularGrid";
let r = rect_of(p, CMD)?;
let paths =
shapes::rectangular_grid(r, dividers(p, "rows", "rowSkew", CMD)?, dividers(p, "columns", "columnSkew", CMD)?, switch(p, "frame", true, CMD)?);
grid_group(s, "Rectangular Grid", paths, switch(p, "fill", false, CMD)?)
}
fn polar_grid(s: &mut Session, p: &Value) -> Result<Value> {
let r = rect_of(p, "shape.polarGrid")?;
let c = p.get("concentric").and_then(Value::as_u64).unwrap_or(5).min(999) as u32;
let rad = p.get("radial").and_then(Value::as_u64).unwrap_or(5).min(999) as u32;
grid_group(s, "Polar Grid", shapes::polar_grid(r, c, rad))
const CMD: &str = "shape.polarGrid";
let r = rect_of(p, CMD)?;
let paths = shapes::polar_grid(
r,
dividers(p, "concentric", "concentricSkew", CMD)?,
dividers(p, "radial", "radialSkew", CMD)?,
switch(p, "compound", false, CMD)?,
);
grid_group(s, "Polar Grid", paths, switch(p, "fill", false, CMD)?)
}
/// An anchor `{x, y, in?, out?, smooth?}` given to `cmd` (handles `[x, y]`, at the anchor when
+6 -1
View File
@@ -222,7 +222,12 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
.flatten()
.map(|e| (e.intact, move || Some(e.data)));
restore_or_import(editing, || match super::pdfimport::import(bytes, opts) {
Ok((doc, warnings)) => Ok(through_editing_data(bytes, opts, doc, warnings)),
Ok(r) => {
// Pages of text alone: the editing copy alone where it reads, as for a file
// saved without its PDF part (#1387: else the page, which is the art).
let alone = r.only_text.then(|| from_editing_data_alone(bytes, opts)).flatten().and_then(|read| read.ok());
Ok(alone.unwrap_or_else(|| through_editing_data(bytes, opts, r.document, r.warnings)))
}
// Saved without its PDF part: the editing copy is all the file has.
Err(e) if e.to_string() == vectorcraft_pdf::PdfError::PlaceholderOnly.to_string() => {
from_editing_data_alone(bytes, opts).unwrap_or(Err(e))
+3 -4
View File
@@ -130,10 +130,9 @@ fn choice<T: Choice + Default>(cmd: &str, key: &str, id: Option<&str>) -> Result
.ok_or_else(|| bad(cmd, format!("{key} must be one of {}", T::IDS.join(", "))))
}
/// Import the pages `o` pick → the document and import notes.
pub(super) fn import(bytes: &[u8], o: &LoadOptions) -> Result<(Document, Vec<String>)> {
let r = vectorcraft_pdf::import_with_report(bytes, &o.import_options(bytes)?).map_err(err)?;
Ok((r.document, r.warnings))
/// Import the pages `o` pick → the document, import notes and what else the import tells.
pub(super) fn import(bytes: &[u8], o: &LoadOptions) -> Result<vectorcraft_pdf::ImportReport> {
vectorcraft_pdf::import_with_report(bytes, &o.import_options(bytes)?).map_err(err)
}
/// Page `page` (0-based) of a PDF as a document of one artboard (its `o.crop` box) and one
@@ -154,6 +154,52 @@ fn an_ai_file_saved_without_its_pdf_part_opens_from_its_editing_data() {
assert!(e.contains("placeholder"), "{e}");
}
/// A page of text alone, as text-only art has in its PDF part: no path is painted.
const TEXT_ART: &str = "BT /F1 36 Tf 10 40 Td (Text only) Tj ET";
/// The kinds of what the first layer holds: type (or its outlines, without the font) and others.
fn text_and_others(s: &Session) -> (usize, usize) {
let art = s.doc().unwrap().doc.layers[0].children().unwrap();
let text = art.iter().filter(|n| matches!(n.kind, NodeKind::Text(_)) || n.name.as_deref() == Some("<Text Outlines>")).count();
(text, art.len() - text)
}
#[test]
fn an_ai_file_whose_pdf_part_is_only_text_opens_from_it_when_its_editing_data_cant_be_read() {
// #1387. The page states where its art is (an art, trim or bleed box), which the notice
// page of a file saved without its PDF part has no art to do.
let unreadable = b"%AI24_ZStandard_Data\0\0\0\0\0\0\0\0";
for boxed in ["/ArtBox [0 0 200 100]", "/TrimBox [0 0 200 100]", "/BleedBox [0 0 200 100]"] {
let mut s = Session::new();
let r = open(&mut s, "text.ai", &ai::ai_with(unreadable, TEXT_ART, boxed), json!({})).unwrap();
// Then a note on the font, where the machine lacks it.
assert_eq!(r["warnings"][0], json!(vectorcraft_pdf::OFF_ARTBOARD_NOTE), "{boxed}: {r}");
assert_eq!(text_and_others(&s), (1, 0), "{boxed}");
// The PDF part alone, as asked.
open(&mut s, "text.ai", &ai::ai_with(unreadable, TEXT_ART, boxed), json!({"editingData": false})).unwrap();
assert_eq!(text_and_others(&s), (1, 0), "{boxed}");
}
// The same page without a box is the notice: the file still says why it can't open.
let mut s = Session::new();
let e = open(&mut s, "text.ai", &ai::ai(unreadable, TEXT_ART), json!({})).unwrap_err().to_string();
assert!(e.contains("without PDF compatibility") && e.contains("can't be read"), "{e}");
let e = open(&mut s, "text.ai", &ai::ai(unreadable, TEXT_ART), json!({"editingData": false})).unwrap_err().to_string();
assert!(e.contains("placeholder"), "{e}");
// Placed, it is read as its page too.
let r = s.execute("file.new", &json!({})).and_then(|_| {
let data = vectorcraft_format::base64_encode(&ai::ai_with(unreadable, TEXT_ART, "/ArtBox [0 0 200 100]"));
s.execute("file.place", &json!({"name": "text.ai", "dataBase64": data, "link": false}))
});
assert!(r.is_ok(), "{r:?}");
}
#[test]
fn an_ai_file_whose_pdf_part_is_only_text_still_opens_from_editing_data_that_reads() {
let mut s = Session::new();
open(&mut s, "art.ai", &ai::ai_with(&compressed(&ai::sample_data()), TEXT_ART, "/ArtBox [0 0 200 100]"), json!({})).unwrap();
check_sample(&s);
}
#[test]
fn editing_data_false_opens_only_what_the_page_prints() {
let mut s = Session::new();
+4 -2
View File
@@ -857,8 +857,10 @@ fn isolate(s: &mut Session, p: &Value) -> Result<Value> {
.or_else(|| s.active().and_then(|d| d.selection.objects.first().copied()))
.ok_or_else(|| bad("object.isolate", "missing id"))?;
let st = s.doc_mut()?;
if st.doc.node(id).is_none_or(|n| !n.is_container()) {
return Err(bad("object.isolate", "only groups and layers can be isolated"));
// Groups, layers and single objects (a path, a compound path, an image…); type isn't
// isolated: a double-click on it edits its text.
if st.doc.node(id).is_none_or(|n| matches!(n.kind, NodeKind::Text(_))) {
return Err(bad("object.isolate", "type objects can't be isolated"));
}
let isolation = st.doc.node(id).filter(|n| n.extras().shaper.is_some()).and_then(|n| n.children()).and_then(|c| c.first()).map_or(id, |n| n.id);
st.isolation = Some(isolation);
+2 -1
View File
@@ -193,7 +193,8 @@ pub fn specs() -> Vec<CommandSpec> {
fn selectable(d: &Document, iso: Option<NodeId>) -> Vec<NodeId> {
match iso.and_then(|i| d.node(i)) {
Some(c) if d.ancestry(c.id).is_some_and(|a| a.iter().all(|i| d.node(*i).is_some_and(|n| n.visible && !n.locked && !n.is_template()))) => {
c.layer_art(true)
// A single object isolated is all there is to select.
if c.is_container() { c.layer_art(true) } else { vec![c.id] }
}
Some(_) => vec![],
None => d.selectable_art(),
+6
View File
@@ -483,6 +483,12 @@ impl DocState {
{
return Some(i);
}
// A single object isolated: new art goes beside it, in its group or layer.
if let Some(p) = self.isolation.filter(|i| self.doc.node(*i).is_some_and(|n| !n.is_container())).and_then(|i| self.doc.parent_of(i))
&& self.doc.node(p).is_some_and(|n| matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }))
{
return Some(p);
}
// A sublayer takes new art only while it and the layers around it are shown and unlocked.
self.active_layer.filter(|l| self.doc.node(*l).is_some_and(|n| n.is_layer()) && self.doc.is_editable(*l)).or_else(|| self.doc.default_layer())
}
+126
View File
@@ -442,6 +442,132 @@ fn line_family_tools_draw_and_click_asks_dialog() {
assert_eq!(ui, vec![UiRequest::Dialog("polarGrid".into(), json!({"x": 10.0, "y": 10.0}))]);
}
/// The children of the grid a `shape.rectangularGrid` / `shape.polarGrid` call made, bottom first.
fn grid(s: &mut Session, cmd: &str, p: serde_json::Value) -> Vec<std::sync::Arc<vectorcraft_doc::Node>> {
let id = NodeId(s.execute(cmd, &p).unwrap()["id"].as_u64().unwrap());
s.doc().unwrap().doc.node(id).unwrap().children().unwrap().to_vec()
}
/// `base` with the keys of `extra` added.
fn with(base: &serde_json::Value, extra: serde_json::Value) -> serde_json::Value {
let mut p = base.clone();
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
p
}
fn is_filled(n: &vectorcraft_doc::Node) -> bool {
n.appearance.fill().is_some_and(|f| f.paint != vectorcraft_color::Paint::None)
}
/// #1377: the Rectangular Grid options. By default the frame is one rectangle under evenly spaced
/// dividers and nothing is filled; `frame: false` draws its four sides instead, `fill` paints the
/// current fill and the skews crowd the dividers to the top / right (positive) or bottom / left.
#[test]
fn rectangular_grid_options_frame_fill_and_skew() {
const CMD: &str = "shape.rectangularGrid";
let mut s = session();
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
let base = json!({"x": 0, "y": 0, "width": 100, "height": 100, "rows": 3, "columns": 3});
// The y of the three horizontal dividers and the x of the three vertical ones (the last six children).
let dividers = |g: &[std::sync::Arc<vectorcraft_doc::Node>]| {
let starts: Vec<Point> = g[g.len() - 6..].iter().map(|n| n.path_data().unwrap().subpaths[0].anchors[0].p).collect();
starts[..3].iter().map(|p| p.y).chain(starts[3..].iter().map(|p| p.x)).collect::<Vec<_>>()
};
let plain = grid(&mut s, CMD, base.clone());
assert_eq!(plain.len(), 7);
assert!(plain[0].path_data().unwrap().is_closed(), "the frame lies under the dividers");
assert!(plain.iter().all(|n| !is_filled(n) && n.appearance.stroke().is_some()));
assert_eq!(dividers(&plain), [25.0, 50.0, 75.0, 25.0, 50.0, 75.0]);
// No frame: its four sides are lines of their own.
let sides = grid(&mut s, CMD, with(&base, json!({"frame": false})));
assert_eq!(sides.len(), 10);
assert!(sides.iter().all(|n| !n.path_data().unwrap().is_closed() && n.path_data().unwrap().anchor_count() == 2));
assert_eq!(dividers(&sides), dividers(&plain));
let filled = grid(&mut s, CMD, with(&base, json!({"fill": true})));
assert!(filled.iter().all(|n| is_filled(n)), "Fill Grid paints the current fill");
assert_eq!(filled[0].appearance.fill().unwrap().paint, s.paint.fill);
// Skew: toward the top and the right when positive, the bottom and the left when negative;
// past ±500 % is ±500 %.
let top_right = dividers(&grid(&mut s, CMD, with(&base, json!({"rowSkew": 100, "columnSkew": 100}))));
for (got, want) in top_right.iter().zip([6.25, 25.0, 56.25, 43.75, 75.0, 93.75]) {
assert!((got - want).abs() < 1e-9, "{top_right:?}");
}
let bottom_left = dividers(&grid(&mut s, CMD, with(&base, json!({"rowSkew": -100, "columnSkew": -100}))));
assert!(bottom_left[1] > 50.0 && bottom_left[4] < 50.0, "{bottom_left:?}");
let most = dividers(&grid(&mut s, CMD, with(&base, json!({"rowSkew": 500, "columnSkew": -500}))));
let beyond = dividers(&grid(&mut s, CMD, with(&base, json!({"rowSkew": 1e300, "columnSkew": -1e300}))));
assert_eq!(most, beyond);
assert!(most.iter().all(|v| *v > 0.0 && *v < 100.0), "{most:?}");
}
/// #1377: the Polar Grid options. The ellipses lie under the radial dividers, largest first;
/// `compound` pairs them into compound-path rings, `fill` paints the current fill and the skews
/// crowd the ellipses outward and the radial dividers clockwise of the top one (positive).
#[test]
fn polar_grid_options_compound_fill_and_skew() {
const CMD: &str = "shape.polarGrid";
let mut s = session();
s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap();
let base = json!({"x": 0, "y": 0, "width": 100, "height": 100, "concentric": 3, "radial": 4});
let width = |n: &vectorcraft_doc::Node| n.geometric_bounds().unwrap().width();
let tips =
|g: &[std::sync::Arc<vectorcraft_doc::Node>]| g[4..].iter().map(|n| n.path_data().unwrap().subpaths[0].anchors[1].p).collect::<Vec<_>>();
let plain = grid(&mut s, CMD, base.clone());
assert_eq!(plain.len(), 8);
assert_eq!(plain[..4].iter().map(|n| width(n).round()).collect::<Vec<_>>(), [100.0, 75.0, 50.0, 25.0], "largest at the bottom");
assert!(plain[..4].iter().all(|n| n.path_data().unwrap().is_closed()) && plain[4..].iter().all(|n| !n.path_data().unwrap().is_closed()));
assert!(plain.iter().all(|n| !is_filled(n)));
assert!(grid(&mut s, CMD, with(&base, json!({"fill": true}))).iter().all(|n| is_filled(n)));
// Compound: 4 ellipses make 2 rings, each a compound path of an outside and a hole running
// against it that carries the grid's paint itself; with 5, the innermost ellipse stays a path.
let rings = grid(&mut s, CMD, with(&base, json!({"compound": true, "fill": true})));
assert_eq!(rings.len(), 6);
for (ring, sizes) in rings[..2].iter().zip([[100.0, 75.0], [50.0, 25.0]]) {
let NodeKind::Compound { children, rule } = &ring.kind else { panic!("{:?}", ring.kind) };
assert_eq!(*rule, FillRule::NonZero);
assert_eq!(children.iter().map(|n| width(n).round()).collect::<Vec<_>>(), sizes);
let areas: Vec<f64> = children.iter().map(|n| n.path_data().unwrap().subpaths[0].signed_area()).collect();
assert!(areas[0] * areas[1] < 0.0, "the hole runs against the outside: {areas:?}");
assert!(is_filled(ring) && ring.appearance.stroke().is_some());
}
let five = grid(&mut s, CMD, with(&base, json!({"compound": true, "concentric": 4, "radial": 0})));
assert_eq!(five.iter().map(|n| matches!(n.kind, NodeKind::Compound { .. })).collect::<Vec<_>>(), [true, true, false]);
// Skew.
let out = grid(&mut s, CMD, with(&base, json!({"concentricSkew": 100, "radialSkew": 500})));
for (n, want) in out[..4].iter().zip([100.0, 93.75, 75.0, 43.75]) {
assert!((width(n) - want).abs() < 1e-6, "{}", width(n));
}
assert!(tips(&out)[0].distance(Point::new(50.0, 0.0)) < 1e-9, "the top divider stays");
assert!(tips(&out)[1..].iter().all(|t| t.x > 50.0), "clockwise of the top: {:?}", tips(&out));
let inward = grid(&mut s, CMD, with(&base, json!({"concentricSkew": -100, "radialSkew": -500})));
assert!((width(&inward[1]) - 56.25).abs() < 1e-6);
assert!(tips(&inward)[1..].iter().all(|t| t.x < 50.0), "counterclockwise of the top: {:?}", tips(&inward));
}
/// The grid options are checked: a skew is a number and a switch true or false, or the command
/// fails without an undo step.
#[test]
fn grid_commands_reject_options_of_the_wrong_type() {
let mut s = session();
let size = json!({"x": 0, "y": 0, "width": 10, "height": 10});
for (cmd, key, value) in [
("shape.rectangularGrid", "rowSkew", json!("50")),
("shape.rectangularGrid", "columnSkew", json!(true)),
("shape.rectangularGrid", "frame", json!(1)),
("shape.rectangularGrid", "fill", json!("yes")),
("shape.polarGrid", "concentricSkew", json!([1])),
("shape.polarGrid", "radialSkew", json!({})),
("shape.polarGrid", "compound", json!(0)),
("shape.polarGrid", "fill", json!("true")),
] {
let before = s.doc().unwrap().history.undo.len();
let p = with(&size, json!({key: value}));
assert!(s.execute(cmd, &p).is_err(), "{cmd} {p}");
assert_eq!(s.doc().unwrap().history.undo.len(), before, "{cmd} left an undo step");
assert!(s.execute(cmd, &with(&size, json!({key: null}))).is_ok(), "{cmd}: null is the default");
}
}
#[test]
fn draw2_commands_reject_bad_params() {
let mut s = session();
+54
View File
@@ -298,6 +298,60 @@ fn selection_preferences_drive_the_selection_tool() {
assert!(double(&mut s).is_some(), "on: the group is isolated");
}
/// Double Click To Isolate: a double-click with the Selection tool isolates a single object too,
/// not only a group. Inside, only it is hit, marqueed and selected by Select All; new art goes
/// beside it; a double-click on the empty canvas leaves, with it selected. Type isn't isolated:
/// a double-click on it switches to the Type tool.
#[test]
fn double_clicking_a_single_object_isolates_it() {
use vectorcraft_tools::{PointerEvent, PointerKind};
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let id = |v: Value| NodeId(v["id"].as_u64().unwrap());
let rect = |s: &mut Session, x: f64| id(s.execute("shape.rectangle", &json!({"x": x, "y": 100, "width": 50, "height": 50})).unwrap());
let (a, b) = (rect(&mut s, 100.0), rect(&mut s, 200.0));
s.select_tool("selection", ViewInfo::default()).unwrap();
let ev = |s: &mut Session, kinds: &[PointerKind], x: f64, y: f64| {
for k in kinds {
s.pointer(&PointerEvent::new(*k, x, y), ViewInfo::default()).unwrap();
}
};
let click = [PointerKind::Down, PointerKind::Up];
let double = [PointerKind::DoubleClick];
ev(&mut s, &double, 125.0, 125.0);
assert_eq!(s.doc().unwrap().isolation, Some(a));
// Only the isolated object is hit: a click on the other selects nothing.
ev(&mut s, &click, 225.0, 125.0);
assert!(s.doc().unwrap().selection.objects.is_empty());
ev(&mut s, &click, 125.0, 125.0);
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
// A marquee over both and Select All take it alone.
s.execute("select.none", &json!({})).unwrap();
ev(&mut s, &[PointerKind::Down], 90.0, 90.0);
ev(&mut s, &[PointerKind::Move, PointerKind::Up], 300.0, 300.0);
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
s.execute("select.none", &json!({})).unwrap();
s.execute("select.all", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
// A second double-click on it stays in isolation; new art lands on its layer.
ev(&mut s, &double, 125.0, 125.0);
assert_eq!(s.doc().unwrap().isolation, Some(a));
let c = rect(&mut s, 300.0);
let st = s.doc().unwrap();
assert_eq!(st.doc.parent_of(c), st.doc.parent_of(a));
// A double-click on the empty canvas leaves isolation, the object selected.
ev(&mut s, &double, 50.0, 350.0);
let st = s.doc().unwrap();
assert_eq!((st.isolation, st.selection.objects.clone()), (None, vec![a]));
assert!(st.doc.node(b).is_some());
// Type is not isolated.
let t = id(s.execute("text.create", &json!({"x": 100, "y": 300, "text": "Type"})).unwrap());
assert!(s.execute("object.isolate", &json!({"id": t.0})).is_err());
s.execute("prefs.set", &json!({"key": "doubleClickToIsolate", "value": false})).unwrap();
ev(&mut s, &double, 225.0, 125.0);
assert_eq!(s.doc().unwrap().isolation, None, "off: no isolation");
}
/// General › Use Precise Cursors (#394): the Pen's pointer becomes a crosshair; the Selection
/// tool's arrow stays.
#[test]