Skip to main content

inset_services/
clipboard.rs

1//! Flutter counterpart: `services/clipboard.dart`.
2//!
3//! Flutter's text clipboard, as a layer over the host's full one ([`inset_embedder::Clipboard`]):
4//! it writes one item in `text/plain`, and reads the first item offered in it. While the
5//! framework handles a clipboard event of the host's ([`Clipboard::handle_event`]), it writes
6//! through the event's copy request and reads what the user pasted, in place of the host's
7//! clipboard.
8
9use std::rc::Rc;
10
11use inset_embedder::{
12    ClipboardEvent, ClipboardItem, ClipboardItemsFuture, ClipboardWriteFuture, Content,
13    CopyOptions, DataTransferItem,
14};
15use inset_foundation::{App, Handle, Task};
16
17/// Data stored on the system clipboard.
18///
19/// The system clipboard can contain data of various media types. This data
20/// structure currently supports only plain text data, in the [`text`](Self::text) property.
21#[derive(Clone, Debug, PartialEq, Eq)]
22pub struct ClipboardData {
23    /// Plain text variant of this clipboard data.
24    ///
25    /// This is nullable as other clipboard data variants, like images, may be
26    /// added in the future. Currently, plain text is the only supported variant
27    /// and this is guaranteed to be non-null.
28    pub text: Option<String>,
29}
30
31impl ClipboardData {
32    /// Creates data for the system clipboard.
33    pub fn new(text: impl Into<String>) -> ClipboardData {
34        ClipboardData {
35            text: Some(text.into()),
36        }
37    }
38}
39
40/// Utility methods for interacting with the system's clipboard.
41pub struct Clipboard;
42
43impl Clipboard {
44    /// Plain text data format string.
45    ///
46    /// Used with [`get_data`](Self::get_data).
47    pub const K_TEXT_PLAIN: &'static str = "text/plain";
48
49    /// Stores the given clipboard data on the clipboard.
50    pub fn set_data(app: &mut App, data: ClipboardData) -> Task<()> {
51        let Some(text) = data.text else {
52            return Task::ready(());
53        };
54        let item = ClipboardItem {
55            representations: vec![(
56                Self::K_TEXT_PLAIN.to_owned(),
57                Content::Data(text.into_bytes()),
58            )],
59        };
60        match Self::write(app, vec![item]) {
61            Some(written) => app.spawn(async move |_cx| {
62                // Flutter's `setData` completes whether or not the host took the text.
63                let _refused = written.await;
64            }),
65            None => Task::ready(()),
66        }
67    }
68
69    /// Retrieves data from the clipboard that matches the given format.
70    ///
71    /// The `format` argument specifies the media type, such as `text/plain`, of
72    /// the data to obtain.
73    ///
74    /// Completes with [`None`] if the data could not be obtained.
75    pub fn get_data(app: &mut App, format: &str) -> Task<Option<ClipboardData>> {
76        if format != Self::K_TEXT_PLAIN {
77            return Task::ready(None);
78        }
79        match Self::readable(app) {
80            Some(readable) => {
81                app.spawn(async move |_cx| readable.text().await.map(ClipboardData::new))
82            }
83            None => Task::ready(None),
84        }
85    }
86
87    /// Completes with true if (and only if) the clipboard contains string data.
88    pub fn has_strings(app: &mut App) -> Task<bool> {
89        match Self::readable(app) {
90            Some(readable) => app.spawn(async move |_cx| readable.holds_text().await),
91            None => Task::ready(false),
92        }
93    }
94
95    /// Runs `handle` with `event` as the clipboard: while it runs, [`set_data`](Self::set_data)
96    /// writes through a copy's or a cut's request, and [`get_data`](Self::get_data) and
97    /// [`has_strings`](Self::has_strings) read what a paste pasted. What `handle` starts may
98    /// complete later; what is started after it returns reaches the host's clipboard.
99    ///
100    /// No Flutter counterpart: Flutter's engines paste into a text field through its text input
101    /// connection, and reach the clipboard only through the platform channel.
102    pub fn handle_event<R>(
103        app: &mut App,
104        event: ClipboardEvent,
105        handle: impl FnOnce(&mut App) -> R,
106    ) -> R {
107        let in_hand = Self::event_in_hand(app);
108        let outer = app.get_mut(in_hand).0.replace(Rc::new(event));
109        let result = handle(app);
110        app.get_mut(in_hand).0 = outer;
111        result
112    }
113
114    /// Replaces the clipboard with `items`: through the request of the copy or cut being
115    /// handled, or the host's clipboard. `None` where the host has none.
116    fn write(app: &mut App, items: Vec<ClipboardItem>) -> Option<ClipboardWriteFuture> {
117        let in_hand = Self::event_in_hand(app);
118        match app.get(in_hand).0.as_deref() {
119            Some(ClipboardEvent::Copy(request) | ClipboardEvent::Cut(request)) => {
120                Some(request.write(items, CopyOptions::new()))
121            }
122            Some(ClipboardEvent::Paste(_)) | None => app
123                .platform()
124                .clipboard()
125                .map(|clipboard| clipboard.write(items, CopyOptions::new())),
126        }
127    }
128
129    /// What a read reads: the paste being handled, or the host's clipboard. `None` where the
130    /// host has none.
131    fn readable(app: &mut App) -> Option<Readable> {
132        let in_hand = Self::event_in_hand(app);
133        match app.get(in_hand).0.clone() {
134            Some(event) if matches!(*event, ClipboardEvent::Paste(_)) => {
135                Some(Readable::Pasted(event))
136            }
137            _ => app
138                .platform()
139                .clipboard()
140                .map(|clipboard| Readable::Listing(clipboard.items())),
141        }
142    }
143
144    fn event_in_hand(app: &mut App) -> Handle<EventInHand> {
145        app.singleton()
146    }
147}
148
149/// The host's clipboard event the framework is handling, if it is handling one.
150#[derive(Default)]
151struct EventInHand(Option<Rc<ClipboardEvent>>);
152
153/// What a read of the clipboard reads.
154enum Readable {
155    /// What the user pasted: a paste event.
156    Pasted(Rc<ClipboardEvent>),
157    /// What is on the host's clipboard, being listed.
158    Listing(ClipboardItemsFuture),
159}
160
161impl Readable {
162    /// The text of the first item offered as text; none when no item is, or the text could not
163    /// be read.
164    async fn text(self) -> Option<String> {
165        match self {
166            Readable::Pasted(paste) => text_of(pasted_items(&paste)).await,
167            Readable::Listing(items) => text_of(&items.await).await,
168        }
169    }
170
171    /// Whether any item is offered as text.
172    async fn holds_text(self) -> bool {
173        match self {
174            Readable::Pasted(paste) => holds_text(pasted_items(&paste)),
175            Readable::Listing(items) => holds_text(&items.await),
176        }
177    }
178}
179
180/// The items of `paste`, a paste event.
181fn pasted_items(paste: &ClipboardEvent) -> &[Box<dyn DataTransferItem>] {
182    match paste {
183        ClipboardEvent::Paste(items) => items,
184        ClipboardEvent::Copy(_) | ClipboardEvent::Cut(_) => &[],
185    }
186}
187
188fn offers_text(item: &dyn DataTransferItem) -> bool {
189    item.types()
190        .iter()
191        .any(|mime| mime == Clipboard::K_TEXT_PLAIN)
192}
193
194fn holds_text(items: &[Box<dyn DataTransferItem>]) -> bool {
195    items.iter().any(|item| offers_text(item.as_ref()))
196}
197
198async fn text_of(items: &[Box<dyn DataTransferItem>]) -> Option<String> {
199    let item = items.iter().find(|item| offers_text(item.as_ref()))?;
200    let bytes = item.read(Clipboard::K_TEXT_PLAIN).await.ok()?;
201    Some(String::from_utf8_lossy(&bytes).into_owned())
202}
203
204#[cfg(test)]
205mod tests {
206    use std::cell::RefCell;
207    use std::rc::Rc;
208    use std::task::{Context, Poll, Waker};
209
210    use inset_embedder::{Clipboard as _, CopyRequest};
211    use inset_foundation::AppCell;
212    use inset_test::{MockClipboard, TestPlatform};
213
214    use super::*;
215
216    fn app_with(clipboard: &MockClipboard) -> Rc<AppCell> {
217        let clipboard: Rc<dyn inset_embedder::Clipboard> = Rc::new(clipboard.clone());
218        AppCell::with_platform(Rc::new(TestPlatform::new().with_clipboard(clipboard)))
219    }
220
221    /// Runs the checkpoint `task` needs; its answer, or `None` while it is still on its way.
222    fn awaited<T: 'static>(cell: &Rc<AppCell>, task: Task<T>) -> Option<T> {
223        let answer = Rc::new(RefCell::new(None));
224        let out = Rc::clone(&answer);
225        cell.borrow()
226            .spawn(async move |_cx| *out.borrow_mut() = Some(task.await));
227        cell.checkpoint();
228        answer.take()
229    }
230
231    /// What is on `clipboard`, which a mock lists at once.
232    fn listed(clipboard: &MockClipboard) -> Vec<Box<dyn DataTransferItem>> {
233        let mut items = clipboard.items();
234        match items.as_mut().poll(&mut Context::from_waker(Waker::noop())) {
235            Poll::Ready(items) => items,
236            Poll::Pending => panic!("a mock clipboard lists at once"),
237        }
238    }
239
240    // clipboard_test.dart 'Clipboard.getData returns text'.
241    #[test]
242    fn get_data_returns_text() {
243        let mock_clipboard = MockClipboard::new();
244        let cell = app_with(&mock_clipboard);
245        mock_clipboard.set_clipboard_data(Some("Hello world".to_owned()));
246
247        let data = Clipboard::get_data(&mut cell.borrow_mut(), Clipboard::K_TEXT_PLAIN);
248        let data = awaited(&cell, data).expect("the read completed");
249
250        assert_eq!(data, Some(ClipboardData::new("Hello world")));
251    }
252
253    // clipboard_test.dart 'Clipboard.getData returns null'.
254    #[test]
255    fn get_data_returns_none() {
256        let mock_clipboard = MockClipboard::new();
257        let cell = app_with(&mock_clipboard);
258        mock_clipboard.set_clipboard_data(None);
259
260        let data = Clipboard::get_data(&mut cell.borrow_mut(), Clipboard::K_TEXT_PLAIN);
261        let data = awaited(&cell, data).expect("the read completed");
262
263        assert_eq!(data, None);
264    }
265
266    // clipboard_test.dart 'Clipboard.setData sets text'.
267    #[test]
268    fn set_data_sets_text() {
269        let mock_clipboard = MockClipboard::new();
270        let cell = app_with(&mock_clipboard);
271
272        let written =
273            Clipboard::set_data(&mut cell.borrow_mut(), ClipboardData::new("Hello world"));
274        awaited(&cell, written).expect("the write completed");
275
276        assert_eq!(
277            mock_clipboard.clipboard_data().as_deref(),
278            Some("Hello world")
279        );
280    }
281
282    #[test]
283    fn the_text_is_the_first_item_offered_as_text() {
284        let mock_clipboard = MockClipboard::new();
285        let cell = app_with(&mock_clipboard);
286        let png = ClipboardItem {
287            representations: vec![("image/png".to_owned(), Content::Data(vec![0x89]))],
288        };
289        let text = ClipboardItem {
290            representations: vec![("text/plain".to_owned(), Content::Data(b"second".to_vec()))],
291        };
292        drop(mock_clipboard.write(vec![png, text], CopyOptions::new()));
293
294        let has_strings = Clipboard::has_strings(&mut cell.borrow_mut());
295        assert_eq!(awaited(&cell, has_strings), Some(true));
296        let data = Clipboard::get_data(&mut cell.borrow_mut(), Clipboard::K_TEXT_PLAIN);
297        assert_eq!(
298            awaited(&cell, data).expect("the read completed"),
299            Some(ClipboardData::new("second"))
300        );
301    }
302
303    #[test]
304    fn a_paste_being_handled_is_read_in_place_of_the_clipboard() {
305        let on_clipboard = MockClipboard::new();
306        on_clipboard.set_clipboard_data(Some("on the clipboard".to_owned()));
307        let pasted = MockClipboard::new();
308        pasted.set_clipboard_data(Some("pasted".to_owned()));
309        let cell = app_with(&on_clipboard);
310
311        let data = Clipboard::handle_event(
312            &mut cell.borrow_mut(),
313            ClipboardEvent::Paste(listed(&pasted)),
314            |app| Clipboard::get_data(app, Clipboard::K_TEXT_PLAIN),
315        );
316        assert_eq!(
317            awaited(&cell, data).expect("the read completed"),
318            Some(ClipboardData::new("pasted"))
319        );
320
321        let after = Clipboard::get_data(&mut cell.borrow_mut(), Clipboard::K_TEXT_PLAIN);
322        assert_eq!(
323            awaited(&cell, after).expect("the read completed"),
324            Some(ClipboardData::new("on the clipboard")),
325            "a read after the paste reads the clipboard"
326        );
327    }
328
329    #[test]
330    fn a_copy_being_handled_writes_through_its_request() {
331        let on_clipboard = MockClipboard::new();
332        let copied = MockClipboard::new();
333        let cell = app_with(&on_clipboard);
334        let into = copied.clone();
335        let request = CopyRequest::new(move |items, options| into.write(items, options));
336
337        let written = Clipboard::handle_event(
338            &mut cell.borrow_mut(),
339            ClipboardEvent::Copy(request),
340            |app| Clipboard::set_data(app, ClipboardData::new("copied")),
341        );
342        awaited(&cell, written).expect("the write completed");
343
344        assert_eq!(copied.clipboard_data().as_deref(), Some("copied"));
345        assert_eq!(on_clipboard.clipboard_data(), None);
346    }
347}