Skip to main content

tui_lipan/clipboard/
provider.rs

1#[cfg(all(
2    feature = "clipboard",
3    feature = "clipboard-images",
4    not(target_arch = "wasm32")
5))]
6use std::io::Cursor;
7
8use std::path::PathBuf;
9
10use crate::clipboard::error::{ClipboardError, ClipboardOperation};
11
12/// Supported image formats for clipboard operations.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum ImageFormat {
15    /// PNG format (lossless, larger file size).
16    Png,
17    /// JPEG format (lossy, smaller file size).
18    Jpeg,
19}
20
21impl ImageFormat {
22    /// Returns the MIME type for this image format.
23    pub const fn mime_type(&self) -> &'static str {
24        match self {
25            Self::Png => "image/png",
26            Self::Jpeg => "image/jpeg",
27        }
28    }
29}
30
31/// Image content read from or written to the clipboard.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct ImageContent {
34    /// Base64-encoded image data.
35    pub data: String,
36    /// MIME type of the image (e.g., "image/png", "image/jpeg").
37    pub mime: &'static str,
38    /// Optional source filename for attachments created from local files.
39    pub filename: Option<String>,
40}
41
42/// Clipboard content classification used by performable terminal paste shortcuts.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub enum ClipboardPasteContent {
45    /// Plain text that the terminal host can paste directly.
46    Text(String),
47    /// A file list, image, or another richer format that the child should inspect itself.
48    Rich,
49    /// No known text or rich format was available. This may also represent an empty clipboard.
50    Unavailable,
51}
52
53impl ImageContent {
54    /// Creates new image content from raw bytes, encoding as base64.
55    pub fn from_bytes(bytes: &[u8], format: ImageFormat) -> Self {
56        use base64::{Engine as _, engine::general_purpose};
57        Self {
58            data: general_purpose::STANDARD.encode(bytes),
59            mime: format.mime_type(),
60            filename: None,
61        }
62    }
63
64    /// Returns this image content with source filename metadata attached.
65    pub fn with_filename(mut self, filename: impl Into<String>) -> Self {
66        self.filename = Some(filename.into());
67        self
68    }
69
70    /// Decodes the base64 data back to raw bytes.
71    pub fn to_bytes(&self) -> Result<Vec<u8>, base64::DecodeError> {
72        use base64::{Engine as _, engine::general_purpose};
73        general_purpose::STANDARD.decode(&self.data)
74    }
75}
76
77/// Abstraction over clipboard backends.
78pub trait ClipboardProvider {
79    /// Read text from the system clipboard.
80    fn read_clipboard_text(&mut self) -> Result<String, ClipboardError>;
81    /// Write text to the system clipboard.
82    fn write_clipboard_text(&mut self, text: &str) -> Result<(), ClipboardError>;
83
84    /// Classify content for a terminal's performable paste shortcut.
85    ///
86    /// Providers with richer format support should prefer files/images over a text fallback. The
87    /// default keeps custom providers source-compatible and treats text read failures as genuine
88    /// provider failures rather than guessing that the clipboard contains another format.
89    fn read_terminal_paste(&mut self) -> Result<ClipboardPasteContent, ClipboardError> {
90        self.read_clipboard_text().map(ClipboardPasteContent::Text)
91    }
92
93    /// Update any provider-side cache used to satisfy sync clipboard reads.
94    fn set_clipboard_text_cache(&mut self, _text: String) {}
95
96    /// Read text from the primary selection, if supported.
97    fn read_primary_selection_text(&mut self) -> Result<String, ClipboardError> {
98        Err(ClipboardError::unsupported(
99            ClipboardOperation::ReadPrimarySelection,
100        ))
101    }
102
103    /// Write text to the primary selection, if supported.
104    fn write_primary_selection_text(&mut self, _text: &str) -> Result<(), ClipboardError> {
105        Err(ClipboardError::unsupported(
106            ClipboardOperation::WritePrimarySelection,
107        ))
108    }
109
110    /// Returns true when primary selection is supported.
111    fn supports_primary_selection(&self) -> bool {
112        false
113    }
114
115    /// Read an image from the system clipboard.
116    /// Returns the image as base64-encoded data.
117    fn read_clipboard_image(&mut self) -> Result<ImageContent, ClipboardError> {
118        Err(ClipboardError::unsupported(
119            ClipboardOperation::ReadImageClipboard,
120        ))
121    }
122
123    /// Write an image to the system clipboard.
124    /// Accepts base64-encoded image data.
125    fn write_clipboard_image(&mut self, _content: &ImageContent) -> Result<(), ClipboardError> {
126        Err(ClipboardError::unsupported(
127            ClipboardOperation::WriteImageClipboard,
128        ))
129    }
130
131    /// Read a file list from the system clipboard.
132    ///
133    /// Returns an empty vector when the clipboard holds no file list, which is
134    /// distinct from the provider being unable to read one at all.
135    fn read_clipboard_files(&mut self) -> Result<Vec<PathBuf>, ClipboardError> {
136        Err(ClipboardError::unsupported(
137            ClipboardOperation::ReadFileClipboard,
138        ))
139    }
140
141    /// Write a file list to the system clipboard.
142    ///
143    /// Paths are expected to be absolute and to exist; callers going through
144    /// [`ClipboardHandle::copy_files`](crate::ClipboardHandle::copy_files)
145    /// get that guaranteed for them.
146    fn write_clipboard_files(&mut self, _paths: &[PathBuf]) -> Result<(), ClipboardError> {
147        Err(ClipboardError::unsupported(
148            ClipboardOperation::WriteFileClipboard,
149        ))
150    }
151
152    /// Returns true when the provider can exchange file lists.
153    fn supports_file_clipboard(&self) -> bool {
154        false
155    }
156}
157
158/// Clipboard provider that reports all operations as unsupported.
159///
160/// Used as the default when the `clipboard` feature is disabled.
161#[cfg(not(feature = "clipboard"))]
162pub(crate) struct NoOpClipboardProvider;
163
164#[cfg(not(feature = "clipboard"))]
165impl ClipboardProvider for NoOpClipboardProvider {
166    fn read_clipboard_text(&mut self) -> Result<String, ClipboardError> {
167        Err(ClipboardError::unsupported(
168            ClipboardOperation::ReadClipboard,
169        ))
170    }
171
172    fn write_clipboard_text(&mut self, _text: &str) -> Result<(), ClipboardError> {
173        Err(ClipboardError::unsupported(
174            ClipboardOperation::WriteClipboard,
175        ))
176    }
177}
178
179/// Arboard-backed system clipboard provider.
180#[cfg(all(feature = "clipboard", not(target_arch = "wasm32")))]
181pub(crate) struct SystemClipboardProvider {
182    clipboard: Option<arboard::Clipboard>,
183}
184
185#[cfg(all(feature = "clipboard", not(target_arch = "wasm32")))]
186impl SystemClipboardProvider {
187    pub fn new() -> Self {
188        Self { clipboard: None }
189    }
190
191    fn ensure_clipboard(
192        &mut self,
193        operation: ClipboardOperation,
194    ) -> Result<&mut arboard::Clipboard, ClipboardError> {
195        if self.clipboard.is_none() {
196            self.clipboard = Some(
197                arboard::Clipboard::new()
198                    .map_err(|err| ClipboardError::provider(operation, err.to_string()))?,
199            );
200        }
201
202        self.clipboard
203            .as_mut()
204            .ok_or_else(|| ClipboardError::provider(operation, "init"))
205    }
206}
207
208#[cfg(all(feature = "clipboard", not(target_arch = "wasm32")))]
209impl Default for SystemClipboardProvider {
210    fn default() -> Self {
211        Self::new()
212    }
213}
214
215#[cfg(all(feature = "clipboard", not(target_arch = "wasm32")))]
216impl ClipboardProvider for SystemClipboardProvider {
217    fn read_clipboard_text(&mut self) -> Result<String, ClipboardError> {
218        let clipboard = self.ensure_clipboard(ClipboardOperation::ReadClipboard)?;
219        clipboard.get_text().map_err(|err| {
220            ClipboardError::provider(ClipboardOperation::ReadClipboard, err.to_string())
221        })
222    }
223
224    fn write_clipboard_text(&mut self, text: &str) -> Result<(), ClipboardError> {
225        let clipboard = self.ensure_clipboard(ClipboardOperation::WriteClipboard)?;
226        clipboard.set_text(text.to_string()).map_err(|err| {
227            ClipboardError::provider(ClipboardOperation::WriteClipboard, err.to_string())
228        })
229    }
230
231    fn read_terminal_paste(&mut self) -> Result<ClipboardPasteContent, ClipboardError> {
232        let clipboard = self.ensure_clipboard(ClipboardOperation::ReadClipboard)?;
233        match clipboard.get().file_list() {
234            Ok(files) if !files.is_empty() => return Ok(ClipboardPasteContent::Rich),
235            Ok(_) | Err(arboard::Error::ContentNotAvailable) => {}
236            Err(err) => {
237                return Err(ClipboardError::provider(
238                    ClipboardOperation::ReadClipboard,
239                    err.to_string(),
240                ));
241            }
242        }
243
244        #[cfg(feature = "clipboard-images")]
245        {
246            let probe_image_data = true;
247            #[cfg(target_os = "linux")]
248            let probe_image_data = match wayland_clipboard_has_png()? {
249                Some(true) => return Ok(ClipboardPasteContent::Rich),
250                Some(false) => false,
251                None => probe_image_data,
252            };
253
254            if probe_image_data {
255                // arboard has no cross-platform format-presence API. X11, macOS, and Windows must
256                // currently decode the advertised image to confirm it; Wayland uses the MIME list
257                // above and avoids reading the payload.
258                let clipboard = self.ensure_clipboard(ClipboardOperation::ReadImageClipboard)?;
259                match clipboard.get().image() {
260                    Ok(_) => return Ok(ClipboardPasteContent::Rich),
261                    Err(arboard::Error::ContentNotAvailable) => {}
262                    Err(err) => {
263                        return Err(ClipboardError::provider(
264                            ClipboardOperation::ReadImageClipboard,
265                            err.to_string(),
266                        ));
267                    }
268                }
269            }
270        }
271
272        let clipboard = self.ensure_clipboard(ClipboardOperation::ReadClipboard)?;
273        match clipboard.get().text() {
274            Ok(text) => Ok(ClipboardPasteContent::Text(text)),
275            Err(arboard::Error::ContentNotAvailable) => Ok(ClipboardPasteContent::Unavailable),
276            Err(err) => Err(ClipboardError::provider(
277                ClipboardOperation::ReadClipboard,
278                err.to_string(),
279            )),
280        }
281    }
282
283    fn read_primary_selection_text(&mut self) -> Result<String, ClipboardError> {
284        #[cfg(target_os = "linux")]
285        {
286            use arboard::{GetExtLinux, LinuxClipboardKind};
287            let clipboard = self.ensure_clipboard(ClipboardOperation::ReadPrimarySelection)?;
288            clipboard
289                .get()
290                .clipboard(LinuxClipboardKind::Primary)
291                .text()
292                .map_err(|err| {
293                    ClipboardError::provider(
294                        ClipboardOperation::ReadPrimarySelection,
295                        err.to_string(),
296                    )
297                })
298        }
299
300        #[cfg(not(target_os = "linux"))]
301        {
302            Err(ClipboardError::unsupported(
303                ClipboardOperation::ReadPrimarySelection,
304            ))
305        }
306    }
307
308    fn write_primary_selection_text(&mut self, _text: &str) -> Result<(), ClipboardError> {
309        #[cfg(target_os = "linux")]
310        {
311            use arboard::{LinuxClipboardKind, SetExtLinux};
312            let clipboard = self.ensure_clipboard(ClipboardOperation::WritePrimarySelection)?;
313            clipboard
314                .set()
315                .clipboard(LinuxClipboardKind::Primary)
316                .text(_text.to_string())
317                .map_err(|err| {
318                    ClipboardError::provider(
319                        ClipboardOperation::WritePrimarySelection,
320                        err.to_string(),
321                    )
322                })
323        }
324
325        #[cfg(not(target_os = "linux"))]
326        {
327            Err(ClipboardError::unsupported(
328                ClipboardOperation::WritePrimarySelection,
329            ))
330        }
331    }
332
333    fn supports_primary_selection(&self) -> bool {
334        cfg!(target_os = "linux")
335    }
336
337    fn read_clipboard_files(&mut self) -> Result<Vec<PathBuf>, ClipboardError> {
338        let clipboard = self.ensure_clipboard(ClipboardOperation::ReadFileClipboard)?;
339        match clipboard.get().file_list() {
340            Ok(paths) => Ok(paths),
341            // An absent file list is an empty result, not a failure.
342            Err(arboard::Error::ContentNotAvailable) => Ok(Vec::new()),
343            Err(err) => Err(ClipboardError::provider(
344                ClipboardOperation::ReadFileClipboard,
345                err.to_string(),
346            )),
347        }
348    }
349
350    fn write_clipboard_files(&mut self, paths: &[PathBuf]) -> Result<(), ClipboardError> {
351        let clipboard = self.ensure_clipboard(ClipboardOperation::WriteFileClipboard)?;
352        clipboard.set().file_list(paths).map_err(|err| {
353            ClipboardError::provider(ClipboardOperation::WriteFileClipboard, err.to_string())
354        })
355    }
356
357    fn supports_file_clipboard(&self) -> bool {
358        true
359    }
360
361    #[cfg(feature = "clipboard-images")]
362    fn read_clipboard_image(&mut self) -> Result<ImageContent, ClipboardError> {
363        #[cfg(target_os = "linux")]
364        if let Some(content) = read_wayland_png_clipboard()? {
365            return Ok(content);
366        }
367
368        let clipboard = self.ensure_clipboard(ClipboardOperation::ReadImageClipboard)?;
369
370        let image_data = clipboard.get_image().map_err(|err| {
371            ClipboardError::provider(ClipboardOperation::ReadImageClipboard, err.to_string())
372        })?;
373
374        let width = image_data.width;
375        let height = image_data.height;
376        let rgba_bytes = image_data.bytes.into_owned();
377
378        let mut png_buffer = Cursor::new(Vec::new());
379        {
380            let image_buffer = image::ImageBuffer::<image::Rgba<u8>, Vec<u8>>::from_raw(
381                width as u32,
382                height as u32,
383                rgba_bytes,
384            )
385            .ok_or_else(|| {
386                ClipboardError::provider(
387                    ClipboardOperation::ReadImageClipboard,
388                    "invalid image buffer dimensions",
389                )
390            })?;
391
392            image_buffer
393                .write_to(&mut png_buffer, image::ImageFormat::Png)
394                .map_err(|err| {
395                    ClipboardError::provider(
396                        ClipboardOperation::ReadImageClipboard,
397                        format!("PNG encode error: {}", err),
398                    )
399                })?;
400        }
401
402        Ok(ImageContent::from_bytes(
403            png_buffer.into_inner().as_slice(),
404            ImageFormat::Png,
405        ))
406    }
407
408    #[cfg(feature = "clipboard-images")]
409    fn write_clipboard_image(&mut self, content: &ImageContent) -> Result<(), ClipboardError> {
410        let clipboard = self.ensure_clipboard(ClipboardOperation::WriteImageClipboard)?;
411
412        let bytes = content.to_bytes().map_err(|err| {
413            ClipboardError::provider(
414                ClipboardOperation::WriteImageClipboard,
415                format!("base64 decode error: {}", err),
416            )
417        })?;
418
419        let img = image::load_from_memory(&bytes).map_err(|err| {
420            ClipboardError::provider(
421                ClipboardOperation::WriteImageClipboard,
422                format!("image decode error: {}", err),
423            )
424        })?;
425
426        let rgba = img.to_rgba8();
427        let width = rgba.width() as usize;
428        let height = rgba.height() as usize;
429        let pixels: Vec<u8> = rgba.into_raw();
430
431        clipboard
432            .set_image(arboard::ImageData {
433                width,
434                height,
435                bytes: std::borrow::Cow::Owned(pixels),
436            })
437            .map_err(|err| {
438                ClipboardError::provider(ClipboardOperation::WriteImageClipboard, err.to_string())
439            })
440    }
441}
442
443#[cfg(all(
444    feature = "clipboard",
445    feature = "clipboard-images",
446    target_os = "linux"
447))]
448fn read_wayland_png_clipboard() -> Result<Option<ImageContent>, ClipboardError> {
449    use std::io::Read as _;
450
451    use wl_clipboard_rs::paste::{ClipboardType, Error, MimeType, Seat, get_contents};
452
453    if std::env::var_os("WAYLAND_DISPLAY").is_none() {
454        return Ok(None);
455    }
456
457    let (mut reader, _) = match get_contents(
458        ClipboardType::Regular,
459        Seat::Unspecified,
460        MimeType::Specific(ImageFormat::Png.mime_type()),
461    ) {
462        Ok(result) => result,
463        Err(
464            Error::NoSeats
465            | Error::ClipboardEmpty
466            | Error::NoMimeType
467            | Error::SocketOpenError(_)
468            | Error::WaylandConnection(_)
469            | Error::MissingProtocol { .. }
470            | Error::PrimarySelectionUnsupported
471            | Error::SeatNotFound,
472        ) => return Ok(None),
473        Err(err) => {
474            return Err(ClipboardError::provider(
475                ClipboardOperation::ReadImageClipboard,
476                err.to_string(),
477            ));
478        }
479    };
480
481    let mut bytes = Vec::new();
482    reader.read_to_end(&mut bytes).map_err(|err| {
483        ClipboardError::provider(ClipboardOperation::ReadImageClipboard, err.to_string())
484    })?;
485
486    if bytes.is_empty() {
487        return Ok(None);
488    }
489
490    Ok(Some(ImageContent::from_bytes(&bytes, ImageFormat::Png)))
491}
492
493#[cfg(all(
494    feature = "clipboard",
495    feature = "clipboard-images",
496    target_os = "linux"
497))]
498fn wayland_clipboard_has_png() -> Result<Option<bool>, ClipboardError> {
499    use wl_clipboard_rs::paste::{ClipboardType, Error, Seat, get_mime_types};
500
501    if std::env::var_os("WAYLAND_DISPLAY").is_none() {
502        return Ok(None);
503    }
504
505    match get_mime_types(ClipboardType::Regular, Seat::Unspecified) {
506        Ok(types) => Ok(Some(types.contains(ImageFormat::Png.mime_type()))),
507        Err(Error::ClipboardEmpty | Error::NoMimeType) => Ok(Some(false)),
508        Err(
509            Error::NoSeats
510            | Error::SocketOpenError(_)
511            | Error::WaylandConnection(_)
512            | Error::MissingProtocol { .. }
513            | Error::PrimarySelectionUnsupported
514            | Error::SeatNotFound,
515        ) => Ok(None),
516        Err(err) => Err(ClipboardError::provider(
517            ClipboardOperation::ReadImageClipboard,
518            err.to_string(),
519        )),
520    }
521}
522
523#[cfg(all(target_arch = "wasm32", feature = "web"))]
524#[derive(Default)]
525pub(crate) struct WebClipboardProvider {
526    cache: std::rc::Rc<std::cell::RefCell<Option<String>>>,
527}
528
529#[cfg(all(target_arch = "wasm32", feature = "web"))]
530impl WebClipboardProvider {
531    pub fn new() -> Self {
532        Self::default()
533    }
534}
535
536#[cfg(all(target_arch = "wasm32", feature = "web"))]
537impl ClipboardProvider for WebClipboardProvider {
538    fn read_clipboard_text(&mut self) -> Result<String, ClipboardError> {
539        if let Some(cached) = self.cache.borrow().clone() {
540            return Ok(cached);
541        }
542
543        Err(ClipboardError::provider(
544            ClipboardOperation::ReadClipboard,
545            "web clipboard read requires a primed cache from a paste gesture",
546        ))
547    }
548
549    fn write_clipboard_text(&mut self, text: &str) -> Result<(), ClipboardError> {
550        let window = web_sys::window().ok_or_else(|| {
551            ClipboardError::provider(ClipboardOperation::WriteClipboard, "window is unavailable")
552        })?;
553        let navigator = window.navigator();
554        let clipboard = navigator.clipboard();
555        let promise = clipboard.write_text(text);
556
557        let cache = std::rc::Rc::clone(&self.cache);
558        let text = text.to_string();
559        let fut = wasm_bindgen_futures::JsFuture::from(promise);
560        wasm_bindgen_futures::spawn_local(async move {
561            match fut.await {
562                Ok(_) => {
563                    *cache.borrow_mut() = Some(text);
564                }
565                Err(err) => {
566                    web_sys::console::warn_1(&err);
567                }
568            }
569        });
570        Ok(())
571    }
572
573    fn set_clipboard_text_cache(&mut self, text: String) {
574        *self.cache.borrow_mut() = Some(text);
575    }
576
577    fn supports_primary_selection(&self) -> bool {
578        false
579    }
580}