More robust querying for terminal theme and version

This commit is contained in:
Marcin Kulik
2026-02-04 10:10:32 +01:00
parent 9f3ec23c3d
commit 546cd9ab09
3 changed files with 636 additions and 299 deletions

View File

@@ -442,14 +442,23 @@ async fn probe_tty(
let term_info = match kind { let term_info = match kind {
TtyKind::DevTty => { TtyKind::DevTty => {
let term = env::var("TERM").ok(); let term = env::var("TERM").ok();
let mut inspect = true;
let (version, theme) = if let Some("dumb" | "linux") = term.as_deref() { if let Some("dumb" | "linux") = term.as_deref() {
(None, None) // these don't support OSC / XTVERSION
inspect = false;
}
if env::var("STY").is_ok() {
// screen doesn't support OSC 4 / XTVERSION either, and doesn't preserve
// query/reply order
inspect = false;
}
let (version, theme) = if inspect {
tty::inspect(tty.as_ref()).await
} else { } else {
( (None, None)
tty::query_version(tty.as_ref()).await,
tty::query_theme(tty.as_ref()).await,
)
}; };
TermInfo { TermInfo {

View File

@@ -1,3 +1,5 @@
mod inspect;
use std::os::fd::AsFd; use std::os::fd::AsFd;
use async_trait::async_trait; use async_trait::async_trait;
@@ -6,11 +8,6 @@ use nix::pty::Winsize;
use nix::sys::termios::{self, SetArg}; use nix::sys::termios::{self, SetArg};
use rgb::RGB8; use rgb::RGB8;
use tokio::io; use tokio::io;
use tokio::time::{self, Duration};
const QUERY_READ_TIMEOUT: u64 = 1000;
const THEME_QUERY: &str = "\x1b]10;?\x07\x1b]11;?\x07\x1b]4;0;?\x07\x1b]4;1;?\x07\x1b]4;2;?\x07\x1b]4;3;?\x07\x1b]4;4;?\x07\x1b]4;5;?\x07\x1b]4;6;?\x07\x1b]4;7;?\x07\x1b]4;8;?\x07\x1b]4;9;?\x07\x1b]4;10;?\x07\x1b]4;11;?\x07\x1b]4;12;?\x07\x1b]4;13;?\x07\x1b]4;14;?\x07\x1b]4;15;?\x07";
const XTVERSION_QUERY: &str = "\x1b[>0q";
#[cfg(all(not(target_os = "macos"), not(feature = "macos-tty")))] #[cfg(all(not(target_os = "macos"), not(feature = "macos-tty")))]
mod default; mod default;
@@ -153,227 +150,12 @@ fn make_raw<F: AsFd>(fd: F) -> anyhow::Result<libc::termios> {
Ok(termios.into()) Ok(termios.into())
} }
pub(crate) async fn query_theme<T: RawTty + ?Sized>(tty: &T) -> Option<TtyTheme> { pub(crate) use inspect::inspect;
parse_theme_response(&query(tty, THEME_QUERY).await.ok()?)
}
pub(crate) async fn query_version<T: RawTty + ?Sized>(tty: &T) -> Option<String> {
parse_version_response(&query(tty, XTVERSION_QUERY).await.ok()?)
}
async fn query<T: RawTty + ?Sized>(tty: &T, query: &str) -> anyhow::Result<Vec<u8>> {
let mut query = query.to_string().into_bytes();
query.extend_from_slice(b"\x1b[c");
let mut query = &query[..];
let mut response = Vec::new();
let mut buf = [0u8; 1024];
loop {
tokio::select! {
result = tty.read(&mut buf) => {
let n = result?;
response.extend_from_slice(&buf[..n]);
if let Some(len) = complete_da_response_len(&response) {
response.truncate(len);
break;
}
}
result = tty.write(query), if !query.is_empty() => {
let n = result?;
query = &query[n..];
}
_ = time::sleep(Duration::from_millis(QUERY_READ_TIMEOUT)) => {
break;
}
}
}
Ok(response)
}
fn complete_da_response_len(response: &[u8]) -> Option<usize> {
let mut reversed = response.iter().rev();
let mut includes_da_response = false;
let mut da_response_len = 0;
if let Some(b'c') = reversed.next() {
da_response_len += 1;
for b in reversed {
if *b == b'[' {
includes_da_response = true;
break;
}
if *b != b';' && *b != b'?' && !b.is_ascii_digit() {
break;
}
da_response_len += 1;
}
}
if includes_da_response {
Some(response.len() - da_response_len - 2)
} else {
None
}
}
fn parse_theme_response(response: &[u8]) -> Option<TtyTheme> {
let mut fg = None;
let mut bg = None;
let mut palette: [Option<RGB8>; 16] = [None; 16];
let response = String::from_utf8_lossy(response);
let mut rest = &response[..];
loop {
let Some(seq_start) = rest.find("\x1b]") else {
break;
};
let rest_after_start = &rest[seq_start + 2..];
let bel_end = rest_after_start.find("\x07");
let st_end = rest_after_start.find("\x1b\\");
let Some(seq_end) = (match (bel_end, st_end) {
(Some(bel), Some(st)) => Some(bel.min(st)),
(Some(bel), None) => Some(bel),
(None, Some(st)) => Some(st),
(None, None) => None,
}) else {
break;
};
let reply = &rest_after_start[..seq_end];
if rest_after_start[seq_end..].starts_with("\x07") {
rest = &rest_after_start[seq_end + 1..];
} else {
rest = &rest_after_start[seq_end + 2..];
};
let mut params = reply.split(';');
let Some(p1) = params.next() else {
continue;
};
let Some(p2) = params.next() else {
continue;
};
match p1 {
"10" => {
if let Some(c) = p2.strip_prefix("rgb:") {
fg = parse_color(c);
}
}
"11" => {
if let Some(c) = p2.strip_prefix("rgb:") {
bg = parse_color(c);
}
}
"4" => {
let mut next = Some(p2);
while let Some(i_str) = next {
let Ok(i) = i_str.parse::<u8>() else {
break;
};
let Some(p3) = params.next() else {
break;
};
if i < 16 {
if let Some(c) = p3.strip_prefix("rgb:") {
palette[i as usize] = parse_color(c);
}
}
next = params.next();
}
}
_ => {
continue;
}
}
}
let fg = fg?;
let bg = bg?;
let palette = palette.into_iter().flatten().collect::<Vec<_>>();
if palette.len() < 16 {
return None;
}
Some(TtyTheme { fg, bg, palette })
}
fn parse_color(rgb: &str) -> Option<RGB8> {
let mut components = rgb.split('/');
let r_hex = components.next()?;
let g_hex = components.next()?;
let b_hex = components.next()?;
if r_hex.len() < 2 || g_hex.len() < 2 || b_hex.len() < 2 {
return None;
}
let r = u8::from_str_radix(&r_hex[..2], 16).ok()?;
let g = u8::from_str_radix(&g_hex[..2], 16).ok()?;
let b = u8::from_str_radix(&b_hex[..2], 16).ok()?;
Some(RGB8::new(r, g, b))
}
fn parse_version_response(response: &[u8]) -> Option<String> {
if let [b'\x1b', b'P', b'>', b'|', version @ .., b'\x1b', b'\\'] = response {
Some(String::from_utf8_lossy(version).to_string())
} else {
None
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{FixedSizeTty, NullTty, RawTty}; use super::{FixedSizeTty, NullTty, RawTty};
use rgb::RGB8;
#[test]
fn parse_color() {
use super::parse_color as parse;
let color = Some(RGB8::new(0xaa, 0xbb, 0xcc));
assert_eq!(parse("aa11/bb22/cc33"), color);
assert_eq!(parse("aa11/bb22/cc33\x07"), color);
assert_eq!(parse("aa11/bb22/cc33\x1b\\"), color);
assert_eq!(parse("aa11/bb22/cc33.."), color);
assert_eq!(parse("aa1/bb2/cc3"), color);
assert_eq!(parse("aa1/bb2/cc3\x07"), color);
assert_eq!(parse("aa1/bb2/cc3\x1b\\"), color);
assert_eq!(parse("aa1/bb2/cc3.."), color);
assert_eq!(parse("aa/bb/cc"), color);
assert_eq!(parse("aa/bb/cc\x07"), color);
assert_eq!(parse("aa/bb/cc\x1b\\"), color);
assert_eq!(parse("aa/bb/cc.."), color);
assert_eq!(parse("aa11/bb22"), None);
assert_eq!(parse("xxxx/yyyy/zzzz"), None);
assert_eq!(parse("xxx/yyy/zzz"), None);
assert_eq!(parse("xx/yy/zz"), None);
assert_eq!(parse("foo"), None);
assert_eq!(parse(""), None);
}
#[test] #[test]
fn fixed_size_tty_get_size() { fn fixed_size_tty_get_size() {
let tty = FixedSizeTty::new(NullTty, Some(100), Some(50)); let tty = FixedSizeTty::new(NullTty, Some(100), Some(50));
@@ -391,76 +173,4 @@ mod tests {
assert!(winsize.ws_col == 80); assert!(winsize.ws_col == 80);
assert!(winsize.ws_row == 24); assert!(winsize.ws_row == 24);
} }
#[test]
fn parse_theme_response_ok() {
let response = concat!(
"\x1b]4;1;rgb:3333/4444/5555\x07",
"\x1b]11;rgb:7788/99aa/bbcc\x07",
"\x1b]4;3;rgb:9999/aaaa/bbbb\x07",
"\x1b]4;2;rgb:6666/7777/8888\x1b\\",
"\x1b]4;4;rgb:cccc/dddd/eeee\x07",
"\x1b]4;0;rgb:0000/1111/2222\x07",
"\x1b]4;6;rgb:2222/3333/4444\x07",
"\x1b]4;5;rgb:ffff/0000/1111\x07",
"\x1b]4;7;rgb:5555/6666/7777\x07",
"\x1b]4;10;rgb:eeee/ffff/0000\x07",
"\x1b]4;8;rgb:8888/9999/aaaa\x07",
"\x1b]4;9;rgb:bbbb/cccc/dddd\x07",
"\x1b]4;11;rgb:1111/2222/3333\x07",
"\x1b]4;14;rgb:aaaa/bbbb/cccc\x07",
"\x1b]4;13;rgb:7777/8888/9999\x07",
"\x1b]10;rgb:1122/3344/5566\x1b\\",
"\x1b]4;15;rgb:dddd/eeee/ffff\x07",
"\x1b]4;12;rgb:4444/5555/6666\x07",
)
.as_bytes();
let theme = super::parse_theme_response(response).expect("theme");
assert_eq!(theme.fg, RGB8::new(0x11, 0x33, 0x55));
assert_eq!(theme.bg, RGB8::new(0x77, 0x99, 0xbb));
assert_eq!(theme.palette.len(), 16);
assert_eq!(theme.palette[0], RGB8::new(0x00, 0x11, 0x22));
assert_eq!(theme.palette[15], RGB8::new(0xdd, 0xee, 0xff));
}
#[test]
fn parse_theme_response_osc4_packed() {
let response = concat!(
"\x1b]10;rgb:1122/3344/5566\x07",
"\x1b]11;rgb:7788/99aa/bbcc\x07",
"\x1b]4;0;rgb:0000/1111/2222;1;rgb:3333/4444/5555;2;rgb:6666/7777/8888\x07",
"\x1b]4;3;rgb:9999/aaaa/bbbb;4;rgb:cccc/dddd/eeee;5;rgb:ffff/0000/1111\x07",
"\x1b]4;6;rgb:2222/3333/4444;7;rgb:5555/6666/7777;8;rgb:8888/9999/aaaa\x07",
"\x1b]4;9;rgb:bbbb/cccc/dddd;10;rgb:eeee/ffff/0000;11;rgb:1111/2222/3333\x07",
"\x1b]4;12;rgb:4444/5555/6666;13;rgb:7777/8888/9999;14;rgb:aaaa/bbbb/cccc;15;rgb:dddd/eeee/ffff\x07",
)
.as_bytes();
let theme = super::parse_theme_response(response).expect("theme");
assert_eq!(theme.fg, RGB8::new(0x11, 0x33, 0x55));
assert_eq!(theme.bg, RGB8::new(0x77, 0x99, 0xbb));
assert_eq!(theme.palette.len(), 16);
assert_eq!(theme.palette[0], RGB8::new(0x00, 0x11, 0x22));
assert_eq!(theme.palette[15], RGB8::new(0xdd, 0xee, 0xff));
}
#[test]
fn parse_theme_response_missing_colors() {
let response = b"\x1b]10;rgb:1122/3344/5566\x07";
assert!(super::parse_theme_response(response).is_none());
}
#[test]
fn parse_version_response_ok() {
let response = b"\x1bP>|xterm-395\x1b\\";
let version = super::parse_version_response(response).expect("version");
assert_eq!(version, "xterm-395");
}
#[test]
fn parse_version_response_invalid() {
let response = b"\x1bP>|xterm-395\x07";
assert!(super::parse_version_response(response).is_none());
}
} }

618
src/tty/inspect.rs Normal file
View File

@@ -0,0 +1,618 @@
use rgb::RGB8;
use tokio::time::{self, Duration};
use super::{RawTty, TtyTheme};
const INSPECT_QUERY: &str = concat!(
"\x1b[>0q", // XTVERSION
"\x1b]10;?\x07\x1b]11;?\x07", // fg, bg
"\x1b]4;0;?\x07\x1b]4;1;?\x07\x1b]4;2;?\x07\x1b]4;3;?\x07", // palette 0-3
"\x1b]4;4;?\x07\x1b]4;5;?\x07\x1b]4;6;?\x07\x1b]4;7;?\x07", // palette 4-7
"\x1b]4;8;?\x07\x1b]4;9;?\x07\x1b]4;10;?\x07\x1b]4;11;?\x07", // palette 8-11
"\x1b]4;12;?\x07\x1b]4;13;?\x07\x1b]4;14;?\x07\x1b]4;15;?\x07", // palette 12-15
"\x1b[c", // DA (flush)
);
pub(crate) async fn inspect<T: RawTty + ?Sized>(tty: &T) -> (Option<String>, Option<TtyTheme>) {
query(tty, INSPECT_QUERY, ReplyParser::new())
.await
.unwrap_or_default()
}
enum ParseResult {
Pending,
Done,
}
async fn query<T: RawTty + ?Sized>(
tty: &T,
query: &str,
mut parser: ReplyParser,
) -> anyhow::Result<(Option<String>, Option<TtyTheme>)> {
let mut query = query.as_bytes();
let mut buf = [0u8; 1024];
loop {
tokio::select! {
result = tty.read(&mut buf) => {
let n = result?;
if let ParseResult::Done = parser.feed(&buf[..n]) {
break;
}
}
result = tty.write(query), if !query.is_empty() => {
let n = result?;
query = &query[n..];
}
_ = time::sleep(Duration::from_millis(1000)) => {
break;
}
}
}
Ok(parser.result())
}
struct ReplyParser {
buf: Vec<u8>,
fg: Option<RGB8>,
bg: Option<RGB8>,
palette: [Option<RGB8>; 16],
version: Option<String>,
}
impl ReplyParser {
fn new() -> Self {
ReplyParser {
buf: Vec::new(),
fg: None,
bg: None,
palette: [None; 16],
version: None,
}
}
}
impl ReplyParser {
fn feed(&mut self, chunk: &[u8]) -> ParseResult {
self.buf.extend_from_slice(chunk);
let mut i = 0;
while i < self.buf.len() {
let buf = &self.buf[i..];
if let Some(m) = match_seq_prefix(buf, b"\x1b]10;") {
// OSC 10 (fg color) reply
let PrefixMatch::Full(rest) = m else {
break;
};
let Some((end, terminator_len)) = find_osc_end(rest) else {
break;
};
self.fg = parse_rgb_color(&rest[..end]);
i += 5 + end + terminator_len;
} else if let Some(m) = match_seq_prefix(buf, b"\x1b]11;") {
// OSC 11 (bg color) reply
let PrefixMatch::Full(rest) = m else {
break;
};
let Some((end, terminator_len)) = find_osc_end(rest) else {
break;
};
self.bg = parse_rgb_color(&rest[..end]);
i += 5 + end + terminator_len;
} else if let Some(m) = match_seq_prefix(buf, b"\x1b]4;") {
// OSC 4 (palette entry) reply
let PrefixMatch::Full(rest) = m else {
break;
};
let Some((end, terminator_len)) = find_osc_end(rest) else {
break;
};
for (idx, color) in parse_palette_entries(&rest[..end]) {
self.palette[idx] = Some(color);
}
i += 4 + end + terminator_len;
} else if let Some(m) = match_seq_prefix(buf, b"\x1bP") {
// DCS reply
let PrefixMatch::Full(rest) = m else {
break;
};
let Some((end, terminator_len)) = find_dcs_end(rest) else {
break;
};
// looking for XTVERSION function selector
if let Some(version) = parse_dcs_reply(&rest[..end], b">|") {
self.version = Some(version);
}
i += 2 + end + terminator_len;
} else if let Some(m) = match_seq_prefix(buf, b"\x1b[?") {
// DEC private reply
let PrefixMatch::Full(rest) = m else {
break;
};
let Some((end, terminator)) = find_dec_prv_final(rest) else {
break;
};
// check for DA
if terminator == 'c' {
// We assume here that the reply order matches the query order. There's no spec
// guaranteeing orderly replies, but in practice the replies for the queries we
// use here are ordered on all tested terminals. Therefore, if we get a reply
// for DA (which is widely supported) it means all the replies for supported
// queries already came.
return ParseResult::Done;
}
i += 3 + end + 1;
} else {
i += 1;
}
}
self.buf.drain(..i);
ParseResult::Pending
}
fn result(self) -> (Option<String>, Option<TtyTheme>) {
let theme = self.build_theme();
(self.version, theme)
}
fn build_theme(&self) -> Option<TtyTheme> {
let fg = self.fg?;
let bg = self.bg?;
let palette = self.palette.iter().flatten().cloned().collect::<Vec<_>>();
if palette.len() < 16 {
return None;
}
Some(TtyTheme { fg, bg, palette })
}
}
enum PrefixMatch<'a> {
Full(&'a [u8]),
Partial,
}
fn match_seq_prefix<'a>(a: &'a [u8], b: &[u8]) -> Option<PrefixMatch<'a>> {
if let Some(rest) = a.strip_prefix(b) {
Some(PrefixMatch::Full(rest))
} else if b.starts_with(a) {
Some(PrefixMatch::Partial)
} else {
None
}
}
fn find_osc_end(buf: &[u8]) -> Option<(usize, usize)> {
let mut i = 0;
while i < buf.len() {
if buf[i] == 0x07 {
return Some((i, 1));
}
if buf[i] == 0x1b && i + 1 < buf.len() && buf[i + 1] == b'\\' {
return Some((i, 2));
}
i += 1;
}
None
}
fn find_dcs_end(buf: &[u8]) -> Option<(usize, usize)> {
let mut i = 0;
while i + 1 < buf.len() {
if buf[i] == 0x1b && buf[i + 1] == b'\\' {
return Some((i, 2));
}
i += 1;
}
None
}
fn find_dec_prv_final(buf: &[u8]) -> Option<(usize, char)> {
for (i, byte) in buf.iter().enumerate() {
if (0x40..=0x7e).contains(byte) {
return Some((i, *byte as char));
}
if !((0x20..=0x3f).contains(byte)) {
return None;
}
}
None
}
fn parse_dcs_reply(reply: &[u8], prefix: &[u8]) -> Option<String> {
reply
.strip_prefix(prefix)
.map(|value| String::from_utf8_lossy(value).to_string())
}
fn parse_palette_entries(reply: &[u8]) -> Vec<(usize, RGB8)> {
let mut params = reply.split(|b| *b == b';');
let mut entries = Vec::new();
while let Some(idx_bytes) = params.next() {
let Ok(idx_str) = std::str::from_utf8(idx_bytes) else {
break;
};
let Ok(idx) = idx_str.parse::<u8>() else {
break;
};
let Some(color_bytes) = params.next() else {
break;
};
if idx < 16 {
if let Some(c) = parse_rgb_color(color_bytes) {
entries.push((idx as usize, c));
}
}
}
entries
}
fn parse_rgb_color(rgb: &[u8]) -> Option<RGB8> {
let rgb = rgb.strip_prefix(b"rgb:")?;
let mut components = rgb.split(|b| *b == b'/');
let r_hex = components.next()?;
let g_hex = components.next()?;
let b_hex = components.next()?;
let r = parse_hex_byte(r_hex)?;
let g = parse_hex_byte(g_hex)?;
let b = parse_hex_byte(b_hex)?;
Some(RGB8::new(r, g, b))
}
fn parse_hex_byte(bytes: &[u8]) -> Option<u8> {
if bytes.len() < 2 {
return None;
}
let hi = hex_value(bytes[0])?;
let lo = hex_value(bytes[1])?;
Some((hi << 4) | lo)
}
fn hex_value(byte: u8) -> Option<u8> {
match byte {
b'0'..=b'9' => Some(byte - b'0'),
b'a'..=b'f' => Some(byte - b'a' + 10),
b'A'..=b'F' => Some(byte - b'A' + 10),
_ => None,
}
}
#[cfg(test)]
mod tests {
use rgb::RGB8;
use super::{ParseResult, ReplyParser};
const PALETTE_RESP: &[u8] = concat!(
"\x1b]4;0;rgb:0000/1111/2222\x07",
"\x1b]4;1;rgb:3333/4444/5555\x07",
"\x1b]4;2;rgb:6666/7777/8888\x07",
"\x1b]4;3;rgb:9999/aaaa/bbbb\x07",
"\x1b]4;4;rgb:cccc/dddd/eeee\x07",
"\x1b]4;5;rgb:ffff/0000/1111\x07",
"\x1b]4;6;rgb:2222/3333/4444\x07",
"\x1b]4;7;rgb:5555/6666/7777\x07",
"\x1b]4;8;rgb:8888/9999/aaaa\x07",
"\x1b]4;9;rgb:bbbb/cccc/dddd\x07",
"\x1b]4;10;rgb:eeee/ffff/0000\x07",
"\x1b]4;11;rgb:1111/2222/3333\x07",
"\x1b]4;12;rgb:4444/5555/6666\x07",
"\x1b]4;13;rgb:7777/8888/9999\x07",
"\x1b]4;14;rgb:aaaa/bbbb/cccc\x07",
"\x1b]4;15;rgb:dddd/eeee/ffff\x07",
)
.as_bytes();
const FG_RESP: &[u8] = b"\x1b]10;rgb:1122/3344/5566\x07";
const BG_RESP: &[u8] = b"\x1b]11;rgb:7788/99aa/bbcc\x07";
const DA_RESP: &[u8] = b"\x1b[?1;2c";
const XTVERSION_RESP: &[u8] = b"\x1bP>|xterm-395\x1b\\";
fn feed_chunks(chunks: &[&[u8]]) -> (Option<String>, Option<super::TtyTheme>, bool) {
let mut parser = ReplyParser::new();
for chunk in chunks {
if let ParseResult::Done = parser.feed(chunk) {
let (version, theme) = parser.result();
return (version, theme, true);
}
}
let (version, theme) = parser.result();
(version, theme, false)
}
#[test]
fn parse_rgb_color() {
use super::parse_rgb_color as parse;
let color = Some(RGB8::new(0xaa, 0xbb, 0xcc));
assert_eq!(parse(b"rgb:aa11/bb22/cc33"), color);
assert_eq!(parse(b"rgb:aa11/bb22/cc33\x07"), color);
assert_eq!(parse(b"rgb:aa11/bb22/cc33\x1b\\"), color);
assert_eq!(parse(b"rgb:aa11/bb22/cc33.."), color);
assert_eq!(parse(b"rgb:aa1/bb2/cc3"), color);
assert_eq!(parse(b"rgb:aa1/bb2/cc3\x07"), color);
assert_eq!(parse(b"rgb:aa1/bb2/cc3\x1b\\"), color);
assert_eq!(parse(b"rgb:aa1/bb2/cc3.."), color);
assert_eq!(parse(b"rgb:aa/bb/cc"), color);
assert_eq!(parse(b"rgb:aa/bb/cc\x07"), color);
assert_eq!(parse(b"rgb:aa/bb/cc\x1b\\"), color);
assert_eq!(parse(b"rgb:aa/bb/cc.."), color);
assert_eq!(parse(b"rgb:aa11/bb22"), None);
assert_eq!(parse(b"rgb:xxxx/yyyy/zzzz"), None);
assert_eq!(parse(b"rgb:xxx/yyy/zzz"), None);
assert_eq!(parse(b"rgb:xx/yy/zz"), None);
assert_eq!(parse(b"foo"), None);
assert_eq!(parse(b""), None);
}
#[test]
fn parse_palette_entries() {
use super::parse_palette_entries as parse;
// Valid entries
let entries = parse(b"0;rgb:0000/1111/2222;1;rgb:3333/4444/5555");
assert_eq!(entries.len(), 2);
assert_eq!(entries[0], (0, RGB8::new(0x00, 0x11, 0x22)));
assert_eq!(entries[1], (1, RGB8::new(0x33, 0x44, 0x55)));
// Index >= 16 is ignored
let entries = parse(b"16;rgb:3333/4444/5555");
assert_eq!(entries.len(), 0);
// Invalid index stops parsing
let entries = parse(b"0;rgb:0000/1111/2222;xx;rgb:ffff/eeee/dddd");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0], (0, RGB8::new(0x00, 0x11, 0x22)));
// Empty input
let entries = parse(b"");
assert_eq!(entries.len(), 0);
}
#[test]
fn parser_version_only() {
// Just XTVERSION response, no theme
let (version, theme, done) = feed_chunks(&[XTVERSION_RESP]);
assert!(!done); // not done because no DA response
assert!(theme.is_none());
assert_eq!(version, Some("xterm-395".to_string()));
// XTVERSION response with DA
let (version, theme, done) = feed_chunks(&[XTVERSION_RESP, DA_RESP]);
assert!(done);
assert!(theme.is_none());
assert_eq!(version, Some("xterm-395".to_string()));
// No XTVERSION response at all
let (version, theme, done) = feed_chunks(&[b""]);
assert!(!done);
assert!(theme.is_none());
assert!(version.is_none());
}
#[test]
fn parser_theme_only() {
// Theme with out-of-order palette and mixed terminators (BEL and ST)
let (version, theme, done) = feed_chunks(&[
b"\x1b]4;1;rgb:3333/4444/5555\x07", // index 1 first
b"\x1b]4;0;rgb:0000/1111/2222\x1b\\", // index 0 with ST terminator
b"\x1b]4;2;rgb:6666/7777/8888\x07",
b"\x1b]4;3;rgb:9999/aaaa/bbbb\x07",
b"\x1b]4;4;rgb:cccc/dddd/eeee\x07",
b"\x1b]4;5;rgb:ffff/0000/1111\x07",
b"\x1b]4;6;rgb:2222/3333/4444\x07",
b"\x1b]4;7;rgb:5555/6666/7777\x07",
b"\x1b]4;8;rgb:8888/9999/aaaa\x07",
b"\x1b]4;9;rgb:bbbb/cccc/dddd\x07",
b"\x1b]4;10;rgb:eeee/ffff/0000\x07",
b"\x1b]4;11;rgb:1111/2222/3333\x07",
b"\x1b]4;12;rgb:4444/5555/6666\x07",
b"\x1b]4;13;rgb:7777/8888/9999\x07",
b"\x1b]4;14;rgb:aaaa/bbbb/cccc\x07",
b"\x1b]4;15;rgb:dddd/eeee/ffff\x07",
b"\x1b]10;rgb:1122/3344/5566\x1b\\", // fg with ST
BG_RESP,
DA_RESP,
]);
let theme = theme.expect("theme should be present");
assert!(done);
assert!(version.is_none());
assert_eq!(theme.fg, RGB8::new(0x11, 0x33, 0x55));
assert_eq!(theme.bg, RGB8::new(0x77, 0x99, 0xbb));
assert_eq!(theme.palette.len(), 16);
assert_eq!(theme.palette[0], RGB8::new(0x00, 0x11, 0x22));
assert_eq!(theme.palette[1], RGB8::new(0x33, 0x44, 0x55));
assert_eq!(theme.palette[15], RGB8::new(0xdd, 0xee, 0xff));
}
#[test]
fn parser_version_and_theme() {
// The happy path: both version and theme in one response
let (version, theme, done) = feed_chunks(&[
b"\x1bP>|foot(1.22.0)\x1b\\",
PALETTE_RESP,
FG_RESP,
BG_RESP,
DA_RESP,
]);
let theme = theme.expect("theme should be present");
assert!(done);
assert_eq!(version, Some("foot(1.22.0)".to_string()));
assert_eq!(theme.fg, RGB8::new(0x11, 0x33, 0x55));
assert_eq!(theme.bg, RGB8::new(0x77, 0x99, 0xbb));
assert_eq!(theme.palette.len(), 16);
}
#[test]
fn parser_packed_palette() {
// OSC 4 with multiple colors per response
let (version, theme, done) = feed_chunks(&[
b"\x1b]4;0;rgb:0000/1111/2222;1;rgb:3333/4444/5555;2;rgb:6666/7777/8888\x07",
b"\x1b]4;3;rgb:9999/aaaa/bbbb;4;rgb:cccc/dddd/eeee;5;rgb:ffff/0000/1111\x07",
b"\x1b]4;6;rgb:2222/3333/4444;7;rgb:5555/6666/7777;8;rgb:8888/9999/aaaa\x07",
b"\x1b]4;9;rgb:bbbb/cccc/dddd;10;rgb:eeee/ffff/0000;11;rgb:1111/2222/3333\x07",
b"\x1b]4;12;rgb:4444/5555/6666;13;rgb:7777/8888/9999;14;rgb:aaaa/bbbb/cccc;15;rgb:dddd/eeee/ffff\x07",
FG_RESP,
BG_RESP,
DA_RESP,
]);
let theme = theme.expect("theme should be present");
assert!(done);
assert!(version.is_none());
assert_eq!(theme.fg, RGB8::new(0x11, 0x33, 0x55));
assert_eq!(theme.bg, RGB8::new(0x77, 0x99, 0xbb));
assert_eq!(theme.palette.len(), 16);
assert_eq!(theme.palette[0], RGB8::new(0x00, 0x11, 0x22));
assert_eq!(theme.palette[15], RGB8::new(0xdd, 0xee, 0xff));
}
#[test]
fn parser_chunked_response() {
// Response split across multiple feed() calls at awkward boundaries
let chunks = [
b"\x1bP>|xterm-".as_slice(), // version split mid-string
b"395\x1b".as_slice(), // escape without backslash
b"\\\x1b]10;rgb:1122/3344/5566\x1b".as_slice(), // OSC 10 with partial ST
b"\\\x1b]4;0;rgb:0000/1111/2222;".as_slice(), // packed palette, split mid-entry
b"1;rgb:3333/4444/5555;2;rgb:6666/7777/8888\x07".as_slice(),
b"\x1b]4;3;rgb:9999/aaaa/bbbb\x07".as_slice(),
b"\x1b]4;4;rgb:cccc/dd".as_slice(), // color split mid-value
b"dd/eeee\x07".as_slice(),
b"\x1b]4;5;rgb:ffff/0000/1111\x07".as_slice(),
b"\x1b]4;6;rgb:2222/3333/4444\x07".as_slice(),
b"\x1b]4;7;rgb:5555/6666/7777\x07".as_slice(),
b"\x1b]4;8;rgb:8888/9999/aaaa\x07".as_slice(),
b"\x1b]4;9;rgb:bbbb/cccc/dddd\x07".as_slice(),
b"\x1b]4;10;rgb:eeee/ffff/0000\x07".as_slice(),
b"\x1b]4;11;rgb:1111/2222/3333\x07".as_slice(),
b"\x1b]4;12;rgb:4444/5555/6666\x07".as_slice(),
b"\x1b]4;13;rgb:7777/8888/9999\x07".as_slice(),
b"\x1b]4;14;rgb:aaaa/bbbb/cccc\x07".as_slice(),
b"\x1b]4;15;rgb:dddd/eeee/ffff\x07".as_slice(),
b"\x1b]11;rgb:7788/99aa/bbcc\x07".as_slice(),
DA_RESP,
];
let (version, theme, done) = feed_chunks(&chunks);
let theme = theme.expect("theme should be present");
assert!(done);
assert_eq!(version, Some("xterm-395".to_string()));
assert_eq!(theme.fg, RGB8::new(0x11, 0x33, 0x55));
assert_eq!(theme.bg, RGB8::new(0x77, 0x99, 0xbb));
assert_eq!(theme.palette.len(), 16);
}
#[test]
fn parser_garbage_ignored() {
// Unknown sequences between valid ones should be skipped
let (version, theme, done) = feed_chunks(&[
b"\x1b[?25h", // DECSET (ignored)
b"\x1bP>|ghostty\x1b\\", // version
b"\x1b[>0;1;2c", // DA2 response (ignored)
b"random garbage", // plain text (ignored)
PALETTE_RESP,
FG_RESP,
BG_RESP,
DA_RESP,
]);
let theme = theme.expect("theme should be present");
assert!(done);
assert_eq!(version, Some("ghostty".to_string()));
assert_eq!(theme.fg, RGB8::new(0x11, 0x33, 0x55));
assert_eq!(theme.bg, RGB8::new(0x77, 0x99, 0xbb));
assert_eq!(theme.palette.len(), 16);
}
#[test]
fn parser_incomplete_theme() {
// Missing fg -> theme is None
let (_version, theme, done) = feed_chunks(&[
PALETTE_RESP,
BG_RESP, // only bg, no fg
DA_RESP,
]);
assert!(done);
assert!(theme.is_none());
// Missing palette -> theme is None
let (_version, theme, done) = feed_chunks(&[FG_RESP, BG_RESP, DA_RESP]);
assert!(done);
assert!(theme.is_none());
// Partial palette (only 8 colors) -> theme is None
let partial_palette = concat!(
"\x1b]4;0;rgb:0000/1111/2222\x07",
"\x1b]4;1;rgb:3333/4444/5555\x07",
"\x1b]4;2;rgb:6666/7777/8888\x07",
"\x1b]4;3;rgb:9999/aaaa/bbbb\x07",
"\x1b]4;4;rgb:cccc/dddd/eeee\x07",
"\x1b]4;5;rgb:ffff/0000/1111\x07",
"\x1b]4;6;rgb:2222/3333/4444\x07",
"\x1b]4;7;rgb:5555/6666/7777\x07",
)
.as_bytes();
let (_version, theme, done) = feed_chunks(&[partial_palette, FG_RESP, BG_RESP, DA_RESP]);
assert!(done);
assert!(theme.is_none());
}
}