use std::io::{self, Write};
use std::sync::atomic::{AtomicU8, Ordering};
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClipboardMode {
Auto,
X11,
Wayland,
Osc52,
None,
}
impl ClipboardMode {
pub fn parse(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"auto" => Some(Self::Auto),
"x11" | "xclip" | "xsel" => Some(Self::X11),
"wayland" | "wl-copy" | "wl" => Some(Self::Wayland),
"osc52" | "osc" | "terminal" => Some(Self::Osc52),
"none" | "off" | "disabled" | "0" => Some(Self::None),
_ => None,
}
}
}
static MODE_OVERRIDE: AtomicU8 = AtomicU8::new(0);
pub fn set_clipboard_mode(mode: ClipboardMode) {
let encoded = match mode {
ClipboardMode::Auto => 1,
ClipboardMode::X11 => 2,
ClipboardMode::Wayland => 3,
ClipboardMode::Osc52 => 4,
ClipboardMode::None => 5,
};
MODE_OVERRIDE.store(encoded, Ordering::Relaxed);
}
pub fn clipboard_mode() -> ClipboardMode {
match MODE_OVERRIDE.load(Ordering::Relaxed) {
1 => return ClipboardMode::Auto,
2 => return ClipboardMode::X11,
3 => return ClipboardMode::Wayland,
4 => return ClipboardMode::Osc52,
5 => return ClipboardMode::None,
_ => {}
}
let fallback = if cfg!(test) {
ClipboardMode::None
} else {
ClipboardMode::Auto
};
std::env::var("TUI_PANEL_SELECT_CLIPBOARD")
.ok()
.and_then(|v| ClipboardMode::parse(&v))
.unwrap_or(fallback)
}
pub fn osc52_sequence(text: &str, in_tmux: bool) -> String {
let encoded = STANDARD.encode(text.as_bytes());
let inner = format!("\x1b]52;c;{encoded}\x07");
if in_tmux {
let escaped = inner.replace('\x1b', "\x1b\x1b");
format!("\x1bPtmux;{escaped}\x1b\\")
} else {
inner
}
}
fn clipboard_candidates(
has_wayland: bool,
has_x11: bool,
is_macos: bool,
mode: ClipboardMode,
) -> Vec<(&'static str, &'static [&'static str])> {
let mut out: Vec<(&'static str, &'static [&'static str])> = Vec::new();
let want_x11 = has_x11 && matches!(mode, ClipboardMode::Auto | ClipboardMode::X11);
let want_wayland = has_wayland && matches!(mode, ClipboardMode::Auto | ClipboardMode::Wayland);
if want_x11 {
out.push(("xclip", &["-selection", "clipboard"]));
out.push(("xsel", &["--clipboard", "--input"]));
}
if want_wayland {
out.push(("wl-copy", &[]));
}
if !matches!(mode, ClipboardMode::Auto) {
return out;
}
if is_macos {
out.push(("pbcopy", &[]));
}
out.push(("clip.exe", &[]));
out
}
fn try_external_clipboard(text: &str) -> bool {
use std::process::{Command, Stdio};
let has_wayland = std::env::var_os("WAYLAND_DISPLAY").is_some();
let has_x11 = std::env::var_os("DISPLAY").is_some();
let is_macos = cfg!(target_os = "macos");
for (cmd, args) in clipboard_candidates(has_wayland, has_x11, is_macos, clipboard_mode()) {
let mut builder = Command::new(cmd);
builder
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null());
#[cfg(unix)]
unsafe {
use std::os::unix::process::CommandExt as _;
builder.pre_exec(|| {
libc::setsid();
Ok(())
});
}
let Ok(mut child) = builder.spawn() else {
continue;
};
let Some(mut stdin) = child.stdin.take() else {
reap_in_background(child);
continue;
};
let wrote = stdin.write_all(text.as_bytes()).is_ok();
drop(stdin);
reap_in_background(child);
if wrote {
return true;
}
}
false
}
fn reap_in_background(mut child: std::process::Child) {
std::thread::spawn(move || {
let _ = child.wait();
});
}
pub fn copy_to_clipboard(text: &str) {
if text.is_empty() {
return;
}
let mode = clipboard_mode();
if mode == ClipboardMode::None {
return;
}
if mode != ClipboardMode::Osc52 && try_external_clipboard(text) {
return;
}
let in_tmux = std::env::var_os("TMUX").is_some();
let seq = osc52_sequence(text, in_tmux);
let _ = io::stdout().write_all(seq.as_bytes());
let _ = io::stdout().flush();
}
#[cfg(test)]
mod tests {
use super::*;
const AUTO: ClipboardMode = ClipboardMode::Auto;
#[test]
fn builds_a_plain_osc52_sequence_outside_tmux() {
let seq = osc52_sequence("hi", false);
assert_eq!(seq, format!("\x1b]52;c;{}\x07", STANDARD.encode(b"hi")));
}
#[test]
fn wraps_the_sequence_in_a_tmux_dcs_passthrough_and_doubles_escapes() {
let seq = osc52_sequence("hi", true);
let inner = format!("\x1b]52;c;{}\x07", STANDARD.encode(b"hi"));
let expected = format!("\x1bPtmux;{}\x1b\\", inner.replace('\x1b', "\x1b\x1b"));
assert_eq!(seq, expected);
assert!(seq.starts_with("\x1bPtmux;"));
assert!(seq.ends_with("\x1b\\"));
}
#[test]
fn empty_text_still_produces_a_valid_sequence() {
let seq = osc52_sequence("", false);
assert_eq!(seq, "\x1b]52;c;\x07");
}
#[test]
fn x11_tools_are_tried_before_wayland_when_both_are_present() {
let c = clipboard_candidates(true, true, false, AUTO);
assert_eq!(c[0].0, "xclip");
assert_eq!(c[1].0, "xsel");
assert!(c.iter().any(|(name, _)| *name == "wl-copy"));
}
#[test]
fn wayland_is_used_when_no_x11_display_is_reachable() {
let c = clipboard_candidates(true, false, false, AUTO);
assert_eq!(c[0].0, "wl-copy");
assert!(!c.iter().any(|(name, _)| *name == "xclip"));
}
#[test]
fn x11_tools_are_offered_without_wayland() {
let c = clipboard_candidates(false, true, false, AUTO);
assert_eq!(c[0].0, "xclip");
assert!(!c.iter().any(|(name, _)| *name == "wl-copy"));
}
#[test]
fn macos_offers_pbcopy() {
let c = clipboard_candidates(false, false, true, AUTO);
assert!(c.iter().any(|(name, _)| *name == "pbcopy"));
}
#[test]
fn clip_exe_is_always_offered_as_a_last_resort_for_wsl() {
let c = clipboard_candidates(false, false, false, AUTO);
assert_eq!(c, vec![("clip.exe", &[][..])]);
}
#[test]
fn pinning_wayland_skips_x11_even_when_a_display_is_present() {
let c = clipboard_candidates(true, true, false, ClipboardMode::Wayland);
assert_eq!(c, vec![("wl-copy", &[][..])]);
}
#[test]
fn pinning_x11_skips_wayland_and_the_platform_fallbacks() {
let c = clipboard_candidates(true, true, true, ClipboardMode::X11);
assert_eq!(
c,
vec![
("xclip", &["-selection", "clipboard"][..]),
("xsel", &["--clipboard", "--input"][..]),
]
);
}
#[test]
fn pinning_osc52_or_none_spawns_nothing_at_all() {
assert!(clipboard_candidates(true, true, true, ClipboardMode::Osc52).is_empty());
assert!(clipboard_candidates(true, true, true, ClipboardMode::None).is_empty());
}
#[test]
fn parses_every_documented_mode_spelling_case_insensitively() {
assert_eq!(ClipboardMode::parse("Auto"), Some(ClipboardMode::Auto));
assert_eq!(ClipboardMode::parse(" x11 "), Some(ClipboardMode::X11));
assert_eq!(ClipboardMode::parse("xclip"), Some(ClipboardMode::X11));
assert_eq!(
ClipboardMode::parse("WAYLAND"),
Some(ClipboardMode::Wayland)
);
assert_eq!(ClipboardMode::parse("osc52"), Some(ClipboardMode::Osc52));
assert_eq!(ClipboardMode::parse("off"), Some(ClipboardMode::None));
}
#[test]
fn an_unrecognised_mode_is_rejected_rather_than_disabling_the_clipboard() {
assert_eq!(ClipboardMode::parse("waylnad"), None);
assert_eq!(ClipboardMode::parse(""), None);
}
#[test]
fn the_test_default_is_no_op_and_an_explicit_override_wins_over_it() {
assert_eq!(clipboard_mode(), ClipboardMode::None);
copy_to_clipboard("should not reach any clipboard");
set_clipboard_mode(ClipboardMode::Osc52);
assert_eq!(clipboard_mode(), ClipboardMode::Osc52);
MODE_OVERRIDE.store(0, Ordering::Relaxed);
assert_eq!(clipboard_mode(), ClipboardMode::None);
}
}