mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-31 21:27:00 -06:00
Linux clipboard: copy text through the core Wayland protocol
Where arboard can't open (GNOME has no data-control protocol, and the Flatpak on a Wayland session has no X11), Copy and Paste use the compositor's core data-device protocol on the window's own connection through smithay-clipboard, already in the build through egui-winit: text only, a copy's text or its SVG markup. A bitmap-only copy reports that this clipboard carries text only and leaves it as it was. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
committed by
bflatastic
co-authored by
Claude Opus 5.5
parent
33d15bec56
commit
dd934dfd4e
@@ -92,6 +92,10 @@ x11rb = { version = "0.13", default-features = false, features = ["xinput"] }
|
||||
# Asks the Wayland compositor which GPU it draws with (linux-dmabuf feedback, src/compositor_gpu.rs),
|
||||
# so Automatic draws on that one. Both are already in the build through winit.
|
||||
wayland-client = "0.31"
|
||||
# Copy and Paste of text through the compositor's core data-device protocol on the window's
|
||||
# connection (src/clipboard.rs), where arboard has neither data-control (GNOME) nor X11 (a Flatpak
|
||||
# on a Wayland session). MIT, already in the build through egui-winit.
|
||||
smithay-clipboard = "0.7"
|
||||
wayland-protocols = { version = "0.32", features = ["client", "unstable"] }
|
||||
# The save dialog's parent window on Wayland (src/portal_save.rs): an xdg-foreign handle (in
|
||||
# wayland-protocols' `unstable` set) made on the window's own connection. MIT, already in the build
|
||||
|
||||
@@ -9,8 +9,10 @@
|
||||
//! and EPS as vector art (#1499) and its PNG or TIFF as they are, as screenshots copy them (on
|
||||
//! Linux arboard reads no other formats). On Wayland, arboard's data-control
|
||||
//! backend reads the compositor's clipboard (X11 through XWayland only sees what X11 apps
|
||||
//! copied), falling back to X11 where the compositor lacks the protocol. A clipboard that can't
|
||||
//! be opened is tried again after a wait that grows to a minute, not each time Paste asks.
|
||||
//! copied), falling back to X11 where the compositor lacks the protocol, and where there is no
|
||||
//! X11 either (a Flatpak on GNOME) to the compositor's core protocol on the window's connection,
|
||||
//! text only. A clipboard that can't be opened is tried again after a wait that grows to a
|
||||
//! minute, not each time Paste asks.
|
||||
//! - Everywhere: files copied in a file manager paste as the first one that is art (SVG, PDF, EPS,
|
||||
//! a metafile or a bitmap), before the clipboard's other formats (the files' paths as text).
|
||||
|
||||
@@ -20,12 +22,16 @@ use std::path::PathBuf;
|
||||
use vectorcraft_engine::cmd::clipboard::{BITMAP, EPS, FILE_HEAD, Flavour, PDF, PNG, SVG, TEXT, file_flavour};
|
||||
use vectorcraft_ui_egui::SystemClipboard;
|
||||
|
||||
/// This platform's system clipboard.
|
||||
pub fn system_clipboard() -> Box<dyn SystemClipboard> {
|
||||
/// This platform's system clipboard. On Linux, `window`'s Wayland connection carries text where
|
||||
/// arboard can't open (GNOME has no data-control protocol, a Flatpak there no X11).
|
||||
pub fn system_clipboard(window: Option<std::sync::Arc<winit::window::Window>>) -> Box<dyn SystemClipboard> {
|
||||
#[cfg(windows)]
|
||||
let cb = Box::<win::Clipboard>::default();
|
||||
let cb = {
|
||||
let _ = window;
|
||||
Box::<win::Clipboard>::default()
|
||||
};
|
||||
#[cfg(not(windows))]
|
||||
let cb = Box::<portable::Clipboard>::default();
|
||||
let cb = Box::new(portable::Clipboard::new(window));
|
||||
cb
|
||||
}
|
||||
|
||||
@@ -307,10 +313,12 @@ mod win {
|
||||
}
|
||||
}
|
||||
|
||||
/// arboard: one format at a time. Built everywhere so it is checked everywhere.
|
||||
/// arboard, or on a Wayland compositor without its data-control protocol the core protocol's text
|
||||
/// clipboard: one format at a time. Built everywhere so it is checked everywhere.
|
||||
#[cfg_attr(windows, allow(dead_code))]
|
||||
mod portable {
|
||||
use std::borrow::Cow;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -322,6 +330,108 @@ mod portable {
|
||||
Image(usize, usize),
|
||||
}
|
||||
|
||||
/// An open clipboard connection.
|
||||
enum Backend {
|
||||
Arboard(arboard::Clipboard),
|
||||
/// The compositor's core data-device protocol on the window's connection (text only).
|
||||
#[cfg(target_os = "linux")]
|
||||
Wayland(super::wayland::TextClipboard),
|
||||
}
|
||||
|
||||
/// What a write of a picture says where the clipboard carries text only.
|
||||
const TEXT_ONLY: &str = "this clipboard carries text only";
|
||||
|
||||
impl Backend {
|
||||
/// arboard (Wayland data-control, else X11), else the core protocol on `window`'s
|
||||
/// Wayland connection. Err: why each failed.
|
||||
fn open(window: Option<&Arc<winit::window::Window>>) -> Result<Self, String> {
|
||||
first_open(
|
||||
|| arboard::Clipboard::new().map(Self::Arboard).map_err(|e| e.to_string()),
|
||||
|| {
|
||||
#[cfg(target_os = "linux")]
|
||||
return window.and_then(|w| super::wayland::TextClipboard::new(w.clone())).map(Self::Wayland).ok_or("not a Wayland window");
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let _ = window;
|
||||
Err("not on Linux")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Arboard(_) => "arboard",
|
||||
#[cfg(target_os = "linux")]
|
||||
Self::Wayland(_) => "the Wayland core protocol (text only)",
|
||||
}
|
||||
}
|
||||
|
||||
fn get_text(&mut self) -> Result<String, String> {
|
||||
match self {
|
||||
Self::Arboard(cb) => cb.get_text().map_err(|e| e.to_string()),
|
||||
#[cfg(target_os = "linux")]
|
||||
Self::Wayland(cb) => cb.get_text(),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_text(&mut self, text: &str) -> Result<(), String> {
|
||||
match self {
|
||||
Self::Arboard(cb) => cb.set_text(text).map_err(|e| e.to_string()),
|
||||
#[cfg(target_os = "linux")]
|
||||
Self::Wayland(cb) => {
|
||||
cb.set_text(text);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_image(&mut self) -> Result<arboard::ImageData<'static>, String> {
|
||||
match self {
|
||||
Self::Arboard(cb) => cb.get_image().map_err(|e| e.to_string()),
|
||||
#[cfg(target_os = "linux")]
|
||||
Self::Wayland(_) => Err(TEXT_ONLY.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_image(&mut self, img: arboard::ImageData<'_>) -> Result<(), String> {
|
||||
match self {
|
||||
Self::Arboard(cb) => cb.set_image(img).map_err(|e| e.to_string()),
|
||||
#[cfg(target_os = "linux")]
|
||||
Self::Wayland(_) => Err(TEXT_ONLY.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn clear(&mut self) -> Result<(), String> {
|
||||
match self {
|
||||
Self::Arboard(cb) => cb.clear().map_err(|e| e.to_string()),
|
||||
#[cfg(target_os = "linux")]
|
||||
Self::Wayland(cb) => {
|
||||
cb.set_text("");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Files copied in a file manager (none where the backend can't read a file list).
|
||||
fn file_list(&mut self) -> Vec<std::path::PathBuf> {
|
||||
match self {
|
||||
Self::Arboard(cb) => cb.get().file_list().unwrap_or_default(),
|
||||
#[cfg(target_os = "linux")]
|
||||
Self::Wayland(_) => vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The first of `arboard` and `wayland` that opens, tried in that order. Err: why each failed.
|
||||
fn first_open<B>(arboard: impl FnOnce() -> Result<B, String>, wayland: impl FnOnce() -> Result<B, &'static str>) -> Result<B, String> {
|
||||
let arboard = match arboard() {
|
||||
Ok(cb) => return Ok(cb),
|
||||
Err(e) => e,
|
||||
};
|
||||
wayland().map_err(|wayland| format!("arboard: {arboard}; Wayland core protocol: {wayland}"))
|
||||
}
|
||||
|
||||
/// Who asks for the clipboard: the background paste check waits out the whole wait after a
|
||||
/// failure, a Copy or Paste of the user's [`USER_RETRY`] at most.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
@@ -348,7 +458,7 @@ mod portable {
|
||||
}
|
||||
|
||||
/// A handle opened on first use and kept, or after a failure opened again only once its wait is
|
||||
/// over: where the clipboard can't be opened, asking a few times a second must not reconnect each time.
|
||||
/// over: where neither backend opens, asking a few times a second must not reconnect each time.
|
||||
pub(super) struct Reopen<B> {
|
||||
handle: Option<B>,
|
||||
failed: Option<Failed>,
|
||||
@@ -415,20 +525,20 @@ mod portable {
|
||||
|
||||
pub struct Clipboard {
|
||||
/// Kept open: on Linux the copied data lives as long as the handle does.
|
||||
cb: Reopen<arboard::Clipboard>,
|
||||
cb: Reopen<Backend>,
|
||||
/// The app window, for the Wayland core protocol (on its connection).
|
||||
window: Option<Arc<winit::window::Window>>,
|
||||
ours: Option<Ours>,
|
||||
}
|
||||
|
||||
impl Default for Clipboard {
|
||||
fn default() -> Self {
|
||||
Self { cb: Reopen::new(&UNAVAILABLE_LOGGED), ours: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl Clipboard {
|
||||
fn cb(&mut self, asker: Asker) -> Result<&mut arboard::Clipboard, String> {
|
||||
let open = || arboard::Clipboard::new().map_err(|e| e.to_string());
|
||||
self.cb.get(asker, Instant::now(), open, |_| "arboard").ok_or_else(|| "no system clipboard".to_string())
|
||||
pub fn new(window: Option<Arc<winit::window::Window>>) -> Self {
|
||||
Self { cb: Reopen::new(&UNAVAILABLE_LOGGED), window, ours: None }
|
||||
}
|
||||
|
||||
fn cb(&mut self, asker: Asker) -> Result<&mut Backend, String> {
|
||||
let window = self.window.as_ref();
|
||||
self.cb.get(asker, Instant::now(), || Backend::open(window), Backend::name).ok_or_else(|| "no system clipboard".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -442,16 +552,16 @@ mod portable {
|
||||
let cb = self.cb(Asker::User)?;
|
||||
let ours = match (text, image) {
|
||||
(Some(t), _) => {
|
||||
cb.set_text(t.as_str()).map_err(|e| e.to_string())?;
|
||||
cb.set_text(t.as_str())?;
|
||||
Some(Ours::Text(t))
|
||||
}
|
||||
(None, Some(img)) => {
|
||||
let (width, height) = (img.width() as usize, img.height() as usize);
|
||||
cb.set_image(arboard::ImageData { width, height, bytes: Cow::Owned(img.into_raw()) }).map_err(|e| e.to_string())?;
|
||||
cb.set_image(arboard::ImageData { width, height, bytes: Cow::Owned(img.into_raw()) })?;
|
||||
Some(Ours::Image(width, height))
|
||||
}
|
||||
(None, None) => {
|
||||
cb.clear().map_err(|e| e.to_string())?;
|
||||
cb.clear()?;
|
||||
None
|
||||
}
|
||||
};
|
||||
@@ -470,7 +580,7 @@ mod portable {
|
||||
|
||||
fn read(&mut self, mimes: &[&'static str]) -> Option<Flavour> {
|
||||
let cb = self.cb(Asker::User).ok()?;
|
||||
if let Some(f) = cb.get().file_list().ok().and_then(|paths| copied_file(paths, mimes)) {
|
||||
if let Some(f) = copied_file(cb.file_list(), mimes) {
|
||||
return Some(f);
|
||||
}
|
||||
mimes.iter().find_map(|m| {
|
||||
@@ -608,6 +718,26 @@ mod portable {
|
||||
assert_eq!(cb.opened().copied(), Some(7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arboard_is_tried_before_the_wayland_core_protocol() {
|
||||
let wayland_tried = Cell::new(false);
|
||||
let wayland = |ok| {
|
||||
wayland_tried.set(true);
|
||||
if ok { Ok("wayland") } else { Err("not a Wayland window") }
|
||||
};
|
||||
// Where arboard opens (data-control, X11) the core protocol isn't tried.
|
||||
assert_eq!(first_open(|| Ok("arboard"), || wayland(true)), Ok("arboard"));
|
||||
assert!(!wayland_tried.get());
|
||||
// The Flatpak on GNOME: no data-control, no X11.
|
||||
assert_eq!(first_open(|| Err("no X11".into()), || wayland(true)), Ok("wayland"));
|
||||
assert!(wayland_tried.get());
|
||||
// Neither: every reason is kept for the log.
|
||||
assert_eq!(
|
||||
first_open(|| Err("no X11".into()), || wayland(false)),
|
||||
Err::<&str, _>("arboard: no X11; Wayland core protocol: not a Wayland window".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_outage_is_logged_once_across_handles() {
|
||||
static LOGGED: AtomicBool = AtomicBool::new(false);
|
||||
@@ -623,6 +753,43 @@ mod portable {
|
||||
}
|
||||
}
|
||||
|
||||
/// The compositor's clipboard through the core data-device protocol (`wl_data_device`), on the
|
||||
/// 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.
|
||||
#[cfg(target_os = "linux")]
|
||||
mod wayland {
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct TextClipboard {
|
||||
cb: smithay_clipboard::Clipboard,
|
||||
/// Held as long as the clipboard: its connection is the window's.
|
||||
_window: Arc<winit::window::Window>,
|
||||
}
|
||||
|
||||
impl TextClipboard {
|
||||
/// None when `window` isn't a Wayland window.
|
||||
#[allow(unsafe_code)]
|
||||
pub fn new(window: Arc<winit::window::Window>) -> Option<Self> {
|
||||
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 })
|
||||
}
|
||||
|
||||
pub fn get_text(&self) -> Result<String, String> {
|
||||
self.cb.load().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn set_text(&self, text: &str) {
|
||||
self.cb.store(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The macOS pasteboard's vector art and bitmaps as they are (arboard reads text, and of the
|
||||
/// pictures TIFF only, decoded).
|
||||
#[cfg(target_os = "macos")]
|
||||
|
||||
@@ -255,6 +255,7 @@ pub(crate) fn services(parent: Parent, ui: egui::Context) -> Services {
|
||||
.as_ref()
|
||||
.and_then(|w| w.window_handle().ok())
|
||||
.is_some_and(|h| matches!(h.as_raw(), RawWindowHandle::Xlib(_) | RawWindowHandle::Xcb(_)));
|
||||
let (clip, clip_probe) = (parent.clone(), parent.clone());
|
||||
let (p1, p2, p3, p4, p5) = (parent.clone(), parent.clone(), parent.clone(), parent.clone(), parent);
|
||||
#[cfg(target_os = "macos")]
|
||||
let sampled_ui = ui.clone();
|
||||
@@ -286,11 +287,12 @@ pub(crate) fn services(parent: Parent, ui: egui::Context) -> Services {
|
||||
fileio::write_file_with(p, fill).map_err(|e| e.to_string())
|
||||
})),
|
||||
// Every format Copy offers and Paste reads (menu-bar Paste never sees egui's Paste event).
|
||||
system_clipboard: Some(clipboard::system_clipboard()),
|
||||
system_clipboard: Some(clipboard::system_clipboard(clip)),
|
||||
// Linux checks whether Paste has something to take on a background thread: an X11 clipboard
|
||||
// owner that never answers holds a read for up to 4 s. Windows only asks which formats the
|
||||
// clipboard holds and macOS asks the pasteboard server, so they check in line.
|
||||
clipboard_probe: cfg!(target_os = "linux").then(|| Box::new(clipboard::system_clipboard) as ClipboardProbeFactory),
|
||||
// The probe's handle is made on its own thread, from the window (`Send`), not its raw display.
|
||||
clipboard_probe: cfg!(target_os = "linux").then(|| Box::new(move || clipboard::system_clipboard(clip_probe)) as ClipboardProbeFactory),
|
||||
// Help → Discord / website / GitHub, the Discord button, About and Home links.
|
||||
open_url: Some(Box::new(|url: &str| {
|
||||
let _ = webbrowser::open(url);
|
||||
|
||||
Reference in New Issue
Block a user