Files
vectorcraft/crates/engine/src/tests_prefs.rs
T
bflatasticandClaude Opus 5.5 d12d5195da Fix #502: no startup crash on dual-GPU Linux desktops under Wayland
0.5.0 asked wgpu for the low-power adapter everywhere (#306). On a Linux
desktop whose KDE Wayland compositor runs on an NVIDIA GPU, wgpu sorted the
Ryzen's integrated RADV GPU first: it reports that it can present to the
window, but KWin can't import the buffers it allocates ("importing the
supplied dmabufs failed"), which is a fatal Wayland protocol error. The next
surface configuration then got ERROR_SURFACE_LOST_KHR and egui-wgpu panicked
("The surface isn't supported by this adapter", winit.rs:129).

- Preferences > Performance > Graphics Processor gains Automatic, the new
  default: power saving on Windows and macOS as #306 wanted, and on Linux the
  system's order, where Mesa's device-select layer puts the GPU the desktop
  runs on first. Values are now automatic / lowPower / highPerformance; 0.5.0
  saved powerSaving for everyone, which reads as automatic.
- The app picks the window's adapter itself (apps/vectorcraft/src/gpu.rs,
  eframe's native_adapter_selector): only adapters that present to the
  surface, WGPU_ADAPTER_NAME first, hardware before software, native backends
  before OpenGL, then the power preference. Every adapter and the pick are
  logged.
- run_native runs under the engine guard. When the graphics fail during the
  first frames (a wgpu error, or a panic in wgpu, egui-wgpu or naga), the app
  starts again without that adapter (exec on Unix, so an AppImage stays
  mounted; a new process on Windows), passing the failed adapters in
  VECTORCRAFT_GPU_SKIP, and says so in the status bar. When none is left it
  logs why and exits with an error, never a panic.
- docs/development.md, README and ROADMAP: the new default, the restart and
  the escape hatches (WGPU_POWER_PREF, WGPU_ADAPTER_NAME, WGPU_BACKEND,
  MESA_VK_DEVICE_SELECT, DRI_PRIME).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:59:55 +07:00

646 lines
32 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));
}
#[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");
}
/// 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");
}
/// 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]);
}
/// 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())));
}