Skip to main content

egui_winit/
clipboard.rs

1use raw_window_handle::RawDisplayHandle;
2
3/// Handles interfacing with the OS clipboard.
4///
5/// If the "clipboard" feature is off, or we cannot connect to the OS clipboard,
6/// then a fallback clipboard that just works within the same app is used instead.
7pub struct Clipboard {
8    #[cfg(all(
9        not(any(target_os = "android", target_os = "ios")),
10        feature = "arboard",
11    ))]
12    arboard: Option<arboard::Clipboard>,
13
14    #[cfg(all(
15        any(
16            target_os = "linux",
17            target_os = "dragonfly",
18            target_os = "freebsd",
19            target_os = "netbsd",
20            target_os = "openbsd"
21        ),
22        feature = "smithay-clipboard"
23    ))]
24    smithay: Option<smithay_clipboard::Clipboard>,
25
26    /// Fallback manual clipboard.
27    clipboard: String,
28}
29
30impl Clipboard {
31    /// Construct a new instance.
32    ///
33    /// # Safety
34    ///
35    /// If `raw_display_handle` is `Some`, the display handle must remain valid for the
36    /// entire lifetime of the returned `Clipboard` instance.
37    #[expect(unsafe_code)]
38    pub unsafe fn new(_raw_display_handle: Option<RawDisplayHandle>) -> Self {
39        Self {
40            #[cfg(all(
41                not(any(target_os = "android", target_os = "ios")),
42                feature = "arboard",
43            ))]
44            arboard: init_arboard(),
45
46            #[cfg(all(
47                any(
48                    target_os = "linux",
49                    target_os = "dragonfly",
50                    target_os = "freebsd",
51                    target_os = "netbsd",
52                    target_os = "openbsd"
53                ),
54                feature = "smithay-clipboard"
55            ))]
56            // SAFETY: The caller guarantees that the display handle remains valid.
57            smithay: unsafe { init_smithay_clipboard(_raw_display_handle) },
58
59            clipboard: Default::default(),
60        }
61    }
62
63    #[cfg_attr(
64        not(feature = "clipboard"),
65        expect(clippy::needless_pass_by_ref_mut, clippy::unnecessary_wraps)
66    )]
67    pub fn get(&mut self) -> Option<String> {
68        #[cfg(all(
69            any(
70                target_os = "linux",
71                target_os = "dragonfly",
72                target_os = "freebsd",
73                target_os = "netbsd",
74                target_os = "openbsd"
75            ),
76            feature = "smithay-clipboard"
77        ))]
78        if let Some(clipboard) = &mut self.smithay {
79            match clipboard.load() {
80                Ok(text) => return Some(text),
81                // Smithay uses NotFound when no supported text MIME type is offered.
82                Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
83                Err(err) => {
84                    // Not fatal: we fall back to arboard below.
85                    log::debug!("smithay paste error: {err}");
86                }
87            }
88        }
89
90        #[cfg(all(
91            not(any(target_os = "android", target_os = "ios")),
92            feature = "arboard",
93        ))]
94        if let Some(clipboard) = &mut self.arboard {
95            return match clipboard.get_text() {
96                Ok(text) => Some(text),
97                Err(err) => {
98                    // Expected whenever the clipboard holds something other than text (e.g.
99                    // an image copied with a screenshot tool) — the caller falls back to
100                    // `Self::get_image` in that case, so this is not an error worth
101                    // alarming the user/log about.
102                    if !is_expected_content_absence(&err) {
103                        log::error!("arboard paste error: {err}");
104                    }
105                    None
106                }
107            };
108        }
109
110        Some(self.clipboard.clone())
111    }
112
113    pub fn set_text(&mut self, text: String) {
114        #[cfg(all(
115            any(
116                target_os = "linux",
117                target_os = "dragonfly",
118                target_os = "freebsd",
119                target_os = "netbsd",
120                target_os = "openbsd"
121            ),
122            feature = "smithay-clipboard"
123        ))]
124        if let Some(clipboard) = &mut self.smithay {
125            clipboard.store(text);
126            return;
127        }
128
129        #[cfg(all(
130            not(any(target_os = "android", target_os = "ios")),
131            feature = "arboard",
132        ))]
133        if let Some(clipboard) = &mut self.arboard {
134            if let Err(err) = clipboard.set_text(text) {
135                log::error!("arboard copy/cut error: {err}");
136            }
137            return;
138        }
139
140        self.clipboard = text;
141    }
142
143    /// Get an image from the clipboard, if there is one and the platform backend supports it.
144    ///
145    /// This mirrors [`Self::set_image`] for the opposite direction, so that a Ctrl+V/Cmd+V
146    /// paste can carry an image (e.g. a screenshot or a copied image) instead of text — see
147    /// [`egui::Event::PasteImage`].
148    #[cfg_attr(
149        not(feature = "clipboard"),
150        expect(clippy::needless_pass_by_ref_mut, clippy::unused_self)
151    )]
152    pub fn get_image(&mut self) -> Option<egui::ColorImage> {
153        #[cfg(all(
154            not(any(target_os = "android", target_os = "ios")),
155            feature = "arboard",
156        ))]
157        if let Some(clipboard) = &mut self.arboard {
158            return match clipboard.get_image() {
159                Ok(image) => Some(color_image_from_arboard(&image)),
160                Err(err) => {
161                    // Expected whenever the clipboard holds neither text nor an image (e.g.
162                    // it's simply empty) — `Self::get` was already tried first and came up
163                    // empty too, so this is the mundane "nothing to paste" case, not an error.
164                    if !is_expected_content_absence(&err) {
165                        log::error!("arboard paste-image error: {err}");
166                    }
167                    None
168                }
169            };
170        }
171
172        None
173    }
174
175    #[cfg_attr(
176        not(feature = "clipboard"),
177        expect(clippy::needless_pass_by_ref_mut, clippy::unused_self)
178    )]
179    pub fn set_image(&mut self, image: &egui::ColorImage) {
180        #[cfg(all(
181            not(any(target_os = "android", target_os = "ios")),
182            feature = "arboard",
183        ))]
184        if let Some(clipboard) = &mut self.arboard {
185            if let Err(err) = clipboard.set_image(arboard_from_color_image(image)) {
186                log::error!("arboard copy/cut error: {err}");
187            }
188            log::debug!("Copied image to clipboard");
189            return;
190        }
191
192        log::error!(
193            "Copying images is not supported. Enable the 'clipboard' feature of `egui-winit` to enable it."
194        );
195        _ = image;
196    }
197}
198
199// The X11/Wayland PRIMARY selection: filled in by selecting text, pasted with the middle mouse button.
200cfg_select! {
201    any(
202        target_os = "linux",
203        target_os = "dragonfly",
204        target_os = "freebsd",
205        target_os = "netbsd",
206        target_os = "openbsd"
207    ) => {
208        #[cfg_attr(
209            not(any(feature = "arboard", feature = "smithay-clipboard")),
210            expect(
211                clippy::unused_self,
212                clippy::needless_pass_by_ref_mut,
213                clippy::needless_pass_by_value,
214                reason = "no backend to use"
215            )
216        )]
217        impl Clipboard {
218            /// Read the PRIMARY selection, if there is one.
219            pub fn get_primary_text(&mut self) -> Option<String> {
220                #[cfg(feature = "smithay-clipboard")]
221                if let Some(clipboard) = &mut self.smithay {
222                    return clipboard
223                        .load_primary()
224                        .inspect_err(|err| log::debug!("smithay primary paste error: {err}"))
225                        .ok();
226                }
227
228                #[cfg(feature = "arboard")]
229                if let Some(clipboard) = &mut self.arboard {
230                    use arboard::GetExtLinux as _;
231                    return clipboard
232                        .get()
233                        .clipboard(arboard::LinuxClipboardKind::Primary)
234                        .text()
235                        .inspect_err(|err| log::debug!("arboard primary paste error: {err}"))
236                        .ok();
237                }
238
239                None
240            }
241
242            /// Set the PRIMARY selection.
243            pub fn set_primary_text(&mut self, text: String) {
244                #[cfg(feature = "smithay-clipboard")]
245                if let Some(clipboard) = &mut self.smithay {
246                    clipboard.store_primary(text);
247                    return;
248                }
249
250                #[cfg(feature = "arboard")]
251                if let Some(clipboard) = &mut self.arboard {
252                    use arboard::SetExtLinux as _;
253                    if let Err(err) = clipboard
254                        .set()
255                        .clipboard(arboard::LinuxClipboardKind::Primary)
256                        .text(text)
257                    {
258                        log::error!("arboard primary copy error: {err}");
259                    }
260                    return;
261                }
262
263                _ = text;
264            }
265        }
266    }
267    _ => {
268        #[expect(
269            clippy::unused_self,
270            clippy::needless_pass_by_ref_mut,
271            reason = "there is no PRIMARY selection on this platform"
272        )]
273        impl Clipboard {
274            /// Read the PRIMARY selection. Always `None` on this platform.
275            pub fn get_primary_text(&mut self) -> Option<String> {
276                None
277            }
278
279            /// Set the PRIMARY selection. Does nothing on this platform.
280            pub fn set_primary_text(&mut self, _text: String) {}
281        }
282    }
283}
284
285/// Whether an `arboard::Error` from reading the clipboard is the expected, mundane outcome
286/// of the clipboard simply not holding the requested content type (e.g. text was asked for
287/// but the clipboard holds an image, or vice versa, or it's just empty) — as opposed to a
288/// genuine failure (permissions, a locked clipboard, a conversion error) worth an `error!` log.
289///
290/// Pulled out as its own pure function (rather than inlined in the two `match`es above) so it
291/// can be unit-tested without touching the real OS clipboard, which CI can't rely on.
292#[cfg(all(
293    not(any(target_os = "android", target_os = "ios")),
294    feature = "arboard",
295))]
296fn is_expected_content_absence(err: &arboard::Error) -> bool {
297    matches!(err, arboard::Error::ContentNotAvailable)
298}
299
300#[cfg(all(
301    not(any(target_os = "android", target_os = "ios")),
302    feature = "arboard",
303))]
304fn color_image_from_arboard(image: &arboard::ImageData<'_>) -> egui::ColorImage {
305    egui::ColorImage::from_rgba_unmultiplied([image.width, image.height], &image.bytes)
306}
307
308/// arboard expects unmultiplied alpha, but [`egui::Color32`] is premultiplied.
309#[cfg(all(
310    not(any(target_os = "android", target_os = "ios")),
311    feature = "arboard",
312))]
313fn arboard_from_color_image(image: &egui::ColorImage) -> arboard::ImageData<'static> {
314    let bytes: Vec<u8> = image
315        .pixels
316        .iter()
317        .flat_map(|color| color.to_srgba_unmultiplied())
318        .collect();
319    arboard::ImageData {
320        width: image.width(),
321        height: image.height(),
322        bytes: bytes.into(),
323    }
324}
325
326#[cfg(all(
327    not(any(target_os = "android", target_os = "ios")),
328    feature = "arboard",
329))]
330fn init_arboard() -> Option<arboard::Clipboard> {
331    profiling::function_scope!();
332
333    log::trace!("Initializing arboard clipboard…");
334    match arboard::Clipboard::new() {
335        Ok(clipboard) => Some(clipboard),
336        Err(err) => {
337            log::warn!("Failed to initialize arboard clipboard: {err}");
338            None
339        }
340    }
341}
342
343/// # Safety
344///
345/// The display handle in `raw_display_handle` must remain valid for the
346/// lifetime of the returned `Clipboard`.
347#[cfg(all(
348    any(
349        target_os = "linux",
350        target_os = "dragonfly",
351        target_os = "freebsd",
352        target_os = "netbsd",
353        target_os = "openbsd"
354    ),
355    feature = "smithay-clipboard"
356))]
357#[expect(unsafe_code)]
358unsafe fn init_smithay_clipboard(
359    raw_display_handle: Option<RawDisplayHandle>,
360) -> Option<smithay_clipboard::Clipboard> {
361    profiling::function_scope!();
362
363    if let Some(RawDisplayHandle::Wayland(display)) = raw_display_handle {
364        log::trace!("Initializing smithay clipboard…");
365        // SAFETY: The caller guarantees that the display handle remains valid.
366        Some(unsafe { smithay_clipboard::Clipboard::new(display.display.as_ptr()) })
367    } else {
368        #[cfg(feature = "wayland")]
369        log::debug!("Cannot init smithay clipboard without a Wayland display handle");
370        #[cfg(not(feature = "wayland"))]
371        log::debug!(
372            "Cannot init smithay clipboard: the 'wayland' feature of 'egui-winit' is not enabled"
373        );
374        None
375    }
376}
377
378#[cfg(all(
379    not(any(target_os = "android", target_os = "ios")),
380    feature = "arboard",
381))]
382#[cfg(test)]
383mod tests {
384    use super::{arboard_from_color_image, color_image_from_arboard, is_expected_content_absence};
385
386    /// Regression test for the spurious `error!`-level log a maintainer caught by manually
387    /// testing an image paste (nothing had exercised this distinction before): only
388    /// `ContentNotAvailable` — clipboard simply doesn't hold the requested content type — is
389    /// expected and should stay silent; every other `arboard::Error` variant is a real failure
390    /// and must still be logged.
391    #[test]
392    fn only_content_not_available_is_treated_as_expected() {
393        assert!(is_expected_content_absence(
394            &arboard::Error::ContentNotAvailable
395        ));
396
397        assert!(!is_expected_content_absence(
398            &arboard::Error::ClipboardNotSupported
399        ));
400        assert!(!is_expected_content_absence(
401            &arboard::Error::ClipboardOccupied
402        ));
403        assert!(!is_expected_content_absence(
404            &arboard::Error::ConversionFailure
405        ));
406        assert!(!is_expected_content_absence(&arboard::Error::Unknown {
407            description: "anything".to_owned(),
408        }));
409    }
410
411    #[test]
412    fn color_image_from_arboard_converts_straight_to_premultiplied_alpha() {
413        // 2x1 image: opaque red, then half-transparent white — straight (unmultiplied) alpha,
414        // as arboard/the OS clipboard would hand it to us.
415        let image = arboard::ImageData {
416            width: 2,
417            height: 1,
418            bytes: std::borrow::Cow::Borrowed(&[255, 0, 0, 255, 255, 255, 255, 128]),
419        };
420        let color_image = color_image_from_arboard(&image);
421        assert_eq!(color_image.size, [2, 1]);
422        assert_eq!(
423            color_image.pixels[0],
424            egui::Color32::from_rgba_unmultiplied(255, 0, 0, 255)
425        );
426        assert_eq!(
427            color_image.pixels[1],
428            egui::Color32::from_rgba_unmultiplied(255, 255, 255, 128)
429        );
430    }
431
432    #[test]
433    fn arboard_from_color_image_converts_premultiplied_to_straight_alpha() {
434        let color_image = egui::ColorImage::new(
435            [3, 1],
436            vec![
437                egui::Color32::from_rgba_unmultiplied(255, 0, 0, 255),
438                egui::Color32::from_rgba_unmultiplied(255, 255, 255, 128),
439                egui::Color32::TRANSPARENT,
440            ],
441        );
442        let image = arboard_from_color_image(&color_image);
443        assert_eq!([image.width, image.height], [3, 1]);
444        assert_eq!(
445            &*image.bytes,
446            &[255, 0, 0, 255, 255, 255, 255, 128, 0, 0, 0, 0]
447        );
448    }
449}