use std::sync::Arc;
use crate::graphics::{GraphicsBuilder, GraphicsCounts, GraphicsPayload};
use crate::screen::{Clipboard, Link, MouseModes};
const OSC_CAPTURE_MAX: usize = 64 * 1024;
const LINK_HISTORY: usize = 64;
const LINK_LABEL_MAX: usize = 4 * 1024;
pub(crate) fn decode_base64(input: &[u8]) -> Option<Vec<u8>> {
fn value(b: u8) -> Option<u32> {
match b {
b'A'..=b'Z' => Some(u32::from(b - b'A')),
b'a'..=b'z' => Some(u32::from(b - b'a') + 26),
b'0'..=b'9' => Some(u32::from(b - b'0') + 52),
b'+' => Some(62),
b'/' => Some(63),
_ => None,
}
}
let body = input.strip_suffix(b"==").map_or_else(
|| input.strip_suffix(b"=").unwrap_or(input),
|stripped| stripped,
);
let pad = input.len() - body.len();
if pad > 0 && input.len() % 4 != 0 {
return None;
}
if body.len() % 4 == 1 {
return None; }
let mut out = Vec::with_capacity(body.len() / 4 * 3 + 2);
for quantum in body.chunks(4) {
let mut bits = 0u32;
for &b in quantum {
bits = (bits << 6) | value(b)?;
}
let carried = match quantum.len() {
4 => 3,
3 => 2,
_ => 1,
};
bits <<= 6 * (4 - quantum.len());
for i in 0..carried {
#[allow(clippy::cast_possible_truncation)]
out.push((bits >> (16 - 8 * i)) as u8);
}
}
Some(out)
}
const TAB_INTERVAL: u16 = 8;
fn default_stop(col: u16) -> bool {
col % TAB_INTERVAL == 0
}
#[derive(Debug)]
struct TabStops {
stops: Vec<bool>,
}
impl TabStops {
fn new(cols: u16) -> Self {
Self {
stops: (0..cols).map(default_stop).collect(),
}
}
fn last_column(&self) -> u16 {
u16::try_from(self.stops.len())
.unwrap_or(u16::MAX)
.saturating_sub(1)
}
fn set_cols(&mut self, cols: u16) {
let old = u16::try_from(self.stops.len()).unwrap_or(u16::MAX);
self.stops.resize(usize::from(cols), false);
for col in old..cols {
self.stops[usize::from(col)] = default_stop(col);
}
}
fn reset(&mut self) {
let cols = u16::try_from(self.stops.len()).unwrap_or(u16::MAX);
for col in 0..cols {
self.stops[usize::from(col)] = default_stop(col);
}
}
fn set(&mut self, col: u16) {
if let Some(stop) = self.stops.get_mut(usize::from(col)) {
*stop = true;
}
}
fn clear(&mut self, col: u16) {
if let Some(stop) = self.stops.get_mut(usize::from(col)) {
*stop = false;
}
}
fn clear_all(&mut self) {
self.stops.fill(false);
}
fn steps_bound(&self) -> u16 {
u16::try_from(self.stops.len()).unwrap_or(u16::MAX)
}
fn forward(&self, col: u16, count: u16) -> u16 {
let last = self.last_column();
let mut at = col.min(last);
for _ in 0..count.min(self.steps_bound()) {
match ((at.saturating_add(1))..=last).find(|&c| self.stops[usize::from(c)]) {
Some(next) => at = next,
None => return last,
}
}
at
}
fn back(&self, col: u16, count: u16) -> u16 {
let mut at = col.min(self.last_column());
for _ in 0..count.min(self.steps_bound()) {
match (0..at).rev().find(|&c| self.stops[usize::from(c)]) {
Some(prev) => at = prev,
None => return 0,
}
}
at
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum TabOp {
Set,
ClearAtCursor,
ClearAll,
Forward(u16),
Back(u16),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Charset {
Ascii,
Uk,
DecSpecialGraphics,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct SavedCharsets {
g0: Charset,
g1: Charset,
g2: Charset,
g3: Charset,
shifted_out: bool,
}
fn dec_special_graphics(b: u8) -> Option<&'static str> {
Some(match b {
b'_' => " ", b'`' => "\u{25c6}", b'a' => "\u{2592}", b'b' => "\u{2409}", b'c' => "\u{240c}", b'd' => "\u{240d}", b'e' => "\u{240a}", b'f' => "\u{b0}", b'g' => "\u{b1}", b'h' => "\u{2424}", b'i' => "\u{240b}", b'j' => "\u{2518}", b'k' => "\u{2510}", b'l' => "\u{250c}", b'm' => "\u{2514}", b'n' => "\u{253c}", b'o' => "\u{23ba}", b'p' => "\u{23bb}", b'q' => "\u{2500}", b'r' => "\u{23bc}", b's' => "\u{23bd}", b't' => "\u{251c}", b'u' => "\u{2524}", b'v' => "\u{2534}", b'w' => "\u{252c}", b'x' => "\u{2502}", b'y' => "\u{2264}", b'z' => "\u{2265}", b'{' => "\u{3c0}", b'|' => "\u{2260}", b'}' => "\u{a3}", b'~' => "\u{b7}", _ => return None,
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SingleShift {
G2,
G3,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum State {
Ground,
Esc,
EscIntermediate,
Csi,
Osc,
Dcs,
OscEsc,
DcsEsc,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum SeqEvent {
None,
SyncBegin,
SyncEnd,
SoftReset,
Query(Query),
Graphics(Box<GraphicsPayload>),
Tabs(TabOp),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Query {
CursorPosition {
private: bool,
},
OperatingStatus,
PrimaryDa,
SecondaryDa,
TextAreaSize,
OscColor {
code: u8,
st_terminated: bool,
},
RequestMode(u32),
WindowSizePixels,
CellSizePixels,
KittyGraphics {
id: Option<u32>,
shape: String,
},
RequestTermcap {
names: String,
shape: String,
},
Unanswerable(String),
}
fn kitty_key(control: &[u8], key: &[u8]) -> Option<u32> {
control
.split(|&b| b == b',')
.find_map(|pair| pair.strip_prefix(key))
.and_then(|digits| std::str::from_utf8(digits).ok())
.and_then(|digits| digits.parse().ok())
}
fn key_is(control: &[u8], key: &[u8], value: &[u8]) -> bool {
control.split(|&b| b == b',').any(|pair| {
pair.strip_prefix(key)
.and_then(|rest| rest.strip_prefix(b"="))
== Some(value)
})
}
fn printable(bytes: &[u8]) -> String {
let mut out = String::new();
for &b in bytes {
match b {
0x1b => out.push_str("^["),
0x07 => out.push_str("^G"),
0x20..=0x7e => out.push(b as char),
_ => out.push_str(&format!("\\x{b:02x}")),
}
}
out
}
fn mouse_bit(mode: u32) -> u8 {
match mode {
9 => 1,
1000 => 2,
1002 => 4,
1003 => 8,
_ => 0,
}
}
#[derive(Debug)]
pub(crate) struct SeqTracker {
state: State,
utf8_remaining: u8,
sync_update: bool,
csi_prefix: u8,
csi_intermediate: u8,
csi_invalid: bool,
csi_first: bool,
csi_param: u32,
csi_has_digits: bool,
csi_first_param: u32,
csi_param_count: u8,
csi_saw_2026: bool,
csi_saw_1004: bool,
csi_saw_irm: bool,
csi_saw_mouse: u8,
seq_buf: [u8; 24],
seq_len: u8,
osc_buf: Vec<u8>,
osc_truncated: bool,
title: Arc<str>,
clipboard: Option<Arc<Clipboard>>,
links: Arc<Vec<Link>>,
link_label: Vec<u8>,
link_open: bool,
link_label_truncated: bool,
bells: u64,
counts: GraphicsCounts,
building: GraphicsBuilder,
capture: usize,
frame_printable: u32,
focus_events: bool,
mouse_tracking: u8,
cursor_style: Option<u8>,
esc_intermediate: u8,
g0: Charset,
g1: Charset,
g2: Charset,
g3: Charset,
shifted_out: bool,
single_shift: Option<SingleShift>,
saved_charsets: Option<SavedCharsets>,
dcs_introducer: u8,
dcs_final: u8,
dcs_intermediate: u8,
dcs_data_len: u64,
dcs_head: [u8; 128],
dcs_head_len: u8,
dcs_body: Vec<u8>,
dcs_body_full: bool,
tabs: TabStops,
insert_mode: bool,
}
impl SeqTracker {
pub(crate) fn new(capture: usize, cols: u16) -> Self {
Self {
state: State::Ground,
utf8_remaining: 0,
sync_update: false,
csi_prefix: 0,
csi_intermediate: 0,
csi_invalid: false,
csi_first: true,
csi_param: 0,
csi_has_digits: false,
csi_first_param: 0,
csi_param_count: 0,
csi_saw_2026: false,
csi_saw_1004: false,
csi_saw_irm: false,
csi_saw_mouse: 0,
seq_buf: [0; 24],
seq_len: 0,
osc_buf: Vec::new(),
osc_truncated: false,
title: Arc::from(""),
clipboard: None,
links: Arc::new(Vec::new()),
link_label: Vec::new(),
link_open: false,
link_label_truncated: false,
bells: 0,
counts: GraphicsCounts::default(),
building: GraphicsBuilder::default(),
capture,
frame_printable: 0,
focus_events: false,
mouse_tracking: 0,
cursor_style: None,
esc_intermediate: 0,
g0: Charset::Ascii,
g1: Charset::Ascii,
g2: Charset::Ascii,
g3: Charset::Ascii,
shifted_out: false,
single_shift: None,
saved_charsets: None,
dcs_introducer: 0,
dcs_final: 0,
dcs_intermediate: 0,
dcs_data_len: 0,
dcs_head: [0; 128],
dcs_head_len: 0,
dcs_body: Vec::new(),
dcs_body_full: true,
tabs: TabStops::new(cols),
insert_mode: false,
}
}
pub(crate) fn set_cols(&mut self, cols: u16) {
self.tabs.set_cols(cols);
}
pub(crate) fn tab_op(&mut self, op: TabOp, col: u16) -> Option<u16> {
match op {
TabOp::Set => {
self.tabs.set(col);
None
}
TabOp::ClearAtCursor => {
self.tabs.clear(col);
None
}
TabOp::ClearAll => {
self.tabs.clear_all();
None
}
TabOp::Forward(count) => Some(self.tabs.forward(col, count)),
TabOp::Back(count) => Some(self.tabs.back(col, count)),
}
}
#[cfg(test)]
pub(crate) fn feed(&mut self, bytes: &[u8]) {
for &b in bytes {
self.step(b);
}
}
pub(crate) fn mid_sequence(&self) -> bool {
self.state != State::Ground || self.utf8_remaining > 0
}
pub(crate) fn in_sync_update(&self) -> bool {
self.sync_update
}
pub(crate) fn clipboard(&self) -> Option<Arc<Clipboard>> {
self.clipboard.clone()
}
pub(crate) fn title(&self) -> Arc<str> {
Arc::clone(&self.title)
}
pub(crate) fn bells(&self) -> u64 {
self.bells
}
pub(crate) fn links(&self) -> Arc<Vec<Link>> {
Arc::clone(&self.links)
}
fn open_link(&mut self, params: &[u8], uri: &[u8]) {
self.close_link();
let id = params
.split(|&b| b == b':')
.find_map(|pair| pair.strip_prefix(b"id="))
.map(String::from_utf8_lossy);
let uri = String::from_utf8_lossy(uri);
let links = Arc::make_mut(&mut self.links);
links.push(Link::open(&uri, id.as_deref()));
while links.len() > LINK_HISTORY {
links.remove(0);
}
self.link_open = true;
self.link_label.clear();
self.link_label_truncated = false;
}
fn close_link(&mut self) {
if !self.link_open {
return;
}
self.link_open = false;
let raw = std::mem::take(&mut self.link_label);
let label = if self.link_label_truncated {
None
} else {
String::from_utf8(raw).ok()
};
self.link_label_truncated = false;
if let Some(link) = Arc::make_mut(&mut self.links).last_mut() {
link.close(label);
}
}
pub(crate) fn graphics(&self) -> GraphicsCounts {
self.counts
}
pub(crate) fn focus_events(&self) -> bool {
self.focus_events
}
pub(crate) fn mouse_tracking(&self) -> MouseModes {
MouseModes::from_bits(self.mouse_tracking)
}
pub(crate) fn cursor_style(&self) -> Option<u8> {
self.cursor_style
}
pub(crate) fn take_frame_printable(&mut self) -> u32 {
std::mem::replace(&mut self.frame_printable, 0)
}
pub(crate) fn charset_glyph(&self, b: u8) -> Option<&'static str> {
if self.state != State::Ground {
return None;
}
match self.invoked_charset() {
Charset::Ascii => None,
Charset::Uk => (b == b'#').then_some("\u{a3}"),
Charset::DecSpecialGraphics => dec_special_graphics(b),
}
}
fn invoked_charset(&self) -> Charset {
match self.single_shift {
Some(SingleShift::G2) => self.g2,
Some(SingleShift::G3) => self.g3,
None => {
if self.shifted_out {
self.g1
} else {
self.g0
}
}
}
}
fn reset_charsets(&mut self) {
self.g0 = Charset::Ascii;
self.g1 = Charset::Ascii;
self.g2 = Charset::Ascii;
self.g3 = Charset::Ascii;
self.shifted_out = false;
self.single_shift = None;
}
fn soft_reset(&mut self) {
self.reset_charsets();
self.saved_charsets = None;
self.cursor_style = None;
self.focus_events = false;
self.mouse_tracking = 0;
self.insert_mode = false;
self.tabs.reset();
}
pub(crate) fn insert_mode(&self) -> bool {
self.insert_mode
}
fn designate(&mut self, final_byte: u8) {
let set = match final_byte {
b'0' => Charset::DecSpecialGraphics,
b'A' => Charset::Uk,
_ => Charset::Ascii,
};
match self.esc_intermediate {
b'(' => self.g0 = set,
b')' => self.g1 = set,
b'*' => self.g2 = set,
b'+' => self.g3 = set,
_ => {}
}
}
fn reset_dcs_scanner(&mut self, introducer: u8) {
self.dcs_introducer = introducer;
self.dcs_final = 0;
self.dcs_intermediate = 0;
self.dcs_data_len = 0;
self.dcs_head_len = 0;
self.dcs_body.clear();
self.dcs_body_full = true;
}
fn reset_csi_scanner(&mut self) {
self.csi_prefix = 0;
self.csi_intermediate = 0;
self.csi_invalid = false;
self.csi_first = true;
self.csi_param = 0;
self.csi_has_digits = false;
self.csi_first_param = 0;
self.csi_param_count = 0;
self.csi_saw_2026 = false;
self.csi_saw_1004 = false;
self.csi_saw_irm = false;
self.csi_saw_mouse = 0;
}
fn push_seq(&mut self, b: u8) {
if usize::from(self.seq_len) < self.seq_buf.len() {
self.seq_buf[usize::from(self.seq_len)] = b;
self.seq_len += 1;
}
}
fn push_osc(&mut self, b: u8) {
if self.osc_buf.len() < OSC_CAPTURE_MAX {
self.osc_buf.push(b);
} else {
self.osc_truncated = true;
}
}
fn seq_printable(&self) -> String {
printable(&self.seq_buf[..usize::from(self.seq_len)])
}
fn end_csi_param(&mut self) {
if self.csi_param == 2026 {
self.csi_saw_2026 = true;
}
if self.csi_param == 1004 {
self.csi_saw_1004 = true;
}
if self.csi_param == 4 {
self.csi_saw_irm = true;
}
self.csi_saw_mouse |= mouse_bit(self.csi_param);
if self.csi_param_count == 0 {
self.csi_first_param = self.csi_param;
}
self.csi_param_count = self.csi_param_count.saturating_add(1);
self.csi_param = 0;
self.csi_has_digits = false;
}
fn scan_csi_byte(&mut self, b: u8) {
match b {
b'?' | b'>' | b'=' if self.csi_first => self.csi_prefix = b,
b'0'..=b'9' => {
self.csi_param = self
.csi_param
.saturating_mul(10)
.saturating_add(u32::from(b - b'0'));
self.csi_has_digits = true;
}
b';' => self.end_csi_param(),
b'$' | b' ' | b'!' => self.csi_intermediate = b,
_ => self.csi_invalid = true,
}
self.csi_first = false;
}
fn csi_final(&mut self, b: u8) -> SeqEvent {
if self.csi_invalid {
return SeqEvent::None;
}
if self.csi_has_digits {
self.end_csi_param();
}
let params_empty = self.csi_param_count == 0;
let single = |v: u32| self.csi_param_count == 1 && self.csi_first_param == v;
if self.csi_intermediate == b'$' {
return match (self.csi_prefix, b) {
(b'?', b'p') if self.csi_param_count == 1 => {
SeqEvent::Query(Query::RequestMode(self.csi_first_param))
}
(_, b'p' | b'y') => SeqEvent::Query(Query::Unanswerable(self.seq_printable())),
_ => SeqEvent::None,
};
}
if self.csi_intermediate == b'!' {
if b == b'p' && self.csi_prefix == 0 && params_empty {
self.soft_reset();
return SeqEvent::SoftReset;
}
return SeqEvent::None;
}
if self.csi_intermediate == b' ' {
if b == b'q' && self.csi_prefix == 0 && self.csi_param_count <= 1 {
let ps = if self.csi_param_count == 0 {
0
} else {
self.csi_first_param
};
if let Ok(style @ 0..=6) = u8::try_from(ps) {
self.cursor_style = Some(style);
}
}
return SeqEvent::None;
}
if self.csi_prefix == b'?' && self.csi_saw_1004 {
match b {
b'h' => self.focus_events = true,
b'l' => self.focus_events = false,
_ => {}
}
}
if self.csi_prefix == b'?' && self.csi_saw_mouse != 0 {
match b {
b'h' => self.mouse_tracking |= self.csi_saw_mouse,
b'l' => self.mouse_tracking &= !self.csi_saw_mouse,
_ => {}
}
}
if self.csi_prefix == b'?' && self.csi_saw_2026 {
match b {
b'h' => {
self.sync_update = true;
self.frame_printable = 0;
return SeqEvent::SyncBegin;
}
b'l' if self.sync_update => {
self.sync_update = false;
return SeqEvent::SyncEnd;
}
_ => {}
}
}
if self.csi_prefix == 0 && self.csi_saw_irm {
match b {
b'h' => self.insert_mode = true,
b'l' => self.insert_mode = false,
_ => {}
}
}
if self.csi_prefix == 0 {
let ps = if params_empty {
0
} else {
self.csi_first_param
};
let count = u16::try_from(ps).unwrap_or(u16::MAX).max(1);
match b {
b'g' if self.csi_param_count <= 1 => {
return match ps {
0 => SeqEvent::Tabs(TabOp::ClearAtCursor),
3 => SeqEvent::Tabs(TabOp::ClearAll),
_ => SeqEvent::None,
};
}
b'I' if self.csi_param_count <= 1 => {
return SeqEvent::Tabs(TabOp::Forward(count));
}
b'Z' if self.csi_param_count <= 1 => {
return SeqEvent::Tabs(TabOp::Back(count));
}
_ => {}
}
}
let query = match (self.csi_prefix, b) {
(0, b'n') if single(6) => Some(Query::CursorPosition { private: false }),
(b'?', b'n') if single(6) => Some(Query::CursorPosition { private: true }),
(0, b'n') if single(5) => Some(Query::OperatingStatus),
(0, b'c') if params_empty || single(0) => Some(Query::PrimaryDa),
(b'>', b'c') if params_empty || single(0) => Some(Query::SecondaryDa),
(0, b't') if single(18) => Some(Query::TextAreaSize),
(0, b't') if single(14) => Some(Query::WindowSizePixels),
(0, b't') if single(16) => Some(Query::CellSizePixels),
(_, b'n') | (b'=', b'c') => Some(Query::Unanswerable(self.seq_printable())),
(b'?', b'u') if params_empty => {
Some(Query::Unanswerable(self.seq_printable()))
}
(0, b't')
if matches!(self.csi_first_param, 11 | 13 | 19 | 20 | 21)
&& self.csi_param_count == 1 =>
{
Some(Query::Unanswerable(self.seq_printable()))
}
_ => None,
};
query.map_or(SeqEvent::None, SeqEvent::Query)
}
fn string_final(&mut self, was_osc: bool, st_terminated: bool) -> SeqEvent {
if was_osc {
match self.osc_buf.as_slice() {
b"10;?" => {
return SeqEvent::Query(Query::OscColor {
code: 10,
st_terminated,
})
}
b"11;?" => {
return SeqEvent::Query(Query::OscColor {
code: 11,
st_terminated,
})
}
b"12;?" => return SeqEvent::Query(Query::Unanswerable(self.seq_printable())),
content
if content.ends_with(b"?")
&& (content.starts_with(b"4;") || content.starts_with(b"52;")) =>
{
return SeqEvent::Query(Query::Unanswerable(self.seq_printable()));
}
content => {
if let Some(title) = content
.strip_prefix(b"0;")
.or_else(|| content.strip_prefix(b"2;"))
{
self.title = Arc::from(String::from_utf8_lossy(title));
} else if let Some(rest) = content.strip_prefix(b"8;") {
if let Some(sep) = rest.iter().position(|&b| b == b';') {
if !self.osc_truncated {
let params = rest[..sep].to_vec();
let uri = rest[sep + 1..].to_vec();
if uri.is_empty() {
self.close_link();
} else {
self.open_link(¶ms, &uri);
}
}
}
} else if let Some(rest) = content.strip_prefix(b"52;") {
if let Some(sep) = rest.iter().position(|&b| b == b';') {
let targets = String::from_utf8_lossy(&rest[..sep]).into_owned();
let payload = &rest[sep + 1..];
let text = if self.osc_truncated {
None
} else {
decode_base64(payload)
.and_then(|bytes| String::from_utf8(bytes).ok())
};
self.clipboard = Some(Arc::new(Clipboard::new(&targets, text)));
}
}
return SeqEvent::None;
}
}
}
if self.dcs_introducer == b'_'
&& (self.dcs_head.first() == Some(&b'G') || self.building.in_progress())
{
let control = self.dcs_control_block().to_vec();
let keys = control.strip_prefix(b"G").unwrap_or(&control);
let is_query = keys.split(|&b| b == b',').any(|pair| pair == b"a=q");
if !is_query {
self.building.kitty(keys);
let at = self.dcs_payload_start();
self.building.chunk(
self.dcs_data_len,
&self.dcs_body[at..],
self.dcs_body_full,
self.capture,
);
if key_is(keys, b"m", b"1") {
return SeqEvent::None;
}
return match self.building.finish() {
Some(payload) => {
self.counts.record(&payload);
SeqEvent::Graphics(Box::new(payload))
}
None => SeqEvent::None,
};
}
let id = kitty_key(keys, b"i=");
return SeqEvent::Query(Query::KittyGraphics {
id,
shape: self.seq_printable(),
});
}
if self.dcs_introducer == b'P' && self.dcs_final == b'q' && self.dcs_intermediate == 0 {
self.building.sixel();
let at = self.dcs_payload_start();
self.building.chunk(
self.dcs_data_len,
&self.dcs_body[at..],
self.dcs_body_full,
self.capture,
);
return match self.building.finish() {
Some(payload) => {
self.counts.record(&payload);
SeqEvent::Graphics(Box::new(payload))
}
None => SeqEvent::None,
};
}
if self.dcs_introducer == b'P' && self.dcs_intermediate == b'+' && self.dcs_final == b'q' {
let head = &self.dcs_head[..usize::from(self.dcs_head_len)];
let names = head
.iter()
.position(|&b| b == b'q')
.map(|at| String::from_utf8_lossy(&head[at + 1..]).into_owned())
.unwrap_or_default();
return SeqEvent::Query(Query::RequestTermcap {
names,
shape: self.seq_printable(),
});
}
let body = &self.seq_buf[..usize::from(self.seq_len)];
if matches!(body.get(2..4), Some(b"+q" | b"$q")) {
return SeqEvent::Query(Query::Unanswerable(self.seq_printable()));
}
SeqEvent::None
}
fn dcs_payload_start(&self) -> usize {
let separator = if self.dcs_introducer == b'_' {
b';'
} else {
self.dcs_final
};
self.dcs_body.iter().position(|&b| b == separator).map_or(
self.dcs_body.len(),
|at| at + 1,
)
}
fn dcs_control_block(&self) -> &[u8] {
let head = &self.dcs_head[..usize::from(self.dcs_head_len)];
match head.iter().position(|&b| b == b';') {
Some(sep) => &head[..sep],
None => head,
}
}
fn push_dcs(&mut self, b: u8) {
self.dcs_data_len += 1;
if self.dcs_introducer != b'_' && self.dcs_final == 0 {
match b {
0x30..=0x3f => {}
0x20..=0x2f => self.dcs_intermediate = b,
0x40..=0x7e => self.dcs_final = b,
_ => {}
}
}
if usize::from(self.dcs_head_len) < self.dcs_head.len() {
self.dcs_head[usize::from(self.dcs_head_len)] = b;
self.dcs_head_len += 1;
}
if self.dcs_body.len() < self.capture {
self.dcs_body.push(b);
} else {
self.dcs_body_full = false;
}
}
pub(crate) fn step(&mut self, b: u8) -> SeqEvent {
if self.state == State::Ground {
if b == 0x1b {
self.seq_len = 0;
self.push_seq(b);
}
} else {
self.push_seq(b);
}
self.transition(b)
}
fn transition(&mut self, b: u8) -> SeqEvent {
const ESC: u8 = 0x1b;
const CAN: u8 = 0x18;
const SUB: u8 = 0x1a;
const BEL: u8 = 0x07;
const HT: u8 = 0x09;
const SO: u8 = 0x0e;
const SI: u8 = 0x0f;
let mut event = SeqEvent::None;
self.state = match self.state {
State::Ground => {
if b == ESC {
self.utf8_remaining = 0;
State::Esc
} else {
if b == BEL && self.utf8_remaining == 0 {
self.bells = self.bells.saturating_add(1);
}
match b {
SO => self.shifted_out = true,
SI => self.shifted_out = false,
HT => event = SeqEvent::Tabs(TabOp::Forward(1)),
_ => {}
}
let printable = b >= 0x20 && b != 0x7f;
if printable && self.utf8_remaining == 0 {
self.frame_printable = self.frame_printable.saturating_add(1);
}
if printable && self.link_open {
let drawn: &[u8] = match self.charset_glyph(b) {
Some(glyph) => glyph.as_bytes(),
None => std::slice::from_ref(&b),
};
for &drawn in drawn {
if self.link_label.len() < LINK_LABEL_MAX {
self.link_label.push(drawn);
} else {
self.link_label_truncated = true;
}
}
}
let utf8_continuation = (0x80..=0xbf).contains(&b);
if b >= 0x20 && b != 0x7f && !utf8_continuation {
self.single_shift = None;
}
self.track_utf8(b);
State::Ground
}
}
State::Esc => match b {
b'[' => {
self.reset_csi_scanner();
State::Csi
}
b']' => {
self.osc_buf.clear();
self.osc_truncated = false;
State::Osc
}
b'P' | b'X' | b'^' | b'_' => {
self.reset_dcs_scanner(b);
State::Dcs
}
0x20..=0x2f => {
self.esc_intermediate = b;
State::EscIntermediate
}
ESC => State::Esc,
CAN | SUB => State::Ground,
b'c' => {
self.cursor_style = None;
self.reset_charsets();
self.saved_charsets = None;
self.mouse_tracking = 0;
self.insert_mode = false;
self.tabs.reset();
self.close_link();
State::Ground
}
b'7' => {
self.saved_charsets = Some(SavedCharsets {
g0: self.g0,
g1: self.g1,
g2: self.g2,
g3: self.g3,
shifted_out: self.shifted_out,
});
State::Ground
}
b'8' => {
match self.saved_charsets {
Some(saved) => {
self.g0 = saved.g0;
self.g1 = saved.g1;
self.g2 = saved.g2;
self.g3 = saved.g3;
self.shifted_out = saved.shifted_out;
}
None => self.reset_charsets(),
}
State::Ground
}
b'H' => {
event = SeqEvent::Tabs(TabOp::Set);
State::Ground
}
b'N' => {
self.single_shift = Some(SingleShift::G2);
State::Ground
}
b'O' => {
self.single_shift = Some(SingleShift::G3);
State::Ground
}
_ => State::Ground,
},
State::EscIntermediate => match b {
0x20..=0x2f => {
self.esc_intermediate = 0;
State::EscIntermediate
}
ESC => State::Esc,
CAN | SUB => State::Ground,
0x30..=0x7e => {
self.designate(b);
State::Ground
}
_ => State::Ground,
},
State::Csi => match b {
0x40..=0x7e => {
event = self.csi_final(b);
State::Ground
}
ESC => State::Esc,
CAN | SUB => State::Ground,
_ => {
self.scan_csi_byte(b);
State::Csi
}
},
State::Osc => match b {
BEL => {
event = self.string_final(true, false);
State::Ground
}
ESC => State::OscEsc,
CAN | SUB => State::Ground,
_ => {
self.push_osc(b);
State::Osc
}
},
State::Dcs => match b {
ESC => State::DcsEsc,
CAN | SUB => State::Ground,
_ => {
self.push_dcs(b);
State::Dcs }
},
State::OscEsc => match b {
b'\\' => {
event = self.string_final(true, true); State::Ground
}
ESC => State::OscEsc,
_ => {
self.state = State::Esc;
return self.transition(b);
}
},
State::DcsEsc => match b {
b'\\' => {
event = self.string_final(false, true);
State::Ground
}
ESC => State::DcsEsc,
_ => {
self.state = State::Esc;
return self.transition(b);
}
},
};
event
}
fn track_utf8(&mut self, b: u8) {
if self.utf8_remaining > 0 && (0x80..=0xbf).contains(&b) {
self.utf8_remaining -= 1;
return;
}
self.utf8_remaining = match b {
0xc2..=0xdf => 1,
0xe0..=0xef => 2,
0xf0..=0xf4 => 3,
_ => 0,
};
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graphics::GraphicsFormat;
const TEST_COLS: u16 = 80;
fn fed(bytes: &[u8]) -> SeqTracker {
let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, TEST_COLS);
t.feed(bytes);
t
}
#[test]
fn only_a_bel_in_ground_state_is_a_bell() {
assert_eq!(fed(b"a\x07b").bells(), 1);
assert_eq!(fed(b"\x07\x07\x07").bells(), 3, "a count, not a flag");
assert_eq!(fed(b"plain").bells(), 0);
let t = fed(b"\x1b]0;my app\x07");
assert_eq!(t.bells(), 0);
assert_eq!(&*t.title(), "my app");
assert_eq!(fed(b"\x1bPq\x07\x07\x1b\\").bells(), 0);
assert_eq!(fed(b"\x1b]0;t\x07\x07").bells(), 1);
}
#[test]
fn the_capture_bound_holds_across_a_chunked_transmission() {
let mut tracker = SeqTracker::new(40, TEST_COLS);
let mut wire: Vec<u8> = b"\x1b_Ga=T,f=32,s=4,v=4,m=1;AAAAAAAA\x1b\\".to_vec();
for _ in 0..8 {
wire.extend_from_slice(b"\x1b_Gm=1;BBBBBBBB\x1b\\");
}
wire.extend_from_slice(b"\x1b_Gm=0;CCCCCCCC\x1b\\");
let mut seen = None;
for &byte in &wire {
if let SeqEvent::Graphics(payload) = tracker.step(byte) {
seen = Some(*payload);
}
}
let payload = seen.expect("a payload");
assert_eq!(payload.chunks(), 10);
assert_eq!(
payload.data(),
None,
"80 bytes must not be kept under a 40-byte bound"
);
assert_eq!(tracker.graphics().kitty, 1, "and it is still one image");
}
fn payloads(bytes: &[u8]) -> Vec<GraphicsPayload> {
let mut tracker = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, TEST_COLS);
let mut out = Vec::new();
for &byte in bytes {
if let SeqEvent::Graphics(payload) = tracker.step(byte) {
out.push(*payload);
}
}
out
}
#[test]
fn graphics_payloads_are_counted_by_protocol() {
let kitty = fed(b"\x1b_Gf=24,s=1,v=1,a=T;AAAABBBB\x1b\\");
assert_eq!(kitty.graphics().kitty, 1);
assert_eq!(kitty.graphics().sixel, 0);
assert_eq!(kitty.graphics().bytes, 26);
assert_eq!(fed(b"\x1bPq~~\x1b\\").graphics().bytes, 3, "q~~");
let sixel = fed(b"\x1bPq#0;2;0;0;0#0~~-~~\x1b\\");
assert_eq!(sixel.graphics().sixel, 1);
assert_eq!(sixel.graphics().kitty, 0);
assert_eq!(fed(b"\x1bP0;1;0q~~\x1b\\").graphics().sixel, 1);
let termcap = fed(b"\x1bP+q544e\x1b\\").graphics();
assert_eq!((termcap.kitty, termcap.sixel), (0, 0), "XTGETTCAP");
let decrqss = fed(b"\x1bP$qm\x1b\\").graphics();
assert_eq!((decrqss.kitty, decrqss.sixel), (0, 0), "DECRQSS");
let both = fed(b"\x1b_Ga=T;AA\x1b\\\x1bPq~\x1b\\\x1b_Ga=T;BB\x1b\\");
assert_eq!((both.graphics().kitty, both.graphics().sixel), (2, 1));
}
#[test]
fn a_chunked_kitty_transmission_is_one_image() {
let wire = b"\x1b_Ga=T,f=32,s=2,v=2,i=1,m=1;AAAA\x1b\\ \x1b_Gm=1;BBBB\x1b\\\x1b_Gm=0;CCCC\x1b\\";
let counts = fed(wire).graphics();
assert_eq!(counts.kitty, 1, "one image, three escapes");
let payloads = payloads(wire);
assert_eq!(payloads.len(), 1);
assert_eq!(payloads[0].chunks(), 3);
assert_eq!(payloads[0].data(), Some(&b"AAAABBBBCCCC"[..]));
assert_eq!(payloads[0].size(), Some((2, 2)));
assert_eq!(payloads[0].id(), Some(1));
assert_eq!(counts.bytes, payloads[0].bytes());
}
#[test]
fn a_delete_is_counted_apart_from_the_images() {
let counts = fed(b"\x1b_Ga=T,f=32,s=1,v=1;AA\x1b\\\x1b_Ga=d,d=I,i=1,q=2\x1b\\").graphics();
assert_eq!(counts.kitty, 1, "one image");
assert_eq!(counts.deletes, 1, "and one teardown");
assert!(counts.bytes > 22);
}
#[test]
fn a_payload_past_the_capture_bound_is_counted_and_not_kept() {
let mut tracker = SeqTracker::new(8, TEST_COLS);
let mut seen = None;
for &byte in b"\x1b_Ga=T,f=32,s=4,v=4;AAAABBBBCCCCDDDD\x1b\\" {
if let SeqEvent::Graphics(payload) = tracker.step(byte) {
seen = Some(*payload);
}
}
let payload = seen.expect("a payload");
assert_eq!(payload.data(), None, "not kept");
assert_eq!(payload.size(), Some((4, 4)), "but still described");
assert_eq!(tracker.graphics().kitty, 1, "and still counted");
}
#[test]
fn a_payload_carries_the_data_and_not_the_framing() {
let kitty = payloads(b"\x1b_Ga=T,f=24,s=1,v=1;QUJD\x1b\\");
assert_eq!(kitty[0].data(), Some(&b"QUJD"[..]));
assert_eq!(kitty[0].format(), GraphicsFormat::Rgb);
let sixel = payloads(b"\x1bP0;1;0q\"1;1;2;6#0;2;100;0;0~~\x1b\\");
assert_eq!(sixel[0].data(), Some(&b"\"1;1;2;6#0;2;100;0;0~~"[..]));
assert_eq!(sixel[0].size(), Some((2, 6)));
}
#[test]
fn the_kitty_graphics_query_is_classified() {
let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, TEST_COLS);
let mut events = Vec::new();
for &b in b"\x1b_Gi=1,a=q;\x1b\\" {
events.push(t.step(b));
}
assert!(
events.iter().any(|e| matches!(
e,
SeqEvent::Query(Query::KittyGraphics { id: Some(1), shape })
if shape.contains("_G")
)),
"the query must be classified, with its id, for a reply or a \
timeout note: {events:?}"
);
assert_eq!(t.graphics().kitty, 0);
}
#[test]
fn a_kitty_transmission_is_not_treated_as_a_query() {
let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, TEST_COLS);
let mut events = Vec::new();
for &b in b"\x1b_Gf=24,a=T;QUJD\x1b\\" {
events.push(t.step(b));
}
assert!(
!events.iter().any(|e| matches!(e, SeqEvent::Query(_))),
"a transmit is not a question: {events:?}"
);
assert_eq!(t.graphics().kitty, 1);
}
fn links(bytes: &[u8]) -> Vec<(String, Option<String>, Option<String>, bool)> {
fed(bytes)
.links()
.iter()
.map(|l| {
(
l.uri().to_string(),
l.id().map(str::to_string),
l.label().map(str::to_string),
l.closed(),
)
})
.collect()
}
#[test]
fn an_osc8_span_records_its_uri_and_the_text_it_wrapped() {
let seen = links(b"see \x1b]8;;https://example.invalid/a\x1b\\docs\x1b]8;;\x1b\\ here");
assert_eq!(
seen,
vec![(
"https://example.invalid/a".to_string(),
None,
Some("docs".to_string()),
true
)]
);
}
#[test]
fn a_bel_terminated_span_and_a_multibyte_label_both_survive() {
let seen = links("\x1b]8;;http://x/\x07café\x1b]8;;\x07".as_bytes());
assert_eq!(seen[0].2, Some("café".to_string()));
assert!(seen[0].3);
}
#[test]
fn the_id_parameter_is_kept_so_multi_span_links_can_be_grouped() {
let seen = links(
b"\x1b]8;id=42:x=y;http://x/\x1b\\one\x1b]8;;\x1b\\ \x1b]8;id=42;http://x/\x1b\\two\x1b]8;;\x1b\\",
);
assert_eq!(seen.len(), 2);
assert_eq!(seen[0].1.as_deref(), Some("42"));
assert_eq!(seen[1].1.as_deref(), Some("42"));
assert_eq!(seen[0].2.as_deref(), Some("one"));
assert_eq!(seen[1].2.as_deref(), Some("two"));
}
#[test]
fn only_the_first_separator_ends_the_parameters() {
let seen = links(b"\x1b]8;;http://x/?a=1;b=2\x1b\\t\x1b]8;;\x1b\\");
assert_eq!(seen[0].0, "http://x/?a=1;b=2");
}
#[test]
fn an_unterminated_span_is_reported_open_and_cannot_bleed() {
let mut bytes = b"\x1b]8;;http://x/\x1b\\".to_vec();
bytes.extend(std::iter::repeat_n(b'z', LINK_LABEL_MAX + 100));
let seen = links(&bytes);
assert_eq!(seen.len(), 1);
assert_eq!(seen[0].0, "http://x/");
assert!(!seen[0].3, "the application never closed it");
assert_eq!(seen[0].2, None);
}
#[test]
fn a_label_past_the_bound_is_unknown_rather_than_a_prefix() {
let mut bytes = b"\x1b]8;;http://x/\x1b\\".to_vec();
bytes.extend(std::iter::repeat_n(b'z', LINK_LABEL_MAX + 1));
bytes.extend_from_slice(b"\x1b]8;;\x1b\\");
let seen = links(&bytes);
assert!(seen[0].3, "closed");
assert_eq!(
seen[0].2, None,
"a prefix of the wrong length is a wrong answer"
);
}
#[test]
fn a_new_span_supersedes_one_left_open() {
let seen = links(b"\x1b]8;;http://a/\x1b\\one\x1b]8;;http://b/\x1b\\two\x1b]8;;\x1b\\");
assert_eq!(seen.len(), 2);
assert_eq!(
(seen[0].0.as_str(), seen[0].2.as_deref()),
("http://a/", Some("one"))
);
assert_eq!(
(seen[1].0.as_str(), seen[1].2.as_deref()),
("http://b/", Some("two"))
);
}
#[test]
fn an_osc8_past_the_capture_bound_is_refused_rather_than_truncated() {
let mut bytes = b"\x1b]8;;http://x/".to_vec();
bytes.extend(std::iter::repeat_n(b'a', OSC_CAPTURE_MAX));
bytes.extend_from_slice(b"\x1b\\label\x1b]8;;\x1b\\");
assert!(
links(&bytes).is_empty(),
"a truncated URI must not become a link"
);
}
#[test]
fn a_malformed_osc8_neither_opens_nor_closes() {
let seen = links(b"\x1b]8;http://x/\x1b\\t");
assert!(seen.is_empty());
let seen = links(b"\x1b]8;;http://x/\x1b\\a\x1b]8;junk\x1b\\b\x1b]8;;\x1b\\");
assert_eq!(seen.len(), 1);
assert_eq!(seen[0].2.as_deref(), Some("ab"));
}
#[test]
fn a_label_drawn_in_the_graphics_set_records_the_glyphs() {
let seen = links(b"\x1b(0\x1b]8;;http://x/\x1b\\lqk\x1b]8;;\x1b\\\x1b(B");
assert_eq!(seen[0].2.as_deref(), Some("\u{250c}\u{2500}\u{2510}"));
let seen = links(b"\x1b*0\x1b]8;;http://x/\x1b\\\x1bNl|\x1b]8;;\x1b\\");
assert_eq!(seen[0].2.as_deref(), Some("\u{250c}|"));
}
#[test]
fn control_characters_are_not_part_of_a_label() {
let seen = links(b"\x1b]8;;http://x/\x1b\\a\r\nb\x1b]8;;\x1b\\");
assert_eq!(seen[0].2.as_deref(), Some("ab"));
}
#[test]
fn the_link_log_is_bounded_and_evicts_the_oldest() {
let mut bytes = Vec::new();
for n in 0..LINK_HISTORY + 10 {
bytes
.extend_from_slice(format!("\x1b]8;;http://x/{n}\x1b\\l\x1b]8;;\x1b\\").as_bytes());
}
let seen = links(&bytes);
assert_eq!(seen.len(), LINK_HISTORY);
assert_eq!(seen[0].0, "http://x/10");
assert_eq!(
seen[LINK_HISTORY - 1].0,
format!("http://x/{}", LINK_HISTORY + 9)
);
}
#[test]
fn an_application_that_emits_no_links_reports_none() {
assert!(links(b"plain text\x1b]0;title\x07").is_empty());
}
#[test]
fn decscusr_records_the_parameter_the_application_asked_for() {
assert_eq!(fed(b"hello").cursor_style(), None);
for ps in 0u8..=6 {
let bytes = format!("\x1b[{ps} q");
assert_eq!(
fed(bytes.as_bytes()).cursor_style(),
Some(ps),
"DECSCUSR {ps} was not recorded"
);
}
assert_eq!(fed(b"\x1b[ q").cursor_style(), Some(0));
assert_eq!(fed(b"\x1b[5 q\x1b[2 q").cursor_style(), Some(2));
}
#[test]
fn a_hard_reset_returns_the_cursor_to_the_terminals_default() {
assert_eq!(fed(b"\x1b[5 q").cursor_style(), Some(5));
assert_eq!(fed(b"\x1b[5 q\x1bc").cursor_style(), None);
assert_eq!(fed(b"\x1b[5 q\x1bc\x1b[2 q").cursor_style(), Some(2));
}
#[test]
fn a_hard_reset_closes_an_open_span_and_keeps_the_log() {
let seen = links(b"\x1b]8;;http://x/\x1b\\lab\x1bcafter");
assert_eq!(seen.len(), 1);
assert_eq!(seen[0].0, "http://x/");
assert_eq!(
seen[0].2.as_deref(),
Some("lab"),
"the text before the reset"
);
assert!(seen[0].3, "closed by the reset");
let seen = links(b"\x1b]8;;http://x/\x1b\\a\x1bcbbb");
assert_eq!(seen[0].2.as_deref(), Some("a"));
}
#[test]
fn other_two_character_escapes_do_not_reset_anything() {
for seq in [
&b"\x1b[5 q\x1b7"[..],
b"\x1b[5 q\x1b8",
b"\x1b[5 q\x1bD",
b"\x1b[5 q\x1bM",
] {
assert_eq!(
fed(seq).cursor_style(),
Some(5),
"{seq:?} reset the cursor style"
);
}
}
#[test]
fn an_undefined_or_misshapen_decscusr_leaves_the_last_known_style() {
assert_eq!(fed(b"\x1b[5 q\x1b[7 q").cursor_style(), Some(5));
assert_eq!(fed(b"\x1b[7 q").cursor_style(), None);
assert_eq!(fed(b"\x1b[?5 q").cursor_style(), None);
assert_eq!(fed(b"\x1b[5;2 q").cursor_style(), None);
}
#[test]
fn the_other_space_intermediate_sequences_are_not_cursor_styles() {
for seq in [&b"\x1b[2 @"[..], b"\x1b[2 A", b"\x1b[1 t"] {
let mut t = fed(seq);
assert_eq!(t.cursor_style(), None, "{seq:?} set a cursor style");
assert_eq!(t.step(b'x'), SeqEvent::None);
}
}
#[test]
fn hostile_input_cannot_panic_or_grow_a_buffer_past_its_bound() {
let capture = crate::graphics::DEFAULT_CAPTURE;
let mut tracker = SeqTracker::new(capture, TEST_COLS);
let mut peak = (0usize, 0usize, 0usize, 0usize);
fn observe(t: &SeqTracker, capture: usize, peak: &mut (usize, usize, usize, usize)) {
assert!(t.links.len() <= LINK_HISTORY, "link log overran");
assert!(t.link_label.len() <= LINK_LABEL_MAX, "label overran");
assert!(t.osc_buf.len() <= OSC_CAPTURE_MAX, "osc buffer overran");
assert!(t.dcs_body.len() <= capture, "dcs body overran");
assert!(usize::from(t.seq_len) <= t.seq_buf.len());
assert!(usize::from(t.dcs_head_len) <= t.dcs_head.len());
peak.0 = peak.0.max(t.links.len());
peak.1 = peak.1.max(t.link_label.len());
peak.2 = peak.2.max(t.osc_buf.len());
peak.3 = peak.3.max(t.dcs_body.len());
}
let mut state: u64 = 0x2545_F491_4F6C_DD1D;
let mut rand = move || {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
state
};
const PALETTE: &[u8] =
b"\x1b[]P_^X;:0123456789 qhlmc\\\x07\x18\x1a~?$8abzId=\xff\x80\xc3\n\r\t";
for _ in 0..400_000 {
tracker.step(PALETTE[(rand() as usize) % PALETTE.len()]);
observe(&tracker, capture, &mut peak);
}
let feed = |bytes: &[u8], t: &mut SeqTracker, peak: &mut _| {
for &b in bytes {
t.step(b);
observe(t, capture, peak);
}
};
for n in 0..LINK_HISTORY * 3 {
feed(
format!("\x1b]8;;http://x/{n}\x1b\\L\x1b]8;;\x1b\\").as_bytes(),
&mut tracker,
&mut peak,
);
}
feed(b"\x1b]8;;http://x/\x1b\\", &mut tracker, &mut peak);
feed(&vec![b'L'; LINK_LABEL_MAX * 2], &mut tracker, &mut peak);
feed(b"\x1b]8;;\x1b\\", &mut tracker, &mut peak);
feed(b"\x1b]0;", &mut tracker, &mut peak);
feed(&vec![b'T'; OSC_CAPTURE_MAX * 2], &mut tracker, &mut peak);
feed(b"\x07", &mut tracker, &mut peak);
assert_eq!(peak.0, LINK_HISTORY, "the link log was never filled");
assert_eq!(peak.1, LINK_LABEL_MAX, "the label bound was never reached");
assert_eq!(peak.2, OSC_CAPTURE_MAX, "the osc bound was never reached");
let _ = tracker.title();
let _ = tracker.clipboard();
let _ = tracker.links();
let _ = tracker.cursor_style();
let _ = tracker.graphics();
}
#[test]
fn a_long_well_formed_stream_stays_bounded_and_consistent() {
let mut tracker = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, TEST_COLS);
for n in 0..5_000u32 {
let chunk = format!(
"\x1b]8;id={n};http://example.invalid/{n}\x1b\\label{n}\x1b]8;;\x1b\\\
\x1b]0;title{n}\x07\x1b[{} q text\r\n",
n % 7
);
tracker.feed(chunk.as_bytes());
assert!(tracker.links.len() <= LINK_HISTORY);
}
let links = tracker.links();
assert_eq!(links.len(), LINK_HISTORY, "the log fills and then holds");
assert!(links.iter().all(|l| l.closed() && l.label().is_some()));
assert_eq!(links[LINK_HISTORY - 1].uri(), "http://example.invalid/4999");
assert_eq!(links[LINK_HISTORY - 1].label(), Some("label4999"));
}
fn drawn(bytes: &[u8]) -> String {
let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, TEST_COLS);
let mut out = String::new();
for &b in bytes {
match t.charset_glyph(b) {
Some(glyph) => out.push_str(glyph),
None if t.state == State::Ground && (0x20..0x7f).contains(&b) => {
out.push(b as char);
}
None => {}
}
t.step(b);
}
out
}
#[test]
fn dec_special_graphics_in_g0_translates_the_bytes_it_redefines() {
assert_eq!(drawn(b"lqqqk"), "lqqqk", "ASCII until designated");
assert_eq!(
drawn(b"\x1b(0lqqqk\x1b(B"),
"\u{250c}\u{2500}\u{2500}\u{2500}\u{2510}"
);
assert_eq!(drawn(b"\x1b(0lq\x1b(Blq"), "\u{250c}\u{2500}lq");
assert_eq!(drawn(b"\x1b(0A1 +\x1b(B"), "A1 +");
assert_eq!(drawn(b"\x1b(0\x1b(0"), "");
}
#[test]
fn shift_out_invokes_g1_and_shift_in_returns_to_g0() {
assert_eq!(drawn(b"\x1b)0lqk"), "lqk", "G1 designated but not invoked");
assert_eq!(
drawn(b"\x1b)0\x0elqk\x0flqk"),
"\u{250c}\u{2500}\u{2510}lqk"
);
assert_eq!(drawn(b"\x0elqk"), "lqk");
assert_eq!(drawn(b"\x1b(0q\x0eq\x0fq"), "\u{2500}q\u{2500}");
}
#[test]
fn ss2_invokes_g2_for_one_character() {
assert_eq!(drawn(b"\x1b*0\x1bNl\x1b(B|"), "\u{250c}|");
assert_eq!(
drawn(b"\x1b*0\x1bNll"),
"\u{250c}l",
"the character after the shift is the locked set again"
);
assert_eq!(drawn(b"\x1b*0l"), "l");
assert_eq!(drawn(b"\x1bNlq"), "lq");
}
#[test]
fn ss3_invokes_g3_for_one_character() {
assert_eq!(drawn(b"\x1b+0\x1bOl\x1b(B|"), "\u{250c}|");
assert_eq!(drawn(b"\x1b+0\x1bOll"), "\u{250c}l");
assert_eq!(drawn(b"\x1b+0l"), "l");
}
#[test]
fn a_single_shift_overrides_the_locking_shift_for_one_character() {
assert_eq!(drawn(b"\x1b)0\x1b*B\x0e\x1bNlqk"), "l\u{2500}\u{2510}");
assert_eq!(drawn(b"\x1b(0\x1b*B\x1bNlq"), "l\u{2500}");
}
#[test]
fn a_single_shift_survives_intervening_controls_and_designations() {
assert_eq!(drawn(b"\x1b*0\x1bN\x0fl"), "\u{250c}");
assert_eq!(drawn(b"\x1b*0\x1bN\x7fl"), "\u{250c}");
assert_eq!(drawn(b"\x1b*0\x1bN\x1b(Bl"), "\u{250c}");
assert_eq!(drawn(b"\x1b*0\x1bN\x1b*Bl"), "l");
assert_eq!(drawn(b"\x1bN\x1b*0l"), "\u{250c}");
}
#[test]
fn a_multibyte_character_consumes_a_single_shift() {
for ch in ["汉", "🦀", "é"] {
let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, TEST_COLS);
t.feed(b"\x1b*0\x1bN");
assert_eq!(
t.charset_glyph(b'l'),
Some("\u{250c}"),
"shift pending before {ch}"
);
t.feed(ch.as_bytes());
assert_eq!(
t.charset_glyph(b'l'),
None,
"shift must not survive {ch} and translate the next l"
);
}
}
#[test]
fn a_hard_reset_returns_all_four_sets_to_ascii() {
assert_eq!(drawn(b"\x1b(0\x1b)0\x0eq\x1bcq\x0fq"), "\u{2500}qq");
assert_eq!(drawn(b"\x1b*0\x1bN\x1bc\x1b*0l"), "l");
assert_eq!(drawn(b"\x1b*0\x1bc\x1bNl"), "l");
assert_eq!(drawn(b"\x1b+0\x1bO\x1bc\x1b+0l"), "l");
}
#[test]
fn bytes_inside_sequences_are_never_translated() {
let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, TEST_COLS);
for &b in b"\x1b(0" {
t.step(b);
}
assert_eq!(t.charset_glyph(b'q'), Some("\u{2500}"));
for &b in b"\x1b]0;lqqk" {
assert_eq!(t.charset_glyph(b), None, "inside an OSC: {b:?}");
t.step(b);
}
t.step(0x07);
assert_eq!(&*t.title(), "lqqk", "the title keeps its letters");
for &b in b"\x1b[3" {
assert_eq!(t.charset_glyph(b), None, "inside a CSI: {b:?}");
t.step(b);
}
t.step(b'm');
assert_eq!(t.charset_glyph(b'x'), Some("\u{2502}"), "and ground again");
}
#[test]
fn other_designations_read_as_ascii() {
for seq in [
&b"\x1b(A"[..],
b"\x1b(B",
b"\x1b(1",
b"\x1b*A",
b"\x1b+B",
b"\x1b(2",
b"\x1b(<",
] {
let mut bytes = seq.to_vec();
bytes.push(b'q');
assert_eq!(drawn(&bytes), "q", "{seq:?}");
}
assert_eq!(drawn(b"\x1b(0\x1b%Gq"), "\u{2500}");
assert_eq!(drawn(b"\x1b*A\x1bNq"), "q");
assert_eq!(drawn(b"\x1b+A\x1bOq"), "q");
}
#[test]
fn a_designation_split_across_feeds_still_applies() {
let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, TEST_COLS);
t.feed(b"\x1b(");
assert!(t.mid_sequence());
t.feed(b"0");
assert_eq!(t.charset_glyph(b'l'), Some("\u{250c}"));
let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, TEST_COLS);
t.feed(b"\x1b*");
assert!(t.mid_sequence());
t.feed(b"0\x1bN");
assert!(!t.mid_sequence());
assert_eq!(t.charset_glyph(b'l'), Some("\u{250c}"));
}
#[test]
fn plain_text_is_ground() {
assert!(!fed(b"hello world\r\n").mid_sequence());
}
#[test]
fn split_csi_is_mid_sequence_until_final_byte() {
let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, TEST_COLS);
t.feed(b"\x1b[3");
assert!(t.mid_sequence());
t.feed(b"1");
assert!(t.mid_sequence());
t.feed(b"m");
assert!(!t.mid_sequence());
}
#[test]
fn two_char_escape_completes() {
assert!(!fed(b"\x1b7").mid_sequence()); assert!(fed(b"\x1b").mid_sequence());
}
#[test]
fn esc_intermediate_completes_on_final() {
assert!(fed(b"\x1b(").mid_sequence()); assert!(!fed(b"\x1b(B").mid_sequence());
assert!(fed(b"\x1b*").mid_sequence());
assert!(!fed(b"\x1b*0").mid_sequence());
assert!(!fed(b"\x1bN").mid_sequence());
assert!(!fed(b"\x1bO").mid_sequence());
}
#[test]
fn osc_terminated_by_bel_or_st() {
assert!(fed(b"\x1b]0;title").mid_sequence());
assert!(!fed(b"\x1b]0;title\x07").mid_sequence());
assert!(!fed(b"\x1b]0;title\x1b\\").mid_sequence());
}
#[test]
fn dcs_terminated_by_st_only() {
assert!(fed(b"\x1bPdata").mid_sequence());
assert!(fed(b"\x1bPdata\x07").mid_sequence()); assert!(!fed(b"\x1bPdata\x1b\\").mid_sequence());
}
#[test]
fn esc_inside_string_starts_new_sequence() {
assert!(!fed(b"\x1b]0;title\x1bc").mid_sequence());
assert!(fed(b"\x1b]0;title\x1b[3").mid_sequence());
}
#[test]
fn can_aborts_sequences() {
assert!(!fed(b"\x1b[31\x18").mid_sequence());
}
#[test]
fn sync_update_events_fire_on_2026_set_and_reset() {
let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, TEST_COLS);
let events: Vec<SeqEvent> = b"\x1b[?2026h".iter().map(|&b| t.step(b)).collect();
assert_eq!(*events.last().unwrap(), SeqEvent::SyncBegin);
assert!(t.in_sync_update());
let events: Vec<SeqEvent> = b"\x1b[?2026l".iter().map(|&b| t.step(b)).collect();
assert_eq!(*events.last().unwrap(), SeqEvent::SyncEnd);
assert!(!t.in_sync_update());
}
#[test]
fn sync_2026_is_recognized_anywhere_in_a_multi_mode_list() {
let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, TEST_COLS);
t.feed(b"\x1b[?2026;25h");
assert!(t.in_sync_update());
let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, TEST_COLS);
t.feed(b"\x1b[?25;2026h");
assert!(t.in_sync_update());
}
#[test]
fn lookalike_sequences_do_not_toggle_sync() {
let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, TEST_COLS);
t.feed(b"\x1b[2026h"); assert!(!t.in_sync_update());
t.feed(b"\x1b[?2026m"); assert!(!t.in_sync_update());
t.feed(b"\x1b[?2026:1h"); assert!(!t.in_sync_update());
t.feed(b"\x1b[?20260h"); assert!(!t.in_sync_update());
}
#[test]
fn base64_decodes_or_refuses() {
assert_eq!(
decode_base64(b"dGhlIHRpdGxl").as_deref(),
Some(&b"the title"[..])
);
assert_eq!(decode_base64(b"YQ==").as_deref(), Some(&b"a"[..]));
assert_eq!(decode_base64(b"YWI=").as_deref(), Some(&b"ab"[..]));
assert_eq!(decode_base64(b"YWJj").as_deref(), Some(&b"abc"[..]));
assert_eq!(decode_base64(b"YQ").as_deref(), Some(&b"a"[..]));
assert_eq!(decode_base64(b"YWI").as_deref(), Some(&b"ab"[..]));
assert_eq!(decode_base64(b"").as_deref(), Some(&b""[..]));
assert_eq!(decode_base64(b"not base64!"), None);
assert_eq!(decode_base64(b"YWJjZ"), None);
assert_eq!(decode_base64(b"Y===="), None);
assert_eq!(decode_base64(b"Y"), None);
}
#[test]
fn osc52_writes_are_captured_with_their_target() {
let t = fed(b"\x1b]52;c;V2lyZSB1cCB0aGUgUFRZIHJlYWRlcg==\x07");
let clip = t.clipboard().expect("a write was observed");
assert_eq!(clip.targets(), "c");
assert_eq!(clip.text(), Some("Wire up the PTY reader"));
let t = fed(b"\x1b]52;p;c2VsZWN0ZWQgd29yZHM=\x1b\\");
let clip = t.clipboard().expect("a write was observed");
assert_eq!(clip.targets(), "p");
assert_eq!(clip.text(), Some("selected words"));
let t = fed(b"\x1b]52;;Y29waWVk\x07");
assert_eq!(t.clipboard().expect("write").targets(), "");
let t = fed(b"\x1b]52;c;YQ==\x07\x1b]52;c;YWI=\x07");
assert_eq!(t.clipboard().expect("write").text(), Some("ab"));
}
#[test]
fn an_undecodable_payload_is_not_an_empty_clipboard() {
let empty = fed(b"\x1b]52;c;\x07");
assert_eq!(empty.clipboard().expect("write").text(), Some(""));
let broken = fed(b"\x1b]52;c;!!!not base64!!!\x07");
assert_eq!(broken.clipboard().expect("write").text(), None);
assert_eq!(broken.clipboard().expect("write").targets(), "c");
let not_utf8 = fed(b"\x1b]52;c;//8=\x07");
assert_eq!(not_utf8.clipboard().expect("write").text(), None);
}
#[test]
fn a_payload_past_the_capture_bound_is_reported_as_unreadable() {
let mut stream = b"\x1b]52;c;".to_vec();
stream.extend(std::iter::repeat_n(b'A', OSC_CAPTURE_MAX + 64));
stream.push(0x07);
let t = fed(&stream);
assert_eq!(t.clipboard().expect("write").text(), None);
}
#[test]
fn a_clipboard_read_is_a_query_not_a_write() {
let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, TEST_COLS);
let events: Vec<SeqEvent> = b"\x1b]52;c;?\x07".iter().map(|&b| t.step(b)).collect();
assert!(matches!(
events.last(),
Some(SeqEvent::Query(Query::Unanswerable(_)))
));
assert!(t.clipboard().is_none(), "a read must not invent a write");
}
#[test]
fn an_end_that_closes_no_begin_is_not_a_frame() {
let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, TEST_COLS);
let events: Vec<SeqEvent> = b"\x1b[?2026l".iter().map(|&b| t.step(b)).collect();
assert!(!events.contains(&SeqEvent::SyncEnd));
assert!(!t.in_sync_update());
let reset = b"\x1b[?2026l\x1b[?25h\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?2004l\x1b[?1049l";
let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, TEST_COLS);
let events: Vec<SeqEvent> = reset.iter().map(|&b| t.step(b)).collect();
assert!(!events.contains(&SeqEvent::SyncEnd));
let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, TEST_COLS);
let events: Vec<SeqEvent> = b"\x1b[?2026h\x1b[?2026l\x1b[?2026l"
.iter()
.map(|&b| t.step(b))
.collect();
assert_eq!(
events.iter().filter(|e| **e == SeqEvent::SyncEnd).count(),
1
);
}
#[test]
fn sync_survives_an_aborted_csi_inside_the_update() {
let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, TEST_COLS);
t.feed(b"\x1b[?2026h\x1b[31\x18"); assert!(t.in_sync_update());
t.feed(b"\x1b[?2026l");
assert!(!t.in_sync_update());
}
fn queries_of(bytes: &[u8]) -> Vec<Query> {
let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, TEST_COLS);
bytes
.iter()
.filter_map(|&b| match t.step(b) {
SeqEvent::Query(q) => Some(q),
_ => None,
})
.collect()
}
#[test]
fn recognizes_the_answerable_queries() {
assert_eq!(
queries_of(b"\x1b[6n"),
vec![Query::CursorPosition { private: false }]
);
assert_eq!(
queries_of(b"\x1b[?6n"),
vec![Query::CursorPosition { private: true }]
);
assert_eq!(queries_of(b"\x1b[5n"), vec![Query::OperatingStatus]);
assert_eq!(queries_of(b"\x1b[c"), vec![Query::PrimaryDa]);
assert_eq!(queries_of(b"\x1b[0c"), vec![Query::PrimaryDa]);
assert_eq!(queries_of(b"\x1b[>c"), vec![Query::SecondaryDa]);
assert_eq!(queries_of(b"\x1b[18t"), vec![Query::TextAreaSize]);
assert_eq!(
queries_of(b"\x1b]11;?\x07"),
vec![Query::OscColor {
code: 11,
st_terminated: false
}]
);
assert_eq!(
queries_of(b"\x1b]10;?\x1b\\"),
vec![Query::OscColor {
code: 10,
st_terminated: true
}]
);
}
#[test]
fn pixel_geometry_queries_are_classified_apart_from_the_rest() {
assert_eq!(queries_of(b"\x1b[14t"), vec![Query::WindowSizePixels]);
assert_eq!(queries_of(b"\x1b[16t"), vec![Query::CellSizePixels]);
assert_eq!(queries_of(b"\x1b[18t"), vec![Query::TextAreaSize]);
for shape in [&b"\x1b[11t"[..], b"\x1b[13t", b"\x1b[19t", b"\x1b[20t"] {
assert!(
matches!(queries_of(shape).as_slice(), [Query::Unanswerable(_)]),
"still declined: {shape:?}"
);
}
}
#[test]
fn xtgettcap_carries_the_names_it_was_asked_for() {
let q = queries_of(b"\x1bP+q544e;636f6c6f7273\x1b\\");
assert_eq!(
q,
vec![Query::RequestTermcap {
names: "544e;636f6c6f7273".into(),
shape: "^[P+q544e;636f6c6f7273^[\\".into(),
}]
);
}
#[test]
fn a_kitty_probe_without_an_id_still_classifies() {
assert_eq!(
queries_of(b"\x1b_Ga=q;\x1b\\"),
vec![Query::KittyGraphics {
id: None,
shape: "^[_Ga=q;^[\\".into()
}]
);
}
#[test]
fn recognizes_unanswerable_questions_with_their_shape() {
let q = queries_of(b"\x1b[?u");
assert_eq!(q, vec![Query::Unanswerable("^[[?u".into())]);
let q = queries_of(b"\x1b[13t");
assert_eq!(q, vec![Query::Unanswerable("^[[13t".into())]);
let q = queries_of(b"\x1bP$qm\x1b\\"); assert_eq!(q, vec![Query::Unanswerable("^[P$qm^[\\".into())]);
let q = queries_of(b"\x1b[=c"); assert_eq!(q, vec![Query::Unanswerable("^[[=c".into())]);
let q = queries_of(b"\x1b]12;?\x07"); assert_eq!(q, vec![Query::Unanswerable("^[]12;?^G".into())]);
let q = queries_of(b"\x1b[6;1n");
assert_eq!(q, vec![Query::Unanswerable("^[[6;1n".into())]);
}
#[test]
fn recognizes_decrqm_mode_requests() {
assert_eq!(queries_of(b"\x1b[?2026$p"), vec![Query::RequestMode(2026)]);
assert_eq!(queries_of(b"\x1b[?2004$p"), vec![Query::RequestMode(2004)]);
assert_eq!(queries_of(b"\x1b[?1$p"), vec![Query::RequestMode(1)]);
assert_eq!(
queries_of(b"\x1b[4$p"),
vec![Query::Unanswerable("^[[4$p".into())]
);
}
#[test]
fn recognizes_the_remaining_unanswerable_families() {
assert_eq!(
queries_of(b"\x1bP$qm\x1b\\"),
vec![Query::Unanswerable("^[P$qm^[\\".into())]
);
assert_eq!(
queries_of(b"\x1b]4;1;?\x07"),
vec![Query::Unanswerable("^[]4;1;?^G".into())]
);
assert_eq!(
queries_of(b"\x1b]52;c;?\x07"),
vec![Query::Unanswerable("^[]52;c;?^G".into())]
);
}
#[test]
fn setting_a_palette_colour_is_not_a_query() {
assert!(queries_of(b"\x1b]4;1;rgb:ff/00/00\x07").is_empty());
assert!(queries_of(b"\x1b]52;c;aGVsbG8=\x07").is_empty());
}
#[test]
fn ordinary_output_is_not_a_query() {
assert!(queries_of(b"\x1b[31m").is_empty()); assert!(queries_of(b"\x1b[2J\x1b[H").is_empty()); assert!(queries_of(b"\x1b[8;30;100t").is_empty()); assert!(queries_of(b"\x1b]0;title\x07").is_empty()); assert!(queries_of(b"\x1b[1;6H").is_empty()); assert!(queries_of(b"plain text").is_empty());
}
#[test]
fn osc_0_and_2_set_the_title_via_bel_or_st() {
let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, TEST_COLS);
assert_eq!(&*t.title(), "");
t.feed(b"\x1b]2;hello world\x07");
assert_eq!(&*t.title(), "hello world");
t.feed("\x1b]0;second ✓\x1b\\".as_bytes());
assert_eq!(&*t.title(), "second ✓");
}
#[test]
fn titles_longer_than_the_diagnostic_capture_are_kept_whole() {
let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, TEST_COLS);
let title = "t".repeat(80); t.feed(format!("\x1b]2;{title}\x07").as_bytes());
assert_eq!(&*t.title(), title.as_str());
}
#[test]
fn title_survives_chunked_delivery() {
let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, TEST_COLS);
t.feed(b"\x1b]2;split");
t.feed(b" title\x07");
assert_eq!(&*t.title(), "split title");
}
#[test]
fn title_keeps_embedded_semicolons() {
let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, TEST_COLS);
t.feed(b"\x1b]0;a;b;c\x07");
assert_eq!(&*t.title(), "a;b;c");
}
#[test]
fn icon_only_and_aborted_titles_do_not_change_the_title() {
let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, TEST_COLS);
t.feed(b"\x1b]2;kept\x07");
t.feed(b"\x1b]1;icon only\x07"); assert_eq!(&*t.title(), "kept");
t.feed(b"\x1b]2;aborted\x18"); assert_eq!(&*t.title(), "kept");
t.feed(b"\x1b]2;also aborted\x1b[31m"); assert_eq!(&*t.title(), "kept");
t.feed(b"\x1b]2;\x07"); assert_eq!(&*t.title(), "");
}
#[test]
fn split_utf8_is_mid_sequence() {
let bytes = "汉".as_bytes(); let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, TEST_COLS);
t.feed(&bytes[..1]);
assert!(t.mid_sequence());
t.feed(&bytes[1..2]);
assert!(t.mid_sequence());
t.feed(&bytes[2..]);
assert!(!t.mid_sequence());
}
fn tab_ops(bytes: &[u8]) -> Vec<TabOp> {
let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, TEST_COLS);
let mut out = Vec::new();
for &byte in bytes {
if let SeqEvent::Tabs(op) = t.step(byte) {
out.push(op);
}
}
out
}
#[test]
fn the_five_tab_sequences_are_recognized() {
assert_eq!(tab_ops(b"\x1bH"), vec![TabOp::Set]);
assert_eq!(tab_ops(b"\x1b[g"), vec![TabOp::ClearAtCursor]);
assert_eq!(tab_ops(b"\x1b[0g"), vec![TabOp::ClearAtCursor]);
assert_eq!(tab_ops(b"\x1b[3g"), vec![TabOp::ClearAll]);
assert_eq!(tab_ops(b"\t"), vec![TabOp::Forward(1)]);
assert_eq!(tab_ops(b"\x1b[I"), vec![TabOp::Forward(1)]);
assert_eq!(tab_ops(b"\x1b[0I"), vec![TabOp::Forward(1)]);
assert_eq!(tab_ops(b"\x1b[3I"), vec![TabOp::Forward(3)]);
assert_eq!(tab_ops(b"\x1b[Z"), vec![TabOp::Back(1)]);
assert_eq!(tab_ops(b"\x1b[2Z"), vec![TabOp::Back(2)]);
}
#[test]
fn sequences_that_only_look_like_tab_operations_are_left_alone() {
assert!(tab_ops(b"\x1b[1g").is_empty());
assert!(tab_ops(b"\x1b[2g").is_empty());
assert!(tab_ops(b"\x1b[?3g").is_empty());
assert!(tab_ops(b"\x1b[?1I").is_empty());
assert!(tab_ops(b"\x1b[H").is_empty());
assert!(tab_ops(b"\x1b[1;1H").is_empty());
assert!(tab_ops(b"\x1b]0;a\tb\x07").is_empty());
assert!(tab_ops(b"\x1bPq\t\x1b\\").is_empty());
}
#[test]
fn a_stop_set_at_the_cursor_is_where_the_next_tab_lands() {
let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, 24);
assert_eq!(t.tab_op(TabOp::Forward(1), 1), Some(8));
assert_eq!(t.tab_op(TabOp::Set, 3), None);
assert_eq!(t.tab_op(TabOp::Forward(1), 1), Some(3));
assert_eq!(t.tab_op(TabOp::Forward(1), 3), Some(8));
assert_eq!(t.tab_op(TabOp::ClearAtCursor, 3), None);
assert_eq!(t.tab_op(TabOp::Forward(1), 1), Some(8));
}
#[test]
fn motions_move_by_whole_stops_and_saturate_at_the_edges() {
let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, 24);
assert_eq!(t.tab_op(TabOp::Forward(2), 0), Some(16));
assert_eq!(t.tab_op(TabOp::Forward(9), 0), Some(23));
assert_eq!(t.tab_op(TabOp::Forward(u16::MAX), 0), Some(23));
assert_eq!(t.tab_op(TabOp::Back(1), 17), Some(16));
assert_eq!(t.tab_op(TabOp::Back(1), 16), Some(8));
assert_eq!(t.tab_op(TabOp::Back(2), 17), Some(8));
assert_eq!(t.tab_op(TabOp::Back(u16::MAX), 17), Some(0));
}
#[test]
fn clearing_every_stop_leaves_the_two_edges() {
let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, 24);
assert_eq!(t.tab_op(TabOp::ClearAll, 0), None);
assert_eq!(t.tab_op(TabOp::Forward(1), 0), Some(23));
assert_eq!(t.tab_op(TabOp::Back(1), 20), Some(0));
assert_eq!(t.tab_op(TabOp::Set, 5), None);
assert_eq!(t.tab_op(TabOp::Forward(1), 0), Some(5));
}
#[test]
fn a_reset_restores_the_default_every_eighth_set() {
for reset in [&b"\x1bc"[..], b"\x1b[!p"] {
let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, 24);
assert_eq!(t.tab_op(TabOp::ClearAll, 0), None);
assert_eq!(t.tab_op(TabOp::Set, 3), None);
assert_eq!(t.tab_op(TabOp::Forward(1), 0), Some(3));
t.feed(reset);
assert_eq!(
t.tab_op(TabOp::Forward(1), 0),
Some(8),
"the custom stop must not survive {}",
printable(reset)
);
assert_eq!(t.tab_op(TabOp::Forward(1), 8), Some(16));
}
}
#[test]
fn a_resize_extends_the_set_without_disturbing_the_stops_it_had() {
let mut t = SeqTracker::new(crate::graphics::DEFAULT_CAPTURE, 24);
assert_eq!(t.tab_op(TabOp::Set, 3), None);
assert_eq!(t.tab_op(TabOp::ClearAtCursor, 16), None);
t.set_cols(40);
assert_eq!(t.tab_op(TabOp::Forward(1), 0), Some(3));
assert_eq!(t.tab_op(TabOp::Forward(1), 8), Some(24), "16 stays cleared");
assert_eq!(t.tab_op(TabOp::Forward(1), 24), Some(32), "new territory");
t.set_cols(10);
assert_eq!(t.tab_op(TabOp::Forward(1), 8), Some(9));
}
}