vectorcraft/crates/engine/src/tests_prefs.rs

1110 lines
58 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Preferences: `prefs.get` / `prefs.set` / `prefs.reset` / `prefs.list`.
use serde_json::{Value, json};
use super::*;
use crate::cmd::prefscmds::{PREF_CATEGORIES, PREF_GROUPS, PREF_SPECS, validate};
#[test]
fn every_spec_matches_a_prefs_field_and_back() {
let all = Prefs::default().to_json();
let obj = all.as_object().unwrap();
for sp in PREF_SPECS {
assert!(obj.contains_key(sp.key), "spec `{}` has no Prefs field", sp.key);
assert!(PREF_CATEGORIES.contains(&sp.category), "spec `{}` has unknown category", sp.key);
// Defaults validate against their own spec.
assert!(validate(sp.key, &obj[sp.key]).is_ok(), "default of `{}` fails validation", sp.key);
}
for k in obj.keys() {
assert!(PREF_SPECS.iter().any(|s| s.key == k) || PREF_GROUPS.contains(&k.as_str()), "Prefs field `{k}` has no spec");
}
for c in PREF_CATEGORIES {
assert!(PREF_SPECS.iter().any(|s| s.category == *c), "category {c} is empty");
}
}
#[test]
fn set_and_get_keyboard_increment_drives_nudge() {
let mut s = Session::new();
s.execute("prefs.set", &json!({"key": "keyboardIncrement", "value": 5})).unwrap();
assert_eq!(s.execute("prefs.get", &json!({"key": "keyboardIncrement"})).unwrap(), json!(5.0));
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let r = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
s.execute("object.nudge", &json!({"dx": 1, "dy": 0})).unwrap();
let b = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap();
assert!((b.x0 - 15.0).abs() < 1e-9);
}
#[test]
fn set_rejects_out_of_range_and_wrong_types() {
let mut s = Session::new();
assert!(s.execute("prefs.set", &json!({"key": "keyboardIncrement", "value": -1})).is_err());
assert!(s.execute("prefs.set", &json!({"key": "anchorSize", "value": 9})).is_err());
assert!(s.execute("prefs.set", &json!({"key": "anchorSize", "value": 2.5})).is_err());
assert!(s.execute("prefs.set", &json!({"key": "scaleStrokes", "value": 3})).is_err());
assert!(s.execute("prefs.set", &json!({"key": "uiBrightness", "value": "purple"})).is_err());
assert!(s.execute("prefs.set", &json!({"key": "gridColor", "value": "#12345"})).is_err());
assert!(s.execute("prefs.set", &json!({"key": "nope", "value": 1})).is_err());
assert!(s.execute("prefs.set", &json!({"key": "scaleStrokes"})).is_err());
assert_eq!(s.prefs, Prefs::default());
}
#[test]
fn set_normalizes_strings_labels_and_colours() {
let mut s = Session::new();
let r = s
.execute(
"prefs.set",
&json!({"values": {"uiBrightness": "Medium Light", "gridColor": "ABCDEF", "keyboardIncrement": "2.5 pt", "scaleStrokes": "false"}}),
)
.unwrap();
assert_eq!(r["uiBrightness"], json!("mediumLight"));
assert_eq!(s.prefs.grid_color, "#abcdef");
assert_eq!(s.prefs.keyboard_increment, 2.5);
assert!(!s.prefs.scale_strokes);
}
#[test]
fn batch_set_is_atomic() {
let mut s = Session::new();
assert!(s.execute("prefs.set", &json!({"values": {"keyboardIncrement": 3, "anchorSize": 99}})).is_err());
assert_eq!(s.prefs.keyboard_increment, 1.0);
}
#[test]
fn grid_prefs_apply_to_open_documents() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
s.execute("prefs.set", &json!({"values": {"gridlineEvery": 36, "gridSubdivisions": 4}})).unwrap();
let g = &s.doc().unwrap().doc.grid;
assert_eq!((g.spacing, g.subdivisions), (36.0, 4));
}
/// Guides & Grid › Gridline every and Subdivisions (#394): set with no document open, as the
/// desktop app restores them at launch, they give `file.new`'s document their grid.
#[test]
fn grid_prefs_apply_to_new_documents() {
let mut s = Session::new();
s.execute("prefs.set", &json!({"values": {"gridlineEvery": 36, "gridSubdivisions": 4}})).unwrap();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let g = &s.doc().unwrap().doc.grid;
assert_eq!((g.spacing, g.subdivisions), (36.0, 4));
}
/// An SVG file with no grid in it.
const GRIDLESS_SVG: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><rect width="10" height="10"/></svg>"#;
/// Gridline every and Subdivisions (#394): an SVG or a plain PDF opens with their grid, while a
/// native file, and the native document a PDF saved with Preserve Editing carries, keep the grid
/// they were saved with.
#[test]
fn grid_prefs_apply_to_opened_files_without_a_grid() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
s.execute("prefs.set", &json!({"values": {"gridlineEvery": 18, "gridSubdivisions": 3}})).unwrap();
let native = vectorcraft_format::save(&s.doc().unwrap().doc, false);
let mut pdf = |editing: bool| {
let v = s.execute("document.exportPdf", &json!({"preserveEditing": editing})).unwrap();
vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()
};
let (editable, plain) = (pdf(true), pdf(false));
s.execute("prefs.set", &json!({"values": {"gridlineEvery": 36, "gridSubdivisions": 4}})).unwrap();
let mut open = |name: &str, bytes: &[u8]| {
let r = s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)})).unwrap();
let g = &s.doc().unwrap().doc.grid;
(g.spacing, g.subdivisions, r["restored"].as_bool().unwrap())
};
assert_eq!(open("drawing.svg", GRIDLESS_SVG.as_bytes()), (36.0, 4, false));
assert_eq!(open("plain.pdf", &plain), (36.0, 4, false));
assert_eq!(open("saved.vectorcraft", &native), (18.0, 3, false));
assert_eq!(open("editable.pdf", &editable), (18.0, 3, true));
}
/// Gridline every and Subdivisions (#394): File › Revert reads the file again, and an SVG gets
/// their grid while a native file gets the grid it was saved with.
#[test]
fn grid_prefs_apply_to_reverted_files_without_a_grid() {
let dir = std::env::temp_dir().join(format!("vc-grid-revert-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = |name: &str| dir.join(name).to_string_lossy().to_string();
let (svg, native) = (path("drawing.svg"), path("saved.vectorcraft"));
std::fs::write(&svg, GRIDLESS_SVG).unwrap();
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
s.execute("prefs.set", &json!({"values": {"gridlineEvery": 18, "gridSubdivisions": 3}})).unwrap();
s.execute("document.save", &json!({"path": native})).unwrap();
// The preference changes while the file is open, which sets the open documents' grid.
let mut reverted = |file: &str, every: f64| {
s.execute("document.open", &json!({"path": file})).unwrap();
s.execute("prefs.set", &json!({"values": {"gridlineEvery": every, "gridSubdivisions": 4}})).unwrap();
s.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 10, "height": 10})).unwrap();
s.execute("file.revert", &json!({})).unwrap();
let g = &s.doc().unwrap().doc.grid;
(g.spacing, g.subdivisions)
};
assert_eq!(reverted(&svg, 36.0), (36.0, 4));
assert_eq!(reverted(&native, 24.0), (18.0, 3));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn history_states_limit_undo_depth() {
let mut s = Session::new();
s.execute("prefs.set", &json!({"key": "historyStates", "value": 5})).unwrap();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
for i in 0..9 {
s.execute("shape.rectangle", &json!({"x": i * 10, "y": 0, "width": 5, "height": 5})).unwrap();
}
assert!(s.doc().unwrap().history.undo.len() <= 5);
}
#[test]
fn reset_category_and_all() {
let mut s = Session::new();
s.execute("prefs.set", &json!({"values": {"keyboardIncrement": 7, "gridColor": "#000000"}})).unwrap();
s.execute("prefs.reset", &json!({"category": "General"})).unwrap();
assert_eq!(s.prefs.keyboard_increment, 1.0);
assert_eq!(s.prefs.grid_color, "#000000");
s.execute("prefs.reset", &json!({})).unwrap();
assert_eq!(s.prefs, Prefs::default());
assert!(s.execute("prefs.reset", &json!({"category": "Bogus"})).is_err());
}
#[test]
fn list_describes_every_pref() {
let mut s = Session::new();
let l = s.execute("prefs.list", &json!({})).unwrap();
let a = l.as_array().unwrap();
assert_eq!(a.len(), PREF_SPECS.len());
assert!(a.iter().any(|e| e["key"] == "renderThreads" && e["kind"] == "integer" && e["min"] == -1));
}
#[test]
fn prefs_serde_round_trip_and_tolerates_missing_fields() {
let p = Prefs { ui_scaling: 1.25, units_general: "millimeters".into(), ..Default::default() };
let back: Prefs = serde_json::from_value(p.to_json()).unwrap();
assert_eq!(back, p);
let partial: Prefs = serde_json::from_value(json!({"keyboardIncrement": 4})).unwrap();
assert_eq!(partial.keyboard_increment, 4.0);
assert_eq!(partial.corner_radius, 12.0);
}
/// Performance › Graphics Processor (#306, #502): automatic by default, set by value or label,
/// kept through a save/load round trip, and a bad value (0.5.0's `powerSaving` among them) is an
/// error that changes nothing.
#[test]
fn gpu_preference_defaults_to_automatic_and_validates() {
let mut s = Session::new();
assert_eq!(s.prefs.gpu_preference, "automatic");
assert_eq!(s.execute("prefs.get", &json!({"key": "gpuPreference"})).unwrap(), json!("automatic"));
s.execute("prefs.set", &json!({"key": "gpuPreference", "value": "highPerformance"})).unwrap();
assert_eq!(s.prefs.gpu_preference, "highPerformance");
s.execute("prefs.set", &json!({"key": "gpuPreference", "value": "Power Saving (integrated)"})).unwrap();
assert_eq!(s.prefs.gpu_preference, "lowPower");
s.execute("prefs.set", &json!({"key": "gpuPreference", "value": "automatic"})).unwrap();
assert_eq!(s.prefs.gpu_preference, "automatic");
for bad in [json!("powerSaving"), json!("turbo"), json!(""), json!(1), json!(null), json!(["highPerformance"])] {
assert!(s.execute("prefs.set", &json!({"key": "gpuPreference", "value": bad})).is_err(), "{bad}");
assert_eq!(s.prefs.gpu_preference, "automatic");
}
let p = Prefs { gpu_preference: "lowPower".into(), ..Default::default() };
let back: Prefs = serde_json::from_value(p.to_json()).unwrap();
assert_eq!(back.gpu_preference, "lowPower");
// Preference files written before the preference existed get the default.
let old: Prefs = serde_json::from_value(json!({"gpuPerformance": true})).unwrap();
assert_eq!(old.gpu_preference, "automatic");
let l = s.execute("prefs.list", &json!({})).unwrap();
let row = l.as_array().unwrap().iter().find(|e| e["key"] == "gpuPreference").unwrap().clone();
assert_eq!(row["category"], "Performance");
assert_eq!(row["options"], json!(["automatic", "lowPower", "highPerformance"]));
s.execute("prefs.set", &json!({"key": "gpuPreference", "value": "highPerformance"})).unwrap();
s.execute("prefs.reset", &json!({"category": "Performance"})).unwrap();
assert_eq!(s.prefs.gpu_preference, "automatic");
}
/// Performance › Linux Display Server (#1151): automatic by default, X11 by value or label, and
/// anything else is an error that changes nothing.
#[test]
fn linux_display_server_defaults_to_automatic_and_validates() {
let mut s = Session::new();
assert_eq!(s.prefs.linux_display_server, "automatic");
s.execute("prefs.set", &json!({"key": "linuxDisplayServer", "value": "X11 (XWayland)"})).unwrap();
assert_eq!(s.prefs.linux_display_server, "x11");
for bad in [json!("wayland"), json!(""), json!(11), json!(null)] {
assert!(s.execute("prefs.set", &json!({"key": "linuxDisplayServer", "value": bad})).is_err(), "{bad}");
assert_eq!(s.prefs.linux_display_server, "x11");
}
assert_eq!(s.prefs.to_json()["linuxDisplayServer"], "x11");
s.execute("prefs.reset", &json!({"category": "Performance"})).unwrap();
assert_eq!(s.prefs.linux_display_server, "automatic");
}
/// Selection & Anchor Display › Tolerance, Object Selection by Path Only and Command Click to
/// Select Objects Behind, and General › Double Click To Isolate, as the Selection tool sees them
/// (#394).
#[test]
fn selection_preferences_drive_the_selection_tool() {
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let rect = |s: &mut Session, x: f64| {
NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap())
};
// Two filled squares overlapping between x = 150 and 200, `front` on top.
let (back, front) = (rect(&mut s, 100.0), rect(&mut s, 150.0));
s.select_tool("selection", ViewInfo::default()).unwrap();
let set = |s: &mut Session, key: &str, value: Value| s.execute("prefs.set", &json!({"key": key, "value": value})).unwrap();
let click = |s: &mut Session, x: f64, mods: Mods| {
for k in [PointerKind::Down, PointerKind::Up] {
// Halfway between the bounding box's handles.
s.pointer(&PointerEvent::new(k, x, 125.0).with_mods(mods), ViewInfo::default()).unwrap();
}
s.doc().unwrap().selection.objects.clone()
};
let plain = Mods::default();
// Tolerance: the right edge's stroke ends at x = 250.5.
assert_eq!(click(&mut s, 252.5, plain), vec![front], "2 px out: within the default 3 px");
assert!(click(&mut s, 256.5, plain).is_empty(), "6 px out: beyond 3 px");
set(&mut s, "selectionTolerance", json!(8));
assert_eq!(click(&mut s, 256.5, plain), vec![front], "6 px out: within 8 px");
set(&mut s, "selectionTolerance", json!(1));
assert!(click(&mut s, 252.5, plain).is_empty(), "2 px out: beyond 1 px");
set(&mut s, "selectionTolerance", json!(3));
// Object Selection by Path Only: the fill no longer selects, the path does.
assert_eq!(click(&mut s, 225.0, plain), vec![front]);
set(&mut s, "objectSelectionByPathOnly", json!(true));
assert!(click(&mut s, 225.0, plain).is_empty(), "a click inside the fill selects nothing");
assert_eq!(click(&mut s, 250.0, plain), vec![front], "a click on the path selects it");
set(&mut s, "objectSelectionByPathOnly", json!(false));
// Command Click to Select Objects Behind (on by default): each Cmd/Ctrl-click goes one down,
// then back to the top.
let cmd = Mods { cmd: true, ..Mods::default() };
assert_eq!(click(&mut s, 175.0, plain), vec![front]);
assert_eq!(click(&mut s, 175.0, cmd), vec![back], "the object behind");
assert_eq!(click(&mut s, 175.0, cmd), vec![front], "back to the topmost");
set(&mut s, "ctrlClickSelectsBehind", json!(false));
assert_eq!(click(&mut s, 175.0, cmd), vec![front], "off: a plain click");
// Double Click To Isolate (on by default) isolates a group; off, it doesn't.
s.execute("select.all", &json!({})).unwrap();
s.execute("object.group", &json!({})).unwrap();
let double = |s: &mut Session| {
s.pointer(&PointerEvent::new(PointerKind::DoubleClick, 175.0, 150.0), ViewInfo::default()).unwrap();
s.doc().unwrap().isolation
};
set(&mut s, "doubleClickToIsolate", json!(false));
assert_eq!(double(&mut s), None, "off: no isolation");
set(&mut s, "doubleClickToIsolate", json!(true));
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]
fn use_precise_cursors_makes_drawing_cursors_crosshairs() {
use vectorcraft_tools::{Cursor, Mods};
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let cursor = |s: &mut Session, tool: &str| {
s.select_tool(tool, ViewInfo::default()).unwrap();
s.cursor(vectorcraft_geom::Point::new(300.0, 300.0), Mods::default(), ViewInfo::default())
};
assert_eq!(cursor(&mut s, "pen"), Cursor::Pen);
assert_eq!(cursor(&mut s, "eyedropper"), Cursor::Eyedropper);
s.execute("prefs.set", &json!({"key": "usePreciseCursors", "value": true})).unwrap();
assert_eq!(cursor(&mut s, "pen"), Cursor::Crosshair);
assert_eq!(cursor(&mut s, "eyedropper"), Cursor::Crosshair);
assert_eq!(cursor(&mut s, "selection"), Cursor::Arrow);
s.execute("prefs.set", &json!({"key": "usePreciseCursors", "value": false})).unwrap();
assert_eq!(cursor(&mut s, "eyedropper"), Cursor::Eyedropper);
}
// ---------- #394, second batch ----------
fn new_doc() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
s
}
fn square(s: &mut Session, x: f64, y: f64) -> NodeId {
NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap())
}
fn set_pref(s: &mut Session, key: &str, value: Value) {
s.execute("prefs.set", &json!({"key": key, "value": value})).unwrap();
}
fn gesture(s: &mut Session, v: ViewInfo, events: &[(vectorcraft_tools::PointerKind, f64, f64)]) {
for (k, x, y) in events {
s.pointer(&vectorcraft_tools::PointerEvent::new(*k, *x, *y), v).unwrap();
}
}
fn top_left(s: &Session, id: NodeId) -> (f64, f64) {
let b = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap();
(b.x0, b.y0)
}
/// View › Snap to Point with Selection & Anchor Display › Snap to Point's distance (#394): with
/// Smart Guides off, the point a selection is dragged by lands on an anchor within 2 px.
#[test]
fn snap_to_point_lands_a_dragged_selection_on_anchors() {
use vectorcraft_tools::PointerKind::{Down, Drag, Up};
let mut s = new_doc();
let a = square(&mut s, 100.0, 100.0);
square(&mut s, 300.0, 100.0);
let v = ViewInfo { smart_guides: false, ..ViewInfo::default() };
s.select_tool("selection", v).unwrap();
// Grabbed by its centre and dropped 1.5, 1 px from the other square's top-left anchor.
let drag = |s: &mut Session, v: ViewInfo| {
gesture(s, v, &[(Down, 125.0, 125.0), (Drag, 200.0, 110.0), (Drag, 301.5, 101.0), (Up, 301.5, 101.0)]);
let at = top_left(s, a);
s.execute("edit.undo", &json!({})).unwrap();
at
};
assert_eq!(drag(&mut s, v), (275.0, 75.0), "the centre lands on the anchor");
set_pref(&mut s, "snapToPointTolerance", json!(1));
assert_eq!(drag(&mut s, v), (276.5, 76.0), "1.8 px is beyond 1 px");
set_pref(&mut s, "snapToPointTolerance", json!(2));
assert_eq!(drag(&mut s, ViewInfo { snap_to_point: false, ..v }), (276.5, 76.0), "View › Snap to Point off");
}
/// Smart Guides › Spacing Guides (#394): a square dragged 21 pt to the right of a row of two
/// squares 20 pt apart lands 20 pt from it, with the gaps marked; off, it stays at 21.
#[test]
fn spacing_guides_space_a_dragged_square_like_its_row() {
use vectorcraft_tools::PointerKind::{Down, Drag, Up};
let mut s = new_doc();
square(&mut s, 20.0, 300.0);
square(&mut s, 90.0, 300.0);
let m = square(&mut s, 200.0, 305.0);
let v = ViewInfo::default();
s.select_tool("selection", v).unwrap();
// `m` grabbed by its centre and dragged so its left edge is 21 pt from the second square.
let drag = |s: &mut Session| {
gesture(s, v, &[(Down, 225.0, 330.0), (Drag, 200.0, 330.0), (Drag, 186.0, 330.0)]);
let marks = s.overlays(v).iter().filter(|o| matches!(o, vectorcraft_tools::Overlay::Line { a, b, .. } if a.y == b.y)).count();
gesture(s, v, &[(Up, 186.0, 330.0)]);
let at = top_left(s, m).0;
s.execute("edit.undo", &json!({})).unwrap();
(at, marks)
};
let (at, marks) = drag(&mut s);
assert_eq!(at, 160.0, "20 pt from it");
assert!(marks >= 2, "both gaps marked");
set_pref(&mut s, "spacingGuides", json!(false));
assert_eq!(drag(&mut s), (161.0, 0), "off: where it was dragged");
}
/// Preferences › Smart Guides (#394): Color, Alignment Guides, Anchor/Path Labels, Measurement
/// Labels, Transform Tools and Snapping Tolerance change what the Selection tool shows while a
/// square is dragged into line with another, and how far the pull reaches; a hidden guide still
/// snaps.
#[test]
fn smart_guide_display_preferences_filter_the_overlays() {
use vectorcraft_tools::Overlay;
use vectorcraft_tools::PointerKind::{Down, Drag, Up};
let mut s = new_doc();
square(&mut s, 100.0, 100.0);
let b = square(&mut s, 200.0, 100.0);
let v = ViewInfo::default();
s.select_tool("selection", v).unwrap();
let count = |s: &mut Session, f: &dyn Fn(&Overlay) -> bool| s.overlays(v).iter().filter(|o| f(o)).count();
let line = |o: &Overlay| matches!(o, Overlay::Line { .. });
let label = |o: &Overlay| matches!(o, Overlay::Label { .. });
let measure = |o: &Overlay| matches!(o, Overlay::Measure { .. });
// `b` grabbed by its centre and dragged so its left edge comes `off` px from the first
// square's right edge (x = 150): the x of its left edge mid-drag, before the pointer is let go.
let drag_to = |s: &mut Session, off: f64| {
gesture(s, v, &[(Down, 225.0, 125.0), (Drag, 200.0, 125.0), (Drag, 175.0 + off, 125.0)]);
top_left(s, b).0
};
let release = |s: &mut Session| {
gesture(s, v, &[(Up, 175.0, 125.0)]);
s.execute("edit.undo", &json!({})).unwrap();
};
assert_eq!(drag_to(&mut s, 3.0), 150.0, "3 px off: into line");
assert!(count(&mut s, &line) > 0 && count(&mut s, &measure) == 1);
let magenta = s.overlays(v).iter().find_map(|o| if let Overlay::Line { color, .. } = o { Some(*color) } else { None });
assert_eq!(magenta, Some(vectorcraft_tools::guides::MAGENTA), "the default colour");
release(&mut s);
set_pref(&mut s, "smartGuideColor", json!("#00ff00"));
drag_to(&mut s, 3.0);
assert!(s.overlays(v).iter().all(|o| !matches!(o, Overlay::Line { color, .. } | Overlay::Label { color, .. } if *color != [0, 255, 0])));
release(&mut s);
set_pref(&mut s, "alignmentGuides", json!(false));
assert_eq!(drag_to(&mut s, 3.0), 150.0, "still into line");
assert_eq!(count(&mut s, &line), 0, "no line");
release(&mut s);
set_pref(&mut s, "measurementLabels", json!(false));
drag_to(&mut s, 3.0);
assert_eq!(count(&mut s, &measure), 0);
release(&mut s);
// Snapping Tolerance: 6 px is beyond the default 4, within 8.
assert_eq!(drag_to(&mut s, 6.0), 156.0);
release(&mut s);
set_pref(&mut s, "snappingTolerance", json!(8));
assert_eq!(drag_to(&mut s, 6.0), 150.0);
release(&mut s);
// Transform Tools: the size readout while a bounding-box handle is dragged.
let scale = |s: &mut Session| {
gesture(s, v, &[(Down, 225.0, 150.0), (Drag, 225.0, 170.0)]);
let n = count(s, &measure);
gesture(s, v, &[(Up, 225.0, 170.0)]);
s.execute("edit.undo", &json!({})).unwrap();
n
};
assert_eq!(scale(&mut s), 1);
set_pref(&mut s, "transformToolsGuides", json!(false));
assert_eq!(scale(&mut s), 0);
// Anchor/Path Labels: a drawn corner pulled onto an anchor says "anchor" (and still lands there).
s.select_tool("rectangle", v).unwrap();
let draw = |s: &mut Session| {
gesture(s, v, &[(Down, 300.0, 300.0), (Drag, 153.0, 151.0)]);
let n = count(s, &label);
gesture(s, v, &[(Up, 153.0, 151.0)]);
let at = top_left(s, s.doc().unwrap().selection.objects[0]);
s.execute("edit.undo", &json!({})).unwrap();
(n, at)
};
assert_eq!(draw(&mut s), (1, (150.0, 150.0)));
set_pref(&mut s, "anchorPathLabels", json!(false));
assert_eq!(draw(&mut s), (0, (150.0, 150.0)));
}
/// Enable Rubber Band for Pen Tool / Curvature Tool (#394): off, no segment follows the pointer.
#[test]
fn rubber_band_preferences_hide_the_preview_to_the_pointer() {
use vectorcraft_tools::Overlay;
use vectorcraft_tools::PointerKind::{Down, Move, Up};
let v = ViewInfo::default();
for (tool, key) in [("pen", "penRubberBand"), ("curvature", "curvatureRubberBand")] {
let mut s = new_doc();
s.select_tool(tool, v).unwrap();
gesture(&mut s, v, &[(Down, 50.0, 50.0), (Up, 50.0, 50.0), (Down, 150.0, 50.0), (Up, 150.0, 50.0), (Move, 200.0, 150.0)]);
let bands = |s: &mut Session| s.overlays(v).iter().filter(|o| matches!(o, Overlay::Path { .. })).count();
assert_eq!(bands(&mut s), 1, "{tool}: the rubber band");
set_pref(&mut s, key, json!(false));
assert_eq!(bands(&mut s), 0, "{tool}: off");
}
}
/// Move Locked and Hidden Artwork with Artboard (#394): off, an artboard moved with its art
/// leaves locked and hidden objects where they are; on, they move too.
#[test]
fn move_locked_and_hidden_artwork_with_artboard() {
let mut s = new_doc();
let (free, locked, hidden) = (square(&mut s, 10.0, 10.0), square(&mut s, 100.0, 10.0), square(&mut s, 200.0, 10.0));
for (id, cmd) in [(locked, "object.lock"), (hidden, "object.hide")] {
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute(cmd, &json!({})).unwrap();
}
let moved = |s: &mut Session| {
s.execute("artboard.move", &json!({"index": 0, "dx": 30, "dy": 0, "moveArt": true})).unwrap();
let xs = [free, locked, hidden].map(|id| top_left(s, id).0);
s.execute("edit.undo", &json!({})).unwrap();
xs
};
assert_eq!(moved(&mut s), [40.0, 100.0, 200.0]);
set_pref(&mut s, "moveLockedWithArtboard", json!(true));
assert_eq!(moved(&mut s), [40.0, 130.0, 230.0]);
}
/// General › Transform Pattern Tiles (#394) is what transforms do with pattern fills unless their
/// `patterns` param says otherwise (the dialogs' Transform Patterns).
#[test]
fn transform_pattern_tiles_is_the_transforms_default() {
use vectorcraft_color::Paint;
let mut s = new_doc();
let tile = square(&mut s, 0.0, 0.0);
s.execute("select.set", &json!({"ids": [tile.0]})).unwrap();
s.execute("object.pattern.make", &json!({"name": "Dots", "width": 20, "height": 20})).unwrap();
s.execute("object.pattern.done", &json!({})).unwrap();
let big = square(&mut s, 100.0, 100.0);
s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap();
s.execute("select.set", &json!({"ids": [big.0]})).unwrap();
let tiles = |s: &Session| match s.doc().unwrap().doc.node(big).unwrap().appearance.fill_paint() {
Paint::Pattern { xf, .. } => xf.as_coeffs(),
p => panic!("not a pattern: {p:?}"),
};
s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap();
assert_eq!(tiles(&s), [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], "off: the tiles stay");
set_pref(&mut s, "transformPatternTiles", json!(true));
s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap();
assert_eq!(tiles(&s), [1.0, 0.0, 0.0, 1.0, 10.0, 0.0], "on: they move with the art");
assert_eq!(s.journal.last().unwrap().1["patterns"], json!(true), "the journal keeps the choice");
s.execute("object.scale", &json!({"sx": 200, "origin": [0, 0], "patterns": false})).unwrap();
assert_eq!(tiles(&s), [1.0, 0.0, 0.0, 1.0, 10.0, 0.0], "the param wins");
s.execute("object.scale", &json!({"sx": 50, "origin": [0, 0]})).unwrap();
assert_eq!(tiles(&s), [0.5, 0.0, 0.0, 0.5, 5.0, 0.0]);
}
/// A 50 pt square at (100, 100) with a 1 pt stroke, filled with a 20 pt pattern, selected.
fn patterned_square(s: &mut Session) -> NodeId {
let tile = square(s, 0.0, 0.0);
s.execute("select.set", &json!({"ids": [tile.0]})).unwrap();
s.execute("object.pattern.make", &json!({"name": "Dots", "width": 20, "height": 20})).unwrap();
s.execute("object.pattern.done", &json!({})).unwrap();
let id = square(s, 100.0, 100.0);
s.execute("paint.setFill", &json!({"ids": [id.0], "swatch": "Dots"})).unwrap();
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
id
}
/// The tile transform of the pattern fill of `id`.
fn pattern_tiles(s: &Session, id: NodeId) -> [f64; 6] {
match s.doc().unwrap().doc.node(id).unwrap().appearance.fill_paint() {
vectorcraft_color::Paint::Pattern { xf, .. } => xf.as_coeffs(),
p => panic!("not a pattern: {p:?}"),
}
}
/// Transform Again repeats the options of the transform it repeats, whatever the preferences say:
/// a Move with Transform Patterns off while Transform Pattern Tiles is on, and a Scale with Scale
/// Strokes & Effects on while its preference is off.
#[test]
fn transform_again_repeats_the_options_of_the_transform_it_repeats() {
let mut s = new_doc();
let id = patterned_square(&mut s);
set_pref(&mut s, "transformPatternTiles", json!(true));
s.execute("object.move", &json!({"dx": 10, "dy": 0, "patterns": false})).unwrap();
s.execute("object.transformAgain", &json!({})).unwrap();
assert_eq!(top_left(&s, id), (120.0, 100.0), "the square moved twice");
assert_eq!(pattern_tiles(&s, id), [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], "the tiles stay");
assert_eq!(s.journal.last().unwrap().1, json!({"patterns": false}), "the journal keeps the choice");
s.execute("object.scale", &json!({"sx": 200, "strokes": true})).unwrap();
s.execute("object.transformAgain", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.node(id).unwrap().appearance.stroke_width(), 4.0, "the 1 pt stroke scaled twice");
}
/// A recorded Transform Again plays back with the Transform Patterns its step records, whatever the
/// transform it repeats used.
#[test]
fn a_recorded_transform_again_plays_back_with_its_transform_patterns() {
let mut s = new_doc();
let id = patterned_square(&mut s);
s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap();
let start = s.journal.len();
s.execute("object.transformAgain", &json!({})).unwrap();
let action = s.journal_for_action(start);
assert_eq!(action, [("object.transformAgain".to_string(), json!({"patterns": false}))], "recorded with the preference off");
// With the preference on, a Move moves the tiles, and the recorded Transform Again leaves them.
set_pref(&mut s, "transformPatternTiles", json!(true));
s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap();
assert_eq!(pattern_tiles(&s, id), [1.0, 0.0, 0.0, 1.0, 10.0, 0.0]);
for (cmd, p) in &action {
s.execute(cmd, p).unwrap();
}
assert_eq!(top_left(&s, id), (140.0, 100.0), "the square moved");
assert_eq!(pattern_tiles(&s, id), [1.0, 0.0, 0.0, 1.0, 10.0, 0.0], "the tiles stay");
}
/// General › Transform Pattern Tiles (#394) applies to Align and Distribute as it does to the
/// transforms: off, the tiles of a pattern-filled square stay in place when the square moves;
/// on, they move with it, and `patterns: false` keeps them in place for one call.
#[test]
fn align_and_distribute_follow_transform_pattern_tiles() {
use vectorcraft_color::Paint;
let mut s = new_doc();
let plain = square(&mut s, 0.0, 0.0);
s.execute("select.set", &json!({"ids": [plain.0]})).unwrap();
s.execute("object.pattern.make", &json!({"name": "Dots", "width": 20, "height": 20})).unwrap();
s.execute("object.pattern.done", &json!({})).unwrap();
let far = square(&mut s, 300.0, 0.0);
let filled = square(&mut s, 100.0, 0.0);
s.execute("paint.setFill", &json!({"ids": [filled.0], "swatch": "Dots"})).unwrap();
s.execute("select.set", &json!({"ids": [plain.0, filled.0, far.0]})).unwrap();
let tiles = |s: &Session| match s.doc().unwrap().doc.node(filled).unwrap().appearance.fill_paint() {
Paint::Pattern { xf, .. } => xf.as_coeffs(),
p => panic!("not a pattern: {p:?}"),
};
// Align left moves the square 100 pt left, to the left edge of `plain`. Both Distribute
// commands move it 50 pt right, halfway between the other two.
for (cmd, p, dx) in [
("object.align", json!({"horizontal": "left"}), -100.0),
("object.distribute", json!({"horizontal": "left"}), 50.0),
("object.distributeSpacing", json!({"axis": "horizontal"}), 50.0),
] {
s.execute(cmd, &p).unwrap();
assert_eq!(tiles(&s), [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], "{cmd}: off, the tiles stay");
assert_eq!(s.journal.last().unwrap().1["patterns"], json!(false), "{cmd}: the journal keeps the choice");
s.execute("edit.undo", &json!({})).unwrap();
set_pref(&mut s, "transformPatternTiles", json!(true));
s.execute(cmd, &p).unwrap();
assert_eq!(tiles(&s), [1.0, 0.0, 0.0, 1.0, dx, 0.0], "{cmd}: on, they move with the square");
assert_eq!(s.journal.last().unwrap().1["patterns"], json!(true), "{cmd}: the journal keeps the choice");
s.execute("edit.undo", &json!({})).unwrap();
let mut p = p;
p["patterns"] = json!(false);
s.execute(cmd, &p).unwrap();
assert_eq!(tiles(&s), [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], "{cmd}: the param wins");
s.execute("edit.undo", &json!({})).unwrap();
set_pref(&mut s, "transformPatternTiles", json!(false));
}
}
/// General › Transform Pattern Tiles (#394) applies to art moved or duplicated with its artboard
/// as it does to the transforms: off, the tiles of a pattern-filled square stay in place; on, they
/// move with the square or its copy, and `patterns: false` keeps them in place for one call.
#[test]
fn art_moved_with_its_artboard_follows_transform_pattern_tiles() {
use vectorcraft_color::Paint;
let mut s = new_doc();
let plain = square(&mut s, 500.0, 0.0);
s.execute("select.set", &json!({"ids": [plain.0]})).unwrap();
s.execute("object.pattern.make", &json!({"name": "Dots", "width": 20, "height": 20})).unwrap();
s.execute("object.pattern.done", &json!({})).unwrap();
s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 400, "height": 400})).unwrap();
let filled = square(&mut s, 1100.0, 100.0);
s.execute("paint.setFill", &json!({"ids": [filled.0], "swatch": "Dots"})).unwrap();
let tiles = |s: &Session, id: NodeId| match s.doc().unwrap().doc.node(id).unwrap().appearance.fill_paint() {
Paint::Pattern { xf, .. } => xf.as_coeffs(),
p => panic!("not a pattern: {p:?}"),
};
// Artboard 1 starts at x 1000 and holds the square; artboard 0 holds no art, and `plain` is on
// no artboard. Duplicate Artboard puts the copy 20 pt right of the last artboard, artboard 1, so
// the art's copy is 420 pt right of the square. Rearranged in a row, artboard 1 moves to x 420,
// 20 pt right of artboard 0, and `artboard.setProps` puts it at x 1030. `moved` and `ids` name
// the art moved or its copies.
for (cmd, p, key, d) in [
("artboard.move", json!({"index": 1, "dx": 30, "dy": 0, "moveArt": true}), Some("moved"), [30.0, 0.0]),
("artboard.move", json!({"indices": [0, 1], "dx": 30, "dy": 0, "moveArt": true}), Some("moved"), [30.0, 0.0]),
("artboard.move", json!({"index": 1, "dx": 0, "dy": 500, "moveArt": true, "copy": true}), Some("moved"), [0.0, 500.0]),
("artboard.duplicate", json!({"index": 1, "art": true}), Some("ids"), [420.0, 0.0]),
("artboard.rearrange", json!({"layout": "row"}), None, [-580.0, 0.0]),
("artboard.setProps", json!({"index": 1, "x": 1030, "moveArt": true}), None, [30.0, 0.0]),
] {
let run = |s: &mut Session, p: &Value| {
let r = s.execute(cmd, p).unwrap();
let ids = key.and_then(|k| r[k].as_array().cloned()).unwrap_or_else(|| vec![json!(filled.0)]);
assert_eq!(ids.len(), 1, "{cmd}: the square alone goes with the artboard");
NodeId(ids[0].as_u64().unwrap())
};
let id = run(&mut s, &p);
assert_eq!(tiles(&s, id), [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], "{cmd}: off, the tiles stay");
assert_eq!(s.journal.last().unwrap().1["patterns"], json!(false), "{cmd}: the journal keeps the choice");
s.execute("edit.undo", &json!({})).unwrap();
set_pref(&mut s, "transformPatternTiles", json!(true));
let id = run(&mut s, &p);
assert_eq!(tiles(&s, id), [1.0, 0.0, 0.0, 1.0, d[0], d[1]], "{cmd}: on, they move with the square");
assert_eq!(s.journal.last().unwrap().1["patterns"], json!(true), "{cmd}: the journal keeps the choice");
s.execute("edit.undo", &json!({})).unwrap();
let mut p = p;
p["patterns"] = json!(false);
let id = run(&mut s, &p);
assert_eq!(tiles(&s, id), [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], "{cmd}: the param wins");
s.execute("edit.undo", &json!({})).unwrap();
set_pref(&mut s, "transformPatternTiles", json!(false));
}
}
/// General › Transform Pattern Tiles (#394) applies to the objects selected whole that move with
/// direct-selected anchors (an arrow key, `path.moveAnchors` or a Direct Selection drag of an
/// anchor): off, the tiles of a pattern-filled square stay in place; on, they move with it, and
/// `patterns: false` keeps them in place for one call.
#[test]
fn whole_objects_moved_with_selected_anchors_follow_transform_pattern_tiles() {
use vectorcraft_tools::PointerKind::{Down, Drag, Up};
let mut s = new_doc();
let filled = patterned_square(&mut s);
let other = square(&mut s, 300.0, 100.0);
let v = ViewInfo { smart_guides: false, snap_to_point: false, corner_widgets: false, ..ViewInfo::default() };
s.select_tool("directSelection", v).unwrap();
// The square is selected whole with the top-left anchor of `other`, as a click on the square's
// fill and a Shift-click on that anchor select them. Each move takes both 10 pt right: Shift
// with an arrow key, `path.moveAnchors`, and a drag of the anchor (no params).
for (cmd, p) in [
("object.nudge", Some(json!({"dx": 1, "dy": 0, "big": true}))),
("path.moveAnchors", Some(json!({"dx": 10, "dy": 0}))),
("path.moveAnchors", None),
] {
let run = |s: &mut Session, p: Option<&Value>| {
s.execute("select.set", &json!({"ids": [filled.0]})).unwrap();
s.execute("select.anchors", &json!({"id": other.0, "anchors": [[0, 0]], "mode": "add"})).unwrap();
match p {
Some(p) => {
s.execute(cmd, p).unwrap();
}
None => gesture(s, v, &[(Down, 300.0, 100.0), (Drag, 305.0, 100.0), (Drag, 310.0, 100.0), (Up, 310.0, 100.0)]),
}
assert_eq!(top_left(s, filled), (110.0, 100.0), "{cmd}: the square moves whole");
let anchors = &s.doc().unwrap().doc.node(other).unwrap().path_data().unwrap().subpaths[0].anchors;
assert_eq!((anchors[0].p.x, anchors[1].p.x), (310.0, 350.0), "{cmd}: the selected anchor moves alone");
assert_eq!(s.journal.last().unwrap().0, cmd);
};
run(&mut s, p.as_ref());
assert_eq!(pattern_tiles(&s, filled), [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], "{cmd}: off, the tiles stay");
assert_eq!(s.journal.last().unwrap().1["patterns"], json!(false), "{cmd}: the journal keeps the choice");
s.execute("edit.undo", &json!({})).unwrap();
set_pref(&mut s, "transformPatternTiles", json!(true));
run(&mut s, p.as_ref());
assert_eq!(pattern_tiles(&s, filled), [1.0, 0.0, 0.0, 1.0, 10.0, 0.0], "{cmd}: on, they move with the square");
assert_eq!(s.journal.last().unwrap().1["patterns"], json!(true), "{cmd}: the journal keeps the choice");
s.execute("edit.undo", &json!({})).unwrap();
if let Some(mut p) = p {
p["patterns"] = json!(false);
run(&mut s, Some(&p));
assert_eq!(pattern_tiles(&s, filled), [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], "{cmd}: the param wins");
s.execute("edit.undo", &json!({})).unwrap();
}
set_pref(&mut s, "transformPatternTiles", json!(false));
}
}
/// General › Select Same Tint % (#394): off, Select › Same › Fill Color takes every tint of the
/// swatch; on, only the same tint.
#[test]
fn select_same_tint_percent() {
let mut s = new_doc();
s.execute("swatch.new", &json!({"name": "Ink", "color": "#cc0066", "spot": true})).unwrap();
// Before the others: new art takes the last fill applied.
square(&mut s, 300.0, 10.0);
let ids: Vec<NodeId> = [(10.0, 40), (100.0, 40), (200.0, 80)]
.into_iter()
.map(|(x, tint)| {
let id = square(&mut s, x, 10.0);
s.execute("paint.setFill", &json!({"ids": [id.0], "swatch": "Ink", "tint": tint})).unwrap();
id
})
.collect();
let same = |s: &mut Session| {
s.execute("select.set", &json!({"ids": [ids[0].0]})).unwrap();
s.execute("select.same.fillColor", &json!({})).unwrap();
s.doc().unwrap().selection.objects.len()
};
assert_eq!(same(&mut s), 3, "every tint of Ink");
set_pref(&mut s, "selectSameTintPercent", json!(true));
assert_eq!(same(&mut s), 2, "only Ink 40%");
}
fn style_at(s: &Session, id: NodeId, byte: usize) -> vectorcraft_doc::CharStyle {
let Some(vectorcraft_doc::NodeKind::Text(t)) = s.doc().unwrap().doc.node(id).map(|n| &n.kind) else { panic!("not text") };
let mut at = 0;
for r in &t.runs {
if byte < at + r.text.len() {
return r.style.clone();
}
at += r.text.len();
}
panic!("byte {byte} past the text")
}
/// Type › Size/Leading, Tracking and Baseline Shift (#394): the increments `type.step` and the
/// font size shortcuts step by, on selected type objects and on the Type tool's selected text
/// (Alt+arrows; Cmd/Ctrl too: five steps).
#[test]
fn type_increments_step_the_type() {
use vectorcraft_tools::PointerKind::{Down, Up};
use vectorcraft_tools::{Mods, ToolKey};
let mut s = new_doc();
let id = NodeId(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Hello world", "size": 20})).unwrap()["id"].as_u64().unwrap());
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("type.size.increase", &json!({})).unwrap();
assert_eq!(style_at(&s, id, 0).size, 22.0, "Size/Leading is 2 pt");
set_pref(&mut s, "typeSizeIncrement", json!(3));
s.execute("type.size.decrease", &json!({})).unwrap();
assert_eq!(style_at(&s, id, 0).size, 19.0);
s.execute("type.step", &json!({"attribute": "leading"})).unwrap();
assert!((style_at(&s, id, 0).leading.unwrap() - (19.0 * 1.2 + 3.0)).abs() < 1e-9, "from Auto");
set_pref(&mut s, "baselineShiftIncrement", json!(1.5));
s.execute("type.step", &json!({"attribute": "baselineShift", "by": -2})).unwrap();
assert_eq!(style_at(&s, id, 10).baseline_shift, -3.0);
// The Type tool: Alt+→ tracks the selected text by Tracking (20/1000 em), Cmd-Alt five times.
let v = ViewInfo::default();
s.select_tool("type", v).unwrap();
gesture(&mut s, v, &[(Down, 102.0, 95.0), (Up, 102.0, 95.0)]);
assert_eq!(s.tool_options()["editing"], json!(id.0), "editing the text");
let alt = Mods { alt: true, ..Mods::default() };
let select = |s: &mut Session, a: usize, b: usize| s.set_tool_option("select", &json!({"start": a, "end": b}));
select(&mut s, 0, 5);
s.tool_key(ToolKey::Right, alt, v).unwrap();
assert_eq!((style_at(&s, id, 0).tracking, style_at(&s, id, 6).tracking), (20.0, 0.0));
s.tool_key(ToolKey::Left, Mods { cmd: true, ..alt }, v).unwrap();
assert_eq!(style_at(&s, id, 4).tracking, -80.0);
// At a caret: Alt+← kerns the character before it, Alt+↓ opens up the paragraph's leading.
select(&mut s, 3, 3);
s.tool_key(ToolKey::Left, alt, v).unwrap();
assert_eq!((style_at(&s, id, 2).kerning, style_at(&s, id, 3).kerning), (Some(-20.0), None));
let leading = style_at(&s, id, 0).leading.unwrap();
s.tool_key(ToolKey::Down, alt, v).unwrap();
assert_eq!(style_at(&s, id, 8).leading, Some(leading + 3.0));
// Alt+Shift+↑ raises the selected text's baseline.
select(&mut s, 6, 11);
s.tool_key(ToolKey::Up, Mods { shift: true, ..alt }, v).unwrap();
assert_eq!((style_at(&s, id, 0).baseline_shift, style_at(&s, id, 6).baseline_shift), (-3.0, -1.5));
// The font size shortcut steps the selected text while the tool edits.
s.execute("type.size.increase", &json!({})).unwrap();
assert_eq!((style_at(&s, id, 0).size, style_at(&s, id, 6).size), (19.0, 22.0));
}
/// Type › Fill New Type Objects With Placeholder Text (#394, on by default): type the Type tool
/// places starts with placeholder text, selected, so typing replaces it; off, it starts empty.
#[test]
fn new_type_starts_with_placeholder_text() {
use vectorcraft_tools::PointerKind::{Down, Drag, Up};
let v = ViewInfo::default();
let mut s = new_doc();
s.select_tool("type", v).unwrap();
let text = |s: &Session| {
let st = s.doc().unwrap();
match &st.doc.node(st.selection.objects[0]).unwrap().kind {
vectorcraft_doc::NodeKind::Text(t) => t.plain_text(),
_ => panic!("not text"),
}
};
gesture(&mut s, v, &[(Down, 50.0, 50.0), (Up, 50.0, 50.0)]);
let placeholder = text(&s);
assert!(placeholder.len() > 10, "{placeholder}");
assert_eq!((s.tool_options()["start"].clone(), s.tool_options()["end"].clone()), (json!(0), json!(placeholder.len())), "selected");
s.tool_text("Hi", v).unwrap();
assert_eq!(text(&s), "Hi");
// Area type is filled to its frame.
gesture(&mut s, v, &[(Down, 100.0, 150.0), (Drag, 300.0, 300.0), (Up, 300.0, 300.0)]);
assert!(text(&s).len() > placeholder.len());
set_pref(&mut s, "placeholderText", json!(false));
gesture(&mut s, v, &[(Down, 50.0, 350.0), (Up, 50.0, 350.0)]);
assert_eq!(text(&s), "");
}
/// Type › Enable Missing Glyph Protection (#394, on by default): characters a new font has no
/// glyph for keep the font that has one; off, they take the new font.
#[test]
fn missing_glyph_protection_keeps_glyphs_a_new_font_lacks() {
let (from, to, c) = ("Source Sans 3", "JetBrains Mono", '\u{180}');
let db = vectorcraft_text::FontDb::global();
let covers = |f: &str| db.face(f, "Regular").is_some_and(|face| face.covers(c));
if !covers(from) || covers(to) {
return; // Installed fonts of these names cover other characters.
}
let mut s = new_doc();
let id = NodeId(s.execute("text.create", &json!({"x": 10, "y": 50, "text": "a\u{180}c", "font": from})).unwrap()["id"].as_u64().unwrap());
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
let fonts = |s: &Session| [0, 1, 3].map(|b| style_at(s, id, b).font_family);
s.execute("text.setStyle", &json!({"font": to})).unwrap();
assert_eq!(fonts(&s), [to, from, to], "the character keeps its font");
s.execute("edit.undo", &json!({})).unwrap();
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 1, "end": 4, "font": to})).unwrap();
assert_eq!(fonts(&s), [from, from, to]);
s.execute("edit.undo", &json!({})).unwrap();
set_pref(&mut s, "missingGlyphProtection", json!(false));
s.execute("text.setStyle", &json!({"font": to})).unwrap();
assert_eq!(fonts(&s), [to, to, to]);
}
// ---------- #394, fourth batch ----------
/// Type › Type Object Selection by Path Only (#394): on, a click among point type's glyphs no
/// longer selects it with the Selection tool, a click on its baseline does; off (the default),
/// anywhere in its bounds selects it.
#[test]
fn type_selection_by_path_only_picks_type_on_its_baseline() {
use vectorcraft_tools::{PointerEvent, PointerKind};
let mut s = new_doc();
let id = NodeId(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Hello world", "size": 20})).unwrap()["id"].as_u64().unwrap());
s.execute("select.none", &json!({})).unwrap();
let b = s.doc().unwrap().doc.node(id).and_then(|n| n.geometric_bounds()).unwrap();
assert!(b.y0 < 95.0 && b.x1 > 130.0, "the glyphs rise above the baseline at y = 100: {b:?}");
s.select_tool("selection", ViewInfo::default()).unwrap();
let click = |s: &mut Session, x: f64, y: f64| {
for k in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(k, x, y), ViewInfo::default()).unwrap();
}
s.doc().unwrap().selection.objects.clone()
};
// Clear of the bounding box's handles: 15 px in from the left, halfway up the glyphs.
let (x, glyphs) = (b.x0 + 15.0, (b.y0 + 100.0) / 2.0);
assert_eq!(click(&mut s, x, glyphs), vec![id], "off: a click among the glyphs selects the type");
assert!(click(&mut s, x, 300.0).is_empty(), "empty canvas deselects");
set_pref(&mut s, "typeSelectionByPathOnly", json!(true));
assert!(click(&mut s, x, glyphs).is_empty(), "on: a click among the glyphs selects nothing");
assert_eq!(click(&mut s, x, 101.0), vec![id], "on: a click on the baseline selects it");
assert!(click(&mut s, x, 300.0).is_empty());
set_pref(&mut s, "typeSelectionByPathOnly", json!(false));
assert_eq!(click(&mut s, x, glyphs), vec![id], "off again: the glyphs select it");
}
/// Type Object Selection by Path Only (#394) with real layout: every line's baseline picks point
/// type, between the lines nothing does; the Type tool still edits type clicked among its
/// characters, and the Eyedropper still samples it there.
#[test]
fn type_selection_by_path_only_takes_every_line_and_spares_the_type_tool_and_eyedropper() {
use vectorcraft_tools::{PointerEvent, PointerKind};
let v = ViewInfo::default();
let mut s = new_doc();
let created = s
.execute(
"text.create",
&json!({"x": 100, "y": 100, "text": "Hello
world", "size": 20}),
)
.unwrap();
let src = NodeId(created["id"].as_u64().unwrap());
s.execute("text.setStyle", &json!({"fill": "#00ff00"})).unwrap();
let NodeKind::Text(t) = &s.doc().unwrap().doc.node(src).unwrap().kind else { panic!("not type") };
let [(a, _), (b, _)] = t.cached_baselines[..] else { panic!("two baselines: {:?}", t.cached_baselines) };
let (first, second) = (t.xf * a, t.xf * b);
assert!(first.y == 100.0 && second.y > 115.0, "{first:?} {second:?}");
let click = |s: &mut Session, tool: &str, x: f64, y: f64| {
s.select_tool(tool, v).unwrap();
for k in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(k, x, y), v).unwrap();
}
s.doc().unwrap().selection.objects.clone()
};
set_pref(&mut s, "typeSelectionByPathOnly", json!(true));
let x = first.x + 15.0;
assert!(click(&mut s, "selection", x, 300.0).is_empty());
assert_eq!(click(&mut s, "selection", x, second.y + 1.0), vec![src], "the second line's baseline");
assert!(click(&mut s, "selection", x, (first.y + second.y) / 2.0).is_empty(), "between the lines");
// The Type tool: a click among the glyphs puts the caret in that type, it adds none.
assert_eq!(click(&mut s, "type", x, first.y - 7.0), vec![src]);
assert!(s.tool_wants_text(), "editing");
let texts =
|s: &Session| s.doc().unwrap().doc.layers[0].children().map_or(0, |c| c.iter().filter(|n| matches!(n.kind, NodeKind::Text(_))).count());
assert_eq!(texts(&s), 1, "no new type");
// The Eyedropper: a click among the glyphs samples the type into the selected type.
s.select_tool("selection", v).unwrap();
let dst = NodeId(s.execute("text.create", &json!({"x": 100, "y": 300, "text": "Target"})).unwrap()["id"].as_u64().unwrap());
click(&mut s, "eyedropper", x, first.y - 7.0);
let NodeKind::Text(t) = &s.doc().unwrap().doc.node(dst).unwrap().kind else { panic!("not type") };
assert_eq!((t.first_style().size, t.first_style().fill.color().map(|c| c.to_hex())), (20.0, Some("#00ff00".into())));
}
/// Preferences › Type › Show Font Names in English (#394): stored and read by the font menus.
#[test]
fn show_font_names_in_english_preference_is_stored() {
let mut s = Session::new();
assert!(s.prefs.font_names_in_english, "on by default");
set_pref(&mut s, "fontNamesInEnglish", json!(false));
assert!(!s.prefs.font_names_in_english);
set_pref(&mut s, "fontNamesInEnglish", json!(true));
assert!(s.prefs.font_names_in_english);
}
/// Preferences › Hyphenation › Exceptions (#394): `prefs.set` pushes the list into the hyphenator
/// and bumps open documents so type reflows. Parsing of the list is covered in `vectorcraft-text`.
#[test]
fn hyphenation_exceptions_preference_reaches_the_hyphenator() {
struct Clear;
impl Drop for Clear {
fn drop(&mut self) {
vectorcraft_text::set_hyphenation_exceptions("");
}
}
let _clear = Clear;
let mut s = Session::new();
set_pref(&mut s, "hyphenationExceptions", json!(""));
assert!(vectorcraft_text::hyphenation_exceptions().is_empty());
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
let before = s.doc().unwrap().revision;
set_pref(&mut s, "hyphenationExceptions", json!("typography, hap-pen"));
assert_eq!(s.prefs.hyphenation_exceptions, "typography, hap-pen");
assert_eq!(vectorcraft_text::hyphenation_exceptions(), "typography, hap-pen");
assert!(s.doc().unwrap().revision > before, "type reflows");
set_pref(&mut s, "hyphenationExceptions", json!(""));
assert!(vectorcraft_text::hyphenation_exceptions().is_empty());
}
/// #1274: View › Snap to Point lands the anchor a selection is grabbed by (a press within a few
/// pixels of it) on another object's anchor, with Smart Guides on or off, not the pressed spot
/// beside it.
#[test]
fn snap_to_point_lands_the_grabbed_anchor_on_another_anchor() {
use vectorcraft_tools::PointerKind::{Down, Drag, Up};
let mut s = new_doc();
let a = square(&mut s, 100.0, 100.0);
square(&mut s, 300.0, 100.0);
for v in [ViewInfo { smart_guides: false, ..ViewInfo::default() }, ViewInfo::default()] {
s.select_tool("selection", v).unwrap();
// Nothing selected, so no bounding box handle sits on the corner.
s.execute("select.none", &json!({})).unwrap();
// Pressed 2 px left of and below the top-right anchor (150, 100), released with the
// pointer 1.4 px from the other square's top-left anchor (300, 100).
gesture(&mut s, v, &[(Down, 148.0, 102.0), (Drag, 200.0, 110.0), (Drag, 299.0, 101.0), (Up, 299.0, 101.0)]);
assert_eq!(top_left(&s, a), (250.0, 100.0), "the grabbed anchor lands on the anchor ({v:?})");
s.execute("edit.undo", &json!({})).unwrap();
}
}
/// #1274: with Smart Guides off, View › Snap to Point turns the selection arrow hollow over an
/// anchor a press would grab, and lands a Pen click within 2 px of an anchor on it.
#[test]
fn snap_to_point_cues_anchors_and_lands_pen_clicks_on_them() {
use vectorcraft_tools::PointerKind::{Down, Up};
use vectorcraft_tools::{Cursor, Mods};
let mut s = new_doc();
square(&mut s, 300.0, 100.0);
s.execute("select.none", &json!({})).unwrap();
let on = ViewInfo { smart_guides: false, ..ViewInfo::default() };
let off = ViewInfo { snap_to_point: false, ..on };
s.select_tool("selection", on).unwrap();
let near = vectorcraft_geom::Point::new(302.0, 102.0);
assert_eq!(s.cursor(near, Mods::default(), on), Cursor::ArrowHollow);
assert_eq!(s.cursor(vectorcraft_geom::Point::new(325.0, 125.0), Mods::default(), on), Cursor::Arrow, "away from the anchors");
assert_eq!(s.cursor(near, Mods::default(), off), Cursor::Arrow, "Snap to Point off");
for (v, first) in [(on, (300.0, 100.0)), (off, (301.0, 101.0))] {
s.select_tool("pen", v).unwrap();
gesture(&mut s, v, &[(Down, 301.0, 101.0), (Up, 301.0, 101.0), (Down, 200.0, 250.0), (Up, 200.0, 250.0)]);
let id = *s.doc().unwrap().selection.objects.first().unwrap();
let p = s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().subpaths[0].anchors[0].p;
assert_eq!((p.x, p.y), first, "{v:?}");
s.execute("edit.undo", &json!({})).unwrap();
s.execute("select.none", &json!({})).unwrap();
}
}