GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
Clipboard on macOS and in the browser
widget::clipboard stays one synchronous text pair (copy_to_clipboard /
read_from_clipboard) for every widget, now with a backend per platform:
- Wayland: wl-copy / wl-paste (xclip), moved verbatim.
- macOS: the general NSPasteboard's plain-text type.
- A page: a browser hands a page the clipboard only inside a paste event,
so the canvas lets Ctrl/Cmd+C, X and V keep their defaults
(dom::clipboard_key) and holds a Ctrl/Cmd+V from the app until its paste
event has handed over the text, or until a zero timer if none comes; a
release never overtakes the held press. A copy writes through
navigator.clipboard.writeText where the page has it (checked first:
calling into undefined throws through the wasm frames), and the
copy / cut event the key raises carries it too, which needs no secure
context. Before this a copy in a page panicked (std::thread::spawn).
scripts/web-probe/clipboard checks it in headless Chromium through the
demo's text box, each result read back from the system clipboard: copy,
paste, copy with writeText refused, and paste with the paste event
swallowed. All four pass. macOS is type-checked (check-mac) and has not
run. Linux: lib suite as before (the 2 known failures); check-wasm ok.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WjL3pejMNY95NHv9BcmXaZ
CLAUDE.md | 24 ++++-
Cargo.toml | 5 +-
scripts/web-probe/clipboard | 21 +++++
scripts/web-probe/clipboard.mjs | 72 +++++++++++++++
src/backend/dom.rs | 36 ++++++++
src/web/shell.rs | 104 +++++++++++++++++++--
src/widget/core.rs | 198 +++++++++++++++++++++++++++++++---------
7 files changed, 404 insertions(+), 56 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index a68c2c1..23222a7 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -117,8 +117,23 @@ in its own config.
first face with the glyph.
- **`web::capture().await`**: the next frame, read back from the GPU. Headless Chromium
composites in software and leaves a WebGPU canvas out of its screenshots and `toDataURL`.
-
-Not there yet: the clipboard, IME composition, drag and drop, file dialogs, and an app whose
+- **The clipboard** (since 2026-10-05): `widget::clipboard` is one synchronous text pair
+ (`copy_to_clipboard` / `read_from_clipboard`, every widget's copy, cut and paste) with a
+ backend per platform — `wl-copy` / `wl-paste` (`xclip`) on Wayland, `NSPasteboard` on
+ macOS, and in a page the page's own clipboard events, because a page may read the
+ clipboard only inside a `paste` event. So the canvas lets ⌘/Ctrl+C, X and V keep their
+ defaults (`dom::clipboard_key`), and a ⌘/Ctrl+V is HELD from the app until its `paste`
+ event has handed over the text (then a read answers it) — or, if none comes, until a
+ zero timer, when a read answers the page's own last copy or paste; a release never
+ overtakes it. A copy writes through `navigator.clipboard.writeText` where the page has it
+ (a secure context; checked first, since calling into undefined throws through the wasm
+ frames), and the `copy` / `cut` event the key raises carries it too, which needs no
+ secure context. Until then a copy in a page panicked (`std::thread::spawn`).
+ `scripts/web-probe/clipboard` is the check: copy, paste, copy with `writeText` refused,
+ and paste with the `paste` event swallowed, each read back from the system clipboard —
+ all four pass in headless Chromium (2026-10-05).
+
+Not there yet: IME composition, drag and drop, file dialogs, and an app whose
text is not the display list's (`display_list_text` false — it stages its own through the
native-only `stage_renderer`, so draws no text here).
@@ -260,8 +275,11 @@ change. What the shell does, in AppKit's terms (module doc in `src/mac/mod.rs`):
compositor's close does; the run loop is stopped once it has.
- **Fonts**: the system set is always loaded on macOS (`build_font_system`) — it is what
cosmic-text's macOS fallback list names.
+- **Clipboard**: the general `NSPasteboard`'s plain-text type, behind the same
+ `widget::clipboard` pair every widget uses; ⌘C / ⌘X / ⌘V reach the widgets as Ctrl+C /
+ X / V do on Linux, since Command reads as `ctrl`.
-Not there yet: the clipboard, IME (`NSTextInputClient`), drag and drop, the context menu
+Not there yet: IME (`NSTextInputClient`), drag and drop, the context menu
in a popup window (it is drawn in the window, as on a layer surface), blur behind the window,
a menu bar beyond Quit. **None of it has run**: this is Linux, where an Apple target can be
type-checked but not linked. `scripts/check-mac` type-checks the library, the demo, every
diff --git a/Cargo.toml b/Cargo.toml
index 6de8039..ec7c42c 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -77,7 +77,7 @@ xkeysym = "0.2"
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6"
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSString", "NSGeometry", "NSNotification", "NSObject", "NSDate", "NSDictionary"] }
-objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSWindow", "NSView", "NSResponder", "NSEvent", "NSScreen", "NSCursor", "NSMenu", "NSMenuItem", "NSColor", "NSTrackingArea", "NSGraphics", "NSGraphicsContext", "NSRunningApplication", "objc2-quartz-core", "objc2-core-foundation"] }
+objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSWindow", "NSView", "NSResponder", "NSEvent", "NSScreen", "NSCursor", "NSMenu", "NSMenuItem", "NSPasteboard", "NSColor", "NSTrackingArea", "NSGraphics", "NSGraphicsContext", "NSRunningApplication", "objc2-quartz-core", "objc2-core-foundation"] }
objc2-quartz-core = { version = "0.3", default-features = false, features = ["std", "CALayer", "CAMetalLayer", "objc2-core-foundation"] }
dispatch2 = "0.3"
@@ -91,6 +91,9 @@ web-sys = { version = "0.3", features = [
"console",
"Window",
"Navigator",
+ "Clipboard",
+ "ClipboardEvent",
+ "DataTransfer",
"Document",
"Element",
"HtmlCanvasElement",
diff --git a/scripts/web-probe/clipboard b/scripts/web-probe/clipboard
new file mode 100755
index 0000000..8a55249
--- /dev/null
+++ b/scripts/web-probe/clipboard
@@ -0,0 +1,21 @@
+#!/usr/bin/env bash
+# web-probe/clipboard — the clipboard through the browser shell: the
+# reference app (src/main.rs) built for the page as `demo` builds it, and
+# clipboard.mjs's copy / paste round trips through its text box, in headless
+# Chromium with the clipboard permissions granted. Prints one line a check
+# and fails if any does. Needs what `run` needs.
+set -euo pipefail
+here=$(cd "$(dirname "$0")" && pwd)
+fonts=${DEMO_FONTS_DIR:-/usr/share/fonts}
+cd "$here/../.."
+
+cargo build --release --target wasm32-unknown-unknown --example demo_web
+site=$(mktemp -d)
+trap 'rm -rf "$site"' EXIT
+wasm-bindgen --target web --no-typescript --out-dir "$site" \
+ target/wasm32-unknown-unknown/release/examples/demo_web.wasm
+cp "$here/demo.html" "$site/demo.html"
+ln -s "$fonts" "$site/fonts"
+(cd "$fonts" && find . \( -type f -o -type l \) \( -iname '*.ttf' -o -iname '*.otf' -o -iname '*.ttc' -o -iname '*.otc' \) \
+ | sed 's|^\./||' | DEMO_FAMILIES="${DEMO_FAMILIES:-}" python3 -c 'import json,os,sys; print(json.dumps({"files": sys.stdin.read().split(), "families": os.environ["DEMO_FAMILIES"]}))') > "$site/fonts.json"
+node "$here/clipboard.mjs" "$site"
diff --git a/scripts/web-probe/clipboard.mjs b/scripts/web-probe/clipboard.mjs
new file mode 100644
index 0000000..3605e48
--- /dev/null
+++ b/scripts/web-probe/clipboard.mjs
@@ -0,0 +1,72 @@
+// clipboard.mjs <site-dir>: the clipboard through the browser shell, in the
+// demo's text box (src/main.rs on demo_web). Four checks, each read back
+// from the system clipboard:
+// 1. copy — Ctrl+A, Ctrl+C — puts the box's text there;
+// 2. paste — Ctrl+V with other text there — takes it (copied back out);
+// 3. copy with the page's writeText refused: the `copy` event alone
+// carries it (what an insecure context has);
+// 4. paste with the `paste` event swallowed: the held key goes through on
+// its timer and pastes the page's own last copy.
+import { open } from './browser.mjs';
+
+const [root] = process.argv.slice(2);
+if (!root) { console.error('usage: clipboard.mjs <site-dir>'); process.exit(2); }
+let ok = true;
+const check = (what, got, want) => {
+ const pass = got === want;
+ ok &&= pass;
+ console.log(`${pass ? 'ok ' : 'FAIL'} ${what}: ${JSON.stringify(got)}${pass ? '' : ` (want ${JSON.stringify(want)})`}`);
+};
+
+async function session(body) {
+ const { page, logs, close } = await open(root, 'demo.html', 1280, 800);
+ await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
+ const m = page.mouse, k = page.keyboard, wait = ms => page.waitForTimeout(ms);
+ const t = {
+ page, wait,
+ read: () => page.evaluate(() => navigator.clipboard.readText()),
+ write: s => page.evaluate(s => (window.realWrite || (t => navigator.clipboard.writeText(t)))(s), s),
+ chord: async key => { await k.down('Control'); await k.press(key); await k.up('Control'); await wait(400); },
+ focusBox: async () => { await m.move(640, 168); await m.down(); await m.up(); await wait(300); },
+ type: async s => { await k.type(s); await wait(300); },
+ key: async s => { await k.press(s); await wait(100); },
+ };
+ try {
+ await m.move(1279, 799);
+ await page.waitForFunction(() => window.demoReady === true, null, { timeout: 120000 });
+ await wait(3000);
+ await body(t);
+ } catch (e) { ok = false; logs.push('[clipboard] ' + String(e)); }
+ for (const l of logs) if (!l.includes('WebGPU is experimental')) console.log(l);
+ await close();
+}
+
+await session(async t => {
+ await t.write('before');
+ await t.focusBox(); await t.type('hello web');
+ await t.chord('a'); await t.chord('c');
+ check('copy', await t.read(), 'hello web');
+ await t.write('from the system');
+ await t.chord('a'); await t.chord('v');
+ await t.chord('a'); await t.chord('c');
+ check('paste', await t.read(), 'from the system');
+});
+
+await session(async t => {
+ await t.write('before');
+ await t.page.evaluate(() => {
+ const real = Clipboard.prototype.writeText;
+ window.realWrite = s => real.call(navigator.clipboard, s);
+ Clipboard.prototype.writeText = () => Promise.reject(new Error('refused'));
+ });
+ await t.focusBox(); await t.type('via the event');
+ await t.chord('a'); await t.chord('c');
+ check('copy, the copy event alone', await t.read(), 'via the event');
+ await t.page.evaluate(() => window.addEventListener('paste', e => e.stopImmediatePropagation(), true));
+ await t.write('system text the page never sees');
+ await t.focusBox(); await t.key('End'); await t.chord('v');
+ await t.chord('a'); await t.chord('c');
+ check('paste, no paste event', await t.read(), 'via the eventvia the event');
+});
+
+process.exit(ok ? 0 : 1);
diff --git a/src/backend/dom.rs b/src/backend/dom.rs
index e0a3d10..9c50048 100644
--- a/src/backend/dom.rs
+++ b/src/backend/dom.rs
@@ -63,6 +63,30 @@ pub fn map_key(key: &str, accel: bool) -> Option<(Key, Option<String>)> {
}
}
+/// A clipboard shortcut, as the page's clipboard events are raised by it.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ClipKey {
+ Copy,
+ Cut,
+ Paste,
+}
+
+/// Whether a `KeyboardEvent.key` with Ctrl (or ⌘) held, and Alt not, is a
+/// clipboard shortcut: the browser shell lets its default through, since
+/// that default is the `copy`, `cut` or `paste` event — and a page gets the
+/// clipboard's text only inside a `paste` event.
+pub fn clipboard_key(key: &str, accel: bool, alt: bool) -> Option<ClipKey> {
+ if !accel || alt {
+ return None;
+ }
+ match key {
+ "c" | "C" => Some(ClipKey::Copy),
+ "x" | "X" => Some(ClipKey::Cut),
+ "v" | "V" => Some(ClipKey::Paste),
+ _ => None,
+ }
+}
+
/// A `WheelEvent`'s deltas as a scroll frame in the units the driver takes
/// (a `wl_pointer` axis frame's: a finger in px, a wheel notch as a discrete
/// step plus ten units, positive down and right — the DOM's signs too).
@@ -132,6 +156,18 @@ mod tests {
assert_eq!(map_key("é", false), Some((Key::Character("é".into()), Some("é".into()))));
}
+ #[test]
+ fn only_an_accelerated_c_x_or_v_is_a_clipboard_shortcut() {
+ assert_eq!(clipboard_key("c", true, false), Some(ClipKey::Copy));
+ assert_eq!(clipboard_key("X", true, false), Some(ClipKey::Cut));
+ assert_eq!(clipboard_key("v", true, false), Some(ClipKey::Paste));
+ // Ctrl+Shift+V, a paste too.
+ assert_eq!(clipboard_key("V", true, false), Some(ClipKey::Paste));
+ assert_eq!(clipboard_key("v", false, false), None);
+ assert_eq!(clipboard_key("v", true, true), None);
+ assert_eq!(clipboard_key("z", true, false), None);
+ }
+
#[test]
fn notches_are_discrete_and_a_trackpad_is_a_finger() {
// Chromium's mouse wheel: 100 px a notch, three notches up.
diff --git a/src/web/shell.rs b/src/web/shell.rs
index 7397f6a..3c358a9 100644
--- a/src/web/shell.rs
+++ b/src/web/shell.rs
@@ -17,6 +17,15 @@
//! - **Frames out.** [`build_frame`] at the canvas's CSS size and the page's
//! `devicePixelRatio`, drawn by the [`WebRenderer`].
//!
+//! **The clipboard** comes through the page's clipboard events, since a page
+//! may read the clipboard only inside a `paste` event: a ⌘/Ctrl+V is held
+//! back from the app until its `paste` event has handed over the text (or,
+//! if none comes, until the task after), so the widget that pastes on it
+//! reads that text (`widget::clipboard`). A ⌘/Ctrl+C or X reaches the app at
+//! once, and the `copy` / `cut` event it raises carries whatever the app
+//! copied. The three keys' defaults are the only ones the canvas lets the
+//! page have.
+//!
//! The page owns the canvas's place in it; [`Sizing`] says who owns its
//! size. There is no context-menu popup surface here: the menu is drawn in
//! the canvas and kept inside it (`context_menu::constrain_to`), as on a
@@ -30,15 +39,15 @@ use std::time::Duration;
use cursor_icon::CursorIcon;
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
-use web_sys::{AddEventListenerOptions, FocusEvent, HtmlCanvasElement, KeyboardEvent, PointerEvent, WheelEvent};
+use web_sys::{AddEventListenerOptions, ClipboardEvent, FocusEvent, HtmlCanvasElement, KeyboardEvent, PointerEvent, WheelEvent};
use super::renderer::{Capture, WebRenderer};
use crate::backend::app::{set_wake, AppSender, Application, LogicalPosition, LogicalSize};
-use crate::backend::dom::{map_key, wheel_frame};
+use crate::backend::dom::{clipboard_key, map_key, wheel_frame, ClipKey};
use crate::backend::driver::{Driver, Modifiers, PressSite, ScrollFrame, ScrollSource, Turn};
use crate::backend::frame::build_frame;
use crate::backend::shell::{Pacer, Shell, Step, ACTIVE_DISPATCH};
-use crate::widget::{context_menu, ElementState, MouseButton, TextItem};
+use crate::widget::{clipboard, context_menu, ElementState, Key, MouseButton, TextItem};
/// Who decides the canvas's size.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -139,6 +148,8 @@ pub async fn run<A: Application>(canvas: HtmlCanvasElement, fonts: Fonts, sizing
timer_cb: RefCell::new(None),
finger_end_cb: RefCell::new(None),
finger_timer: Cell::new(None),
+ held_paste: RefCell::new(None),
+ paste_cb: RefCell::new(None),
});
lp.shell.borrow_mut().measure();
lp.shell.borrow_mut().just_configured = true;
@@ -395,6 +406,18 @@ struct Loop<A: Application> {
finger_end_cb: RefCell<Option<Closure<dyn FnMut()>>>,
/// The lift timer the last finger frame set; the next frame cancels it.
finger_timer: Cell<Option<i32>>,
+ /// A ⌘/Ctrl+V held back until its `paste` event, and the timer that
+ /// lets it through if no event comes.
+ held_paste: RefCell<Option<HeldKey>>,
+ paste_cb: RefCell<Option<Closure<dyn FnMut()>>>,
+}
+
+/// A key press, kept to dispatch later: the key, its text and the
+/// modifiers it came with (ctrl, shift, alt, meta).
+struct HeldKey {
+ key: Key,
+ text: Option<String>,
+ mods: (bool, bool, bool, bool),
}
/// A browser reports no lift for a two-finger scroll: this long without a
@@ -474,6 +497,8 @@ impl<A: Application> Loop<A> {
}));
let l = lp.clone();
*lp.finger_end_cb.borrow_mut() = Some(Closure::new(move || l.finger_lift()));
+ let l = lp.clone();
+ *lp.paste_cb.borrow_mut() = Some(Closure::new(move || l.release_paste()));
let l = Rc::downgrade(lp);
set_wake(Some(Box::new(move || {
if let Some(l) = l.upgrade() {
@@ -568,6 +593,29 @@ impl<A: Application> Loop<A> {
})
})?;
}
+ // The clipboard's events, raised by the three keys `key` lets
+ // through. They go to the focused element or the body, so they are
+ // heard on the document.
+ if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
+ let doc: &web_sys::EventTarget = doc.as_ref();
+ let l = lp.clone();
+ listen(doc, "paste", true, move |e: ClipboardEvent| {
+ if let Some(text) = e.clipboard_data().and_then(|d| d.get_data("text/plain").ok()) {
+ clipboard::pasted(text);
+ }
+ e.prevent_default();
+ l.release_paste();
+ })?;
+ for name in ["copy", "cut"] {
+ listen(doc, name, true, move |e: ClipboardEvent| {
+ if let (Some(text), Some(data)) = (clipboard::take_copied(), e.clipboard_data()) {
+ if data.set_data("text/plain", &text).is_ok() {
+ e.prevent_default();
+ }
+ }
+ })?;
+ }
+ }
// The page laying the canvas out anew is a configure; the turn it
// wakes measures the box (`sync`).
let l = lp.clone();
@@ -580,8 +628,11 @@ impl<A: Application> Loop<A> {
}
fn key(&self, e: &KeyboardEvent, state: ElementState) {
- let Some((key, text)) = map_key(&e.key(), e.ctrl_key() || e.meta_key()) else { return };
- if !passes_to_page(e) {
+ let accel = e.ctrl_key() || e.meta_key();
+ let Some((key, text)) = map_key(&e.key(), accel) else { return };
+ let clip = clipboard_key(&e.key(), accel, e.alt_key());
+ // A clipboard key's default is its clipboard event: the page keeps it.
+ if clip.is_none() && !passes_to_page(e) {
e.prevent_default();
}
// The driver repeats a held key itself, at the toolkit's own rate
@@ -590,14 +641,53 @@ impl<A: Application> Loop<A> {
if e.repeat() {
return;
}
+ let mods = (e.ctrl_key(), e.shift_key(), e.alt_key(), e.meta_key());
+ if state == ElementState::Pressed {
+ match clip {
+ // Held until the `paste` event has handed over the text: it
+ // is raised after this listener returns, in the same task,
+ // so a zero timer is the fallback for a browser that raises
+ // none (the clipboard then reads what it read before).
+ Some(ClipKey::Paste) => {
+ self.release_paste();
+ *self.held_paste.borrow_mut() = Some(HeldKey { key, text, mods });
+ if let Some(cb) = self.paste_cb.borrow().as_ref() {
+ let _ = window().set_timeout_with_callback_and_timeout_and_arguments_0(cb.as_ref().unchecked_ref(), 0);
+ }
+ return;
+ }
+ // A copy left over from a menu click is not this key's.
+ Some(ClipKey::Copy | ClipKey::Cut) => {
+ clipboard::take_copied();
+ }
+ None => {}
+ }
+ } else {
+ // A release never overtakes the press it ends.
+ self.release_paste();
+ }
+ self.dispatch_key(HeldKey { key, text, mods }, state);
+ }
+
+ fn dispatch_key(&self, k: HeldKey, state: ElementState) {
+ let (ctrl, shift, alt, meta) = k.mods;
self.event(|s| {
- let mods = s.mods_from(e.ctrl_key(), e.shift_key(), e.alt_key(), e.meta_key());
+ let mods = s.mods_from(ctrl, shift, alt, meta);
s.sync_mods(mods);
let (driver, t) = s.turn();
- driver.key(t, key, text, state);
+ driver.key(t, k.key, k.text, state);
});
}
+ /// Hand a held ⌘/Ctrl+V to the app, if one is held: its `paste` event
+ /// has come, or will not.
+ fn release_paste(&self) {
+ let held = self.held_paste.borrow_mut().take();
+ if let Some(k) = held {
+ self.dispatch_key(k, ElementState::Pressed);
+ }
+ }
+
fn arm_finger_lift(&self) {
let win = window();
if let Some(t) = self.finger_timer.take() {
diff --git a/src/widget/core.rs b/src/widget/core.rs
index 3f5c9e9..a2d998e 100644
--- a/src/widget/core.rs
+++ b/src/widget/core.rs
@@ -269,65 +269,173 @@ pub mod hover_animation {
}
}
+/// The system clipboard, as text: what every widget's copy, cut and paste go
+/// through. One synchronous pair, with a backend per platform:
+///
+/// - **Wayland** (Linux and the other non-Apple unixes): `wl-copy` /
+/// `wl-paste`, `xclip` where those are missing.
+/// - **macOS**: the general `NSPasteboard`, plain-text type.
+/// - **A page**: a browser hands a page the clipboard only inside a `paste`
+/// event, so a read is the text of the last one the browser shell saw (it
+/// holds a ⌘/Ctrl+V back until the event has come; `web::shell`), or what
+/// the page itself copied last. A copy writes through the async Clipboard
+/// API where the page is a secure context, and the shell also answers the
+/// `copy` / `cut` event a ⌘/Ctrl+C or X raises with it, which needs none.
+/// Before this a copy in a page panicked (`std::thread::spawn`).
pub mod clipboard {
+ /// Put `text` on the clipboard.
pub fn copy_to_clipboard(text: &str) {
- let text = text.to_string();
- std::thread::spawn(move || {
- if let Ok(mut child) = std::process::Command::new("wl-copy")
- .stdin(std::process::Stdio::piped())
- .spawn()
+ imp::copy(text);
+ }
+
+ /// The clipboard's text, if it has any.
+ pub fn read_from_clipboard() -> Option<String> {
+ imp::read()
+ }
+
+ #[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
+ mod imp {
+ pub fn copy(text: &str) {
+ let text = text.to_string();
+ std::thread::spawn(move || {
+ if let Ok(mut child) = std::process::Command::new("wl-copy")
+ .stdin(std::process::Stdio::piped())
+ .spawn()
+ {
+ if let Some(mut stdin) = child.stdin.take() {
+ use std::io::Write;
+ let _ = stdin.write_all(text.as_bytes());
+ }
+ let _ = child.wait();
+ } else if let Ok(mut child) = std::process::Command::new("xclip")
+ .arg("-selection")
+ .arg("clipboard")
+ .stdin(std::process::Stdio::piped())
+ .spawn()
+ {
+ if let Some(mut stdin) = child.stdin.take() {
+ use std::io::Write;
+ let _ = stdin.write_all(text.as_bytes());
+ }
+ let _ = child.wait();
+ }
+ });
+ }
+
+ pub fn read() -> Option<String> {
+ match std::process::Command::new("wl-paste")
+ .arg("-n")
+ .output()
{
- if let Some(mut stdin) = child.stdin.take() {
- use std::io::Write;
- let _ = stdin.write_all(text.as_bytes());
+ Ok(output) => {
+ if output.status.success() {
+ if let Ok(text) = String::from_utf8(output.stdout) {
+ return Some(text);
+ }
+ }
}
- let _ = child.wait();
- } else if let Ok(mut child) = std::process::Command::new("xclip")
+ Err(_) => {}
+ }
+ match std::process::Command::new("xclip")
.arg("-selection")
.arg("clipboard")
- .stdin(std::process::Stdio::piped())
- .spawn()
+ .arg("-o")
+ .output()
{
- if let Some(mut stdin) = child.stdin.take() {
- use std::io::Write;
- let _ = stdin.write_all(text.as_bytes());
+ Ok(output) => {
+ if output.status.success() {
+ if let Ok(text) = String::from_utf8(output.stdout) {
+ return Some(text);
+ }
+ }
}
- let _ = child.wait();
+ Err(_) => {}
}
- });
+ None
+ }
}
- pub fn read_from_clipboard() -> Option<String> {
- match std::process::Command::new("wl-paste")
- .arg("-n")
- .output()
- {
- Ok(output) => {
- if output.status.success() {
- if let Ok(text) = String::from_utf8(output.stdout) {
- return Some(text);
- }
- }
- }
- Err(_) => {}
- }
- match std::process::Command::new("xclip")
- .arg("-selection")
- .arg("clipboard")
- .arg("-o")
- .output()
- {
- Ok(output) => {
- if output.status.success() {
- if let Ok(text) = String::from_utf8(output.stdout) {
- return Some(text);
- }
- }
+ #[cfg(target_os = "macos")]
+ mod imp {
+ use objc2_app_kit::{NSPasteboard, NSPasteboardTypeString};
+ use objc2_foundation::NSString;
+
+ pub fn copy(text: &str) {
+ let board = NSPasteboard::generalPasteboard();
+ board.clearContents();
+ // SAFETY: an extern static AppKit defines.
+ let ty = unsafe { NSPasteboardTypeString };
+ board.setString_forType(&NSString::from_str(text), ty);
+ }
+
+ pub fn read() -> Option<String> {
+ // SAFETY: as above.
+ let ty = unsafe { NSPasteboardTypeString };
+ NSPasteboard::generalPasteboard().stringForType(ty).map(|s| s.to_string())
+ }
+ }
+
+ #[cfg(target_arch = "wasm32")]
+ mod imp {
+ use std::cell::RefCell;
+
+ #[derive(Default)]
+ struct Page {
+ /// What a read answers: the last paste the shell saw, or the
+ /// page's own last copy, whichever came later.
+ text: Option<String>,
+ /// A copy not yet handed to a `copy` / `cut` event.
+ copied: Option<String>,
+ }
+
+ thread_local! {
+ static PAGE: RefCell<Page> = RefCell::new(Page::default());
+ }
+
+ pub fn copy(text: &str) {
+ PAGE.with(|p| {
+ let mut p = p.borrow_mut();
+ p.text = Some(text.to_string());
+ p.copied = Some(text.to_string());
+ });
+ write_text(text);
+ }
+
+ pub fn read() -> Option<String> {
+ PAGE.with(|p| p.borrow().text.clone())
+ }
+
+ /// Write through the async Clipboard API, if the page has one: it is
+ /// undefined outside a secure context, and calling into undefined
+ /// would throw through the wasm frames. Its promise is awaited only
+ /// to keep a refusal (no user activation, no focus) off the console
+ /// as an unhandled rejection; the `copy` event path covers it.
+ fn write_text(text: &str) {
+ let Some(nav) = web_sys::window().map(|w| w.navigator()) else { return };
+ let has = js_sys::Reflect::get(&nav, &"clipboard".into()).is_ok_and(|c| !c.is_undefined() && !c.is_null());
+ if !has {
+ return;
}
- Err(_) => {}
+ let promise = nav.clipboard().write_text(text);
+ wasm_bindgen_futures::spawn_local(async move {
+ let _ = wasm_bindgen_futures::JsFuture::from(promise).await;
+ });
+ }
+
+ /// The copy a `copy` / `cut` event should carry, taken.
+ pub(crate) fn take_copied() -> Option<String> {
+ PAGE.with(|p| p.borrow_mut().copied.take())
+ }
+
+ /// A `paste` event's text: what reads answer from now on.
+ pub(crate) fn pasted(text: String) {
+ PAGE.with(|p| p.borrow_mut().text = Some(text));
}
- None
}
+
+ /// The browser shell's half: the clipboard events it answers.
+ #[cfg(target_arch = "wasm32")]
+ pub(crate) use imp::{pasted, take_copied};
}
pub mod context_menu {