From 01c718333a8b2a5e8afe547db7ace2aef0215179 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Fri, 2 Oct 2026 00:14:02 -0700 Subject: [PATCH] chore: Linux serves the Wayland clipboard from the event loop The clipboard now lives on winit's own Wayland connection, on a queue the event loop dispatches, instead of smithay-clipboard's thread: other apps' paste requests wake the event loop, which answers them, now that no dialog holds it up. Files, pages and pictures are read from the Wayland selection itself rather than through a new X11 connection to Xwayland on every paste, so they paste on compositors without Xwayland or that share the selection with it only while an X11 window has focus. Copy now offers HTML and Snowbound's own format on Wayland too, beside the text. X11 sessions keep arboard. The clipboard borrows winit's connection through a frame callback moved onto winit's event queue. libwayland names a proxy's queue only from 1.23 (wl_proxy_get_queue), so it is read from struct wl_proxy, laid out alike since 1.0. smithay-clipboard goes, and with it the second smithay-client-toolkit and calloop, calloop-wayland-source, wayland-protocols-experimental and wayland-protocols-misc. Assisted-by: claude-opus-5.5 --- Cargo.lock | 104 +---- arc/platforms.md | 5 +- crates/snowbound/Cargo.toml | 2 +- crates/snowbound/src/clipboard_linux.rs | 567 ++++++++++++++++++++++++ crates/snowbound/src/linux.rs | 101 +---- crates/snowbound/src/main.rs | 14 +- 6 files changed, 593 insertions(+), 200 deletions(-) create mode 100644 crates/snowbound/src/clipboard_linux.rs diff --git a/Cargo.lock b/Cargo.lock index 542707cc6ad71ccef512cef18e90500febe714bb..3118429d17ab66dc62060c56ada258e70c503d68 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -601,43 +601,18 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "calloop" -version = "0.14.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" -dependencies = [ - "bitflags 2.13.1", - "polling", - "rustix 1.1.4", - "slab", - "tracing", -] - [[package]] name = "calloop-wayland-source" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" dependencies = [ - "calloop 0.13.0", + "calloop", "rustix 0.38.44", "wayland-backend", "wayland-client", ] -[[package]] -name = "calloop-wayland-source" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" -dependencies = [ - "calloop 0.14.4", - "rustix 1.1.4", - "wayland-backend", - "wayland-client", -] - [[package]] name = "canvas" version = "0.1.0" @@ -3377,7 +3352,7 @@ dependencies = [ "ab_glyph", "log", "memmap2", - "smithay-client-toolkit 0.19.2", + "smithay-client-toolkit", "tiny-skia", ] @@ -3604,8 +3579,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" dependencies = [ "bitflags 2.13.1", - "calloop 0.13.0", - "calloop-wayland-source 0.3.0", + "calloop", + "calloop-wayland-source", "cursor-icon", "libc", "log", @@ -3622,44 +3597,6 @@ dependencies = [ "xkeysym", ] -[[package]] -name = "smithay-client-toolkit" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" -dependencies = [ - "bitflags 2.13.1", - "calloop 0.14.4", - "calloop-wayland-source 0.4.1", - "cursor-icon", - "libc", - "log", - "memmap2", - "rustix 1.1.4", - "thiserror 2.0.20", - "wayland-backend", - "wayland-client", - "wayland-csd-frame", - "wayland-cursor", - "wayland-protocols", - "wayland-protocols-experimental", - "wayland-protocols-misc", - "wayland-protocols-wlr", - "wayland-scanner", - "xkeysym", -] - -[[package]] -name = "smithay-clipboard" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" -dependencies = [ - "libc", - "smithay-client-toolkit 0.20.0", - "wayland-backend", -] - [[package]] name = "smol_str" version = "0.2.2" @@ -3700,7 +3637,6 @@ dependencies = [ "sctk-adwaita", "serde", "serde_json", - "smithay-clipboard", "spellbook", "ui", "unicode_names2", @@ -3710,6 +3646,7 @@ dependencies = [ "wayland-backend", "wayland-client", "wayland-protocols", + "wayland-sys", "web-sys", "web-time", "webpki-root-certs", @@ -4067,7 +4004,6 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ - "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -4420,32 +4356,6 @@ dependencies = [ "wayland-scanner", ] -[[package]] -name = "wayland-protocols-experimental" -version = "20250721.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" -dependencies = [ - "bitflags 2.13.1", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-scanner", -] - -[[package]] -name = "wayland-protocols-misc" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" -dependencies = [ - "bitflags 2.13.1", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-scanner", -] - [[package]] name = "wayland-protocols-plasma" version = "0.3.12" @@ -5040,7 +4950,7 @@ dependencies = [ "bitflags 2.13.1", "block2 0.5.1", "bytemuck", - "calloop 0.13.0", + "calloop", "cfg_aliases", "concurrent-queue", "core-foundation 0.9.4", @@ -5062,7 +4972,7 @@ dependencies = [ "redox_syscall 0.4.1", "rustix 0.38.44", "sctk-adwaita", - "smithay-client-toolkit 0.19.2", + "smithay-client-toolkit", "smol_str", "tracing", "unicode-segmentation", diff --git a/arc/platforms.md b/arc/platforms.md index 1565a69d970bbea08b7943c7beef654a0740d0e4..0afb491628c40898af1d56e5cb61ced2b7702c9c 100644 --- a/arc/platforms.md +++ b/arc/platforms.md @@ -104,7 +104,10 @@ keyboard, the toolbar and the macOS menu bar all run commands from it. and so is Enchant, which checks spelling with whatever dictionaries its providers have; without it, words go unmarked. - Wayland's clipboard goes through the window's own connection, since not every - compositor offers a clipboard to clients without a window. + compositor offers a clipboard to clients without a window. Its queue wakes the + event loop, which answers other apps' pastes with no thread of its own. Text, + pages, pictures and files are read there too, not through Xwayland. X11 + sessions use arboard. - The executable carries its desktop entry and icon (`desktop_linux.rs`). The window is `net.paperclover.snowbound` to Wayland and X11 alike. Where a Wayland compositor lacks xdg-toplevel-icon, as GNOME's does, it finds the diff --git a/crates/snowbound/Cargo.toml b/crates/snowbound/Cargo.toml index 3218e1b35d104a0d7c912b0ecafb5ccde8014078..034475a5319f09541541661141ce47d3d4788cc5 100644 --- a/crates/snowbound/Cargo.toml +++ b/crates/snowbound/Cargo.toml @@ -103,7 +103,6 @@ windows-sys = { version = "0.61", features = [ [target.'cfg(target_os = "linux")'.dependencies] accesskit_winit = { version = "0.34.0", default-features = false, features = ["accesskit_unix", "async-io"] } -smithay-clipboard = "0.7.3" # Scales the window's icon. image = { version = "0.25", default-features = false } # Loads fontconfig at run time, so builds need no fontconfig headers or library. @@ -118,6 +117,7 @@ sctk-adwaita = { version = "0.10.1", default-features = false } wayland-backend = { version = "0.3.17", features = ["client_system"] } wayland-client = "0.31.15" wayland-protocols = { version = "0.32.13", features = ["client", "staging"] } +wayland-sys = { version = "0.31.11", features = ["client"] } # Brings the window forward on X11, on a connection of its own. x11rb = "0.13.2" diff --git a/crates/snowbound/src/clipboard_linux.rs b/crates/snowbound/src/clipboard_linux.rs new file mode 100644 index 0000000000000000000000000000000000000000..59436b82f20801d269ff56a40b4cbcd4a929cb67 --- /dev/null +++ b/crates/snowbound/src/clipboard_linux.rs @@ -0,0 +1,567 @@ +//! The clipboard on Linux. On Wayland it lives on winit's own connection, so other apps' +//! paste requests are answered on the event loop as they arrive, with no thread of its own; +//! text, pages, pictures and files are all read from the selection there. On X11, arboard's. + +use std::{ + error::Error, + io::Read, + os::fd::{AsFd, AsRawFd, BorrowedFd, OwnedFd}, + path::PathBuf, + sync::{Arc, Mutex}, + task::{Context, Wake, Waker}, + time::{Duration, Instant}, +}; +use wayland_client::{ + Connection, Dispatch, EventQueue, Proxy, QueueHandle, WEnum, + backend::{Backend, ObjectData, ObjectId, protocol::Message}, + protocol::{ + wl_data_device::{self, WlDataDevice}, + wl_data_device_manager::WlDataDeviceManager, + wl_data_offer::{self, WlDataOffer}, + wl_data_source::{self, WlDataSource}, + wl_keyboard::{self, WlKeyboard}, + wl_pointer::{self, WlPointer}, + wl_registry::{self, WlRegistry}, + wl_seat::{self, WlSeat}, + wl_surface::{self, WlSurface}, + }, +}; +use wayland_sys::{ + client::{wayland_client_handle, wl_event_queue, wl_proxy}, + ffi_dispatch, +}; +use winit::{ + raw_window_handle::{HasDisplayHandle, HasWindowHandle, RawDisplayHandle, RawWindowHandle}, + window::Window, +}; + +/// The MIME types text is offered and read as, the most precise first. +const TEXT: [&str; 4] = [ + "text/plain;charset=utf-8", + "UTF8_STRING", + "text/plain", + "STRING", +]; +/// Snowbound's own format, [`crate::paste::Copied::clip`]. +const CLIP: &str = "application/x-snowbound-clip"; +/// How long a paste waits on the app that copied. +const PATIENCE: Duration = Duration::from_secs(2); + +/// The HTML and Snowbound's own format of this process's last copy through X11. +static LAST_COPY: Mutex> = Mutex::new(None); + +pub enum Clipboard { + Wayland(Box), + X11(arboard::Clipboard), +} + +impl Clipboard { + pub fn new(window: &Window) -> Result { + let handles = window + .display_handle() + .ok() + .zip(window.window_handle().ok()); + match handles.map(|(display, window)| (display.as_raw(), window.as_raw())) { + Some((RawDisplayHandle::Wayland(display), RawWindowHandle::Wayland(window))) => Ok( + Self::Wayland(Box::new(Wayland::new(display.display, window.surface))), + ), + _ => arboard::Clipboard::new().map(Self::X11), + } + } + + pub fn set_text(&mut self, text: String) -> Result<(), Box> { + match self { + Self::Wayland(wayland) => { + wayland.copy(TEXT.map(|kind| (kind, text.clone().into_bytes())).to_vec()) + } + Self::X11(clipboard) => Ok(clipboard.set_text(text)?), + } + } + + /// Text, HTML and Snowbound's own format, as one copy: see [`crate::paste::Copied`]. + pub fn set(&mut self, copied: &crate::paste::Copied) -> Result<(), Box> { + match self { + Self::Wayland(wayland) => { + let text = TEXT.map(|kind| (kind, copied.text.clone().into_bytes())); + let rich = [("text/html", &copied.html), (CLIP, &copied.clip)] + .map(|(kind, bytes)| (kind, bytes.clone().into_bytes())); + wayland.copy(text.into_iter().chain(rich).collect()) + } + Self::X11(clipboard) => { + clipboard.set().html(&copied.html, Some(&copied.text))?; + *LAST_COPY.lock().unwrap() = Some((copied.html.clone(), copied.clip.clone())); + Ok(()) + } + } + } + + /// What Snowbound itself copied, if it did: on X11, which arboard offers no format of + /// Snowbound's own through, the last copy's, while the clipboard holds its HTML. + pub fn get_clip(&mut self) -> Option { + if let Self::Wayland(wayland) = self { + return String::from_utf8(wayland.paste(CLIP)?).ok(); + } + let html = self.get_html()?; + match &*LAST_COPY.lock().unwrap() { + Some((copied, clip)) if *copied == html => Some(clip.clone()), + _ => None, + } + } + + pub fn get_text(&mut self) -> Result> { + match self { + Self::Wayland(wayland) => { + let bytes = TEXT.iter().find_map(|kind| wayland.paste(kind)); + Ok(String::from_utf8_lossy(&bytes.unwrap_or_default()).into_owned()) + } + Self::X11(clipboard) => Ok(clipboard.get_text()?), + } + } + + pub fn get_files(&mut self) -> Vec { + use std::os::unix::ffi::OsStrExt; + match self { + Self::Wayland(wayland) => files(&wayland.paste("text/uri-list").unwrap_or_default()), + // arboard splits text/uri-list at LF and keeps the CR its CRLF lines end in. + Self::X11(clipboard) => (clipboard.get().file_list().unwrap_or_default().into_iter()) + .map(|path| { + let bytes = path.as_os_str().as_bytes(); + let bytes = bytes.strip_suffix(b"\r").unwrap_or(bytes); + std::ffi::OsStr::from_bytes(bytes).into() + }) + .collect(), + } + } + + pub fn get_html(&mut self) -> Option { + match self { + Self::Wayland(wayland) => wayland + .paste("text/html") + .map(|bytes| String::from_utf8_lossy(&bytes).into_owned()), + Self::X11(clipboard) => clipboard.get().html().ok(), + } + } + + pub fn get_picture(&mut self) -> Option> { + match self { + Self::Wayland(wayland) => ["image/png", "image/jpeg", "image/gif"] + .iter() + .find_map(|kind| wayland.paste(kind)), + Self::X11(clipboard) => crate::paste::bitmap(clipboard.get_image().ok()?), + } + } + + /// Answers what the compositor asked of the clipboard since it last woke the event loop. + pub fn serve(&mut self) { + if let Self::Wayland(wayland) = self { + wayland.serve(); + } + } +} + +/// The local files a `text/uri-list` names; its lines end in CRLF, though some apps end +/// them in LF alone. +fn files(list: &[u8]) -> Vec { + use std::os::unix::ffi::OsStringExt; + String::from_utf8_lossy(list) + .lines() + .map(|line| line.trim_end_matches('\r')) + .filter(|line| !line.starts_with('#')) + .filter_map(|uri| { + let path = uri.strip_prefix("file://")?; + let path = path.strip_prefix("localhost").unwrap_or(path); + path.starts_with('/') + .then(|| std::ffi::OsString::from_vec(crate::paste::percent_decode(path)).into()) + }) + .collect() +} + +/// A copy's bytes in each MIME type it is offered as. +type Formats = Vec<(&'static str, Vec)>; + +/// The Wayland clipboard, on winit's connection once the window's first frame lends it. +pub struct Wayland { + lent: Arc>>, + joined: Option<(Connection, EventQueue, Seat)>, +} + +/// What the clipboard knows of the seat: the selection and the serial to claim it with. +#[derive(Default)] +struct Seat { + seat: Option, + manager: Option, + device: Option, + keyboard: Option, + pointer: Option, + /// The latest input event's, which claiming the selection must name. + serial: u32, + /// The selection another app offers. + offer: Option, + /// What this app copied, in each format it offers, while it holds the selection. + copied: Option<(WlDataSource, Formats)>, +} + +/// Wakes the event loop to serve the clipboard. +struct Serve; + +impl Wake for Serve { + fn wake(self: Arc) { + if let Some(proxy) = crate::platform::PROXY.get() { + let _ = proxy.send_event(crate::UserEvent::Clipboard); + } + } +} + +/// Takes winit's connection from the event that reaches it, a frame callback moved onto +/// winit's queue: winit dispatches only that queue, with its own connection. +struct Lend(Arc>>); + +impl ObjectData for Lend { + fn event( + self: Arc, + backend: &Backend, + _: Message, + ) -> Option> { + *self.0.lock().unwrap() = Some(backend.clone()); + Arc::new(Serve).wake(); + None + } + + fn destroyed(&self, _: ObjectId) {} +} + +impl Wayland { + fn new( + display: std::ptr::NonNull, + surface: std::ptr::NonNull, + ) -> Self { + let lent = Arc::new(Mutex::new(None)); + // Safety: winit's display and surface outlive the window, which outlives this. + let backend = unsafe { Backend::from_foreign_display(display.as_ptr().cast()) }; + let connection = Connection::from_backend(backend); + let surface = + unsafe { ObjectId::from_ptr(WlSurface::interface(), surface.as_ptr().cast()) } + .and_then(|id| WlSurface::from_id(&connection, id)); + if let Ok(surface) = surface { + let lend: Arc = Arc::new(Lend(Arc::clone(&lent))); + let frame = wl_surface::Request::Frame {}; + if let Ok(callback) = connection.send_request(&surface, frame, Some(lend)) { + // Safety: both proxies are live, and the callback has had no event yet. + unsafe { onto_queue_of(callback.as_ptr(), surface.id().as_ptr()) }; + } + let _ = connection.flush(); + } + // The callback, on winit's queue, ends under winit's connection, which leaves this one + // a record of it that dropping it would free a second time. + std::mem::forget(connection); + Self { lent, joined: None } + } + + /// Dispatches the seat's events and asks to be woken for the next; first joins winit's + /// connection once lent. + fn serve(&mut self) { + if self.joined.is_none() + && let Some(backend) = self.lent.lock().unwrap().take() + { + let connection = Connection::from_backend(backend); + let queue = connection.new_event_queue(); + connection.display().get_registry(&queue.handle(), ()); + self.joined = Some((connection, queue, Seat::default())); + } + if let Some((_, queue, seat)) = &mut self.joined { + let waker = Waker::from(Arc::new(Serve)); + if let std::task::Poll::Ready(Err(error)) = + queue.poll_dispatch_pending(&mut Context::from_waker(&waker), seat) + { + eprintln!("The clipboard stopped: {error}"); + self.joined = None; + } + } + } + + fn copy(&mut self, formats: Formats) -> Result<(), Box> { + self.serve(); + let Some((connection, queue, seat)) = &mut self.joined else { + return Err("The clipboard isn't ready yet.".into()); + }; + let (Some(manager), Some(device)) = (&seat.manager, &seat.device) else { + return Err("The compositor offers no clipboard.".into()); + }; + let source = manager.create_data_source(&queue.handle(), ()); + for (kind, _) in &formats { + source.offer(kind.to_string()); + } + device.set_selection(Some(&source), seat.serial); + // The source replaced is cancelled, and destroyed then. + seat.copied = Some((source, formats)); + connection.flush()?; + Ok(()) + } + + /// The selection as `kind`, where it is offered as that: this app's own copy without a + /// round trip through the compositor, or what another app answers. + fn paste(&mut self, kind: &str) -> Option> { + self.serve(); + let (connection, _, seat) = self.joined.as_ref()?; + if let Some((_, formats)) = &seat.copied { + return offered(formats, kind).map(<[u8]>::to_vec); + } + let offer = seat.offer.as_ref()?; + let kinds = offer.data::>>()?; + if !kinds.lock().unwrap().iter().any(|offered| offered == kind) { + return None; + } + let (mut reader, writer) = std::io::pipe().ok()?; + offer.receive(kind.to_owned(), writer.as_fd()); + drop(writer); + connection.flush().ok()?; + let deadline = Instant::now() + PATIENCE; + let mut bytes = Vec::new(); + loop { + if !ready(reader.as_fd(), libc::POLLIN, deadline) { + eprintln!("The app that copied didn't answer the paste in time."); + return None; + } + let mut buffer = [0; 1 << 16]; + match reader.read(&mut buffer) { + Ok(0) => return Some(bytes), + Ok(read) => bytes.extend_from_slice(&buffer[..read]), + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {} + Err(_) => return None, + } + } + } +} + +/// Moves `proxy` onto `owner`'s event queue. libwayland names a proxy's queue only from 1.23 +/// (`wl_proxy_get_queue`), so this reads it from `struct wl_proxy`, laid out alike since 1.0. +unsafe fn onto_queue_of(proxy: *mut wl_proxy, owner: *mut wl_proxy) { + #[repr(C)] + struct Head { + interface: *const std::ffi::c_void, + implementation: *const std::ffi::c_void, + id: u32, + display: *mut std::ffi::c_void, + queue: *mut wl_event_queue, + } + let queue = unsafe { (*owner.cast::()).queue }; + unsafe { ffi_dispatch!(wayland_client_handle(), wl_proxy_set_queue, proxy, queue) }; +} + +/// The bytes of `formats` in the format `kind`. +fn offered<'a>(formats: &'a [(&str, Vec)], kind: &str) -> Option<&'a [u8]> { + let (_, bytes) = formats.iter().find(|(offered, _)| *offered == kind)?; + Some(bytes) +} + +/// Whether `fd` is ready for `events` before `deadline`. +fn ready(fd: BorrowedFd, events: i16, deadline: Instant) -> bool { + let left = deadline.saturating_duration_since(Instant::now()); + let mut poll = libc::pollfd { + fd: fd.as_raw_fd(), + events, + revents: 0, + }; + !left.is_zero() && unsafe { libc::poll(&mut poll, 1, left.as_millis() as i32) } > 0 +} + +/// Writes `bytes` to the pipe `fd`, waiting on a slow reader no longer than a paste waits. +fn answer(fd: OwnedFd, bytes: &[u8]) { + use std::io::Write; + let mut pipe = std::fs::File::from(fd); + let deadline = Instant::now() + PATIENCE; + // A pipe ready for writing takes PIPE_BUF bytes without blocking. + for chunk in bytes.chunks(libc::PIPE_BUF) { + if !ready(pipe.as_fd(), libc::POLLOUT, deadline) || pipe.write_all(chunk).is_err() { + return; + } + } +} + +impl Dispatch for Seat { + fn event( + seat: &mut Self, + registry: &WlRegistry, + event: wl_registry::Event, + _: &(), + _: &Connection, + queue: &QueueHandle, + ) { + let wl_registry::Event::Global { + name, + interface, + version, + } = event + else { + return; + }; + match interface.as_str() { + "wl_seat" if seat.seat.is_none() => { + seat.seat = Some(registry.bind(name, version.min(5), queue, ())); + } + "wl_data_device_manager" if seat.manager.is_none() => { + seat.manager = Some(registry.bind(name, version.min(3), queue, ())); + } + _ => return, + } + if let (Some(manager), Some(wl_seat), None) = (&seat.manager, &seat.seat, &seat.device) { + seat.device = Some(manager.get_data_device(wl_seat, queue, ())); + } + } +} + +impl Dispatch for Seat { + fn event( + seat: &mut Self, + wl_seat: &WlSeat, + event: wl_seat::Event, + _: &(), + _: &Connection, + queue: &QueueHandle, + ) { + let wl_seat::Event::Capabilities { + capabilities: WEnum::Value(capabilities), + } = event + else { + return; + }; + let keyboard = capabilities.contains(wl_seat::Capability::Keyboard); + if keyboard != seat.keyboard.is_some() { + seat.keyboard = keyboard.then(|| wl_seat.get_keyboard(queue, ())); + } + let pointer = capabilities.contains(wl_seat::Capability::Pointer); + if pointer != seat.pointer.is_some() { + seat.pointer = pointer.then(|| wl_seat.get_pointer(queue, ())); + } + } +} + +impl Dispatch for Seat { + fn event( + seat: &mut Self, + _: &WlKeyboard, + event: wl_keyboard::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + if let wl_keyboard::Event::Enter { serial, .. } | wl_keyboard::Event::Key { serial, .. } = + event + { + seat.serial = serial; + } + } +} + +impl Dispatch for Seat { + fn event( + seat: &mut Self, + _: &WlPointer, + event: wl_pointer::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + if let wl_pointer::Event::Enter { serial, .. } | wl_pointer::Event::Button { serial, .. } = + event + { + seat.serial = serial; + } + } +} + +impl Dispatch for Seat { + fn event( + _: &mut Self, + _: &WlDataDeviceManager, + _: ::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + } +} + +impl Dispatch for Seat { + fn event( + seat: &mut Self, + _: &WlDataDevice, + event: wl_data_device::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + match event { + wl_data_device::Event::Selection { id } => { + if let Some(old) = std::mem::replace(&mut seat.offer, id) { + old.destroy(); + } + } + // Drags aren't taken. + wl_data_device::Event::Enter { + id: Some(offer), .. + } => offer.destroy(), + _ => {} + } + } + + wayland_client::event_created_child!(Seat, WlDataDevice, [ + wl_data_device::EVT_DATA_OFFER_OPCODE => (WlDataOffer, Mutex::new(Vec::::new())), + ]); +} + +impl Dispatch>> for Seat { + fn event( + _: &mut Self, + _: &WlDataOffer, + event: wl_data_offer::Event, + kinds: &Mutex>, + _: &Connection, + _: &QueueHandle, + ) { + if let wl_data_offer::Event::Offer { mime_type } = event { + kinds.lock().unwrap().push(mime_type); + } + } +} + +impl Dispatch for Seat { + fn event( + seat: &mut Self, + source: &WlDataSource, + event: wl_data_source::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + let copied = seat.copied.as_ref().filter(|(copied, _)| copied == source); + match event { + wl_data_source::Event::Send { mime_type, fd } => { + if let Some(bytes) = copied.and_then(|(_, formats)| offered(formats, &mime_type)) { + answer(fd, bytes); + } + } + wl_data_source::Event::Cancelled => { + if copied.is_some() { + seat.copied = None; + } + source.destroy(); + } + _ => {} + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn uri_lists_name_local_files_whatever_their_line_ends() { + let list = b"# copied\r\nfile:///home/ben/Notes%20One.one\r\nfile://localhost/tmp/a.png\nhttps://example.com/b.png\r\n"; + assert_eq!( + files(list), + ["/home/ben/Notes One.one", "/tmp/a.png"].map(PathBuf::from) + ); + } +} diff --git a/crates/snowbound/src/linux.rs b/crates/snowbound/src/linux.rs index 4c6fec45551ee540471a0c17352306b480acdce3..aedc3f60c1183e55ea47100026c3e9c6fde365b8 100644 --- a/crates/snowbound/src/linux.rs +++ b/crates/snowbound/src/linux.rs @@ -212,106 +212,7 @@ pub fn install_title_bar(window: &Window) { crate::desktop::set_toplevel_icon(window); } -/// The HTML and Snowbound's own format of this process's last copy through X11. -static LAST_COPY: std::sync::Mutex> = std::sync::Mutex::new(None); - -/// Wayland's clipboard through the window's own connection, as not every compositor offers -/// a clipboard to clients without a window; X11's otherwise. -pub enum Clipboard { - Wayland(smithay_clipboard::Clipboard), - X11(arboard::Clipboard), -} - -impl Clipboard { - pub fn new(window: &Window) -> Result { - use winit::raw_window_handle::{HasDisplayHandle, RawDisplayHandle}; - match window.display_handle().map(|handle| handle.as_raw()) { - // The display outlives the clipboard, which the window's state owns. - Ok(RawDisplayHandle::Wayland(handle)) => Ok(Self::Wayland(unsafe { - smithay_clipboard::Clipboard::new(handle.display.as_ptr()) - })), - _ => arboard::Clipboard::new().map(Self::X11), - } - } - - pub fn set_text(&mut self, text: String) -> Result<(), arboard::Error> { - match self { - Self::Wayland(clipboard) => { - clipboard.store(text); - Ok(()) - } - Self::X11(clipboard) => clipboard.set_text(text), - } - } - - /// Text, HTML and Snowbound's own format, as one copy: see [`crate::paste::Copied`]. - /// smithay-clipboard offers text alone. - pub fn set(&mut self, copied: &crate::paste::Copied) -> Result<(), arboard::Error> { - match self { - Self::Wayland(clipboard) => { - clipboard.store(copied.text.clone()); - Ok(()) - } - Self::X11(clipboard) => { - clipboard.set().html(&copied.html, Some(&copied.text))?; - *LAST_COPY.lock().unwrap() = Some((copied.html.clone(), copied.clip.clone())); - Ok(()) - } - } - } - - /// What Snowbound itself copied, if it did: on X11, which arboard offers no format of - /// Snowbound's own through, the last copy's, while the clipboard holds its HTML. - pub fn get_clip(&mut self) -> Option { - if !matches!(self, Self::X11(_)) { - return None; - } - let html = self.get_html()?; - match &*LAST_COPY.lock().unwrap() { - Some((copied, clip)) if *copied == html => Some(clip.clone()), - _ => None, - } - } - - pub fn get_text(&mut self) -> Result> { - Ok(match self { - Self::Wayland(clipboard) => clipboard.load()?, - Self::X11(clipboard) => clipboard.get_text()?, - }) - } - - pub fn get_files(&mut self) -> Vec { - use std::os::unix::ffi::OsStrExt; - let files = self.through_x11(|clipboard| clipboard.get().file_list().ok()); - // arboard splits text/uri-list at LF and keeps the CR its CRLF lines end in. - let path = |path: std::path::PathBuf| { - let bytes = path.as_os_str().as_bytes(); - let bytes = bytes.strip_suffix(b"\r").unwrap_or(bytes); - std::ffi::OsStr::from_bytes(bytes).into() - }; - files.unwrap_or_default().into_iter().map(path).collect() - } - - pub fn get_html(&mut self) -> Option { - self.through_x11(|clipboard| clipboard.get().html().ok()) - } - - pub fn get_picture(&mut self) -> Option> { - self.through_x11(|clipboard| crate::paste::bitmap(clipboard.get_image().ok()?)) - } - - /// Files, pages and pictures come through X11 on Wayland too, whose clipboard Xwayland - /// shares. - fn through_x11( - &mut self, - read: impl FnOnce(&mut arboard::Clipboard) -> Option, - ) -> Option { - match self { - Self::Wayland(_) => read(&mut arboard::Clipboard::new().ok()?), - Self::X11(clipboard) => read(clipboard), - } - } -} +pub use crate::clipboard::Clipboard; /// Whether the system draws the title bar: the window manager, or on Wayland the compositor. /// Where winit's Adwaita frame decorates the window instead, as on GNOME, the frame has no diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index f9a2b42a2d8fd694b05cea9a225eb00673999526..9b704eba4b5dbcd248bcc017c551d8faacfadbdf 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -6,6 +6,9 @@ mod aqua; mod art; mod attachment; mod background; +#[cfg(target_os = "linux")] +#[path = "clipboard_linux.rs"] +mod clipboard; mod commands; #[cfg(all(test, feature = "wgpu", not(windows)))] mod conflict_render; @@ -278,6 +281,9 @@ fn confirm(message: &str, detail: &str, cancel: &str, action: &str, reply: Reply enum UserEvent { Quit, + /// The Wayland compositor asked something of the clipboard. + #[cfg(target_os = "linux")] + Clipboard, /// Quits without asking, as after the user agreed to discard a temporary page. #[cfg(not(target_arch = "wasm32"))] Exit, @@ -6194,6 +6200,12 @@ impl State { UserEvent::Quit | UserEvent::Replay(Replay::Quit) => {} #[cfg(not(target_arch = "wasm32"))] UserEvent::Exit => {} + #[cfg(target_os = "linux")] + UserEvent::Clipboard => { + if let Clipboard::System(clipboard) = &mut self.clipboard { + clipboard.serve(); + } + } UserEvent::Picture(bytes) => { if let Err(error) = self.insert_picture(bytes, None) { eprintln!("{error}"); @@ -6623,7 +6635,7 @@ impl ApplicationHandler for App { } /// Drops the window's state while the event loop still holds the Wayland connection the - /// clipboard's thread and the surface use; `run_app` closes it on returning. + /// clipboard and the surface use; `run_app` closes it on returning. fn exiting(&mut self, _: &ActiveEventLoop) { self.restart = self .state -- 2.54.0