Files
OneCAD/crates/ui-egui/src/edit_clipboard_tests.rs
T
bherbruckandClaude Opus 5.5 843c548589 Copy, cut and paste sketch geometry (Ctrl+C/X/V, context menu) (#1500)
* Copy, cut and paste sketch geometry (Ctrl+C/X/V, context menu)

Sketch geometry had no copy or paste: Ctrl+C / Ctrl+X / Ctrl+V arrive as
clipboard events that nothing handled, and the sketch context menu had no
Copy, Cut or Paste.

- sketch.copy / sketch.cut / sketch.paste: lines, arcs, circles, ellipses,
  splines, conics, points and text. Constraints and dimensions wholly among
  the copied entities come along (each dimension gets its own parameter
  with the same expression); ones that reach outside, fixes and pattern or
  offset relations are dropped. Paste goes centred at `at`, by
  `translate`, or offset from the original (same sketch) / at the same
  coordinates (another sketch, on its plane). One undo step; the pasted
  geometry is selected.
- Ctrl+C / Ctrl+X / Ctrl+V in a sketch (paste at the cursor), and Cut,
  Copy and Paste in the sketch context menu. Outside a sketch Ctrl+C /
  Ctrl+V copy and paste the selected timeline features. A copy also puts a
  line of text on the system clipboard so the next Ctrl+V is delivered.

Fixes #1480

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep a pasted text's height and angle expressions, drop its place's

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:55:10 -04:00

165 lines
7.8 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.
//! Copy and Paste from the keyboard and the context menu (#1480).
use serde_json::{Value, json};
use solidcraft_engine::Sel;
use solidcraft_engine::geom::Vec2;
use crate::scenario::Harness;
fn strs(v: &Value) -> Vec<String> {
v.as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default()
}
/// A sketch on XY with a 20 × 10 rectangle (dimensioned, its corner on the origin), looked at
/// from the top, and the rectangle selected.
fn rectangle() -> (Harness, Vec<String>) {
let mut h = Harness::new();
h.app.run("sketch.create", json!({"plane": "XY"})).unwrap();
let rect = strs(&h.app.run("sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10]})).unwrap()["curves"]);
h.app.run("sketch.constraint.coincident", json!({"a": format!("{}.start", rect[0]), "b": "origin"})).unwrap();
h.app.run("sketch.dimension", json!({"entities": [rect[0]], "value": 20})).unwrap();
h.app.run("sketch.dimension", json!({"entities": [rect[1]], "value": 10})).unwrap();
h.call("ui.view", json!({"view": "top"}));
h.call("ui.camera", json!({"target": [20, 10, 0], "distance": 150}));
let items: Vec<Value> = rect.iter().map(|id| json!({"type": "sketch_curve", "id": id})).collect();
h.app.run("select.set", json!({ "items": items })).unwrap();
h.frames(4);
(h, rect)
}
/// Send the window system's clipboard event (what Ctrl+C / Ctrl+V become), with the pointer at
/// `at` (sketch coordinates) or off the viewport.
fn clipboard_event(h: &mut Harness, event: egui::Event, at: Option<[f64; 2]>) {
match at {
Some(p) => h.step(&json!({"move": {"sketch": p}})).unwrap(),
None => drop(h.call("ui.move", json!({"x": 100, "y": 990}))),
}
h.app.synthetic.push(event);
h.settle();
}
fn curves(h: &Harness) -> Vec<String> {
h.app.session.doc.sketch(h.app.session.active_sketch.unwrap()).unwrap().curves.iter().map(|c| c.id.clone()).collect()
}
fn start(h: &Harness, id: &str) -> Vec2 {
let sk = h.app.session.doc.sketch(h.app.session.active_sketch.unwrap()).unwrap();
let c = &sk.curves[sk.curve_index(id).unwrap()];
sk.point(c.kind.point_ids()[0]).unwrap()
}
#[test]
fn ctrl_c_ctrl_v_pastes_the_sketch_selection_at_the_cursor_selected_in_one_undo_step() {
let (mut h, rect) = rectangle();
let before = curves(&h);
let undo = h.app.session.undo.len();
clipboard_event(&mut h, egui::Event::Copy, Some([10.0, 5.0]));
assert!(h.app.session.sketch_clipboard.is_some(), "{:?}", h.app.status);
// The window system only sends Paste while its clipboard holds text: a copy leaves some.
h.call("ui.inspect", json!({}));
clipboard_event(&mut h, egui::Event::Paste("x".into()), Some([60.0, 40.0]));
let after = curves(&h);
assert_eq!(after.len(), before.len() + 4, "{:?}", h.app.status);
let pasted: Vec<String> = after.iter().filter(|c| !before.contains(c)).cloned().collect();
// Centred at the cursor: the rectangle (centre 10, 5) goes to (60, 40), give or take the snap.
let corner = start(&h, &pasted[0]);
assert!(corner.dist(Vec2::new(50.0, 35.0)) < 1.0, "{corner:?}");
// The originals stay put, with their constraints.
assert!(start(&h, &rect[0]).dist(Vec2::ZERO) < 1e-9);
// Selected, ready to move.
let selected: Vec<String> =
h.app.session.selection.iter().filter_map(|s| if let Sel::SketchCurve { id } = s { Some(id.clone()) } else { None }).collect();
assert_eq!(selected, pasted);
// One undo step.
assert_eq!(h.app.session.undo.len(), undo + 1);
h.step(&json!({"key": "Z", "cmd": true})).unwrap();
assert_eq!(curves(&h), before);
}
#[test]
fn copy_leaves_text_on_the_system_clipboard_so_paste_can_follow() {
let (mut h, _) = rectangle();
h.step(&json!({"move": {"sketch": [10, 5]}})).unwrap();
h.app.synthetic.push(egui::Event::Copy);
let mut copied = Vec::new();
for _ in 0..6 {
h.frame();
copied.extend(h.output.commands.iter().filter_map(|c| match c {
egui::OutputCommand::CopyText(text) => Some(text.clone()),
_ => None,
}));
}
assert!(copied.iter().any(|t| t.starts_with("SolidCraft sketch geometry")), "{copied:?}");
}
#[test]
fn paste_away_from_the_viewport_offsets_from_the_original_and_cmd_keys_work_too() {
let (mut h, rect) = rectangle();
let before = curves(&h);
// Ctrl+C / Ctrl+V as key presses (as the control channel and some platforms send them).
h.step(&json!({"move": {"sketch": [10, 5]}})).unwrap();
h.step(&json!({"key": "C", "cmd": true})).unwrap();
h.call("ui.move", json!({"x": 100, "y": 990}));
h.step(&json!({"key": "V", "cmd": true})).unwrap();
let pasted: Vec<String> = curves(&h).into_iter().filter(|c| !before.contains(c)).collect();
assert_eq!(pasted.len(), 4, "{:?}", h.app.status);
// 20 mm wide: a 2 mm step down and to the right.
let d = start(&h, &pasted[0]) - start(&h, &rect[0]);
assert!(d.dist(Vec2::new(2.0, -2.0)) < 1e-6, "{d:?}");
}
#[test]
fn the_context_menu_copies_and_pastes_sketch_geometry_at_the_click() {
let (mut h, rect) = rectangle();
let before = curves(&h);
h.step(&json!({"click": {"sketch": [10, 0]}, "button": "right"})).unwrap();
let menu = h.call("ui.menu", json!({}));
let labels: Vec<&str> = menu["result"]["items"].as_array().unwrap().iter().filter_map(|i| i["label"].as_str()).collect();
assert!(labels.contains(&"Copy") && labels.contains(&"Paste"), "{labels:?}");
h.call("ui.menuPick", json!({"item": "Copy"}));
assert!(h.app.session.sketch_clipboard.is_some());
h.step(&json!({"click": {"sketch": [70, 30]}, "button": "right"})).unwrap();
h.call("ui.menuPick", json!({"item": "Paste"}));
h.frames(3);
let pasted: Vec<String> = curves(&h).into_iter().filter(|c| !before.contains(c)).collect();
assert!(!pasted.is_empty(), "{:?}", h.app.status);
// Whatever the right-click selected was copied, and its copy is centred on the click.
let sk = h.app.session.doc.sketch(h.app.session.active_sketch.unwrap()).unwrap();
let pts: Vec<Vec2> = pasted.iter().flat_map(|c| sk.curves[sk.curve_index(c).unwrap()].kind.point_ids()).map(|i| sk.point(i).unwrap()).collect();
let (lo, hi) = pts.iter().fold((Vec2::new(1e9, 1e9), Vec2::new(-1e9, -1e9)), |(lo, hi), p| {
(Vec2::new(lo.x.min(p.x), lo.y.min(p.y)), Vec2::new(hi.x.max(p.x), hi.y.max(p.y)))
});
assert!(((lo + hi) * 0.5).dist(Vec2::new(70.0, 30.0)) < 1.0, "{lo:?} {hi:?}");
assert!(start(&h, &rect[0]).dist(Vec2::ZERO) < 1e-9);
}
#[test]
fn ctrl_c_ctrl_v_outside_a_sketch_copies_and_pastes_timeline_features() {
let mut h = Harness::new();
h.app.run("solid.box", json!({"length": 10, "width": 10, "height": 10})).unwrap();
h.frames(3);
let id = h.app.session.doc.features[0].id;
h.app.run("select.set", json!({"items": [{"type": "feature", "id": id}]})).unwrap();
h.frames(2);
clipboard_event(&mut h, egui::Event::Copy, None);
clipboard_event(&mut h, egui::Event::Paste("x".into()), None);
assert_eq!(h.app.session.doc.features.len(), 2, "{:?}", h.app.status);
}
#[test]
fn ctrl_x_cuts_the_sketch_selection_and_ctrl_v_brings_it_back() {
let (mut h, rect) = rectangle();
let before = curves(&h);
clipboard_event(&mut h, egui::Event::Cut, Some([10.0, 5.0]));
let left = curves(&h);
assert!(rect.iter().all(|c| !left.contains(c)), "{:?}", h.app.status);
assert!(h.app.session.sketch_clipboard.is_some());
clipboard_event(&mut h, egui::Event::Paste("x".into()), Some([10.0, 5.0]));
assert_eq!(curves(&h).len(), before.len(), "{:?}", h.app.status);
// The context menu offers Cut too.
h.step(&json!({"click": {"sketch": [10, 0]}, "button": "right"})).unwrap();
let menu = h.call("ui.menu", json!({}));
let labels: Vec<&str> = menu["result"]["items"].as_array().unwrap().iter().filter_map(|i| i["label"].as_str()).collect();
assert!(labels.contains(&"Cut"), "{labels:?}");
}