diff --git a/Cargo.lock b/Cargo.lock index 996657e..1a6b28a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -73,6 +73,12 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "1.3.2" @@ -816,6 +822,7 @@ name = "otree" version = "0.6.4" dependencies = [ "anyhow", + "base64", "clap", "console", "crossterm 0.29.0", diff --git a/Cargo.toml b/Cargo.toml index 7d25a11..8dc35ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ description = "A command line tool to view objects (json/yaml/toml) in TUI tree [dependencies] anyhow = "^1" +base64 = "^0" clap = { version = "^4", features = ["derive"] } console = "^0" crossterm = { version = "^0", features = ["use-dev-tty"] } diff --git a/src/clipboard.rs b/src/clipboard.rs index a0092fa..1e09093 100644 --- a/src/clipboard.rs +++ b/src/clipboard.rs @@ -1,9 +1,95 @@ use std::env; +use std::fs::OpenOptions; use std::io::{self, Write}; use std::process::{Command, Stdio}; use anyhow::{bail, Context, Result}; +/// The terminal multiplexer environment we are running inside, if any. +enum Muxer { + /// tmux — requires DCS passthrough wrapping with doubled ESC bytes. + Tmux, + /// GNU Screen — requires DCS passthrough wrapping. + Screen, + /// Zellij — supports OSC 52 natively, no wrapping needed. + Zellij, + /// No known multiplexer detected. + None, +} + +fn detect_muxer() -> Muxer { + // Order matters: it is possible to nest muxers (e.g. tmux inside Zellij). + // Check the innermost (most specific) first. + if env::var("ZELLIJ").is_ok() { + Muxer::Zellij + } else if env::var("TMUX").is_ok() { + Muxer::Tmux + } else if env::var("STY").is_ok() { + Muxer::Screen + } else { + Muxer::None + } +} + +/// Build the OSC 52 clipboard write sequence, wrapped appropriately for the +/// current terminal multiplexer. +/// +/// The raw OSC 52 sequence is: +/// ESC ] 52 ; c ; BEL +/// +/// Multiplexer wrapping: +/// tmux — `ESC P tmux; ESC ESC \` +/// screen — `ESC P ESC \` +/// zellij / bare terminal — no wrapping needed. +fn build_osc52_sequence(text: &str) -> String { + use base64::Engine; + let encoded = base64::engine::general_purpose::STANDARD.encode(text.as_bytes()); + + let osc52 = format!("\x1b]52;c;{encoded}\x07"); + + match detect_muxer() { + Muxer::Tmux => { + // Inside tmux the ESC bytes in the inner sequence must be doubled + // and the whole thing wrapped in a DCS passthrough. + let inner = osc52.replace('\x1b', "\x1b\x1b"); + format!("\x1bPtmux;{inner}\x1b\\") + } + Muxer::Screen => { + // GNU Screen DCS passthrough. + format!("\x1bP{osc52}\x1b\\") + } + Muxer::Zellij | Muxer::None => osc52, + } +} + +/// Write to the clipboard using the OSC 52 escape sequence. +/// +/// The sequence is written directly to the controlling TTY (`/dev/tty`) so it +/// reaches the outer terminal emulator even when stdout is owned by a TUI +/// framework like ratatui / crossterm. +fn write_osc52(text: &str) -> Result<()> { + let seq = build_osc52_sequence(text); + + let mut tty = OpenOptions::new() + .write(true) + .open("/dev/tty") + .context("open /dev/tty for OSC 52 clipboard write")?; + + tty.write_all(seq.as_bytes()) + .context("write OSC 52 sequence to /dev/tty")?; + tty.flush().context("flush /dev/tty after OSC 52 write")?; + + Ok(()) +} + +/// Return `true` when we should prefer OSC 52 over a system clipboard command. +/// +/// This is the case when we are inside any known terminal multiplexer, or when +/// no suitable clipboard command can be found on the system. +fn should_use_osc52() -> bool { + !matches!(detect_muxer(), Muxer::None) +} + fn get_cmd() -> Result { let cmd = match env::consts::OS { "macos" => Command::new("pbcopy"), @@ -25,15 +111,15 @@ fn get_cmd() -> Result { Ok(cmd) } -pub fn write_clipboard(text: &str) -> Result<()> { +fn write_clipboard_cmd(text: &str) -> Result<()> { let mut cmd = get_cmd()?; cmd.stdin(Stdio::piped()); let mut child = match cmd.spawn() { Ok(child) => child, Err(err) if err.kind() == io::ErrorKind::NotFound => { - let program = cmd.get_program().to_string_lossy(); - bail!("cannot find clipboard program '{program}' in your system, please install it first to support clipboard") + // No system clipboard tool — fall back to OSC 52 as a last resort. + return write_osc52(text); } Err(err) => return Err(err).context("launch clipboard program failed"), }; @@ -54,3 +140,10 @@ pub fn write_clipboard(text: &str) -> Result<()> { Ok(()) } + +pub fn write_clipboard(text: &str) -> Result<()> { + if should_use_osc52() { + return write_osc52(text); + } + write_clipboard_cmd(text) +}