forked from NotAShelf/beer
osc: clipboard get/set via OSC 52
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I39637cb00313f1f9f83a4ac2977794246a6a6964
This commit is contained in:
parent
1b4c293c99
commit
28a49c5bbe
2 changed files with 196 additions and 10 deletions
142
src/vt.rs
142
src/vt.rs
|
|
@ -21,6 +21,16 @@ enum DaLevel {
|
||||||
Tertiary,
|
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.
|
/// DECRQM mode-state code: 1 = set, 2 = reset.
|
||||||
fn set_reset(on: bool) -> u8 {
|
fn set_reset(on: bool) -> u8 {
|
||||||
if on { 1 } else { 2 }
|
if on { 1 } else { 2 }
|
||||||
|
|
@ -85,6 +95,8 @@ pub struct Term {
|
||||||
shift_out: bool,
|
shift_out: bool,
|
||||||
/// Accumulated payload of an in-progress `DCS + q` (XTGETTCAP) query.
|
/// Accumulated payload of an in-progress `DCS + q` (XTGETTCAP) query.
|
||||||
xtgettcap: Option<Vec<u8>>,
|
xtgettcap: Option<Vec<u8>>,
|
||||||
|
/// Pending OSC 52 clipboard requests, drained by the front-end.
|
||||||
|
clipboard_ops: Vec<ClipboardOp>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Term {
|
impl Term {
|
||||||
|
|
@ -98,9 +110,15 @@ impl Term {
|
||||||
g1: Charset::Ascii,
|
g1: Charset::Ascii,
|
||||||
shift_out: false,
|
shift_out: false,
|
||||||
xtgettcap: None,
|
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<ClipboardOp> {
|
||||||
|
std::mem::take(&mut self.clipboard_ops)
|
||||||
|
}
|
||||||
|
|
||||||
/// Answer an XTGETTCAP query: for each hex-encoded capability name, reply
|
/// 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`.
|
/// with `DCS 1 + r name=value ST` if known, else `DCS 0 + r name ST`.
|
||||||
fn answer_xtgettcap(&mut self, payload: &[u8]) {
|
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)));
|
.set_cursor_color(params.get(1).and_then(|s| parse_color(s)));
|
||||||
}
|
}
|
||||||
Some(&n) if n == b"112" => self.grid.set_cursor_color(None),
|
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<Vec<u8>> {
|
||||||
|
let val = |c: u8| -> Option<u32> {
|
||||||
|
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<u8> = 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.
|
/// Decode an even-length lowercase/uppercase hex string into bytes.
|
||||||
fn decode_hex(s: &[u8]) -> Option<Vec<u8>> {
|
fn decode_hex(s: &[u8]) -> Option<Vec<u8>> {
|
||||||
if s.is_empty() || !s.len().is_multiple_of(2) {
|
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");
|
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]
|
#[test]
|
||||||
fn mouse_modes_track_protocol_and_encoding() {
|
fn mouse_modes_track_protocol_and_encoding() {
|
||||||
let mut t = Term::new(20, 4);
|
let mut t = Term::new(20, 4);
|
||||||
|
|
|
||||||
|
|
@ -689,11 +689,8 @@ impl App {
|
||||||
(!text.is_empty()).then_some(text)
|
(!text.is_empty()).then_some(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Claim the clipboard (CLIPBOARD) with the current selection (Ctrl+Shift+C).
|
/// Take ownership of the CLIPBOARD selection, serving `text` to pasters.
|
||||||
fn set_clipboard(&mut self, qh: &QueueHandle<App>) {
|
fn claim_clipboard(&mut self, text: String, qh: &QueueHandle<App>) {
|
||||||
let Some(text) = self.selection_text() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let Some(device) = self.data_device.as_ref() else {
|
let Some(device) = self.data_device.as_ref() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
@ -705,11 +702,8 @@ impl App {
|
||||||
self.copy_source = Some(source);
|
self.copy_source = Some(source);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Claim the primary selection with the current selection (select-to-copy).
|
/// Take ownership of the primary selection, serving `text` to pasters.
|
||||||
fn set_primary(&mut self, qh: &QueueHandle<App>) {
|
fn claim_primary(&mut self, text: String, qh: &QueueHandle<App>) {
|
||||||
let Some(text) = self.selection_text() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let (Some(manager), Some(device)) =
|
let (Some(manager), Some(device)) =
|
||||||
(self.primary_manager.as_ref(), self.primary_device.as_ref())
|
(self.primary_manager.as_ref(), self.primary_device.as_ref())
|
||||||
else {
|
else {
|
||||||
|
|
@ -721,6 +715,52 @@ impl App {
|
||||||
self.primary_source = Some(source);
|
self.primary_source = Some(source);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Claim the clipboard (CLIPBOARD) with the current selection (Ctrl+Shift+C).
|
||||||
|
fn set_clipboard(&mut self, qh: &QueueHandle<App>) {
|
||||||
|
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<App>) {
|
||||||
|
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<crate::vt::ClipboardOp>) {
|
||||||
|
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).
|
/// Paste the CLIPBOARD selection into the shell (Ctrl+Shift+V).
|
||||||
fn paste_clipboard(&mut self) {
|
fn paste_clipboard(&mut self) {
|
||||||
let Some(offer) = self
|
let Some(offer) = self
|
||||||
|
|
@ -836,6 +876,10 @@ impl App {
|
||||||
self.window
|
self.window
|
||||||
.set_title(self.title.clone().unwrap_or_default());
|
.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;
|
self.needs_draw = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue