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}