diff --git a/Cargo.lock b/Cargo.lock index aead39d1..bc60687e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -259,6 +259,7 @@ dependencies = [ "parking_lot", "percent-encoding", "windows-sys 0.60.2", + "wl-clipboard-rs", "x11rb", ] @@ -1734,6 +1735,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.10" @@ -2820,6 +2827,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -3204,6 +3220,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "parking" version = "2.2.1" @@ -3264,6 +3290,17 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + [[package]] name = "phf" version = "0.13.1" @@ -4574,6 +4611,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom", + "petgraph", +] + [[package]] name = "ttf-parser" version = "0.25.1" @@ -5726,6 +5774,24 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wl-clipboard-rs" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.5", + "thiserror 2.0.21", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "write-fonts" version = "0.48.1" diff --git a/apps/figcraft/Cargo.toml b/apps/figcraft/Cargo.toml index bafb6da1..fcc5d03a 100644 --- a/apps/figcraft/Cargo.toml +++ b/apps/figcraft/Cargo.toml @@ -21,7 +21,7 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", " serde_json = { workspace = true } log = { workspace = true } rfd = { workspace = true } -arboard = { workspace = true, features = ["image-data"] } +arboard = { workspace = true, features = ["image-data", "wayland-data-control"] } webbrowser = { workspace = true } image = { workspace = true } env_logger = { version = "0.11", default-features = false, features = ["auto-color", "humantime"] } diff --git a/apps/figcraft/src/services.rs b/apps/figcraft/src/services.rs index bd8a4204..56b14d96 100644 --- a/apps/figcraft/src/services.rs +++ b/apps/figcraft/src/services.rs @@ -223,6 +223,28 @@ fn picked(pick: impl Future> + 'static) -> Dial Box::pin(async move { pick.await.map(|f| f.path().to_path_buf()) }) } +/// How long a clipboard read may wait on the app that copied (Linux and the BSDs). +#[cfg(all(unix, not(target_os = "macos")))] +const CLIPBOARD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2); + +/// Read the clipboard. On Linux and the BSDs the read runs on a helper thread with its own handle +/// and gives up after [`CLIPBOARD_TIMEOUT`]: a Wayland read waits on the app that copied with no +/// timeout of its own, so an app that hangs must not hang the window too. +#[cfg(all(unix, not(target_os = "macos")))] +fn read_clipboard(_: &mut arboard::Clipboard, read: fn(&mut arboard::Clipboard) -> Option) -> Option { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(arboard::Clipboard::new().ok().and_then(|mut c| read(&mut c))); + }); + rx.recv_timeout(CLIPBOARD_TIMEOUT).ok().flatten() +} + +/// Read the clipboard (the system's own reads don't wait on the app that copied). +#[cfg(not(all(unix, not(target_os = "macos"))))] +fn read_clipboard(c: &mut arboard::Clipboard, read: fn(&mut arboard::Clipboard) -> Option) -> Option { + read(c) +} + impl Services for Desktop { fn pick_open(&mut self, title: &str, exts: &[&str]) -> DialogFuture { picked(dialog(title).add_filter("Supported files", exts).pick_file()) @@ -239,7 +261,7 @@ impl Services for Desktop { } } fn clipboard_get_text(&mut self) -> Option { - self.clipboard.as_mut()?.get_text().ok() + read_clipboard(self.clipboard.as_mut()?, |c| c.get_text().ok()) } fn clipboard_set_png(&mut self, w: u32, h: u32, rgba: &[u8]) { if let Some(c) = self.clipboard.as_mut() { @@ -247,7 +269,7 @@ impl Services for Desktop { } } fn clipboard_get_image(&mut self) -> Option<(u32, u32, Vec)> { - let img = self.clipboard.as_mut()?.get_image().ok()?; + let img = read_clipboard(self.clipboard.as_mut()?, |c| c.get_image().ok())?; let (w, h) = (u32::try_from(img.width).ok()?, u32::try_from(img.height).ok()?); // Refuse absurd sizes (and mismatched buffers) rather than allocate. if w == 0 || h == 0 || u64::from(w) * u64::from(h) > 100_000_000 || img.bytes.len() != (w as usize) * (h as usize) * 4 { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 860fb558..b99a04d1 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -142,6 +142,8 @@ mod panel_reorder_tests; #[cfg(test)] mod panel_width_tests; #[cfg(test)] +mod paste_image_tests; +#[cfg(test)] mod pen_mode_tests; #[cfg(test)] mod perf_tests; @@ -496,6 +498,15 @@ pub struct UiState { /// What FigCraft last put on the system clipboard: when the clipboard still holds it, a paste /// uses our copied layers; anything else there (an image, SVG, text) came from another app. pub last_copy: Option, + /// Since the last V key release: whether its press arrived, and whether a paste event did + /// (`shortcuts::swallowed_paste`). + pub v_pressed: bool, + pub paste_event: bool, + /// The window got the focus back since the last V release. + pub refocused: bool, + /// The modifiers held, as of the last modifier change in the raw events: a paste event doesn't + /// carry them. + pub held: egui::Modifiers, /// When (UI clock, seconds) open documents next get an autosave checkpoint. pub next_autosave: f64, /// With auto-save on: when (UI clock, seconds) files with unsaved changes are next saved, and @@ -589,6 +600,10 @@ impl Default for UiState { dev: dev_mode::DevUi::default(), last_copy: None, + v_pressed: false, + paste_event: false, + refocused: false, + held: egui::Modifiers::NONE, next_autosave: 0.0, auto_save_at: None, auto_saving: Vec::new(), @@ -1289,6 +1304,7 @@ impl FigApp { // even if it lets go during the frame (Enter commits it), or as it began (egui drops the // focus on Escape before the frame, which cancels the field's edit and nothing else). let typing = std::mem::take(&mut self.field_focus) || a11y::field_focused(&ctx); + let swallowed = shortcuts::swallowed_paste(self, &ctx); // A dialog that closed acts first, in the window it was opened from. self.poll_dialog(&ctx); // Control requests run first so their results reflect this frame. @@ -1404,7 +1420,7 @@ impl FigApp { branch_ui::keys(self, &ctx); live::dialog_keys(self, &ctx); dialogs::keys(self, &ctx); - shortcuts::handle(self, &ctx, typing); + shortcuts::handle(self, &ctx, typing, swallowed); menus::popups(self, &ctx); variable_ui::picker(self, &ctx); style_picker::show(self, &ctx); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 498dc8de..62c5f0b0 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -32,17 +32,18 @@ pub const UI_COMMANDS: &[&str] = &[ /// Run a menu item: some open dialogs or use platform services, the rest go to the engine. /// Paste: the system clipboard wins when it holds something newer than our own copy (an image, SVG /// or text from another app); otherwise our copied layers. `event_text` is the text the platform -/// handed over with the paste event (the browser has no other way to read it). -pub fn paste(app: &mut FigApp, event_text: Option<&str>) { +/// handed over with the paste event (the browser has no other way to read it). `at`: the page +/// point to paste at (Paste here); `None` pastes in the middle of the view. +pub fn paste(app: &mut FigApp, event_text: Option<&str>, at: Option<(f64, f64)>) { let sys_text = app.services.clipboard_get_text().or_else(|| event_text.map(str::to_string)).filter(|t| !t.is_empty()); let ours = sys_text.is_some() && sys_text == app.ui.last_copy; - let (cx, cy) = app.session.ds().map(|d| (d.view.cx, d.view.cy)).unwrap_or((0.0, 0.0)); + let (cx, cy) = at.or_else(|| app.session.ds().ok().map(|d| (d.view.cx, d.view.cy))).unwrap_or((0.0, 0.0)); if !ours { if let Some((png, w, h)) = clipboard_png(app) { use base64::Engine as _; let b64 = base64::engine::general_purpose::STANDARD.encode(png); - // Centred in the view. - let (x, y) = ((cx - f64::from(w) / 2.0).round(), (cy - f64::from(h) / 2.0).round()); + // Centred in the view; Paste here puts its top-left at the point, as it does layers. + let (x, y) = if at.is_some() { (cx, cy) } else { ((cx - f64::from(w) / 2.0).round(), (cy - f64::from(h) / 2.0).round()) }; app.run("file.import", json!({"data": b64, "name": "Pasted image.png", "x": x, "y": y})); return; } @@ -54,14 +55,14 @@ pub fn paste(app: &mut FigApp, event_text: Option<&str>) { } else if figcraft_engine::looks_like_svg(&text) { use base64::Engine as _; let b64 = base64::engine::general_purpose::STANDARD.encode(text.as_bytes()); - app.run("file.import", json!({"data": b64, "name": "Pasted SVG.svg", "x": cx, "y": cy, "center": true})); + app.run("file.import", json!({"data": b64, "name": "Pasted SVG.svg", "x": cx, "y": cy, "center": at.is_none()})); } else { app.run("text.create", json!({"text": text, "x": cx, "y": cy})); } return; } } - app.run("edit.paste", json!({})); + app.run("edit.paste", at.map_or(json!({}), |(x, y)| json!({"x": x, "y": y}))); } /// The system clipboard's image as PNG bytes, with its size. @@ -143,7 +144,7 @@ pub fn invoke(app: &mut FigApp, id: &str) { app.services.clipboard_set_text(&text); app.ui.last_copy = Some(text); } - "edit.paste" => paste(app, None), + "edit.paste" => paste(app, None, None), "file.close" if app.session.ds().is_ok_and(|d| d.is_dirty() && d.branch_of.is_none()) => { let save = pretty_shortcut(crate::shortcuts::shortcut("file.save").unwrap_or("")); app.toast(trf( @@ -553,11 +554,7 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { if item(ui, label, sc, available(id), false, &t) { if *id == "edit.paste" { let p = app.to_page(pos); - if app.session.clipboard.nodes.is_empty() { - invoke(app, id); - } else { - app.run("edit.paste", json!({"x": p.x, "y": p.y})); - } + paste(app, None, Some((p.x, p.y))); } else { invoke(app, id); } diff --git a/crates/ui-egui/src/paste_image_tests.rs b/crates/ui-egui/src/paste_image_tests.rs new file mode 100644 index 00000000..48bd142e --- /dev/null +++ b/crates/ui-egui/src/paste_image_tests.rs @@ -0,0 +1,408 @@ +//! Pasting an image copied in another app (#663). On the desktop, egui-winit swallows Cmd/Ctrl+V's +//! press and sends a paste event only when the clipboard holds text: an image leaves just the +//! key's release. The browser sends the press and a paste event (`web_clipboard_tests`). + +use std::cell::RefCell; +use std::rc::Rc; + +use egui::{Event, Key, Modifiers, PointerButton, Pos2, RawInput, Rect, Shape, vec2}; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::json; + +use crate::{FigApp, Services}; + +/// What the system clipboard holds when a paste chord is pressed (#716). +#[derive(Clone, Copy, Debug)] +enum Held { + /// FigCraft's own copy (its SVG text). + Ours, + /// Text copied in another app. + Text, + /// An image copied in another app. + Image, +} + +const HELD: [Held; 3] = [Held::Ours, Held::Text, Held::Image]; + +#[derive(Default)] +struct Clip { + text: Option, + image: Option<(u32, u32, Vec)>, +} + +/// The desktop clipboard. +struct Desktop(Rc>); + +impl Services for Desktop { + fn clipboard_set_text(&mut self, s: &str) { + *self.0.borrow_mut() = Clip { text: Some(s.into()), image: None }; + } + fn clipboard_get_text(&mut self) -> Option { + self.0.borrow().text.clone() + } + fn clipboard_get_image(&mut self) -> Option<(u32, u32, Vec)> { + self.0.borrow().image.clone() + } +} + +struct Win { + ctx: egui::Context, + app: FigApp, + time: f64, + clip: Rc>, + shapes: Vec, +} + +impl Win { + fn new() -> Self { + let mut s = Session::new(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap(); + let ctx = egui::Context::default(); + let clip = Rc::new(RefCell::new(Clip::default())); + let app = FigApp::new(s, Box::new(Desktop(clip.clone()))); + let mut w = Self { ctx, app, time: 0.0, clip, shapes: Vec::new() }; + for _ in 0..3 { + w.frame(vec![]); + } + w + } + + fn frame(&mut self, events: Vec) { + self.frame_with(events, false); + } + + /// `take_v`: something ahead of the app (a prototype's key trigger) consumes a V press. + fn frame_with(&mut self, events: Vec, take_v: bool) { + self.time += 1.0 / 60.0; + let input = + RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), time: Some(self.time), events, ..Default::default() }; + let app = &mut self.app; + let mut out = self.ctx.run_ui(input, |ui| { + if take_v { + ui.ctx().input_mut(|i| i.consume_key(Modifiers::NONE, Key::V)); + } + app.ui(ui); + }); + out.textures_delta.clear(); + self.shapes = out.shapes.into_iter().map(|s| s.shape).collect(); + } + + fn copy_image_elsewhere(&mut self) { + *self.clip.borrow_mut() = Clip { text: None, image: Some((2, 2, vec![255; 16])) }; + } + + /// Cmd/Ctrl+V as egui-winit delivers it: the press brings a paste event when the clipboard + /// holds text and nothing otherwise; the release comes as a key event. + fn cmd_v(&mut self, m: Modifiers) { + let text = self.clip.borrow().text.clone().filter(|t| !t.is_empty()); + self.frame(text.map(Event::Paste).into_iter().collect()); + self.frame(vec![key(Key::V, false, m)]); + self.frame(vec![]); + } + + /// A Cmd/Ctrl+V chord with its modifiers, as egui-winit delivers it: they go down first, the V + /// press brings a paste event only for text, its release comes as a key event, then they're + /// let go. + fn chord(&mut self, m: Modifiers) { + let text = self.clip.borrow().text.clone().filter(|t| !t.is_empty()); + self.frame(vec![Event::ModifiersChanged(m)]); + self.frame(text.map(Event::Paste).into_iter().collect()); + self.frame(vec![key(Key::V, false, m)]); + self.frame(vec![Event::ModifiersChanged(Modifiers::NONE)]); + } + + /// A red rectangle A copied in FigCraft, and a grey rectangle B selected; then `held` on the + /// system clipboard. Returns (A, B). + fn copy_a_select_b(&mut self, held: Held) -> (NodeId, NodeId) { + let s = &mut self.app.session; + let a = s.doc().unwrap().nodes().find(|n| n.name == "Rectangle").unwrap().id; + s.execute("fill.set", &json!({"ids": [a.0], "color": "#e53935"})).unwrap(); + let b = s.execute("shape.rectangle", &json!({"x": 400, "y": 250, "width": 100, "height": 50})).unwrap(); + let b = NodeId(b["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + crate::menus::invoke(&mut self.app, "edit.copy"); + self.app.session.execute("edit.copyProperties", &json!({})).unwrap(); + self.app.session.execute("select.set", &json!({"ids": [b.0]})).unwrap(); + match held { + Held::Ours => {} + Held::Text => self.clip.borrow_mut().text = Some("hello from another app".into()), + Held::Image => self.copy_image_elsewhere(), + } + (a, b) + } + + fn fill(&self, id: NodeId) -> String { + let n = figcraft_engine::inspect::node_json(self.app.session.doc().unwrap(), id, 0); + n["fills"][0]["color"].as_str().unwrap_or_default().to_string() + } + + /// Top-lefts of the rectangles other than `b`. + fn rects_besides(&self, b: NodeId) -> Vec<(i64, i64)> { + let doc = self.app.session.doc().unwrap(); + let mut at: Vec<_> = doc + .nodes() + .filter(|n| n.name == "Rectangle" && n.id != b) + .filter_map(|n| doc.world_bounds(n.id)) + .map(|r| (r.x0.round() as i64, r.y0.round() as i64)) + .collect(); + at.sort(); + at + } + + fn named(&self, name: &str) -> usize { + self.app.session.doc().unwrap().nodes().filter(|n| n.name == name).count() + } + + fn texts(&self) -> usize { + self.app.session.doc().unwrap().nodes().filter(|n| matches!(n.kind, figcraft_doc::NodeKind::Text(_))).count() + } + + fn rects(&self) -> usize { + self.named("Rectangle") + } +} + +const CMD: Modifiers = Modifiers::COMMAND.plus(Modifiers::CTRL); + +fn key(key: Key, pressed: bool, modifiers: Modifiers) -> Event { + Event::Key { key, physical_key: Some(key), pressed, repeat: false, modifiers } +} + +#[test] +fn cmd_v_pastes_an_image_copied_elsewhere() { + let mut w = Win::new(); + w.copy_image_elsewhere(); + w.cmd_v(CMD); + assert_eq!(w.named("Pasted image"), 1, "the image was pasted from the key's release"); + w.cmd_v(CMD); + assert_eq!(w.named("Pasted image"), 2, "and again"); +} + +#[test] +fn cmd_released_before_v_still_pastes_the_image() { + let mut w = Win::new(); + w.copy_image_elsewhere(); + w.cmd_v(Modifiers::NONE); + assert_eq!(w.named("Pasted image"), 1); +} + +#[test] +fn text_pastes_once_from_its_paste_event() { + let mut w = Win::new(); + *w.clip.borrow_mut() = Clip { text: Some("hello from another app".into()), image: None }; + w.cmd_v(CMD); + assert_eq!(w.texts(), 1, "the paste event pasted the text, the release nothing more"); + assert_eq!(w.rects(), 1); +} + +#[test] +fn text_pastes_once_when_another_key_is_let_go_first() { + let mut w = Win::new(); + *w.clip.borrow_mut() = Clip { text: Some("hello".into()), image: None }; + // Cmd+A then Cmd+V typed fast: A comes up after V went down. + w.frame(vec![key(Key::A, true, CMD)]); + w.frame(vec![Event::Paste("hello".into())]); + w.frame(vec![key(Key::A, false, CMD)]); + w.frame(vec![key(Key::V, false, CMD)]); + w.frame(vec![]); + assert_eq!(w.texts(), 1); + assert_eq!(w.rects(), 1); +} + +#[test] +fn a_v_press_consumed_elsewhere_does_not_paste_on_release() { + let mut w = Win::new(); + w.copy_image_elsewhere(); + w.frame_with(vec![key(Key::V, true, Modifiers::NONE)], true); + w.frame(vec![key(Key::V, false, Modifiers::NONE)]); + w.frame(vec![]); + assert_eq!(w.named("Pasted image"), 0); +} + +#[test] +fn the_browser_sequence_pastes_once() { + let mut w = Win::new(); + w.copy_image_elsewhere(); + w.frame(vec![key(Key::V, true, CMD)]); + w.frame(vec![key(Key::V, false, CMD)]); + w.frame(vec![]); + assert_eq!(w.named("Pasted image"), 1); +} + +#[test] +fn v_alone_pastes_nothing() { + let mut w = Win::new(); + w.copy_image_elsewhere(); + w.frame(vec![key(Key::V, true, Modifiers::NONE)]); + w.frame(vec![key(Key::V, false, Modifiers::NONE)]); + assert_eq!(w.named("Pasted image"), 0); +} + +#[test] +fn a_paste_event_from_another_key_does_not_block_the_next_cmd_v() { + let mut w = Win::new(); + *w.clip.borrow_mut() = Clip { text: Some("text".into()), image: None }; + // Shift+Insert on Windows. + w.frame(vec![Event::Paste("text".into())]); + w.frame(vec![key(Key::Insert, false, Modifiers::SHIFT)]); + w.copy_image_elsewhere(); + w.cmd_v(CMD); + assert_eq!(w.named("Pasted image"), 1); +} + +#[test] +fn a_v_press_lost_to_another_window_does_not_block_the_next_cmd_v() { + let mut w = Win::new(); + w.copy_image_elsewhere(); + w.frame(vec![key(Key::V, true, Modifiers::NONE), Event::WindowFocused(false)]); + w.frame(vec![Event::WindowFocused(true)]); + w.cmd_v(CMD); + assert_eq!(w.named("Pasted image"), 1); +} + +#[test] +fn v_held_while_the_window_was_away_pastes_nothing_on_release() { + let mut w = Win::new(); + w.copy_image_elsewhere(); + // Pressed in another window: only the focus and the release arrive here. + w.frame(vec![Event::WindowFocused(true)]); + w.frame(vec![key(Key::V, false, Modifiers::NONE)]); + w.frame(vec![]); + assert_eq!(w.named("Pasted image"), 0); + w.cmd_v(CMD); + assert_eq!(w.named("Pasted image"), 1, "the next Cmd+V still pastes"); +} + +#[test] +fn cmd_v_in_a_panel_field_does_not_paste_on_the_canvas() { + let mut w = Win::new(); + w.copy_image_elsewhere(); + w.frame(vec![]); + w.app.field_focus = true; + w.frame(vec![key(Key::V, false, CMD)]); + w.frame(vec![]); + assert_eq!(w.named("Pasted image"), 0); +} + +#[test] +fn paste_here_prefers_a_newer_image_over_copied_layers() { + let mut w = Win::new(); + w.app.session.execute("select.all", &json!({})).unwrap(); + crate::menus::invoke(&mut w.app, "edit.copy"); + assert!(!w.app.session.clipboard.nodes.is_empty(), "layers copied"); + w.app.session.execute("select.none", &json!({})).unwrap(); + w.copy_image_elsewhere(); + let at = Pos2::new(900.0, 600.0); + for pressed in [true, false] { + w.frame(vec![ + Event::PointerMoved(at), + Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }, + ]); + } + w.frame(vec![]); + let item = crate::live_tests::text_center(&w.shapes, "Paste here"); + for pressed in [true, false] { + w.frame(vec![ + Event::PointerMoved(item), + Event::PointerButton { pos: item, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }, + ]); + } + w.frame(vec![]); + assert_eq!(w.named("Pasted image"), 1, "the newer image was pasted"); + assert_eq!(w.rects(), 1, "not the copied rectangle"); + let p = w.app.to_page(at); + let doc = w.app.session.doc().unwrap(); + let img = doc.nodes().find(|n| n.name == "Pasted image").unwrap(); + let b = doc.world_bounds(img.id).unwrap(); + assert!((b.x0 - p.x).abs() < 0.01 && (b.y0 - p.y).abs() < 0.01, "top-left where the menu opened: {b:?} vs {p:?}"); +} + +#[test] +fn shift_cmd_v_pastes_over_the_selection_whatever_the_clipboard_holds() { + for held in HELD { + let mut w = Win::new(); + let (_, b) = w.copy_a_select_b(held); + w.chord(CMD.plus(Modifiers::SHIFT)); + assert_eq!(w.rects_besides(b), [(10, 10), (400, 250)], "{held:?}: A's copy at B's top-left, nothing else"); + assert_eq!(w.texts(), 0, "{held:?}: no text pasted"); + assert_eq!(w.named("Pasted image"), 0, "{held:?}: no image pasted"); + } +} + +#[test] +fn alt_cmd_v_pastes_properties_whatever_the_clipboard_holds() { + for held in HELD { + let mut w = Win::new(); + let (_, b) = w.copy_a_select_b(held); + w.chord(CMD.plus(Modifiers::ALT)); + assert_eq!(w.fill(b), "#E53935", "{held:?}: B took A's fill"); + assert_eq!(w.rects_besides(b), [(10, 10)], "{held:?}: no layer pasted"); + assert_eq!(w.texts(), 0, "{held:?}: no text pasted"); + assert_eq!(w.named("Pasted image"), 0, "{held:?}: no image pasted"); + } +} + +#[test] +fn a_chord_let_go_within_one_frame_still_reads_its_modifiers() { + let mut w = Win::new(); + let (_, b) = w.copy_a_select_b(Held::Text); + let m = CMD.plus(Modifiers::SHIFT); + w.frame(vec![ + Event::ModifiersChanged(m), + Event::Paste("hello from another app".into()), + key(Key::V, false, m), + Event::ModifiersChanged(Modifiers::NONE), + ]); + w.frame(vec![]); + assert_eq!(w.rects_besides(b), [(10, 10), (400, 250)]); + assert_eq!(w.texts(), 0); +} + +#[test] +fn cmd_let_go_before_v_still_pastes_over() { + let mut w = Win::new(); + let (_, b) = w.copy_a_select_b(Held::Image); + w.frame(vec![Event::ModifiersChanged(CMD.plus(Modifiers::SHIFT))]); + w.frame(vec![Event::ModifiersChanged(Modifiers::SHIFT)]); + w.frame(vec![key(Key::V, false, Modifiers::SHIFT)]); + w.frame(vec![Event::ModifiersChanged(Modifiers::NONE)]); + assert_eq!(w.rects_besides(b), [(10, 10), (400, 250)], "pasted over, not flipped or a plain paste"); + assert_eq!(w.named("Pasted image"), 0); +} + +#[test] +fn a_held_chord_pastes_over_once() { + let mut w = Win::new(); + let (_, b) = w.copy_a_select_b(Held::Text); + let m = CMD.plus(Modifiers::SHIFT); + w.frame(vec![Event::ModifiersChanged(m)]); + // Auto-repeat: egui-winit sends a paste event for every repeated press. + for _ in 0..3 { + w.frame(vec![Event::Paste("hello from another app".into())]); + } + w.frame(vec![key(Key::V, false, m)]); + w.frame(vec![Event::ModifiersChanged(Modifiers::NONE)]); + assert_eq!(w.rects_besides(b), [(10, 10), (400, 250)]); + assert_eq!(w.texts(), 0); +} + +#[test] +fn shift_insert_still_pastes_text() { + let mut w = Win::new(); + let (_, b) = w.copy_a_select_b(Held::Text); + w.frame(vec![Event::ModifiersChanged(Modifiers::SHIFT), Event::Paste("hello from another app".into())]); + w.frame(vec![key(Key::Insert, false, Modifiers::SHIFT), Event::ModifiersChanged(Modifiers::NONE)]); + assert_eq!(w.texts(), 1); + assert_eq!(w.rects_besides(b), [(10, 10)]); +} + +#[test] +fn cmd_v_after_a_paste_chord_is_a_plain_paste_again() { + let mut w = Win::new(); + let (_, b) = w.copy_a_select_b(Held::Text); + w.chord(CMD.plus(Modifiers::SHIFT)); + w.chord(CMD); + assert_eq!(w.texts(), 1, "the text was pasted by the plain Cmd+V"); + assert_eq!(w.rects_besides(b), [(10, 10), (400, 250)], "Shift+Cmd+V pasted over once"); +} diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index d5bfb156..0a291a7c 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -94,7 +94,8 @@ fn cached_tip(label: &'static str, key: Option<&'static str>) -> &'static str { /// Run the shortcuts this frame's keys press. `typing`: a field had the keyboard when the frame /// began. -pub fn handle(app: &mut FigApp, ctx: &Context, typing: bool) { +/// `swallowed`: what this frame's Cmd/Ctrl+V chords the shell swallowed ask for ([`swallowed_paste`]). +pub fn handle(app: &mut FigApp, ctx: &Context, typing: bool, swallowed: Swallowed) { note_platform(ctx); // Text fields in panels own the keyboard (also the key that ends their editing). if typing || crate::a11y::field_focused(ctx) || app.ui.palette.is_some() { @@ -109,18 +110,21 @@ pub fn handle(app: &mut FigApp, ctx: &Context, typing: bool) { text_keys(app, ctx, &events); return; } - // An image-only clipboard brings no paste event, just the key: paste from the key then. + // An image-only clipboard brings no paste event: paste from the key then. The browser sends + // the press; the desktop shell swallows it and sends only the release. let paste_event = events.iter().any(|e| matches!(e, Event::Paste(_))); - let paste_key = events.iter().any(|e| matches!(e, Event::Key { key: Key::V, pressed: true, repeat: false, modifiers, .. } if modifiers.command && !modifiers.shift && !modifiers.alt)); - if paste_key && !paste_event { - crate::menus::paste(app, None); + let paste_key = events.iter().any(|e| matches!(e, Event::Key { key: Key::V, pressed: true, repeat: false, modifiers, .. } if modifiers.command && v_binding(*modifiers) == Some("edit.paste"))); + match swallowed.run.or((paste_key && !paste_event).then_some("edit.paste")) { + Some("edit.paste") => crate::menus::paste(app, None, None), + Some(id) => run_binding(app, id), + None => {} } let now = ctx.input(|i| i.time); for ev in &events { match ev { Event::Copy => crate::menus::invoke(app, "edit.copy"), Event::Cut => crate::menus::invoke(app, "edit.cut"), - Event::Paste(text) => crate::menus::paste(app, Some(text)), + Event::Paste(text) if !swallowed.drop_text => crate::menus::paste(app, Some(text), None), Event::Key { key, physical_key, pressed: true, modifiers, repeat } if key_event(app, *key, *physical_key, *modifiers, *repeat, now, ctx) => { @@ -131,6 +135,76 @@ pub fn handle(app: &mut FigApp, ctx: &Context, typing: bool) { } } +/// The command Cmd/Ctrl+V runs with `m` held too: `edit.paste`, `edit.pasteOver` (Shift), +/// `edit.pasteProperties` (Alt), or `None` for a chord nothing is bound to. The keymap decides, +/// whether the key press arrived (browser) or only a paste event and the release (desktop). +fn v_binding(m: Modifiers) -> Option<&'static str> { + lookup(keymap::Context::Canvas, Key::V, Some(Key::V), Modifiers { command: true, ..m }).map(|b| b.id) +} + +/// What this frame's Cmd/Ctrl+V chords ask for when the desktop shell swallowed their press +/// ([`swallowed_paste`]). +#[derive(Clone, Copy, Default)] +pub struct Swallowed { + /// The command to run ([`v_binding`]). + pub run: Option<&'static str>, + /// This frame's paste event belongs to another command or an unbound chord: its text isn't + /// pasted. + pub drop_text: bool, +} + +/// egui-winit swallows every Cmd/Ctrl+V press, Shift or Alt held or not, and sends a paste event +/// only when the clipboard holds text. So a chord brings a paste event (text) or just its release +/// (an image, or nothing). The browser sends the press as well, which runs the command itself. +/// Call at the start of every frame: it reads the raw events, which keys consumed by panels or the +/// presenter still carry. +pub fn swallowed_paste(app: &mut FigApp, ctx: &Context) -> Swallowed { + let ui = &mut app.ui; + let mut out = Swallowed::default(); + ctx.input(|i| { + for ev in &i.raw.events { + match ev { + Event::ModifiersChanged(m) => ui.held = *m, + // Without Cmd/Ctrl held it's another paste key (Shift+Insert, a Paste key). + Event::Paste(_) if ui.held.command => { + let id = v_binding(ui.held); + if id != Some("edit.paste") { + out.drop_text = true; + // Held down, the chord repeats its paste event: run once, as keys do. + if !ui.v_pressed && !ui.paste_event { + out.run = out.run.or(id); + } + } + ui.paste_event = true; + } + Event::Paste(_) => ui.paste_event = true, + Event::Key { key: Key::V, pressed: true, .. } => ui.v_pressed = true, + Event::Key { key: Key::V, pressed: false, modifiers, .. } => { + // Cmd was down when V went down, maybe let go first. A V held while the window + // was away comes back as a release alone: without Cmd held, that's no paste. + if !ui.v_pressed && !ui.paste_event && (modifiers.command || !ui.refocused) { + out.run = out.run.or(v_binding(*modifiers)); + } + ui.v_pressed = false; + ui.paste_event = false; + ui.refocused = false; + } + // The other keys that paste (Shift+Insert, a Paste key) end their paste event. + Event::Key { key: Key::Insert | Key::Paste, pressed: false, .. } => ui.paste_event = false, + // Keys let go in another window never come back as releases. + Event::WindowFocused(false) => { + ui.v_pressed = false; + ui.paste_event = false; + ui.held = Modifiers::NONE; + } + Event::WindowFocused(true) => ui.refocused = true, + _ => {} + } + } + }); + out +} + /// Run the command a shortcut is bound to, with the keyboard's own twists (Escape steps out /// before it deselects, Delete ends a pen path, Enter edits the selected text or vector). pub fn run_binding(app: &mut FigApp, id: &str) { diff --git a/crates/ui-egui/src/web_clipboard_tests.rs b/crates/ui-egui/src/web_clipboard_tests.rs index a7787120..26389ff4 100644 --- a/crates/ui-egui/src/web_clipboard_tests.rs +++ b/crates/ui-egui/src/web_clipboard_tests.rs @@ -86,6 +86,26 @@ impl Page { self.frame(vec![Self::key(Key::V, false)]); } + /// Cmd+V with `m` added, as the browser delivers it: the key press, then the paste event. + fn chord(&mut self, m: Modifiers) { + let m = m | Modifiers::MAC_CMD | Modifiers::COMMAND; + let v = |pressed| Event::Key { key: Key::V, physical_key: Some(Key::V), pressed, repeat: false, modifiers: m }; + let text = self.clipboard.clone(); + self.frame(vec![Event::ModifiersChanged(m), v(true), Event::Paste(text)]); + self.frame(vec![v(false)]); + self.frame(vec![Event::ModifiersChanged(Modifiers::NONE)]); + } + + /// Copy the selected rectangle A (made red) and its properties, then select a new rectangle B. + fn copy_a_select_b(&mut self) -> figcraft_doc::NodeId { + self.app.session.execute("fill.set", &json!({"color": "#e53935"})).unwrap(); + self.copy(); + self.app.session.execute("edit.copyProperties", &json!({})).unwrap(); + let b = self.app.session.execute("shape.rectangle", &json!({"x": 400, "y": 250, "width": 100, "height": 50})).unwrap(); + self.app.session.execute("select.set", &json!({"ids": [b["id"]]})).unwrap(); + figcraft_doc::NodeId(b["id"].as_u64().unwrap()) + } + fn count(&self, kind: fn(&NodeKind) -> bool) -> usize { self.app.session.doc().unwrap().nodes().filter(|n| kind(&n.kind)).count() } @@ -121,6 +141,52 @@ fn text_copied_elsewhere_after_our_copy_still_pastes_as_text() { assert_eq!(p.count(text), 1, "newer text from elsewhere becomes a text layer"); } +#[test] +fn shift_cmd_v_pastes_over_once_and_no_text() { + for foreign in [false, true] { + let mut p = Page::new(); + let b = p.copy_a_select_b(); + if foreign { + p.clipboard = "hello from another app".into(); + } + p.chord(Modifiers::SHIFT); + assert_eq!(p.count(rect), 3, "foreign text {foreign}: one copy pasted over B"); + assert_eq!(p.count(text), 0, "foreign text {foreign}: the paste event pasted nothing"); + let doc = p.app.session.doc().unwrap(); + let over = doc.nodes().filter(|n| n.id != b).filter_map(|n| doc.world_bounds(n.id)).any(|r| r.x0 == 400.0 && r.y0 == 250.0); + assert!(over, "foreign text {foreign}: the copy sits at B's top-left"); + } +} + +#[test] +fn alt_cmd_v_pastes_properties_once_and_no_text() { + for foreign in [false, true] { + let mut p = Page::new(); + let b = p.copy_a_select_b(); + if foreign { + p.clipboard = "hello from another app".into(); + } + p.chord(Modifiers::ALT); + assert_eq!(p.count(rect), 2, "foreign text {foreign}: no layer pasted"); + assert_eq!(p.count(text), 0, "foreign text {foreign}: the paste event pasted nothing"); + let fill = figcraft_engine::inspect::node_json(p.app.session.doc().unwrap(), b, 0)["fills"][0]["color"].clone(); + assert_eq!(fill, "#E53935", "foreign text {foreign}: B took A's fill"); + } +} + +#[test] +fn a_chord_tapped_within_one_frame_runs_once() { + let mut p = Page::new(); + p.copy_a_select_b(); + let m = Modifiers::SHIFT | Modifiers::MAC_CMD | Modifiers::COMMAND; + let v = |pressed| Event::Key { key: Key::V, physical_key: Some(Key::V), pressed, repeat: false, modifiers: m }; + let clip = p.clipboard.clone(); + p.frame(vec![Event::ModifiersChanged(m), v(true), Event::Paste(clip), v(false), Event::ModifiersChanged(Modifiers::NONE)]); + p.frame(vec![]); + assert_eq!(p.count(rect), 3, "one copy pasted over B"); + assert_eq!(p.count(text), 0); +} + #[test] fn copy_as_png_puts_an_image_on_the_clipboard() { let mut p = Page::new();