teksilo_platform/clipboard.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! System clipboard abstraction.
5//!
6//! Widgets do not talk to `arboard` directly — they hold a `ClipboardHandle`
7//! handed to them by the host application, which lets tests swap in a pure
8//! in-memory backend.
9//!
10//! The trait carries two payload kinds:
11//!
12//! * **Plain text** (`get_text` / `set_text`) — the universal baseline.
13//! * **HTML** (`get_html` / `set_html`) — rich content that round-trips
14//! between applications on Linux (`text/html`), macOS (`public.html`),
15//! and Windows (`CF_HTML`). `set_html` takes a plain-text alternative
16//! because real platform clipboards demand both payloads in the same
17//! transaction — writing HTML alone means pasting into a plain-text
18//! surface (Notepad, terminal) yields nothing.
19//!
20//! Backends without native HTML support inherit default trait bodies that
21//! gracefully degrade: `get_html` reports unsupported, `set_html` falls
22//! back to writing the plain-text payload. Callers who query `has_html`
23//! before building rich-paste menu state avoid speculatively probing an
24//! X11 selection owner when no HTML payload exists.
25//!
26//! Extension point for RTF / other typed payloads: add `get_rtf` /
27//! `set_rtf` with the same default-body convention. The named-method
28//! approach is preferred over a generic `get(mime)` for IDE
29//! discoverability and for keeping the ergonomic "write HTML + plain in
30//! one call" contract visible in the signature.
31
32use std::cell::RefCell;
33use std::rc::Rc;
34
35/// Backend-agnostic clipboard interface. Implementors read and write system
36/// clipboard payloads. Errors are returned as strings so backends do not need a
37/// shared error type.
38pub trait ClipboardBackend {
39 fn get_text(&mut self) -> Result<String, String>;
40 fn set_text(&mut self, text: &str) -> Result<(), String>;
41 fn has_text(&mut self) -> bool {
42 self.get_text().map(|s| !s.is_empty()).unwrap_or(false)
43 }
44
45 /// Read an HTML payload from the system clipboard. Backends without
46 /// HTML support return `Err("unsupported".into())`; callers typically
47 /// check `has_html` first and fall back to `get_text`.
48 fn get_html(&mut self) -> Result<String, String> {
49 Err("unsupported".into())
50 }
51
52 /// Write an HTML payload and a plain-text alternative onto the
53 /// clipboard in one transaction. Real platform clipboards demand
54 /// both so apps that only understand plain text still see the
55 /// copied content.
56 ///
57 /// Default body: drop the HTML payload and call `set_text` with
58 /// the plain alternative. Backends that support HTML natively
59 /// override this method to write both payloads to the OS clipboard.
60 fn set_html(&mut self, _html: &str, plain_fallback: &str) -> Result<(), String> {
61 self.set_text(plain_fallback)
62 }
63
64 /// Whether the clipboard currently carries an HTML payload. Default
65 /// body returns `false`; HTML-capable backends override to perform
66 /// a real probe. The probe may be expensive (X11 selection-owner
67 /// round-trip), so callers should invoke `has_html` only when
68 /// building menu state, not per-frame.
69 fn has_html(&mut self) -> bool {
70 false
71 }
72}
73
74/// Shared handle passed to widgets. Interior mutability so multiple widgets
75/// in the same window can call into the same backend without the host having
76/// to own a mutable reference per frame.
77#[derive(Clone)]
78pub struct ClipboardHandle {
79 inner: Rc<RefCell<dyn ClipboardBackend>>,
80}
81
82impl ClipboardHandle {
83 pub fn new<B: ClipboardBackend + 'static>(backend: B) -> Self {
84 Self {
85 inner: Rc::new(RefCell::new(backend)),
86 }
87 }
88
89 pub fn get_text(&self) -> Result<String, String> {
90 self.inner.borrow_mut().get_text()
91 }
92
93 pub fn set_text(&self, text: &str) -> Result<(), String> {
94 self.inner.borrow_mut().set_text(text)
95 }
96
97 pub fn has_text(&self) -> bool {
98 self.inner.borrow_mut().has_text()
99 }
100
101 /// Read an HTML payload from the system clipboard, or `Err` when
102 /// the backend lacks HTML support or the clipboard has none.
103 pub fn get_html(&self) -> Result<String, String> {
104 self.inner.borrow_mut().get_html()
105 }
106
107 /// Write HTML and a plain-text alternative in one transaction. See
108 /// [`ClipboardBackend::set_html`] for the platform-behaviour rationale.
109 pub fn set_html(&self, html: &str, plain_fallback: &str) -> Result<(), String> {
110 self.inner.borrow_mut().set_html(html, plain_fallback)
111 }
112
113 /// Whether the clipboard currently carries an HTML payload. Callers
114 /// should invoke this only when building menu state (e.g. right-click
115 /// context menu), not per-frame: the probe can round-trip to the
116 /// selection owner on X11.
117 pub fn has_html(&self) -> bool {
118 self.inner.borrow_mut().has_html()
119 }
120}
121
122impl std::fmt::Debug for ClipboardHandle {
123 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124 f.debug_struct("ClipboardHandle").finish_non_exhaustive()
125 }
126}
127
128/// In-memory clipboard used by headless tests and by apps that opt out of the
129/// real system clipboard. Not shared across processes.
130#[derive(Debug, Default)]
131pub struct MemoryClipboard {
132 text: Option<String>,
133 html: Option<String>,
134}
135
136impl MemoryClipboard {
137 pub fn new() -> Self {
138 Self::default()
139 }
140}
141
142impl ClipboardBackend for MemoryClipboard {
143 fn get_text(&mut self) -> Result<String, String> {
144 Ok(self.text.clone().unwrap_or_default())
145 }
146
147 fn set_text(&mut self, text: &str) -> Result<(), String> {
148 self.text = Some(text.to_string());
149 // Setting plain text invalidates any stored HTML — the HTML
150 // payload was associated with the *previous* plain content,
151 // and returning it now would be semantically wrong.
152 self.html = None;
153 Ok(())
154 }
155
156 fn has_text(&mut self) -> bool {
157 self.text.as_deref().map(|s| !s.is_empty()).unwrap_or(false)
158 }
159
160 fn get_html(&mut self) -> Result<String, String> {
161 match self.html.as_deref() {
162 Some(h) if !h.is_empty() => Ok(h.to_string()),
163 _ => Err("no html payload".into()),
164 }
165 }
166
167 fn set_html(&mut self, html: &str, plain_fallback: &str) -> Result<(), String> {
168 self.html = Some(html.to_string());
169 self.text = Some(plain_fallback.to_string());
170 Ok(())
171 }
172
173 fn has_html(&mut self) -> bool {
174 self.html.as_deref().map(|s| !s.is_empty()).unwrap_or(false)
175 }
176}
177
178#[cfg(feature = "clipboard")]
179pub use arboard_backend::ArboardClipboard;
180
181#[cfg(feature = "clipboard")]
182mod arboard_backend {
183 use super::ClipboardBackend;
184 use arboard::{Clipboard, Error};
185
186 /// Real system clipboard backed by `arboard`. One live instance per
187 /// window is the expected usage; the host application constructs it
188 /// during startup and hands a `ClipboardHandle::new(ArboardClipboard::new()?)`
189 /// to widgets.
190 pub struct ArboardClipboard {
191 inner: Clipboard,
192 }
193
194 impl ArboardClipboard {
195 pub fn new() -> Result<Self, String> {
196 Clipboard::new()
197 .map(|inner| Self { inner })
198 .map_err(|e| e.to_string())
199 }
200 }
201
202 impl ClipboardBackend for ArboardClipboard {
203 fn get_text(&mut self) -> Result<String, String> {
204 self.inner.get_text().map_err(|e| e.to_string())
205 }
206
207 fn set_text(&mut self, text: &str) -> Result<(), String> {
208 self.inner
209 .set_text(text.to_string())
210 .map_err(|e| e.to_string())
211 }
212
213 fn get_html(&mut self) -> Result<String, String> {
214 self.inner.get().html().map_err(|e| e.to_string())
215 }
216
217 fn set_html(&mut self, html: &str, plain_fallback: &str) -> Result<(), String> {
218 // `arboard::Clipboard::set_html(html, alt_text)` writes both
219 // HTML and the plain-text alternative in a single transaction
220 // — matching the Linux `text/html` + `UTF8_STRING` pair, macOS
221 // `NSPasteboardTypeHTML` + `NSPasteboardTypeString`, and
222 // Windows `CF_HTML` + `CF_UNICODETEXT`.
223 self.inner
224 .set_html(html.to_string(), Some(plain_fallback.to_string()))
225 .map_err(|e| e.to_string())
226 }
227
228 fn has_html(&mut self) -> bool {
229 match self.inner.get().html() {
230 Ok(s) => !s.is_empty(),
231 // `ContentNotAvailable` just means the clipboard does
232 // not currently carry an HTML payload — expected, not an
233 // error. Any other error (backend disconnect, IPC) also
234 // resolves to `false`: the menu treats "don't know" as
235 // "nothing to paste" and we avoid leaking a flaky X11
236 // round-trip into UI state.
237 Err(Error::ContentNotAvailable) => false,
238 Err(_) => false,
239 }
240 }
241 }
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247
248 #[test]
249 fn memory_backend_roundtrip() {
250 let handle = ClipboardHandle::new(MemoryClipboard::new());
251 assert!(!handle.has_text());
252 handle.set_text("hello").unwrap();
253 assert!(handle.has_text());
254 assert_eq!(handle.get_text().unwrap(), "hello");
255 handle.set_text("").unwrap();
256 assert!(!handle.has_text());
257 }
258
259 #[test]
260 fn handle_is_cloneable_and_shares_state() {
261 let a = ClipboardHandle::new(MemoryClipboard::new());
262 let b = a.clone();
263 a.set_text("shared").unwrap();
264 assert_eq!(b.get_text().unwrap(), "shared");
265 }
266
267 #[test]
268 fn memory_backend_html_roundtrip() {
269 let handle = ClipboardHandle::new(MemoryClipboard::new());
270 assert!(!handle.has_html(), "empty clipboard has no html");
271
272 handle.set_html("<p>a</p>", "a").unwrap();
273 assert!(handle.has_html(), "set_html must flip has_html");
274 assert_eq!(handle.get_html().unwrap(), "<p>a</p>");
275 assert_eq!(
276 handle.get_text().unwrap(),
277 "a",
278 "set_html must also install the plain-text alternative"
279 );
280 }
281
282 #[test]
283 fn memory_backend_set_text_invalidates_html() {
284 // Self-round-trip detection in the rich-text editor compares
285 // the stored plain text against what the system clipboard
286 // currently reports. If we left the old HTML behind after a
287 // plain-text overwrite, paste would reinsert a rich fragment
288 // whose plain form no longer matches the clipboard.
289 let handle = ClipboardHandle::new(MemoryClipboard::new());
290 handle.set_html("<b>old</b>", "old").unwrap();
291 assert!(handle.has_html());
292 handle.set_text("new").unwrap();
293 assert!(
294 !handle.has_html(),
295 "plain-text overwrite must invalidate stale html"
296 );
297 assert!(handle.get_html().is_err());
298 }
299
300 #[test]
301 fn handle_html_shared_state() {
302 let a = ClipboardHandle::new(MemoryClipboard::new());
303 let b = a.clone();
304 a.set_html("<p>shared</p>", "shared").unwrap();
305 assert_eq!(b.get_html().unwrap(), "<p>shared</p>");
306 assert_eq!(b.get_text().unwrap(), "shared");
307 }
308
309 #[test]
310 fn default_set_html_falls_back_to_plain_text() {
311 // A hand-rolled backend that does not override set_html / get_html
312 // must still round-trip plain text correctly via the default trait
313 // body — writes become `set_text(plain_fallback)` so pasting into
314 // a plain-text surface continues to work.
315 struct PlainOnly {
316 text: Option<String>,
317 }
318 impl ClipboardBackend for PlainOnly {
319 fn get_text(&mut self) -> Result<String, String> {
320 Ok(self.text.clone().unwrap_or_default())
321 }
322 fn set_text(&mut self, text: &str) -> Result<(), String> {
323 self.text = Some(text.to_string());
324 Ok(())
325 }
326 }
327
328 let handle = ClipboardHandle::new(PlainOnly { text: None });
329 assert!(!handle.has_html());
330 assert!(handle.get_html().is_err());
331 handle.set_html("<p>ignored</p>", "fallback").unwrap();
332 assert_eq!(handle.get_text().unwrap(), "fallback");
333 assert!(!handle.has_html(), "plain-only backend never reports html");
334 }
335}