Skip to main content

lumen_script/
clipboard.rs

1//! The applier behind the `clipboard_write` and `clipboard_read` builtins.
2//!
3//! The backend is whatever [`ClipboardHost`] the assembly put in the world:
4//! the OS clipboard on the desktop, the page's `navigator.clipboard` in a
5//! browser. This applier is the same on both, and an app with no host in the
6//! world still answers every read, with empty text, so a script waiting on
7//! `on_clipboard(tag, text)` is never left hanging.
8
9use bevy_ecs::message::{MessageReader, MessageWriter};
10use bevy_ecs::prelude::*;
11use crossbeam_channel::{Receiver, Sender, unbounded};
12use lumen_core::warn_line;
13use lumen_os_clipboard::ClipboardHost;
14
15use crate::runtime::ScriptCommandEvent;
16use crate::{PluginEvent, ScriptCommand, ScriptValue};
17
18/// Reads that have an answer and have not been delivered yet.
19///
20/// A desktop read answers before the request call returns, so its reply is
21/// delivered by the same run of the applier. A page answers from a promise,
22/// on the page's own queue between ticks, and the reply waits here for the
23/// next run.
24pub struct PendingClipboardReads {
25    /// Handed to each read, which sends `(tag, text)` once it has the text.
26    tx: Sender<(String, String)>,
27    /// Drained into `on_clipboard` events every run.
28    rx: Receiver<(String, String)>,
29}
30
31impl Default for PendingClipboardReads {
32    fn default() -> Self {
33        let (tx, rx) = unbounded();
34        Self { tx, rx }
35    }
36}
37
38/// Carry out the clipboard commands a script queued, and deliver the reads
39/// that have an answer as [`clipboard_read_event`]s.
40pub fn apply_clipboard_commands(
41    mut events: MessageReader<ScriptCommandEvent>,
42    clipboard: Option<NonSend<ClipboardHost>>,
43    pending: Local<PendingClipboardReads>,
44    mut out: MessageWriter<PluginEvent>,
45) {
46    for ev in events.read() {
47        match &ev.0 {
48            ScriptCommand::ClipboardWrite { text } => match clipboard.as_deref() {
49                Some(clip) => {
50                    if !clip.write_text(text) {
51                        warn_line!("clipboard_write: the clipboard refused the text");
52                    }
53                }
54                None => warn_line!("clipboard_write: no clipboard backend"),
55            },
56            ScriptCommand::ClipboardRead { tag } => {
57                let tx = pending.tx.clone();
58                let tag = tag.clone();
59                match clipboard.as_deref() {
60                    Some(clip) => clip.read_text_then(move |text| {
61                        let _ = tx.send((tag, text));
62                    }),
63                    None => {
64                        let _ = tx.send((tag, String::new()));
65                    }
66                }
67            }
68            _ => {}
69        }
70    }
71    for (tag, text) in pending.rx.try_iter() {
72        out.write(clipboard_read_event(tag, text));
73    }
74}
75
76/// The event one answered `clipboard_read(tag)` reaches the script as:
77/// `on_clipboard(tag, text)`, with a per-tag `on("clipboard", tag, fn)`
78/// winning. A clipboard holding no text answers with empty text, so a script
79/// waiting on the read can still clear its pending state.
80pub fn clipboard_read_event(tag: String, text: String) -> PluginEvent {
81    PluginEvent::Call {
82        event: "clipboard".to_string(),
83        key: tag,
84        fallback: "on_clipboard".to_string(),
85        args: vec![ScriptValue::Str(text)],
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use bevy_ecs::message::{MessageRegistry, Messages};
92
93    use super::*;
94
95    /// An app with no clipboard in the world still answers a read, with empty
96    /// text, and a write goes nowhere without taking the tick down.
97    #[test]
98    fn with_no_clipboard_a_read_answers_empty_and_a_write_is_dropped() {
99        let mut world = World::new();
100        MessageRegistry::register_message::<ScriptCommandEvent>(&mut world);
101        crate::runtime::register_plugin_event_message(&mut world);
102        world.write_message(ScriptCommandEvent(ScriptCommand::ClipboardWrite {
103            text: "copied".to_string(),
104        }));
105        world.write_message(ScriptCommandEvent(ScriptCommand::ClipboardRead {
106            tag: "paste".to_string(),
107        }));
108
109        let mut schedule = Schedule::default();
110        schedule.add_systems(apply_clipboard_commands);
111        schedule.run(&mut world);
112
113        let reads = world.resource::<Messages<PluginEvent>>();
114        let delivered: Vec<(&str, &str, &str, &[ScriptValue])> = reads
115            .iter_current_update_messages()
116            .filter_map(|event| match event {
117                PluginEvent::Call {
118                    event,
119                    key,
120                    fallback,
121                    args,
122                } => Some((event.as_str(), key.as_str(), fallback.as_str(), &args[..])),
123                PluginEvent::Commands(_) => None,
124            })
125            .collect();
126        assert_eq!(
127            delivered,
128            [(
129                "clipboard",
130                "paste",
131                "on_clipboard",
132                &[ScriptValue::Str(String::new())][..]
133            )]
134        );
135    }
136}