compose_rust/clipboard.rs
1//! Plain text on the platform clipboard, written and read from application code.
2//!
3//! Copying what the user selected is the Renderer's own business and never comes through
4//! here. This is the other way in: a "Copy link" button whose text the application already
5//! holds. The Host asks with one command record on the batch the current call produces,
6//! and the Renderer puts the text on the clipboard through Compose. Nothing comes back: a
7//! write either lands or, where there is no clipboard to write to, is dropped quietly.
8//!
9//! A read goes the same way and does come back: [`read_text`] numbers the request, the
10//! Renderer reads the clipboard through Compose, and one `ClipboardText` event with that
11//! number carries the text, or says there was none, or that there was too much. Every read
12//! gets exactly one answer.
13//!
14//! Anything on the clipboard can be read by every other application and by clipboard
15//! history tools, so this is not the way to copy a password, a token or a private key.
16
17use std::cell::RefCell;
18use std::fmt;
19
20/// The longest text one call may put on or read from the clipboard, in UTF-8 bytes.
21///
22/// A link or a code snippet fits many times over. The limit keeps one call from carrying a
23/// document's worth of bytes through a batch whose job is the next frame.
24pub const MAX_TEXT_BYTES: usize = 64 * 1024;
25
26/// Why a clipboard write was refused before anything was sent, or a read brought no text.
27#[derive(Clone, Copy, Debug, Eq, PartialEq)]
28pub enum ClipboardError {
29 /// The text is longer than [`MAX_TEXT_BYTES`]. `len` is its length in bytes.
30 TooLong { len: usize },
31}
32
33impl fmt::Display for ClipboardError {
34 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35 match self {
36 ClipboardError::TooLong { len } => write!(
37 f,
38 "clipboard text is {len} bytes, longer than the {MAX_TEXT_BYTES} bytes one read or write may carry"
39 ),
40 }
41 }
42}
43
44impl std::error::Error for ClipboardError {}
45
46thread_local! {
47 static QUEUE: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
48}
49
50/// Puts `text` on the platform clipboard as plain text, replacing what was there.
51///
52/// Call it from an event handler or an effect, like the Host's other calls; a worker
53/// thread updates a signal and lets the UI side call it. The write rides out on the batch
54/// the current call produces. An empty string leaves the clipboard holding empty text.
55///
56/// Returns [`ClipboardError::TooLong`] and sends nothing when `text` is longer than
57/// [`MAX_TEXT_BYTES`]. Success means the request is on its way, not that a clipboard
58/// exists: a Renderer without one ignores it.
59pub fn set_text(text: &str) -> Result<(), ClipboardError> {
60 if text.len() > MAX_TEXT_BYTES {
61 return Err(ClipboardError::TooLong { len: text.len() });
62 }
63 QUEUE.with_borrow_mut(|queue| queue.push(text.to_owned()));
64 Ok(())
65}
66
67/// Hands every queued write to `emit`, oldest first, and empties the queue.
68pub(crate) fn drain(mut emit: impl FnMut(&str)) {
69 let mut taken = QUEUE.with_borrow_mut(std::mem::take);
70 if taken.is_empty() {
71 return;
72 }
73 for text in &taken {
74 emit(text);
75 }
76 taken.clear();
77 QUEUE.with_borrow_mut(|queue| {
78 if queue.is_empty() {
79 *queue = taken;
80 }
81 });
82}
83
84/// Forgets every queued write. Used when a new `Host` takes over the thread.
85#[doc(hidden)]
86pub fn reset_clipboard() {
87 QUEUE.with_borrow_mut(Vec::clear);
88 READS.with_borrow_mut(Vec::clear);
89 WAITING.with_borrow_mut(Vec::clear);
90}
91
92// ----- reading ------------------------------------------------------------------------------
93
94/// What the clipboard held when a read was answered, as the wire says it.
95///
96/// The words travel in the `ClipboardText` event's status field.
97pub mod read_status {
98 /// No plain text: the clipboard is empty, holds something else, is missing, or the
99 /// platform refused to hand it over.
100 pub const NONE: u32 = 0;
101 /// Plain text, carried whole in the event.
102 pub const TEXT: u32 = 1;
103 /// Plain text longer than [`super::MAX_TEXT_BYTES`]. None of it is carried; the event's
104 /// length field says how many bytes it was.
105 pub const TOO_LONG: u32 = 2;
106}
107
108/// The result of one read: the text, `None` when there was no plain text, or
109/// [`ClipboardError::TooLong`] when there was more than one read may carry. Over-long text
110/// is never cut short, so an application can refuse the paste instead of pasting part.
111pub type ReadResult = Result<Option<String>, ClipboardError>;
112
113type ReadCallback = Box<dyn FnOnce(ReadResult)>;
114
115thread_local! {
116 /// Read requests waiting for the next batch.
117 static READS: RefCell<Vec<u32>> = const { RefCell::new(Vec::new()) };
118 /// Callbacks waiting for their answer, by request number.
119 static WAITING: RefCell<Vec<(u32, ReadCallback)>> = const { RefCell::new(Vec::new()) };
120 /// The next request number. 0 is skipped so a zeroed record never looks like a request.
121 static NEXT_READ: std::cell::Cell<u32> = const { std::cell::Cell::new(1) };
122}
123
124fn next_read() -> u32 {
125 NEXT_READ.with(|next| {
126 let mut id = next.get();
127 if id == 0 {
128 id = 1;
129 }
130 next.set(id.wrapping_add(1));
131 id
132 })
133}
134
135/// Reads the plain text on the platform clipboard and calls `on_text` once with it.
136///
137/// Call it on the UI thread, from an event handler or an effect. The request rides out on
138/// the batch the current call produces and the answer comes back as an event, so
139/// `on_text` runs later, on the UI thread, and is then dropped. It is called exactly once
140/// for every read: with `Ok(None)` when the clipboard is empty, holds no text, does not
141/// exist, or the platform (a browser, say) refused, and with [`ClipboardError::TooLong`]
142/// when the text is longer than [`MAX_TEXT_BYTES`].
143///
144/// A paste shortcut is the usual caller:
145///
146/// ```ignore
147/// clipboard::read_text(move |result| match result {
148/// Ok(Some(text)) => terminal.paste(&text),
149/// Ok(None) => {}
150/// Err(error) => show_message(error.to_string()),
151/// });
152/// ```
153pub fn read_text(on_text: impl FnOnce(ReadResult) + 'static) {
154 let request = next_read();
155 WAITING.with_borrow_mut(|waiting| waiting.push((request, Box::new(on_text))));
156 READS.with_borrow_mut(|reads| reads.push(request));
157 // Inside a Host call the batch that call produces carries it. Anywhere else, a frame
158 // is what gets it out.
159 if !crate::boundary::in_host_call() {
160 crate::boundary::request_frame_from_worker();
161 }
162}
163
164/// [`read_text`] as a future, for a coroutine scope or a launched effect.
165///
166/// ```ignore
167/// scope.launch(async move {
168/// if let Ok(Some(text)) = clipboard::read_text_async().await {
169/// field.set(text);
170/// }
171/// });
172/// ```
173///
174/// The read is asked for on the first poll. Dropping the future before it resolves leaves
175/// the answer to be ignored when it arrives.
176pub fn read_text_async() -> impl std::future::Future<Output = ReadResult> {
177 ReadFuture { shared: None }
178}
179
180#[derive(Default)]
181struct ReadShared {
182 result: Option<ReadResult>,
183 waker: Option<std::task::Waker>,
184}
185
186struct ReadFuture {
187 shared: Option<std::rc::Rc<RefCell<ReadShared>>>,
188}
189
190impl std::future::Future for ReadFuture {
191 type Output = ReadResult;
192
193 fn poll(
194 mut self: std::pin::Pin<&mut Self>,
195 context: &mut std::task::Context<'_>,
196 ) -> std::task::Poll<ReadResult> {
197 let shared = match &self.shared {
198 Some(shared) => std::rc::Rc::clone(shared),
199 None => {
200 let shared = std::rc::Rc::new(RefCell::new(ReadShared::default()));
201 let answer = std::rc::Rc::clone(&shared);
202 read_text(move |result| {
203 let waker = {
204 let mut answer = answer.borrow_mut();
205 answer.result = Some(result);
206 answer.waker.take()
207 };
208 if let Some(waker) = waker {
209 waker.wake();
210 }
211 });
212 self.shared = Some(std::rc::Rc::clone(&shared));
213 shared
214 }
215 };
216 let mut state = shared.borrow_mut();
217 match state.result.take() {
218 Some(result) => std::task::Poll::Ready(result),
219 None => {
220 state.waker = Some(context.waker().clone());
221 std::task::Poll::Pending
222 }
223 }
224 }
225}
226
227/// The platform clipboard, as a value a component can hold. Compose's `LocalClipboard`.
228///
229/// It carries nothing: every call goes to the same clipboard. It exists so a component reads
230/// `let clipboard = use_clipboard();` and then calls `clipboard.read_text().await` or
231/// `clipboard.set_text(..)`.
232#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
233pub struct Clipboard {
234 _private: (),
235}
236
237impl Clipboard {
238 /// See [`read_text_async`].
239 pub fn read_text(&self) -> impl std::future::Future<Output = ReadResult> {
240 read_text_async()
241 }
242
243 /// See [`read_text`].
244 pub fn read_text_with(&self, on_text: impl FnOnce(ReadResult) + 'static) {
245 read_text(on_text);
246 }
247
248 /// See [`set_text`].
249 pub fn set_text(&self, text: &str) -> Result<(), ClipboardError> {
250 set_text(text)
251 }
252}
253
254/// The platform clipboard. Free to call anywhere, including during composition.
255pub fn use_clipboard() -> Clipboard {
256 Clipboard::default()
257}
258
259/// Hands every queued read to `emit`, oldest first, and empties the queue.
260pub(crate) fn drain_reads(mut emit: impl FnMut(u32)) {
261 let taken = READS.with_borrow_mut(std::mem::take);
262 for request in taken {
263 emit(request);
264 }
265}
266
267/// Hands an answer to the callback that asked for it. Returns whether there was one.
268///
269/// `status` is one of [`read_status`]; a word this side does not know reads as no text.
270/// `len` is the text's length in bytes as the Renderer measured it, which is what a
271/// too-long answer reports. An answer nobody is waiting for is ignored.
272pub(crate) fn deliver_read(request: u32, status: u32, len: u32, text: &str) -> bool {
273 let result = match status {
274 read_status::TEXT if text.len() > MAX_TEXT_BYTES => {
275 Err(ClipboardError::TooLong { len: text.len() })
276 }
277 read_status::TEXT if text.is_empty() => Ok(None),
278 read_status::TEXT => Ok(Some(text.to_owned())),
279 read_status::TOO_LONG => Err(ClipboardError::TooLong {
280 len: (len as usize).max(MAX_TEXT_BYTES + 1),
281 }),
282 _ => Ok(None),
283 };
284 // Taken out before it runs: the callback may read again, which edits the list.
285 let callback = WAITING.with_borrow_mut(|waiting| {
286 waiting
287 .iter()
288 .position(|(id, _)| *id == request)
289 .map(|at| waiting.remove(at).1)
290 });
291 match callback {
292 Some(callback) => {
293 callback(result);
294 true
295 }
296 None => false,
297 }
298}