Skip to main content

retroglyph_window/
clipboard.rs

1//! System clipboard read/write for windowed apps (issue #296).
2//!
3//! Windowed apps have no equivalent of the terminal backends' bracketed-paste path (see
4//! `crates/crossterm/src/lib.rs`'s `Event::Paste` handling) for pulling text *out* of the
5//! clipboard on demand, nor any way to push text *into* it (e.g. a "copy" keybinding). This
6//! module fills that gap with a small [`Clipboard`](crate::clipboard::Clipboard) trait plus a
7//! native, `arboard`-backed [`SystemClipboard`](crate::clipboard::SystemClipboard) implementation.
8//!
9//! Kept out of [`retroglyph_core::backend::Backend`]: clipboard access has no notion in the
10//! terminal backends this workspace also supports (`crossterm`, `software`'s headless test
11//! paths), and a windowed app that wants it can reach for this trait directly from its own
12//! update loop instead of threading it through every `Backend` implementation.
13//!
14//! # Testing
15//!
16//! The real OS clipboard ([`SystemClipboard`](crate::clipboard::SystemClipboard)) cannot be
17//! exercised headlessly in CI (no display server / clipboard manager is guaranteed to be
18//! running), so it has no automated test coverage here; it needs manual verification on each
19//! target platform instead. [`Clipboard`](crate::clipboard::Clipboard) is a plain
20//! trait specifically so app code (and this module's own tests) can substitute an in-memory fake
21//! in its place; see the `tests` module below for an example.
22
23use std::fmt;
24
25/// Read/write access to a text clipboard.
26///
27/// A trait rather than a single concrete type so callers can substitute a fake for testing --
28/// see this module's doc comment.
29///
30/// # Examples
31///
32/// ```
33/// use retroglyph_window::clipboard::{Clipboard, ClipboardError};
34///
35/// #[derive(Default)]
36/// struct FakeClipboard {
37///     contents: Option<String>,
38/// }
39///
40/// impl Clipboard for FakeClipboard {
41///     fn get_text(&mut self) -> Result<String, ClipboardError> {
42///         self.contents
43///             .clone()
44///             .ok_or_else(|| ClipboardError::new("clipboard is empty"))
45///     }
46///
47///     fn set_text(&mut self, text: String) -> Result<(), ClipboardError> {
48///         self.contents = Some(text);
49///         Ok(())
50///     }
51/// }
52///
53/// let mut clip = FakeClipboard::default();
54/// clip.set_text("hello".to_string())?;
55/// assert_eq!(clip.get_text()?, "hello");
56/// # Ok::<(), ClipboardError>(())
57/// ```
58pub trait Clipboard {
59    /// Returns the current clipboard contents as text.
60    ///
61    /// # Errors
62    ///
63    /// Returns [`ClipboardError`] if the clipboard is unavailable, or does not currently hold
64    /// text (e.g. it holds an image, or is empty).
65    fn get_text(&mut self) -> Result<String, ClipboardError>;
66
67    /// Replaces the clipboard contents with `text`.
68    ///
69    /// # Errors
70    ///
71    /// Returns [`ClipboardError`] if the clipboard is unavailable.
72    fn set_text(&mut self, text: String) -> Result<(), ClipboardError>;
73}
74
75/// Error returned by [`Clipboard::get_text`]/[`Clipboard::set_text`].
76///
77/// An opaque, message-carrying wrapper rather than an enum of specific failure causes: the two
78/// implementations this crate ships (arboard on native, a test fake) fail for platform- or
79/// fake-specific reasons that don't share a meaningful common taxonomy, so the message is kept
80/// as the one thing that's actually useful across both: surfacing it in logs/error messages.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct ClipboardError(String);
83
84impl ClipboardError {
85    /// Wraps `message` as a [`ClipboardError`].
86    #[must_use]
87    pub fn new(message: impl Into<String>) -> Self {
88        Self(message.into())
89    }
90}
91
92impl fmt::Display for ClipboardError {
93    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94        write!(f, "clipboard error: {}", self.0)
95    }
96}
97
98impl std::error::Error for ClipboardError {}
99
100/// The native OS clipboard, backed by [`arboard`].
101///
102/// Not available on `wasm32`: the browser clipboard API
103/// (`navigator.clipboard`) is async-only (returns a `Promise`), which does not fit
104/// [`Clipboard`]'s synchronous methods, and `arboard` itself does not build for
105/// `wasm32-unknown-unknown`: see this crate's `Cargo.toml` for the target-gating.
106#[cfg(not(target_arch = "wasm32"))]
107pub struct SystemClipboard(arboard::Clipboard);
108
109#[cfg(not(target_arch = "wasm32"))]
110impl fmt::Debug for SystemClipboard {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        f.debug_struct("SystemClipboard").finish_non_exhaustive()
113    }
114}
115
116#[cfg(not(target_arch = "wasm32"))]
117impl SystemClipboard {
118    /// Opens a handle to the platform clipboard.
119    ///
120    /// # Errors
121    ///
122    /// Returns [`ClipboardError`] if the platform clipboard could not be opened (e.g. no
123    /// clipboard manager / display server available).
124    pub fn new() -> Result<Self, ClipboardError> {
125        arboard::Clipboard::new()
126            .map(Self)
127            .map_err(|e| ClipboardError::new(e.to_string()))
128    }
129}
130
131#[cfg(not(target_arch = "wasm32"))]
132impl Clipboard for SystemClipboard {
133    fn get_text(&mut self) -> Result<String, ClipboardError> {
134        self.0
135            .get_text()
136            .map_err(|e| ClipboardError::new(e.to_string()))
137    }
138
139    fn set_text(&mut self, text: String) -> Result<(), ClipboardError> {
140        self.0
141            .set_text(text)
142            .map_err(|e| ClipboardError::new(e.to_string()))
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    /// An in-memory fake standing in for the real OS clipboard: [`SystemClipboard`] can't be
151    /// exercised headlessly in CI (see this module's doc comment), so this is what actually gets
152    /// covered by automated tests.
153    #[derive(Default)]
154    struct FakeClipboard {
155        contents: Option<String>,
156    }
157
158    impl Clipboard for FakeClipboard {
159        fn get_text(&mut self) -> Result<String, ClipboardError> {
160            self.contents
161                .clone()
162                .ok_or_else(|| ClipboardError::new("clipboard is empty"))
163        }
164
165        fn set_text(&mut self, text: String) -> Result<(), ClipboardError> {
166            self.contents = Some(text);
167            Ok(())
168        }
169    }
170
171    #[test]
172    fn set_then_get_round_trips() {
173        let mut clip = FakeClipboard::default();
174        clip.set_text("hello".to_string()).unwrap();
175        assert_eq!(clip.get_text().unwrap(), "hello");
176    }
177
178    #[test]
179    fn get_before_any_set_is_an_error() {
180        let mut clip = FakeClipboard::default();
181        assert!(clip.get_text().is_err());
182    }
183
184    #[test]
185    fn set_overwrites_previous_contents() {
186        let mut clip = FakeClipboard::default();
187        clip.set_text("first".to_string()).unwrap();
188        clip.set_text("second".to_string()).unwrap();
189        assert_eq!(clip.get_text().unwrap(), "second");
190    }
191
192    #[test]
193    fn clipboard_error_display_includes_message() {
194        let err = ClipboardError::new("boom");
195        assert_eq!(err.to_string(), "clipboard error: boom");
196    }
197
198    #[test]
199    fn clipboard_error_equality() {
200        assert_eq!(ClipboardError::new("a"), ClipboardError::new("a"));
201        assert_ne!(ClipboardError::new("a"), ClipboardError::new("b"));
202    }
203}