From 7d580710f5835f920219fdf7e6f943eea4fb2081 Mon Sep 17 00:00:00 2001 From: Luca Stefani Date: Sun, 3 May 2026 12:20:56 +0200 Subject: [PATCH 1/2] Wire-up support for Primary clipboard on Linux --- core/src/clipboard.rs | 16 ++++++++-- core/src/shell.rs | 22 ++++++++++++-- runtime/src/clipboard.rs | 54 +++++++++++++++++++++++++++++--- src/lib.rs | 6 ++-- winit/src/clipboard.rs | 66 ++++++++++++++++++++++++++++++++++++++-- winit/src/lib.rs | 24 ++++++++++----- 6 files changed, 167 insertions(+), 21 deletions(-) diff --git a/core/src/clipboard.rs b/core/src/clipboard.rs index 864c83380e..cb2d2ed1f9 100644 --- a/core/src/clipboard.rs +++ b/core/src/clipboard.rs @@ -6,10 +6,10 @@ use std::sync::Arc; #[derive(Debug, Clone)] pub struct Clipboard { /// The read requests the runtime must fulfill. - pub reads: Vec, + pub reads: Vec<(ClipboardKind, Kind)>, /// The content that must be written to the clipboard by the runtime, /// if any. - pub write: Option, + pub write: Option<(ClipboardKind, Content)>, } impl Clipboard { @@ -34,6 +34,18 @@ impl Default for Clipboard { } } +/// The kind of [`Clipboard`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClipboardKind { + /// The standard clipboard. + Standard, + + /// The primary clipboard. + /// + /// Normally only present in X11 and Wayland. + Primary, +} + /// A clipboard event. #[derive(Debug, Clone, PartialEq)] pub enum Event { diff --git a/core/src/shell.rs b/core/src/shell.rs index a7fd61327e..db000a755d 100644 --- a/core/src/shell.rs +++ b/core/src/shell.rs @@ -97,14 +97,32 @@ impl<'a, Message> Shell<'a, Message> { /// /// The runtime will produce a [`clipboard::Event::Read`] when the contents have been read. pub fn read_clipboard(&mut self, kind: clipboard::Kind) { - self.clipboard.reads.push(kind); + self.clipboard + .reads + .push((clipboard::ClipboardKind::Standard, kind)); + } + + /// Requests the runtime to read the primary clipboard contents expecting the given [`clipboard::Kind`]. + /// + /// The runtime will produce a [`clipboard::Event::Read`] when the contents have been read. + pub fn read_clipboard_primary(&mut self, kind: clipboard::Kind) { + self.clipboard + .reads + .push((clipboard::ClipboardKind::Primary, kind)); } /// Requests the runtime to write the given [`clipboard::Content`] to the clipboard. /// /// The runtime will produce a [`clipboard::Event::Written`] when the contents have been written. pub fn write_clipboard(&mut self, content: clipboard::Content) { - self.clipboard.write = Some(content); + self.clipboard.write = Some((clipboard::ClipboardKind::Standard, content)); + } + + /// Requests the runtime to write the given [`clipboard::Content`] to the primary clipboard. + /// + /// The runtime will produce a [`clipboard::Event::Written`] when the contents have been written. + pub fn write_clipboard_primary(&mut self, content: clipboard::Content) { + self.clipboard.write = Some((clipboard::ClipboardKind::Primary, content)); } /// Returns the [`Clipboard`] requests of the [`Shell`], mutably. diff --git a/runtime/src/clipboard.rs b/runtime/src/clipboard.rs index 026859017f..439d1cdc10 100644 --- a/runtime/src/clipboard.rs +++ b/runtime/src/clipboard.rs @@ -1,5 +1,5 @@ //! Access the clipboard. -use crate::core::clipboard::{Content, Error, Kind}; +use crate::core::clipboard::{ClipboardKind, Content, Error, Kind}; use crate::futures::futures::channel::oneshot; use crate::task::{self, Task}; @@ -13,6 +13,8 @@ use std::sync::Arc; pub enum Action { /// Read the clipboard and produce `T` with the result. Read { + /// The kind of clipboard to read from. + clipboard_kind: ClipboardKind, /// The [`Kind`] of [`Content`] to read. kind: Kind, /// The channel to send the read contents. @@ -21,6 +23,9 @@ pub enum Action { /// Write the given contents to the clipboard. Write { + /// The kind of clipboard to write to. + clipboard_kind: ClipboardKind, + /// The [`Content`] to be written. content: Content, @@ -31,14 +36,33 @@ pub enum Action { /// Read the given [`Kind`] of [`Content`] from the clipboard. pub fn read(kind: Kind) -> Task, Error>> { - task::oneshot(|channel| crate::Action::Clipboard(Action::Read { kind, channel })) - .map(|result| result.map(Arc::new)) + task::oneshot(|channel| { + crate::Action::Clipboard(Action::Read { + clipboard_kind: ClipboardKind::Standard, + kind, + channel, + }) + }) + .map(|result| result.map(Arc::new)) +} + +/// Read the given [`Kind`] of [`Content`] from the primary clipboard. +pub fn read_primary(kind: Kind) -> Task, Error>> { + task::oneshot(|channel| { + crate::Action::Clipboard(Action::Read { + clipboard_kind: ClipboardKind::Primary, + kind, + channel, + }) + }) + .map(|result| result.map(Arc::new)) } /// Read the current text contents of the clipboard. pub fn read_text() -> Task, Error>> { task::oneshot(|channel| { crate::Action::Clipboard(Action::Read { + clipboard_kind: ClipboardKind::Standard, kind: Kind::Text, channel, }) @@ -56,6 +80,7 @@ pub fn read_text() -> Task, Error>> { pub fn read_html() -> Task, Error>> { task::oneshot(|channel| { crate::Action::Clipboard(Action::Read { + clipboard_kind: ClipboardKind::Standard, kind: Kind::Html, channel, }) @@ -73,6 +98,7 @@ pub fn read_html() -> Task, Error>> { pub fn read_files() -> Task, Error>> { task::oneshot(|channel| { crate::Action::Clipboard(Action::Read { + clipboard_kind: ClipboardKind::Standard, kind: Kind::Files, channel, }) @@ -91,6 +117,7 @@ pub fn read_files() -> Task, Error>> { pub fn read_image() -> Task> { task::oneshot(|channel| { crate::Action::Clipboard(Action::Read { + clipboard_kind: ClipboardKind::Standard, kind: Kind::Image, channel, }) @@ -108,5 +135,24 @@ pub fn read_image() -> Task> { pub fn write(content: impl Into) -> Task> { let content = content.into(); - task::oneshot(|channel| crate::Action::Clipboard(Action::Write { content, channel })) + task::oneshot(|channel| { + crate::Action::Clipboard(Action::Write { + clipboard_kind: ClipboardKind::Standard, + content, + channel, + }) + }) +} + +/// Write the given [`Content`] to the primary clipboard. +pub fn write_primary(content: impl Into) -> Task> { + let content = content.into(); + + task::oneshot(|channel| { + crate::Action::Clipboard(Action::Write { + clipboard_kind: ClipboardKind::Primary, + content, + channel, + }) + }) } diff --git a/src/lib.rs b/src/lib.rs index e52ea3db15..a5f7c60713 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -559,8 +559,10 @@ pub mod task { pub mod clipboard { //! Access the clipboard. - pub use crate::core::clipboard::{Content, Error, Kind}; - pub use crate::runtime::clipboard::{read, read_files, read_html, read_text, write}; + pub use crate::core::clipboard::{ClipboardKind, Content, Error, Kind}; + pub use crate::runtime::clipboard::{ + read, read_files, read_html, read_primary, read_text, write, write_primary, + }; #[cfg(feature = "image")] pub use crate::core::clipboard::Image; diff --git a/winit/src/clipboard.rs b/winit/src/clipboard.rs index 30e4b37d13..5ae8e9ae29 100644 --- a/winit/src/clipboard.rs +++ b/winit/src/clipboard.rs @@ -1,5 +1,5 @@ //! Access the clipboard. -use crate::core::clipboard::{Content, Error, Kind}; +use crate::core::clipboard::{ClipboardKind, Content, Error, Kind}; pub use platform::*; @@ -11,6 +11,12 @@ impl Default for Clipboard { #[cfg(not(target_arch = "wasm32"))] mod platform { + #[cfg(all( + unix, + not(any(target_os = "macos", target_os = "android", target_os = "emscripten")), + ))] + use arboard::{GetExtLinux, SetExtLinux}; + use super::*; use std::sync::{Arc, Mutex}; @@ -47,6 +53,7 @@ mod platform { /// Reads the current content of the [`Clipboard`] as text. pub fn read( &self, + clipboard_kind: ClipboardKind, kind: Kind, callback: impl FnOnce(Result) + Send + 'static, ) { @@ -63,6 +70,27 @@ mod platform { return; }; + #[cfg(all( + unix, + not(any( + target_os = "macos", + target_os = "android", + target_os = "emscripten" + )), + ))] + let get = clipboard.get().clipboard(match clipboard_kind { + ClipboardKind::Standard => arboard::LinuxClipboardKind::Clipboard, + ClipboardKind::Primary => arboard::LinuxClipboardKind::Primary, + }); + + #[cfg(not(all( + unix, + not(any( + target_os = "macos", + target_os = "android", + target_os = "emscripten" + )), + )))] let get = clipboard.get(); let result = match kind { @@ -94,6 +122,7 @@ mod platform { /// Writes the given text contents to the [`Clipboard`]. pub fn write( &mut self, + clipboard_kind: ClipboardKind, content: Content, callback: impl FnOnce(Result<(), Error>) + Send + 'static, ) { @@ -110,6 +139,27 @@ mod platform { return; }; + #[cfg(all( + unix, + not(any( + target_os = "macos", + target_os = "android", + target_os = "emscripten" + )), + ))] + let set = clipboard.set().clipboard(match clipboard_kind { + ClipboardKind::Standard => arboard::LinuxClipboardKind::Clipboard, + ClipboardKind::Primary => arboard::LinuxClipboardKind::Primary, + }); + + #[cfg(not(all( + unix, + not(any( + target_os = "macos", + target_os = "android", + target_os = "emscripten" + )), + )))] let set = clipboard.set(); let result = match content { @@ -167,12 +217,22 @@ mod platform { } /// Reads the current content of the [`Clipboard`] as text. - pub fn read(&self, _kind: Kind, callback: impl FnOnce(Result)) { + pub fn read( + &self, + _clipboard_kind: ClipboardKind, + _kind: Kind, + callback: impl FnOnce(Result), + ) { callback(Err(Error::ClipboardUnavailable)); } /// Writes the given text contents to the [`Clipboard`]. - pub fn write(&mut self, _content: Content, callback: impl FnOnce(Result<(), Error>)) { + pub fn write( + &mut self, + _clipboard_kind: ClipboardKind, + _content: Content, + callback: impl FnOnce(Result<(), Error>), + ) { callback(Err(Error::ClipboardUnavailable)); } } diff --git a/winit/src/lib.rs b/winit/src/lib.rs index 3aed078b6d..78e9ed7769 100644 --- a/winit/src/lib.rs +++ b/winit/src/lib.rs @@ -1300,13 +1300,21 @@ fn run_action<'a, P, C>( messages.push(message); } Action::Clipboard(action) => match action { - clipboard::Action::Read { kind, channel } => { - clipboard.read(kind, move |result| { + clipboard::Action::Read { + clipboard_kind, + kind, + channel, + } => { + clipboard.read(clipboard_kind, kind, move |result| { let _ = channel.send(result); }); } - clipboard::Action::Write { content, channel } => { - clipboard.write(content, move |result| { + clipboard::Action::Write { + clipboard_kind, + content, + channel, + } => { + clipboard.write(clipboard_kind, content, move |result| { let _ = channel.send(result); }); } @@ -1843,10 +1851,10 @@ fn run_clipboard( requests: core::Clipboard, window: window::Id, ) { - for kind in requests.reads { + for (clipboard_kind, kind) in requests.reads { let proxy = proxy.clone(); - clipboard.read(kind, move |result| { + clipboard.read(clipboard_kind, kind, move |result| { proxy.send_action(Action::Event { window, event: core::Event::Clipboard(core::clipboard::Event::Read(result.map(Arc::new))), @@ -1854,10 +1862,10 @@ fn run_clipboard( }); } - if let Some(content) = requests.write { + if let Some((clipboard_kind, content)) = requests.write { let proxy = proxy.clone(); - clipboard.write(content, move |result| { + clipboard.write(clipboard_kind, content, move |result| { proxy.send_action(Action::Event { window, event: core::Event::Clipboard(core::clipboard::Event::Written(result)), From 1a130d20725e857ae226b1356ef4c926aebf7fec Mon Sep 17 00:00:00 2001 From: Andrew Baldwin Date: Sun, 3 May 2026 13:07:25 +0200 Subject: [PATCH 2/2] Add support for copy/pasting from the primary clipboard into TextInput/TextEditor. --- core/src/mouse/click.rs | 5 ++ widget/src/text_editor.rs | 49 +++++++++++++----- widget/src/text_input.rs | 105 +++++++++++++++++++++++++++++++++++++- 3 files changed, 144 insertions(+), 15 deletions(-) diff --git a/core/src/mouse/click.rs b/core/src/mouse/click.rs index 8a1c461c41..ec407640f3 100644 --- a/core/src/mouse/click.rs +++ b/core/src/mouse/click.rs @@ -66,6 +66,11 @@ impl Click { self.kind } + /// Returns the [`Button`] of [`Click`]. + pub fn button(&self) -> Button { + self.button + } + /// Returns the position of the [`Click`]. pub fn position(&self) -> Point { self.position diff --git a/widget/src/text_editor.rs b/widget/src/text_editor.rs index b41d748924..1b38060fdc 100644 --- a/widget/src/text_editor.rs +++ b/widget/src/text_editor.rs @@ -707,17 +707,29 @@ where ) { match update { Update::Click(click) => { - let action = match click.kind() { - mouse::click::Kind::Single => Action::Click(click.position()), - mouse::click::Kind::Double => Action::SelectWord, - mouse::click::Kind::Triple => Action::SelectLine, - }; - state.focus = Some(Focus::now()); state.last_click = Some(click); - state.drag_click = Some(click.kind()); - shell.publish(on_edit(action)); + match click.button() { + mouse::Button::Left => { + let action = match click.kind() { + mouse::click::Kind::Single => Action::Click(click.position()), + mouse::click::Kind::Double => Action::SelectWord, + mouse::click::Kind::Triple => Action::SelectLine, + }; + + state.drag_click = Some(click.kind()); + + shell.publish(on_edit(action)); + } + mouse::Button::Middle if cfg!(target_os = "linux") => { + shell.publish(on_edit(Action::Click(click.position()))); + + shell.read_clipboard_primary(clipboard::Kind::Text); + } + _ => (), + } + shell.capture_event(); } Update::Drag(position) => { @@ -725,6 +737,12 @@ where } Update::Release => { state.drag_click = None; + + if cfg!(target_os = "linux") && state.focus.is_some() { + shell.write_clipboard_primary(clipboard::Content::Text( + self.content.selection().unwrap_or_default(), + )); + } } Update::Scroll(lines) => { let bounds = self.content.0.borrow().editor.bounds(); @@ -1213,16 +1231,16 @@ impl Update { match event { Event::Mouse(event) => match event { - mouse::Event::ButtonPressed(mouse::Button::Left) => { + mouse::Event::ButtonPressed(button) + if matches!(button, mouse::Button::Left) + || (cfg!(target_os = "linux") + && matches!(button, mouse::Button::Middle)) => + { if let Some(cursor_position) = cursor.position_in(bounds) { let cursor_position = cursor_position - Vector::new(padding.left, padding.top); - let click = mouse::Click::new( - cursor_position, - mouse::Button::Left, - state.last_click, - ); + let click = mouse::Click::new(cursor_position, *button, state.last_click); Some(Update::Click(click)) } else if state.focus.is_some() { @@ -1302,6 +1320,9 @@ impl Update { } .map(Self::Binding) } + Event::Keyboard(keyboard::Event::KeyReleased { .. }) if cfg!(target_os = "linux") => { + Some(Update::Release) + } _ => None, } } diff --git a/widget/src/text_input.rs b/widget/src/text_input.rs index e3a887bc5d..089b364986 100644 --- a/widget/src/text_input.rs +++ b/widget/src/text_input.rs @@ -763,10 +763,97 @@ where shell.capture_event(); } } + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle)) + if cfg!(target_os = "linux") => + { + let Some(on_input) = &self.on_input else { + return; + }; + + let state = state::(tree); + + let click_position = cursor.position_over(layout.bounds()); + + state.is_focused = if click_position.is_some() { + let now = Instant::now(); + + Some(Focus { + updated_at: now, + now, + is_window_focused: true, + }) + } else { + None + }; + + if let Some(cursor_position) = click_position { + let text_layout = layout.children().next().unwrap(); + + let target = { + let text_bounds = text_layout.bounds(); + + let alignment_offset = alignment_offset( + text_bounds.width, + state.value.raw().min_width(), + self.alignment, + ); + + cursor_position.x - text_bounds.x - alignment_offset + }; + + let position = if target > 0.0 { + let value = if self.is_secure { + self.value.secure() + } else { + self.value.clone() + }; + + find_cursor_position(text_layout.bounds(), &value, state, target) + } else { + None + } + .unwrap_or(0); + + state.cursor.move_to(position); + + let content = match &state.is_pasting { + Some(Paste::Pasting(content)) => content, + Some(Paste::Reading) => return, + None => { + shell.read_clipboard_primary(clipboard::Kind::Text); + state.is_pasting = Some(Paste::Reading); + return; + } + }; + + let mut editor = Editor::new(&mut self.value, &mut state.cursor); + editor.paste(content.clone()); + + let message = if let Some(paste) = &self.on_paste { + (paste)(editor.contents()) + } else { + (on_input)(editor.contents()) + }; + shell.publish(message); + shell.capture_event(); + + update_cache(state, &self.value); + } + } Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) | Event::Touch(touch::Event::FingerLifted { .. }) | Event::Touch(touch::Event::FingerLost { .. }) => { - state::(tree).is_dragging = None; + let state = state::(tree); + + state.is_dragging = None; + + if cfg!(target_os = "linux") + && let Some((start, end)) = state.cursor.selection(&self.value) + { + shell.write_clipboard_primary(clipboard::Content::Text( + self.value.select(start, end).to_string(), + )); + } } Event::Mouse(mouse::Event::CursorMoved { position }) | Event::Touch(touch::Event::FingerMoved { position, .. }) => { @@ -1080,6 +1167,14 @@ where focus.updated_at = Instant::now(); shell.request_redraw(); + + if cfg!(target_os = "linux") + && let Some((start, end)) = state.cursor.selection(&self.value) + { + shell.write_clipboard_primary(clipboard::Content::Text( + self.value.select(start, end).to_string(), + )); + } } shell.capture_event(); @@ -1112,6 +1207,14 @@ where focus.updated_at = Instant::now(); shell.request_redraw(); + + if cfg!(target_os = "linux") + && let Some((start, end)) = state.cursor.selection(&self.value) + { + shell.write_clipboard_primary(clipboard::Content::Text( + self.value.select(start, end).to_string(), + )); + } } shell.capture_event();