zng_ext_clipboard/
lib.rs

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