diff --git a/Cargo.lock b/Cargo.lock index 75d41ec4..d9ba616b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -827,6 +827,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" name = "drawcraft" version = "0.1.0" dependencies = [ + "arboard", "drawcraft-engine", "drawcraft-ui-egui", "eframe", @@ -853,9 +854,14 @@ dependencies = [ name = "drawcraft-cli" version = "0.1.0" dependencies = [ + "drawcraft-color", + "drawcraft-doc", + "drawcraft-engine", + "drawcraft-format", "drawcraft-geom", "drawcraft-mcp", "drawcraft-render", + "drawcraft-svg", "drawcraft-testkit", "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 88e995b6..7ed99e46 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,6 +54,7 @@ egui_extras = { version = "0.36", default-features = false, features = ["svg"] } eframe = { version = "0.36", default-features = false } egui_kittest = "0.36" rfd = "0.17" +arboard = { version = "3.6", default-features = false } [workspace.lints.rust] unsafe_code = "deny" diff --git a/ROADMAP.md b/ROADMAP.md index c288fac2..f09076dc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -57,7 +57,7 @@ _Last updated: 2026-10-01._ | M1 | Selection, transform, layers, MCP | โœ… mostly done (rotated persistent bbox pending) | 3โ€“5 | | M2 | Drawing tools + smart guides | โœ… mostly done (Shaper, Pen modifier nuances) | 5โ€“10 | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ๐ŸŸก panels done; opacity masks done (make/release, clip, invert, disable, link; render + SVG `` in/out + PDF soft mask); mask-editing mode, freeform gradients pending | 6โ€“10 | -| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ๐ŸŸก Export for Screens (PNG/JPG/WebP/SVG/PDF ร— scales) done; headless CLI/MCP export every format; system clipboard SVG/PNG interop, EPS/DXF pending | 8โ€“12 | +| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ๐ŸŸก Export for Screens (PNG/JPG/WebP/SVG/PDF ร— scales) done; headless CLI/MCP export every format; system clipboard: copy puts SVG markup on it, paste takes SVG from other apps (Ctrl/Cmd+C/X/V now also work off macOS); PNG/PDF clipboard flavours, EPS/DXF pending | 8โ€“12 | | M5 | Performance | ๐ŸŸก background render + caches + MT done; `drawcraft-cli bench`; raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 10โ€“20 | | M6 | Path operations (Pathfinder, Shape Builder, offsetโ€ฆ) | โœ… mostly done (Shape Builder edge erase, large-offset bug open) | 3โ€“6 | | M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | ๐ŸŸก in progress | 20โ€“30 | diff --git a/apps/drawcraft/Cargo.toml b/apps/drawcraft/Cargo.toml index 9c4110c0..4facecf5 100644 --- a/apps/drawcraft/Cargo.toml +++ b/apps/drawcraft/Cargo.toml @@ -19,6 +19,7 @@ log = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rfd = { workspace = true } +arboard = { workspace = true } [target.'cfg(target_os = "macos")'.dependencies] muda = { version = "0.21", default-features = false } diff --git a/apps/drawcraft/src/main.rs b/apps/drawcraft/src/main.rs index f3c24fb3..9169fbb8 100644 --- a/apps/drawcraft/src/main.rs +++ b/apps/drawcraft/src/main.rs @@ -96,6 +96,8 @@ fn services() -> Services { pick_save: Some(Box::new(|name: &str| rfd::FileDialog::new().set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()))), read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))), + // Menu-bar Paste never sees egui's Paste event, so read the clipboard directly. + clipboard_read: Some(Box::new(|| arboard::Clipboard::new().ok()?.get_text().ok())), ..Default::default() } } diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs new file mode 100644 index 00000000..6a9c7c06 --- /dev/null +++ b/crates/engine/src/cmd/clipboard.rs @@ -0,0 +1,128 @@ +//! System clipboard interchange: the copied objects as SVG markup (what other apps paste), and +//! SVG markup from other apps turned into clipboard objects (pasted with the Paste commands). +//! +//! The UI owns the platform clipboard; these commands only convert. Within DrawCraft the +//! internal clipboard stays lossless (live effects, masks, symbols); SVG is the outside format. + +use std::sync::Arc; + +use drawcraft_doc::{Document, Node, NodeId}; +use drawcraft_geom::{Affine, Point}; +use serde_json::{Value, json}; + +use super::*; + +pub fn specs() -> Vec { + vec![ + cmd!(query "clipboard.exportSvg", "Clipboard as SVG", [], None, "{} โ†’ {svg} the copied objects as standalone SVG (null when the clipboard is empty)", always, export_svg), + cmd!( + query "clipboard.importSvg", + "Load SVG into Clipboard", + [], + None, + "{svg, center?: [x, y]} replace the clipboard with the SVG's objects, centred on `center` (default: the first artboard) โ†’ {count}; then run edit.pasteInPlace", + has_doc, + import_svg + ), + ] +} + +/// Is `text` SVG markup (as other apps put on the clipboard)? +pub fn looks_like_svg(text: &str) -> bool { + let t = text.trim_start_matches('\u{feff}').trim_start(); + (t.starts_with(" Option { + if self.clipboard.is_empty() { + return None; + } + // The active document supplies swatches, symbols, patterns and image blobs. + let mut d = self.active().map(|st| (*st.doc).clone()).unwrap_or_else(|| Document::new(100.0, 100.0)); + let mut layer = Node::layer(NodeId(u64::MAX), "Clipboard", drawcraft_doc::LayerColor::Preset(0)); + if let Some(ch) = layer.children_mut() { + *ch = self.clipboard.iter().cloned().map(Arc::new).collect(); + } + d.layers = vec![Arc::new(layer)]; + Some(drawcraft_svg::export(&d, &drawcraft_svg::ExportOptions { artboard: None, object_ids: false, ..Default::default() })) + } +} + +fn export_svg(s: &mut Session, _: &Value) -> Result { + Ok(json!({ "svg": s.clipboard_svg() })) +} + +fn import_svg(s: &mut Session, p: &Value) -> Result { + let svg = str_param(p, "svg").ok_or_else(|| bad("clipboard.importSvg", "missing svg"))?; + let src = drawcraft_svg::import(svg).map_err(|e| bad("clipboard.importSvg", e.to_string()))?; + let mut nodes: Vec = src.layers.iter().flat_map(|l| l.children().cloned().unwrap_or_default()).map(|n| (*n).clone()).collect(); + if nodes.is_empty() { + return Err(bad("clipboard.importSvg", "the SVG has no drawable objects")); + } + let center = match p.get("center").and_then(Value::as_array) { + Some(c) if c.len() == 2 => Point::new(c[0].as_f64().unwrap_or(0.0), c[1].as_f64().unwrap_or(0.0)), + _ => s.doc()?.doc.artboards.first().map(|a| a.rect.center()).unwrap_or_default(), + }; + let bounds = nodes.iter().filter_map(|n| n.visual_bounds()).reduce(|a, b| a.union(b)); + if let Some(b) = bounds { + let xf = Affine::translate(center - b.center()); + for n in &mut nodes { + n.transform(xf, false); + } + } + // Embedded images: their blobs join the document (keys are content hashes). + if !src.images.is_empty() { + let st = s.doc_mut()?; + let d = Arc::make_mut(&mut st.doc); + for (k, b) in &src.images { + d.images.entry(k.clone()).or_insert_with(|| b.clone()); + } + } + let count = nodes.len(); + s.clipboard = nodes; + Ok(json!({ "count": count })) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + s + } + + #[test] + fn copy_exports_svg_that_round_trips_through_paste() { + let mut s = session(); + let id = s.execute("shape.rectangle", &json!({"x": 10, "y": 20, "width": 30, "height": 40})).unwrap()["id"].as_u64().unwrap(); + s.execute("paint.setFill", &json!({"color": "#12ab34"})).unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + assert!(s.execute("clipboard.exportSvg", &json!({})).unwrap()["svg"].is_null() || !s.clipboard.is_empty()); + s.execute("edit.copy", &json!({})).unwrap(); + let svg = s.execute("clipboard.exportSvg", &json!({})).unwrap()["svg"].as_str().unwrap().to_string(); + assert!(looks_like_svg(&svg) && svg.contains("#12ab34"), "{svg}"); + // As if pasted into another DrawCraft window. + let mut t = session(); + assert_eq!(t.execute("clipboard.importSvg", &json!({"svg": svg, "center": [200, 150]})).unwrap()["count"], 1); + t.execute("edit.pasteInPlace", &json!({})).unwrap(); + let st = t.doc().unwrap(); + let b = st.doc.bounds_of(&st.selection.in_paint_order(&st.doc), false).unwrap(); + assert!((b.center().x - 200.0).abs() < 1e-6 && (b.center().y - 150.0).abs() < 1e-6, "{b:?}"); + assert!((b.width() - 30.0).abs() < 1e-6 && (b.height() - 40.0).abs() < 1e-6); + } + + #[test] + fn foreign_svg_and_non_svg_text() { + let mut s = session(); + let svg = r##""##; + assert!(looks_like_svg(svg) && looks_like_svg(&format!("\n{svg}"))); + assert!(!looks_like_svg("hello world") && !looks_like_svg("plain text")); + assert_eq!(s.execute("clipboard.importSvg", &json!({"svg": svg})).unwrap()["count"], 2); + assert!(s.execute("clipboard.importSvg", &json!({"svg": ""})).is_err()); + assert!(s.execute("clipboard.importSvg", &json!({"svg": "not svg"})).is_err()); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 9994ae4d..4430a990 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -2,6 +2,7 @@ mod brushsym; mod buildcmds; +pub mod clipboard; mod colorcmds; pub mod colormgmt; mod create; @@ -126,6 +127,7 @@ pub fn command_specs() -> &'static [CommandSpec] { SPECS.get_or_init(|| { let mut v = Vec::new(); v.extend(edit::specs()); + v.extend(clipboard::specs()); v.extend(recolor::specs()); v.extend(file::specs()); v.extend(create::specs()); diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 6c63754c..0869b1f9 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -206,7 +206,9 @@ pub fn place_bytes(app: &mut DrawcraftApp, name: &str, bytes: &[u8]) -> Result<( let src = drawcraft_svg::import(s).map_err(|e| e.to_string())?; let nodes: Vec = src.layers.iter().flat_map(|l| l.children().cloned().unwrap_or_default()).map(|n| (*n).clone()).collect(); app.session.clipboard = nodes; - app.run("edit.pasteInPlace", serde_json::json!({}))?; + // Straight to the engine: `app.run` would let the system clipboard replace these nodes. + app.session.execute("edit.pasteInPlace", &serde_json::json!({})).map_err(|e| e.to_string())?; + app.sync_views(); return Ok(()); } let (w, h) = image_size(bytes)?; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1b314391..57c29016 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -58,6 +58,9 @@ pub struct Services { pub download: Option, /// Web: start an async open (bytes arrive via `inbox`). pub open_async: Option>, + /// Read the system clipboard's text (desktop). Without it, pasted text only arrives with + /// egui's Paste event (web, and keyboard paste everywhere). + pub clipboard_read: Option Option>>, } /// Cached canvas raster. @@ -122,6 +125,11 @@ pub struct DrawcraftApp { pub canvas_rect: Option, /// Hover position in document coordinates. pub hover_doc: Option, + /// System clipboard: SVG to publish next frame, the last SVG we published (so pasting it back + /// uses the lossless internal clipboard) and text that arrived with a Paste event. + clipboard_out: Option, + clipboard_published: Option, + pub(crate) clipboard_in: Option, } impl DrawcraftApp { @@ -145,6 +153,9 @@ impl DrawcraftApp { native_menu: false, last_effect: None, native_shortcuts: Default::default(), + clipboard_out: None, + clipboard_published: None, + clipboard_in: None, control_rx: None, pending_screenshots: vec![], queued_screenshots: vec![], @@ -197,7 +208,14 @@ impl DrawcraftApp { if let Some(r) = menus::run_ui_command(self, id, ¶ms) { return r; } + if id.starts_with("edit.paste") { + self.adopt_system_clipboard(); + } let r = self.session.execute(id, ¶ms).map_err(|e| e.to_string()); + if r.is_ok() && matches!(id, "edit.copy" | "edit.cut") && self.session.prefs.copy_as_svg { + self.clipboard_out = self.session.clipboard_svg(); + self.clipboard_published = self.clipboard_out.clone(); + } self.sync_views(); match &r { Err(e) => self.ui.status = e.clone(), @@ -210,6 +228,21 @@ impl DrawcraftApp { r } + /// Before a paste: SVG that another app put on the system clipboard replaces the internal + /// clipboard (centred in the view). Our own published SVG keeps the lossless internal copy. + fn adopt_system_clipboard(&mut self) { + let text = self.clipboard_in.take().or_else(|| self.services.clipboard_read.as_mut().and_then(|f| f())); + let Some(text) = text.filter(|t| drawcraft_engine::cmd::clipboard::looks_like_svg(t)) else { return }; + if self.clipboard_published.as_deref() == Some(text.as_str()) { + return; + } + let center = self.view().map(|v| [v.center.x, v.center.y]); + match self.session.execute("clipboard.importSvg", &serde_json::json!({ "svg": text, "center": center })) { + Ok(_) => self.clipboard_published = Some(text), + Err(e) => self.ui.status = format!("Couldn't paste SVG: {e}"), + } + } + /// Select a tool (also used by the toolbar and shortcuts). pub fn select_tool(&mut self, id: &str) { let v = self.view_info(); @@ -354,6 +387,9 @@ impl DrawcraftApp { } self.collect_screenshots(ctx); self.issue_screenshots(ctx); + if let Some(t) = self.clipboard_out.take() { + ctx.copy_text(t); + } self.drain_inbox(); if self.fonts_ready { shortcuts::handle(self, ctx); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 38c47122..974c9c97 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -115,6 +115,27 @@ pub fn handle(app: &mut DrawcraftApp, ctx: &egui::Context) { } return; } + // Clipboard keys arrive as events, not key presses (except where the native menu has them). + let mut clip = vec![]; + ctx.input_mut(|i| { + i.events.retain(|e| { + let (id, text) = match e { + egui::Event::Copy => ("edit.copy", None), + egui::Event::Cut => ("edit.cut", None), + egui::Event::Paste(t) => ("edit.paste", Some(t.clone())), + _ => return true, + }; + if app.native_shortcuts.contains(id) { + return true; + } + clip.push((id, text)); + false + }) + }); + for (id, text) in clip { + app.clipboard_in = text; + crate::menus::invoke(app, id, json!({})); + } if busy { for (k, tk) in [(Key::ArrowUp, ToolKey::Up), (Key::ArrowDown, ToolKey::Down)] { if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k)) { @@ -186,6 +207,42 @@ pub fn handle(app: &mut DrawcraftApp, ctx: &egui::Context) { mod tests { use super::*; + /// One headless frame delivering `events` to the shortcut handler. + fn frame(app: &mut DrawcraftApp, events: Vec) -> egui::FullOutput { + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { + handle(app, ui.ctx()); + app.logic(ui.ctx()); + }); + out.textures_delta.clear(); + out + } + + #[test] + fn copy_and_paste_events_use_the_system_clipboard() { + let mut app = DrawcraftApp::new(drawcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let id = app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone(); + app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); + // Copy publishes SVG to the system clipboard (egui's CopyText output command). + let out = frame(&mut app, vec![egui::Event::Copy]); + let copied = out.platform_output.commands.iter().find_map(|c| match c { + egui::OutputCommand::CopyText(t) => Some(t.clone()), + _ => None, + }); + let svg = copied.expect("copy publishes SVG"); + assert!(svg.contains(""##; + frame(&mut app, vec![egui::Event::Paste(foreign.into())]); + assert_eq!(app.session.doc().unwrap().doc.layers[0].children().unwrap().len(), 5); + // Plain text is not art: nothing is pasted from it (the internal clipboard is reused). + frame(&mut app, vec![egui::Event::Paste("hello".into())]); + assert_eq!(app.session.doc().unwrap().doc.layers[0].children().unwrap().len(), 8); + } + #[test] fn parses() { let s = parse("Cmd+Shift+]").unwrap();