From 7da7e31596087532705b3e2bf23f8effdaae6125 Mon Sep 17 00:00:00 2001 From: Johannes Agricola Date: Sat, 5 Apr 2025 16:38:59 +0200 Subject: [PATCH] Add copying to clipboard using OSC52 (#974) Many terminal emulators support copying text to clipboard using ANSI OSC Ps; PT ST with Ps = 5 2, see https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands This enables copying even through SSH and terminal multiplexers. Co-authored-by: Naseschwarz --- CHANGELOG.md | 4 + Cargo.toml | 8 + README.md | 2 + examples/copy-to-clipboard.rs | 46 +++++ src/clipboard.rs | 308 ++++++++++++++++++++++++++++++++++ src/lib.rs | 7 + src/macros.rs | 10 +- 7 files changed, 384 insertions(+), 1 deletion(-) create mode 100644 examples/copy-to-clipboard.rs create mode 100644 src/clipboard.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a2a2f05..49eebb42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Unreleased +## Added ⭐ + +- Copy to clipboard using OSC52 (#974) + # Version 0.28.1 ## Fixed 🐛 diff --git a/Cargo.toml b/Cargo.toml index 4f9ca5de..d1746601 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,7 +47,11 @@ use-dev-tty = ["filedescriptor", "rustix/process"] ## Enables `is_*` helper functions for event enums. derive-more = ["dep:derive_more"] +## Enables interacting with a host clipboard via [`clipboard`](clipboard/index.html) +osc52 = ["dep:base64"] + [dependencies] +base64 = { version = "0.22", optional = true } bitflags = { version = "2.3" } derive_more = { version = "1.0.0", features = ["is_variant"], optional = true } document-features = "0.2.10" @@ -112,3 +116,7 @@ required-features = ["events"] [[example]] name = "key-display" required-features = ["events"] + +[[example]] +name = "copy-to-clipboard" +required-features = ["osc52"] diff --git a/README.md b/README.md index 5dfc19bd..f4bc72d3 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,7 @@ features = ["event-stream"] | `events` | Reading input/system events (enabled by default) | | `filedescriptor` | Use raw filedescriptor for all events rather then mio dependency | | `derive-more` | Adds `is_*` helper functions for event types | +| `osc52` | Enables crossterm::clipboard | To use crossterm as a very thin layer you can disable the `events` feature or use `filedescriptor` feature. @@ -172,6 +173,7 @@ This can disable `mio` / `signal-hook` / `signal-hook-mio` dependencies. | `futures-core` | For async stream of events | only with `event-stream` feature flag | | `serde` | ***ser***ializing and ***de***serializing of events | only with `serde` feature flag | | `derive_more` | Adds `is_*` helper functions for event types | optional (`derive-more` feature), included by default | +| `base64` | Encoding clipboard data for OSC52 sequences in crossterm::clipboard | only with `osc52` feature flag | ### Other Resources diff --git a/examples/copy-to-clipboard.rs b/examples/copy-to-clipboard.rs new file mode 100644 index 00000000..d584b4c9 --- /dev/null +++ b/examples/copy-to-clipboard.rs @@ -0,0 +1,46 @@ +//! Demonstrates copying a string to clipboard +//! +//! This example uses OSC control sequence `Pr = 5 2` (See +//! +//! to copy data to the terminal host clipboard. +//! +//! This only works if it is enabled on the respective terminal emulator. If a terminal multiplexer +//! is used, the multiplexer will likely need to support it, too. +//! +//! ```no_run +//! cargo run --example copy-to-clipboard -- --clipboard "Some String" +//! cargo run --example copy-to-clipboard -- --primary "Some String" +//! cargo run --example copy-to-clipboard -- "Some String" +//! ``` + +use std::io; + +use crossterm::clipboard; +use crossterm::execute; + +fn main() -> io::Result<()> { + let mut stdout = io::stdout(); + let mut args = std::env::args(); + args.next(); // Skip to first argument + + let default_text = String::from("Example text"); + let (text, dest) = match args.next().as_deref() { + Some("--clipboard") => ( + args.next().unwrap_or(default_text), + clipboard::ClipboardType::Clipboard, + ), + Some("--primary") => ( + args.next().unwrap_or(default_text), + clipboard::ClipboardType::Primary, + ), + Some(text) => (text.to_owned(), clipboard::ClipboardType::Clipboard), + None => (default_text, clipboard::ClipboardType::Clipboard), + }; + execute!( + stdout, + clipboard::CopyToClipboard { + content: text, + destination: clipboard::ClipboardSelection(vec![dest]) + } + ) +} diff --git a/src/clipboard.rs b/src/clipboard.rs new file mode 100644 index 00000000..75913112 --- /dev/null +++ b/src/clipboard.rs @@ -0,0 +1,308 @@ +//! # Clipboard +//! +//! The `clipboard` module provides functionality to work with a host clipboard. +//! +//! ## Implemented operations: +//! +//! - Copy: [`CopyToClipboard`](struct.CopyToClipboard.html) +use base64::prelude::{Engine, BASE64_STANDARD}; + +use std::fmt; +use std::str::FromStr; + +use crate::{osc, Command}; + +/// Different clipboard types +/// +/// Some operating systems and desktop environments support multiple buffers +/// for copy/cut/paste. Their details differ between operating systems. +/// See +/// for a detailed survey of supported types based on the X window system. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ClipboardType { + /// Default clipboard when using Ctrl+C or Ctrl+V + Clipboard, + + /// Clipboard on Linux/X/Wayland when using selection and middle mouse button + Primary, + + /// Other clipboard type not explicitly supported by crossterm + /// + /// See + /// [XTerm Control Sequences](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands) + /// for potential values. + /// + /// Note that support for these in terminal emulators is very limited. + Other(char), +} + +impl From<&ClipboardType> for char { + fn from(val: &ClipboardType) -> Self { + match val { + ClipboardType::Clipboard => 'c', + ClipboardType::Primary => 'p', + ClipboardType::Other(other) => *other, + } + } +} + +impl From for ClipboardType { + fn from(value: char) -> Self { + match value { + 'c' => ClipboardType::Clipboard, + 'p' => ClipboardType::Primary, + other => ClipboardType::Other(other), + } + } +} + +/// A sequence of clipboard types +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ClipboardSelection( + /// An ordered list of clipboards which will be the destination for the copied selection. + /// + /// Order matters due to implementations deviating from the + /// [XTerm Control Sequences](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands) + /// reference. Some terminal emulators may only interpret the first character of this + /// parameter. For differences, see + /// [`CopyToClipboard` (Terminal Support)](struct.CopyToClipboard.html#terminal-support). + pub Vec, +); + +impl ClipboardSelection { + /// Returns a String corresponsing to the "Pc" parameter of the OSC52 + /// sequence. + fn to_osc52_pc(&self) -> String { + self.0.iter().map(Into::::into).collect() + } +} + +impl fmt::Display for ClipboardSelection { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.to_osc52_pc()) + } +} + +impl FromStr for ClipboardSelection { + type Err = (); + fn from_str(s: &str) -> Result { + Ok(ClipboardSelection( + s.chars().map(From::::from).collect(), + )) + } +} + +/// A command that copies to clipboard +/// +/// This command uses OSC control sequence `Pr = 5 2` (See +/// [XTerm Control Sequences](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands) ) +/// to copy data to the terminal host clipboard. +/// +/// This only works if it is enabled on the user's terminal emulator. If a terminal multiplexer +/// is used, the multiplexer must support it, too. +/// +/// Commands must be executed/queued for execution otherwise they do nothing. +/// +/// # Examples +/// +/// ```no_run +/// use crossterm::execute; +/// use crossterm::clipboard::CopyToClipboard; +/// // Copy foo to clipboard +/// execute!(std::io::stdout(), CopyToClipboard::to_clipboard_from("foo")); +/// // Copy bar to primary +/// execute!(std::io::stdout(), CopyToClipboard::to_primary_from("bar")); +/// ``` +/// +/// See also examples/copy-to-clipboard.rs. +/// +/// # Terminal Support +/// +/// The following table shows what destinations are filled by different terminal emulators when +/// asked to copy to different destination sequences. +/// +/// | Terminal (Version) | dest '' | dest 'c' | dest 'p' | dest 'cp' | dest'pc' | +/// | --------------------- | --------- | --------- | -------- | ------------- | ------------- | +/// | xterm (397) *3 | primary | clipboard | primary | clipb., prim. | clipb., prim. | +/// | Alacritty (0.15.1) *3 | clipboard | clipboard | primary | clipb. | prim. | +/// | Wezterm (*1) *3 | clipboard | clipboard | primary | clipb. | clipb. | +/// | Konsole (24.12.3) *3 | clipboard | clipboard | primary | clipb., prim. | clipb., prim. | +/// | Kitty (0.40.0) *3 | clipboard | clipboard | primary | clipb. | clipb. | +/// | foot (1.20.2) *3 | clipboard | clipboard | primary | clipb., prim. | clipb., prim. | +/// | tmux (3.5a) *2 *3 | primary | clipboard | primary | clipb., prim. | clipb., prim. | +/// +/// Asterisks: +/// 1. 20240203-110809-5046fc22 +/// 2. set-clipboard set to [external](https://github.com/tmux/tmux/wiki/Clipboard#how-it-works), +/// i.e. this is OSC52 pass-through. +/// 3. This was tested on wayland with the +/// [primary selection protocol](https://wayland.app/protocols/primary-selection-unstable-v1) +/// enabled. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CopyToClipboard { + /// Content to be copied + pub content: T, + /// Sequence of copy destinations + /// + /// Not all sequences are equally supported by terminal emulators. See + /// [`CopyToClipboard` (Terminal Support)](struct.CopyToClipboard.html#terminal-support). + pub destination: ClipboardSelection, +} + +impl> Command for CopyToClipboard { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + write!( + f, + osc!("52;{destination};{encoded_text}"), + destination = self.destination.to_osc52_pc(), + encoded_text = BASE64_STANDARD.encode(&self.content) + ) + } + + #[cfg(windows)] + fn execute_winapi(&self) -> std::io::Result<()> { + use std::io; + + Err(io::Error::new( + io::ErrorKind::Unsupported, + "Copying is not implemented for the Windows API.", + )) + } +} + +impl> CopyToClipboard { + /// Construct a [`CopyToClipboard`] that writes content into the + /// "clipboard" (or 'c') clipboard selection. + /// + /// # Example + /// + /// ```no_run + /// use crossterm::{execute, Command}; + /// use crossterm::clipboard::CopyToClipboard; + /// execute!(std::io::stdout(), CopyToClipboard::to_clipboard_from("foo")); + /// ``` + pub fn to_clipboard_from(content: T) -> CopyToClipboard { + CopyToClipboard { + content, + destination: ClipboardSelection(vec![ClipboardType::Clipboard]), + } + } + + /// Construct a [`CopyToClipboard`] that writes content into the "primary" + /// (or 'p') clipboard selection. + /// + /// # Example + /// + /// ```no_run + /// use crossterm::execute; + /// use crossterm::clipboard::CopyToClipboard; + /// execute!(std::io::stdout(), CopyToClipboard::to_primary_from("foo")); + /// ``` + pub fn to_primary_from(content: T) -> CopyToClipboard { + CopyToClipboard { + content, + destination: ClipboardSelection(vec![ClipboardType::Primary]), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_clipboard_string_to_selection() { + assert_eq!( + ClipboardSelection::from_str("p").unwrap(), + ClipboardSelection(vec![ClipboardType::Primary]) + ); + assert_eq!( + ClipboardSelection::from_str("").unwrap(), + ClipboardSelection(vec![]) + ); + assert_eq!( + ClipboardSelection::from_str("cp").unwrap(), + ClipboardSelection(vec![ClipboardType::Clipboard, ClipboardType::Primary]) + ); + } + #[test] + fn test_clipboard_selection_to_osc52_pc() { + assert_eq!(ClipboardSelection(vec![]).to_osc52_pc(), ""); + assert_eq!( + ClipboardSelection(vec![ClipboardType::Clipboard]).to_osc52_pc(), + "c" + ); + assert_eq!( + ClipboardSelection(vec![ClipboardType::Primary]).to_osc52_pc(), + "p" + ); + assert_eq!( + ClipboardSelection(vec![ClipboardType::Primary, ClipboardType::Clipboard]) + .to_osc52_pc(), + "pc" + ); + assert_eq!( + ClipboardSelection(vec![ClipboardType::Clipboard, ClipboardType::Primary]) + .to_osc52_pc(), + "cp" + ); + assert_eq!( + ClipboardSelection(vec![ClipboardType::Other('s')]).to_osc52_pc(), + "s" + ); + } + + #[test] + fn test_clipboard_copy_string_osc52() { + let mut buffer = String::new(); + super::CopyToClipboard { + content: "foo", + destination: ClipboardSelection(vec![ClipboardType::Clipboard]), + } + .write_ansi(&mut buffer) + .unwrap(); + assert_eq!(buffer, "\x1b]52;c;Zm9v\x1b\\"); + + buffer.clear(); + super::CopyToClipboard { + content: "foo", + destination: ClipboardSelection(vec![ClipboardType::Primary]), + } + .write_ansi(&mut buffer) + .unwrap(); + assert_eq!(buffer, "\x1b]52;p;Zm9v\x1b\\"); + + buffer.clear(); + super::CopyToClipboard { + content: "foo", + destination: ClipboardSelection(vec![ClipboardType::Primary, ClipboardType::Clipboard]), + } + .write_ansi(&mut buffer) + .unwrap(); + assert_eq!(buffer, "\x1b]52;pc;Zm9v\x1b\\"); + + buffer.clear(); + super::CopyToClipboard { + content: "foo", + destination: ClipboardSelection(vec![]), + } + .write_ansi(&mut buffer) + .unwrap(); + assert_eq!(buffer, "\x1b]52;;Zm9v\x1b\\"); + } + + #[test] + fn test_clipboard_copy_string_osc52_constructor() { + let mut buffer = String::new(); + super::CopyToClipboard::to_clipboard_from("foo") + .write_ansi(&mut buffer) + .unwrap(); + assert_eq!(buffer, "\x1b]52;c;Zm9v\x1b\\"); + + let mut buffer = String::new(); + super::CopyToClipboard::to_primary_from("foo") + .write_ansi(&mut buffer) + .unwrap(); + assert_eq!(buffer, "\x1b]52;p;Zm9v\x1b\\"); + } +} diff --git a/src/lib.rs b/src/lib.rs index 41e235b2..96818291 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -66,6 +66,9 @@ //! [`EnableLineWrap`](terminal/struct.EnableLineWrap.html) //! - Alternate screen - [`EnterAlternateScreen`](terminal/struct.EnterAlternateScreen.html), //! [`LeaveAlternateScreen`](terminal/struct.LeaveAlternateScreen.html) +//! - Module [`clipboard`](clipboard/index.html) (requires +//! [`feature = "osc52"`](#optional-features)) +//! - Clipboard - [`CopyToClipboard`](clipboard/struct.CopyToClipboard.html) //! //! ### Command Execution //! @@ -246,6 +249,10 @@ pub mod terminal; /// A module to query if the current instance is a tty. pub mod tty; +/// A module for clipboard interaction +#[cfg(feature = "osc52")] +pub mod clipboard; + #[cfg(windows)] /// A module that exposes one function to check if the current terminal supports ANSI sequences. pub mod ansi_support; diff --git a/src/macros.rs b/src/macros.rs index f3983953..47ded985 100644 --- a/src/macros.rs +++ b/src/macros.rs @@ -1,10 +1,18 @@ -/// Append a the first few characters of an ANSI escape code to the given string. +/// Concatenate string literals while prepending a ANSI control sequence introducer (`"\x1b["`) #[macro_export] #[doc(hidden)] macro_rules! csi { ($( $l:expr ),*) => { concat!("\x1B[", $( $l ),*) }; } +/// Concatenate string literals while prepending a xterm Operating System Commands (OSC) +/// introducer (`"\x1b]"`) and appending a BEL (`"\x07"`). +#[macro_export] +#[doc(hidden)] +macro_rules! osc { + ($( $l:expr ),*) => { concat!("\x1B]", $( $l ),*, "\x1B\\") }; +} + /// Queues one or more command(s) for further execution. /// /// Queued commands must be flushed to the underlying device to be executed.