Skip to main content

cranpose_ui/
clipboard_session.rs

1//! Platform clipboard session: an OS clipboard read/write bridge for in-tree UI.
2//!
3//! The text selection contextual menu (Copy / Cut / Paste) lives in the widget
4//! tree (`cranpose-ui`), which cannot reach the OS clipboard directly — that
5//! machinery lives one layer up in `cranpose-app-shell` / platform glue
6//! (`arboard` on desktop, DOM clipboard on web). Platforms install a
7//! [`PlatformClipboard`] so the menu can copy/paste through the system
8//! clipboard; when none is installed an in-process fallback keeps copy/paste
9//! working within the app (and in headless tests).
10//!
11//! The session is stored per [`AppContext`](crate::render_state::AppContext),
12//! like the text-input and focus sessions, so multiple app instances in one
13//! process do not share a clipboard.
14
15use std::cell::RefCell;
16use std::rc::Rc;
17
18#[cfg(test)]
19use std::cell::Cell;
20
21/// A platform-provided OS clipboard. Installed by the platform runtime so that
22/// in-tree UI (the selection menu) can read/write the system clipboard.
23pub trait PlatformClipboard {
24    /// Writes `text` to the OS clipboard.
25    fn write_text(&self, text: &str);
26    /// Reads the OS clipboard's text, or `None` when empty/unavailable.
27    ///
28    /// A platform whose clipboard cannot be read synchronously answers `None`
29    /// here and takes the paste through [`request_paste`](Self::request_paste)
30    /// instead.
31    fn read_text(&self) -> Option<String>;
32
33    /// Whether this platform can complete a paste it cannot answer
34    /// synchronously, so a Paste action stays offered even though
35    /// [`read_text`](Self::read_text) has nothing to show.
36    fn can_request_paste(&self) -> bool {
37        false
38    }
39
40    /// Asks the platform to paste the clipboard into whatever holds focus, for
41    /// platforms that cannot answer [`read_text`](Self::read_text).
42    ///
43    /// Returns `true` when the platform has taken the request — the text lands
44    /// in the focused field through the platform's own paste path, possibly
45    /// after this returns. `false` means the caller should paste
46    /// [`read_text`](Self::read_text) itself, which is what every clipboard
47    /// with a synchronous read does.
48    ///
49    /// This exists because the browser's clipboard is a promise: reading it is
50    /// asynchronous and permissioned, so `Some(text)` is not something a web
51    /// bridge can produce on the call stack of the tap that asked for it.
52    fn request_paste(&self) -> bool {
53        false
54    }
55}
56
57/// Per-app-context clipboard state: an optionally-installed platform clipboard
58/// plus an in-process fallback used when none is installed.
59pub(crate) struct ClipboardSessionState {
60    platform: RefCell<Option<Rc<dyn PlatformClipboard>>>,
61    fallback: RefCell<Option<String>>,
62}
63
64impl ClipboardSessionState {
65    pub(crate) fn new() -> Self {
66        Self {
67            platform: RefCell::new(None),
68            fallback: RefCell::new(None),
69        }
70    }
71
72    fn set_platform(&self, clipboard: Option<Rc<dyn PlatformClipboard>>) {
73        *self.platform.borrow_mut() = clipboard;
74    }
75
76    fn write(&self, text: &str) {
77        // Keep a local copy even when a platform bridge is installed: native
78        // clipboards may be temporarily unavailable (notably headless desktop
79        // sessions), and copy/paste must still round-trip within this app.
80        *self.fallback.borrow_mut() = Some(text.to_string());
81        if let Some(platform) = self.platform.borrow().clone() {
82            platform.write_text(text);
83        }
84    }
85
86    fn read(&self) -> Option<String> {
87        if let Some(platform) = self.platform.borrow().clone() {
88            if let Some(text) = platform.read_text() {
89                return Some(text);
90            }
91        }
92        self.fallback.borrow().clone()
93    }
94
95    fn has_platform(&self) -> bool {
96        self.platform.borrow().is_some()
97    }
98
99    fn can_request_paste(&self) -> bool {
100        self.platform
101            .borrow()
102            .as_ref()
103            .is_some_and(|platform| platform.can_request_paste())
104    }
105
106    fn request_paste(&self) -> bool {
107        self.platform
108            .borrow()
109            .clone()
110            .is_some_and(|platform| platform.request_paste())
111    }
112}
113
114/// Installs the platform OS clipboard for the current app context, replacing any
115/// previously installed one. Platform runtimes call this
116/// (`AppShell::set_platform_clipboard`).
117pub fn set_platform_clipboard(clipboard: Rc<dyn PlatformClipboard>) {
118    crate::render_state::with_clipboard_session(|state| state.set_platform(Some(clipboard)));
119}
120
121/// Removes the installed platform clipboard, falling back to the in-process one.
122pub fn clear_platform_clipboard() {
123    crate::render_state::with_clipboard_session(|state| state.set_platform(None));
124}
125
126/// Writes `text` to the clipboard (OS clipboard when a platform is installed,
127/// otherwise the in-process fallback).
128pub fn clipboard_write_text(text: &str) {
129    crate::render_state::with_clipboard_session(|state| state.write(text));
130}
131
132/// Reads the clipboard's text, or `None` when empty/unavailable.
133pub fn clipboard_read_text() -> Option<String> {
134    crate::render_state::with_clipboard_session(|state| state.read())
135}
136
137/// Whether a real OS clipboard is installed for the current app context (as
138/// opposed to the in-process fallback used in headless tests or on platforms
139/// with no clipboard backend registered).
140pub fn has_platform_clipboard() -> bool {
141    crate::render_state::with_clipboard_session(|state| state.has_platform())
142}
143
144/// Whether a Paste action should be offered.
145///
146/// True when the clipboard has readable text, and also when the platform can
147/// only answer a paste asynchronously (the browser): a clipboard nobody can
148/// read on the spot is not the same as an empty one, and hiding Paste there
149/// would be wrong every time the user actually has something to paste.
150pub fn clipboard_can_paste() -> bool {
151    crate::render_state::with_clipboard_session(|state| {
152        state.read().is_some() || state.can_request_paste()
153    })
154}
155
156/// Pastes the clipboard into the focused text field — the in-tree Paste action.
157///
158/// Every native clipboard reads synchronously and the paste lands before this
159/// returns. The browser's does not: there the platform takes the request and
160/// completes it through its own paste path once the clipboard promise resolves,
161/// which is why this is a command rather than a read.
162pub fn clipboard_paste_into_focus() {
163    if crate::render_state::with_clipboard_session(|state| state.request_paste()) {
164        return;
165    }
166    if let Some(text) = clipboard_read_text() {
167        crate::text_field_focus::dispatch_paste(&text);
168    }
169}
170
171/// A Compose-style handle to the system clipboard — the framework analogue of
172/// Jetpack Compose's `LocalClipboardManager`. Obtain it from
173/// [`local_clipboard`] during composition, then read/write it (typically from an
174/// event handler):
175///
176/// ```ignore
177/// let clipboard = local_clipboard().current();
178/// Button(Modifier::empty(), move || clipboard.set_text("copied!"), || Text("Copy"));
179/// ```
180///
181/// It reads and writes through the app's clipboard session, so it targets the
182/// installed platform clipboard (UIPasteboard, arboard, …) when present and an
183/// in-process fallback otherwise.
184#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)]
185pub struct ClipboardManager;
186
187impl ClipboardManager {
188    /// Writes `text` to the clipboard.
189    pub fn set_text(&self, text: &str) {
190        clipboard_write_text(text);
191    }
192
193    /// Reads the clipboard's text, or `None` when empty/unavailable.
194    pub fn text(&self) -> Option<String> {
195        clipboard_read_text()
196    }
197
198    /// Whether writes reach a real OS clipboard (vs the in-process fallback).
199    pub fn has_system_clipboard(&self) -> bool {
200        has_platform_clipboard()
201    }
202}
203
204/// CompositionLocal carrying the [`ClipboardManager`]. The same instance is
205/// returned on every call (cached per thread), matching `local_uri_handler` and
206/// the insets locals.
207pub fn local_clipboard() -> cranpose_core::CompositionLocal<ClipboardManager> {
208    thread_local! {
209        static LOCAL_CLIPBOARD: RefCell<Option<cranpose_core::CompositionLocal<ClipboardManager>>> =
210            const { RefCell::new(None) };
211    }
212
213    LOCAL_CLIPBOARD.with(|cell| {
214        cell.borrow_mut()
215            .get_or_insert_with(|| cranpose_core::compositionLocalOf(ClipboardManager::default))
216            .clone()
217    })
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use std::cell::RefCell;
224
225    struct RecordingClipboard {
226        value: RefCell<Option<String>>,
227    }
228
229    struct UnavailableClipboard;
230
231    /// A clipboard shaped like the browser's: nothing to read on the call
232    /// stack, but able to complete a paste of its own accord.
233    struct AsyncOnlyClipboard {
234        requests: Cell<usize>,
235    }
236
237    impl PlatformClipboard for RecordingClipboard {
238        fn write_text(&self, text: &str) {
239            *self.value.borrow_mut() = Some(text.to_string());
240        }
241        fn read_text(&self) -> Option<String> {
242            self.value.borrow().clone()
243        }
244    }
245
246    impl PlatformClipboard for UnavailableClipboard {
247        fn write_text(&self, _text: &str) {}
248
249        fn read_text(&self) -> Option<String> {
250            None
251        }
252    }
253
254    impl PlatformClipboard for AsyncOnlyClipboard {
255        fn write_text(&self, _text: &str) {}
256
257        fn read_text(&self) -> Option<String> {
258            None
259        }
260
261        fn can_request_paste(&self) -> bool {
262            true
263        }
264
265        fn request_paste(&self) -> bool {
266            self.requests.set(self.requests.get() + 1);
267            true
268        }
269    }
270
271    #[test]
272    fn manager_uses_in_process_fallback_without_a_platform_clipboard() {
273        let context = crate::render_state::AppContext::new();
274        context.enter(|| {
275            let clipboard = ClipboardManager;
276            assert!(!clipboard.has_system_clipboard());
277            clipboard.set_text("hello");
278            assert_eq!(clipboard.text().as_deref(), Some("hello"));
279        });
280    }
281
282    #[test]
283    fn manager_routes_through_the_installed_platform_clipboard() {
284        let context = crate::render_state::AppContext::new();
285        context.enter(|| {
286            let recorder = Rc::new(RecordingClipboard {
287                value: RefCell::new(None),
288            });
289            set_platform_clipboard(recorder.clone());
290
291            let clipboard = ClipboardManager;
292            assert!(clipboard.has_system_clipboard());
293            clipboard.set_text("world");
294            assert_eq!(recorder.value.borrow().as_deref(), Some("world"));
295            assert_eq!(clipboard.text().as_deref(), Some("world"));
296
297            clear_platform_clipboard();
298            assert!(!clipboard.has_system_clipboard());
299        });
300    }
301
302    #[test]
303    fn a_paste_goes_to_the_platform_when_it_cannot_be_read_on_the_spot() {
304        let context = crate::render_state::AppContext::new();
305        context.enter(|| {
306            let clipboard = Rc::new(AsyncOnlyClipboard {
307                requests: Cell::new(0),
308            });
309            set_platform_clipboard(clipboard.clone());
310
311            // A clipboard nobody can read here and now still offers Paste --
312            // hiding it would hide every real paste the browser can serve.
313            assert!(clipboard_can_paste());
314            clipboard_paste_into_focus();
315            assert_eq!(clipboard.requests.get(), 1);
316        });
317    }
318
319    #[test]
320    fn a_readable_clipboard_pastes_without_asking_the_platform() {
321        let context = crate::render_state::AppContext::new();
322        context.enter(|| {
323            // No platform at all: the in-process fallback is readable, so the
324            // paste must take the synchronous path rather than vanish.
325            assert!(!clipboard_can_paste());
326            clipboard_write_text("pasted");
327            assert!(clipboard_can_paste());
328            clipboard_paste_into_focus();
329        });
330    }
331
332    #[test]
333    fn manager_falls_back_when_the_installed_platform_is_unavailable() {
334        let context = crate::render_state::AppContext::new();
335        context.enter(|| {
336            set_platform_clipboard(Rc::new(UnavailableClipboard));
337
338            let clipboard = ClipboardManager;
339            clipboard.set_text("headless copy");
340            assert_eq!(clipboard.text().as_deref(), Some("headless copy"));
341        });
342    }
343}