* 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 <[email protected]> * Keep a pasted text's height and angle expressions, drop its place's Co-Authored-By: Claude Opus 5.5 <[email protected]> --------- Co-authored-by: Claude Opus 5.5 <[email protected]>
184 lines
7.5 KiB
Rust
184 lines
7.5 KiB
Rust
//! Copy, Cut and Paste from the keyboard (Ctrl+C / Ctrl+X / Ctrl+V, ⌘C / ⌘X / ⌘V) and the context
|
|
//! menu (#1480).
|
|
//!
|
|
//! In a sketch they copy the selected sketch geometry (`sketch.copy`) and paste it at the cursor
|
|
//! (`sketch.paste` with `at`); Cut copies the sketch geometry and deletes it (`sketch.cut`), or offset from the original when the cursor is not over the
|
|
//! viewport. Outside a sketch they copy the selected timeline features and paste them at the
|
|
//! history marker (`timeline.copy` / `timeline.paste`).
|
|
//!
|
|
//! The window system turns Ctrl+C / Ctrl+V into clipboard events, not key presses, and only sends
|
|
//! the paste event while the system clipboard holds text. So a copy also puts a short line of
|
|
//! text on the system clipboard (a menu's Copy leaves it for the next frame, which has the
|
|
//! context to write it).
|
|
|
|
use std::sync::Mutex;
|
|
|
|
use egui::Pos2;
|
|
use serde_json::{Value, json};
|
|
use solidcraft_engine::Sel;
|
|
|
|
use crate::SolveApp;
|
|
|
|
/// Text for the system clipboard, written on the next frame.
|
|
static PENDING_TEXT: Mutex<Option<String>> = Mutex::new(None);
|
|
|
|
fn leave_text(text: String) {
|
|
if let Ok(mut pending) = PENDING_TEXT.lock() {
|
|
*pending = Some(text);
|
|
}
|
|
}
|
|
|
|
/// The selected sketch geometry of the active sketch.
|
|
fn sketch_selection(app: &SolveApp) -> Vec<String> {
|
|
app.session
|
|
.selection
|
|
.iter()
|
|
.filter_map(|s| match s {
|
|
Sel::SketchCurve { id } | Sel::SketchPoint { id } => Some(id.clone()),
|
|
_ => None,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Is there something Copy would copy?
|
|
pub fn can_copy(app: &SolveApp) -> bool {
|
|
if app.session.active_sketch.is_some() { !sketch_selection(app).is_empty() } else { !crate::timeline::selected_feature_ids(app).is_empty() }
|
|
}
|
|
|
|
/// Is there something Paste would paste here?
|
|
pub fn can_paste(app: &SolveApp) -> bool {
|
|
if app.session.active_sketch.is_some() { app.session.sketch_clipboard.is_some() } else { !app.session.clipboard.is_empty() }
|
|
}
|
|
|
|
/// Copy the selection: sketch geometry in a sketch, otherwise timeline features.
|
|
pub fn copy(app: &mut SolveApp) -> bool {
|
|
if app.session.active_sketch.is_some() {
|
|
let entities = sketch_selection(app);
|
|
if entities.is_empty() {
|
|
app.set_status("Select sketch geometry to copy", true);
|
|
return false;
|
|
}
|
|
let Ok(out) = app.run("sketch.copy", json!({ "entities": entities })) else { return false };
|
|
let n = out.get("curves").and_then(Value::as_u64).unwrap_or(0) + out.get("points").and_then(Value::as_u64).unwrap_or(0);
|
|
let k = out.get("constraints").and_then(Value::as_u64).unwrap_or(0);
|
|
let text = format!("SolidCraft sketch geometry: {n} entities, {k} constraints and dimensions");
|
|
app.set_status(format!("Copied {n} sketch entities with {k} constraints and dimensions"), false);
|
|
leave_text(text);
|
|
return true;
|
|
}
|
|
let features = crate::timeline::selected_feature_ids(app);
|
|
if features.is_empty() {
|
|
app.set_status("Select sketch geometry or timeline features to copy", true);
|
|
return false;
|
|
}
|
|
let Ok(out) = app.run("timeline.copy", json!({ "features": features })) else { return false };
|
|
let names: Vec<&str> = out.get("copied").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).collect()).unwrap_or_default();
|
|
app.set_status(format!("Copied {}", names.join(", ")), false);
|
|
leave_text(format!("SolidCraft features: {}", names.join(", ")));
|
|
true
|
|
}
|
|
|
|
/// Cut the selected sketch geometry: copy it, then delete it (one undo step).
|
|
pub fn cut(app: &mut SolveApp) -> bool {
|
|
if app.session.active_sketch.is_none() {
|
|
app.set_status("Cut works on sketch geometry: edit a sketch and select some", true);
|
|
return false;
|
|
}
|
|
let entities = sketch_selection(app);
|
|
if entities.is_empty() {
|
|
app.set_status("Select sketch geometry to cut", true);
|
|
return false;
|
|
}
|
|
let Ok(out) = app.run("sketch.cut", json!({ "entities": entities })) else { return false };
|
|
let n = out.get("curves").and_then(Value::as_u64).unwrap_or(0) + out.get("points").and_then(Value::as_u64).unwrap_or(0);
|
|
app.set_status(format!("Cut {n} sketch entities"), false);
|
|
leave_text(format!("SolidCraft sketch geometry: {n} entities"));
|
|
true
|
|
}
|
|
|
|
/// The point of the active sketch under `at` (screen), when `at` is over the viewport.
|
|
fn sketch_point(app: &SolveApp, at: Option<Pos2>) -> Option<[f64; 2]> {
|
|
let at = at?;
|
|
let rect = app.viewport.rect?;
|
|
if !rect.contains(at) {
|
|
return None;
|
|
}
|
|
let proj = crate::viewport::projection(app, rect);
|
|
let (p, _) = crate::viewport::sketch_point_at(app, &proj, at)?;
|
|
Some([p.x, p.y])
|
|
}
|
|
|
|
/// Paste: in a sketch, the copied sketch geometry centred at `at` (or offset from the original
|
|
/// when `at` is not over the sketch); otherwise the copied features at the history marker.
|
|
pub fn paste(app: &mut SolveApp, at: Option<Pos2>) -> bool {
|
|
if app.session.active_sketch.is_some() {
|
|
if app.session.sketch_clipboard.is_none() {
|
|
app.set_status("Nothing to paste: copy sketch geometry first", true);
|
|
return false;
|
|
}
|
|
let params = match sketch_point(app, at) {
|
|
Some(p) => json!({ "at": p }),
|
|
None => json!({}),
|
|
};
|
|
let Ok(out) = app.run("sketch.paste", params) else { return false };
|
|
let n = out.get("curves").and_then(Value::as_array).map_or(0, Vec::len) + out.get("points").and_then(Value::as_array).map_or(0, Vec::len);
|
|
app.set_status(format!("Pasted {n} sketch entities (selected: drag them, or press M to move them)"), false);
|
|
return true;
|
|
}
|
|
if app.session.clipboard.is_empty() {
|
|
app.set_status("Nothing to paste: copy timeline features first", true);
|
|
return false;
|
|
}
|
|
app.run("timeline.paste", json!({})).is_ok()
|
|
}
|
|
|
|
/// The frame's Copy, Cut and Paste events (and Ctrl+C / Ctrl+X / Ctrl+V sent as key presses), plus any
|
|
/// clipboard text a menu's Copy left. Called by the global shortcuts when no text field has the
|
|
/// keyboard.
|
|
pub fn handle(app: &mut SolveApp, ctx: &egui::Context) {
|
|
let (copies, cuts, pastes, pointer) = ctx.input(|i| {
|
|
let mut copies = 0;
|
|
let mut cuts = 0;
|
|
let mut pastes = 0;
|
|
for e in &i.events {
|
|
match e {
|
|
egui::Event::Copy => copies += 1,
|
|
egui::Event::Cut => cuts += 1,
|
|
egui::Event::Paste(_) => pastes += 1,
|
|
egui::Event::Key { key: egui::Key::C, pressed: true, modifiers, .. } if modifiers.command && !modifiers.shift && !modifiers.alt => {
|
|
copies += 1
|
|
}
|
|
egui::Event::Key { key: egui::Key::X, pressed: true, modifiers, .. } if modifiers.command && !modifiers.shift && !modifiers.alt => {
|
|
cuts += 1
|
|
}
|
|
egui::Event::Key { key: egui::Key::V, pressed: true, modifiers, .. } if modifiers.command && !modifiers.shift && !modifiers.alt => {
|
|
pastes += 1
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
(copies, cuts, pastes, i.pointer.latest_pos())
|
|
});
|
|
if copies > 0 {
|
|
copy(app);
|
|
}
|
|
if cuts > 0 {
|
|
cut(app);
|
|
}
|
|
if pastes > 0 {
|
|
paste(app, pointer);
|
|
}
|
|
flush(ctx);
|
|
}
|
|
|
|
/// Write clipboard text a copy left.
|
|
pub fn flush(ctx: &egui::Context) {
|
|
if let Some(text) = PENDING_TEXT.lock().ok().and_then(|mut p| p.take()) {
|
|
ctx.copy_text(text);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "edit_clipboard_tests.rs"]
|
|
mod tests;
|