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}