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 crate::clipboard::error::{ClipboardError, ClipboardOperation};
9
10/// Supported image formats for clipboard operations.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum ImageFormat {
13    /// PNG format (lossless, larger file size).
14    Png,
15    /// JPEG format (lossy, smaller file size).
16    Jpeg,
17}
18
19impl ImageFormat {
20    /// Returns the MIME type for this image format.
21    pub const fn mime_type(&self) -> &'static str {
22        match self {
23            Self::Png => "image/png",
24            Self::Jpeg => "image/jpeg",
25        }
26    }
27}
28
29/// Image content read from or written to the clipboard.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct ImageContent {
32    /// Base64-encoded image data.
33    pub data: String,
34    /// MIME type of the image (e.g., "image/png", "image/jpeg").
35    pub mime: &'static str,
36    /// Optional source filename for attachments created from local files.
37    pub filename: Option<String>,
38}
39
40impl ImageContent {
41    /// Creates new image content from raw bytes, encoding as base64.
42    pub fn from_bytes(bytes: &[u8], format: ImageFormat) -> Self {
43        use base64::{Engine as _, engine::general_purpose};
44        Self {
45            data: general_purpose::STANDARD.encode(bytes),
46            mime: format.mime_type(),
47            filename: None,
48        }
49    }
50
51    /// Returns this image content with source filename metadata attached.
52    pub fn with_filename(mut self, filename: impl Into<String>) -> Self {
53        self.filename = Some(filename.into());
54        self
55    }
56
57    /// Decodes the base64 data back to raw bytes.
58    pub fn to_bytes(&self) -> Result<Vec<u8>, base64::DecodeError> {
59        use base64::{Engine as _, engine::general_purpose};
60        general_purpose::STANDARD.decode(&self.data)
61    }
62}
63
64/// Abstraction over clipboard backends.
65pub trait ClipboardProvider {
66    /// Read text from the system clipboard.
67    fn read_clipboard_text(&mut self) -> Result<String, ClipboardError>;
68    /// Write text to the system clipboard.
69    fn write_clipboard_text(&mut self, text: &str) -> Result<(), ClipboardError>;
70
71    /// Update any provider-side cache used to satisfy sync clipboard reads.
72    fn set_clipboard_text_cache(&mut self, _text: String) {}
73
74    /// Read text from the primary selection, if supported.
75    fn read_primary_selection_text(&mut self) -> Result<String, ClipboardError> {
76        Err(ClipboardError::unsupported(
77            ClipboardOperation::ReadPrimarySelection,
78        ))
79    }
80
81    /// Write text to the primary selection, if supported.
82    fn write_primary_selection_text(&mut self, _text: &str) -> Result<(), ClipboardError> {
83        Err(ClipboardError::unsupported(
84            ClipboardOperation::WritePrimarySelection,
85        ))
86    }
87
88    /// Returns true when primary selection is supported.
89    fn supports_primary_selection(&self) -> bool {
90        false
91    }
92
93    /// Read an image from the system clipboard.
94    /// Returns the image as base64-encoded data.
95    fn read_clipboard_image(&mut self) -> Result<ImageContent, ClipboardError> {
96        Err(ClipboardError::unsupported(
97            ClipboardOperation::ReadImageClipboard,
98        ))
99    }
100
101    /// Write an image to the system clipboard.
102    /// Accepts base64-encoded image data.
103    fn write_clipboard_image(&mut self, _content: &ImageContent) -> Result<(), ClipboardError> {
104        Err(ClipboardError::unsupported(
105            ClipboardOperation::WriteImageClipboard,
106        ))
107    }
108}
109
110/// Clipboard provider that reports all operations as unsupported.
111///
112/// Used as the default when the `clipboard` feature is disabled.
113#[cfg(not(feature = "clipboard"))]
114pub(crate) struct NoOpClipboardProvider;
115
116#[cfg(not(feature = "clipboard"))]
117impl ClipboardProvider for NoOpClipboardProvider {
118    fn read_clipboard_text(&mut self) -> Result<String, ClipboardError> {
119        Err(ClipboardError::unsupported(
120            ClipboardOperation::ReadClipboard,
121        ))
122    }
123
124    fn write_clipboard_text(&mut self, _text: &str) -> Result<(), ClipboardError> {
125        Err(ClipboardError::unsupported(
126            ClipboardOperation::WriteClipboard,
127        ))
128    }
129}
130
131/// Arboard-backed system clipboard provider.
132#[cfg(all(feature = "clipboard", not(target_arch = "wasm32")))]
133pub(crate) struct SystemClipboardProvider {
134    clipboard: Option<arboard::Clipboard>,
135}
136
137#[cfg(all(feature = "clipboard", not(target_arch = "wasm32")))]
138impl SystemClipboardProvider {
139    pub fn new() -> Self {
140        Self { clipboard: None }
141    }
142
143    fn ensure_clipboard(
144        &mut self,
145        operation: ClipboardOperation,
146    ) -> Result<&mut arboard::Clipboard, ClipboardError> {
147        if self.clipboard.is_none() {
148            self.clipboard = Some(
149                arboard::Clipboard::new()
150                    .map_err(|err| ClipboardError::provider(operation, err.to_string()))?,
151            );
152        }
153
154        self.clipboard
155            .as_mut()
156            .ok_or_else(|| ClipboardError::provider(operation, "init"))
157    }
158}
159
160#[cfg(all(feature = "clipboard", not(target_arch = "wasm32")))]
161impl Default for SystemClipboardProvider {
162    fn default() -> Self {
163        Self::new()
164    }
165}
166
167#[cfg(all(feature = "clipboard", not(target_arch = "wasm32")))]
168impl ClipboardProvider for SystemClipboardProvider {
169    fn read_clipboard_text(&mut self) -> Result<String, ClipboardError> {
170        let clipboard = self.ensure_clipboard(ClipboardOperation::ReadClipboard)?;
171        clipboard.get_text().map_err(|err| {
172            ClipboardError::provider(ClipboardOperation::ReadClipboard, err.to_string())
173        })
174    }
175
176    fn write_clipboard_text(&mut self, text: &str) -> Result<(), ClipboardError> {
177        let clipboard = self.ensure_clipboard(ClipboardOperation::WriteClipboard)?;
178        clipboard.set_text(text.to_string()).map_err(|err| {
179            ClipboardError::provider(ClipboardOperation::WriteClipboard, err.to_string())
180        })
181    }
182
183    fn read_primary_selection_text(&mut self) -> Result<String, ClipboardError> {
184        #[cfg(target_os = "linux")]
185        {
186            use arboard::{GetExtLinux, LinuxClipboardKind};
187            let clipboard = self.ensure_clipboard(ClipboardOperation::ReadPrimarySelection)?;
188            clipboard
189                .get()
190                .clipboard(LinuxClipboardKind::Primary)
191                .text()
192                .map_err(|err| {
193                    ClipboardError::provider(
194                        ClipboardOperation::ReadPrimarySelection,
195                        err.to_string(),
196                    )
197                })
198        }
199
200        #[cfg(not(target_os = "linux"))]
201        {
202            Err(ClipboardError::unsupported(
203                ClipboardOperation::ReadPrimarySelection,
204            ))
205        }
206    }
207
208    fn write_primary_selection_text(&mut self, _text: &str) -> Result<(), ClipboardError> {
209        #[cfg(target_os = "linux")]
210        {
211            use arboard::{LinuxClipboardKind, SetExtLinux};
212            let clipboard = self.ensure_clipboard(ClipboardOperation::WritePrimarySelection)?;
213            clipboard
214                .set()
215                .clipboard(LinuxClipboardKind::Primary)
216                .text(_text.to_string())
217                .map_err(|err| {
218                    ClipboardError::provider(
219                        ClipboardOperation::WritePrimarySelection,
220                        err.to_string(),
221                    )
222                })
223        }
224
225        #[cfg(not(target_os = "linux"))]
226        {
227            Err(ClipboardError::unsupported(
228                ClipboardOperation::WritePrimarySelection,
229            ))
230        }
231    }
232
233    fn supports_primary_selection(&self) -> bool {
234        cfg!(target_os = "linux")
235    }
236
237    #[cfg(feature = "clipboard-images")]
238    fn read_clipboard_image(&mut self) -> Result<ImageContent, ClipboardError> {
239        #[cfg(target_os = "linux")]
240        if let Some(content) = read_wayland_png_clipboard()? {
241            return Ok(content);
242        }
243
244        let clipboard = self.ensure_clipboard(ClipboardOperation::ReadImageClipboard)?;
245
246        let image_data = clipboard.get_image().map_err(|err| {
247            ClipboardError::provider(ClipboardOperation::ReadImageClipboard, err.to_string())
248        })?;
249
250        let width = image_data.width;
251        let height = image_data.height;
252        let rgba_bytes = image_data.bytes.into_owned();
253
254        let mut png_buffer = Cursor::new(Vec::new());
255        {
256            let image_buffer = image::ImageBuffer::<image::Rgba<u8>, Vec<u8>>::from_raw(
257                width as u32,
258                height as u32,
259                rgba_bytes,
260            )
261            .ok_or_else(|| {
262                ClipboardError::provider(
263                    ClipboardOperation::ReadImageClipboard,
264                    "invalid image buffer dimensions",
265                )
266            })?;
267
268            image_buffer
269                .write_to(&mut png_buffer, image::ImageFormat::Png)
270                .map_err(|err| {
271                    ClipboardError::provider(
272                        ClipboardOperation::ReadImageClipboard,
273                        format!("PNG encode error: {}", err),
274                    )
275                })?;
276        }
277
278        Ok(ImageContent::from_bytes(
279            png_buffer.into_inner().as_slice(),
280            ImageFormat::Png,
281        ))
282    }
283
284    #[cfg(feature = "clipboard-images")]
285    fn write_clipboard_image(&mut self, content: &ImageContent) -> Result<(), ClipboardError> {
286        let clipboard = self.ensure_clipboard(ClipboardOperation::WriteImageClipboard)?;
287
288        let bytes = content.to_bytes().map_err(|err| {
289            ClipboardError::provider(
290                ClipboardOperation::WriteImageClipboard,
291                format!("base64 decode error: {}", err),
292            )
293        })?;
294
295        let img = image::load_from_memory(&bytes).map_err(|err| {
296            ClipboardError::provider(
297                ClipboardOperation::WriteImageClipboard,
298                format!("image decode error: {}", err),
299            )
300        })?;
301
302        let rgba = img.to_rgba8();
303        let width = rgba.width() as usize;
304        let height = rgba.height() as usize;
305        let pixels: Vec<u8> = rgba.into_raw();
306
307        clipboard
308            .set_image(arboard::ImageData {
309                width,
310                height,
311                bytes: std::borrow::Cow::Owned(pixels),
312            })
313            .map_err(|err| {
314                ClipboardError::provider(ClipboardOperation::WriteImageClipboard, err.to_string())
315            })
316    }
317}
318
319#[cfg(all(
320    feature = "clipboard",
321    feature = "clipboard-images",
322    target_os = "linux"
323))]
324fn read_wayland_png_clipboard() -> Result<Option<ImageContent>, ClipboardError> {
325    use std::io::Read as _;
326
327    use wl_clipboard_rs::paste::{ClipboardType, Error, MimeType, Seat, get_contents};
328
329    if std::env::var_os("WAYLAND_DISPLAY").is_none() {
330        return Ok(None);
331    }
332
333    let (mut reader, _) = match get_contents(
334        ClipboardType::Regular,
335        Seat::Unspecified,
336        MimeType::Specific(ImageFormat::Png.mime_type()),
337    ) {
338        Ok(result) => result,
339        Err(
340            Error::NoSeats
341            | Error::ClipboardEmpty
342            | Error::NoMimeType
343            | Error::SocketOpenError(_)
344            | Error::WaylandConnection(_)
345            | Error::MissingProtocol { .. }
346            | Error::PrimarySelectionUnsupported
347            | Error::SeatNotFound,
348        ) => return Ok(None),
349        Err(err) => {
350            return Err(ClipboardError::provider(
351                ClipboardOperation::ReadImageClipboard,
352                err.to_string(),
353            ));
354        }
355    };
356
357    let mut bytes = Vec::new();
358    reader.read_to_end(&mut bytes).map_err(|err| {
359        ClipboardError::provider(ClipboardOperation::ReadImageClipboard, err.to_string())
360    })?;
361
362    if bytes.is_empty() {
363        return Ok(None);
364    }
365
366    Ok(Some(ImageContent::from_bytes(&bytes, ImageFormat::Png)))
367}
368
369#[cfg(all(target_arch = "wasm32", feature = "web"))]
370#[derive(Default)]
371pub(crate) struct WebClipboardProvider {
372    cache: std::rc::Rc<std::cell::RefCell<Option<String>>>,
373}
374
375#[cfg(all(target_arch = "wasm32", feature = "web"))]
376impl WebClipboardProvider {
377    pub fn new() -> Self {
378        Self::default()
379    }
380}
381
382#[cfg(all(target_arch = "wasm32", feature = "web"))]
383impl ClipboardProvider for WebClipboardProvider {
384    fn read_clipboard_text(&mut self) -> Result<String, ClipboardError> {
385        if let Some(cached) = self.cache.borrow().clone() {
386            return Ok(cached);
387        }
388
389        Err(ClipboardError::provider(
390            ClipboardOperation::ReadClipboard,
391            "web clipboard read requires a primed cache from a paste gesture",
392        ))
393    }
394
395    fn write_clipboard_text(&mut self, text: &str) -> Result<(), ClipboardError> {
396        let window = web_sys::window().ok_or_else(|| {
397            ClipboardError::provider(ClipboardOperation::WriteClipboard, "window is unavailable")
398        })?;
399        let navigator = window.navigator();
400        let clipboard = navigator.clipboard();
401        let promise = clipboard.write_text(text);
402
403        let cache = std::rc::Rc::clone(&self.cache);
404        let text = text.to_string();
405        let fut = wasm_bindgen_futures::JsFuture::from(promise);
406        wasm_bindgen_futures::spawn_local(async move {
407            match fut.await {
408                Ok(_) => {
409                    *cache.borrow_mut() = Some(text);
410                }
411                Err(err) => {
412                    web_sys::console::warn_1(&err);
413                }
414            }
415        });
416        Ok(())
417    }
418
419    fn set_clipboard_text_cache(&mut self, text: String) {
420        *self.cache.borrow_mut() = Some(text);
421    }
422
423    fn supports_primary_selection(&self) -> bool {
424        false
425    }
426}