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//! # Services
7//!
8//! Services provided by this extension.
9//!
10//! * [`CLIPBOARD`]
11//!
12//! # Crate
13//!
14#![doc = include_str!(concat!("../", std::env!("CARGO_PKG_README")))]
15#![warn(unused_extern_crates)]
16#![warn(missing_docs)]
17
18use std::{collections::HashMap, fmt, path::PathBuf, sync::Arc};
19
20use zng_app::{command, event::*, shortcut::*, update::UPDATES, view_process::VIEW_PROCESS};
21use zng_ext_image::{IMAGES, ImageEntry, ImageVar};
22use zng_task::channel::{ChannelError, IpcBytes};
23use zng_txt::{ToTxt, Txt};
24use zng_var::{ResponseVar, response_var};
25use zng_view_api::{clipboard::ClipboardError as ViewError, image::ImageDecoded};
26use zng_wgt::{CommandIconExt as _, ICONS, wgt_fn};
27
28pub use zng_view_api::clipboard::{ClipboardType, ClipboardTypes};
29
30/// Error getting or setting the clipboard.
31///
32/// The [`CLIPBOARD`] service already logs the error.
33#[derive(Debug, Clone, PartialEq)]
34#[non_exhaustive]
35pub enum ClipboardError {
36    /// No view-process available to process the request.
37    ///
38    /// Note that this error only happens if the view-process is respawning. For headless apps (without renderer)
39    /// a in memory "clipboard" is used and this error does not return.
40    Disconnected,
41    /// View-process or operating system does not support the data type.
42    NotSupported,
43    /// Cannot set image in clipboard because it has not finished loading or loaded with error.
44    ImageNotLoaded,
45    /// Other error.
46    ///
47    /// The string can be a debug description of the error, only suitable for logging.
48    Other(Txt),
49}
50impl std::error::Error for ClipboardError {}
51impl fmt::Display for ClipboardError {
52    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53        match self {
54            ClipboardError::Disconnected => write!(f, "no view-process available to process the request"),
55            ClipboardError::NotSupported => write!(f, "view-process or operating system does not support the data type"),
56            ClipboardError::ImageNotLoaded => write!(
57                f,
58                "cannot set image in clipboard because it has not finished loading or loaded with error"
59            ),
60            ClipboardError::Other(e) => write!(f, "{e}"),
61        }
62    }
63}
64impl From<ChannelError> for ClipboardError {
65    fn from(e: ChannelError) -> Self {
66        match e {
67            ChannelError::Disconnected { .. } => ClipboardError::Disconnected,
68            e => ClipboardError::Other(e.to_txt()),
69        }
70    }
71}
72impl From<ViewError> for ClipboardError {
73    fn from(e: ViewError) -> Self {
74        match e {
75            ViewError::NotSupported => ClipboardError::NotSupported,
76            e => ClipboardError::Other(e.to_txt()),
77        }
78    }
79}
80
81#[derive(Default)]
82struct ClipboardService {
83    update_text: std::sync::Weak<Result<Option<Txt>, ClipboardError>>,
84    update_image: std::sync::Weak<Result<Option<ImageVar>, ClipboardError>>,
85    update_paths: std::sync::Weak<Result<Option<Vec<PathBuf>>, ClipboardError>>,
86    update_exts: HashMap<Txt, std::sync::Weak<Result<Option<IpcBytes>, ClipboardError>>>,
87}
88
89app_local! {
90    static CLIPBOARD_SV: ClipboardService = ClipboardService::default();
91}
92
93/// Clipboard service.
94///
95/// This service synchronizes with the UI update cycle, the getter methods provide the same data for all requests in the
96/// same update pass, even if the system clipboard happens to change mid update, the setter methods only set the system clipboard
97/// at the end of the update pass.
98///
99/// This service needs a running view-process to actually interact with the system clipboard, in a headless app
100/// without renderer (no view-process) the service will always return [`ClipboardError::Disconnected`].
101pub struct CLIPBOARD;
102impl CLIPBOARD {
103    /// Gets a text string from the clipboard.
104    pub fn text(&self) -> Result<Option<Txt>, ClipboardError> {
105        let mut s = CLIPBOARD_SV.write();
106
107        match s.update_text.upgrade() {
108            // already requested this update, use same value
109            Some(r) => (*r).clone(),
110            None => {
111                // read
112                if !VIEW_PROCESS.is_available() {
113                    return Err(ClipboardError::Disconnected);
114                }
115                let r = match VIEW_PROCESS.clipboard()?.read_text()? {
116                    Ok(r) => Ok(Some(r)),
117                    Err(e) => match e {
118                        ViewError::NotFound => Ok(None),
119                        ViewError::NotSupported => Err(ClipboardError::NotSupported),
120                        e => Err(ClipboardError::Other(e.to_txt())),
121                    },
122                };
123
124                // hold same value until current update ends
125                let arc = Arc::new(r.clone());
126                s.update_text = Arc::downgrade(&arc);
127                UPDATES.once_update("", || {
128                    let _hold = arc;
129                });
130
131                r
132            }
133        }
134    }
135    /// Sets the text string on the clipboard after the current update.
136    ///
137    /// Returns a response var that updates once the text is set.
138    pub fn set_text(&self, txt: impl Into<Txt>) -> ResponseVar<Result<(), ClipboardError>> {
139        self.set_text_impl(txt.into())
140    }
141    fn set_text_impl(&self, txt: Txt) -> ResponseVar<Result<(), ClipboardError>> {
142        let (r, rsp) = response_var();
143        UPDATES.once_update("CLIPBOARD.set_text", move || {
144            if !VIEW_PROCESS.is_available() {
145                return r.respond(Err(ClipboardError::Disconnected));
146            }
147            match VIEW_PROCESS.clipboard() {
148                Ok(c) => match c.write_text(txt) {
149                    Ok(vr) => r.respond(vr.map_err(ClipboardError::from)),
150                    Err(e) => r.respond(Err(e.into())),
151                },
152                Err(e) => r.respond(Err(e.into())),
153            }
154        });
155        rsp
156    }
157
158    /// Gets an image from the clipboard.
159    ///
160    /// The image is loaded in parallel by the [`IMAGES`] service, it is not cached.
161    ///
162    /// [`IMAGES`]: zng_ext_image::IMAGES
163    pub fn image(&self) -> Result<Option<ImageVar>, ClipboardError> {
164        let mut s = CLIPBOARD_SV.write();
165
166        match s.update_image.upgrade() {
167            Some(r) => (*r).clone(),
168            None => {
169                if !VIEW_PROCESS.is_available() {
170                    return Err(ClipboardError::Disconnected);
171                }
172                let r = match VIEW_PROCESS.clipboard()?.read_image()? {
173                    Ok(r) => {
174                        let r = IMAGES.register(None, (r, ImageDecoded::default()));
175                        Ok(Some(r))
176                    }
177                    Err(e) => match e {
178                        ViewError::NotFound => Ok(None),
179                        ViewError::NotSupported => Err(ClipboardError::NotSupported),
180                        e => Err(ClipboardError::Other(e.to_txt())),
181                    },
182                };
183
184                let arc = Arc::new(r.clone());
185                s.update_image = Arc::downgrade(&arc);
186                UPDATES.once_update("", || {
187                    let _hold = arc;
188                });
189
190                r
191            }
192        }
193    }
194
195    /// Set the image on the clipboard after the current update, if it is loaded.
196    ///
197    /// Returns a response var that updates once the image is set.
198    pub fn set_image(&self, img: ImageEntry) -> ResponseVar<Result<(), ClipboardError>> {
199        let (r, rsp) = response_var();
200        UPDATES.once_update("CLIPBOARD.set_image", move || {
201            if !VIEW_PROCESS.is_available() {
202                return r.respond(Err(ClipboardError::Disconnected));
203            }
204            match VIEW_PROCESS.clipboard() {
205                Ok(c) => {
206                    if img.is_loaded() {
207                        match c.write_image(img.view_handle()) {
208                            Ok(vr) => r.respond(vr.map_err(ClipboardError::from)),
209                            Err(e) => r.respond(Err(e.into())),
210                        }
211                    } else {
212                        r.respond(Err(ClipboardError::ImageNotLoaded));
213                    }
214                }
215                Err(e) => r.respond(Err(e.into())),
216            }
217        });
218        rsp
219    }
220
221    /// Gets a path list from the clipboard.
222    pub fn paths(&self) -> Result<Option<Vec<PathBuf>>, ClipboardError> {
223        let mut s = CLIPBOARD_SV.write();
224
225        match s.update_paths.upgrade() {
226            Some(r) => (*r).clone(),
227            None => {
228                if !VIEW_PROCESS.is_available() {
229                    return Err(ClipboardError::Disconnected);
230                }
231                let r = match VIEW_PROCESS.clipboard()?.read_paths()? {
232                    Ok(r) => Ok(Some(r)),
233                    Err(e) => match e {
234                        ViewError::NotFound => Ok(None),
235                        ViewError::NotSupported => Err(ClipboardError::NotSupported),
236                        e => Err(ClipboardError::Other(e.to_txt())),
237                    },
238                };
239
240                let arc = Arc::new(r.clone());
241                s.update_paths = Arc::downgrade(&arc);
242                UPDATES.once_update("", || {
243                    let _hold = arc;
244                });
245
246                r
247            }
248        }
249    }
250
251    /// Sets the file list on the clipboard after the current update.
252    ///
253    /// Returns a response var that updates once the paths are set.
254    pub fn set_paths(&self, list: impl Into<Vec<PathBuf>>) -> ResponseVar<Result<(), ClipboardError>> {
255        self.set_paths_impl(list.into())
256    }
257    fn set_paths_impl(&self, list: Vec<PathBuf>) -> ResponseVar<Result<(), ClipboardError>> {
258        let (r, rsp) = response_var();
259        UPDATES.once_update("CLIPBOARD.set_paths", move || {
260            if !VIEW_PROCESS.is_available() {
261                return r.respond(Err(ClipboardError::Disconnected));
262            }
263            match VIEW_PROCESS.clipboard() {
264                Ok(c) => match c.write_paths(list) {
265                    Ok(vr) => r.respond(vr.map_err(ClipboardError::from)),
266                    Err(e) => r.respond(Err(e.into())),
267                },
268                Err(e) => r.respond(Err(e.into())),
269            }
270        });
271        rsp
272    }
273
274    /// Gets custom data from the clipboard.
275    ///
276    /// The current view-process must support `data_type`.
277    pub fn extension(&self, data_type: impl Into<Txt>) -> Result<Option<IpcBytes>, ClipboardError> {
278        self.extension_impl(data_type.into())
279    }
280    fn extension_impl(&self, data_type: Txt) -> Result<Option<IpcBytes>, ClipboardError> {
281        let mut s = CLIPBOARD_SV.write();
282        if s.update_exts.len() > 20 {
283            s.update_exts.retain(|_, v| v.strong_count() > 0);
284        }
285        match s.update_exts.get(&data_type).and_then(|r| r.upgrade()) {
286            Some(r) => (*r).clone(),
287            None => {
288                if !VIEW_PROCESS.is_available() {
289                    return Err(ClipboardError::Disconnected);
290                }
291                let r = match VIEW_PROCESS.clipboard()?.read_extension(data_type.clone())? {
292                    Ok(r) => Ok(Some(r)),
293                    Err(e) => match e {
294                        ViewError::NotFound => Ok(None),
295                        ViewError::NotSupported => Err(ClipboardError::NotSupported),
296                        e => Err(ClipboardError::Other(e.to_txt())),
297                    },
298                };
299
300                let arc = Arc::new(r.clone());
301                s.update_exts.insert(data_type, Arc::downgrade(&arc));
302                UPDATES.once_update("", || {
303                    let _hold = arc;
304                });
305
306                r
307            }
308        }
309    }
310
311    /// Set a custom data on the clipboard.
312    ///
313    /// The current view-process must support `data_type` after the current update.
314    pub fn set_extension(&self, data_type: impl Into<Txt>, data: IpcBytes) -> ResponseVar<Result<(), ClipboardError>> {
315        self.set_extension_impl(data_type.into(), data)
316    }
317    fn set_extension_impl(&self, data_type: Txt, data: IpcBytes) -> ResponseVar<Result<(), ClipboardError>> {
318        let (r, rsp) = response_var();
319        UPDATES.once_update("CLIPBOARD.set_extension", move || {
320            if !VIEW_PROCESS.is_available() {
321                return r.respond(Err(ClipboardError::Disconnected));
322            }
323            match VIEW_PROCESS.clipboard() {
324                Ok(c) => match c.write_extension(data_type, data) {
325                    Ok(vr) => r.respond(vr.map_err(ClipboardError::from)),
326                    Err(e) => r.respond(Err(e.into())),
327                },
328                Err(e) => r.respond(Err(e.into())),
329            }
330        });
331        rsp
332    }
333
334    /// Get what clipboard types and operations the current view-process implements.
335    pub fn available_types(&self) -> ClipboardTypes {
336        VIEW_PROCESS.info().clipboard.clone()
337    }
338}
339
340command! {
341    /// Represents the clipboard **cut** action.
342    pub static CUT_CMD {
343        l10n!: true,
344        name: "Cut",
345        info: "Remove the selection and place it in the clipboard",
346        shortcut: [shortcut!(CTRL + 'X'), shortcut!(SHIFT + Delete), shortcut!(Cut)],
347        shortcut_filter: ShortcutFilter::FOCUSED | ShortcutFilter::CMD_ENABLED,
348        icon: wgt_fn!(|_| ICONS.get("cut")),
349    };
350
351    /// Represents the clipboard **copy** action.
352    pub static COPY_CMD {
353        l10n!: true,
354        name: "Copy",
355        info: "Place a copy of the selection in the clipboard",
356        shortcut: [shortcut!(CTRL + 'C'), shortcut!(CTRL + Insert), shortcut!(Copy)],
357        shortcut_filter: ShortcutFilter::FOCUSED | ShortcutFilter::CMD_ENABLED,
358        icon: wgt_fn!(|_| ICONS.get("copy")),
359    };
360
361    /// Represents the clipboard **paste** action.
362    pub static PASTE_CMD {
363        l10n!: true,
364        name: "Paste",
365        info: "Insert content from the clipboard",
366        shortcut: [shortcut!(CTRL + 'V'), shortcut!(SHIFT + Insert), shortcut!(Paste)],
367        shortcut_filter: ShortcutFilter::FOCUSED | ShortcutFilter::CMD_ENABLED,
368        icon: wgt_fn!(|_| ICONS.get("paste")),
369    };
370}