use std::io::{Read, Write};
use std::process::{Command, Stdio};
use std::sync::mpsc;
use std::thread;
use std::time::{Duration, Instant};
use crossterm::Command as _;
use crossterm::clipboard::CopyToClipboard;
pub trait Clipboard: Send {
fn set(&mut self, text: &str) -> Result<(), String>;
fn get(&mut self) -> Option<String>;
}
#[derive(Debug, Default)]
pub struct MemoryClipboard {
text: Option<String>,
}
impl Clipboard for MemoryClipboard {
fn set(&mut self, text: &str) -> Result<(), String> {
self.text = Some(text.to_string());
Ok(())
}
fn get(&mut self) -> Option<String> {
self.text.clone()
}
}
const TOOL_TIMEOUT: Duration = Duration::from_millis(500);
pub struct SystemClipboard {
native: Option<arboard::Clipboard>,
terminal: Option<Box<dyn Write + Send>>,
tools: &'static [Tool],
}
#[derive(Debug, Clone, Default)]
struct Env {
wayland: bool,
x11: bool,
term: Option<String>,
}
impl Env {
fn current() -> Self {
let set = |name| std::env::var_os(name).is_some_and(|v| !v.is_empty());
Self {
wayland: set("WAYLAND_DISPLAY"),
x11: set("DISPLAY"),
term: std::env::var("TERM").ok(),
}
}
fn has_display(&self) -> bool {
if cfg!(any(target_os = "macos", windows)) {
return true;
}
self.wayland || self.x11
}
fn has_terminal(&self) -> bool {
self.term
.as_deref()
.is_some_and(|t| !t.is_empty() && t != "dumb")
}
fn tools(&self) -> &'static [Tool] {
if cfg!(target_os = "macos") {
&[PBCOPY]
} else if cfg!(windows) {
&[]
} else if self.wayland {
&[WL_COPY, XCLIP, XSEL]
} else if self.x11 {
&[XCLIP, XSEL]
} else {
&[]
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Tool {
copy: &'static [&'static str],
paste: &'static [&'static str],
}
const WL_COPY: Tool = Tool {
copy: &["wl-copy"],
paste: &["wl-paste", "--no-newline"],
};
const XCLIP: Tool = Tool {
copy: &["xclip", "-selection", "clipboard", "-in"],
paste: &["xclip", "-selection", "clipboard", "-out"],
};
const XSEL: Tool = Tool {
copy: &["xsel", "--clipboard", "--input"],
paste: &["xsel", "--clipboard", "--output"],
};
const PBCOPY: Tool = Tool {
copy: &["pbcopy"],
paste: &["pbpaste"],
};
impl SystemClipboard {
pub fn new(terminal: Box<dyn Write + Send>) -> Self {
Self::with_env(&Env::current(), terminal)
}
fn with_env(env: &Env, terminal: Box<dyn Write + Send>) -> Self {
let native = if env.has_display() {
arboard::Clipboard::new().ok()
} else {
None
};
Self {
native,
terminal: env.has_terminal().then_some(terminal),
tools: env.tools(),
}
}
fn set_with_tool(&self, text: &str) -> Result<(), String> {
let mut last = Err("no clipboard tool found".to_string());
for tool in self.tools {
last = run_tool(tool.copy, Some(text)).map(|_| ());
if last.is_ok() {
break;
}
}
last
}
fn get_with_tool(&self) -> Option<String> {
self.tools
.iter()
.find_map(|tool| run_tool(tool.paste, None).ok().flatten())
}
fn set_with_osc52(&mut self, text: &str) -> Result<(), String> {
let Some(terminal) = self.terminal.as_mut() else {
return Err("no terminal for OSC 52".to_string());
};
terminal
.write_all(osc52(text).as_bytes())
.and_then(|()| terminal.flush())
.map_err(|e| format!("OSC 52: {e}"))
}
}
impl Clipboard for SystemClipboard {
fn set(&mut self, text: &str) -> Result<(), String> {
let native = match self.native.as_mut() {
Some(clip) => clip.set_text(text).map_err(|e| e.to_string()),
None => Err("no display".to_string()),
};
let native = native.or_else(|e| self.set_with_tool(text).map_err(|_| e));
let osc = self.set_with_osc52(text);
match (native, osc) {
(Ok(()), _) | (_, Ok(())) => Ok(()),
(Err(e), Err(_)) => Err(e),
}
}
fn get(&mut self) -> Option<String> {
let native = self.native.as_mut().and_then(|clip| clip.get_text().ok());
native
.or_else(|| self.get_with_tool())
.filter(|text| !text.is_empty())
}
}
fn osc52(text: &str) -> String {
let mut out = String::new();
let _ = CopyToClipboard::to_clipboard_from(text).write_ansi(&mut out);
out
}
fn run_tool(argv: &[&str], input: Option<&str>) -> Result<Option<String>, String> {
let (program, args) = argv.split_first().ok_or("empty command")?;
let mut child = Command::new(program)
.args(args)
.stdin(if input.is_some() {
Stdio::piped()
} else {
Stdio::null()
})
.stdout(if input.is_some() {
Stdio::null()
} else {
Stdio::piped()
})
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("{program}: {e}"))?;
if let (Some(mut stdin), Some(text)) = (child.stdin.take(), input) {
let text = text.to_string();
thread::spawn(move || stdin.write_all(text.as_bytes()));
}
let output = child.stdout.take().map(|mut stdout| {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let mut buf = Vec::new();
let _ = tx.send(stdout.read_to_end(&mut buf).map(|_| buf));
});
rx
});
let deadline = Instant::now() + TOOL_TIMEOUT;
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break status,
Ok(None) if Instant::now() < deadline => thread::sleep(Duration::from_millis(5)),
Ok(None) => {
let _ = child.kill();
let _ = child.wait();
return Err(format!("{program} timed out"));
}
Err(e) => return Err(format!("{program}: {e}")),
}
};
if !status.success() {
return Err(format!("{program} failed: {status}"));
}
let Some(rx) = output else {
return Ok(None);
};
let left = deadline.saturating_duration_since(Instant::now());
match rx.recv_timeout(left.max(Duration::from_millis(50))) {
Ok(Ok(bytes)) => Ok(Some(String::from_utf8_lossy(&bytes).into_owned())),
Ok(Err(e)) => Err(format!("{program}: {e}")),
Err(_) => Err(format!("{program} timed out")),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Arc, Mutex};
#[derive(Clone, Default)]
struct Recorder(Arc<Mutex<Vec<u8>>>);
impl Recorder {
fn text(&self) -> String {
String::from_utf8(self.0.lock().unwrap().clone()).unwrap()
}
}
impl Write for Recorder {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
fn headless(term: Option<&str>) -> Env {
Env {
wayland: false,
x11: false,
term: term.map(str::to_string),
}
}
#[test]
fn paste_with_nothing_anywhere_does_nothing() {
for keys in ["p", "P"] {
let mut h = crate::tui::harness::Harness::at(&["abc", "d"], 0, 1);
h.keys(keys);
assert_eq!(h.lines(), ["abc", "d"], "{keys}");
assert_eq!(h.app.editor.clipboard().get(), None, "{keys}");
}
}
#[test]
fn osc52_is_base64_between_the_prefix_and_st() {
let cases = [
("", "\x1b]52;c;\x1b\\"),
("hello", "\x1b]52;c;aGVsbG8=\x1b\\"),
("10 + 2", "\x1b]52;c;MTAgKyAy\x1b\\"),
("a\nb", "\x1b]52;c;YQpi\x1b\\"),
("é€", "\x1b]52;c;w6nigqw=\x1b\\"),
];
for (text, want) in cases {
assert_eq!(osc52(text), want, "{text:?}");
}
}
#[test]
fn without_a_display_writes_go_to_the_terminal_only() {
if cfg!(any(target_os = "macos", windows)) {
return;
}
let out = Recorder::default();
let mut clip = SystemClipboard::with_env(&headless(Some("xterm")), Box::new(out.clone()));
assert!(clip.native.is_none());
assert!(clip.tools.is_empty());
assert_eq!(clip.set("hello"), Ok(()));
assert_eq!(out.text(), "\x1b]52;c;aGVsbG8=\x1b\\");
assert_eq!(clip.get(), None);
}
#[test]
fn with_no_channel_at_all_the_write_fails_cleanly() {
if cfg!(any(target_os = "macos", windows)) {
return;
}
for term in [None, Some(""), Some("dumb")] {
let out = Recorder::default();
let mut clip = SystemClipboard::with_env(&headless(term), Box::new(out.clone()));
assert_eq!(clip.set("hello"), Err("no display".to_string()), "{term:?}");
assert_eq!(out.text(), "", "{term:?}");
assert_eq!(clip.get(), None, "{term:?}");
}
}
#[test]
fn tools_follow_the_display() {
if cfg!(any(target_os = "macos", windows)) {
return;
}
let env = |wayland, x11| Env {
wayland,
x11,
term: None,
};
assert_eq!(env(true, true).tools(), &[WL_COPY, XCLIP, XSEL]);
assert_eq!(env(false, true).tools(), &[XCLIP, XSEL]);
assert!(env(false, false).tools().is_empty());
}
#[test]
fn a_missing_tool_is_an_error_not_a_panic() {
let missing = ["nm-test-no-such-clipboard-tool"];
assert!(run_tool(&missing, Some("x")).is_err());
assert!(run_tool(&missing, None).is_err());
assert!(run_tool(&[], None).is_err());
}
#[test]
fn tool_output_is_read_and_slow_tools_are_killed() {
if cfg!(windows) {
return;
}
assert_eq!(
run_tool(&["printf", "a\nb"], None),
Ok(Some("a\nb".to_string()))
);
assert_eq!(run_tool(&["cat"], Some("text")), Ok(None));
assert!(run_tool(&["false"], None).is_err());
let start = Instant::now();
assert_eq!(
run_tool(&["sleep", "5"], None),
Err("sleep timed out".to_string())
);
assert!(start.elapsed() < Duration::from_secs(2));
}
#[test]
fn real_construction_in_this_environment() {
let env = Env::current();
if env.has_display() {
return;
}
assert!(arboard::Clipboard::new().is_err());
let out = Recorder::default();
let mut clip = SystemClipboard::new(Box::new(out.clone()));
assert!(clip.native.is_none());
assert_eq!(clip.get(), None);
let _ = clip.set("x");
}
#[test]
fn real_construction_without_a_display() {
if cfg!(any(target_os = "macos", windows)) {
return;
}
let exe = std::env::current_exe().unwrap();
let out = Command::new(exe)
.args([
"--exact",
"tui::clipboard::tests::real_construction_in_this_environment",
])
.env_remove("WAYLAND_DISPLAY")
.env_remove("DISPLAY")
.stderr(Stdio::null())
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(out.status.success(), "{stdout}");
assert!(stdout.contains("1 passed"), "{stdout}");
}
}