mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 10:47:11 -06:00
Fourteen more Preferences options that were stored but read by nothing now
act, for the mouse and for agents (prefs.set, then pointer_gesture,
press_key or the commands):
- Selection & Anchor Display: Snap to Point and its distance (with Smart
Guides off, View > Snap to Point lands a dragged selection's grabbed
point, drawn points and reference points on anchors and ruler guides);
Move Locked and Hidden Artwork with Artboard (artboard.move, the Artboard
tool and Rearrange; off, hidden art now stays too); Enable Rubber Band for
the Pen and Curvature tools; Handles (solid, hollow, large);
Show handles when multiple anchors are selected (canvas and Direct
Selection's handle picking); Hide Corner Widget for angles greater than.
- General: Transform Pattern Tiles is the default of a new `patterns`
param on object.transform/move/rotate/scale/reflect/shear/transformEach
(journalled when the art uses a pattern), and the transform dialogs get a
Transform Patterns checkbox; Select Same Tint % (off, Select > Same
Fill/Stroke Color takes every tint of a global or spot swatch).
- Type: new type.step {attribute, by} steps size, leading, tracking, kerning
and baseline shift by the Size/Leading, Tracking and Baseline Shift
increments; Increase/Decrease Font Size (Cmd+Shift+. and Cmd+Shift+,);
the Type tool's Alt+arrows (kerning at a caret or tracking, leading,
Shift for baseline shift, Cmd/Ctrl for five steps). Enable Missing Glyph
Protection keeps characters a new font lacks in the font that had them
(text.setStyle, text.setRangeStyle). Fill New Type Objects With
Placeholder Text: type the Type tools place starts with placeholder text,
selected (text.create / text.createInPath take `placeholder`).
Units > General, Stroke and Type were already the single source of truth
(units.rs). Auto Size New Area Type, Show Font Names in English and
Highlight Alternate Glyphs need subsystems the app doesn't have yet and
stay open with the rest of #394.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
488 lines
24 KiB
Rust
488 lines
24 KiB
Rust
//! 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));
|
||
}
|
||
|
||
#[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): power saving by default, set by value or label, kept
|
||
/// through a save/load round trip, and a bad value is an error that changes nothing.
|
||
#[test]
|
||
fn gpu_preference_defaults_to_power_saving_and_validates() {
|
||
let mut s = Session::new();
|
||
assert_eq!(s.prefs.gpu_preference, "powerSaving");
|
||
assert_eq!(s.execute("prefs.get", &json!({"key": "gpuPreference"})).unwrap(), json!("powerSaving"));
|
||
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, "powerSaving");
|
||
for bad in [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, "powerSaving");
|
||
}
|
||
let p = Prefs { gpu_preference: "highPerformance".into(), ..Default::default() };
|
||
let back: Prefs = serde_json::from_value(p.to_json()).unwrap();
|
||
assert_eq!(back.gpu_preference, "highPerformance");
|
||
// 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, "powerSaving");
|
||
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!(["powerSaving", "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, "powerSaving");
|
||
}
|
||
|
||
/// 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");
|
||
}
|
||
|
||
/// 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);
|
||
s.execute("prefs.set", &json!({"key": "usePreciseCursors", "value": true})).unwrap();
|
||
assert_eq!(cursor(&mut s, "pen"), Cursor::Crosshair);
|
||
assert_eq!(cursor(&mut s, "selection"), Cursor::Arrow);
|
||
}
|
||
|
||
// ---------- #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");
|
||
}
|
||
|
||
/// 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]);
|
||
}
|
||
|
||
/// 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]);
|
||
}
|