From 28a49c5bbedf7d9785c49eb667eba539f9b56212 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 25 Jun 2026 09:30:20 +0300 Subject: [PATCH] osc: clipboard get/set via OSC 52 Signed-off-by: NotAShelf Change-Id: I39637cb00313f1f9f83a4ac2977794246a6a6964 --- src/vt.rs | 142 +++++++++++++++++++++++++++++++++++++++++++++++++ src/wayland.rs | 64 ++++++++++++++++++---- 2 files changed, 196 insertions(+), 10 deletions(-) diff --git a/src/vt.rs b/src/vt.rs index 0c3f5b8..69f71b1 100644 --- a/src/vt.rs +++ b/src/vt.rs @@ -21,6 +21,16 @@ enum DaLevel { Tertiary, } +/// A clipboard request from the application (OSC 52), for the front-end to act +/// on since it owns the Wayland selections. +#[derive(Clone, Debug)] +pub enum ClipboardOp { + /// Set the clipboard (or primary) to `text`. + Set { primary: bool, text: String }, + /// Report the current clipboard (or primary) contents back to the app. + Query { primary: bool }, +} + /// DECRQM mode-state code: 1 = set, 2 = reset. fn set_reset(on: bool) -> u8 { if on { 1 } else { 2 } @@ -85,6 +95,8 @@ pub struct Term { shift_out: bool, /// Accumulated payload of an in-progress `DCS + q` (XTGETTCAP) query. xtgettcap: Option>, + /// Pending OSC 52 clipboard requests, drained by the front-end. + clipboard_ops: Vec, } impl Term { @@ -98,9 +110,15 @@ impl Term { g1: Charset::Ascii, shift_out: false, xtgettcap: None, + clipboard_ops: Vec::new(), } } + /// Drain the OSC 52 clipboard requests accumulated since the last call. + pub fn take_clipboard_ops(&mut self) -> Vec { + std::mem::take(&mut self.clipboard_ops) + } + /// Answer an XTGETTCAP query: for each hex-encoded capability name, reply /// with `DCS 1 + r name=value ST` if known, else `DCS 0 + r name ST`. fn answer_xtgettcap(&mut self, payload: &[u8]) { @@ -562,6 +580,20 @@ impl Perform for Term { .set_cursor_color(params.get(1).and_then(|s| parse_color(s))); } Some(&n) if n == b"112" => self.grid.set_cursor_color(None), + // OSC 52: clipboard get/set. Pc selects the target, Pd is base64 or + // `?` to query. We only touch `c` (clipboard) and `p` (primary). + Some(&n) if n == b"52" => { + let target = params.get(1).copied().unwrap_or(b""); + let data = params.get(2).copied().unwrap_or(b""); + let primary = target.first() == Some(&b'p'); + if data == b"?" { + self.clipboard_ops.push(ClipboardOp::Query { primary }); + } else if let Some(text) = + base64_decode(data).and_then(|b| String::from_utf8(b).ok()) + { + self.clipboard_ops.push(ClipboardOp::Set { primary, text }); + } + } _ => {} } } @@ -596,6 +628,73 @@ fn cap_value(name: &[u8]) -> Option<&'static str> { } } +const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +/// Standard base64 encode (used for OSC 52 query replies). +pub fn base64_encode(data: &[u8]) -> String { + let mut out = String::with_capacity(data.len().div_ceil(3) * 4); + for chunk in data.chunks(3) { + let b = [ + chunk[0], + *chunk.get(1).unwrap_or(&0), + *chunk.get(2).unwrap_or(&0), + ]; + let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]); + out.push(B64[(n >> 18 & 63) as usize] as char); + out.push(B64[(n >> 12 & 63) as usize] as char); + out.push(if chunk.len() > 1 { + B64[(n >> 6 & 63) as usize] as char + } else { + '=' + }); + out.push(if chunk.len() > 2 { + B64[(n & 63) as usize] as char + } else { + '=' + }); + } + out +} + +/// Standard base64 decode, ignoring padding and whitespace; `None` on a bad +/// character. +fn base64_decode(data: &[u8]) -> Option> { + let val = |c: u8| -> Option { + match c { + b'A'..=b'Z' => Some(u32::from(c - b'A')), + b'a'..=b'z' => Some(u32::from(c - b'a') + 26), + b'0'..=b'9' => Some(u32::from(c - b'0') + 52), + b'+' => Some(62), + b'/' => Some(63), + _ => None, + } + }; + let filtered: Vec = data + .iter() + .copied() + .filter(|&c| c != b'=' && !c.is_ascii_whitespace()) + .collect(); + let mut out = Vec::with_capacity(filtered.len() / 4 * 3); + for chunk in filtered.chunks(4) { + if chunk.len() == 1 { + return None; // a lone sextet cannot form a byte + } + let mut n = 0u32; + for &c in chunk { + n = (n << 6) | val(c)?; + } + n <<= 6 * (4 - chunk.len() as u32); + out.push((n >> 16) as u8); + if chunk.len() >= 3 { + out.push((n >> 8) as u8); + } + if chunk.len() >= 4 { + out.push(n as u8); + } + } + Some(out) +} + /// Decode an even-length lowercase/uppercase hex string into bytes. fn decode_hex(s: &[u8]) -> Option> { if s.is_empty() || !s.len().is_multiple_of(2) { @@ -757,6 +856,49 @@ mod tests { assert_eq!(t.take_response(), b"\x1b[?2026;2$y"); } + #[test] + fn base64_round_trips() { + for s in [ + "", + "f", + "fo", + "foo", + "foob", + "fooba", + "foobar", + "hi there\n", + ] { + let enc = base64_encode(s.as_bytes()); + assert_eq!(base64_decode(enc.as_bytes()).as_deref(), Some(s.as_bytes())); + } + assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy"); + assert_eq!(base64_decode(b"Zm9vYmFy").as_deref(), Some(&b"foobar"[..])); + } + + #[test] + fn osc52_set_and_query() { + let mut t = Term::new(20, 2); + // Set clipboard to "hi" (base64 "aGk="). + feed(&mut t, b"\x1b]52;c;aGk=\x07"); + let ops = t.take_clipboard_ops(); + match ops.as_slice() { + [ + ClipboardOp::Set { + primary: false, + text, + }, + ] => assert_eq!(text, "hi"), + other => panic!("unexpected ops: {other:?}"), + } + // Query the primary selection. + feed(&mut t, b"\x1b]52;p;?\x07"); + let ops = t.take_clipboard_ops(); + assert!(matches!( + ops.as_slice(), + [ClipboardOp::Query { primary: true }] + )); + } + #[test] fn mouse_modes_track_protocol_and_encoding() { let mut t = Term::new(20, 4); diff --git a/src/wayland.rs b/src/wayland.rs index 1d1221a..7a4de4e 100644 --- a/src/wayland.rs +++ b/src/wayland.rs @@ -689,11 +689,8 @@ impl App { (!text.is_empty()).then_some(text) } - /// Claim the clipboard (CLIPBOARD) with the current selection (Ctrl+Shift+C). - fn set_clipboard(&mut self, qh: &QueueHandle) { - let Some(text) = self.selection_text() else { - return; - }; + /// Take ownership of the CLIPBOARD selection, serving `text` to pasters. + fn claim_clipboard(&mut self, text: String, qh: &QueueHandle) { let Some(device) = self.data_device.as_ref() else { return; }; @@ -705,11 +702,8 @@ impl App { self.copy_source = Some(source); } - /// Claim the primary selection with the current selection (select-to-copy). - fn set_primary(&mut self, qh: &QueueHandle) { - let Some(text) = self.selection_text() else { - return; - }; + /// Take ownership of the primary selection, serving `text` to pasters. + fn claim_primary(&mut self, text: String, qh: &QueueHandle) { let (Some(manager), Some(device)) = (self.primary_manager.as_ref(), self.primary_device.as_ref()) else { @@ -721,6 +715,52 @@ impl App { self.primary_source = Some(source); } + /// Claim the clipboard (CLIPBOARD) with the current selection (Ctrl+Shift+C). + fn set_clipboard(&mut self, qh: &QueueHandle) { + if let Some(text) = self.selection_text() { + self.claim_clipboard(text, qh); + } + } + + /// Claim the primary selection with the current selection (select-to-copy). + fn set_primary(&mut self, qh: &QueueHandle) { + if let Some(text) = self.selection_text() { + self.claim_primary(text, qh); + } + } + + /// Act on the OSC 52 clipboard requests an application made: take ownership + /// of the selection it set, or answer a query with what we currently hold. + fn handle_clipboard_ops(&mut self, ops: Vec) { + use crate::vt::ClipboardOp; + let qh = self.qh.clone(); + for op in ops { + match op { + ClipboardOp::Set { + primary: true, + text, + } => self.claim_primary(text, &qh), + ClipboardOp::Set { + primary: false, + text, + } => self.claim_clipboard(text, &qh), + ClipboardOp::Query { primary } => { + let text = if primary { + &self.primary_clip + } else { + &self.clipboard + }; + let kind = if primary { 'p' } else { 'c' }; + let reply = format!( + "\x1b]52;{kind};{}\x07", + crate::vt::base64_encode(text.as_bytes()) + ); + self.write_to_pty(reply.as_bytes()); + } + } + } + } + /// Paste the CLIPBOARD selection into the shell (Ctrl+Shift+V). fn paste_clipboard(&mut self) { let Some(offer) = self @@ -836,6 +876,10 @@ impl App { self.window .set_title(self.title.clone().unwrap_or_default()); } + let ops = session.term.take_clipboard_ops(); + if !ops.is_empty() { + self.handle_clipboard_ops(ops); + } self.needs_draw = true; }