Add rudimentary clipboard support (#1237)

This only supports copy-pasting strings, not rich text. And the way
Ctrl+V is detected is somewhat dubious.

But it does effectively add clipboard support to Masonry apps.

Ideally, on the long term, we may want to be able to programmatically
request clipboard contents; I'm not sure how to implement that cleanly
given RenderRoot's architecture, without making masonry_core depend
directly on a clipboard-handling library.

---------

Co-authored-by: Daniel McNab <36049421+DJMcNab@users.noreply.github.com>
This commit is contained in:
Olivier FAURE 2025-07-28 15:30:26 +02:00 committed by GitHub
parent dd45044538
commit 32503ec1ea
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 140 additions and 27 deletions

51
Cargo.lock generated
View File

@ -673,6 +673,15 @@ dependencies = [
"windows-link",
]
[[package]]
name = "clipboard-win"
version = "5.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4"
dependencies = [
"error-code",
]
[[package]]
name = "codespan-reporting"
version = "0.11.1"
@ -741,6 +750,20 @@ dependencies = [
"web-sys",
]
[[package]]
name = "copypasta"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e6811e17f81fe246ef2bc553f76b6ee6ab41a694845df1d37e52a92b7bbd38a"
dependencies = [
"clipboard-win",
"objc2 0.5.2",
"objc2-app-kit",
"objc2-foundation 0.2.2",
"smithay-clipboard",
"x11-clipboard",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
@ -991,6 +1014,12 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "error-code"
version = "3.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59"
[[package]]
name = "euclid"
version = "0.22.11"
@ -2172,6 +2201,7 @@ name = "masonry_winit"
version = "0.3.0"
dependencies = [
"accesskit_winit",
"copypasta",
"image",
"masonry",
"masonry_core",
@ -3772,6 +3802,17 @@ dependencies = [
"xkeysym",
]
[[package]]
name = "smithay-clipboard"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc8216eec463674a0e90f29e0ae41a4db573ec5b56b1c6c1c71615d249b6d846"
dependencies = [
"libc",
"smithay-client-toolkit",
"wayland-backend",
]
[[package]]
name = "smol_str"
version = "0.2.2"
@ -5584,6 +5625,16 @@ dependencies = [
"tap",
]
[[package]]
name = "x11-clipboard"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "662d74b3d77e396b8e5beb00b9cad6a9eccf40b2ef68cc858784b14c41d535a3"
dependencies = [
"libc",
"x11rb",
]
[[package]]
name = "x11-dl"
version = "2.21.0"

View File

@ -489,35 +489,23 @@ impl<const EDITABLE: bool> Widget for TextArea<EDITABLE> {
Key::Character(x)
if EDITABLE && action_mod && x.as_str().eq_ignore_ascii_case("x") =>
{
edited = true;
// TODO: use clipboard_rs::{Clipboard, ClipboardContext};
// if let Some(text) = self.editor.selected_text() {
// let cb = ClipboardContext::new().unwrap();
// cb.set_text(text.to_owned()).ok();
// self.editor.drive(fcx, lcx, |drv| drv.delete_selection());
// }
// edited = true;
if let Some(text) = self.editor.selected_text()
&& !text.is_empty()
{
let text = text.to_string();
self.editor.driver(fctx, lctx).delete_selection();
edited = true;
ctx.set_clipboard(text);
}
}
// Copy
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
Key::Character(c) if action_mod && c.as_str().eq_ignore_ascii_case("c") => {
// TODO: use clipboard_rs::{Clipboard, ClipboardContext};
// if let Some(text) = self.editor.selected_text() {
// let cb = ClipboardContext::new().unwrap();
// cb.set_text(text.to_owned()).ok();
// }
}
// Paste
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
Key::Character(v)
if EDITABLE && action_mod && v.as_str().eq_ignore_ascii_case("v") =>
{
edited = true;
// TODO: use clipboard_rs::{Clipboard, ClipboardContext};
// let cb = ClipboardContext::new().unwrap();
// let text = cb.get_text().unwrap_or_default();
// self.editor.drive(fcx, lcx, |drv| drv.insert_or_replace_selection(&text));
// edited = true;
if let Some(text) = self.editor.selected_text()
&& !text.is_empty()
{
ctx.set_clipboard(text.to_string());
}
}
Key::Character(a) if action_mod && a.as_str().eq_ignore_ascii_case("a") => {
let mut drv = self.editor.driver(fctx, lctx);
@ -672,8 +660,10 @@ impl<const EDITABLE: bool> Widget for TextArea<EDITABLE> {
self.rendered_generation = new_generation;
}
}
// TODO: Set our highlighting colour to a lighter blue as window unfocused
TextEvent::WindowFocusChange(_) => {}
TextEvent::Ime(e) => {
// TODO: Handle the cursor movement things from https://github.com/rust-windowing/winit/pull/3824
let (fctx, lctx) = ctx.text_contexts();
@ -714,6 +704,23 @@ impl<const EDITABLE: bool> Widget for TextArea<EDITABLE> {
self.rendered_generation = new_generation;
}
}
TextEvent::ClipboardPaste(text) => {
if EDITABLE {
let (fctx, lctx) = ctx.text_contexts();
self.editor
.driver(fctx, lctx)
.insert_or_replace_selection(text);
// TODO - Factor out with other branches
let new_generation = self.editor.generation();
if new_generation != self.rendered_generation {
ctx.submit_action(TextAction::Changed(self.text().into_iter().collect()));
ctx.request_layout();
self.rendered_generation = new_generation;
}
}
}
}
}

View File

@ -208,6 +208,8 @@ pub enum RenderRootSignal {
EndIme,
/// The IME area has been moved.
ImeMoved(LogicalPosition<f64>, LogicalSize<f64>),
/// A user interaction has sent something to the clipboard.
ClipboardStore(String),
/// The window needs to be redrawn.
RequestRedraw,
/// The window should be redrawn for an animation frame. Currently this isn't really different from `RequestRedraw`.

View File

@ -1186,6 +1186,16 @@ impl_context_method!(
self.widget_state.ime_area = None;
}
/// Set the contents of the platform clipboard.
///
/// For example, text widgets should call this for "cut" and "copy" user interactions.
/// Note that we currently don't support the "Primary" selection buffer on X11/Wayland.
pub fn set_clipboard(&mut self, contents: String) {
trace!("set_clipboard");
self.global_state
.emit_signal(RenderRootSignal::ClipboardStore(contents));
}
/// Start a window drag.
///
/// Moves the window with the left mouse button until the button is released.

View File

@ -33,7 +33,9 @@ pub enum TextEvent {
Ime(Ime),
/// The window took or lost focus.
WindowFocusChange(bool),
// TODO - Add ClipboardPaste variant.
// TODO - Handle rich text copy-pasting
/// The user pasted content in.
ClipboardPaste(String),
}
/// An accessibility event.
@ -206,6 +208,7 @@ impl TextEvent {
Self::Ime(Ime::Preedit(s, _)) if s.is_empty() => "Ime::Preedit(\"\")",
Self::Ime(Ime::Preedit(_, _)) => "Ime::Preedit(\"...\")",
Self::WindowFocusChange(_) => "WindowFocusChange",
Self::ClipboardPaste(_) => "ClipboardPaste",
}
}
}

View File

@ -139,6 +139,7 @@ pub struct TestHarness<W: Widget> {
action_queue: VecDeque<(ErasedAction, WidgetId)>,
has_ime_session: bool,
ime_rect: (LogicalPosition<f64>, LogicalSize<f64>),
clipboard: String,
title: String,
_marker: PhantomData<W>,
}
@ -305,6 +306,7 @@ impl<W: Widget> TestHarness<W> {
action_queue: VecDeque::new(),
has_ime_session: false,
ime_rect: Default::default(),
clipboard: String::new(),
title: String::new(),
_marker: PhantomData,
};
@ -359,6 +361,9 @@ impl<W: Widget> TestHarness<W> {
RenderRootSignal::ImeMoved(position, size) => {
self.ime_rect = (position, size);
}
RenderRootSignal::ClipboardStore(text) => {
self.clipboard = text;
}
RenderRootSignal::RequestRedraw => (),
RenderRootSignal::RequestAnimFrame => (),
RenderRootSignal::TakeFocus => (),
@ -795,6 +800,13 @@ impl<W: Widget> TestHarness<W> {
self.ime_rect
}
/// Returns the contents of the emulated clipboard.
///
/// This is an empty string by default.
pub fn clipboard_contents(&self) -> String {
self.clipboard.clone()
}
/// Return the size of the simulated window.
pub fn window_size(&self) -> PhysicalSize<u32> {
self.window_size

View File

@ -32,6 +32,7 @@ ui-events-winit.workspace = true
pollster = "0.4.0"
accesskit_winit.workspace = true
wgpu-profiler = { optional = true, version = "0.22.0", default-features = false }
copypasta = "0.10.2"
[dev-dependencies]
image = { workspace = true, features = ["png"] }

View File

@ -9,7 +9,9 @@ use std::sync::mpsc::Sender;
use std::sync::{Arc, mpsc};
use accesskit_winit::Adapter;
use copypasta::{ClipboardContext, ClipboardProvider};
use masonry_core::app::{RenderRoot, RenderRootOptions, RenderRootSignal, WindowSizePolicy};
use masonry_core::core::keyboard::{Key, KeyState};
use masonry_core::core::{
DefaultProperties, ErasedAction, NewWidget, TextEvent, Widget, WidgetId, WindowEvent,
};
@ -127,6 +129,8 @@ pub struct MasonryState<'a> {
surfaces: HashMap<HandleId, RenderSurface<'a>>,
windows: HashMap<HandleId, Window>,
clipboard_cx: ClipboardContext,
// Is `Some` if the most recently displayed frame was an animation frame.
last_anim: Option<Instant>,
signal_receiver: mpsc::Receiver<(WindowId, RenderRootSignal)>,
@ -284,6 +288,8 @@ impl MasonryState<'_> {
windows: HashMap::new(),
surfaces: HashMap::new(),
clipboard_cx: ClipboardContext::new().unwrap(),
signal_sender,
default_properties: Arc::new(default_properties),
exit: false,
@ -591,7 +597,25 @@ impl MasonryState<'_> {
if let Some(wet) = window.event_reducer.reduce(&event) {
match wet {
WindowEventTranslation::Keyboard(k) => {
window.render_root.handle_text_event(TextEvent::Keyboard(k));
// TODO - Detect in Masonry code instead
let action_mod = if cfg!(target_os = "macos") {
k.modifiers.meta()
} else {
k.modifiers.ctrl()
};
if let Key::Character(c) = &k.key
&& c.as_str().eq_ignore_ascii_case("v")
&& action_mod
&& k.state == KeyState::Down
{
window
.render_root
.handle_text_event(TextEvent::ClipboardPaste(
self.clipboard_cx.get_contents().unwrap(),
));
} else {
window.render_root.handle_text_event(TextEvent::Keyboard(k));
}
}
WindowEventTranslation::Pointer(p) => {
window.render_root.handle_pointer_event(p);
@ -782,6 +806,9 @@ impl MasonryState<'_> {
RenderRootSignal::ImeMoved(position, size) => {
handle.set_ime_cursor_area(position, size);
}
RenderRootSignal::ClipboardStore(text) => {
self.clipboard_cx.set_contents(text).unwrap();
}
RenderRootSignal::RequestRedraw => {
need_redraw.insert(*handle_id);
}