From 40955aae7c97d48667c2a114bf31a260a257025c Mon Sep 17 00:00:00 2001 From: RealtaNua Date: Fri, 16 Oct 2026 23:09:59 +0700 Subject: [PATCH 1/5] fix(ui): Cmd/Ctrl+V pastes an image copied in another app (#663) egui-winit swallows the Cmd/Ctrl+V press and sends a paste event only when the clipboard holds text, so with an image on the clipboard only the key's release arrives and the image fallback, which waited for the press, never ran. Paste from a V release whose press never came and brought no paste event; the press and paste event are tracked from the raw events at the start of each frame. On Wayland arboard read the X11 clipboard, which the compositor does not fill for a native Wayland window, so every system paste found it empty. Enable arboard's wayland-data-control: it uses the Wayland clipboard when WAYLAND_DISPLAY is set and falls back to X11 otherwise. Right-click > Paste here now follows the same newest-wins rule, so a newer image beats copied layers there too, and lands at the point. Co-Authored-By: Claude Opus 5.5 (1M context) --- Cargo.lock | 66 +++++++ apps/figcraft/Cargo.toml | 2 +- crates/ui-egui/src/lib.rs | 11 +- crates/ui-egui/src/menus.rs | 23 +-- crates/ui-egui/src/paste_image_tests.rs | 234 ++++++++++++++++++++++++ crates/ui-egui/src/shortcuts.rs | 44 ++++- 6 files changed, 360 insertions(+), 20 deletions(-) create mode 100644 crates/ui-egui/src/paste_image_tests.rs 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/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 20a8a77c..bbc61149 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; @@ -494,6 +496,10 @@ 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, /// 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 @@ -587,6 +593,8 @@ impl Default for UiState { dev: dev_mode::DevUi::default(), last_copy: None, + v_pressed: false, + paste_event: false, next_autosave: 0.0, auto_save_at: None, auto_saving: Vec::new(), @@ -1283,6 +1291,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 paste_release = 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. @@ -1398,7 +1407,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, paste_release); 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 b9d3299a..fcbc1dad 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( @@ -532,11 +533,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..6e202df5 --- /dev/null +++ b/crates/ui-egui/src/paste_image_tests.rs @@ -0,0 +1,234 @@ +//! 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_engine::Session; +use serde_json::json; + +use crate::{FigApp, Services}; + +#[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![]); + } + + 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_and_other_paste_commands_paste_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)]); + w.cmd_v(CMD.plus(Modifiers::SHIFT)); + w.cmd_v(CMD.plus(Modifiers::ALT)); + 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 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:?}"); +} diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index d5bfb156..1ac2824f 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) { +/// `paste_release`: this frame released a Cmd/Ctrl+V the shell swallowed ([`swallowed_paste`]). +pub fn handle(app: &mut FigApp, ctx: &Context, typing: bool, paste_release: bool) { 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,19 @@ 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); + if (paste_key && !paste_event) || paste_release { + crate::menus::paste(app, None, 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) => 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 +133,38 @@ pub fn handle(app: &mut FigApp, ctx: &Context, typing: bool) { } } +/// Whether this frame releases a Cmd/Ctrl+V whose press never arrived and that brought no paste +/// event: egui-winit swallows the press and sends a paste event only when the clipboard holds +/// text, so an image on the clipboard leaves just this release. 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) -> bool { + let ui = &mut app.ui; + let mut paste = false; + ctx.input(|i| { + for ev in &i.raw.events { + match ev { + 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, .. } => { + // Shift or Alt with it is another command (paste over, paste properties). + paste = !ui.v_pressed && !ui.paste_event && !modifiers.shift && !modifiers.alt; + ui.v_pressed = false; + ui.paste_event = 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; + } + _ => {} + } + } + }); + paste +} + /// 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) { From 4adbd6cee98b80c877ad31de57d3bf54ef13741c Mon Sep 17 00:00:00 2001 From: RealtaNua Date: Sat, 17 Oct 2026 00:35:46 +0700 Subject: [PATCH 2/5] fix(ui): a V held while the window was away doesn't paste on release (#663) winit sends no press for a key held across a focus change (X11's synthetic presses are dropped by egui-winit), so letting go of V after coming back looked like a swallowed Cmd+V. After a focus gain, a V release only pastes with Cmd still held. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/lib.rs | 3 +++ crates/ui-egui/src/paste_image_tests.rs | 24 ++++++++++++++++++++++++ crates/ui-egui/src/shortcuts.rs | 8 ++++++-- 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bbc61149..9fbd17cc 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -500,6 +500,8 @@ pub struct UiState { /// (`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, /// 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 @@ -595,6 +597,7 @@ impl Default for UiState { last_copy: None, v_pressed: false, paste_event: false, + refocused: false, next_autosave: 0.0, auto_save_at: None, auto_saving: Vec::new(), diff --git a/crates/ui-egui/src/paste_image_tests.rs b/crates/ui-egui/src/paste_image_tests.rs index 6e202df5..fa797fb9 100644 --- a/crates/ui-egui/src/paste_image_tests.rs +++ b/crates/ui-egui/src/paste_image_tests.rs @@ -200,6 +200,30 @@ fn a_v_press_lost_to_another_window_does_not_block_the_next_cmd_v() { 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(); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 1ac2824f..f28a2433 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -146,10 +146,13 @@ pub fn swallowed_paste(app: &mut FigApp, ctx: &Context) -> bool { 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, .. } => { - // Shift or Alt with it is another command (paste over, paste properties). - paste = !ui.v_pressed && !ui.paste_event && !modifiers.shift && !modifiers.alt; + // Shift or Alt with it is another command (paste over, paste properties). A V + // held while the window was away comes back as a release alone: without Cmd + // held, that's no paste. + paste = !ui.v_pressed && !ui.paste_event && !modifiers.shift && !modifiers.alt && (modifiers.command || !ui.refocused); 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, @@ -158,6 +161,7 @@ pub fn swallowed_paste(app: &mut FigApp, ctx: &Context) -> bool { ui.v_pressed = false; ui.paste_event = false; } + Event::WindowFocused(true) => ui.refocused = true, _ => {} } } From 9f52819fe9877421876fe42e401ceed7c3c97fc6 Mon Sep 17 00:00:00 2001 From: RealtaNua Date: Sat, 17 Oct 2026 08:35:03 +0700 Subject: [PATCH 3/5] fix(ui): Shift+Cmd/Ctrl+V pastes over the selection and Alt+Cmd/Ctrl+V pastes properties (#716) egui-winit swallows every Cmd/Ctrl+V press, Shift or Alt held or not, and sends a paste event only for text. Shift+Cmd+V and Alt+Cmd+V became a plain paste (text) or did nothing (image). The keymap now decides what a swallowed chord means, from the modifiers tracked in the raw events: its paste event or its release runs paste over or paste properties instead. In the browser the key press runs the command, and the paste event no longer pastes its text as well. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/lib.rs | 7 +- crates/ui-egui/src/paste_image_tests.rs | 152 ++++++++++++++++++++++ crates/ui-egui/src/shortcuts.rs | 73 ++++++++--- crates/ui-egui/src/web_clipboard_tests.rs | 66 ++++++++++ 4 files changed, 279 insertions(+), 19 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9fbd17cc..a64ef6a8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -502,6 +502,8 @@ pub struct UiState { 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 raw event that told: 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 @@ -598,6 +600,7 @@ impl Default for UiState { v_pressed: false, paste_event: false, refocused: false, + held: egui::Modifiers::NONE, next_autosave: 0.0, auto_save_at: None, auto_saving: Vec::new(), @@ -1294,7 +1297,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 paste_release = shortcuts::swallowed_paste(self, &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. @@ -1410,7 +1413,7 @@ impl FigApp { branch_ui::keys(self, &ctx); live::dialog_keys(self, &ctx); dialogs::keys(self, &ctx); - shortcuts::handle(self, &ctx, typing, paste_release); + 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/paste_image_tests.rs b/crates/ui-egui/src/paste_image_tests.rs index fa797fb9..23cec8ea 100644 --- a/crates/ui-egui/src/paste_image_tests.rs +++ b/crates/ui-egui/src/paste_image_tests.rs @@ -6,11 +6,25 @@ 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, @@ -87,6 +101,55 @@ impl Win { 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() } @@ -256,3 +319,92 @@ fn paste_here_prefers_a_newer_image_over_copied_layers() { 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 f28a2433..57295c22 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -94,8 +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. -/// `paste_release`: this frame released a Cmd/Ctrl+V the shell swallowed ([`swallowed_paste`]). -pub fn handle(app: &mut FigApp, ctx: &Context, typing: bool, paste_release: 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() { @@ -113,16 +113,18 @@ pub fn handle(app: &mut FigApp, ctx: &Context, typing: bool, paste_release: bool // 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) || paste_release { - crate::menus::paste(app, None, 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), None), + 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) => { @@ -133,23 +135,59 @@ pub fn handle(app: &mut FigApp, ctx: &Context, typing: bool, paste_release: bool } } -/// Whether this frame releases a Cmd/Ctrl+V whose press never arrived and that brought no paste -/// event: egui-winit swallows the press and sends a paste event only when the clipboard holds -/// text, so an image on the clipboard leaves just this release. 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) -> 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 paste = false; + let mut out = Swallowed::default(); ctx.input(|i| { for ev in &i.raw.events { + if let Event::Key { modifiers, .. } = ev { + ui.held = *modifiers; + } 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, .. } => { - // Shift or Alt with it is another command (paste over, paste properties). A V - // held while the window was away comes back as a release alone: without Cmd - // held, that's no paste. - paste = !ui.v_pressed && !ui.paste_event && !modifiers.shift && !modifiers.alt && (modifiers.command || !ui.refocused); + // 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; @@ -160,13 +198,14 @@ pub fn swallowed_paste(app: &mut FigApp, ctx: &Context) -> bool { Event::WindowFocused(false) => { ui.v_pressed = false; ui.paste_event = false; + ui.held = Modifiers::NONE; } Event::WindowFocused(true) => ui.refocused = true, _ => {} } } }); - paste + out } /// Run the command a shortcut is bound to, with the keyboard's own twists (Escape steps out 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(); From 18a7a063fe29627f7d02cbf8aab6ba36fc63f45a Mon Sep 17 00:00:00 2001 From: RealtaNua Date: Sat, 17 Oct 2026 09:00:56 +0700 Subject: [PATCH 4/5] fix(ui): paste chords read modifiers from modifier changes only (#716) Every shell reports a modifier change before the key it goes with, so key events needn't update the held modifiers too. The old test that pressed Shift+Cmd+V and Alt+Cmd+V expecting "nothing" now covers plain V only: those chords run their commands, covered by their own tests. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/lib.rs | 3 ++- crates/ui-egui/src/paste_image_tests.rs | 4 +--- crates/ui-egui/src/shortcuts.rs | 3 --- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a64ef6a8..e32e8e6e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -502,7 +502,8 @@ pub struct UiState { 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 raw event that told: a paste event doesn't carry them. + /// 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, diff --git a/crates/ui-egui/src/paste_image_tests.rs b/crates/ui-egui/src/paste_image_tests.rs index 23cec8ea..48bd142e 100644 --- a/crates/ui-egui/src/paste_image_tests.rs +++ b/crates/ui-egui/src/paste_image_tests.rs @@ -231,13 +231,11 @@ fn the_browser_sequence_pastes_once() { } #[test] -fn v_alone_and_other_paste_commands_paste_nothing() { +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)]); - w.cmd_v(CMD.plus(Modifiers::SHIFT)); - w.cmd_v(CMD.plus(Modifiers::ALT)); assert_eq!(w.named("Pasted image"), 0); } diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 57295c22..0a291a7c 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -163,9 +163,6 @@ pub fn swallowed_paste(app: &mut FigApp, ctx: &Context) -> Swallowed { let mut out = Swallowed::default(); ctx.input(|i| { for ev in &i.raw.events { - if let Event::Key { modifiers, .. } = ev { - ui.held = *modifiers; - } match ev { Event::ModifiersChanged(m) => ui.held = *m, // Without Cmd/Ctrl held it's another paste key (Shift+Insert, a Paste key). From 25e9be3936f15dd67ae034e4debbbd3832817416 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 14:49:48 +0700 Subject: [PATCH 5/5] #693: clipboard reads on Linux and the BSDs give up after 2 s A Wayland clipboard read waits on the app that copied with no timeout of its own. The read now runs on a helper thread with its own clipboard handle, so a stuck app can't hang the window. --- apps/figcraft/src/services.rs | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) 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 {