brewery
notashelf /
28a49c5bbedf7d9785c49eb667eba539f9b56212

beer

public

A terminal worth pouring time into

Star0tarball
clone
ssh://git@git.notashelf.dev:33/notashelf/beer.git

Commit 28a49c5bbedf

tarball

NotAShelf <raf@notashelf.dev> · 2026-06-26 07:21 UTC

unverified2 files changed+196-10
osc: clipboard get/set via OSC 52

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I39637cb00313f1f9f83a4ac2977794246a6a6964
diff --git a/src/vt.rs b/src/vt.rsindex 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<Vec<u8>>,+    /// Pending OSC 52 clipboard requests, drained by the front-end.+    clipboard_ops: Vec<ClipboardOp>, }  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<ClipboardOp> {+        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<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. fn decode_hex(s: &[u8]) -> Option<Vec<u8>> {     if s.is_empty() || !s.len().is_multiple_of(2) {@@ -758,6 +857,49 @@ mod tests {     }      #[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);         feed(&mut t, b"\x1b[?1002h\x1b[?1006h");diff --git a/src/wayland.rs b/src/wayland.rsindex 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<App>) {-        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<App>) {         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<App>) {-        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<App>) {         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<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).     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;     }