use alacritty_terminal::event::{Event, EventListener};
use alacritty_terminal::grid::Scroll;
use alacritty_terminal::index::{Column, Line};
use alacritty_terminal::term::test::TermSize;
use alacritty_terminal::term::{Config as TermConfig, Term, TermMode};
use alacritty_terminal::vte::ansi::Processor;
use std::io::{self, Write};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
const SCROLLBACK_LINES: usize = 200_000;
#[derive(Clone)]
struct PtyWriteListener {
write_queue: Arc<Mutex<Vec<String>>>,
pending_title: Arc<Mutex<Option<String>>>,
}
impl PtyWriteListener {
fn new() -> Self {
Self {
write_queue: Arc::new(Mutex::new(Vec::new())),
pending_title: Arc::new(Mutex::new(None)),
}
}
}
impl EventListener for PtyWriteListener {
fn send_event(&self, event: Event) {
match event {
Event::PtyWrite(text) => {
if let Ok(mut queue) = self.write_queue.lock() {
queue.push(text);
}
}
Event::Title(title) => {
if let Ok(mut pending) = self.pending_title.lock() {
*pending = Some(title);
}
}
Event::ResetTitle => {
if let Ok(mut pending) = self.pending_title.lock() {
*pending = Some(String::new());
}
}
_ => {}
}
}
}
#[derive(Debug, Default)]
struct Osc7Scanner {
intro_match: usize,
collecting: bool,
saw_esc: bool,
buf: Vec<u8>,
}
const OSC7_INTRO: [u8; 4] = [0x1b, b']', b'7', b';'];
const OSC7_MAX_PAYLOAD: usize = 4096;
impl Osc7Scanner {
fn feed(&mut self, data: &[u8], out: &mut Vec<String>) {
for &byte in data {
if self.collecting {
if self.saw_esc {
self.saw_esc = false;
if byte == b'\\' {
self.finish(out);
} else {
self.reset();
}
} else if byte == 0x07 {
self.finish(out);
} else if byte == 0x1b {
self.saw_esc = true;
} else if self.buf.len() >= OSC7_MAX_PAYLOAD {
self.reset();
} else {
self.buf.push(byte);
}
} else if byte == OSC7_INTRO[self.intro_match] {
self.intro_match += 1;
if self.intro_match == OSC7_INTRO.len() {
self.collecting = true;
self.intro_match = 0;
self.buf.clear();
}
} else {
self.intro_match = usize::from(byte == OSC7_INTRO[0]);
}
}
}
fn finish(&mut self, out: &mut Vec<String>) {
if let Ok(s) = std::str::from_utf8(&self.buf) {
out.push(s.to_owned());
}
self.reset();
}
fn reset(&mut self) {
self.collecting = false;
self.saw_esc = false;
self.intro_match = 0;
self.buf.clear();
}
}
fn parse_osc7_path(payload: &str) -> Option<PathBuf> {
let raw = if let Some(rest) = payload.strip_prefix("file://") {
let path_part = match rest.find('/') {
Some(idx) => &rest[idx..],
None => return None,
};
percent_decode(path_part)
} else {
payload.to_owned()
};
if raw.is_empty() {
return None;
}
let raw = {
let bytes = raw.as_bytes();
if bytes.len() >= 3 && bytes[0] == b'/' && bytes[2] == b':' {
raw[1..].to_owned()
} else {
raw
}
};
if is_osc7_absolute(&raw) {
Some(PathBuf::from(raw))
} else {
None
}
}
fn is_osc7_absolute(s: &str) -> bool {
let bytes = s.as_bytes();
s.starts_with('/')
|| s.starts_with('\\')
|| (bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':')
}
fn percent_decode(input: &str) -> String {
let bytes = input.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
let hi = (bytes[i + 1] as char).to_digit(16);
let lo = (bytes[i + 2] as char).to_digit(16);
if let (Some(hi), Some(lo)) = (hi, lo) {
out.push((hi * 16 + lo) as u8);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
pub struct TerminalState {
term: Term<PtyWriteListener>,
parser: Processor,
cols: u16,
rows: u16,
dirty: bool,
terminal_title: String,
synced_history_lines: usize,
synced_logical_lines: usize,
pending_reflow_resync: bool,
backing_file_history_end: u64,
pty_write_queue: Arc<Mutex<Vec<String>>>,
pending_title: Arc<Mutex<Option<String>>>,
cwd: Option<PathBuf>,
osc7: Osc7Scanner,
}
impl TerminalState {
pub fn new(cols: u16, rows: u16) -> Self {
let size = TermSize::new(cols as usize, rows as usize);
let config = TermConfig {
scrolling_history: SCROLLBACK_LINES,
..Default::default()
};
let listener = PtyWriteListener::new();
let pty_write_queue = listener.write_queue.clone();
let pending_title = listener.pending_title.clone();
let term = Term::new(config, &size, listener);
Self {
term,
parser: Processor::new(),
cols,
rows,
dirty: true,
terminal_title: String::new(),
synced_history_lines: 0,
synced_logical_lines: 0,
pending_reflow_resync: false,
backing_file_history_end: 0,
pty_write_queue,
pending_title,
cwd: None,
osc7: Osc7Scanner::default(),
}
}
pub fn cwd(&self) -> Option<&std::path::Path> {
self.cwd.as_deref()
}
pub fn drain_pty_write_queue(&self) -> Vec<String> {
if let Ok(mut queue) = self.pty_write_queue.lock() {
std::mem::take(&mut *queue)
} else {
Vec::new()
}
}
pub fn process_output(&mut self, data: &[u8]) {
use alacritty_terminal::grid::Dimensions;
let history_before = self.term.grid().history_size();
let alt_before = self.term.mode().contains(TermMode::ALT_SCREEN);
let mut osc7_payloads = Vec::new();
self.osc7.feed(data, &mut osc7_payloads);
if let Some(path) = osc7_payloads.iter().rev().find_map(|p| parse_osc7_path(p)) {
self.cwd = Some(path);
}
self.parser.advance(&mut self.term, data);
if let Ok(mut pending) = self.pending_title.lock() {
if let Some(title) = pending.take() {
self.terminal_title = title;
}
}
let alt_after = self.term.mode().contains(TermMode::ALT_SCREEN);
if alt_before && !alt_after && self.pending_reflow_resync {
self.resync_after_reflow();
self.pending_reflow_resync = false;
}
if !alt_after {
let history_after = self.term.grid().history_size();
if history_after < history_before {
self.synced_history_lines = 0;
self.synced_logical_lines = 0;
}
}
self.dirty = true;
}
pub fn resize(&mut self, cols: u16, rows: u16) {
if cols != self.cols || rows != self.rows {
let cols_changed = cols != self.cols;
self.cols = cols;
self.rows = rows;
let size = TermSize::new(cols as usize, rows as usize);
self.term.resize(size);
if cols_changed {
if self.term.mode().contains(TermMode::ALT_SCREEN) {
self.pending_reflow_resync = true;
} else {
self.resync_after_reflow();
}
}
self.dirty = true;
}
}
fn resync_after_reflow(&mut self) {
use alacritty_terminal::grid::Dimensions;
let history = self.term.grid().history_size();
let target = self.synced_logical_lines;
let mut logical_seen = 0usize;
let mut synced = 0usize;
let mut k = 0usize;
while k < history && logical_seen < target {
let line_idx = -((history - k) as i32);
if !self.row_wraps(Line(line_idx)) {
logical_seen += 1;
synced = k + 1;
}
k += 1;
}
self.synced_history_lines = synced;
self.synced_logical_lines = logical_seen;
}
pub fn size(&self) -> (u16, u16) {
(self.cols, self.rows)
}
pub fn is_dirty(&self) -> bool {
self.dirty
}
pub fn mark_clean(&mut self) {
self.dirty = false;
}
pub fn cursor_position(&self) -> (u16, u16) {
let cursor = self.term.grid().cursor.point;
(cursor.column.0 as u16, cursor.line.0 as u16)
}
pub fn cursor_visible(&self) -> bool {
true
}
pub fn last_visible_line(&self) -> String {
let (col, row) = self.cursor_position();
if row >= self.rows {
return String::new();
}
if col == 0 && row > 0 {
let cells = self.get_line(row - 1);
let mut s: String = cells.iter().map(|cell| cell.c).collect();
let trimmed_len = s.trim_end_matches(' ').len();
s.truncate(trimmed_len);
return s;
}
let cells = self.get_line(row);
let take = (col as usize).min(cells.len());
cells.iter().take(take).map(|cell| cell.c).collect()
}
pub fn get_line(&self, row: u16) -> Vec<TerminalCell> {
use alacritty_terminal::index::{Column, Line};
use alacritty_terminal::term::cell::Flags;
let grid = self.term.grid();
let display_offset = grid.display_offset();
let line = Line(row as i32 - display_offset as i32);
if row >= self.rows {
return vec![TerminalCell::default(); self.cols as usize];
}
let row_data = &grid[line];
let mut cells = Vec::with_capacity(self.cols as usize);
for col in 0..self.cols as usize {
let cell = &row_data[Column(col)];
let c = cell.c;
let fg = color_to_rgb(&cell.fg);
let bg = color_to_rgb(&cell.bg);
let flags = cell.flags;
let bold = flags.contains(Flags::BOLD);
let italic = flags.contains(Flags::ITALIC);
let underline = flags.contains(Flags::UNDERLINE);
let inverse = flags.contains(Flags::INVERSE);
cells.push(TerminalCell {
c,
fg,
bg,
bold,
italic,
underline,
inverse,
});
}
cells
}
pub fn content_string(&self) -> String {
let mut result = String::new();
for row in 0..self.rows {
let line = self.get_line(row);
for cell in line {
result.push(cell.c);
}
result.push('\n');
}
result
}
#[allow(dead_code)]
pub fn full_content_string(&self) -> String {
use alacritty_terminal::grid::Dimensions;
use alacritty_terminal::index::{Column, Line};
let grid = self.term.grid();
let history_size = grid.history_size();
let mut result = String::new();
for i in (1..=history_size).rev() {
let line = Line(-(i as i32));
let row_data = &grid[line];
let mut line_str = String::new();
for col in 0..self.cols as usize {
line_str.push(row_data[Column(col)].c);
}
let trimmed = line_str.trim_end();
result.push_str(trimmed);
result.push('\n');
}
for row in 0..self.rows {
let line = self.get_line(row);
let line_str: String = line.iter().map(|c| c.c).collect();
let trimmed = line_str.trim_end();
result.push_str(trimmed);
if row < self.rows - 1 {
result.push('\n');
}
}
result
}
pub fn history_size(&self) -> usize {
use alacritty_terminal::grid::Dimensions;
self.term.grid().history_size()
}
pub fn title(&self) -> &str {
&self.terminal_title
}
pub fn set_title(&mut self, title: String) {
self.terminal_title = title;
}
pub fn scroll_to_bottom(&mut self) {
self.term.scroll_display(Scroll::Bottom);
self.dirty = true;
}
pub fn is_alternate_screen(&self) -> bool {
self.term.mode().contains(TermMode::ALT_SCREEN)
}
pub fn wants_mouse_events(&self) -> bool {
let mode = self.term.mode();
mode.intersects(
TermMode::MOUSE_REPORT_CLICK | TermMode::MOUSE_MOTION | TermMode::MOUSE_DRAG,
)
}
pub fn uses_sgr_mouse(&self) -> bool {
self.term.mode().contains(TermMode::SGR_MOUSE)
}
pub fn uses_alternate_scroll(&self) -> bool {
self.term.mode().contains(TermMode::ALTERNATE_SCROLL)
}
pub fn is_app_cursor(&self) -> bool {
self.term.mode().contains(TermMode::APP_CURSOR)
}
pub fn flush_new_scrollback<W: Write>(&mut self, writer: &mut W) -> io::Result<usize> {
use alacritty_terminal::grid::Dimensions;
let history = self.term.grid().history_size();
if history <= self.synced_history_lines {
return Ok(0);
}
let mut written = 0usize;
let mut line_start = self.synced_history_lines;
let mut k = self.synced_history_lines;
while k < history {
let line_idx = -((history - k) as i32);
if self.row_wraps(Line(line_idx)) {
k += 1;
continue;
}
self.write_logical_line(writer, line_start, k, history)?;
written += 1;
self.synced_logical_lines += 1;
k += 1;
self.synced_history_lines = k;
line_start = k;
}
Ok(written)
}
pub fn append_visible_screen<W: Write>(&self, writer: &mut W) -> io::Result<()> {
let rows = self.rows as i32;
let mut start = 0i32;
let mut row = 0i32;
while row < rows {
if self.row_wraps(Line(row)) && row + 1 < rows {
row += 1;
continue;
}
self.write_visible_logical_line(writer, start, row)?;
row += 1;
start = row;
}
Ok(())
}
fn row_wraps(&self, line: Line) -> bool {
use alacritty_terminal::term::cell::Flags;
if self.cols == 0 {
return false;
}
let grid = self.term.grid();
grid[line][Column(self.cols as usize - 1)]
.flags
.contains(Flags::WRAPLINE)
}
fn write_logical_line<W: Write>(
&self,
writer: &mut W,
line_start: usize,
line_end: usize,
history: usize,
) -> io::Result<()> {
let mut sgr = SgrState::default();
let mut out = String::with_capacity((line_end - line_start + 1) * self.cols as usize * 2);
for k in line_start..=line_end {
let line_idx = -((history - k) as i32);
self.append_row_cells(Line(line_idx), &mut sgr, &mut out);
}
Self::finish_logical_line(&mut out, &sgr);
writeln!(writer, "{}", out)
}
fn write_visible_logical_line<W: Write>(
&self,
writer: &mut W,
line_start: i32,
line_end: i32,
) -> io::Result<()> {
let mut sgr = SgrState::default();
let mut out = String::with_capacity(self.cols as usize * 2);
for row in line_start..=line_end {
self.append_row_cells(Line(row), &mut sgr, &mut out);
}
Self::finish_logical_line(&mut out, &sgr);
writeln!(writer, "{}", out)
}
fn finish_logical_line(out: &mut String, sgr: &SgrState) {
if sgr.has_style() {
out.push_str("\x1b[0m");
}
let trimmed_len = out.trim_end_matches([' ', '\0']).len();
out.truncate(trimmed_len);
}
fn append_row_cells(&self, line: Line, sgr: &mut SgrState, out: &mut String) {
use alacritty_terminal::term::cell::Flags;
let grid = self.term.grid();
let row_data = &grid[line];
for col in 0..self.cols as usize {
let cell = &row_data[Column(col)];
let fg = color_to_rgb(&cell.fg);
let bg = color_to_rgb(&cell.bg);
let flags = cell.flags;
let bold = flags.contains(Flags::BOLD);
let italic = flags.contains(Flags::ITALIC);
let underline = flags.contains(Flags::UNDERLINE);
let fg_changed = fg != sgr.fg;
let bg_changed = bg != sgr.bg;
let bold_changed = bold != sgr.bold;
let italic_changed = italic != sgr.italic;
let underline_changed = underline != sgr.underline;
if fg_changed || bg_changed || bold_changed || italic_changed || underline_changed {
let mut codes: Vec<String> = Vec::new();
if (sgr.bold && !bold) || (sgr.italic && !italic) || (sgr.underline && !underline) {
codes.push("0".to_string());
if bold {
codes.push("1".to_string());
}
if italic {
codes.push("3".to_string());
}
if underline {
codes.push("4".to_string());
}
if let Some((r, g, b)) = fg {
codes.push(format!("38;2;{};{};{}", r, g, b));
}
if let Some((r, g, b)) = bg {
codes.push(format!("48;2;{};{};{}", r, g, b));
}
} else {
if bold_changed && bold {
codes.push("1".to_string());
}
if italic_changed && italic {
codes.push("3".to_string());
}
if underline_changed && underline {
codes.push("4".to_string());
}
if fg_changed {
if let Some((r, g, b)) = fg {
codes.push(format!("38;2;{};{};{}", r, g, b));
} else {
codes.push("39".to_string());
}
}
if bg_changed {
if let Some((r, g, b)) = bg {
codes.push(format!("48;2;{};{};{}", r, g, b));
} else {
codes.push("49".to_string());
}
}
}
if !codes.is_empty() {
out.push_str(&format!("\x1b[{}m", codes.join(";")));
}
sgr.fg = fg;
sgr.bg = bg;
sgr.bold = bold;
sgr.italic = italic;
sgr.underline = underline;
}
out.push(cell.c);
}
}
pub fn backing_file_history_end(&self) -> u64 {
self.backing_file_history_end
}
pub fn set_backing_file_history_end(&mut self, offset: u64) {
self.backing_file_history_end = offset;
}
pub fn synced_history_lines(&self) -> usize {
self.synced_history_lines
}
pub fn reset_sync_state(&mut self) {
self.synced_history_lines = 0;
self.synced_logical_lines = 0;
self.pending_reflow_resync = false;
self.backing_file_history_end = 0;
}
}
#[derive(Debug, Clone)]
pub struct TerminalCell {
pub c: char,
pub fg: Option<(u8, u8, u8)>,
pub bg: Option<(u8, u8, u8)>,
pub bold: bool,
pub italic: bool,
pub underline: bool,
pub inverse: bool,
}
impl Default for TerminalCell {
fn default() -> Self {
Self {
c: ' ',
fg: None,
bg: None,
bold: false,
italic: false,
underline: false,
inverse: false,
}
}
}
#[derive(Default)]
struct SgrState {
fg: Option<(u8, u8, u8)>,
bg: Option<(u8, u8, u8)>,
bold: bool,
italic: bool,
underline: bool,
}
impl SgrState {
fn has_style(&self) -> bool {
self.fg.is_some() || self.bg.is_some() || self.bold || self.italic || self.underline
}
}
fn color_to_rgb(color: &alacritty_terminal::vte::ansi::Color) -> Option<(u8, u8, u8)> {
use alacritty_terminal::vte::ansi::Color;
match color {
Color::Spec(rgb) => Some((rgb.r, rgb.g, rgb.b)),
Color::Named(named) => {
let rgb = match named {
alacritty_terminal::vte::ansi::NamedColor::Black => (0, 0, 0),
alacritty_terminal::vte::ansi::NamedColor::Red => (205, 49, 49),
alacritty_terminal::vte::ansi::NamedColor::Green => (13, 188, 121),
alacritty_terminal::vte::ansi::NamedColor::Yellow => (229, 229, 16),
alacritty_terminal::vte::ansi::NamedColor::Blue => (36, 114, 200),
alacritty_terminal::vte::ansi::NamedColor::Magenta => (188, 63, 188),
alacritty_terminal::vte::ansi::NamedColor::Cyan => (17, 168, 205),
alacritty_terminal::vte::ansi::NamedColor::White => (229, 229, 229),
alacritty_terminal::vte::ansi::NamedColor::BrightBlack => (102, 102, 102),
alacritty_terminal::vte::ansi::NamedColor::BrightRed => (241, 76, 76),
alacritty_terminal::vte::ansi::NamedColor::BrightGreen => (35, 209, 139),
alacritty_terminal::vte::ansi::NamedColor::BrightYellow => (245, 245, 67),
alacritty_terminal::vte::ansi::NamedColor::BrightBlue => (59, 142, 234),
alacritty_terminal::vte::ansi::NamedColor::BrightMagenta => (214, 112, 214),
alacritty_terminal::vte::ansi::NamedColor::BrightCyan => (41, 184, 219),
alacritty_terminal::vte::ansi::NamedColor::BrightWhite => (255, 255, 255),
alacritty_terminal::vte::ansi::NamedColor::Foreground => return None,
alacritty_terminal::vte::ansi::NamedColor::Background => return None,
alacritty_terminal::vte::ansi::NamedColor::Cursor => return None,
_ => return None,
};
Some(rgb)
}
Color::Indexed(idx) => {
let idx = *idx as usize;
if idx < 16 {
let colors = [
(0, 0, 0), (205, 49, 49), (13, 188, 121), (229, 229, 16), (36, 114, 200), (188, 63, 188), (17, 168, 205), (229, 229, 229), (102, 102, 102), (241, 76, 76), (35, 209, 139), (245, 245, 67), (59, 142, 234), (214, 112, 214), (41, 184, 219), (255, 255, 255), ];
Some(colors[idx])
} else if idx < 232 {
let idx = idx - 16;
let r = (idx / 36) * 51;
let g = ((idx / 6) % 6) * 51;
let b = (idx % 6) * 51;
Some((r as u8, g as u8, b as u8))
} else {
let gray = (idx - 232) * 10 + 8;
Some((gray as u8, gray as u8, gray as u8))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_terminal_state_new() {
let state = TerminalState::new(80, 24);
assert_eq!(state.size(), (80, 24));
assert!(state.is_dirty());
}
#[test]
fn test_terminal_process_output() {
let mut state = TerminalState::new(80, 24);
state.process_output(b"Hello, World!");
let content = state.content_string();
assert!(content.contains("Hello, World!"));
}
#[test]
fn test_terminal_resize() {
let mut state = TerminalState::new(80, 24);
state.mark_clean();
assert!(!state.is_dirty());
state.resize(100, 30);
assert_eq!(state.size(), (100, 30));
assert!(state.is_dirty());
}
#[test]
fn test_resize_reanchors_synced_history() {
let mut state = TerminalState::new(80, 24);
for i in 0..200 {
state.process_output(format!("line {i}\r\n").as_bytes());
}
let mut sink: Vec<u8> = Vec::new();
state.flush_new_scrollback(&mut sink).unwrap();
assert_eq!(state.synced_history_lines(), state.history_size());
state.resize(200, 24);
assert_eq!(state.synced_history_lines(), state.history_size());
let mut after: Vec<u8> = Vec::new();
assert_eq!(state.flush_new_scrollback(&mut after).unwrap(), 0);
}
#[test]
fn test_height_shrink_streams_spilled_rows() {
let mut state = TerminalState::new(80, 24);
for i in 0..24 {
state.process_output(format!("row{i:02}\r\n").as_bytes());
}
let mut sink: Vec<u8> = Vec::new();
state.flush_new_scrollback(&mut sink).unwrap();
let before = state.synced_history_lines();
state.resize(80, 10);
assert!(
state.history_size() > before,
"shrink should push rows into history"
);
let mut spill: Vec<u8> = Vec::new();
let written = state.flush_new_scrollback(&mut spill).unwrap();
assert!(written > 0, "spilled rows must be streamed, got {written}");
}
#[test]
fn test_height_grow_does_not_reflow_duplicate() {
let mut state = TerminalState::new(80, 24);
for i in 0..100 {
state.process_output(format!("line {i}\r\n").as_bytes());
}
let mut sink: Vec<u8> = Vec::new();
state.flush_new_scrollback(&mut sink).unwrap();
let synced_before = state.synced_history_lines();
state.resize(80, 40);
assert_eq!(state.synced_history_lines(), synced_before);
let mut after: Vec<u8> = Vec::new();
assert_eq!(
state.flush_new_scrollback(&mut after).unwrap(),
0,
"growing height must not re-stream rows already in the backing file"
);
}
fn marker_counts(text: &str, n: usize) -> (usize, usize) {
let mut min = usize::MAX;
let mut max = 0;
for i in 0..n {
let c = text.matches(&format!("L{i:05}#")).count();
min = min.min(c);
max = max.max(c);
}
(min, max)
}
#[test]
fn test_wrapped_line_stored_as_single_logical_line() {
let mut state = TerminalState::new(40, 24);
let long = "X".repeat(100);
state.process_output(format!("{long}\r\n").as_bytes());
for _ in 0..24 {
state.process_output(b"y\r\n");
}
let mut sink: Vec<u8> = Vec::new();
state.flush_new_scrollback(&mut sink).unwrap();
let text = String::from_utf8_lossy(&sink);
let xline = text.lines().find(|l| l.contains("XXXX")).unwrap();
assert_eq!(
xline.chars().filter(|&c| c == 'X').count(),
100,
"the wrapped line must be rejoined into one 100-char logical line"
);
}
#[test]
fn test_no_scrollback_lost_across_many_mixed_resizes() {
let mut state = TerminalState::new(80, 24);
let n = 500;
let mut sink: Vec<u8> = Vec::new();
let sizes = [
(120u16, 24u16),
(60, 30),
(200, 18),
(90, 40),
(50, 22),
(160, 50),
(70, 20),
];
for b in 0..n / 20 {
for i in 0..20 {
let idx = b * 20 + i;
let pad = "=".repeat((idx % 90) + 5);
state.process_output(format!("L{idx:05}# {pad}\r\n").as_bytes());
}
state.flush_new_scrollback(&mut sink).unwrap();
let (w, h) = sizes[b % sizes.len()];
state.resize(w, h);
}
state.flush_new_scrollback(&mut sink).unwrap();
state.append_visible_screen(&mut sink).unwrap();
let text = String::from_utf8_lossy(&sink);
let (min, max) = marker_counts(&text, n);
assert!(
min >= 1,
"lost scrollback line(s): some marker missing (min={min})"
);
assert!(max <= 3, "excessive duplication (max={max})");
}
#[test]
fn test_clear_scrollback_resumes_capture() {
let mut state = TerminalState::new(80, 24);
let mut sink: Vec<u8> = Vec::new();
for i in 0..100 {
state.process_output(format!("OLD{i:04}#\r\n").as_bytes());
}
state.flush_new_scrollback(&mut sink).unwrap();
assert!(state.synced_logical_lines > 0);
state.process_output(b"\x1b[3J\x1b[H\x1b[2J");
for i in 0..100 {
state.process_output(format!("NEW{i:04}#\r\n").as_bytes());
}
state.flush_new_scrollback(&mut sink).unwrap();
state.append_visible_screen(&mut sink).unwrap();
let text = String::from_utf8_lossy(&sink);
assert!(text.contains("OLD0000#"), "pre-clear scrollback lost");
assert!(text.contains("NEW0000#"), "post-clear output dropped");
assert!(text.contains("NEW0090#"), "later post-clear output dropped");
}
#[test]
fn test_alt_screen_roundtrip_no_duplicate() {
let mut state = TerminalState::new(80, 24);
let mut sink: Vec<u8> = Vec::new();
for i in 0..100 {
state.process_output(format!("BASE{i:04}#\r\n").as_bytes());
}
state.flush_new_scrollback(&mut sink).unwrap();
state.process_output(b"\x1b[?1049h");
state.process_output(b"full screen app drawing\r\nmore\r\n");
state.process_output(b"\x1b[?1049l");
for i in 0..5 {
state.process_output(format!("AFTER{i:04}#\r\n").as_bytes());
}
state.flush_new_scrollback(&mut sink).unwrap();
state.append_visible_screen(&mut sink).unwrap();
let text = String::from_utf8_lossy(&sink);
for i in 0..100 {
assert!(
text.matches(&format!("BASE{i:04}#")).count() <= 1,
"alt-screen round trip duplicated BASE{i:04}"
);
}
assert!(
text.contains("AFTER0000#"),
"post-alt-screen output dropped"
);
}
#[test]
fn test_resize_during_alt_screen_then_capture() {
let mut state = TerminalState::new(80, 24);
let mut sink: Vec<u8> = Vec::new();
for i in 0..150 {
let pad = "=".repeat(60);
state.process_output(format!("PRE{i:04}# {pad}\r\n").as_bytes());
}
state.flush_new_scrollback(&mut sink).unwrap();
state.process_output(b"\x1b[?1049h");
state.resize(40, 24);
state.resize(120, 24);
state.process_output(b"\x1b[?1049l");
for i in 0..150 {
state.process_output(format!("POST{i:04}# x\r\n").as_bytes());
}
state.flush_new_scrollback(&mut sink).unwrap();
state.append_visible_screen(&mut sink).unwrap();
let text = String::from_utf8_lossy(&sink);
for i in 0..150 {
assert!(
text.contains(&format!("POST{i:04}#")),
"post-alt output lost POST{i:04}"
);
}
for i in 0..150 {
assert!(
text.matches(&format!("PRE{i:04}#")).count() <= 2,
"pre-alt scrollback duplicated PRE{i:04}"
);
}
assert!(text.contains("PRE0000#"), "pre-alt scrollback lost");
}
#[test]
fn test_last_visible_line_returns_cursor_row() {
let mut state = TerminalState::new(80, 24);
state.process_output(b"hello\r\nworld");
assert_eq!(state.last_visible_line(), "world");
}
#[test]
fn test_last_visible_line_preserves_prompt_trailing_space() {
let mut state = TerminalState::new(80, 24);
state.process_output(b"Continue? (Y/n): ");
assert_eq!(state.last_visible_line(), "Continue? (Y/n): ");
}
#[test]
fn test_last_visible_line_blank_row_is_empty() {
let state = TerminalState::new(80, 24);
assert_eq!(state.last_visible_line(), "");
}
#[test]
fn test_flush_new_scrollback_no_history() {
let mut state = TerminalState::new(80, 24);
state.process_output(b"Hello");
let mut buffer = Vec::new();
let count = state.flush_new_scrollback(&mut buffer).unwrap();
assert_eq!(count, 0, "No scrollback yet, should flush 0 lines");
assert!(buffer.is_empty(), "Buffer should be empty");
}
#[test]
fn test_flush_new_scrollback_after_scroll() {
let mut state = TerminalState::new(80, 10);
for i in 1..=20 {
state.process_output(format!("Line {}\r\n", i).as_bytes());
}
let mut buffer = Vec::new();
let count = state.flush_new_scrollback(&mut buffer).unwrap();
let output = String::from_utf8_lossy(&buffer);
eprintln!(
"Scrollback test: count={}, synced={}, buffer_len={}, output:\n{}",
count,
state.synced_history_lines(),
buffer.len(),
output
);
assert!(count > 0, "Should have some scrollback lines");
assert!(
output.contains("Line 1"),
"Scrollback should contain Line 1"
);
}
#[test]
fn test_append_visible_screen() {
let mut state = TerminalState::new(80, 5);
state.process_output(b"Line A\r\nLine B\r\nLine C\r\n");
let mut buffer = Vec::new();
state.append_visible_screen(&mut buffer).unwrap();
let output = String::from_utf8_lossy(&buffer);
assert!(
output.contains("Line A"),
"Visible screen should contain Line A"
);
assert!(
output.contains("Line B"),
"Visible screen should contain Line B"
);
assert!(
output.contains("Line C"),
"Visible screen should contain Line C"
);
}
#[test]
fn test_scrollback_then_visible_no_duplication() {
let mut state = TerminalState::new(80, 5);
for i in 1..=15 {
state.process_output(format!("UNIQUELINE_{:02}\r\n", i).as_bytes());
}
let mut scrollback_buffer = Vec::new();
let scrollback_count = state.flush_new_scrollback(&mut scrollback_buffer).unwrap();
let scrollback_output = String::from_utf8_lossy(&scrollback_buffer);
let mut visible_buffer = Vec::new();
state.append_visible_screen(&mut visible_buffer).unwrap();
let visible_output = String::from_utf8_lossy(&visible_buffer);
eprintln!(
"Scrollback ({} lines):\n{}",
scrollback_count, scrollback_output
);
eprintln!("Visible screen:\n{}", visible_output);
let combined = format!("{}{}", scrollback_output, visible_output);
for i in 1..=15 {
let pattern = format!("UNIQUELINE_{:02}", i);
let count = combined.matches(&pattern).count();
assert!(
count >= 1,
"Line {} should appear at least once, but found {} times",
i,
count
);
assert!(
count <= 2,
"Line {} appears {} times - too much duplication",
i,
count
);
}
}
#[test]
fn test_backing_file_history_end_tracking() {
let mut state = TerminalState::new(80, 5);
assert_eq!(state.backing_file_history_end(), 0);
state.set_backing_file_history_end(1234);
assert_eq!(state.backing_file_history_end(), 1234);
state.reset_sync_state();
assert_eq!(state.backing_file_history_end(), 0);
assert_eq!(state.synced_history_lines(), 0);
}
#[test]
fn test_multiple_flush_cycles_no_duplication() {
use alacritty_terminal::grid::Dimensions;
let mut state = TerminalState::new(80, 5);
for i in 1..=10 {
state.process_output(format!("Batch1-Line{}\r\n", i).as_bytes());
}
let history1 = state.term.grid().history_size();
eprintln!("After Batch1: history_size={}", history1);
assert_eq!(
history1, 6,
"After 10 lines in 5-row terminal, 6 should be in history"
);
let mut buffer1 = Vec::new();
let count1 = state.flush_new_scrollback(&mut buffer1).unwrap();
let output1 = String::from_utf8_lossy(&buffer1);
eprintln!("First flush: {} lines\n{}", count1, output1);
assert_eq!(count1, 6);
assert!(output1.contains("Batch1-Line1"));
assert!(output1.contains("Batch1-Line6"));
assert!(
!output1.contains("Batch1-Line7"),
"Line 7 should still be visible, not in scrollback"
);
let mut buffer2 = Vec::new();
let count2 = state.flush_new_scrollback(&mut buffer2).unwrap();
assert_eq!(count2, 0, "Second flush without new output should be 0");
for i in 1..=10 {
state.process_output(format!("Batch2-Line{}\r\n", i).as_bytes());
}
let history3 = state.term.grid().history_size();
eprintln!("After Batch2: history_size={}", history3);
let mut buffer3 = Vec::new();
let count3 = state.flush_new_scrollback(&mut buffer3).unwrap();
let output3 = String::from_utf8_lossy(&buffer3);
eprintln!("Third flush: {} lines\n{}", count3, output3);
assert_eq!(count3, 10, "Should flush 10 new lines");
assert!(
output3.contains("Batch1-Line7"),
"Batch1-Line7 should be in third flush (was visible, now scrolled)"
);
assert!(output3.contains("Batch1-Line10"));
assert!(output3.contains("Batch2-Line1"));
assert!(output3.contains("Batch2-Line6"));
assert!(
!output3.contains("Batch1-Line1\n"),
"Batch1-Line1 was already flushed, shouldn't appear again"
);
assert!(
!output3.contains("Batch1-Line6\n"),
"Batch1-Line6 was already flushed, shouldn't appear again"
);
}
#[test]
fn test_dsr_cursor_position_response() {
let mut state = TerminalState::new(80, 24);
assert!(
state.drain_pty_write_queue().is_empty(),
"Write queue should be empty initially"
);
state.process_output(b"\x1b[6n");
let responses = state.drain_pty_write_queue();
assert_eq!(responses.len(), 1, "Should have exactly one response");
let response = &responses[0];
assert!(
response.starts_with("\x1b["),
"Response should start with ESC["
);
assert!(response.ends_with("R"), "Response should end with R");
eprintln!("DSR response: {:?}", response);
assert!(
state.drain_pty_write_queue().is_empty(),
"Write queue should be empty after draining"
);
}
#[test]
fn test_dsr_response_after_cursor_move() {
let mut state = TerminalState::new(80, 24);
state.process_output(b"\x1b[5;10H");
state.process_output(b"\x1b[6n");
let responses = state.drain_pty_write_queue();
assert_eq!(responses.len(), 1);
let response = &responses[0];
assert_eq!(response, "\x1b[5;10R", "Response should be \\x1b[5;10R");
}
#[test]
fn test_osc_set_window_title() {
let mut state = TerminalState::new(80, 24);
assert_eq!(state.title(), "");
state.process_output(b"\x1b]2;my-shell: ~/project\x07");
assert_eq!(state.title(), "my-shell: ~/project");
}
#[test]
fn test_osc_set_icon_and_window_title() {
let mut state = TerminalState::new(80, 24);
state.process_output(b"\x1b]0;vim README.md\x07");
assert_eq!(state.title(), "vim README.md");
}
#[test]
fn test_osc_title_updates_and_mixes_with_output() {
let mut state = TerminalState::new(80, 24);
state.process_output(b"\x1b]2;first\x07hello");
assert_eq!(state.title(), "first");
state.process_output(b"world\x1b]2;second\x07");
assert_eq!(state.title(), "second");
assert!(state.content_string().contains("helloworld"));
}
#[test]
fn test_osc7_sets_cwd_bel_terminated() {
let mut state = TerminalState::new(80, 24);
assert_eq!(state.cwd(), None);
state.process_output(b"\x1b]7;file://myhost/home/user/project\x07ok");
assert_eq!(
state.cwd(),
Some(std::path::Path::new("/home/user/project"))
);
let content = state.content_string();
assert!(content.contains("ok"));
assert!(!content.contains("file://"));
}
#[test]
fn test_osc7_st_terminated() {
let mut state = TerminalState::new(80, 24);
state.process_output(b"\x1b]7;file://host/var/log\x1b\\");
assert_eq!(state.cwd(), Some(std::path::Path::new("/var/log")));
}
#[test]
fn test_osc7_percent_decoded() {
let mut state = TerminalState::new(80, 24);
state.process_output(b"\x1b]7;file://host/home/user/my%20dir\x07");
assert_eq!(state.cwd(), Some(std::path::Path::new("/home/user/my dir")));
}
#[test]
fn test_osc7_split_across_reads() {
let mut state = TerminalState::new(80, 24);
state.process_output(b"\x1b]7;file://host/home/u");
assert_eq!(state.cwd(), None);
state.process_output(b"ser/split\x07");
assert_eq!(state.cwd(), Some(std::path::Path::new("/home/user/split")));
}
#[test]
fn test_osc7_updates_on_cd() {
let mut state = TerminalState::new(80, 24);
state.process_output(b"\x1b]7;file://host/first\x07");
assert_eq!(state.cwd(), Some(std::path::Path::new("/first")));
state.process_output(b"\x1b]7;file://host/second/dir\x07");
assert_eq!(state.cwd(), Some(std::path::Path::new("/second/dir")));
}
#[test]
fn test_osc7_windows_drive_path() {
let mut state = TerminalState::new(80, 24);
state.process_output(b"\x1b]7;file:///C:/Users/me/proj\x07");
assert_eq!(state.cwd(), Some(std::path::Path::new("C:/Users/me/proj")));
}
#[test]
fn test_osc7_bare_path_fallback() {
let mut state = TerminalState::new(80, 24);
state.process_output(b"\x1b]7;/opt/work\x07");
assert_eq!(state.cwd(), Some(std::path::Path::new("/opt/work")));
}
#[test]
fn test_osc7_rejects_relative() {
let mut state = TerminalState::new(80, 24);
state.process_output(b"\x1b]7;file://host/good\x07");
state.process_output(b"\x1b]7;relative/path\x07");
assert_eq!(state.cwd(), Some(std::path::Path::new("/good")));
}
}