fix(ui): paste uses the event's clipboard text instead of re-reading it (#2021) (#2243)

The paste handler read the system clipboard synchronously (2 s timeout) even
though the paste event already carries that text, and discarded the read's
result. On the desktop, the clipboard bridge delivers `event_text` that it read
while the window thread was free; re-reading from the paste handler blocks that
same window thread - and when the app itself owns the clipboard (a self-paste),
its own data source can only be served by that very thread, so every self-paste
froze the window for the full 2 s read timeout on Linux/Wayland.

Prefer the event's text and only fall back to the synchronous read when the
platform handed over none (chords, image-only clipboard, context-menu paste).
A regression test pins that a paste event carrying text never triggers the
synchronous read.
This commit is contained in:
rumangerst authored and GitHub committed 2026-11-11 15:37:20 +01:00
1 parent 2dd1b5a53a
commit 7bf098d75d
2 files changed
+60 -3

No files matched your search

+11 -3
View File
@@ -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
+49
View File
@@ -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<AtomicUsize>,
}
impl Services for CountingReads {
fn clipboard_get_text(&mut self) -> Option<String> {
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 = "<svg width=\"100\" height=\"50\"><rect/></svg>";
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");
}