Skip to main content

typ_buffer/
clipboard.rs

1//! The clipboard: an internal register, the system clipboard behind it, and
2//! OSC 52 in front of both.
3//!
4//! # Why not a clipboard crate
5//!
6//! X11 has no clipboard daemon. A selection is owned by a *live process*, and
7//! when that process exits the content is gone unless a clipboard manager
8//! happened to claim it. `xclip` and `wl-copy` fork a background process
9//! specifically to hold that ownership; a library that sets the selection from
10//! inside this process requires this process to stay alive to serve it. For an
11//! editor the failure is: copy, quit, paste elsewhere, get nothing.
12//!
13//! Helix reaches for external commands for exactly this reason, citing Neovim's
14//! `provider/clipboard.vim`; oh-my-pi does the same and keeps a PowerShell path
15//! on Windows. Three implementations agreeing is enough evidence.
16//!
17//! # The layers
18//!
19//! 1. **The register.** Always present, always the source of truth for paste
20//!    inside TYPE. Nothing can make this fail.
21//! 2. **OSC 52 on write, emitted first.** An escape sequence carrying base64
22//!    that the *local* terminal intercepts, so a copy over SSH lands in the
23//!    laptop's clipboard rather than the server's.
24//! 3. **A command provider**, chosen by environment variable and binary
25//!    presence rather than by trying and catching.
26//!
27//! # Reading
28//!
29//! There is no OSC 52 read. The reply has to be parsed off the input stream,
30//! and terminals disable clipboard *reads* by default for good reason — a
31//! remote host that can read your clipboard reads whatever you last copied.
32//! Reads go to the provider, then fall back to the register.
33//!
34//! # Failure
35//!
36//! Nothing here surfaces an error. A headless box with no clipboard is a normal
37//! condition, not something to interrupt someone about.
38
39use std::io::Write;
40#[cfg(not(windows))]
41use std::process::{Command, Stdio};
42use std::sync::{Mutex, OnceLock};
43
44/// The internal register. Process-wide because the clipboard is.
45fn cell() -> &'static Mutex<String> {
46    static CELL: OnceLock<Mutex<String>> = OnceLock::new();
47    CELL.get_or_init(|| Mutex::new(String::new()))
48}
49
50/// Whether to talk to the system clipboard at all.
51///
52/// Off by default and switched on by the binary at startup, so a test suite
53/// never spawns `wl-copy` or clobbers whatever the developer had copied. A
54/// library that reaches for the machine's clipboard the moment it is linked is
55/// a library that cannot be tested politely.
56fn system_enabled() -> &'static Mutex<bool> {
57    static ENABLED: OnceLock<Mutex<bool>> = OnceLock::new();
58    ENABLED.get_or_init(|| Mutex::new(false))
59}
60
61/// Let the clipboard reach the system. Called once, by the binary.
62pub fn enable_system() {
63    *system_enabled().lock().unwrap() = true;
64}
65
66fn system_is_enabled() -> bool {
67    *system_enabled().lock().unwrap()
68}
69
70/// What the register holds.
71pub fn register() -> String {
72    cell().lock().unwrap().clone()
73}
74
75/// Set the register alone, touching nothing outside this process.
76pub fn set_register(text: &str) {
77    *cell().lock().unwrap() = text.to_string();
78}
79
80/// Copy: register, then OSC 52, then the system provider.
81///
82/// The register is set first and unconditionally, so a paste inside TYPE works
83/// even when every outward path fails.
84pub fn set(text: &str) {
85    set_register(text);
86    if !system_is_enabled() {
87        return;
88    }
89    emit_osc52(text);
90    Provider::detect().set(text);
91}
92
93/// Paste: the system provider, falling back to the register.
94///
95/// The provider wins when it answers, so text copied in another application is
96/// available here. An empty answer counts as no answer — every provider prints
97/// nothing when the clipboard holds nothing, which is indistinguishable from
98/// failing, and preferring the register in that case is the more useful guess.
99pub fn get() -> String {
100    if system_is_enabled() {
101        let external = Provider::detect().get();
102        if !external.is_empty() {
103            return external;
104        }
105    }
106    register()
107}
108
109/// Write the selection to the terminal as OSC 52.
110///
111/// `\x1b]52;c;<base64>\x07` — `c` is the clipboard selection. Terminals that do
112/// not support it ignore the sequence, and the write is best-effort: stdout may
113/// legitimately not be a terminal.
114fn emit_osc52(text: &str) {
115    let mut out = std::io::stdout();
116    let _ = write!(out, "\x1b]52;c;{}\x07", base64(text.as_bytes()));
117    let _ = out.flush();
118}
119
120/// Base64, by hand.
121///
122/// Twenty lines against a dependency that would be pulled in for one call site.
123fn base64(input: &[u8]) -> String {
124    const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
125    let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
126    for chunk in input.chunks(3) {
127        let b = [
128            chunk[0],
129            *chunk.get(1).unwrap_or(&0),
130            *chunk.get(2).unwrap_or(&0),
131        ];
132        let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32;
133        out.push(ALPHABET[(n >> 18) as usize & 63] as char);
134        out.push(ALPHABET[(n >> 12) as usize & 63] as char);
135        out.push(if chunk.len() > 1 {
136            ALPHABET[(n >> 6) as usize & 63] as char
137        } else {
138            '='
139        });
140        out.push(if chunk.len() > 2 {
141            ALPHABET[n as usize & 63] as char
142        } else {
143            '='
144        });
145    }
146    out
147}
148
149/// How this machine talks to its clipboard.
150///
151/// Ordered by preference and detected from the environment, following Helix.
152/// Each platform carries only the variants it can actually construct — Windows
153/// goes straight to the native API and never shells out, so listing `XClip`
154/// there would be a variant that exists to be dead.
155///
156/// `Primary` — the X11 middle-click selection — is deliberately absent: it is a
157/// second clipboard rather than a second provider, and it lands with the mouse
158/// work at M4.
159enum Provider {
160    #[cfg(windows)]
161    Windows,
162    #[cfg(target_os = "macos")]
163    Pasteboard,
164    #[cfg(not(windows))]
165    Wayland,
166    #[cfg(not(windows))]
167    XClip,
168    #[cfg(not(windows))]
169    XSel,
170    #[cfg(not(windows))]
171    Tmux,
172    #[cfg(not(windows))]
173    Termux,
174    #[cfg(not(windows))]
175    None,
176}
177
178/// Which clipboard provider was chosen, for a log line.
179///
180/// A name rather than a log call, because `typ-buffer` sits below `typ-app` and
181/// cannot reach its logger. That is the right direction for the dependency and
182/// arguably the right direction for the decision too — the library reports what
183/// it did and the application decides whether anyone is told.
184///
185/// This is the single most useful line in the log when somebody reports that
186/// copy does not work: the answer is almost always that detection picked a
187/// provider whose binary is missing, or `None`.
188pub fn provider_name() -> &'static str {
189    match Provider::detect() {
190        #[cfg(windows)]
191        Provider::Windows => "windows",
192        #[cfg(target_os = "macos")]
193        Provider::Pasteboard => "pbcopy",
194        #[cfg(not(windows))]
195        Provider::Wayland => "wl-copy",
196        #[cfg(not(windows))]
197        Provider::XClip => "xclip",
198        #[cfg(not(windows))]
199        Provider::XSel => "xsel",
200        #[cfg(not(windows))]
201        Provider::Tmux => "tmux",
202        #[cfg(not(windows))]
203        Provider::Termux => "termux-clipboard",
204        #[cfg(not(windows))]
205        Provider::None => "none (internal register only)",
206    }
207}
208
209/// Is this binary on the PATH?
210///
211/// `command -v` rather than a `which` crate: the shell already answers this,
212/// and detection runs once per process.
213#[cfg(not(windows))]
214fn has(binary: &str) -> bool {
215    Command::new("sh")
216        .arg("-c")
217        .arg(format!("command -v {binary}"))
218        .stdout(Stdio::null())
219        .stderr(Stdio::null())
220        .status()
221        .map(|s| s.success())
222        .unwrap_or(false)
223}
224
225#[cfg(not(windows))]
226fn env_set(name: &str) -> bool {
227    std::env::var_os(name).is_some_and(|v| !v.is_empty())
228}
229
230impl Provider {
231    /// Detected once. The environment does not change under a running editor,
232    /// and probing for four binaries on every copy would be absurd.
233    fn detect() -> &'static Self {
234        static PROVIDER: OnceLock<Provider> = OnceLock::new();
235        PROVIDER.get_or_init(Self::detect_uncached)
236    }
237
238    #[cfg(windows)]
239    fn detect_uncached() -> Self {
240        // The native API, always. There is no Windows equivalent of the
241        // ownership problem that makes shelling out the right answer on X11,
242        // and `clip.exe` writes in the console codepage — which mangles
243        // anything outside it, in an editor built on grapheme correctness.
244        Self::Windows
245    }
246
247    #[cfg(not(windows))]
248    fn detect_uncached() -> Self {
249        // Inside tmux, tmux owns the clipboard regardless of what is underneath.
250        if env_set("TMUX") && has("tmux") {
251            return Self::Tmux;
252        }
253        if has("termux-clipboard-set") {
254            return Self::Termux;
255        }
256
257        #[cfg(target_os = "macos")]
258        if has("pbcopy") {
259            return Self::Pasteboard;
260        }
261
262        if env_set("WAYLAND_DISPLAY") && has("wl-copy") {
263            return Self::Wayland;
264        }
265        if env_set("DISPLAY") && has("xclip") {
266            return Self::XClip;
267        }
268        if env_set("DISPLAY") && has("xsel") {
269            return Self::XSel;
270        }
271        // OSC 52 already went out in `set`, so no provider is a working
272        // configuration rather than a broken one.
273        Self::None
274    }
275
276    /// The command that writes, and the one that reads.
277    #[cfg(not(windows))]
278    fn commands(&self) -> Option<(Vec<&'static str>, Vec<&'static str>)> {
279        match self {
280            // Gated to match the variant. Without this the arm names a variant
281            // that does not exist on Linux, which compiles on macOS and on
282            // Windows-with-no-Unix-arms and fails only where it matters.
283            #[cfg(target_os = "macos")]
284            Self::Pasteboard => Some((vec!["pbcopy"], vec!["pbpaste"])),
285            Self::Wayland => Some((
286                vec!["wl-copy", "--foreground", "--type", "text/plain"],
287                vec!["wl-paste", "--no-newline"],
288            )),
289            Self::XClip => Some((
290                vec!["xclip", "-i", "-selection", "clipboard"],
291                vec!["xclip", "-o", "-selection", "clipboard"],
292            )),
293            Self::XSel => Some((vec!["xsel", "-i", "-b"], vec!["xsel", "-o", "-b"])),
294            Self::Tmux => Some((
295                vec!["tmux", "load-buffer", "-w", "-"],
296                vec!["tmux", "save-buffer", "-"],
297            )),
298            Self::Termux => Some((vec!["termux-clipboard-set"], vec!["termux-clipboard-get"])),
299            Self::None => None,
300        }
301    }
302
303    #[cfg(windows)]
304    fn set(&self, text: &str) {
305        let _ = clipboard_win::set_clipboard(clipboard_win::formats::Unicode, text);
306    }
307
308    #[cfg(not(windows))]
309    fn set(&self, text: &str) {
310        let Some((write, _)) = self.commands() else {
311            return;
312        };
313        let Ok(mut child) = Command::new(write[0])
314            .args(&write[1..])
315            .stdin(Stdio::piped())
316            .stdout(Stdio::null())
317            .stderr(Stdio::null())
318            .spawn()
319        else {
320            return;
321        };
322        if let Some(stdin) = child.stdin.as_mut() {
323            let _ = stdin.write_all(text.as_bytes());
324        }
325        // Dropping stdin closes it, which is what tells the provider the write
326        // is finished; without it `wl-copy --foreground` waits forever.
327        drop(child.stdin.take());
328        let _ = child.wait();
329    }
330
331    #[cfg(windows)]
332    fn get(&self) -> String {
333        clipboard_win::get_clipboard(clipboard_win::formats::Unicode).unwrap_or_default()
334    }
335
336    #[cfg(not(windows))]
337    fn get(&self) -> String {
338        let Some((_, read)) = self.commands() else {
339            return String::new();
340        };
341        Command::new(read[0])
342            .args(&read[1..])
343            .stderr(Stdio::null())
344            .output()
345            .ok()
346            .filter(|out| out.status.success())
347            .map(|out| String::from_utf8_lossy(&out.stdout).into_owned())
348            .unwrap_or_default()
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use super::base64;
355
356    #[test]
357    fn base64_matches_the_rfc_examples() {
358        assert_eq!(base64(b""), "");
359        assert_eq!(base64(b"f"), "Zg==");
360        assert_eq!(base64(b"fo"), "Zm8=");
361        assert_eq!(base64(b"foo"), "Zm9v");
362        assert_eq!(base64(b"foob"), "Zm9vYg==");
363        assert_eq!(base64(b"fooba"), "Zm9vYmE=");
364        assert_eq!(base64(b"foobar"), "Zm9vYmFy");
365    }
366
367    #[test]
368    fn base64_handles_non_ascii() {
369        // The padding maths is where a hand-rolled encoder goes wrong, and
370        // multibyte input is what exercises it.
371        assert_eq!(base64("é".as_bytes()), "w6k=");
372        assert_eq!(base64("日本".as_bytes()), "5pel5pys");
373    }
374}