use crossterm::{
cursor::{Hide, MoveTo, Show},
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use std::io::{Write, stdout};
use std::time::Duration;
mod ansi {
pub fn cursor_to(row: u16, col: u16) -> String {
format!("\x1b[{};{}H", row + 1, col + 1)
}
pub fn cursor_home() -> &'static str {
"\x1b[H"
}
pub fn cursor_to_column(col: u16) -> String {
format!("\x1b[{}G", col + 1)
}
pub fn cursor_up(n: u16) -> String {
if n == 0 {
String::new()
} else {
format!("\x1b[{}A", n)
}
}
pub fn erase_end_of_line() -> &'static str {
"\x1b[K"
}
pub fn erase_line() -> &'static str {
"\x1b[2K"
}
pub fn erase_screen() -> &'static str {
"\x1b[2J"
}
pub fn hide_cursor() -> &'static str {
"\x1b[?25l"
}
pub fn show_cursor() -> &'static str {
"\x1b[?25h"
}
pub fn enter_alt_screen() -> &'static str {
"\x1b[?1049h"
}
pub fn leave_alt_screen() -> &'static str {
"\x1b[?1049l"
}
}
pub struct Terminal {
previous_lines: Vec<String>,
last_output: String,
alternate_screen: bool,
cursor_hidden: bool,
raw_mode: bool,
mouse_enabled: bool,
inline_lines_rendered: usize,
}
impl Terminal {
pub fn new() -> Self {
Self {
previous_lines: Vec::new(),
last_output: String::new(),
alternate_screen: false,
cursor_hidden: false,
raw_mode: false,
mouse_enabled: false,
inline_lines_rendered: 0,
}
}
pub fn is_alt_screen(&self) -> bool {
self.alternate_screen
}
pub fn enter(&mut self) -> std::io::Result<()> {
enable_raw_mode()?;
self.raw_mode = true;
execute!(stdout(), EnterAlternateScreen, Hide)?;
self.alternate_screen = true;
self.cursor_hidden = true;
Ok(())
}
pub fn exit(&mut self) -> std::io::Result<()> {
if self.mouse_enabled {
execute!(stdout(), DisableMouseCapture)?;
self.mouse_enabled = false;
}
if self.alternate_screen {
execute!(stdout(), Show, LeaveAlternateScreen)?;
self.alternate_screen = false;
self.cursor_hidden = false;
}
if self.raw_mode {
disable_raw_mode()?;
self.raw_mode = false;
}
Ok(())
}
pub fn enter_inline(&mut self) -> std::io::Result<()> {
enable_raw_mode()?;
self.raw_mode = true;
let mut stdout = stdout();
write!(stdout, "{}", ansi::hide_cursor())?;
stdout.flush()?;
self.cursor_hidden = true;
self.inline_lines_rendered = 0;
Ok(())
}
pub fn exit_inline(&mut self) -> std::io::Result<()> {
let mut stdout = stdout();
if self.mouse_enabled {
execute!(stdout, DisableMouseCapture)?;
self.mouse_enabled = false;
}
if self.cursor_hidden {
write!(stdout, "{}", ansi::show_cursor())?;
self.cursor_hidden = false;
}
let line_count = self.previous_lines.len();
if line_count > 0 {
writeln!(stdout)?;
}
stdout.flush()?;
if self.raw_mode {
disable_raw_mode()?;
self.raw_mode = false;
}
Ok(())
}
pub fn switch_to_alt_screen(&mut self) -> std::io::Result<()> {
if self.alternate_screen {
return Ok(());
}
let mut stdout = stdout();
self.clear_inline_content()?;
write!(stdout, "{}", ansi::enter_alt_screen())?;
write!(stdout, "{}", ansi::erase_screen())?;
write!(stdout, "{}", ansi::cursor_home())?;
if !self.cursor_hidden {
write!(stdout, "{}", ansi::hide_cursor())?;
self.cursor_hidden = true;
}
stdout.flush()?;
self.alternate_screen = true;
self.previous_lines.clear();
self.inline_lines_rendered = 0;
Ok(())
}
pub fn switch_to_inline(&mut self) -> std::io::Result<()> {
if !self.alternate_screen {
return Ok(());
}
let mut stdout = stdout();
write!(stdout, "{}", ansi::leave_alt_screen())?;
if self.cursor_hidden {
write!(stdout, "{}", ansi::show_cursor())?;
self.cursor_hidden = false;
}
stdout.flush()?;
self.alternate_screen = false;
self.previous_lines.clear();
self.inline_lines_rendered = 0;
write!(stdout, "{}", ansi::hide_cursor())?;
stdout.flush()?;
self.cursor_hidden = true;
Ok(())
}
fn clear_inline_content(&mut self) -> std::io::Result<()> {
if self.previous_lines.is_empty() {
return Ok(());
}
let mut stdout = stdout();
let line_count = self.previous_lines.len();
if line_count > 1 {
write!(stdout, "{}", ansi::cursor_up(line_count as u16 - 1))?;
}
write!(stdout, "{}", ansi::cursor_to_column(0))?;
for i in 0..line_count {
write!(stdout, "{}", ansi::erase_line())?;
if i < line_count - 1 {
write!(stdout, "\r\n")?;
}
}
if line_count > 1 {
write!(stdout, "{}", ansi::cursor_up(line_count as u16 - 1))?;
}
write!(stdout, "{}", ansi::cursor_to_column(0))?;
stdout.flush()?;
self.previous_lines.clear();
self.inline_lines_rendered = 0;
Ok(())
}
pub fn println(&mut self, message: &str) -> std::io::Result<()> {
if self.alternate_screen {
return Ok(());
}
let mut stdout = stdout();
self.clear_inline_content()?;
for line in message.lines() {
write!(stdout, "{}{}\r\n", line, ansi::erase_end_of_line())?;
}
stdout.flush()?;
self.repaint();
Ok(())
}
pub fn render(&mut self, output: &str) -> std::io::Result<()> {
if output == self.last_output && !self.previous_lines.is_empty() {
return Ok(());
}
let result = if self.alternate_screen {
self.render_fullscreen(output)
} else {
self.render_inline(output)
};
if result.is_ok() {
self.last_output = output.to_string();
}
result
}
fn render_fullscreen(&mut self, output: &str) -> std::io::Result<()> {
let mut stdout = stdout();
execute!(stdout, MoveTo(0, 0))?;
let new_lines: Vec<&str> = output.lines().collect();
for (i, new_line) in new_lines.iter().enumerate() {
let old_line = self.previous_lines.get(i).map(|s| s.as_str());
if old_line != Some(*new_line) {
write!(
stdout,
"{}{}{}",
ansi::cursor_to(i as u16, 0),
ansi::erase_line(),
new_line
)?;
}
}
if self.previous_lines.len() > new_lines.len() {
for i in new_lines.len()..self.previous_lines.len() {
write!(
stdout,
"{}{}",
ansi::cursor_to(i as u16, 0),
ansi::erase_line()
)?;
}
}
stdout.flush()?;
self.previous_lines = new_lines.iter().map(|s| s.to_string()).collect();
Ok(())
}
fn render_inline(&mut self, output: &str) -> std::io::Result<()> {
let mut stdout = stdout();
let new_lines: Vec<&str> = output.lines().collect();
let new_count = new_lines.len();
let lines_on_screen = self.inline_lines_rendered;
if lines_on_screen > 0 {
if lines_on_screen > 1 {
write!(stdout, "{}", ansi::cursor_up(lines_on_screen as u16 - 1))?;
}
write!(stdout, "{}", ansi::cursor_to_column(0))?;
}
let max_lines = lines_on_screen.max(new_count);
for (i, new_line) in new_lines.iter().enumerate() {
let old_line = self.previous_lines.get(i).map(|s| s.as_str());
if old_line != Some(*new_line) {
write!(stdout, "{}{}", ansi::erase_line(), new_line)?;
}
if i < max_lines - 1 {
write!(stdout, "\r\n")?;
}
}
for i in new_count..max_lines {
write!(stdout, "{}", ansi::erase_line())?;
if i < max_lines - 1 {
write!(stdout, "\r\n")?;
}
}
if new_count < lines_on_screen {
let lines_to_go_up = lines_on_screen - new_count;
write!(stdout, "{}", ansi::cursor_up(lines_to_go_up as u16))?;
}
write!(stdout, "{}", ansi::cursor_to_column(0))?;
stdout.flush()?;
self.previous_lines = new_lines.iter().map(|s| s.to_string()).collect();
self.inline_lines_rendered = new_count;
Ok(())
}
pub fn clear(&mut self) -> std::io::Result<()> {
if self.previous_lines.is_empty() {
return Ok(());
}
let mut stdout = stdout();
let line_count = self.previous_lines.len();
if self.alternate_screen {
execute!(stdout, MoveTo(0, 0))?;
for i in 0..line_count {
write!(
stdout,
"{}{}",
ansi::cursor_to(i as u16, 0),
ansi::erase_line()
)?;
}
} else {
if line_count > 1 {
write!(stdout, "{}", ansi::cursor_up(line_count as u16 - 1))?;
}
for _ in 0..line_count {
writeln!(
stdout,
"{}{}",
ansi::cursor_to_column(0),
ansi::erase_line()
)?;
}
write!(stdout, "{}", ansi::cursor_up(line_count as u16))?;
}
stdout.flush()?;
self.previous_lines.clear();
self.inline_lines_rendered = 0;
Ok(())
}
pub fn repaint(&mut self) {
self.previous_lines.clear();
self.last_output.clear();
}
pub fn size() -> std::io::Result<(u16, u16)> {
crossterm::terminal::size()
}
pub fn poll_event(timeout: Duration) -> std::io::Result<Option<Event>> {
if event::poll(timeout)? {
Ok(Some(event::read()?))
} else {
Ok(None)
}
}
pub fn read_event() -> std::io::Result<Event> {
event::read()
}
pub fn is_ctrl_c(event: &Event) -> bool {
matches!(
event,
Event::Key(crossterm::event::KeyEvent {
code: KeyCode::Char('c'),
modifiers,
..
}) if modifiers.contains(KeyModifiers::CONTROL)
)
}
pub fn enable_mouse(&mut self) -> std::io::Result<()> {
if !self.mouse_enabled {
execute!(stdout(), EnableMouseCapture)?;
self.mouse_enabled = true;
}
Ok(())
}
pub fn disable_mouse(&mut self) -> std::io::Result<()> {
if self.mouse_enabled {
execute!(stdout(), DisableMouseCapture)?;
self.mouse_enabled = false;
}
Ok(())
}
pub fn is_mouse_enabled(&self) -> bool {
self.mouse_enabled
}
pub fn suspend(&mut self) -> std::io::Result<()> {
let mut stdout = stdout();
if self.mouse_enabled {
execute!(stdout, DisableMouseCapture)?;
}
if self.cursor_hidden {
write!(stdout, "{}", ansi::show_cursor())?;
}
if self.alternate_screen {
write!(stdout, "{}", ansi::leave_alt_screen())?;
}
if self.raw_mode {
disable_raw_mode()?;
}
stdout.flush()?;
Ok(())
}
pub fn resume(&mut self) -> std::io::Result<()> {
let mut stdout = stdout();
if self.raw_mode {
enable_raw_mode()?;
}
if self.alternate_screen {
write!(stdout, "{}", ansi::enter_alt_screen())?;
write!(stdout, "{}", ansi::erase_screen())?;
write!(stdout, "{}", ansi::cursor_home())?;
}
if self.cursor_hidden {
write!(stdout, "{}", ansi::hide_cursor())?;
}
if self.mouse_enabled {
execute!(stdout, EnableMouseCapture)?;
}
stdout.flush()?;
self.repaint();
Ok(())
}
}
impl Default for Terminal {
fn default() -> Self {
Self::new()
}
}
impl Drop for Terminal {
fn drop(&mut self) {
let _ = self.exit();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_terminal_size() {
if let Ok((width, height)) = Terminal::size() {
assert!(width > 0);
assert!(height > 0);
}
}
#[test]
fn test_ansi_codes() {
assert_eq!(ansi::cursor_to(0, 0), "\x1b[1;1H");
assert_eq!(ansi::cursor_to(5, 10), "\x1b[6;11H");
assert_eq!(ansi::cursor_up(3), "\x1b[3A");
assert_eq!(ansi::erase_line(), "\x1b[2K");
assert_eq!(ansi::cursor_home(), "\x1b[H");
assert_eq!(ansi::erase_screen(), "\x1b[2J");
assert_eq!(ansi::enter_alt_screen(), "\x1b[?1049h");
assert_eq!(ansi::leave_alt_screen(), "\x1b[?1049l");
}
#[test]
fn test_terminal_new() {
let terminal = Terminal::new();
assert!(!terminal.is_alt_screen());
assert!(terminal.previous_lines.is_empty());
assert!(terminal.last_output.is_empty());
assert_eq!(terminal.inline_lines_rendered, 0);
}
#[test]
fn test_repaint_clears_previous_lines() {
let mut terminal = Terminal::new();
terminal.previous_lines = vec!["line1".to_string(), "line2".to_string()];
terminal.last_output = "line1\nline2".to_string();
terminal.repaint();
assert!(terminal.previous_lines.is_empty());
assert!(terminal.last_output.is_empty());
}
#[test]
fn test_fast_path_identical_output() {
let mut terminal = Terminal::new();
terminal.previous_lines = vec!["line1".to_string(), "line2".to_string()];
terminal.last_output = "line1\nline2".to_string();
terminal.inline_lines_rendered = 2;
assert_eq!(terminal.last_output, "line1\nline2");
assert!(!terminal.previous_lines.is_empty());
}
}