diff --git a/CHANGELOG.md b/CHANGELOG.md index f40a15d..7f9a58c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - +## [Unreleased] + +### Added + +* Parse a terminal's answer to `Osc::QuerySelection` (`OSC 52 ; Pc ; Pd ST`) into `Event::Osc(Osc::SetSelection(..))`. + +### Changed + +* `Osc::SetSelection` carries its payload as a `Cow`. ## [v0.4.0] - 2026-08-31 diff --git a/src/base64.rs b/src/base64.rs index 51120c9..183f8af 100644 --- a/src/base64.rs +++ b/src/base64.rs @@ -91,6 +91,41 @@ pub fn encode(input: &[u8]) -> String { String::from_utf8(output).expect("Invalid UTF8") } +/// Decodes standard base64, with or without `=` padding. +/// +/// Returns `None` for a byte outside the alphabet or a final group too short to hold a byte. +pub fn decode(input: &[u8]) -> Option> { + let input = input + .strip_suffix(b"==") + .or_else(|| input.strip_suffix(b"=")) + .unwrap_or(input); + let mut output = Vec::with_capacity(input.len() / 4 * 3 + 2); + let mut accumulator: u32 = 0; + let mut bits = 0u8; + + for &byte in input { + let value = match byte { + b'A'..=b'Z' => byte - b'A', + b'a'..=b'z' => byte - b'a' + 26, + b'0'..=b'9' => byte - b'0' + 52, + b'+' => 62, + b'/' => 63, + _ => return None, + }; + accumulator = (accumulator << 6) | u32::from(value); + bits += 6; + if bits >= 8 { + bits -= 8; + output.push((accumulator >> bits) as u8); + accumulator &= (1 << bits) - 1; + } + } + if bits >= 6 { + return None; + } + Some(output) +} + #[cfg(test)] mod tests { fn compare_encode(expected: &str, target: &[u8]) { @@ -165,4 +200,97 @@ mod tests { &bytes, ); } + + fn compare_decode(expected: &[u8], target: &str) { + assert_eq!(Some(expected.to_vec()), super::decode(target.as_bytes())); + } + + #[test] + fn decode_rfc4648_0() { + compare_decode(b"", ""); + } + + #[test] + fn decode_rfc4648_1() { + compare_decode(b"f", "Zg=="); + } + + #[test] + fn decode_rfc4648_2() { + compare_decode(b"fo", "Zm8="); + } + + #[test] + fn decode_rfc4648_3() { + compare_decode(b"foo", "Zm9v"); + } + + #[test] + fn decode_rfc4648_4() { + compare_decode(b"foob", "Zm9vYg=="); + } + + #[test] + fn decode_rfc4648_5() { + compare_decode(b"fooba", "Zm9vYmE="); + } + + #[test] + fn decode_rfc4648_6() { + compare_decode(b"foobar", "Zm9vYmFy"); + } + + #[test] + fn decode_rfc4648_1_unpadded() { + compare_decode(b"f", "Zg"); + } + + #[test] + fn decode_rfc4648_2_unpadded() { + compare_decode(b"fo", "Zm8"); + } + + #[test] + fn decode_invalid_character() { + assert_eq!(None, super::decode(b"Zm?v")); + } + + #[test] + fn decode_lone_sextet() { + assert_eq!(None, super::decode(b"Zm9vY")); + } + + #[test] + fn decode_all_ascii() { + let mut ascii = Vec::::with_capacity(128); + + for i in 0..128 { + ascii.push(i); + } + + compare_decode( + &ascii, + "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7P\ + D0+P0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn8\ + =", + ); + } + + #[test] + fn decode_all_bytes() { + let mut bytes = Vec::::with_capacity(256); + + for i in 0..255 { + bytes.push(i); + } + bytes.push(255); + + compare_decode( + &bytes, + "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7P\ + D0+P0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn\ + +AgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq+wsbKztLW2t7i5uru8vb6\ + /wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5+jp6uvs7e7v8PHy8/T19vf4+fr7/P3+/w==", + ); + } } diff --git a/src/escape/osc.rs b/src/escape/osc.rs index 1bf4f03..1267783 100644 --- a/src/escape/osc.rs +++ b/src/escape/osc.rs @@ -17,7 +17,7 @@ //! ``` //! use termina::escape::osc::{Osc, Selection}; //! -//! let command = Osc::SetSelection(Selection::CLIPBOARD, "copied text"); +//! let command = Osc::SetSelection(Selection::CLIPBOARD, "copied text".into()); //! assert_eq!(command.to_string(), "\x1b]52;c;Y29waWVkIHRleHQ=\x1b\\"); //! ``` //! @@ -29,7 +29,11 @@ //! //! [termwiz's OSC support]: https://docs.rs/termwiz/latest/termwiz/escape/struct.Osc.html -use std::fmt::{self, Display}; +use std::{ + borrow::Cow, + fmt::{self, Display}, + str::FromStr, +}; use crate::{base64, style::RgbColor}; @@ -64,13 +68,14 @@ pub enum Osc<'a> { /// OSC 52: query one or more terminal selections described by [`Selection`]. /// /// Querying sends `?` as the content payload and lets a cooperating terminal report the - /// selection contents. + /// selection contents as a [`Self::SetSelection`]. QuerySelection(Selection), /// OSC 52: set one or more terminal selections described by [`Selection`]. /// - /// The string payload is base64-encoded when formatted, as required by OSC 52. - SetSelection(Selection, &'a str), + /// The string payload is base64-encoded when formatted, as required by OSC 52. A terminal + /// answers [`Self::QuerySelection`] in this same form. + SetSelection(Selection, Cow<'a, str>), /// OSC 10-19: change or query dynamic terminal colors. /// @@ -210,6 +215,40 @@ impl Display for Selection { } } +/// Error returned when parsing a selection string fails. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct InvalidFormatError; + +/// Parses selection targets as they appear in an OSC 52 payload, such as `c` or `cp`. +/// +/// An empty string parses as [`Selection::NONE`]; any other character is an error. +impl FromStr for Selection { + type Err = InvalidFormatError; + + fn from_str(s: &str) -> Result { + let mut selection = Self::NONE; + for c in s.chars() { + selection |= match c { + 'c' => Self::CLIPBOARD, + 'p' => Self::PRIMARY, + 's' => Self::SELECT, + '0' => Self::CUT0, + '1' => Self::CUT1, + '2' => Self::CUT2, + '3' => Self::CUT3, + '4' => Self::CUT4, + '5' => Self::CUT5, + '6' => Self::CUT6, + '7' => Self::CUT7, + '8' => Self::CUT8, + '9' => Self::CUT9, + _ => return Err(InvalidFormatError), + }; + } + Ok(selection) + } +} + /// Dynamic color slots addressed by OSC 10-19. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[repr(u8)] @@ -318,27 +357,49 @@ impl From for ColorOrQuery { mod test { use super::*; + fn compare_encode(input: Osc, expected: &str) { + assert_eq!(input.to_string(), expected); + } + + /// OSC 11 query, asks the terminal for the background color. + /// + /// See and + /// . #[test] - fn encoding() { - // OSC 11 query, asks the terminal for the background color. - // - // - assert_eq!( - "\x1b]11;?\x1b\\", + fn dynamic_color_encoding() { + compare_encode( Osc::ChangeDynamicColors( DynamicColorNumber::TextBackgroundColor, - vec![ColorOrQuery::Query] - ) - .to_string() + vec![ColorOrQuery::Query], + ), + "\x1b]11;?\x1b\\", ); - assert_eq!( - "\x1b]11;rgb:2828/2828/2828\x1b\\", + compare_encode( Osc::ChangeDynamicColors( DynamicColorNumber::TextBackgroundColor, - vec![RgbColor::new(40, 40, 40).into()] - ) - .to_string() + vec![RgbColor::new(40, 40, 40).into()], + ), + "\x1b]11;rgb:2828/2828/2828\x1b\\", + ); + } + + fn compare_decode(input: &str, expected: Result) { + assert_eq!(input.parse(), expected); + } + + /// OSC 52 response, reports the terminal's selection. + /// + /// See . + #[test] + fn selection_decoding() { + compare_decode("c", Ok(Selection::CLIPBOARD)); + compare_decode( + "cp0", + Ok(Selection::CLIPBOARD | Selection::PRIMARY | Selection::CUT0), ); + compare_decode("", Ok(Selection::NONE)); + compare_decode("q", Err(InvalidFormatError)); + compare_decode(&Selection::all().to_string(), Ok(Selection::all())); } } diff --git a/src/parse.rs b/src/parse.rs index 46edc90..95dd67b 100644 --- a/src/parse.rs +++ b/src/parse.rs @@ -33,6 +33,7 @@ use std::{collections::VecDeque, num::NonZeroU16, str}; use crate::EventReader; use crate::{ + base64, escape::{ self, csi::{self, Csi, KittyKeyboardFlags, ThemeMode}, @@ -351,9 +352,36 @@ fn parse_osc(buffer: &[u8]) -> Result> { let s = str::from_utf8(&buffer[2..buffer.len()])?; let mut split = s.split(';'); let index = next_parsed::(&mut split)?; - let Some(color_number) = osc::DynamicColorNumber::from_index(index) else { + if let Some(color_number) = osc::DynamicColorNumber::from_index(index) { + return parse_osc_dynamic_color(color_number, &mut split); + } + match index { + 52 => parse_osc_selection(&mut split), + _ => bail!(), + } +} + +fn parse_osc_selection(split: &mut dyn Iterator) -> Result> { + let selection: osc::Selection = split + .next() + .ok_or(MalformedSequenceError)? + .parse() + .map_err(|_| MalformedSequenceError)?; + let Some(payload) = split.next() else { bail!() }; + let Some(bytes) = base64::decode(payload.as_bytes()) else { bail!() }; + let content = String::from_utf8(bytes).map_err(|_| MalformedSequenceError)?; + Ok(Some(Event::Osc(osc::Osc::SetSelection( + selection, + content.into(), + )))) +} + +fn parse_osc_dynamic_color( + color_number: osc::DynamicColorNumber, + split: &mut dyn Iterator, +) -> Result> { let Some(color_or_query) = split.next() else { bail!() }; @@ -1265,6 +1293,35 @@ mod test { ); } + #[test] + fn parse_osc_selection_report() { + assert_eq!( + parse_event(b"\x1b]52;c;aGVsbG8=\x1b\\", false) + .unwrap() + .unwrap(), + Event::Osc(osc::Osc::SetSelection( + osc::Selection::CLIPBOARD, + "hello".into() + )) + ); + // BEL ending instead of ST, unpadded base64, several selections. + assert_eq!( + parse_event(b"\x1b]52;cp;aGk\x07", false).unwrap().unwrap(), + Event::Osc(osc::Osc::SetSelection( + osc::Selection::CLIPBOARD | osc::Selection::PRIMARY, + "hi".into() + )) + ); + // A query is not a report, and neither is a clear. + assert!(parse_event(b"\x1b]52;c;?\x07", false).is_err()); + assert!(parse_event(b"\x1b]52;c\x07", false).is_err()); + // Incomplete until the terminator arrives. + assert_eq!(parse_event(b"\x1b]52;c;aGVsbG8=", true).unwrap(), None); + // An OSC number the parser does not know is rejected. + assert!(parse_event(b"\x1b]777;notify;hi\x07", false).is_err()); + assert!(parse_event(b"\x1b]9;hi\x07", false).is_err()); + } + #[test] fn parse_osc_dynamic_color_response() { assert_eq!(