Skip to main content

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}