Skip to main content

ftui_core/
osc52.rs

1//! OSC 52 clipboard encoding shared by terminal output and clipboard backends.
2
3use base64::{Engine as _, engine::general_purpose::STANDARD};
4
5/// Common maximum base64 payload length (excluding the OSC envelope).
6pub const MAX_OSC52_PAYLOAD: usize = 74_994;
7
8/// Destination selection for an OSC 52 request.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum ClipboardSelection {
11    /// System clipboard.
12    Clipboard,
13    /// X11 primary selection.
14    Primary,
15    /// X11 secondary selection.
16    Secondary,
17    /// Cut buffer 0 through 7.
18    CutBuffer(u8),
19}
20
21impl ClipboardSelection {
22    /// Validate the selection and return its wire selector.
23    pub fn osc52_code(self) -> Result<char, Osc52Error> {
24        match self {
25            Self::Clipboard => Ok('c'),
26            Self::Primary => Ok('p'),
27            Self::Secondary => Ok('s'),
28            Self::CutBuffer(index @ 0..=7) => Ok(char::from(b'0' + index)),
29            Self::CutBuffer(index) => Err(Osc52Error::InvalidSelection(index)),
30        }
31    }
32}
33
34/// Invalid OSC 52 request; no bytes have been written.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum Osc52Error {
37    /// The encoded payload would exceed the configured limit.
38    PayloadTooLarge {
39        /// Encoded size, saturated if the calculation overflows.
40        len: usize,
41        /// Configured encoded size limit.
42        max: usize,
43    },
44    /// Cut buffer index is outside 0 through 7.
45    InvalidSelection(u8),
46}
47
48impl std::fmt::Display for Osc52Error {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        match self {
51            Self::PayloadTooLarge { len, max } => {
52                write!(f, "OSC 52 payload too large ({len} > {max})")
53            }
54            Self::InvalidSelection(index) => {
55                write!(f, "cut buffer index must be 0..=7 (got {index})")
56            }
57        }
58    }
59}
60
61impl std::error::Error for Osc52Error {}
62
63/// Encode a clipboard write, rejecting oversized payloads before allocating.
64pub fn encode_set(selection: ClipboardSelection, bytes: &[u8]) -> Result<Vec<u8>, Osc52Error> {
65    encode_set_with_limit(selection, bytes, MAX_OSC52_PAYLOAD)
66}
67
68/// Encode a write with an explicit maximum base64 length.
69///
70/// Used by clipboard backends that allow a terminal-specific payload limit.
71pub fn encode_set_with_limit(
72    selection: ClipboardSelection,
73    bytes: &[u8],
74    max: usize,
75) -> Result<Vec<u8>, Osc52Error> {
76    let code = selection.osc52_code()?;
77    let len = bytes.len().div_ceil(3).saturating_mul(4);
78    if len > max {
79        return Err(Osc52Error::PayloadTooLarge { len, max });
80    }
81    let encoded = STANDARD.encode(bytes);
82    Ok(format!("\x1b]52;{code};{encoded}\x07").into_bytes())
83}
84
85/// Encode a read request. Replies arrive asynchronously as clipboard events.
86pub fn encode_query(selection: ClipboardSelection) -> Result<Vec<u8>, Osc52Error> {
87    let code = selection.osc52_code()?;
88    Ok(format!("\x1b]52;{code};?\x07").into_bytes())
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    #[test]
96    fn osc52_wire_format_and_selections() {
97        assert_eq!(
98            encode_set(ClipboardSelection::Clipboard, b"hello").unwrap(),
99            b"\x1b]52;c;aGVsbG8=\x07"
100        );
101        for (selection, code) in [
102            (ClipboardSelection::Clipboard, 'c'),
103            (ClipboardSelection::Primary, 'p'),
104            (ClipboardSelection::Secondary, 's'),
105            (ClipboardSelection::CutBuffer(0), '0'),
106            (ClipboardSelection::CutBuffer(7), '7'),
107        ] {
108            assert_eq!(
109                encode_query(selection).unwrap(),
110                format!("\x1b]52;{code};?\x07").as_bytes()
111            );
112            assert_eq!(
113                encode_set(selection, b"").unwrap(),
114                format!("\x1b]52;{code};\x07").as_bytes()
115            );
116        }
117        for index in [8, 255] {
118            assert_eq!(
119                encode_query(ClipboardSelection::CutBuffer(index)),
120                Err(Osc52Error::InvalidSelection(index))
121            );
122            assert_eq!(
123                encode_set(ClipboardSelection::CutBuffer(index), b"x"),
124                Err(Osc52Error::InvalidSelection(index))
125            );
126        }
127    }
128
129    #[test]
130    fn osc52_payload_cap_and_mux_wire_format() {
131        let selection = ClipboardSelection::Clipboard;
132        assert!(encode_set_with_limit(selection, b"abc", 4).is_ok());
133        assert_eq!(
134            encode_set_with_limit(selection, b"abcd", 4),
135            Err(Osc52Error::PayloadTooLarge { len: 8, max: 4 })
136        );
137        assert!(encode_set(selection, &vec![0; 56_244]).is_ok());
138        assert!(matches!(
139            encode_set(selection, &vec![0; 56_245]),
140            Err(Osc52Error::PayloadTooLarge { .. })
141        ));
142        let seq = encode_set(selection, b"hello").unwrap();
143        let mut tmux = Vec::new();
144        crate::mux_passthrough::tmux_wrap(&mut tmux, &seq).unwrap();
145        assert_eq!(tmux, b"\x1bPtmux;\x1b\x1b]52;c;aGVsbG8=\x07\x1b\\");
146        let mut screen = Vec::new();
147        crate::mux_passthrough::screen_wrap(&mut screen, &seq).unwrap();
148        assert_eq!(screen, b"\x1bP\x1b]52;c;aGVsbG8=\x07\x1b\\");
149    }
150}