Skip to main content

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}