mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-31 21:27:00 -06:00
Merge remote-tracking branch 'origin/main' into K-1397ff
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user