Skip to main content

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}