//! Wrapper around `alacritty_terminal::Term` for headless terminal emulation. //! //! Provides a simplified interface for feeding PTY output into the terminal //! state machine and reading back screen content as text, styled JSON, or HTML. use std::ops::Range; use alacritty_terminal::event::{Event, EventListener}; use alacritty_terminal::grid::Dimensions; use alacritty_terminal::index::{Column, Line}; use alacritty_terminal::term::cell::Flags; use alacritty_terminal::term::{Config, Term, TermMode}; use alacritty_terminal::vte::ansi; use crate::styled::{self, StyledLine}; /// Event listener that captures `PtyWrite` events for forwarding back to PTY. /// /// When the terminal emulator needs to respond to device status queries (DSR), /// color queries, etc., it emits `Event::PtyWrite`. These MUST be forwarded /// back to the PTY, otherwise programs like vim/tmux will hang waiting for /// a response. #[derive(Clone)] pub struct SessionListener { pty_write_tx: tokio::sync::mpsc::UnboundedSender>, } impl SessionListener { pub fn new(pty_write_tx: tokio::sync::mpsc::UnboundedSender>) -> Self { Self { pty_write_tx } } } impl EventListener for SessionListener { fn send_event(&self, event: Event) { if let Event::PtyWrite(text) = event { let _ = self.pty_write_tx.send(text.into_bytes()); } } } /// Options for querying screen content. #[derive(Debug, Clone, Default)] pub struct ScreenOpts { /// Row range (1-indexed, inclusive). None = all rows. pub rows: Option>, /// Column range (1-indexed, inclusive). None = all columns. pub cols: Option>, /// If set, replace the character at cursor position with this char. pub cursor_char: Option, /// Include trailing empty lines (default: trim them). pub include_empty: bool, } /// Plain text screen output. #[derive(Debug, Clone, serde::Serialize)] pub struct ScreenOutput { pub lines: Vec, pub cursor: CursorPosition, pub size: TerminalSize, } /// Cursor position (1-indexed). #[derive(Debug, Clone, Copy, serde::Serialize)] pub struct CursorPosition { pub row: usize, pub col: usize, } /// Terminal dimensions. #[derive(Debug, Clone, Copy, serde::Serialize)] pub struct TerminalSize { pub cols: usize, pub rows: usize, } /// Active terminal modes — tells callers what the running program has enabled. #[derive(Debug, Clone, Copy, serde::Serialize)] pub struct TerminalModes { /// Alternate screen buffer is active (vim, less, htop, etc.). pub alt_screen: bool, /// Bracketed paste mode — input pasted between ESC[200~ / ESC[201~. pub bracketed_paste: bool, /// Application cursor keys (arrow keys send SS3 instead of CSI). pub app_cursor: bool, /// Application keypad mode. pub app_keypad: bool, /// Line-wrap mode (auto-wrap at right margin). pub line_wrap: bool, /// Origin mode (cursor addressing relative to scroll region). pub origin: bool, /// Cursor is visible. pub show_cursor: bool, /// Insert mode. pub insert: bool, /// LF/NL mode (linefeed also does carriage return). pub linefeed_newline: bool, /// Focus in/out reporting enabled. pub focus_in_out: bool, /// Mouse click reporting enabled. pub mouse_reporting: bool, } /// A single line from scrollback history. #[derive(Debug, Clone, serde::Serialize)] pub struct ScrollbackLine { /// 1-indexed offset from the bottom of scrollback (1 = most recent). pub offset: usize, /// Text content of the line. pub text: String, } /// A simple `Dimensions` impl for creating a `Term`. struct TermDimensions { columns: usize, screen_lines: usize, } impl TermDimensions { fn new(columns: usize, screen_lines: usize) -> Self { Self { columns, screen_lines, } } } impl Dimensions for TermDimensions { fn total_lines(&self) -> usize { self.screen_lines } fn screen_lines(&self) -> usize { self.screen_lines } fn columns(&self) -> usize { self.columns } } /// Headless terminal emulator wrapping `alacritty_terminal`. pub struct Terminal { term: Term, parser: ansi::Processor, } impl Terminal { /// Create a new terminal with the given dimensions. pub fn new(cols: u16, rows: u16, listener: SessionListener) -> Self { let size = TermDimensions::new(cols as usize, rows as usize); let config = Config::default(); let term = Term::new(config, &size, listener); let parser = ansi::Processor::new(); Self { term, parser } } /// Feed raw bytes from PTY output into the terminal emulator. pub fn feed(&mut self, bytes: &[u8]) { self.parser.advance(&mut self.term, bytes); } /// Get the cursor position (1-indexed). pub fn cursor_position(&self) -> CursorPosition { let point = self.term.grid().cursor.point; CursorPosition { row: point.line.0 as usize + 1, col: point.column.0 + 1, } } /// Get terminal dimensions. pub fn size(&self) -> TerminalSize { TerminalSize { cols: self.term.columns(), rows: self.term.screen_lines(), } } /// Resize the terminal. pub fn resize(&mut self, cols: u16, rows: u16) { let size = TermDimensions::new(cols as usize, rows as usize); self.term.resize(size); } /// Get the active terminal modes. pub fn terminal_modes(&self) -> TerminalModes { let mode = self.term.mode(); TerminalModes { alt_screen: mode.contains(TermMode::ALT_SCREEN), bracketed_paste: mode.contains(TermMode::BRACKETED_PASTE), app_cursor: mode.contains(TermMode::APP_CURSOR), app_keypad: mode.contains(TermMode::APP_KEYPAD), line_wrap: mode.contains(TermMode::LINE_WRAP), origin: mode.contains(TermMode::ORIGIN), show_cursor: mode.contains(TermMode::SHOW_CURSOR), insert: mode.contains(TermMode::INSERT), linefeed_newline: mode.contains(TermMode::LINE_FEED_NEW_LINE), focus_in_out: mode.contains(TermMode::FOCUS_IN_OUT), mouse_reporting: mode.intersects( TermMode::MOUSE_REPORT_CLICK | TermMode::MOUSE_DRAG | TermMode::MOUSE_MOTION, ), } } /// Number of lines in the scrollback buffer. pub fn scrollback_count(&self) -> usize { self.term.grid().history_size() } /// Read scrollback lines. `count` limits how many to return (from the /// bottom / most-recent). Returns them in chronological order (oldest first). pub fn scrollback_lines(&self, count: usize) -> Vec { let grid = self.term.grid(); let history = grid.history_size(); let n = count.min(history); let num_cols = grid.columns(); let mut lines = Vec::with_capacity(n); // history lines are at negative indices: Line(-1) is most recent for offset in (1..=n).rev() { let row = &grid[Line(-(offset as i32))]; let mut text = String::new(); for col_idx in 0..num_cols { let cell = &row[Column(col_idx)]; if cell .flags .intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) { continue; } text.push(cell.c); if let Some(zw) = cell.zerowidth() { for &c in zw { text.push(c); } } } lines.push(ScrollbackLine { offset, text: text.trim_end().to_string(), }); } lines } /// Read screen content as plain text lines. pub fn screen_content(&self, opts: &ScreenOpts) -> ScreenOutput { let grid = self.term.grid(); let num_lines = grid.screen_lines(); let num_cols = grid.columns(); let cursor = self.cursor_position(); let (row_start, row_end) = resolve_range(&opts.rows, num_lines); let (col_start, col_end) = resolve_range(&opts.cols, num_cols); let mut lines = Vec::new(); for line_idx in row_start..row_end { let row = &grid[Line(line_idx as i32)]; let mut text = String::new(); for col_idx in col_start..col_end { let cell = &row[Column(col_idx)]; // Skip wide char spacers. if cell .flags .intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) { continue; } // Replace cursor position if requested. let is_cursor = cursor.row == line_idx + 1 && cursor.col == col_idx + 1; match opts.cursor_char { Some(c) if is_cursor => text.push(c), _ => { text.push(cell.c); if let Some(zw) = cell.zerowidth() { for &c in zw { text.push(c); } } } } } lines.push(text); } // Trim trailing empty lines unless include_empty is set. if !opts.include_empty { while lines.last().is_some_and(|l| l.trim().is_empty()) { lines.pop(); } } // Right-trim each line. for line in &mut lines { let trimmed = line.trim_end().to_string(); *line = trimmed; } ScreenOutput { lines, cursor, size: self.size(), } } /// Read screen content with full style information. pub fn screen_styled(&self, opts: &ScreenOpts) -> Vec { let grid = self.term.grid(); let num_lines = grid.screen_lines(); let num_cols = grid.columns(); let cursor = self.cursor_position(); let (row_start, row_end) = resolve_range(&opts.rows, num_lines); let (col_start, col_end) = resolve_range(&opts.cols, num_cols); let mut result = Vec::new(); for line_idx in row_start..row_end { let row = &grid[Line(line_idx as i32)]; let styled_line = styled::extract_styled_line(row, col_start, col_end, line_idx + 1, &cursor, opts); result.push(styled_line); } // Trim trailing empty styled lines unless include_empty is set. if !opts.include_empty { while result.last().is_some_and(|l| l.runs.is_empty()) { result.pop(); } } result } /// Render screen content as HTML. pub fn screen_html(&self, opts: &ScreenOpts) -> String { let styled = self.screen_styled(opts); styled::render_html(&styled, &self.cursor_position(), &self.size()) } } /// Resolve an optional 1-indexed range to 0-indexed (start, end). fn resolve_range(range: &Option>, max: usize) -> (usize, usize) { match range { Some(r) => { let start = r.start.saturating_sub(1).min(max); let end = r.end.min(max); (start, end) } None => (0, max), } }