mod backend;
mod crossterm_backend;
pub mod remote_input;
#[cfg(feature = "ssh")]
mod input_parser;
#[cfg(feature = "ssh")]
mod ssh_backend;
pub use backend::{Backend, Capabilities};
pub use crossterm_backend::CrosstermBackend;
#[cfg(feature = "ssh")]
pub use input_parser::InputParser;
#[cfg(feature = "ssh")]
pub use ssh_backend::{SshBackend, SshSessionBuilder, SshSessionHandle};
use crate::core::ansi_dump;
use crate::core::draw::Cell;
use crate::core::error::Result;
use crate::core::event::Event;
use crate::core::geometry::{Point, Rect};
use crate::core::palette::Attr;
use std::io::{self, Write};
use std::sync::mpsc::Receiver;
use std::time::Duration;
fn attr_to_sgr(attr: Attr) -> String {
use crate::core::palette::Style;
let (fg_r, fg_g, fg_b) = attr.fg.to_rgb();
let (bg_r, bg_g, bg_b) = attr.bg.to_rgb();
let mut s = format!(
"\x1b[0;38;2;{};{};{};48;2;{};{};{}",
fg_r, fg_g, fg_b, bg_r, bg_g, bg_b
);
if attr.style.contains(Style::BOLD) {
s.push_str(";1");
}
if attr.style.contains(Style::DIM) {
s.push_str(";2");
}
if attr.style.contains(Style::ITALIC) {
s.push_str(";3");
}
if attr.style.contains(Style::UNDERLINE) {
s.push_str(";4");
}
if attr.style.contains(Style::REVERSE) {
s.push_str(";7");
}
if attr.style.contains(Style::STRIKETHROUGH) {
s.push_str(";9");
}
s.push('m');
s
}
pub struct Terminal {
backend: Box<dyn Backend>,
buffer: Vec<Vec<Cell>>,
prev_buffer: Vec<Vec<Cell>>,
width: u16,
height: u16,
clip_stack: Vec<Rect>,
origin_stack: Vec<Point>,
pending_event: Option<Event>,
injected_rx: Option<Receiver<Event>>,
}
impl Terminal {
pub fn init() -> Result<Self> {
let backend = CrosstermBackend::new()?;
Self::with_backend(Box::new(backend))
}
pub fn with_backend(mut backend: Box<dyn Backend>) -> Result<Self> {
backend.init()?;
let (width, height) = backend.size()?;
let empty_cell = Cell::new(' ', Attr::from_u8(0x07));
let buffer = vec![vec![empty_cell; width as usize]; height as usize];
let prev_buffer = vec![vec![empty_cell; width as usize]; height as usize];
Ok(Self {
backend,
buffer,
prev_buffer,
width,
height,
clip_stack: Vec::new(),
origin_stack: Vec::new(),
pending_event: None,
injected_rx: None,
})
}
pub fn shutdown(&mut self) -> Result<()> {
self.backend.cleanup()?;
Ok(())
}
pub fn suspend(&mut self) -> Result<()> {
self.backend.suspend()?;
Ok(())
}
pub fn resume(&mut self) -> Result<()> {
self.backend.resume()?;
let empty_cell = Cell::new(' ', Attr::from_u8(0x07));
for row in &mut self.prev_buffer {
for cell in row {
*cell = empty_cell;
}
}
Ok(())
}
pub fn size(&self) -> (i16, i16) {
(self.width as i16, self.height as i16)
}
pub fn query_size() -> io::Result<(i16, i16)> {
let (width, height) = crossterm::terminal::size()?;
Ok((width as i16, height as i16))
}
pub fn backend_size(&self) -> io::Result<(i16, i16)> {
let (width, height) = self.backend.size()?;
Ok((width as i16, height as i16))
}
pub fn query_cell_aspect_ratio() -> (i16, i16) {
use crossterm::terminal::window_size;
if let Ok(ws) = window_size() {
if ws.width > 0 && ws.height > 0 && ws.columns > 0 && ws.rows > 0 {
let cell_width = ws.width as f32 / ws.columns as f32;
let cell_height = ws.height as f32 / ws.rows as f32;
if cell_width > 0.0 {
let ratio = (cell_height / cell_width).round() as i16;
return (ratio.max(1), 1);
}
}
}
(2, 1)
}
pub fn cell_aspect_ratio(&self) -> (i16, i16) {
self.backend.cell_aspect_ratio()
}
pub fn resize(&mut self, new_width: u16, new_height: u16) {
self.width = new_width;
self.height = new_height;
let empty_cell = Cell::new(' ', Attr::from_u8(0x07));
self.buffer = vec![vec![empty_cell; new_width as usize]; new_height as usize];
let force_redraw_cell = Cell::new('\0', Attr::from_u8(0xFF));
self.prev_buffer = vec![vec![force_redraw_cell; new_width as usize]; new_height as usize];
let _ = self.backend.clear_screen();
}
pub fn set_esc_timeout(&mut self, timeout_ms: u64) {
if let Some(ct_backend) = self.backend_as_crossterm_mut() {
ct_backend.set_esc_timeout(timeout_ms);
}
}
fn backend_as_crossterm_mut(&mut self) -> Option<&mut CrosstermBackend> {
self.backend.as_any_mut().downcast_mut::<CrosstermBackend>()
}
pub fn force_full_redraw(&mut self) {
let force_cell = Cell::new('\0', Attr::from_u8(0xFF));
for row in &mut self.prev_buffer {
for cell in row {
*cell = force_cell;
}
}
}
pub fn push_clip(&mut self, mut rect: Rect) {
let o = self.origin();
rect.move_by(o.x, o.y);
self.clip_stack.push(rect);
}
pub fn push_origin(&mut self, origin: Point) {
self.origin_stack.push(origin);
}
pub fn pop_origin(&mut self) {
self.origin_stack.pop();
}
pub fn draw_view(&mut self, view: &mut (impl crate::views::View + ?Sized)) {
self.push_origin(view.bounds().a);
view.draw(self);
self.pop_origin();
}
pub fn origin(&self) -> Point {
self.origin_stack
.iter()
.fold(Point::new(0, 0), |acc, p| Point::new(acc.x + p.x, acc.y + p.y))
}
pub fn pop_clip(&mut self) {
self.clip_stack.pop();
}
fn get_clip_rect(&self) -> Option<Rect> {
if self.clip_stack.is_empty() {
None
} else {
let mut result = self.clip_stack[0];
for clip in &self.clip_stack[1..] {
result = result.intersect(clip);
}
Some(result)
}
}
fn is_clipped(&self, x: i16, y: i16) -> bool {
if let Some(clip) = self.get_clip_rect() {
!clip.contains(Point::new(x, y))
} else {
false
}
}
pub fn write_cell(&mut self, x: i16, y: i16, cell: Cell) {
let o = self.origin();
let (sx, sy) = (x + o.x, y + o.y);
if sx < 0 || sy < 0 || sx >= self.width as i16 || sy >= self.height as i16 {
return;
}
if self.is_clipped(sx, sy) {
return;
}
self.buffer[sy as usize][sx as usize] = cell;
}
pub fn write_line(&mut self, x: i16, y: i16, cells: &[Cell]) {
let o = self.origin();
let sy = y + o.y;
if sy < 0 || sy >= self.height as i16 {
return;
}
for (i, cell) in cells.iter().enumerate() {
let sx = x + o.x + i as i16;
if sx < 0 || sx >= self.width as i16 {
continue;
}
if !self.is_clipped(sx, sy) {
self.buffer[sy as usize][sx as usize] = *cell;
}
}
}
pub fn read_cell(&self, x: i16, y: i16) -> Option<Cell> {
let o = self.origin();
let (x, y) = (x + o.x, y + o.y);
if x < 0 || y < 0 || x >= self.width as i16 || y >= self.height as i16 {
return None;
}
Some(self.buffer[y as usize][x as usize])
}
pub fn clear(&mut self) {
let empty_cell = Cell::new(' ', Attr::from_u8(0x07));
for row in &mut self.buffer {
for cell in row {
*cell = empty_cell;
}
}
}
pub fn flush(&mut self) -> io::Result<()> {
let mut output = Vec::new();
for y in 0..self.height as usize {
let mut x = 0;
while x < self.width as usize {
if self.buffer[y][x] == self.prev_buffer[y][x] {
x += 1;
continue;
}
let start_x = x;
let current_attr = self.buffer[y][x].attr;
while x < self.width as usize
&& self.buffer[y][x] != self.prev_buffer[y][x]
&& self.buffer[y][x].attr == current_attr
{
x += 1;
}
write!(output, "\x1b[{};{}H", y + 1, start_x + 1)?;
output.extend_from_slice(attr_to_sgr(current_attr).as_bytes());
for i in start_x..x {
let ch = self.buffer[y][i].ch;
if ch == '\0' {
continue;
}
let mut buf = [0u8; 4];
let encoded = ch.encode_utf8(&mut buf);
output.extend_from_slice(encoded.as_bytes());
}
}
}
if !output.is_empty() {
self.backend.write_raw(&output)?;
}
self.backend.flush()?;
self.prev_buffer.clone_from(&self.buffer);
Ok(())
}
pub fn show_cursor(&mut self, x: i16, y: i16) -> io::Result<()> {
let o = self.origin();
let (sx, sy) = (x + o.x, y + o.y);
if sx < 0 || sy < 0 || sx >= self.width as i16 || sy >= self.height as i16 {
return self.backend.hide_cursor();
}
self.backend.show_cursor(sx as u16, sy as u16)
}
pub fn hide_cursor(&mut self) -> io::Result<()> {
self.backend.hide_cursor()
}
pub fn put_event(&mut self, event: Event) {
self.pending_event = Some(event);
}
pub fn enable_remote_input(&mut self, port: u16) -> io::Result<()> {
let (tx, rx) = std::sync::mpsc::channel();
remote_input::spawn(port, tx)?;
self.injected_rx = Some(rx);
Ok(())
}
pub fn poll_event(&mut self, timeout: Duration) -> io::Result<Option<Event>> {
if let Some(event) = self.pending_event.take() {
return Ok(Some(event));
}
if let Some(rx) = &self.injected_rx {
if let Ok(event) = rx.try_recv() {
return Ok(Some(event));
}
}
self.backend.poll_event(timeout)
}
pub fn read_event(&mut self) -> io::Result<Event> {
loop {
if let Some(event) = self.poll_event(Duration::from_secs(60))? {
return Ok(event);
}
}
}
pub fn query_font_pixel_size() -> Option<(u16, u16)> {
use crossterm::terminal::window_size;
if let Ok(ws) = window_size() {
if ws.columns > 0 && ws.rows > 0 && ws.width > 0 && ws.height > 0 {
return Some((ws.width / ws.columns, ws.height / ws.rows));
}
}
None
}
pub fn save_screenshot_png(&self, path: &str) -> io::Result<()> {
use crate::core::screenshot::{self, GLYPH_HEIGHT};
let scale = match Self::query_font_pixel_size() {
Some((_, ch)) if ch > 0 => {
((ch as usize + GLYPH_HEIGHT / 2) / GLYPH_HEIGHT).clamp(1, 8)
}
_ => 1,
};
screenshot::render_to_png(
&self.buffer,
self.width as usize,
self.height as usize,
scale,
std::path::Path::new(path),
)
}
pub fn dump_screen(&self, path: &str) -> io::Result<()> {
ansi_dump::dump_buffer_to_file(
&self.buffer,
self.width as usize,
self.height as usize,
path,
)
}
pub fn dump_region(
&self,
x: u16,
y: u16,
width: u16,
height: u16,
path: &str,
) -> io::Result<()> {
let mut file = std::fs::File::create(path)?;
ansi_dump::dump_buffer_region(
&mut file,
&self.buffer,
x as usize,
y as usize,
width as usize,
height as usize,
)
}
pub fn buffer(&self) -> &[Vec<Cell>] {
&self.buffer
}
pub fn flash(&mut self) -> io::Result<()> {
use std::thread;
let saved_buffer = self.buffer.clone();
for row in &mut self.buffer {
for cell in row {
let temp_fg = cell.attr.fg;
cell.attr.fg = cell.attr.bg;
cell.attr.bg = temp_fg;
}
}
self.flush()?;
thread::sleep(Duration::from_millis(50));
self.buffer = saved_buffer;
self.flush()?;
Ok(())
}
pub fn beep(&mut self) -> io::Result<()> {
self.backend.bell()
}
pub fn capabilities(&self) -> Capabilities {
self.backend.capabilities()
}
pub fn write_kitty_graphics(&mut self, data: &[u8]) -> io::Result<()> {
self.backend.write_raw(data)?;
self.backend.flush()
}
pub fn supports_kitty_graphics(&self) -> bool {
if let Ok(term) = std::env::var("TERM") {
let term_lower = term.to_lowercase();
if term_lower.contains("kitty")
|| term_lower.contains("wezterm")
|| term_lower.contains("ghostty")
{
return true;
}
}
if let Ok(term_program) = std::env::var("TERM_PROGRAM") {
let prog_lower = term_program.to_lowercase();
if prog_lower.contains("kitty")
|| prog_lower.contains("wezterm")
|| prog_lower.contains("ghostty")
{
return true;
}
}
if std::env::var("KITTY_WINDOW_ID").is_ok() {
return true;
}
false
}
pub fn delete_kitty_image(&mut self, image_id: u32) -> io::Result<()> {
let cmd = format!("\x1b_Ga=d,d=I,i={},q=2;\x1b\\", image_id);
self.write_kitty_graphics(cmd.as_bytes())
}
pub fn clear_kitty_images(&mut self) -> io::Result<()> {
self.write_kitty_graphics(b"\x1b_Ga=d,d=A,q=2;\x1b\\")
}
}
impl Drop for Terminal {
fn drop(&mut self) {
let _ = self.shutdown();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_attr_to_sgr_bold_and_reset() {
use crate::core::palette::{Attr, TvColor};
let bold = Attr::new(TvColor::White, TvColor::Black).bold();
let plain = Attr::new(TvColor::White, TvColor::Black);
let s = attr_to_sgr(bold);
assert!(
s.starts_with("\x1b[0;38;2;"),
"leading reset + truecolor fg"
);
assert!(s.contains(";48;2;"), "truecolor bg present");
assert!(s.ends_with(";1m"), "bold code appended: {s:?}");
let p = attr_to_sgr(plain);
assert!(p.starts_with("\x1b[0;38;2;"));
assert!(p.ends_with("m"));
assert!(!p.ends_with(";1m"), "no bold code for plain attr: {p:?}");
}
#[test]
fn test_attr_to_sgr_multiple_styles_ordered() {
use crate::core::palette::{Attr, Style, TvColor};
let a =
Attr::new(TvColor::White, TvColor::Blue).with_style(Style::ITALIC | Style::UNDERLINE);
let s = attr_to_sgr(a);
assert!(
s.ends_with(";3;4m"),
"style codes emitted in canonical order: {s:?}"
);
}
use crate::test_util::test_terminal;
fn cell(ch: char) -> Cell {
Cell::new(ch, Attr::from_u8(0x07))
}
#[test]
fn origin_stack_accumulates_and_pops() {
let mut t = test_terminal(20, 10);
t.push_origin(Point::new(2, 3));
t.push_origin(Point::new(4, 5));
t.write_cell(0, 0, cell('a'));
t.pop_origin();
t.write_cell(0, 0, cell('b'));
t.pop_origin();
t.write_cell(0, 0, cell('c'));
assert_eq!(t.read_cell(6, 8).unwrap().ch, 'a');
assert_eq!(t.read_cell(2, 3).unwrap().ch, 'b');
assert_eq!(t.read_cell(0, 0).unwrap().ch, 'c');
}
#[test]
fn write_line_is_translated_by_the_origin() {
let mut t = test_terminal(20, 10);
t.push_origin(Point::new(5, 1));
t.write_line(1, 2, &[cell('x'), cell('y')]);
t.pop_origin();
assert_eq!(t.read_cell(6, 3).unwrap().ch, 'x');
assert_eq!(t.read_cell(7, 3).unwrap().ch, 'y');
}
#[test]
fn negative_local_coordinates_that_land_off_screen_are_dropped() {
let mut t = test_terminal(20, 10);
t.push_origin(Point::new(2, 0));
t.write_cell(-3, 0, cell('a'));
t.write_line(-3, 0, &[cell('p'), cell('q')]);
t.pop_origin();
assert_eq!(t.read_cell(0, 0).unwrap().ch, 'q');
assert_eq!(t.read_cell(1, 0).unwrap().ch, ' ');
}
#[test]
fn clip_pushed_under_an_origin_is_stored_translated() {
let mut t = test_terminal(20, 10);
t.push_origin(Point::new(10, 0));
t.push_clip(Rect::new(0, 0, 2, 1));
t.write_cell(1, 0, cell('a'));
t.write_cell(2, 0, cell('b'));
t.pop_clip();
t.pop_origin();
assert_eq!(t.read_cell(11, 0).unwrap().ch, 'a');
assert_eq!(t.read_cell(12, 0).unwrap().ch, ' ');
}
#[test]
fn read_cell_is_translated_by_the_origin() {
let mut t = test_terminal(20, 10);
t.write_cell(7, 4, cell('z'));
t.push_origin(Point::new(7, 4));
assert_eq!(t.read_cell(0, 0).unwrap().ch, 'z');
assert!(t.read_cell(-8, 0).is_none());
}
#[test]
fn show_cursor_is_translated_by_the_origin() {
let backend = crate::test_util::TestBackend::new(20, 10);
let cursor = backend.cursor_handle();
let mut t = Terminal::with_backend(Box::new(backend)).unwrap();
t.push_origin(Point::new(3, 2));
t.show_cursor(1, 1).unwrap();
assert_eq!(*cursor.lock().unwrap(), Some((4, 3)));
}
}