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}