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}