diff --git a/apps/vectorcraft/src/clipboard.rs b/apps/vectorcraft/src/clipboard.rs index 949474958..7a45f42e8 100644 --- a/apps/vectorcraft/src/clipboard.rs +++ b/apps/vectorcraft/src/clipboard.rs @@ -757,37 +757,155 @@ mod portable { /// window's own Wayland connection: what Copy and Paste use where the compositor lacks the /// data-control protocol arboard needs (GNOME) and there is no X11 to fall back on (the Flatpak on /// a Wayland session). Text only (smithay-clipboard), and only while the window has focus, as the -/// protocol has it. +/// protocol has it. A read waits [`LOAD_WAIT`] at most: smithay-clipboard waits for the owner's +/// data with no limit, so reads run on a worker of their own. #[cfg(target_os = "linux")] mod wayland { use std::sync::Arc; + use std::sync::mpsc; + use std::time::Duration; + + /// The longest a read waits for the selection's owner to send its text. + const LOAD_WAIT: Duration = Duration::from_secs(2); + /// What a read says once its worker has stopped. + const GONE: &str = "the clipboard's reader stopped"; pub struct TextClipboard { + /// Writes: a store only hands the text to smithay-clipboard's thread. + cb: smithay_clipboard::Clipboard, + /// Reads, on a worker with a second clipboard on the same connection. + loads: Loads, + /// Held as long as the clipboards: their connection is the window's. + _window: Arc, + } + + /// The read worker's clipboard and the window whose connection it uses, dropped in that order. + struct Loader { cb: smithay_clipboard::Clipboard, - /// Held as long as the clipboard: its connection is the window's. _window: Arc, } impl TextClipboard { - /// None when `window` isn't a Wayland window. + /// None when `window` isn't a Wayland window or the read worker can't start. #[allow(unsafe_code)] pub fn new(window: Arc) -> Option { use winit::raw_window_handle::{HasDisplayHandle as _, RawDisplayHandle}; let RawDisplayHandle::Wayland(d) = window.display_handle().ok()?.as_raw() else { return None }; - // SAFETY: the display is winit's open `wl_display`, which outlives `window` (held beside - // the clipboard); smithay-clipboard makes its own event queue on it and doesn't close it. - let cb = unsafe { smithay_clipboard::Clipboard::new(d.display.as_ptr()) }; - Some(Self { cb, _window: window }) + let display = d.display.as_ptr(); + // SAFETY: the display is winit's open `wl_display`, which outlives `window`; each + // clipboard is held beside a clone of `window` and dropped before it. smithay-clipboard + // makes its own event queue on the display and doesn't close it. + let (cb, reader) = unsafe { (smithay_clipboard::Clipboard::new(display), smithay_clipboard::Clipboard::new(display)) }; + let loader = Loader { cb: reader, _window: window.clone() }; + let loads = Loads::spawn(move || loader.cb.load().map_err(|e| e.to_string()))?; + Some(Self { cb, loads, _window: window }) } - pub fn get_text(&self) -> Result { - self.cb.load().map_err(|e| e.to_string()) + pub fn get_text(&mut self) -> Result { + self.loads.get(LOAD_WAIT) } pub fn set_text(&self, text: &str) { self.cb.store(text); } } + + /// Reads made on a worker thread and waited for a bounded time: a selection owner that never + /// sends its data holds the worker, not the caller. + struct Loads { + requests: mpsc::Sender<()>, + answers: mpsc::Receiver>, + /// A read that timed out and hasn't answered yet: until it does, reads don't queue behind it. + pending: bool, + } + + impl Loads { + fn spawn(mut load: impl FnMut() -> Result + Send + 'static) -> Option { + let (requests, asked) = mpsc::channel::<()>(); + let (answer, answers) = mpsc::channel(); + let work = move || { + while asked.recv().is_ok() { + if answer.send(load()).is_err() { + break; + } + } + }; + std::thread::Builder::new().name("wayland-clipboard-read".into()).spawn(work).ok()?; + Some(Self { requests, answers, pending: false }) + } + + /// The text, or why there is none, after `wait` at most. + fn get(&mut self, wait: Duration) -> Result { + if self.pending { + match self.answers.try_recv() { + // The late answer of the read that timed out: stale by now. + Ok(_) => self.pending = false, + Err(mpsc::TryRecvError::Empty) => return Err("the clipboard's owner hasn't answered an earlier read".into()), + Err(mpsc::TryRecvError::Disconnected) => return Err(GONE.into()), + } + } + self.requests.send(()).map_err(|_| GONE.to_string())?; + match self.answers.recv_timeout(wait) { + Ok(text) => text, + Err(mpsc::RecvTimeoutError::Timeout) => { + self.pending = true; + Err(format!("the clipboard's owner didn't send its text within {wait:?}")) + } + Err(mpsc::RecvTimeoutError::Disconnected) => Err(GONE.into()), + } + } + } + + #[cfg(test)] + mod tests { + use std::time::Instant; + + use super::*; + + #[test] + fn a_read_that_answers_in_time_gives_the_text() { + let mut loads = Loads::spawn(|| Ok("".into())).unwrap(); + assert_eq!(loads.get(Duration::from_secs(5)), Ok("".to_string())); + assert_eq!(loads.get(Duration::from_secs(5)), Ok("".to_string())); + } + + #[test] + fn an_owner_that_never_answers_holds_the_worker_not_the_caller() { + let (release, gate) = mpsc::channel::<()>(); + let mut calls = 0; + let mut loads = Loads::spawn(move || { + calls += 1; + if calls == 1 { + // The first owner sends nothing until released. + let _ = gate.recv(); + return Ok("stale".into()); + } + Ok("fresh".into()) + }) + .unwrap(); + let start = Instant::now(); + assert!(loads.get(Duration::from_millis(50)).is_err(), "the first read times out"); + // While it is unanswered, a read doesn't queue behind it or wait at all. + assert!(loads.get(Duration::from_secs(60)).is_err()); + assert!(start.elapsed() < Duration::from_secs(30), "no read waited past its bound"); + release.send(()).unwrap(); + // Once the late answer is in, it is dropped and a new read is made. + let text = (0..200).find_map(|_| { + std::thread::sleep(Duration::from_millis(10)); + loads.get(Duration::from_secs(5)).ok() + }); + assert_eq!(text.as_deref(), Some("fresh")); + } + + #[test] + fn a_read_after_the_worker_stops_says_so() { + let mut loads = Loads::spawn(|| Ok(String::new())).unwrap(); + // The worker ends when its answers can't be sent: drop them by hand. + let (_, dead) = mpsc::channel(); + drop(std::mem::replace(&mut loads.answers, dead)); + assert_eq!(loads.get(Duration::from_millis(50)), Err(GONE.to_string())); + } + } } /// The macOS pasteboard's vector art and bitmaps as they are (arboard reads text, and of the diff --git a/docs/development.md b/docs/development.md index 28ca41831..261dde457 100644 --- a/docs/development.md +++ b/docs/development.md @@ -115,7 +115,7 @@ On GNOME/Wayland, enable **Preferences › User Interface › System Title Bar** The window runs natively on Wayland (eframe's `wayland` feature) and on X11. On machines with two GPUs it renders on the one the desktop runs on unless Preferences say otherwise; see [Desktop graphics processor](#desktop-graphics-processor) (#502). The system clipboard (`apps/vectorcraft/src/clipboard.rs`) is arboard with its `wayland-data-control` feature: -- **Clipboard:** under Wayland arboard talks to the compositor through the data-control protocol (wlroots compositors such as Sway and Hyprland, KDE Plasma), so bitmaps (`image/png`), SVG markup and text copied in any app paste (#398). Where the compositor lacks the protocol (GNOME's Mutter, [arboard#223](https://github.com/1Password/arboard/issues/223)), arboard falls back to the X11 clipboard through XWayland, which only holds what X11 apps copied. Where there is no X11 either (the Flatpak on a Wayland session: `--socket=fallback-x11` gives it none), Copy and Paste use the compositor's core data-device protocol on the window's own connection through smithay-clipboard: text only (a copy's text or its SVG markup), and only while the window has focus. A clipboard neither backend opens is tried again after 1 s, doubling to a minute (a Copy or Paste of the user's at most once a second), and logged once when it goes and once when it comes back: before, the background Paste check reconnected and logged a warning every 250 ms. egui's own text paste into fields goes through smithay-clipboard and works either way. +- **Clipboard:** under Wayland arboard talks to the compositor through the data-control protocol (wlroots compositors such as Sway and Hyprland, KDE Plasma), so bitmaps (`image/png`), SVG markup and text copied in any app paste (#398). Where the compositor lacks the protocol (GNOME's Mutter, [arboard#223](https://github.com/1Password/arboard/issues/223)), arboard falls back to the X11 clipboard through XWayland, which only holds what X11 apps copied. Where there is no X11 either (the Flatpak on a Wayland session: `--socket=fallback-x11` gives it none), Copy and Paste use the compositor's core data-device protocol on the window's own connection through smithay-clipboard: text only (a copy's text or its SVG markup), and only while the window has focus. smithay-clipboard waits for the owner's data with no limit, so its reads run on a worker thread and a Paste waits 2 s at most: an owner that never sends its data costs one Paste 2 s, and later reads return at once until it answers. A clipboard neither backend opens is tried again after 1 s, doubling to a minute (a Copy or Paste of the user's at most once a second), and logged once when it goes and once when it comes back: before, the background Paste check reconnected and logged a warning every 250 ms. egui's own text paste into fields goes through smithay-clipboard and works either way. - **Clipboard owners that never answer (#510):** reading the clipboard waits on the app that owns it: arboard gives up on an X11 owner after 4 s, and a Wayland read waits for the owner to send its data. So on Linux the check that enables the Paste menu items runs on a background thread with a second clipboard handle (`crates/ui-egui/src/clipboard_probe.rs`, installed as `Services::clipboard_probe`), only while nothing is copied in the app and a document is open, and the window keeps drawing. A paste itself still reads on the UI thread, so pasting from such an owner waits for it once. Windows and macOS keep the check on the UI thread: Windows only asks which formats the clipboard holds, macOS asks the pasteboard server. - **Copied files:** files copied in a file manager (`text/uri-list`; `CF_HDROP` on Windows, file URLs on macOS) paste as the first one that is art (SVG, PDF, EPS, EMF/WMF or a bitmap; a `.ai` or `.ait` file from the editing data it holds, else as the PDF or PostScript it holds), before the clipboard's other formats (which include the files' paths as text). - **Dropping files on the window does not work under Wayland:** winit 0.30, which eframe 0.37 runs on, has no Wayland drag and drop ([winit#1881](https://github.com/rust-windowing/winit/issues/1881), added in winit 0.31; [egui#1563](https://github.com/emilk/egui/issues/1563)), so no `dropped_files` arrive. Until eframe moves to winit 0.31: copy the files in the file manager and paste them, use File › Place or Open, or open the window through XWayland, where drops work: set **Preferences › Performance › Linux Display Server** to **X11 (XWayland)** and restart (#1151), or start the app with `WAYLAND_DISPLAY= vectorcraft`. The preference (`linuxDisplayServer`, read at startup in `apps/vectorcraft/src/main.rs`, shown on Linux only) has winit open the window on X11 (`EventLoopBuilderExtX11::with_x11`) when the session is Wayland, `DISPLAY` is set and the X11 libraries winit loads are installed (`apps/vectorcraft/src/xwayland.rs` reads `ldconfig -p` and the usual library directories); otherwise the window opens on Wayland and the log says why. Unlike clearing `WAYLAND_DISPLAY`, it leaves the clipboard on the compositor's own. @@ -494,7 +494,7 @@ Scores and hour estimates are unchanged. | Date | Change | Summary | |---|---|---| -| 2026-10-29 | minor | Linux clipboard: core Wayland protocol text fallback where arboard has neither data-control nor X11 (the Flatpak on GNOME). | +| 2026-10-29 | minor | Linux clipboard: core Wayland protocol text fallback where arboard has neither data-control nor X11 (the Flatpak on GNOME).; its reads wait 2 s at most. | | 2026-10-29 | minor | Linux clipboard: a clipboard arboard can't open is retried after 1 s doubling to 60 s and logged once, instead of every 250 ms. | | 2026-10-28 | minor | Localisation: Catalan (`ca`) catalog and registry entry (every `ca-*` locale); complete under the catalog tests. | | 2026-10-25 | minor | Linux Wayland drag-and-drop note names eframe 0.37 (still on winit 0.30); behavior unchanged. |