//! VT emulation: feed bytes through `vte` and drive the [`Grid`]. use std::io::Write as _; use vte::{Params, Perform}; use crate::grid::{Color, CursorShape, Flags, Grid, MouseEncoding, MouseProtocol, Underline}; use crate::theme::{Rgb, Theme}; /// G0/G1 character set designation. #[derive(Clone, Copy, PartialEq, Eq, Debug)] enum Charset { Ascii, DecSpecial, } /// Which device-attributes query is being answered. #[derive(Clone, Copy, Debug)] enum DaLevel { Primary, Secondary, 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 }, } /// Which dynamic colour an OSC 10/11/17/19 escape targets. #[derive(Clone, Copy, Debug)] enum Dynamic { Fg, Bg, SelBg, SelFg, } /// DECRQM mode-state code: 1 = set, 2 = reset. fn set_reset(on: bool) -> u8 { if on { 1 } else { 2 } } /// Parse an OSC colour spec into an [`Rgb`]. fn parse_spec(spec: &[u8]) -> Option { std::str::from_utf8(spec) .ok() .and_then(crate::theme::parse_color) } /// Parse a decimal palette index (0-255). fn parse_index(b: &[u8]) -> Option { std::str::from_utf8(b).ok()?.parse().ok() } fn rgb_tuple(rgb: Rgb) -> (u8, u8, u8) { (rgb.0, rgb.1, rgb.2) } /// Select `protocol` when a mouse mode is set, else turn reporting off. fn proto(on: bool, protocol: MouseProtocol) -> MouseProtocol { if on { protocol } else { MouseProtocol::Off } } /// Select `encoding` when its mode is set, else fall back to the default form. fn enc(on: bool, encoding: MouseEncoding) -> MouseEncoding { if on { encoding } else { MouseEncoding::X10 } } /// Map an SGR 4 param (`4` or `4:x`) to an underline style. fn underline_from(param: &[u16]) -> Underline { match param.get(1).copied().unwrap_or(1) { 0 => Underline::None, 2 => Underline::Double, 3 => Underline::Curly, 4 => Underline::Dotted, 5 => Underline::Dashed, _ => Underline::Single, } } /// The terminal model: a grid plus the escape-sequence state around it. #[derive(Debug)] pub struct Term { grid: Grid, title: Option, title_stack: Vec>, response: Vec, g0: Charset, g1: Charset, shift_out: bool, /// Accumulated payload of an in-progress `DCS + q` (XTGETTCAP) query. xtgettcap: Option>, /// Pending OSC 52 clipboard requests, drained by the front-end. clipboard_ops: Vec, /// The active colour scheme (seeded from config, mutated by OSC escapes). theme: Theme, } impl Term { pub fn new(cols: usize, rows: usize) -> Self { Self { grid: Grid::new(cols, rows), title: None, title_stack: Vec::new(), response: Vec::new(), g0: Charset::Ascii, g1: Charset::Ascii, shift_out: false, xtgettcap: None, clipboard_ops: Vec::new(), theme: Theme::default(), } } /// Drain the OSC 52 clipboard requests accumulated since the last call. pub fn take_clipboard_ops(&mut self) -> Vec { std::mem::take(&mut self.clipboard_ops) } pub fn theme(&self) -> &Theme { &self.theme } /// Replace the colour scheme (config load / reload). pub fn set_theme(&mut self, theme: Theme) { self.theme = theme; } /// 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]) { for name_hex in payload.split(|&b| b == b';') { let value = decode_hex(name_hex).and_then(|name| cap_value(&name)); match value { Some(value) => { self.response.extend_from_slice(b"\x1bP1+r"); self.response.extend_from_slice(name_hex); self.response.push(b'='); for byte in value.bytes() { let _ = write!(self.response, "{byte:02x}"); } self.response.extend_from_slice(b"\x1b\\"); } None => { self.response.extend_from_slice(b"\x1bP0+r"); self.response.extend_from_slice(name_hex); self.response.extend_from_slice(b"\x1b\\"); } } } } pub fn grid(&self) -> &Grid { &self.grid } pub fn grid_mut(&mut self) -> &mut Grid { &mut self.grid } pub fn resize(&mut self, cols: usize, rows: usize) { self.grid.resize(cols, rows); } pub fn scroll_view(&mut self, delta: isize) { self.grid.scroll_view(delta); } pub fn scroll_to_bottom(&mut self) { self.grid.scroll_to_bottom(); } /// Lines per page, for page-scroll bindings. pub fn page(&self) -> usize { self.grid.page() } pub fn title(&self) -> Option<&str> { self.title.as_deref() } /// Bytes the terminal needs to send back to the application (DA, CPR, ...). pub fn take_response(&mut self) -> Vec { std::mem::take(&mut self.response) } fn active_charset(&self) -> Charset { if self.shift_out { self.g1 } else { self.g0 } } fn set_mode(&mut self, params: &Params, private: bool, on: bool) { for p in params.iter() { let Some(&code) = p.first() else { continue }; match (private, code) { (true, 6) => self.grid.set_origin(on), (true, 7) => self.grid.set_autowrap(on), (true, 1049) => { if on { self.grid.save_cursor(); self.grid.enter_alt_screen(); self.grid.erase_display(2); } else { self.grid.leave_alt_screen(); self.grid.restore_cursor(); } } (true, 47 | 1047) => { if on { self.grid.enter_alt_screen(); } else { self.grid.leave_alt_screen(); } } (false, 4) => self.grid.set_insert(on), (true, 1) => self.grid.set_app_cursor(on), (true, 25) => self.grid.set_cursor_visible(on), (true, 9) => self.grid.set_mouse_protocol(proto(on, MouseProtocol::X10)), (true, 1000) => self .grid .set_mouse_protocol(proto(on, MouseProtocol::Normal)), (true, 1002) => self .grid .set_mouse_protocol(proto(on, MouseProtocol::Button)), (true, 1003) => self.grid.set_mouse_protocol(proto(on, MouseProtocol::Any)), (true, 1004) => self.grid.set_focus_events(on), (true, 1005) => self.grid.set_mouse_encoding(enc(on, MouseEncoding::Utf8)), (true, 1006) => self.grid.set_mouse_encoding(enc(on, MouseEncoding::Sgr)), (true, 2004) => self.grid.set_bracketed_paste(on), (true, 2026) => self.grid.set_sync(on), _ => tracing::trace!("unhandled mode {code} private={private} on={on}"), } } } fn sgr(&mut self, params: &Params) { let items: Vec<&[u16]> = params.iter().collect(); if items.is_empty() { self.grid.reset_pen(); return; } let mut i = 0; while i < items.len() { let p = items[i]; let code = p.first().copied().unwrap_or(0); let pen = self.grid.pen_mut(); let mut step = 1; match code { 0 => *pen = Default::default(), 1 => pen.flags.insert(Flags::BOLD), 2 => pen.flags.insert(Flags::DIM), 3 => pen.flags.insert(Flags::ITALIC), 4 => pen.underline = underline_from(p), 5 | 6 => pen.flags.insert(Flags::BLINK), 7 => pen.flags.insert(Flags::REVERSE), 8 => pen.flags.insert(Flags::HIDDEN), 9 => pen.flags.insert(Flags::STRIKE), 21 => pen.underline = Underline::Double, 22 => pen.flags.remove(Flags::BOLD.union(Flags::DIM)), 23 => pen.flags.remove(Flags::ITALIC), 24 => pen.underline = Underline::None, 25 => pen.flags.remove(Flags::BLINK), 27 => pen.flags.remove(Flags::REVERSE), 28 => pen.flags.remove(Flags::HIDDEN), 29 => pen.flags.remove(Flags::STRIKE), 30..=37 => pen.fg = Color::Indexed((code - 30) as u8), 39 => pen.fg = Color::Default, 40..=47 => pen.bg = Color::Indexed((code - 40) as u8), 49 => pen.bg = Color::Default, 53 => pen.flags.insert(Flags::OVERLINE), 55 => pen.flags.remove(Flags::OVERLINE), 90..=97 => pen.fg = Color::Indexed((code - 90 + 8) as u8), 100..=107 => pen.bg = Color::Indexed((code - 100 + 8) as u8), 38 | 48 | 58 => { let (color, consumed) = ext_color(&items, i); if let Some(color) = color { match code { 38 => pen.fg = color, 48 => pen.bg = color, _ => pen.underline_color = color, } } step = consumed; } 59 => pen.underline_color = Color::Default, _ => {} } i += step; } } /// Device attributes. DA1 claims a VT220 with ANSI colour; DA2 a generic /// firmware level; DA3 a (zero) unit ID. fn device_attrs(&mut self, level: DaLevel) { match level { DaLevel::Primary => self.response.extend_from_slice(b"\x1b[?62;22c"), DaLevel::Secondary => self.response.extend_from_slice(b"\x1b[>0;276;0c"), DaLevel::Tertiary => self.response.extend_from_slice(b"\x1bP!|00000000\x1b\\"), } } /// XTVERSION (`CSI > q`): report the terminal name and version. fn report_version(&mut self) { let _ = write!( self.response, "\x1bP>|beer({})\x1b\\", env!("CARGO_PKG_VERSION") ); } /// Emit an OSC colour reply (`OSC code ; rgb:rrrr/gggg/bbbb` + terminator). fn reply_color(&mut self, code: &str, rgb: Rgb, bell: bool) { let Rgb(r, g, b) = rgb; let _ = write!( self.response, "\x1b]{code};rgb:{:04x}/{:04x}/{:04x}", u16::from(r) * 0x101, u16::from(g) * 0x101, u16::from(b) * 0x101, ); self.response .extend_from_slice(if bell { b"\x07" } else { b"\x1b\\" }); } /// OSC 4: set or query palette entries, given as `index;spec` pairs. fn osc_palette(&mut self, params: &[&[u8]], bell: bool) { let mut rest = params[1..].iter(); while let (Some(idx_raw), Some(spec)) = (rest.next(), rest.next()) { let Some(idx) = parse_index(idx_raw) else { continue; }; if *spec == b"?" { let rgb = self.theme.palette[idx as usize]; self.reply_color(&format!("4;{idx}"), rgb, bell); } else if let Some(rgb) = parse_spec(spec) { self.theme.set_palette(idx, rgb); } } } /// OSC 10/11/17/19: set or query a dynamic colour. fn osc_dynamic_color(&mut self, kind: Dynamic, spec: Option<&&[u8]>, bell: bool) { let Some(spec) = spec else { return }; if **spec == b"?"[..] { let rgb = match kind { Dynamic::Fg => self.theme.fg, Dynamic::Bg => self.theme.bg, Dynamic::SelBg => self.theme.selection_bg, Dynamic::SelFg => self.theme.selection_fg.unwrap_or(self.theme.fg), }; let code = match kind { Dynamic::Fg => "10", Dynamic::Bg => "11", Dynamic::SelBg => "17", Dynamic::SelFg => "19", }; self.reply_color(code, rgb, bell); } else if let Some(rgb) = parse_spec(spec) { match kind { Dynamic::Fg => self.theme.fg = rgb, Dynamic::Bg => self.theme.bg = rgb, Dynamic::SelBg => self.theme.selection_bg = rgb, Dynamic::SelFg => self.theme.selection_fg = Some(rgb), } } } fn device_status(&mut self, params: &Params) { match params.iter().next().and_then(|p| p.first().copied()) { Some(5) => self.response.extend_from_slice(b"\x1b[0n"), Some(6) => { let (x, y) = self.grid.cursor(); let _ = write!(self.response, "\x1b[{};{}R", y + 1, x + 1); } _ => {} } } /// DECRQM (`CSI [?] Ps $ p`): report whether a mode is set (1), reset (2), /// or unrecognized (0). Only the modes we actually track are reported. fn report_mode(&mut self, params: &Params, private: bool) { let code = raw(params, 0); let state = match (private, code) { (true, 6) => set_reset(self.grid.origin()), (true, 7) => set_reset(self.grid.autowrap()), (true, 47 | 1047 | 1049) => set_reset(self.grid.alt_active()), (true, 9) => set_reset(self.grid.mouse_protocol() == MouseProtocol::X10), (true, 1000) => set_reset(self.grid.mouse_protocol() == MouseProtocol::Normal), (true, 1002) => set_reset(self.grid.mouse_protocol() == MouseProtocol::Button), (true, 1003) => set_reset(self.grid.mouse_protocol() == MouseProtocol::Any), (true, 1004) => set_reset(self.grid.focus_events()), (true, 1005) => set_reset(self.grid.mouse_encoding() == MouseEncoding::Utf8), (true, 1006) => set_reset(self.grid.mouse_encoding() == MouseEncoding::Sgr), (true, 2004) => set_reset(self.grid.bracketed_paste()), (true, 2026) => set_reset(self.grid.sync_active()), (false, 4) => set_reset(self.grid.insert()), _ => 0, }; let prefix = if private { "?" } else { "" }; let _ = write!(self.response, "\x1b[{prefix}{code};{state}$y"); } /// Title stack (`CSI 22/23 ; Ps t`): push or pop the window title. fn title_stack_op(&mut self, params: &Params) { match raw(params, 0) { 22 => self.title_stack.push(self.title.clone()), 23 => { if let Some(title) = self.title_stack.pop() { self.title = title; } } _ => {} } } } /// First param value, with 0/absent folded to `default` (xterm convention for /// cursor movement and counts). fn n(params: &Params, idx: usize, default: usize) -> usize { match params.iter().nth(idx).and_then(|p| p.first().copied()) { Some(0) | None => default, Some(v) => v as usize, } } /// Raw first param value (0 is meaningful), defaulting to 0 when absent. fn raw(params: &Params, idx: usize) -> u16 { params .iter() .nth(idx) .and_then(|p| p.first().copied()) .unwrap_or(0) } /// Parse an SGR 38/48 extended colour, returning the colour and how many /// top-level params it consumed (1 for colon form, more for semicolon form). fn ext_color(items: &[&[u16]], i: usize) -> (Option, usize) { let head = items[i]; if head.len() >= 2 { return (color_from_subparams(&head[1..]), 1); } match items.get(i + 1).and_then(|s| s.first().copied()) { Some(5) => { let idx = items .get(i + 2) .and_then(|s| s.first().copied()) .unwrap_or(0); (Some(Color::Indexed(idx as u8)), 3) } Some(2) => { let get = |k: usize| { items .get(i + k) .and_then(|s| s.first().copied()) .unwrap_or(0) }; ( Some(Color::Rgb(get(2) as u8, get(3) as u8, get(4) as u8)), 5, ) } _ => (None, 1), } } fn color_from_subparams(sub: &[u16]) -> Option { match sub.first().copied() { Some(5) => sub.get(1).map(|&i| Color::Indexed(i as u8)), Some(2) => { // Either `2:r:g:b` or `2:colorspace:r:g:b`. let rgb = if sub.len() >= 5 { &sub[2..5] } else { &sub[1..] }; match rgb { [r, g, b, ..] => Some(Color::Rgb(*r as u8, *g as u8, *b as u8)), _ => None, } } _ => None, } } fn charset(byte: u8) -> Charset { match byte { b'0' => Charset::DecSpecial, _ => Charset::Ascii, } } /// Translate a byte under the DEC special graphics set (line drawing). fn dec_special(c: char) -> char { match c { '`' => '◆', 'a' => '▒', 'f' => '°', 'g' => '±', 'j' => '┘', 'k' => '┐', 'l' => '┌', 'm' => '└', 'n' => '┼', 'o' => '⎺', 'p' => '⎻', 'q' => '─', 'r' => '⎼', 's' => '⎽', 't' => '├', 'u' => '┤', 'v' => '┴', 'w' => '┬', 'x' => '│', 'y' => '≤', 'z' => '≥', '~' => '·', _ => c, } } impl Perform for Term { fn print(&mut self, c: char) { let c = if self.active_charset() == Charset::DecSpecial { dec_special(c) } else { c }; self.grid.print(c); } fn execute(&mut self, byte: u8) { match byte { 0x08 => self.grid.backspace(), 0x09 => self.grid.tab(), 0x0A..=0x0C => self.grid.line_feed(), 0x0D => self.grid.carriage_return(), 0x0E => self.shift_out = true, 0x0F => self.shift_out = false, _ => {} } } fn csi_dispatch(&mut self, params: &Params, intermediates: &[u8], _ignore: bool, action: char) { let private = intermediates.first() == Some(&b'?'); match action { 'A' => self.grid.cursor_up(n(params, 0, 1)), 'B' | 'e' => self.grid.cursor_down(n(params, 0, 1)), 'C' | 'a' => self.grid.cursor_fwd(n(params, 0, 1)), 'D' => self.grid.cursor_back(n(params, 0, 1)), 'E' => { self.grid.cursor_down(n(params, 0, 1)); self.grid.carriage_return(); } 'F' => { self.grid.cursor_up(n(params, 0, 1)); self.grid.carriage_return(); } 'G' | '`' => self.grid.move_to_col(n(params, 0, 1) - 1), 'd' => self.grid.move_to_row(n(params, 0, 1) - 1), 'H' | 'f' => self.grid.move_to(n(params, 1, 1) - 1, n(params, 0, 1) - 1), 'J' => self.grid.erase_display(raw(params, 0)), 'K' => self.grid.erase_line(raw(params, 0)), '@' => self.grid.insert_chars(n(params, 0, 1)), 'P' => self.grid.delete_chars(n(params, 0, 1)), 'L' => self.grid.insert_lines(n(params, 0, 1)), 'M' => self.grid.delete_lines(n(params, 0, 1)), 'X' => self.grid.erase_chars(n(params, 0, 1)), 'S' => self.grid.scroll_up(n(params, 0, 1)), 'T' => self.grid.scroll_down(n(params, 0, 1)), 'm' => self.sgr(params), 'r' => { let top = n(params, 0, 1) - 1; let bottom = match params.iter().nth(1).and_then(|p| p.first().copied()) { Some(0) | None => self.grid.rows() - 1, Some(v) => (v as usize).saturating_sub(1), }; self.grid.set_scroll_region(top, bottom); } 'h' => self.set_mode(params, private, true), 'l' => self.set_mode(params, private, false), 'c' => self.device_attrs(match intermediates.first() { Some(b'>') => DaLevel::Secondary, Some(b'=') => DaLevel::Tertiary, _ => DaLevel::Primary, }), 'q' if intermediates.first() == Some(&b'>') => self.report_version(), 'q' if intermediates.first() == Some(&b' ') => { let code = raw(params, 0); self.grid.set_cursor_shape(match code { 3 | 4 => CursorShape::Underline, 5 | 6 => CursorShape::Beam, _ => CursorShape::Block, }); // Even codes are steady; 0/1 and other odd codes blink. self.grid.set_cursor_blink(code == 0 || code % 2 == 1); } 'p' if intermediates.contains(&b'$') => self.report_mode(params, private), 'n' => self.device_status(params), 's' => self.grid.save_cursor(), 'u' => self.grid.restore_cursor(), 't' => self.title_stack_op(params), 'g' => match raw(params, 0) { 3 => self.grid.clear_all_tabs(), _ => self.grid.clear_tab(), }, _ => tracing::trace!("unhandled CSI {action:?} {intermediates:?}"), } } fn esc_dispatch(&mut self, intermediates: &[u8], _ignore: bool, byte: u8) { match (intermediates.first().copied(), byte) { (None, b'D') => self.grid.line_feed(), (None, b'M') => self.grid.reverse_index(), (None, b'E') => self.grid.next_line(), (None, b'7') => self.grid.save_cursor(), (None, b'8') => self.grid.restore_cursor(), (None, b'H') => self.grid.set_tab(), (None, b'c') => { self.grid.reset_pen(); self.grid.set_scroll_region(0, self.grid.rows() - 1); self.grid.set_autowrap(true); self.grid.set_origin(false); self.grid.erase_display(2); self.grid.move_to(0, 0); self.g0 = Charset::Ascii; self.g1 = Charset::Ascii; self.shift_out = false; } (Some(b'('), c) => self.g0 = charset(c), (Some(b')'), c) => self.g1 = charset(c), _ => {} } } fn osc_dispatch(&mut self, params: &[&[u8]], bell: bool) { match params.first() { Some(&n) if n == b"0" || n == b"2" => { if let Some(text) = params.get(1) { self.title = Some(String::from_utf8_lossy(text).into_owned()); } } // OSC 4: set/query palette entries (pairs of index;spec). Some(&n) if n == b"4" => self.osc_palette(params, bell), // OSC 104: reset palette (all, or the listed indices). Some(&n) if n == b"104" => { if params.len() <= 1 { self.theme.reset_palette(); } else { for p in ¶ms[1..] { if let Some(i) = parse_index(p) { self.theme.reset_palette_index(i); } } } } // OSC 10/11: foreground / background; OSC 110/111 reset them. Some(&n) if n == b"10" => self.osc_dynamic_color(Dynamic::Fg, params.get(1), bell), Some(&n) if n == b"11" => self.osc_dynamic_color(Dynamic::Bg, params.get(1), bell), Some(&n) if n == b"110" => self.theme.reset_fg(), Some(&n) if n == b"111" => self.theme.reset_bg(), // OSC 17/19: selection (highlight) background / foreground. Some(&n) if n == b"17" => self.osc_dynamic_color(Dynamic::SelBg, params.get(1), bell), Some(&n) if n == b"19" => self.osc_dynamic_color(Dynamic::SelFg, params.get(1), bell), // OSC 12: set cursor colour; OSC 112: reset to default. Some(&n) if n == b"12" => { self.grid .set_cursor_color(params.get(1).and_then(|s| parse_spec(s)).map(rgb_tuple)); } 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 }); } } _ => {} } } fn hook(&mut self, _: &Params, intermediates: &[u8], _: bool, action: char) { // XTGETTCAP arrives as `DCS + q ST`. if action == 'q' && intermediates == [b'+'] { self.xtgettcap = Some(Vec::new()); } } fn put(&mut self, byte: u8) { if let Some(buf) = self.xtgettcap.as_mut() { buf.push(byte); } } fn unhook(&mut self) { if let Some(payload) = self.xtgettcap.take() { self.answer_xtgettcap(&payload); } } } /// Look up a terminfo capability beer reports via XTGETTCAP. fn cap_value(name: &[u8]) -> Option<&'static str> { match name { b"TN" => Some("beer"), b"Co" | b"colors" => Some("256"), b"RGB" => Some("8/8/8"), _ => None, } } 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> { let val = |c: u8| -> Option { 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 = 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> { if s.is_empty() || !s.len().is_multiple_of(2) { return None; } let nibble = |b: u8| (b as char).to_digit(16).map(|d| d as u8); s.chunks_exact(2) .map(|pair| Some((nibble(pair[0])? << 4) | nibble(pair[1])?)) .collect() } #[cfg(test)] mod tests { use super::*; use crate::grid::{MouseEncoding, MouseProtocol}; fn feed(term: &mut Term, bytes: &[u8]) { let mut parser = vte::Parser::new(); parser.advance(term, bytes); } #[test] fn plain_text_lands_in_the_grid() { let mut t = Term::new(20, 4); feed(&mut t, b"hello"); assert_eq!(t.grid().row_text(0), "hello"); } #[test] fn cursor_position_and_erase() { let mut t = Term::new(20, 4); feed(&mut t, b"abcde\x1b[Hxyz"); assert_eq!(t.grid().row_text(0), "xyzde"); } #[test] fn newline_sequence() { let mut t = Term::new(20, 4); feed(&mut t, b"one\r\ntwo"); assert_eq!(t.grid().row_text(0), "one"); assert_eq!(t.grid().row_text(1), "two"); } #[test] fn device_attributes_levels() { let mut t = Term::new(20, 4); feed(&mut t, b"\x1b[c"); assert_eq!(t.take_response(), b"\x1b[?62;22c"); feed(&mut t, b"\x1b[>c"); assert_eq!(t.take_response(), b"\x1b[>0;276;0c"); feed(&mut t, b"\x1b[=c"); assert_eq!(t.take_response(), b"\x1bP!|00000000\x1b\\"); } #[test] fn xtversion_reports_name() { let mut t = Term::new(20, 4); feed(&mut t, b"\x1b[>q"); let resp = t.take_response(); assert!(resp.starts_with(b"\x1bP>|beer(")); assert!(resp.ends_with(b")\x1b\\")); } #[test] fn decrqm_reports_known_modes() { let mut t = Term::new(20, 4); feed(&mut t, b"\x1b[?7$p"); // autowrap, on by default assert_eq!(t.take_response(), b"\x1b[?7;1$y"); feed(&mut t, b"\x1b[?7l\x1b[?7$p"); // turn it off, re-query assert_eq!(t.take_response(), b"\x1b[?7;2$y"); feed(&mut t, b"\x1b[?9999$p"); // unknown mode assert_eq!(t.take_response(), b"\x1b[?9999;0$y"); } #[test] fn sgr_underline_styles_and_lines() { let mut t = Term::new(20, 1); feed(&mut t, b"\x1b[4:3;58;5;1;53mX"); let cell = t.grid().cell(0, 0); assert_eq!(cell.underline, Underline::Curly); assert_eq!(cell.underline_color, Color::Indexed(1)); assert!(cell.flags.contains(Flags::OVERLINE)); // 4:0 turns the underline back off. feed(&mut t, b"\x1b[4:0mY"); assert_eq!(t.grid().cell(1, 0).underline, Underline::None); } #[test] fn decscusr_and_cursor_visibility() { let mut t = Term::new(20, 1); feed(&mut t, b"\x1b[4 q"); assert_eq!(t.grid().cursor_shape(), CursorShape::Underline); feed(&mut t, b"\x1b[6 q"); assert_eq!(t.grid().cursor_shape(), CursorShape::Beam); feed(&mut t, b"\x1b[0 q"); assert_eq!(t.grid().cursor_shape(), CursorShape::Block); feed(&mut t, b"\x1b[?25l"); assert!(!t.grid().cursor_visible()); feed(&mut t, b"\x1b[?25h"); assert!(t.grid().cursor_visible()); } #[test] fn osc12_sets_and_resets_cursor_color() { let mut t = Term::new(20, 1); feed(&mut t, b"\x1b]12;#ff0000\x07"); assert_eq!(t.grid().cursor_color(), Some((255, 0, 0))); feed(&mut t, b"\x1b]12;rgb:00/80/ff\x07"); assert_eq!(t.grid().cursor_color(), Some((0, 0x80, 0xff))); feed(&mut t, b"\x1b]112\x07"); assert_eq!(t.grid().cursor_color(), None); } #[test] fn decscusr_and_cursor_color() { use crate::grid::CursorShape; let mut t = Term::new(20, 1); feed(&mut t, b"\x1b[5 q"); // blinking bar assert_eq!(t.grid().cursor_shape(), CursorShape::Beam); feed(&mut t, b"\x1b[4 q"); // steady underline assert_eq!(t.grid().cursor_shape(), CursorShape::Underline); feed(&mut t, b"\x1b]12;#ff3030\x07"); assert_eq!(t.grid().cursor_color(), Some((0xff, 0x30, 0x30))); feed(&mut t, b"\x1b]112\x07"); assert_eq!(t.grid().cursor_color(), None); feed(&mut t, b"\x1b[?25l"); // hide cursor assert!(!t.grid().cursor_visible()); } #[test] fn osc_palette_and_dynamic_colors() { use crate::theme::Rgb; let mut t = Term::new(20, 2); // Set palette index 1 and foreground via OSC, then query them back. feed(&mut t, b"\x1b]4;1;#ff0000\x1b\\"); assert_eq!(t.theme().palette[1], Rgb(0xff, 0, 0)); feed(&mut t, b"\x1b]10;rgb:00/80/ff\x1b\\"); assert_eq!(t.theme().fg, Rgb(0, 0x80, 0xff)); feed(&mut t, b"\x1b]11;?\x07"); let resp = t.take_response(); assert!(resp.starts_with(b"\x1b]11;rgb:")); // Reset returns the palette entry to its default. feed(&mut t, b"\x1b]104;1\x1b\\"); assert_ne!(t.theme().palette[1], Rgb(0xff, 0, 0)); } #[test] fn xtgettcap_known_and_unknown() { let mut t = Term::new(20, 1); feed(&mut t, b"\x1bP+q544e\x1b\\"); // "TN" assert_eq!(t.take_response(), b"\x1bP1+r544e=62656572\x1b\\"); // = "beer" feed(&mut t, b"\x1bP+q6162\x1b\\"); // "ab", unknown assert_eq!(t.take_response(), b"\x1bP0+r6162\x1b\\"); } #[test] fn bracketed_paste_and_sync_modes() { let mut t = Term::new(20, 2); feed(&mut t, b"\x1b[?2004h"); assert!(t.grid().bracketed_paste()); feed(&mut t, b"\x1b[?2004$p"); assert_eq!(t.take_response(), b"\x1b[?2004;1$y"); feed(&mut t, b"\x1b[?2026h"); assert!(t.grid().sync_active()); feed(&mut t, b"\x1b[?2026l\x1b[?2026$p"); assert!(!t.grid().sync_active()); assert_eq!(t.take_response(), b"\x1b[?2026;2$y"); } #[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"); assert_eq!(t.grid().mouse_protocol(), MouseProtocol::Button); assert_eq!(t.grid().mouse_encoding(), MouseEncoding::Sgr); feed(&mut t, b"\x1b[?1002$p"); assert_eq!(t.take_response(), b"\x1b[?1002;1$y"); feed(&mut t, b"\x1b[?1003h"); // any-event supersedes button-event assert_eq!(t.grid().mouse_protocol(), MouseProtocol::Any); feed(&mut t, b"\x1b[?1000l"); // turning a mouse mode off clears reporting assert_eq!(t.grid().mouse_protocol(), MouseProtocol::Off); feed(&mut t, b"\x1b[?1004h"); assert!(t.grid().focus_events()); } #[test] fn title_stack_push_pop() { let mut t = Term::new(20, 4); feed(&mut t, b"\x1b]0;first\x07"); feed(&mut t, b"\x1b[22t"); // push "first" feed(&mut t, b"\x1b]0;second\x07"); assert_eq!(t.title(), Some("second")); feed(&mut t, b"\x1b[23t"); // pop -> "first" assert_eq!(t.title(), Some("first")); } #[test] fn sgr_sets_pen_colours() { let mut t = Term::new(20, 1); feed(&mut t, b"\x1b[31;1mX"); let cell = t.grid().cell(0, 0); assert_eq!(cell.fg, Color::Indexed(1)); assert!(cell.flags.contains(Flags::BOLD)); } #[test] fn truecolor_semicolon_and_colon() { let mut t = Term::new(20, 1); feed(&mut t, b"\x1b[38;2;10;20;30mA"); assert_eq!(t.grid().cell(0, 0).fg, Color::Rgb(10, 20, 30)); feed(&mut t, b"\x1b[38:2:40:50:60mB"); assert_eq!(t.grid().cell(1, 0).fg, Color::Rgb(40, 50, 60)); } #[test] fn device_status_reports_cursor() { let mut t = Term::new(20, 4); feed(&mut t, b"\x1b[3;5H\x1b[6n"); assert_eq!(t.take_response(), b"\x1b[3;5R"); } #[test] fn line_drawing_charset() { let mut t = Term::new(20, 1); feed(&mut t, b"\x1b(0qx\x1b(B"); assert_eq!(t.grid().row_text(0), "─│"); } #[test] fn title_via_osc() { let mut t = Term::new(20, 1); feed(&mut t, b"\x1b]0;hello\x07"); assert_eq!(t.title(), Some("hello")); } #[test] fn alt_screen_preserves_primary() { let mut t = Term::new(20, 2); feed(&mut t, b"main"); feed(&mut t, b"\x1b[?1049h"); assert_eq!(t.grid().row_text(0), ""); feed(&mut t, b"\x1b[?1049l"); assert_eq!(t.grid().row_text(0), "main"); } }