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}