Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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] -->
## [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<str>`.

## [v0.4.0] - 2026-08-31

Expand Down
128 changes: 128 additions & 0 deletions src/base64.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<u8>> {
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]) {
Expand Down Expand Up @@ -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::<u8>::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::<u8>::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==",
);
}
}
99 changes: 80 additions & 19 deletions src/escape/osc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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\\");
//! ```
//!
Expand All @@ -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};

Expand Down Expand Up @@ -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.
///
Expand Down Expand Up @@ -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<Self, Self::Err> {
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)]
Expand Down Expand Up @@ -318,27 +357,49 @@ impl From<RgbColor> 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 <https://terminalguide.namepad.de/seq/osc-11/> and
/// <https://terminalguide.namepad.de/seq/osc-4/>.
#[test]
fn encoding() {
// OSC 11 query, asks the terminal for the background color.
// <https://terminalguide.namepad.de/seq/osc-11/>
// <https://terminalguide.namepad.de/seq/osc-4/>
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<Selection, InvalidFormatError>) {
assert_eq!(input.parse(), expected);
}

/// OSC 52 response, reports the terminal's selection.
///
/// See <https://terminalguide.namepad.de/seq/osc-52/>.
#[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()));
}
}
59 changes: 58 additions & 1 deletion src/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ use std::{collections::VecDeque, num::NonZeroU16, str};
use crate::EventReader;

use crate::{
base64,
escape::{
self,
csi::{self, Csi, KittyKeyboardFlags, ThemeMode},
Expand Down Expand Up @@ -351,9 +352,36 @@ fn parse_osc(buffer: &[u8]) -> Result<Option<Event>> {
let s = str::from_utf8(&buffer[2..buffer.len()])?;
let mut split = s.split(';');
let index = next_parsed::<u8>(&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<Item = &str>) -> Result<Option<Event>> {
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<Item = &str>,
) -> Result<Option<Event>> {
let Some(color_or_query) = split.next() else {
bail!()
};
Expand Down Expand Up @@ -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!(
Expand Down