zng_ext_clipboard/
lib.rs

1#![doc(html_favicon_url = "https://zng-ui.github.io/res/zng-logo-icon.png")]
2#![doc(html_logo_url = "https://zng-ui.github.io/res/zng-logo.png")]
3//!
4//! Clipboard app extension, service and commands.
5//!
6//! # Crate
7//!
8#![doc = include_str!(concat!("../", std::env!("CARGO_PKG_README")))]
9#![warn(unused_extern_crates)]
10#![warn(missing_docs)]
11
12use core::fmt;
13use std::{any::Any, path::PathBuf};
14
15use zng_app::{
16    APP, AppExtension,
17    event::{CommandInfoExt as _, CommandNameExt as _, command},
18    shortcut::{CommandShortcutExt as _, ShortcutFilter, shortcut},
19    view_process::{VIEW_PROCESS, ViewClipboard, ViewImage},
20};
21use zng_app_context::app_local;
22use zng_ext_image::{IMAGES, ImageHasher, ImageVar, Img};
23use zng_txt::{ToTxt, Txt};
24use zng_var::{ResponderVar, ResponseVar, response_var};
25use zng_wgt::{CommandIconExt as _, ICONS, wgt_fn};
26
27use zng_view_api::ipc::IpcBytes;
28use zng_view_api::{clipboard as clipboard_api, ipc::ViewChannelError};
29
30/// Clipboard app extension.
31///
32/// # Services
33///
34/// Services provided by this extension.
35///
36/// * [`CLIPBOARD`]
37#[derive(Default)]
38#[non_exhaustive]
39pub struct ClipboardManager {}
40
41impl AppExtension for ClipboardManager {
42    fn update(&mut self) {
43        let mut clipboard = CLIPBOARD_SV.write();
44        clipboard.text.update(|v, txt| v.write_text(txt));
45        clipboard.image.map_update(
46            |img| {
47                if let Some(img) = img.view() {
48                    Ok(img.clone())
49                } else {
50                    Err(ClipboardError::ImageNotLoaded)
51                }
52            },
53            |v, img| v.write_image(&img),
54        );
55        clipboard.file_list.update(|v, list| v.write_file_list(list));
56        clipboard.ext.update(|v, (data_type, data)| v.write_extension(data_type, data))
57    }
58}
59
60app_local! {
61    static CLIPBOARD_SV: ClipboardService = {
62        APP.extensions().require::<ClipboardManager>();
63        ClipboardService::default()
64    };
65    static HEADLESS_CLIPBOARD: Option<Box<dyn Any + Send + Sync>> = const { None };
66}
67
68#[derive(Default)]
69struct ClipboardService {
70    text: ClipboardData<Txt, Txt>,
71    image: ClipboardData<ImageVar, Img>,
72    file_list: ClipboardData<Vec<PathBuf>, Vec<PathBuf>>,
73    ext: ClipboardData<IpcBytes, (Txt, IpcBytes)>,
74}
75
76struct ClipboardData<O: 'static, I: 'static> {
77    latest: Option<Result<Option<O>, ClipboardError>>,
78    request: Option<(I, ResponderVar<Result<bool, ClipboardError>>)>,
79}
80impl<O: 'static, I: 'static> Default for ClipboardData<O, I> {
81    fn default() -> Self {
82        Self {
83            latest: None,
84            request: None,
85        }
86    }
87}
88impl<O: Clone + 'static, I: 'static> ClipboardData<O, I> {
89    pub fn get(&mut self, getter: impl FnOnce(&dyn ActualClipboard) -> ActualClipboardResult<O>) -> Result<Option<O>, ClipboardError> {
90        self.latest
91            .get_or_insert_with(|| {
92                let r = match getter(CLIPBOARD.actual()) {
93                    Ok(r) => match r {
94                        Ok(r) => Ok(Some(r)),
95                        Err(e) => match e {
96                            clipboard_api::ClipboardError::NotFound => Ok(None),
97                            clipboard_api::ClipboardError::NotSupported => Err(ClipboardError::NotSupported),
98                            clipboard_api::ClipboardError::Other(e) => Err(ClipboardError::Other(e)),
99                            e => Err(ClipboardError::Other(e.to_txt())),
100                        },
101                    },
102                    Err(_) => Err(ClipboardError::Disconnected),
103                };
104                if let Err(e) = &r {
105                    tracing::error!("clipboard get error, {e:?}");
106                }
107                r
108            })
109            .clone()
110    }
111
112    pub fn request(&mut self, r: I) -> ResponseVar<Result<bool, ClipboardError>> {
113        let (responder, response) = response_var();
114
115        if let Some((_, r)) = self.request.replace((r, responder)) {
116            r.respond(Ok(false));
117        }
118
119        response
120    }
121
122    pub fn update(&mut self, setter: impl FnOnce(&dyn ActualClipboard, I) -> ActualClipboardResult<()>) {
123        self.map_update(Ok, setter)
124    }
125
126    pub fn map_update<VI>(
127        &mut self,
128        to_view: impl FnOnce(I) -> Result<VI, ClipboardError>,
129        setter: impl FnOnce(&dyn ActualClipboard, VI) -> ActualClipboardResult<()>,
130    ) {
131        self.latest = None;
132        if let Some((i, rsp)) = self.request.take() {
133            let vi = match to_view(i) {
134                Ok(vi) => vi,
135                Err(e) => {
136                    tracing::error!("clipboard set error, {e:?}");
137                    rsp.respond(Err(e));
138                    return;
139                }
140            };
141            let r = match setter(CLIPBOARD.actual(), vi) {
142                Ok(r) => match r {
143                    Ok(()) => Ok(true),
144                    Err(e) => match e {
145                        clipboard_api::ClipboardError::NotFound => {
146                            Err(ClipboardError::Other(Txt::from_static("not found error in set operation")))
147                        }
148                        clipboard_api::ClipboardError::NotSupported => Err(ClipboardError::NotSupported),
149                        clipboard_api::ClipboardError::Other(e) => Err(ClipboardError::Other(e)),
150                        e => Err(ClipboardError::Other(e.to_txt())),
151                    },
152                },
153                Err(_) => Err(ClipboardError::Disconnected),
154            };
155            if let Err(e) = &r {
156                tracing::error!("clipboard set error, {e:?}");
157            }
158            rsp.respond(r);
159        }
160    }
161}
162
163/// Error getting or setting the clipboard.
164///
165/// The [`CLIPBOARD`] service already logs the error.
166#[derive(Debug, Clone, PartialEq)]
167#[non_exhaustive]
168pub enum ClipboardError {
169    /// No view-process available to process the request.
170    ///
171    /// Note that this error only happens if the view-process is respawning. For headless apps (without renderer)
172    /// a in memory "clipboard" is used and this error does not return.
173    Disconnected,
174    /// View-process or operating system does not support the data type.
175    NotSupported,
176    /// Cannot set image in clipboard because it has not finished loading or loaded with error.
177    ImageNotLoaded,
178    /// Other error.
179    ///
180    /// The string can be a debug description of the error, only suitable for logging.
181    Other(Txt),
182}
183impl std::error::Error for ClipboardError {}
184impl fmt::Display for ClipboardError {
185    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
186        match self {
187            ClipboardError::Disconnected => write!(f, "no view-process available to process the request"),
188            ClipboardError::NotSupported => write!(f, "view-process or operating system does not support the data type"),
189            ClipboardError::ImageNotLoaded => write!(
190                f,
191                "cannot set image in clipboard because it has not finished loading or loaded with error"
192            ),
193            ClipboardError::Other(e) => write!(f, "{e}"),
194        }
195    }
196}
197
198/// Clipboard service.
199///
200/// This service synchronizes with the UI update cycle, the getter methods provide the same data for all requests in the
201/// same update pass, even if the system clipboard happens to change mid update, the setter methods only set the system clipboard
202/// at the end of the update pass.
203///
204/// This service needs a running view-process to actually interact with the system clipboard, in a headless app
205/// without renderer (no view-process) the service will always return [`ClipboardError::Disconnected`].
206///
207/// # Provider
208///
209/// This service is provided by the [`ClipboardManager`] extension, it will panic if used in an app not extended.
210pub struct CLIPBOARD;
211impl CLIPBOARD {
212    /// Gets a text string from the clipboard.
213    pub fn text(&self) -> Result<Option<Txt>, ClipboardError> {
214        CLIPBOARD_SV
215            .write()
216            .text
217            .get(|v| v.read_text())
218            .map(|s| s.map(|s| Txt::from_str(&s)))
219    }
220    /// Sets the text string on the clipboard after the current update.
221    ///
222    /// Returns a response var that updates to `Ok(true)` is the text is put on the clipboard,
223    /// `Ok(false)` if another request made on the same update pass replaces this one or `Err(ClipboardError)`.
224    pub fn set_text(&self, txt: impl Into<Txt>) -> ResponseVar<Result<bool, ClipboardError>> {
225        CLIPBOARD_SV.write().text.request(txt.into())
226    }
227
228    /// Gets an image from the clipboard.
229    ///
230    /// The image is loaded in parallel and cached by the [`IMAGES`] service.
231    ///
232    /// [`IMAGES`]: zng_ext_image::IMAGES
233    pub fn image(&self) -> Result<Option<ImageVar>, ClipboardError> {
234        CLIPBOARD_SV.write().image.get(|v| {
235            let img = v.read_image()?;
236            match img {
237                Ok(img) => {
238                    let mut hash = ImageHasher::new();
239                    hash.update("zng_ext_clipboard::CLIPBOARD");
240                    hash.update(img.id().unwrap().get().to_be_bytes());
241                    match IMAGES.register(hash.finish(), img) {
242                        Ok(r) => Ok(Ok(r)),
243                        Err((_, r)) => Ok(Ok(r)),
244                    }
245                }
246                Err(e) => Ok(Err(e)),
247            }
248        })
249    }
250
251    /// Set the image on the clipboard after the current update, if it is loaded.
252    ///
253    /// Returns a response var that updates to `Ok(true)` is the text is put on the clipboard,
254    /// `Ok(false)` if another request made on the same update pass replaces this one or `Err(ClipboardError)`.
255    pub fn set_image(&self, img: Img) -> ResponseVar<Result<bool, ClipboardError>> {
256        CLIPBOARD_SV.write().image.request(img)
257    }
258
259    /// Gets a file list from the clipboard.
260    pub fn file_list(&self) -> Result<Option<Vec<PathBuf>>, ClipboardError> {
261        CLIPBOARD_SV.write().file_list.get(|v| v.read_file_list())
262    }
263
264    /// Sets the file list on the clipboard after the current update.
265    ///
266    /// Returns a response var that updates to `Ok(true)` is the text is put on the clipboard,
267    /// `Ok(false)` if another request made on the same update pass replaces this one or `Err(ClipboardError)`.
268    pub fn set_file_list(&self, list: impl Into<Vec<PathBuf>>) -> ResponseVar<Result<bool, ClipboardError>> {
269        CLIPBOARD_SV.write().file_list.request(list.into())
270    }
271
272    /// Gets custom data from the clipboard.
273    ///
274    /// The current view-process must support `data_type`.
275    pub fn extension(&self, data_type: impl Into<Txt>) -> Result<Option<IpcBytes>, ClipboardError> {
276        CLIPBOARD_SV.write().ext.get(|v| v.read_extension(data_type.into()))
277    }
278
279    /// Set a custom data on the clipboard.
280    ///
281    /// The current view-process must support `data_type` after the current update.
282    ///
283    /// Returns a response var that updates to `Ok(true)` is the text is put on the clipboard,
284    /// `Ok(false)` if another request made on the same update pass replaces this one or `Err(ClipboardError)`.
285    pub fn set_extension(&self, data_type: impl Into<Txt>, data: IpcBytes) -> ResponseVar<Result<bool, ClipboardError>> {
286        CLIPBOARD_SV.write().ext.request((data_type.into(), data))
287    }
288}
289
290type ActualClipboardResult<T> = Result<Result<T, clipboard_api::ClipboardError>, ViewChannelError>;
291impl CLIPBOARD {
292    fn actual(&self) -> &dyn ActualClipboard {
293        if VIEW_PROCESS.is_available() {
294            match VIEW_PROCESS.clipboard() {
295                Ok(c) => c,
296                Err(_) => {
297                    if !APP.window_mode().has_renderer() {
298                        &CLIPBOARD
299                    } else {
300                        &ViewChannelError::Disconnected
301                    }
302                }
303            }
304        } else if !APP.window_mode().has_renderer() {
305            &CLIPBOARD
306        } else {
307            &ViewChannelError::Disconnected
308        }
309    }
310}
311trait ActualClipboard {
312    fn read_text(&self) -> ActualClipboardResult<Txt>;
313    fn write_text(&self, txt: Txt) -> ActualClipboardResult<()>;
314
315    fn read_image(&self) -> ActualClipboardResult<ViewImage>;
316    fn write_image(&self, img: &ViewImage) -> ActualClipboardResult<()>;
317
318    fn read_file_list(&self) -> ActualClipboardResult<Vec<PathBuf>>;
319    fn write_file_list(&self, list: Vec<PathBuf>) -> ActualClipboardResult<()>;
320
321    fn read_extension(&self, data_type: Txt) -> ActualClipboardResult<IpcBytes>;
322    fn write_extension(&self, data_type: Txt, data: IpcBytes) -> ActualClipboardResult<()>;
323}
324impl ActualClipboard for ViewClipboard {
325    fn read_text(&self) -> ActualClipboardResult<Txt> {
326        self.read_text()
327    }
328    fn write_text(&self, txt: Txt) -> ActualClipboardResult<()> {
329        self.write_text(txt)
330    }
331
332    fn read_image(&self) -> ActualClipboardResult<ViewImage> {
333        self.read_image()
334    }
335    fn write_image(&self, img: &ViewImage) -> ActualClipboardResult<()> {
336        self.write_image(img)
337    }
338
339    fn read_file_list(&self) -> ActualClipboardResult<Vec<PathBuf>> {
340        self.read_file_list()
341    }
342    fn write_file_list(&self, list: Vec<PathBuf>) -> ActualClipboardResult<()> {
343        self.write_file_list(list)
344    }
345
346    fn read_extension(&self, data_type: Txt) -> ActualClipboardResult<IpcBytes> {
347        self.read_extension(data_type)
348    }
349    fn write_extension(&self, data_type: Txt, data: IpcBytes) -> ActualClipboardResult<()> {
350        self.write_extension(data_type, data)
351    }
352}
353impl CLIPBOARD {
354    fn headless_clipboard_get<T: Any + Clone>(&self) -> ActualClipboardResult<T> {
355        let sv = HEADLESS_CLIPBOARD.read();
356        Ok(match &*sv {
357            Some(v) => match v.downcast_ref::<T>() {
358                Some(v) => Ok(v.clone()),
359                None => Err(clipboard_api::ClipboardError::NotFound),
360            },
361            None => Err(clipboard_api::ClipboardError::NotFound),
362        })
363    }
364
365    fn headless_clipboard_set(&self, t: impl Any + Send + Sync) -> ActualClipboardResult<()> {
366        *HEADLESS_CLIPBOARD.write() = Some(Box::new(t));
367        Ok(Ok(()))
368    }
369}
370impl ActualClipboard for CLIPBOARD {
371    fn read_text(&self) -> ActualClipboardResult<Txt> {
372        self.headless_clipboard_get()
373    }
374    fn write_text(&self, txt: Txt) -> ActualClipboardResult<()> {
375        self.headless_clipboard_set(txt)
376    }
377    fn read_image(&self) -> ActualClipboardResult<ViewImage> {
378        self.headless_clipboard_get()
379    }
380    fn write_image(&self, img: &ViewImage) -> ActualClipboardResult<()> {
381        self.headless_clipboard_set(img.clone())
382    }
383
384    fn read_file_list(&self) -> ActualClipboardResult<Vec<PathBuf>> {
385        self.headless_clipboard_get()
386    }
387    fn write_file_list(&self, list: Vec<PathBuf>) -> ActualClipboardResult<()> {
388        self.headless_clipboard_set(list)
389    }
390
391    fn read_extension(&self, data_type: Txt) -> ActualClipboardResult<IpcBytes> {
392        match self.headless_clipboard_get::<(Txt, IpcBytes)>()? {
393            Ok((t, b)) => {
394                if t == data_type {
395                    Ok(Ok(b))
396                } else {
397                    Ok(Err(clipboard_api::ClipboardError::NotFound))
398                }
399            }
400            Err(e) => Ok(Err(e)),
401        }
402    }
403    fn write_extension(&self, data_type: Txt, data: IpcBytes) -> ActualClipboardResult<()> {
404        self.headless_clipboard_set((data_type, data))
405    }
406}
407impl ActualClipboard for ViewChannelError {
408    fn read_text(&self) -> ActualClipboardResult<Txt> {
409        Err(self.clone())
410    }
411    fn write_text(&self, _: Txt) -> ActualClipboardResult<()> {
412        Err(self.clone())
413    }
414
415    fn read_image(&self) -> ActualClipboardResult<ViewImage> {
416        Err(self.clone())
417    }
418    fn write_image(&self, _: &ViewImage) -> ActualClipboardResult<()> {
419        Err(self.clone())
420    }
421
422    fn read_file_list(&self) -> ActualClipboardResult<Vec<PathBuf>> {
423        Err(self.clone())
424    }
425    fn write_file_list(&self, _: Vec<PathBuf>) -> ActualClipboardResult<()> {
426        Err(self.clone())
427    }
428
429    fn read_extension(&self, _: Txt) -> ActualClipboardResult<IpcBytes> {
430        Err(self.clone())
431    }
432    fn write_extension(&self, _: Txt, _: IpcBytes) -> ActualClipboardResult<()> {
433        Err(self.clone())
434    }
435}
436
437command! {
438    /// Represents the clipboard **cut** action.
439    pub static CUT_CMD = {
440        l10n!: true,
441        name: "Cut",
442        info: "Remove the selection and place it in the clipboard",
443        shortcut: [shortcut!(CTRL + 'X'), shortcut!(SHIFT + Delete), shortcut!(Cut)],
444        shortcut_filter: ShortcutFilter::FOCUSED | ShortcutFilter::CMD_ENABLED,
445        icon: wgt_fn!(|_| ICONS.get("cut")),
446    };
447
448    /// Represents the clipboard **copy** action.
449    pub static COPY_CMD = {
450        l10n!: true,
451        name: "Copy",
452        info: "Place a copy of the selection in the clipboard",
453        shortcut: [shortcut!(CTRL + 'C'), shortcut!(CTRL + Insert), shortcut!(Copy)],
454        shortcut_filter: ShortcutFilter::FOCUSED | ShortcutFilter::CMD_ENABLED,
455        icon: wgt_fn!(|_| ICONS.get("copy")),
456    };
457
458    /// Represents the clipboard **paste** action.
459    pub static PASTE_CMD = {
460        l10n!: true,
461        name: "Paste",
462        info: "Insert content from the clipboard",
463        shortcut: [shortcut!(CTRL + 'V'), shortcut!(SHIFT + Insert), shortcut!(Paste)],
464        shortcut_filter: ShortcutFilter::FOCUSED | ShortcutFilter::CMD_ENABLED,
465        icon: wgt_fn!(|_| ICONS.get("paste")),
466    };
467}