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