Skip to main content

lattice_core/
clipboard.rs

1//! System-clipboard abstraction (CB.0).
2//!
3//! The editor talks to the OS clipboard through this single trait so the
4//! backend (native `arboard`, OSC52 terminal escape, gpui-native, or an
5//! in-memory fake for tests) is swappable and renderer-neutral. Registered in
6//! the `ServiceRegistry` at boot as [`ClipboardHandle`] so both the host's
7//! register layer AND mode crates (`terminal-mode`, which routes paste to the
8//! PTY — CB.3) can reach it without depending on `lattice-host`.
9//!
10//! **Threading contract (paramount #1).** Clipboard I/O is an OS round-trip
11//! (X11 / Wayland can be slow); it must never sit on the UI / keystroke path.
12//! [`Clipboard::read`] is only ever called from a `spawn_blocking` context (the
13//! paste path); [`Clipboard::write`] is fire-and-forget — a backend that would
14//! block spawns its own blocking task internally and returns immediately.
15//!
16//! See `docs/dev/architecture/clipboard.md`.
17
18use std::sync::Arc;
19
20/// The OS clipboard, behind a swappable backend.
21///
22/// Implementors: `ArboardClipboard` + OSC52 fallback (TUI, CB.2), the
23/// gpui-native bridge (GPUI peer, CB.4), and [`FakeClipboard`] (tests / CI).
24/// All reads/writes are text-only for v1 (images / other MIME are out of
25/// scope).
26pub trait Clipboard: Send + Sync {
27    /// Read the clipboard's current text. `None` when the clipboard is empty,
28    /// holds non-text content, or the backend can't read (e.g. OSC52 over SSH,
29    /// where read-back is unsupported — the register layer falls back to its
30    /// in-memory entry). Only called from a `spawn_blocking` context.
31    fn read(&self) -> Option<String>;
32
33    /// Write `text` to the clipboard. Fire-and-forget: never blocks the
34    /// caller. A backend whose write would block spawns its own blocking task.
35    fn write(&self, text: String);
36}
37
38/// Cheap-clone handle for the clipboard service, stored in the
39/// `ServiceRegistry`. Per the ServiceRegistry Arc/TypeId rule, register AND
40/// look up under this exact type (`services.get::<ClipboardHandle>()`), never
41/// under a concrete backend type.
42pub type ClipboardHandle = Arc<dyn Clipboard>;
43
44/// In-memory [`Clipboard`] for tests / headless CI and the default boot
45/// binding before a real backend is installed (CB.2 / CB.4). Thread-safe;
46/// round-trips text without touching any OS resource.
47///
48/// # Examples
49///
50/// ```
51/// use lattice_core::{Clipboard, ClipboardHandle, FakeClipboard};
52/// use std::sync::Arc;
53///
54/// let clip: ClipboardHandle = Arc::new(FakeClipboard::new());
55/// assert_eq!(clip.read(), None); // starts empty
56/// clip.write("yanked".to_string());
57/// assert_eq!(clip.read().as_deref(), Some("yanked"));
58/// ```
59#[derive(Debug, Default)]
60pub struct FakeClipboard {
61    inner: std::sync::Mutex<Option<String>>,
62}
63
64impl FakeClipboard {
65    /// An empty clipboard: [`Clipboard::read`] returns `None` until the
66    /// first write.
67    pub fn new() -> Self {
68        Self::default()
69    }
70}
71
72impl Clipboard for FakeClipboard {
73    fn read(&self) -> Option<String> {
74        self.inner.lock().expect("clipboard mutex poisoned").clone()
75    }
76
77    fn write(&self, text: String) {
78        *self.inner.lock().expect("clipboard mutex poisoned") = Some(text);
79    }
80}
81
82#[cfg(test)]
83mod tests {
84    #![allow(clippy::unwrap_used, clippy::panic)]
85    use super::*;
86
87    #[test]
88    fn fake_clipboard_roundtrips_text() {
89        let cb = FakeClipboard::new();
90        assert_eq!(cb.read(), None, "empty clipboard reads None");
91        cb.write("hello".to_string());
92        assert_eq!(cb.read(), Some("hello".to_string()));
93        cb.write("world".to_string());
94        assert_eq!(cb.read(), Some("world".to_string()), "write overwrites");
95    }
96
97    #[test]
98    fn fake_clipboard_is_usable_as_handle() {
99        // Exercises the object-safe `dyn Clipboard` path the ServiceRegistry
100        // stores (`ClipboardHandle`).
101        let handle: ClipboardHandle = Arc::new(FakeClipboard::new());
102        handle.write("via handle".to_string());
103        assert_eq!(handle.read(), Some("via handle".to_string()));
104    }
105}