use crate::buffer::{Cell, CellStyle, CellUpdate};
use crate::style::Color;
use crossterm::{
ExecutableCommand, cursor,
style::{Attribute, Print, ResetColor, SetAttribute, SetBackgroundColor, SetForegroundColor},
};
use std::io::{self, Write};
pub struct TerminalRenderer {
stdout: io::Stdout,
current_pos: Option<(u16, u16)>,
current_fg: Option<Color>,
current_bg: Option<Color>,
current_style: CellStyle,
supports_synchronized: bool,
}
#[derive(Debug)]
pub enum TerminalCommand {
MoveTo(u16, u16),
SetColors {
fg: Option<Color>,
bg: Option<Color>,
},
Print(String),
SetStyle(CellStyle),
Reset,
}
pub struct UpdateBatcher {
updates: Vec<CellUpdate>,
}
struct Run {
x: u16,
y: u16,
cells: Vec<Cell>,
fg: Option<Color>,
bg: Option<Color>,
style: CellStyle,
}
impl TerminalRenderer {
pub fn new() -> Self {
Self {
stdout: io::stdout(),
current_pos: None,
current_fg: None,
current_bg: None,
current_style: CellStyle::default(),
supports_synchronized: Self::detect_synchronized_output(),
}
}
fn detect_synchronized_output() -> bool {
std::env::var("TERM_PROGRAM").is_ok_and(|term| {
matches!(
term.as_str(),
"iTerm.app" | "kitty" | "alacritty" | "wezterm"
)
})
}
pub fn apply_updates(&mut self, updates: Vec<CellUpdate>) -> io::Result<()> {
if updates.is_empty() {
return Ok(());
}
if self.supports_synchronized {
self.apply_updates_synchronized(updates)
} else {
self.apply_updates_direct(updates)
}
}
pub fn apply_updates_direct(&mut self, updates: Vec<CellUpdate>) -> io::Result<()> {
for update in updates {
match update {
CellUpdate::Single { x, y, cell } => {
self.stdout.execute(cursor::MoveTo(x, y))?;
self.apply_cell_style(&cell)?;
self.stdout.execute(Print(cell.char))?;
}
CellUpdate::Run { x, y, cells } => {
for (i, cell) in cells.iter().enumerate() {
self.stdout.execute(cursor::MoveTo(x + i as u16, y))?;
self.apply_cell_style(cell)?;
self.stdout.execute(Print(cell.char))?;
}
}
}
}
self.stdout.execute(ResetColor)?;
self.stdout.execute(SetAttribute(Attribute::Reset))?;
self.stdout.flush()?;
Ok(())
}
pub fn draw_full_buffer(&mut self, buffer: &crate::buffer::ScreenBuffer) -> io::Result<()> {
let (width, height) = buffer.dimensions();
for y in 0..height {
for x in 0..width {
if let Some(cell) = buffer.get_cell(x, y) {
self.stdout.execute(cursor::MoveTo(x, y))?;
self.apply_cell_style(cell)?;
self.stdout.execute(Print(cell.char))?;
}
}
}
self.stdout.execute(ResetColor)?;
self.stdout.execute(SetAttribute(Attribute::Reset))?;
self.stdout.flush()?;
Ok(())
}
pub fn color_to_crossterm(&self, color: Color) -> crossterm::style::Color {
match color {
Color::Black => crossterm::style::Color::Black,
Color::Red => crossterm::style::Color::DarkRed,
Color::Green => crossterm::style::Color::DarkGreen,
Color::Yellow => crossterm::style::Color::DarkYellow,
Color::Blue => crossterm::style::Color::DarkBlue,
Color::Magenta => crossterm::style::Color::DarkMagenta,
Color::Cyan => crossterm::style::Color::DarkCyan,
Color::White => crossterm::style::Color::Grey,
Color::BrightBlack => crossterm::style::Color::DarkGrey,
Color::BrightRed => crossterm::style::Color::Red,
Color::BrightGreen => crossterm::style::Color::Green,
Color::BrightYellow => crossterm::style::Color::Yellow,
Color::BrightBlue => crossterm::style::Color::Blue,
Color::BrightMagenta => crossterm::style::Color::Magenta,
Color::BrightCyan => crossterm::style::Color::Cyan,
Color::BrightWhite => crossterm::style::Color::White,
Color::Rgb(r, g, b) => crossterm::style::Color::Rgb { r, g, b },
}
}
fn apply_cell_style(&mut self, cell: &Cell) -> io::Result<()> {
self.stdout.execute(SetAttribute(Attribute::Reset))?;
if let Some(fg) = &cell.fg {
self.stdout
.execute(SetForegroundColor(self.color_to_crossterm(*fg)))?;
}
if let Some(bg) = &cell.bg {
self.stdout
.execute(SetBackgroundColor(self.color_to_crossterm(*bg)))?;
}
if cell.style.bold {
self.stdout.execute(SetAttribute(Attribute::Bold))?;
}
if cell.style.italic {
self.stdout.execute(SetAttribute(Attribute::Italic))?;
}
if cell.style.underline {
self.stdout.execute(SetAttribute(Attribute::Underlined))?;
}
if cell.style.strikethrough {
self.stdout.execute(SetAttribute(Attribute::CrossedOut))?;
}
Ok(())
}
fn apply_updates_synchronized(&mut self, updates: Vec<CellUpdate>) -> io::Result<()> {
self.stdout.execute(Print("\x1b[?2026h"))?;
let result = self.apply_updates_optimized(updates);
self.stdout.execute(Print("\x1b[?2026l"))?;
self.stdout.flush()?;
result
}
fn apply_updates_optimized(&mut self, updates: Vec<CellUpdate>) -> io::Result<()> {
let batcher = UpdateBatcher::new(updates);
let commands = batcher.optimize();
for cmd in commands {
self.apply_command(cmd)?;
}
self.stdout.flush()?;
Ok(())
}
fn apply_command(&mut self, cmd: TerminalCommand) -> io::Result<()> {
match cmd {
TerminalCommand::MoveTo(x, y) => {
if self.current_pos != Some((x, y)) {
self.stdout.execute(cursor::MoveTo(x, y))?;
self.current_pos = Some((x, y));
}
}
TerminalCommand::SetColors { fg, bg } => {
self.set_colors(fg, bg)?;
}
TerminalCommand::Print(text) => {
self.stdout.execute(Print(&text))?;
if let Some((x, y)) = self.current_pos {
self.current_pos = Some((x + text.len() as u16, y));
}
}
TerminalCommand::SetStyle(style) => {
self.set_style(style)?;
}
TerminalCommand::Reset => {
self.stdout.execute(ResetColor)?;
self.stdout.execute(SetAttribute(Attribute::Reset))?;
self.current_fg = None;
self.current_bg = None;
self.current_style = CellStyle::default();
}
}
Ok(())
}
fn set_colors(&mut self, fg: Option<Color>, bg: Option<Color>) -> io::Result<()> {
if fg != self.current_fg {
match fg {
Some(color) => {
self.stdout
.execute(SetForegroundColor(to_crossterm_color(color)))?;
}
None => {
self.stdout
.execute(SetForegroundColor(crossterm::style::Color::Reset))?;
}
}
self.current_fg = fg;
}
if bg != self.current_bg {
match bg {
Some(color) => {
self.stdout
.execute(SetBackgroundColor(to_crossterm_color(color)))?;
}
None => {
self.stdout
.execute(SetBackgroundColor(crossterm::style::Color::Reset))?;
}
}
self.current_bg = bg;
}
Ok(())
}
fn set_style(&mut self, style: CellStyle) -> io::Result<()> {
if style != self.current_style {
self.stdout.execute(SetAttribute(Attribute::Reset))?;
if style.bold {
self.stdout.execute(SetAttribute(Attribute::Bold))?;
}
if style.italic {
self.stdout.execute(SetAttribute(Attribute::Italic))?;
}
if style.underline {
self.stdout.execute(SetAttribute(Attribute::Underlined))?;
}
if style.strikethrough {
self.stdout.execute(SetAttribute(Attribute::CrossedOut))?;
}
self.current_style = style;
}
Ok(())
}
pub fn reset(&mut self) -> io::Result<()> {
self.apply_command(TerminalCommand::Reset)
}
}
impl UpdateBatcher {
pub fn new(updates: Vec<CellUpdate>) -> Self {
Self { updates }
}
pub fn optimize(mut self) -> Vec<TerminalCommand> {
let mut commands = Vec::new();
self.updates.sort_by_key(|update| match update {
CellUpdate::Single { x, y, .. } => (*y, *x),
CellUpdate::Run { x, y, .. } => (*y, *x),
});
let runs = self.group_into_runs();
for run in runs {
commands.extend(run.into_commands());
}
commands
}
fn group_into_runs(self) -> Vec<Run> {
let mut runs = Vec::new();
let mut current_run: Option<Run> = None;
for update in self.updates {
match update {
CellUpdate::Single { x, y, cell } => {
if let Some(ref mut run) = current_run
&& run.can_append(x, y, &cell)
{
run.cells.push(cell);
continue;
}
if let Some(run) = current_run.take() {
runs.push(run);
}
current_run = Some(Run::new(x, y, cell));
}
CellUpdate::Run { x, y, cells } => {
if let Some(run) = current_run.take() {
runs.push(run);
}
runs.push(Run {
x,
y,
cells,
fg: None,
bg: None,
style: CellStyle::default(),
});
}
}
}
if let Some(run) = current_run {
runs.push(run);
}
runs
}
}
impl Run {
fn new(x: u16, y: u16, cell: Cell) -> Self {
let fg = cell.fg;
let bg = cell.bg;
let style = cell.style.clone();
Self {
x,
y,
cells: vec![cell],
fg,
bg,
style,
}
}
fn can_append(&self, x: u16, y: u16, cell: &Cell) -> bool {
if y != self.y || x != self.x + self.cells.len() as u16 {
return false;
}
cell.fg == self.fg && cell.bg == self.bg && cell.style == self.style
}
fn into_commands(self) -> Vec<TerminalCommand> {
let has_styles = self.style != CellStyle::default();
let mut commands = vec![
TerminalCommand::MoveTo(self.x, self.y),
TerminalCommand::SetColors {
fg: self.fg,
bg: self.bg,
},
TerminalCommand::SetStyle(self.style),
];
let text: String = self.cells.iter().map(|c| c.char).collect();
commands.push(TerminalCommand::Print(text));
if has_styles {
commands.push(TerminalCommand::Reset);
}
commands
}
}
fn to_crossterm_color(color: Color) -> crossterm::style::Color {
match color {
Color::Black => crossterm::style::Color::Black,
Color::Red => crossterm::style::Color::DarkRed,
Color::Green => crossterm::style::Color::DarkGreen,
Color::Yellow => crossterm::style::Color::DarkYellow,
Color::Blue => crossterm::style::Color::DarkBlue,
Color::Magenta => crossterm::style::Color::DarkMagenta,
Color::Cyan => crossterm::style::Color::DarkCyan,
Color::White => crossterm::style::Color::Grey,
Color::BrightBlack => crossterm::style::Color::DarkGrey,
Color::BrightRed => crossterm::style::Color::Red,
Color::BrightGreen => crossterm::style::Color::Green,
Color::BrightYellow => crossterm::style::Color::Yellow,
Color::BrightBlue => crossterm::style::Color::Blue,
Color::BrightMagenta => crossterm::style::Color::Magenta,
Color::BrightCyan => crossterm::style::Color::Cyan,
Color::BrightWhite => crossterm::style::Color::White,
Color::Rgb(r, g, b) => crossterm::style::Color::Rgb { r, g, b },
}
}
impl Default for TerminalRenderer {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::buffer::{Cell, CellStyle, CellUpdate};
use crate::style::Color;
#[test]
fn test_update_batcher_single_cell() {
let updates = vec![CellUpdate::Single {
x: 5,
y: 10,
cell: Cell {
char: 'A',
fg: Some(Color::Red),
bg: Some(Color::Blue),
style: CellStyle::default(),
},
}];
let batcher = UpdateBatcher::new(updates);
let commands = batcher.optimize();
assert_eq!(commands.len(), 4);
assert!(matches!(commands[0], TerminalCommand::MoveTo(5, 10)));
assert!(matches!(
commands[1],
TerminalCommand::SetColors {
fg: Some(Color::Red),
bg: Some(Color::Blue)
}
));
assert!(matches!(commands[2], TerminalCommand::SetStyle(_)));
assert!(matches!(commands[3], TerminalCommand::Print(ref s) if s == "A"));
}
#[test]
fn test_update_batcher_consecutive_cells_same_style() {
let style = CellStyle::default();
let updates = vec![
CellUpdate::Single {
x: 0,
y: 0,
cell: Cell {
char: 'H',
fg: Some(Color::Green),
bg: None,
style: style.clone(),
},
},
CellUpdate::Single {
x: 1,
y: 0,
cell: Cell {
char: 'e',
fg: Some(Color::Green),
bg: None,
style: style.clone(),
},
},
CellUpdate::Single {
x: 2,
y: 0,
cell: Cell {
char: 'l',
fg: Some(Color::Green),
bg: None,
style: style.clone(),
},
},
CellUpdate::Single {
x: 3,
y: 0,
cell: Cell {
char: 'l',
fg: Some(Color::Green),
bg: None,
style: style.clone(),
},
},
CellUpdate::Single {
x: 4,
y: 0,
cell: Cell {
char: 'o',
fg: Some(Color::Green),
bg: None,
style,
},
},
];
let batcher = UpdateBatcher::new(updates);
let commands = batcher.optimize();
assert_eq!(commands.len(), 4);
assert!(matches!(commands[0], TerminalCommand::MoveTo(0, 0)));
assert!(matches!(commands[3], TerminalCommand::Print(ref s) if s == "Hello"));
}
#[test]
fn test_update_batcher_different_styles() {
let updates = vec![
CellUpdate::Single {
x: 0,
y: 0,
cell: Cell {
char: 'A',
fg: Some(Color::Red),
bg: None,
style: CellStyle::default(),
},
},
CellUpdate::Single {
x: 1,
y: 0,
cell: Cell {
char: 'B',
fg: Some(Color::Blue),
bg: None,
style: CellStyle::default(),
},
},
];
let batcher = UpdateBatcher::new(updates);
let commands = batcher.optimize();
assert_eq!(commands.len(), 8);
assert!(matches!(commands[0], TerminalCommand::MoveTo(0, 0)));
assert!(matches!(
commands[1],
TerminalCommand::SetColors {
fg: Some(Color::Red),
bg: None
}
));
assert!(matches!(commands[2], TerminalCommand::SetStyle(_)));
assert!(matches!(commands[3], TerminalCommand::Print(ref s) if s == "A"));
assert!(matches!(commands[4], TerminalCommand::MoveTo(1, 0)));
assert!(matches!(
commands[5],
TerminalCommand::SetColors {
fg: Some(Color::Blue),
bg: None
}
));
assert!(matches!(commands[6], TerminalCommand::SetStyle(_)));
assert!(matches!(commands[7], TerminalCommand::Print(ref s) if s == "B"));
}
#[test]
fn test_update_batcher_sorting() {
let updates = vec![
CellUpdate::Single {
x: 5,
y: 2,
cell: Cell::new('C'),
},
CellUpdate::Single {
x: 0,
y: 0,
cell: Cell::new('A'),
},
CellUpdate::Single {
x: 3,
y: 1,
cell: Cell::new('B'),
},
];
let batcher = UpdateBatcher::new(updates);
let commands = batcher.optimize();
assert!(matches!(commands[0], TerminalCommand::MoveTo(0, 0)));
}
#[test]
fn test_update_batcher_run_type() {
let cells = vec![
Cell::new('T'),
Cell::new('e'),
Cell::new('s'),
Cell::new('t'),
];
let updates = vec![CellUpdate::Run { x: 10, y: 5, cells }];
let batcher = UpdateBatcher::new(updates);
let commands = batcher.optimize();
assert!(matches!(commands[0], TerminalCommand::MoveTo(10, 5)));
assert!(matches!(commands[3], TerminalCommand::Print(ref s) if s == "Test"));
}
#[test]
fn test_run_can_append() {
let cell1 = Cell {
char: 'A',
fg: Some(Color::Red),
bg: Some(Color::Blue),
style: CellStyle::default(),
};
let run = Run::new(5, 10, cell1.clone());
let cell2 = Cell {
char: 'B',
fg: Some(Color::Red),
bg: Some(Color::Blue),
style: CellStyle::default(),
};
assert!(run.can_append(6, 10, &cell2));
assert!(!run.can_append(6, 11, &cell2));
assert!(!run.can_append(8, 10, &cell2));
let cell3 = Cell {
char: 'C',
fg: Some(Color::Green),
bg: Some(Color::Blue),
style: CellStyle::default(),
};
assert!(!run.can_append(6, 10, &cell3));
}
#[test]
fn test_run_with_bold_style() {
let style = CellStyle {
bold: true,
..Default::default()
};
let updates = vec![CellUpdate::Single {
x: 0,
y: 0,
cell: Cell {
char: 'B',
fg: None,
bg: None,
style,
},
}];
let batcher = UpdateBatcher::new(updates);
let commands = batcher.optimize();
let has_bold_style = commands
.iter()
.any(|cmd| matches!(cmd, TerminalCommand::SetStyle(style) if style.bold));
assert!(has_bold_style);
}
#[test]
fn test_terminal_command_types() {
let move_cmd = TerminalCommand::MoveTo(10, 20);
assert!(matches!(move_cmd, TerminalCommand::MoveTo(10, 20)));
let color_cmd = TerminalCommand::SetColors {
fg: Some(Color::Red),
bg: Some(Color::Blue),
};
assert!(matches!(
color_cmd,
TerminalCommand::SetColors {
fg: Some(Color::Red),
bg: Some(Color::Blue)
}
));
let print_cmd = TerminalCommand::Print("Hello".to_string());
assert!(matches!(print_cmd, TerminalCommand::Print(ref s) if s == "Hello"));
let reset_cmd = TerminalCommand::Reset;
assert!(matches!(reset_cmd, TerminalCommand::Reset));
}
#[test]
fn test_to_crossterm_color() {
assert_eq!(
to_crossterm_color(Color::Red),
crossterm::style::Color::DarkRed
);
assert_eq!(
to_crossterm_color(Color::BrightRed),
crossterm::style::Color::Red
);
assert_eq!(
to_crossterm_color(Color::Rgb(100, 150, 200)),
crossterm::style::Color::Rgb {
r: 100,
g: 150,
b: 200
}
);
}
#[test]
fn test_empty_updates() {
let updates = vec![];
let batcher = UpdateBatcher::new(updates);
let commands = batcher.optimize();
assert!(commands.is_empty());
}
#[test]
fn test_multiple_runs_different_lines() {
let updates = vec![
CellUpdate::Single {
x: 0,
y: 0,
cell: Cell::new('A'),
},
CellUpdate::Single {
x: 1,
y: 0,
cell: Cell::new('B'),
},
CellUpdate::Single {
x: 0,
y: 1,
cell: Cell::new('C'),
},
CellUpdate::Single {
x: 1,
y: 1,
cell: Cell::new('D'),
},
];
let batcher = UpdateBatcher::new(updates);
let runs = batcher.group_into_runs();
assert_eq!(runs.len(), 2);
assert_eq!(runs[0].cells.len(), 2); assert_eq!(runs[1].cells.len(), 2); }
}