lattice_ui_tui/clipboard.rs
1//! CB.2 / CB.4 (`docs/dev/architecture/clipboard.md` §4): the TUI's
2//! [`lattice_core::Clipboard`] backend. Two implementations composed by
3//! [`TuiClipboard::detect`]:
4//!
5//! - [`Osc52Clipboard`] — terminal escape-sequence write. No link deps,
6//! always compiled in. Read-back is unsupported (most terminals block the
7//! OSC52 read half for security reasons), so `read` always returns
8//! `None` and callers fall back to the in-memory register. This is the
9//! TUI-specific half (writing escape codes to stdout only makes sense for
10//! a terminal), so it lives here rather than in the shared host module.
11//! - [`lattice_host::clipboard::ArboardClipboard`] (behind the
12//! `system-clipboard` feature) — native OS clipboard via `arboard`, the
13//! **shared** backend both renderer peers use (CB.4 moved it to
14//! `lattice-host` so the bounded-read logic isn't duplicated; see that
15//! module's doc). Feature-gated because it pulls X11/Wayland link libs;
16//! `lattice-ui-tui/system-clipboard` forwards to
17//! `lattice-host/system-clipboard`.
18//!
19//! `TuiClipboard::detect()` prefers OSC52 under SSH even when `arboard`
20//! would technically succeed: over `ssh -X`, `arboard` can connect to the
21//! *forwarded* X server, but writes there land in the remote X server's
22//! clipboard, not the user's local machine clipboard. OSC52 tunnels through
23//! the terminal escape codes to the local terminal emulator directly, which
24//! is the semantically correct backend for SSH sessions.
25
26use lattice_core::Clipboard;
27#[cfg(feature = "system-clipboard")]
28use lattice_host::clipboard::ArboardClipboard;
29
30/// The clipboard backend [`crate::app::App::new`] registers over CB.0's
31/// default, as a ready-made [`lattice_core::ClipboardHandle`].
32///
33/// **Test builds get [`lattice_core::FakeClipboard`], never a real backend.**
34/// `cargo test` must not reach outside the process: `Native(ArboardClipboard)`
35/// (any `--features system-clipboard` run, which is the default on macOS /
36/// Windows via `lattice-cli`'s target-gated dep) would clobber the developer's
37/// actual system clipboard on every yank, and the OSC52 fallback would spray
38/// `ESC ] 52 ; ...` escape sequences at the test runner's stdout. The
39/// in-memory fake round-trips text with identical semantics, so the register
40/// layer (`Editor::read_register`'s clipboard-preferred read, the yank mirror)
41/// is still exercised end-to-end — it just terminates inside this process.
42/// `cfg!(test)` rather than `#[cfg(test)]` so both arms stay type-checked in
43/// every build.
44pub(crate) fn boot_backend() -> lattice_core::ClipboardHandle {
45 if cfg!(test) {
46 std::sync::Arc::new(lattice_core::FakeClipboard::new())
47 } else {
48 std::sync::Arc::new(TuiClipboard::detect())
49 }
50}
51
52/// OSC52 clipboard-write escape sequence
53/// (`ESC ] 52 ; c ; <base64> BEL`, `c` = the clipboard selection). No
54/// system-lib dependency; the write-only fallback for headless / SSH
55/// sessions and for builds without the `system-clipboard` feature.
56#[derive(Debug, Default, Clone, Copy)]
57pub struct Osc52Clipboard;
58
59impl Clipboard for Osc52Clipboard {
60 fn read(&self) -> Option<String> {
61 // Read-back is unsupported: most terminal emulators refuse the
62 // OSC52 query half for security reasons (a program could otherwise
63 // read whatever the user last copied in another app/window). The
64 // register layer falls back to its in-memory entry.
65 None
66 }
67
68 fn write(&self, text: String) {
69 use base64::Engine;
70 use std::io::Write;
71 let encoded = base64::engine::general_purpose::STANDARD.encode(text.as_bytes());
72 let seq = format!("\x1b]52;c;{encoded}\x07");
73 // Fire-and-forget: a broken pipe / write error must never
74 // propagate into dispatch (graceful degradation, never panic on
75 // the hot path).
76 let mut stdout = std::io::stdout();
77 let _ = stdout.write_all(seq.as_bytes());
78 let _ = stdout.flush();
79 }
80}
81
82/// The TUI's clipboard backend: native when available, OSC52 write-only
83/// otherwise. See the module doc for the SSH-preference rule.
84pub enum TuiClipboard {
85 #[cfg(feature = "system-clipboard")]
86 Native(ArboardClipboard),
87 Fallback(Osc52Clipboard),
88}
89
90impl TuiClipboard {
91 /// Detect the best backend at boot. Called once from
92 /// [`crate::app::App::new`], right after `Editor::boot`, to override
93 /// the `FakeClipboard` the host registers by default (CB.0).
94 pub fn detect() -> Self {
95 let under_ssh =
96 std::env::var_os("SSH_TTY").is_some() || std::env::var_os("SSH_CONNECTION").is_some();
97 Self::detect_with(under_ssh)
98 }
99
100 /// The pure selection logic behind [`Self::detect`], taking the
101 /// SSH-session determination as a parameter so it's testable without
102 /// mutating process-global env vars (which would race other tests
103 /// running in parallel in this binary).
104 fn detect_with(under_ssh: bool) -> Self {
105 #[cfg(feature = "system-clipboard")]
106 {
107 if !under_ssh {
108 match ArboardClipboard::new() {
109 Some(native) => return Self::Native(native),
110 None => {
111 // Feature is compiled in and we're not under SSH, so
112 // a native backend was expected — arboard couldn't
113 // reach a display server. Fall back to OSC52 (write-
114 // only) and note it once so a user wondering why
115 // paste-from-another-app doesn't work can find out
116 // via `--log-level debug`. Graceful, never fatal.
117 tracing::debug!(
118 "clipboard: native (arboard) init failed; \
119 falling back to OSC52 write-only (no display?)"
120 );
121 }
122 }
123 }
124 }
125 let _ = under_ssh;
126 Self::Fallback(Osc52Clipboard)
127 }
128}
129
130impl Clipboard for TuiClipboard {
131 fn read(&self) -> Option<String> {
132 match self {
133 #[cfg(feature = "system-clipboard")]
134 Self::Native(c) => c.read(),
135 Self::Fallback(c) => c.read(),
136 }
137 }
138
139 fn write(&self, text: String) {
140 match self {
141 #[cfg(feature = "system-clipboard")]
142 Self::Native(c) => c.write(text),
143 Self::Fallback(c) => c.write(text),
144 }
145 }
146}
147
148#[cfg(test)]
149mod tests {
150 #![allow(clippy::unwrap_used, clippy::panic)]
151 use super::*;
152
153 #[test]
154 fn osc52_read_always_none() {
155 assert_eq!(Osc52Clipboard.read(), None);
156 }
157
158 #[test]
159 fn osc52_write_does_not_panic_on_a_normal_stdout() {
160 // Can't assert the escape sequence landed anywhere meaningful in
161 // a unit test (stdout isn't a terminal here), but the write path
162 // must never panic even when nothing is listening for OSC52.
163 Osc52Clipboard.write("hello".to_string());
164 }
165
166 #[test]
167 fn detect_prefers_osc52_under_ssh() {
168 // Even if a native backend would technically connect (X11
169 // forwarding), OSC52 is the semantically correct backend under
170 // SSH -- arboard would write to the *forwarded* server's
171 // clipboard, not the user's local machine clipboard.
172 let cb = TuiClipboard::detect_with(true);
173 assert!(matches!(cb, TuiClipboard::Fallback(_)));
174 }
175
176 #[cfg(not(feature = "system-clipboard"))]
177 #[test]
178 fn detect_without_ssh_falls_back_when_feature_off() {
179 let cb = TuiClipboard::detect_with(false);
180 assert!(matches!(cb, TuiClipboard::Fallback(_)));
181 }
182
183 /// Hermeticity guard for the whole TUI suite: no test may reach the
184 /// developer's real clipboard. Both real backends fail this assertion --
185 /// OSC52 (`read` is unconditionally `None`) in the default build, and
186 /// `Native(ArboardClipboard)` in a `--features system-clipboard` build,
187 /// where the pre-write read below returns whatever the developer last
188 /// copied rather than the empty fake. Only the in-memory fake starts
189 /// empty and then round-trips.
190 #[test]
191 fn boot_backend_is_the_in_memory_fake_in_test_builds() {
192 let cb = boot_backend();
193 assert_eq!(
194 cb.read(),
195 None,
196 "a fresh boot backend must start empty in tests -- a non-None \
197 read means `cargo test` is talking to the OS clipboard"
198 );
199 cb.write("hermetic".to_string());
200 assert_eq!(
201 cb.read(),
202 Some("hermetic".to_string()),
203 "the fake must round-trip so the register layer's \
204 clipboard-preferred read is still exercised"
205 );
206 }
207}