M4.8: system clipboard — copy/cut publish SVG markup, paste adopts SVG from other apps (centred in view); Copy/Cut/Paste events handled off macOS; Place bypasses the clipboard hook
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
f855da4fb8
commit
318a55350a
|
|
@ -827,6 +827,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
|
||||||
name = "drawcraft"
|
name = "drawcraft"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"arboard",
|
||||||
"drawcraft-engine",
|
"drawcraft-engine",
|
||||||
"drawcraft-ui-egui",
|
"drawcraft-ui-egui",
|
||||||
"eframe",
|
"eframe",
|
||||||
|
|
@ -853,9 +854,14 @@ dependencies = [
|
||||||
name = "drawcraft-cli"
|
name = "drawcraft-cli"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"drawcraft-color",
|
||||||
|
"drawcraft-doc",
|
||||||
|
"drawcraft-engine",
|
||||||
|
"drawcraft-format",
|
||||||
"drawcraft-geom",
|
"drawcraft-geom",
|
||||||
"drawcraft-mcp",
|
"drawcraft-mcp",
|
||||||
"drawcraft-render",
|
"drawcraft-render",
|
||||||
|
"drawcraft-svg",
|
||||||
"drawcraft-testkit",
|
"drawcraft-testkit",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ egui_extras = { version = "0.36", default-features = false, features = ["svg"] }
|
||||||
eframe = { version = "0.36", default-features = false }
|
eframe = { version = "0.36", default-features = false }
|
||||||
egui_kittest = "0.36"
|
egui_kittest = "0.36"
|
||||||
rfd = "0.17"
|
rfd = "0.17"
|
||||||
|
arboard = { version = "3.6", default-features = false }
|
||||||
|
|
||||||
[workspace.lints.rust]
|
[workspace.lints.rust]
|
||||||
unsafe_code = "deny"
|
unsafe_code = "deny"
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ _Last updated: 2026-10-01._
|
||||||
| M1 | Selection, transform, layers, MCP | ✅ mostly done (rotated persistent bbox pending) | 3–5 |
|
| 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 |
|
| 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 `<mask>` in/out + PDF soft mask); mask-editing mode, freeform gradients pending | 6–10 |
|
| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | 🟡 panels done; opacity masks done (make/release, clip, invert, disable, link; render + SVG `<mask>` 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 |
|
| 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 |
|
| 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 |
|
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 in progress | 20–30 |
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ log = { workspace = true }
|
||||||
|
|
||||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||||
rfd = { workspace = true }
|
rfd = { workspace = true }
|
||||||
|
arboard = { workspace = true }
|
||||||
|
|
||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
muda = { version = "0.21", default-features = false }
|
muda = { version = "0.21", default-features = false }
|
||||||
|
|
|
||||||
|
|
@ -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()))),
|
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()))),
|
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()))),
|
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()
|
..Default::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<CommandSpec> {
|
||||||
|
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("<svg") || t.starts_with("<?xml") || t.starts_with("<!--") || t.starts_with("<!DOCTYPE svg")) && t.contains("<svg")
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Session {
|
||||||
|
/// The internal clipboard as standalone SVG (`None` when it is empty).
|
||||||
|
pub fn clipboard_svg(&self) -> Option<String> {
|
||||||
|
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<Value> {
|
||||||
|
Ok(json!({ "svg": s.clipboard_svg() }))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn import_svg(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
|
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<Node> = 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##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><circle cx="5" cy="5" r="5" fill="red"/><rect width="2" height="2"/></svg>"##;
|
||||||
|
assert!(looks_like_svg(svg) && looks_like_svg(&format!("<?xml version=\"1.0\"?>\n{svg}")));
|
||||||
|
assert!(!looks_like_svg("hello <svg> 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": "<svg xmlns=\"http://www.w3.org/2000/svg\"/>"})).is_err());
|
||||||
|
assert!(s.execute("clipboard.importSvg", &json!({"svg": "not svg"})).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
mod brushsym;
|
mod brushsym;
|
||||||
mod buildcmds;
|
mod buildcmds;
|
||||||
|
pub mod clipboard;
|
||||||
mod colorcmds;
|
mod colorcmds;
|
||||||
pub mod colormgmt;
|
pub mod colormgmt;
|
||||||
mod create;
|
mod create;
|
||||||
|
|
@ -126,6 +127,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||||
SPECS.get_or_init(|| {
|
SPECS.get_or_init(|| {
|
||||||
let mut v = Vec::new();
|
let mut v = Vec::new();
|
||||||
v.extend(edit::specs());
|
v.extend(edit::specs());
|
||||||
|
v.extend(clipboard::specs());
|
||||||
v.extend(recolor::specs());
|
v.extend(recolor::specs());
|
||||||
v.extend(file::specs());
|
v.extend(file::specs());
|
||||||
v.extend(create::specs());
|
v.extend(create::specs());
|
||||||
|
|
|
||||||
|
|
@ -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 src = drawcraft_svg::import(s).map_err(|e| e.to_string())?;
|
||||||
let nodes: Vec<Node> = src.layers.iter().flat_map(|l| l.children().cloned().unwrap_or_default()).map(|n| (*n).clone()).collect();
|
let nodes: Vec<Node> = src.layers.iter().flat_map(|l| l.children().cloned().unwrap_or_default()).map(|n| (*n).clone()).collect();
|
||||||
app.session.clipboard = nodes;
|
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(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let (w, h) = image_size(bytes)?;
|
let (w, h) = image_size(bytes)?;
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,9 @@ pub struct Services {
|
||||||
pub download: Option<DownloadFn>,
|
pub download: Option<DownloadFn>,
|
||||||
/// Web: start an async open (bytes arrive via `inbox`).
|
/// Web: start an async open (bytes arrive via `inbox`).
|
||||||
pub open_async: Option<Box<dyn FnMut()>>,
|
pub open_async: Option<Box<dyn FnMut()>>,
|
||||||
|
/// 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<Box<dyn FnMut() -> Option<String>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cached canvas raster.
|
/// Cached canvas raster.
|
||||||
|
|
@ -122,6 +125,11 @@ pub struct DrawcraftApp {
|
||||||
pub canvas_rect: Option<egui::Rect>,
|
pub canvas_rect: Option<egui::Rect>,
|
||||||
/// Hover position in document coordinates.
|
/// Hover position in document coordinates.
|
||||||
pub hover_doc: Option<drawcraft_geom::Point>,
|
pub hover_doc: Option<drawcraft_geom::Point>,
|
||||||
|
/// 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<String>,
|
||||||
|
clipboard_published: Option<String>,
|
||||||
|
pub(crate) clipboard_in: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DrawcraftApp {
|
impl DrawcraftApp {
|
||||||
|
|
@ -145,6 +153,9 @@ impl DrawcraftApp {
|
||||||
native_menu: false,
|
native_menu: false,
|
||||||
last_effect: None,
|
last_effect: None,
|
||||||
native_shortcuts: Default::default(),
|
native_shortcuts: Default::default(),
|
||||||
|
clipboard_out: None,
|
||||||
|
clipboard_published: None,
|
||||||
|
clipboard_in: None,
|
||||||
control_rx: None,
|
control_rx: None,
|
||||||
pending_screenshots: vec![],
|
pending_screenshots: vec![],
|
||||||
queued_screenshots: vec![],
|
queued_screenshots: vec![],
|
||||||
|
|
@ -197,7 +208,14 @@ impl DrawcraftApp {
|
||||||
if let Some(r) = menus::run_ui_command(self, id, ¶ms) {
|
if let Some(r) = menus::run_ui_command(self, id, ¶ms) {
|
||||||
return r;
|
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());
|
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();
|
self.sync_views();
|
||||||
match &r {
|
match &r {
|
||||||
Err(e) => self.ui.status = e.clone(),
|
Err(e) => self.ui.status = e.clone(),
|
||||||
|
|
@ -210,6 +228,21 @@ impl DrawcraftApp {
|
||||||
r
|
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).
|
/// Select a tool (also used by the toolbar and shortcuts).
|
||||||
pub fn select_tool(&mut self, id: &str) {
|
pub fn select_tool(&mut self, id: &str) {
|
||||||
let v = self.view_info();
|
let v = self.view_info();
|
||||||
|
|
@ -354,6 +387,9 @@ impl DrawcraftApp {
|
||||||
}
|
}
|
||||||
self.collect_screenshots(ctx);
|
self.collect_screenshots(ctx);
|
||||||
self.issue_screenshots(ctx);
|
self.issue_screenshots(ctx);
|
||||||
|
if let Some(t) = self.clipboard_out.take() {
|
||||||
|
ctx.copy_text(t);
|
||||||
|
}
|
||||||
self.drain_inbox();
|
self.drain_inbox();
|
||||||
if self.fonts_ready {
|
if self.fonts_ready {
|
||||||
shortcuts::handle(self, ctx);
|
shortcuts::handle(self, ctx);
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,27 @@ pub fn handle(app: &mut DrawcraftApp, ctx: &egui::Context) {
|
||||||
}
|
}
|
||||||
return;
|
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 {
|
if busy {
|
||||||
for (k, tk) in [(Key::ArrowUp, ToolKey::Up), (Key::ArrowDown, ToolKey::Down)] {
|
for (k, tk) in [(Key::ArrowUp, ToolKey::Up), (Key::ArrowDown, ToolKey::Down)] {
|
||||||
if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k)) {
|
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 {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// One headless frame delivering `events` to the shortcut handler.
|
||||||
|
fn frame(app: &mut DrawcraftApp, events: Vec<egui::Event>) -> 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("<svg"));
|
||||||
|
// Pasting our own SVG back uses the internal clipboard; foreign SVG replaces it.
|
||||||
|
frame(&mut app, vec![egui::Event::Paste(svg)]);
|
||||||
|
assert_eq!(app.session.doc().unwrap().doc.layers[0].children().unwrap().len(), 2);
|
||||||
|
let foreign = r##"<svg xmlns="http://www.w3.org/2000/svg"><circle cx="5" cy="5" r="5"/><circle cx="20" cy="5" r="5"/><circle cx="35" cy="5" r="5"/></svg>"##;
|
||||||
|
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]
|
#[test]
|
||||||
fn parses() {
|
fn parses() {
|
||||||
let s = parse("Cmd+Shift+]").unwrap();
|
let s = parse("Cmd+Shift+]").unwrap();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue