Skip to main content

rich_ext/
clipboard.rs

1//! Copy to the terminal's clipboard with OSC 52 (#488), and the text a
2//! table cell or row copies as (#434).
3//!
4//! OSC 52 asks the terminal to put text on the system clipboard:
5//! `ESC ] 52 ; c ; <base64> BEL`. It works over SSH, since the terminal does
6//! the copying, but a terminal that does not support it prints nothing or,
7//! worse, garbage, and many cap the payload. So nothing is written blindly:
8//! [`detect`] decides, with provenance, like every other capability in
9//! [`capabilities`](crate::capabilities).
10//!
11//! # Detection rules
12//!
13//! The first rule that matches decides:
14//!
15//! * not a terminal → no: a pipe, a log or an export never gets the escape,
16//!   whatever `RICH_CLIPBOARD` says;
17//! * `RICH_CLIPBOARD=0|1` (also `true/false/yes/no/on/off`) — the override,
18//!   on a terminal;
19//! * `TERM=dumb` → no;
20//! * inside tmux or screen → no: tmux only passes OSC 52 on with
21//!   `set-clipboard on`, which is not its default. Override with
22//!   `RICH_CLIPBOARD=1` when yours does;
23//! * a terminal known to accept it → yes: kitty, iTerm2, WezTerm, Windows
24//!   Terminal, ghostty, Alacritty, foot, contour, rio and VS Code's terminal;
25//! * anything else → no.
26//!
27//! ```
28//! use rich_ext::capabilities::MapEnvironment;
29//! use rich_ext::clipboard::{self, Clipboard};
30//!
31//! let kitty = MapEnvironment::tty().var("TERM", "xterm-kitty");
32//! assert!(clipboard::detect(&kitty).value);
33//! // A pipe never gets the escape, whatever the terminal.
34//! assert!(!clipboard::detect(&kitty.clone().terminal(false)).value);
35//!
36//! let mut out = Vec::new();
37//! Clipboard::detect(&kitty).copy(&mut out, "hi").unwrap();
38//! assert_eq!(out, b"\x1b]52;c;aGk=\x07");
39//! ```
40
41use std::fmt;
42use std::io::Write;
43
44use crate::capabilities::{Environment, Field, Origin, SystemEnvironment, parse_bool};
45
46/// The variable that turns OSC 52 on (`1`) or off (`0`), whatever the
47/// terminal.
48pub const CLIPBOARD_VAR: &str = "RICH_CLIPBOARD";
49
50/// The most bytes one copy sends: 74,994 bytes are 100,000 in base64, the
51/// smallest limit among the terminals that cap OSC 52 (hterm, and xterm's
52/// default). A larger copy is refused rather than cut.
53pub const MAX_BYTES: usize = 74_994;
54
55/// Terminals that take OSC 52 by default, by `TERM_PROGRAM`.
56const PROGRAMS: &[&str] = &[
57    "iTerm.app",
58    "WezTerm",
59    "ghostty",
60    "vscode",
61    "rio",
62    "contour",
63];
64
65/// Terminals that take OSC 52 by default, by the start of `TERM`.
66const TERMS: &[&str] = &[
67    "xterm-kitty",
68    "xterm-ghostty",
69    "alacritty",
70    "foot",
71    "contour",
72    "wezterm",
73    "rio",
74];
75
76/// Whether copies reach the clipboard here, and why.
77pub fn detect(env: &dyn Environment) -> Field<bool> {
78    let get = |name: &str| env.var(name).filter(|value| !value.is_empty());
79    let field = |value: bool, origin: Origin, reason: String| Field {
80        value,
81        origin,
82        reason,
83    };
84    let var = |name: &str| Origin::Environment(name.to_string());
85    // Before the override: forcing OSC 52 is for a terminal that takes it
86    // undetected, never for a file or a pipe that would keep the escape.
87    if !env.is_terminal() {
88        return field(false, Origin::Inferred, "not a terminal".into());
89    }
90    if let Some(raw) = get(CLIPBOARD_VAR)
91        && let Some(value) = parse_bool(&raw)
92    {
93        return field(value, var(CLIPBOARD_VAR), format!("{CLIPBOARD_VAR}={raw}"));
94    }
95    let term = get("TERM").unwrap_or_default();
96    let lower = term.to_ascii_lowercase();
97    if lower == "dumb" {
98        return field(false, var("TERM"), "TERM=dumb".into());
99    }
100    if get("TMUX").is_some() {
101        return field(false, var("TMUX"), "inside tmux".into());
102    }
103    if lower.starts_with("screen") || lower.starts_with("tmux") {
104        return field(false, var("TERM"), "inside a multiplexer".into());
105    }
106    if get("KITTY_WINDOW_ID").is_some() {
107        return field(true, var("KITTY_WINDOW_ID"), "kitty".into());
108    }
109    if get("WT_SESSION").is_some() {
110        return field(true, var("WT_SESSION"), "Windows Terminal".into());
111    }
112    if let Some(program) = get("TERM_PROGRAM")
113        && PROGRAMS.iter().any(|p| program.eq_ignore_ascii_case(p))
114    {
115        return field(true, var("TERM_PROGRAM"), format!("TERM_PROGRAM={program}"));
116    }
117    if TERMS.iter().any(|t| lower.starts_with(t)) {
118        return field(true, var("TERM"), format!("TERM={term}"));
119    }
120    if get("ALACRITTY_WINDOW_ID").is_some() {
121        return field(true, var("ALACRITTY_WINDOW_ID"), "Alacritty".into());
122    }
123    field(
124        false,
125        Origin::Default,
126        "terminal not known to accept OSC 52".into(),
127    )
128}
129
130/// Why a copy did not happen.
131#[derive(Debug)]
132pub enum ClipboardError {
133    /// Detection said no; the reason says why.
134    Unsupported(String),
135    /// The text is over [`MAX_BYTES`].
136    TooLarge { bytes: usize },
137    /// Writing the sequence failed.
138    Io(std::io::Error),
139}
140
141impl fmt::Display for ClipboardError {
142    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143        match self {
144            ClipboardError::Unsupported(reason) => {
145                write!(
146                    f,
147                    "the terminal clipboard is off ({reason}; set {CLIPBOARD_VAR}=1 to force it)"
148                )
149            }
150            ClipboardError::TooLarge { bytes } => write!(
151                f,
152                "{bytes} bytes is too much to copy through the terminal (at most {MAX_BYTES})"
153            ),
154            ClipboardError::Io(error) => write!(f, "could not copy: {error}"),
155        }
156    }
157}
158
159impl std::error::Error for ClipboardError {}
160
161/// The terminal clipboard, as detected once.
162#[derive(Clone, Debug, PartialEq, Eq)]
163pub struct Clipboard {
164    enabled: Field<bool>,
165}
166
167impl Clipboard {
168    /// Detect from `env`.
169    pub fn detect(env: &dyn Environment) -> Clipboard {
170        Clipboard {
171            enabled: detect(env),
172        }
173    }
174
175    /// Detect from the process: standard output's terminal,
176    /// `RICH_CLIPBOARD` and the terminal's identity.
177    pub fn system() -> Clipboard {
178        Clipboard::detect(&SystemEnvironment)
179    }
180
181    /// On or off, whatever the environment says.
182    pub fn forced(on: bool) -> Clipboard {
183        Clipboard {
184            enabled: Field {
185                value: on,
186                origin: Origin::Override,
187                reason: "override".into(),
188            },
189        }
190    }
191
192    /// Whether copies are written.
193    pub fn enabled(&self) -> bool {
194        self.enabled.value
195    }
196
197    /// The decision, with where it came from.
198    pub fn field(&self) -> &Field<bool> {
199        &self.enabled
200    }
201
202    /// Write the OSC 52 sequence that copies `text` to `out`, and flush.
203    pub fn copy(&self, out: &mut dyn Write, text: &str) -> Result<(), ClipboardError> {
204        if !self.enabled.value {
205            return Err(ClipboardError::Unsupported(self.enabled.reason.clone()));
206        }
207        let sequence = osc52(text)?;
208        out.write_all(sequence.as_bytes())
209            .and_then(|()| out.flush())
210            .map_err(ClipboardError::Io)
211    }
212}
213
214/// The OSC 52 sequence that puts `text` on the clipboard, whether or not
215/// anything supports it: `ESC ] 52 ; c ; <base64> BEL`.
216pub fn osc52(text: &str) -> Result<String, ClipboardError> {
217    if text.len() > MAX_BYTES {
218        return Err(ClipboardError::TooLarge { bytes: text.len() });
219    }
220    Ok(format!("\x1b]52;c;{}\x07", base64(text.as_bytes())))
221}
222
223/// Standard base64, padded.
224fn base64(bytes: &[u8]) -> String {
225    const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
226    let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
227    for chunk in bytes.chunks(3) {
228        let n = (u32::from(chunk[0]) << 16)
229            | (u32::from(*chunk.get(1).unwrap_or(&0)) << 8)
230            | u32::from(*chunk.get(2).unwrap_or(&0));
231        for (i, shift) in [18, 12, 6, 0].into_iter().enumerate() {
232            if i <= chunk.len() {
233                out.push(ALPHABET[(n >> shift) as usize & 63] as char);
234            } else {
235                out.push('=');
236            }
237        }
238    }
239    out
240}
241
242// ---------------------------------------------------------------------------
243// What a table's cells copy as (#434).
244
245/// How a copied table cell or row is written.
246#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
247pub enum CopyFormat {
248    /// Cells as they are, separated by tabs: pastes into a spreadsheet.
249    #[default]
250    Text,
251    /// One CSV record (RFC 4180 quoting).
252    Csv,
253    /// A JSON object keyed by the headings (an array without them), or a
254    /// JSON string for one cell.
255    Json,
256}
257
258impl CopyFormat {
259    /// `text`, `csv` or `json`.
260    pub fn parse(name: &str) -> Option<CopyFormat> {
261        match name.trim().to_ascii_lowercase().as_str() {
262            "text" | "tsv" | "plain" => Some(CopyFormat::Text),
263            "csv" => Some(CopyFormat::Csv),
264            "json" => Some(CopyFormat::Json),
265            _ => None,
266        }
267    }
268
269    pub fn name(self) -> &'static str {
270        match self {
271            CopyFormat::Text => "text",
272            CopyFormat::Csv => "csv",
273            CopyFormat::Json => "json",
274        }
275    }
276
277    /// The next format, for a key that cycles through them.
278    #[must_use]
279    pub fn next(self) -> CopyFormat {
280        match self {
281            CopyFormat::Text => CopyFormat::Csv,
282            CopyFormat::Csv => CopyFormat::Json,
283            CopyFormat::Json => CopyFormat::Text,
284        }
285    }
286
287    /// A row of `cells` under `headers` (which may be empty).
288    ///
289    /// ```
290    /// use rich_ext::clipboard::CopyFormat;
291    ///
292    /// let headers = ["name", "note"];
293    /// let row = ["ada", "says \"hi\", twice"];
294    /// assert_eq!(CopyFormat::Text.row(&headers, &row), "ada\tsays \"hi\", twice");
295    /// assert_eq!(CopyFormat::Csv.row(&headers, &row), "ada,\"says \"\"hi\"\", twice\"");
296    /// assert_eq!(
297    ///     CopyFormat::Json.row(&headers, &row),
298    ///     r#"{"name": "ada", "note": "says \"hi\", twice"}"#
299    /// );
300    /// ```
301    pub fn row<H: AsRef<str>, C: AsRef<str>>(self, headers: &[H], cells: &[C]) -> String {
302        match self {
303            CopyFormat::Text => join(cells.iter().map(|c| c.as_ref().to_string()), "\t"),
304            CopyFormat::Csv => join(cells.iter().map(|c| csv_field(c.as_ref())), ","),
305            CopyFormat::Json if headers.is_empty() => format!(
306                "[{}]",
307                join(cells.iter().map(|c| json_string(c.as_ref())), ", ")
308            ),
309            CopyFormat::Json => {
310                let fields = cells.iter().enumerate().map(|(i, cell)| {
311                    let key = headers
312                        .get(i)
313                        .map_or_else(|| format!("column {}", i + 1), |h| h.as_ref().to_string());
314                    format!("{}: {}", json_string(&key), json_string(cell.as_ref()))
315                });
316                format!("{{{}}}", join(fields, ", "))
317            }
318        }
319    }
320
321    /// One cell.
322    ///
323    /// ```
324    /// use rich_ext::clipboard::CopyFormat;
325    ///
326    /// assert_eq!(CopyFormat::Text.cell("a,b"), "a,b");
327    /// assert_eq!(CopyFormat::Csv.cell("a,b"), "\"a,b\"");
328    /// assert_eq!(CopyFormat::Json.cell("a\nb"), r#""a\nb""#);
329    /// ```
330    pub fn cell(self, cell: &str) -> String {
331        match self {
332            CopyFormat::Text => cell.to_string(),
333            CopyFormat::Csv => csv_field(cell),
334            CopyFormat::Json => json_string(cell),
335        }
336    }
337}
338
339fn join(parts: impl Iterator<Item = String>, separator: &str) -> String {
340    parts.collect::<Vec<_>>().join(separator)
341}
342
343/// A CSV field, quoted when it holds a comma, a quote or a line break.
344fn csv_field(cell: &str) -> String {
345    if cell.contains([',', '"', '\n', '\r']) {
346        format!("\"{}\"", cell.replace('"', "\"\""))
347    } else {
348        cell.to_string()
349    }
350}
351
352/// `text` as a JSON string literal, every control character escaped.
353pub fn json_string(text: &str) -> String {
354    let mut out = String::with_capacity(text.len() + 2);
355    out.push('"');
356    for c in text.chars() {
357        match c {
358            '"' => out.push_str("\\\""),
359            '\\' => out.push_str("\\\\"),
360            '\n' => out.push_str("\\n"),
361            '\r' => out.push_str("\\r"),
362            '\t' => out.push_str("\\t"),
363            c if c.is_control() => out.push_str(&format!("\\u{:04x}", c as u32)),
364            c => out.push(c),
365        }
366    }
367    out.push('"');
368    out
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374    use crate::capabilities::MapEnvironment;
375
376    #[test]
377    fn base64_pads_like_the_standard() {
378        assert_eq!(base64(b""), "");
379        assert_eq!(base64(b"f"), "Zg==");
380        assert_eq!(base64(b"fo"), "Zm8=");
381        assert_eq!(base64(b"foo"), "Zm9v");
382        assert_eq!(base64(b"foob"), "Zm9vYg==");
383        assert_eq!(base64("é✓".as_bytes()), "w6ninJM=");
384    }
385
386    #[test]
387    fn detection_needs_a_terminal_and_a_known_one() {
388        let tty = MapEnvironment::tty();
389        assert!(!detect(&tty).value, "an unknown terminal");
390        assert!(detect(&tty.clone().var("TERM_PROGRAM", "WezTerm")).value);
391        assert!(detect(&tty.clone().var("WT_SESSION", "x")).value);
392        assert!(detect(&tty.clone().var("TERM", "foot-extra")).value);
393        let kitty = tty.clone().var("TERM", "xterm-kitty");
394        assert!(!detect(&kitty.clone().var("TMUX", "/tmp/t")).value);
395        assert!(!detect(&kitty.clone().terminal(false)).value);
396        assert!(!detect(&MapEnvironment::tty().var("TERM", "dumb")).value);
397    }
398
399    #[test]
400    fn the_variable_overrides_detection_both_ways() {
401        let off = MapEnvironment::tty()
402            .var("TERM", "xterm-kitty")
403            .var(CLIPBOARD_VAR, "0");
404        let field = detect(&off);
405        assert!(!field.value);
406        assert_eq!(field.origin, Origin::Environment(CLIPBOARD_VAR.into()));
407        // Forced on, on a terminal that is not known to take it.
408        assert!(detect(&MapEnvironment::tty().var(CLIPBOARD_VAR, "yes")).value);
409        // Never through a pipe, forced or not: a log or a file would hold
410        // the escape (release-test audit A).
411        let piped = detect(&MapEnvironment::new().var(CLIPBOARD_VAR, "yes"));
412        assert!(!piped.value);
413        assert_eq!(piped.reason, "not a terminal");
414        // Nonsense is ignored.
415        assert!(!detect(&MapEnvironment::tty().var(CLIPBOARD_VAR, "maybe")).value);
416    }
417
418    #[test]
419    fn a_disabled_or_oversized_copy_writes_nothing() {
420        let mut out = Vec::new();
421        let error = Clipboard::forced(false).copy(&mut out, "x").unwrap_err();
422        assert!(matches!(error, ClipboardError::Unsupported(_)));
423        let big = "x".repeat(MAX_BYTES + 1);
424        let error = Clipboard::forced(true).copy(&mut out, &big).unwrap_err();
425        assert!(matches!(error, ClipboardError::TooLarge { .. }));
426        assert!(out.is_empty());
427    }
428
429    #[test]
430    fn rows_without_headings_are_arrays() {
431        let none: [&str; 0] = [];
432        assert_eq!(CopyFormat::Json.row(&none, &["a", "b"]), r#"["a", "b"]"#);
433        assert_eq!(
434            CopyFormat::Json.row(&["k"], &["a", "b"]),
435            r#"{"k": "a", "column 2": "b"}"#
436        );
437        assert_eq!(CopyFormat::Csv.row(&none, &["a\nb", "c"]), "\"a\nb\",c");
438        assert_eq!(CopyFormat::Json.cell("\u{1b}"), r#""\u001b""#);
439    }
440}