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 <naseschwarz@0x53a.de>
This commit is contained in:
Johannes Agricola 2025-04-05 16:38:59 +02:00 committed by GitHub
parent eb3be0fe1c
commit 7da7e31596
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 384 additions and 1 deletions

View File

@ -1,5 +1,9 @@
# Unreleased
## Added ⭐
- Copy to clipboard using OSC52 (#974)
# Version 0.28.1
## Fixed 🐛

View File

@ -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"]

View File

@ -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

View File

@ -0,0 +1,46 @@
//! Demonstrates copying a string to clipboard
//!
//! This example uses OSC control sequence `Pr = 5 2` (See
//! <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 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])
}
)
}

308
src/clipboard.rs Normal file
View File

@ -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 <https://specifications.freedesktop.org/clipboard-spec/latest/>
/// 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<char> 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<ClipboardType>,
);
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::<char>::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<Self, Self::Err> {
Ok(ClipboardSelection(
s.chars().map(From::<char>::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<T> {
/// 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<T: AsRef<[u8]>> Command for CopyToClipboard<T> {
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<T: AsRef<[u8]>> CopyToClipboard<T> {
/// 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<T> {
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<T> {
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\\");
}
}

View File

@ -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;

View File

@ -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.