diff --git a/crates/ui-egui/src/menus/clipboard.rs b/crates/ui-egui/src/menus/clipboard.rs index bae7aaca9..bd7020150 100644 --- a/crates/ui-egui/src/menus/clipboard.rs +++ b/crates/ui-egui/src/menus/clipboard.rs @@ -7,8 +7,8 @@ use crate::i18n::trf; /// Paste: the system clipboard wins when it holds something newer than our own copy (layers copied /// from another design app, 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). `at`: the page point to paste at (Paste here); `None` pastes in the -/// middle of the view. +/// other way to read it; the desktop's clipboard bridge has already read it). `at`: the page +/// point to paste at (Paste here); `None` pastes in the middle of the view. /// In Dev Mode only a comment's link pastes (it opens the thread); everything else would edit. pub fn paste(app: &mut FigApp, event_text: Option<&str>, at: Option<(f64, f64)>) { // A gesture injected through the control channel pastes only our own copied layers: it must not @@ -47,7 +47,15 @@ pub fn paste(app: &mut FigApp, event_text: Option<&str>, at: Option<(f64, f64)>) } return; } - let sys_text = app.services.clipboard_get_text().or_else(|| event_text.map(str::to_string)).filter(|t| !t.is_empty()); + // The text the platform handed over with the paste event is the system clipboard's text: the + // browser delivers it, and the desktop's async clipboard bridge read it while the window + // thread was free to serve the transfer. Never re-read the clipboard synchronously for it — + // on the desktop that read blocks the window thread for the read timeout, and when this app + // itself owns the clipboard (a self-paste) its own source can only be served by that very + // thread, so the read waits the full timeout and the window freezes for seconds (#2021). + // A paste the platform didn't hand text over (a chord, an image, the menu) reads it, as before. + let sys_text = + if let Some(t) = event_text { (!t.is_empty()).then(|| t.to_string()) } else { app.services.clipboard_get_text().filter(|t| !t.is_empty()) }; // The text matching `last_copy` means the system clipboard still holds what we mirrored only // while the engine clipboard is the one we mirrored. A control-channel copy changes the engine // clipboard without writing the system clipboard, so its generation no longer matches and the diff --git a/crates/ui-egui/src/web_clipboard_tests.rs b/crates/ui-egui/src/web_clipboard_tests.rs index bda57373b..30d7e2b85 100644 --- a/crates/ui-egui/src/web_clipboard_tests.rs +++ b/crates/ui-egui/src/web_clipboard_tests.rs @@ -3,6 +3,9 @@ //! whatever the system clipboard has. A copy that never reached the clipboard pasted the text that //! was there before as a text layer instead of the copied layers. +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + use egui::{Event, Key, Modifiers, OutputCommand, Pos2, RawInput, Rect, vec2}; use figcraft_doc::NodeKind; use figcraft_engine::Session; @@ -223,3 +226,49 @@ fn a_mismatched_image_buffer_is_dropped() { assert!(out.platform_output.commands.is_empty(), "{w}x{h}"); } } + +/// The system clipboard's text read, counted so a test can assert when it ran. The desktop's +/// arboard read blocks the window thread up to `CLIPBOARD_TIMEOUT`; against the app's own Wayland +/// source (a self-paste) it waits the full timeout — the whole UI freezes for seconds (#2021) — +/// and its result is discarded in favour of the paste event's text. A paste whose platform event +/// already carries the clipboard text must never perform that synchronous re-read. +struct CountingReads { + /// How often the synchronous text read was asked for. + reads: Arc, +} + +impl Services for CountingReads { + fn clipboard_get_text(&mut self) -> Option { + self.reads.fetch_add(1, Ordering::Relaxed); + None + } +} + +#[test] +fn a_paste_event_its_own_text_never_triggers_the_synchronous_text_read() { + // The platform (the desktop's async clipboard bridge, or the browser) already read the + // clipboard and handed the text with the paste event. + let reads = Arc::new(AtomicUsize::new(0)); + let mut s = Session::new(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap(); + let mut p = Page { + ctx: egui::Context::default(), + app: FigApp::new(s, Box::new(CountingReads { reads: reads.clone() })), + time: 0.0, + clipboard: String::new(), + }; + for _ in 0..3 { + p.frame(vec![]); + } + + // The desktop delivers the copied layers' SVG as the paste event's text. + let text = ""; + p.frame(vec![Page::key(Key::V, true), Event::Paste(text.to_string())]); + p.frame(vec![Page::key(Key::V, false)]); + assert_eq!(reads.load(Ordering::Relaxed), 0, "the text came with the paste event: no blocking re-read of the system clipboard"); + + // Even a second paste must not re-read: the clipboard still holds the pasted text. + p.frame(vec![Page::key(Key::V, true), Event::Paste(text.to_string())]); + p.frame(vec![Page::key(Key::V, false)]); + assert_eq!(reads.load(Ordering::Relaxed), 0, "still the event's text: no blocking re-read"); +}