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};
use alacritty_terminal::vte::ansi::Processor;
use std::io::{self, Write};
const SCROLLBACK_LINES: usize = 200_000;
struct NullListener;
impl EventListener for NullListener {
fn send_event(&self, _event: Event) {
}
}
pub struct TerminalState {
term: Term<NullListener>,
parser: Processor,
cols: u16,
rows: u16,
dirty: bool,
terminal_title: String,
synced_history_lines: usize,
backing_file_history_end: u64,
}
impl TerminalState {
pub fn new(cols: u16, rows: u16) -> Self {
let size = TermSize::new(cols as usize, rows as usize);
let mut config = TermConfig::default();
config.scrolling_history = SCROLLBACK_LINES;
let term = Term::new(config, &size, NullListener);
Self {
term,
parser: Processor::new(),
cols,
rows,
dirty: true,
terminal_title: String::new(),
synced_history_lines: 0,
backing_file_history_end: 0,
}
}
pub fn process_output(&mut self, data: &[u8]) {
self.parser.advance(&mut self.term, data);
self.dirty = true;
}
pub fn resize(&mut self, cols: u16, rows: u16) {
if cols != self.cols || rows != self.rows {
self.cols = cols;
self.rows = rows;
let size = TermSize::new(cols as usize, rows as usize);
self.term.resize(size);
self.dirty = true;
}
}
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 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 flush_new_scrollback<W: Write>(&mut self, writer: &mut W) -> io::Result<usize> {
use alacritty_terminal::grid::Dimensions;
let grid = self.term.grid();
let current_history = grid.history_size();
if current_history <= self.synced_history_lines {
return Ok(0);
}
let new_count = current_history - self.synced_history_lines;
for i in 0..new_count {
let line_idx = -((new_count - i) as i32);
self.write_grid_line(writer, Line(line_idx))?;
}
self.synced_history_lines = current_history;
Ok(new_count)
}
pub fn append_visible_screen<W: Write>(&self, writer: &mut W) -> io::Result<()> {
for row in 0..self.rows as i32 {
self.write_grid_line(writer, Line(row))?;
}
Ok(())
}
fn write_grid_line<W: Write>(&self, writer: &mut W, line: Line) -> io::Result<()> {
let grid = self.term.grid();
let row_data = &grid[line];
let mut line_str = String::with_capacity(self.cols as usize);
for col in 0..self.cols as usize {
line_str.push(row_data[Column(col)].c);
}
writeln!(writer, "{}", line_str.trim_end())
}
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.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,
}
}
}
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_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"
);
}
}