use std::io::{self, Write};
use crossterm::cursor::{Hide, MoveTo, Show};
use crossterm::queue;
use crossterm::style::{
Attribute as CtAttribute, Color as CtColor, Colors as CtColors, Print, SetAttribute,
SetBackgroundColor, SetColors, SetForegroundColor, SetUnderlineColor,
};
use crossterm::terminal::{self, Clear};
use ratatui_core::backend::{Backend, ClearType, WindowSize};
use ratatui_core::buffer::Cell;
use ratatui_core::layout::{Position, Size};
use ratatui_core::style::{Color, Modifier};
use super::hyperlink::to_ct_color;
pub(crate) struct CrosstermBackend<W: Write> {
writer: W,
}
impl<W: Write> CrosstermBackend<W> {
pub(crate) const fn new(writer: W) -> Self {
Self { writer }
}
}
impl<W: Write> Write for CrosstermBackend<W> {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.writer.write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.writer.flush()
}
}
impl<W: Write> Backend for CrosstermBackend<W> {
type Error = io::Error;
fn draw<'a, I>(&mut self, content: I) -> io::Result<()>
where
I: Iterator<Item = (u16, u16, &'a Cell)>,
{
let mut fg = Color::Reset;
let mut bg = Color::Reset;
let mut underline_color = Color::Reset;
let mut modifier = Modifier::empty();
let mut last_pos: Option<Position> = None;
for (x, y, cell) in content {
if !matches!(last_pos, Some(p) if x == p.x + 1 && y == p.y) {
queue!(self.writer, MoveTo(x, y))?;
}
last_pos = Some(Position { x, y });
if cell.modifier != modifier {
queue_modifier_diff(&mut self.writer, modifier, cell.modifier)?;
modifier = cell.modifier;
}
if cell.fg != fg || cell.bg != bg {
queue!(
self.writer,
SetColors(CtColors::new(to_ct_color(cell.fg), to_ct_color(cell.bg)))
)?;
fg = cell.fg;
bg = cell.bg;
}
if cell.underline_color != underline_color {
queue!(
self.writer,
SetUnderlineColor(to_ct_color(cell.underline_color))
)?;
underline_color = cell.underline_color;
}
queue!(self.writer, Print(cell.symbol()))?;
}
queue!(
self.writer,
SetForegroundColor(CtColor::Reset),
SetBackgroundColor(CtColor::Reset),
SetUnderlineColor(CtColor::Reset),
SetAttribute(CtAttribute::Reset),
)
}
fn hide_cursor(&mut self) -> io::Result<()> {
queue!(self.writer, Hide)?;
self.writer.flush()
}
fn show_cursor(&mut self) -> io::Result<()> {
queue!(self.writer, Show)?;
self.writer.flush()
}
fn get_cursor_position(&mut self) -> io::Result<Position> {
crossterm::cursor::position()
.map(|(x, y)| Position { x, y })
.map_err(io::Error::other)
}
fn set_cursor_position<P: Into<Position>>(&mut self, position: P) -> io::Result<()> {
let Position { x, y } = position.into();
queue!(self.writer, MoveTo(x, y))?;
self.writer.flush()
}
fn clear(&mut self) -> io::Result<()> {
self.clear_region(ClearType::All)
}
fn clear_region(&mut self, clear_type: ClearType) -> io::Result<()> {
queue!(
self.writer,
Clear(match clear_type {
ClearType::All => terminal::ClearType::All,
ClearType::AfterCursor => terminal::ClearType::FromCursorDown,
ClearType::BeforeCursor => terminal::ClearType::FromCursorUp,
ClearType::CurrentLine => terminal::ClearType::CurrentLine,
ClearType::UntilNewLine => terminal::ClearType::UntilNewLine,
})
)?;
self.writer.flush()
}
fn append_lines(&mut self, n: u16) -> io::Result<()> {
for _ in 0..n {
queue!(self.writer, Print("\n"))?;
}
self.writer.flush()
}
fn size(&self) -> io::Result<Size> {
let (width, height) = terminal::size()?;
Ok(Size { width, height })
}
fn window_size(&mut self) -> io::Result<WindowSize> {
let terminal::WindowSize {
columns,
rows,
width,
height,
} = terminal::window_size()?;
Ok(WindowSize {
columns_rows: Size {
width: columns,
height: rows,
},
pixels: Size { width, height },
})
}
fn flush(&mut self) -> io::Result<()> {
self.writer.flush()
}
#[cfg(feature = "scrolling-regions")]
fn scroll_region_up(&mut self, region: std::ops::Range<u16>, amount: u16) -> io::Result<()> {
queue!(self.writer, ScrollInRegion::up(region, amount))?;
self.writer.flush()
}
#[cfg(feature = "scrolling-regions")]
fn scroll_region_down(&mut self, region: std::ops::Range<u16>, amount: u16) -> io::Result<()> {
queue!(self.writer, ScrollInRegion::down(region, amount))?;
self.writer.flush()
}
}
fn queue_modifier_diff<W: Write>(w: &mut W, from: Modifier, to: Modifier) -> io::Result<()> {
let removed = from - to;
if removed.contains(Modifier::REVERSED) {
queue!(w, SetAttribute(CtAttribute::NoReverse))?;
}
let reset_intensity = removed.contains(Modifier::BOLD) || removed.contains(Modifier::DIM);
if reset_intensity {
queue!(w, SetAttribute(CtAttribute::NormalIntensity))?;
if to.contains(Modifier::DIM) {
queue!(w, SetAttribute(CtAttribute::Dim))?;
}
if to.contains(Modifier::BOLD) {
queue!(w, SetAttribute(CtAttribute::Bold))?;
}
}
if removed.contains(Modifier::ITALIC) {
queue!(w, SetAttribute(CtAttribute::NoItalic))?;
}
if removed.contains(Modifier::UNDERLINED) {
queue!(w, SetAttribute(CtAttribute::NoUnderline))?;
}
if removed.contains(Modifier::CROSSED_OUT) {
queue!(w, SetAttribute(CtAttribute::NotCrossedOut))?;
}
if removed.contains(Modifier::HIDDEN) {
queue!(w, SetAttribute(CtAttribute::NoHidden))?;
}
if removed.contains(Modifier::SLOW_BLINK) || removed.contains(Modifier::RAPID_BLINK) {
queue!(w, SetAttribute(CtAttribute::NoBlink))?;
}
let added = to - from;
if added.contains(Modifier::REVERSED) {
queue!(w, SetAttribute(CtAttribute::Reverse))?;
}
if added.contains(Modifier::BOLD) && !reset_intensity {
queue!(w, SetAttribute(CtAttribute::Bold))?;
}
if added.contains(Modifier::ITALIC) {
queue!(w, SetAttribute(CtAttribute::Italic))?;
}
if added.contains(Modifier::UNDERLINED) {
queue!(w, SetAttribute(CtAttribute::Underlined))?;
}
if added.contains(Modifier::DIM) && !reset_intensity {
queue!(w, SetAttribute(CtAttribute::Dim))?;
}
if added.contains(Modifier::CROSSED_OUT) {
queue!(w, SetAttribute(CtAttribute::CrossedOut))?;
}
if added.contains(Modifier::HIDDEN) {
queue!(w, SetAttribute(CtAttribute::Hidden))?;
}
if added.contains(Modifier::SLOW_BLINK) {
queue!(w, SetAttribute(CtAttribute::SlowBlink))?;
}
if added.contains(Modifier::RAPID_BLINK) {
queue!(w, SetAttribute(CtAttribute::RapidBlink))?;
}
Ok(())
}
#[cfg(feature = "scrolling-regions")]
struct ScrollInRegion {
first_row: u16,
last_row: u16,
lines: u16,
verb: char,
}
#[cfg(feature = "scrolling-regions")]
impl ScrollInRegion {
fn up(region: std::ops::Range<u16>, lines: u16) -> Self {
Self::new(region, lines, 'S')
}
fn down(region: std::ops::Range<u16>, lines: u16) -> Self {
Self::new(region, lines, 'T')
}
fn new(region: std::ops::Range<u16>, lines: u16, verb: char) -> Self {
Self {
first_row: region.start,
last_row: region.end.saturating_sub(1),
lines,
verb,
}
}
}
#[cfg(feature = "scrolling-regions")]
impl crossterm::Command for ScrollInRegion {
fn write_ansi(&self, f: &mut impl std::fmt::Write) -> std::fmt::Result {
if self.lines == 0 {
return Ok(());
}
write!(
f,
"\x1b[{};{}r\x1b[{}{}\x1b[r",
self.first_row.saturating_add(1),
self.last_row.saturating_add(1),
self.lines,
self.verb,
)
}
#[cfg(windows)]
fn execute_winapi(&self) -> io::Result<()> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"scrolling regions are not supported through the Windows console API",
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui_core::style::Style;
fn drawn(cells: &[(&'static str, Style)]) -> String {
let owned: Vec<Cell> = cells
.iter()
.map(|&(symbol, style)| {
let mut cell = Cell::new(symbol);
cell.set_style(style);
cell
})
.collect();
let mut backend = CrosstermBackend::new(Vec::<u8>::new());
backend
.draw(
owned
.iter()
.enumerate()
.map(|(i, cell)| (i as u16, 0u16, cell)),
)
.expect("draw");
String::from_utf8(backend.writer).expect("utf8")
}
#[test]
fn a_contiguous_run_moves_the_cursor_once() {
let out = drawn(&[
("a", Style::default()),
("b", Style::default()),
("c", Style::default()),
]);
assert_eq!(out.matches("\x1b[1;1H").count(), 1);
assert_eq!(
out.matches('H').count(),
1,
"no second cursor move: {out:?}"
);
assert!(out.contains('a') && out.contains('b') && out.contains('c'));
}
#[test]
fn a_gap_in_the_run_re_emits_a_cursor_move() {
let a = Cell::new("a");
let b = Cell::new("b");
let mut backend = CrosstermBackend::new(Vec::<u8>::new());
backend
.draw([(0u16, 0u16, &a), (5u16, 0u16, &b)].into_iter())
.expect("draw");
let out = String::from_utf8(backend.writer).expect("utf8");
assert!(out.contains("\x1b[1;1H"), "{out:?}");
assert!(out.contains("\x1b[1;6H"), "{out:?}");
}
#[test]
fn colors_are_emitted_once_per_change_and_reset_at_the_end() {
let red = Style::default().fg(Color::Rgb(255, 0, 0));
let out = drawn(&[("a", red), ("b", red), ("c", Style::default())]);
assert_eq!(
out,
"\x1b[1;1H\x1b[38;2;255;0;0;49mab\x1b[39;49mc\x1b[39m\x1b[49m\x1b[59m\x1b[0m"
);
}
#[test]
fn dropping_bold_while_keeping_dim_reapplies_dim() {
let mut out = Vec::new();
queue_modifier_diff(&mut out, Modifier::BOLD | Modifier::DIM, Modifier::DIM).expect("diff");
let out = String::from_utf8(out).expect("utf8");
assert_eq!(out, "\x1b[22m\x1b[2m", "normal intensity, then dim again");
}
#[test]
fn adding_an_attribute_does_not_reset_the_others() {
let mut out = Vec::new();
queue_modifier_diff(&mut out, Modifier::BOLD, Modifier::BOLD | Modifier::ITALIC)
.expect("diff");
let out = String::from_utf8(out).expect("utf8");
assert_eq!(out, "\x1b[3m", "italic only: {out:?}");
}
#[test]
fn the_byte_stream_matches_ratatui_crossterm_exactly() {
use ratatui::backend::CrosstermBackend as Reference;
let styles = [
Style::default(),
Style::default().fg(Color::Rgb(1, 2, 3)),
Style::default().fg(Color::Indexed(200)).bg(Color::Reset),
Style::default().add_modifier(Modifier::BOLD | Modifier::DIM),
Style::default().add_modifier(Modifier::DIM),
Style::default().add_modifier(Modifier::ITALIC | Modifier::UNDERLINED),
Style::default()
.add_modifier(Modifier::SLOW_BLINK)
.underline_color(Color::LightRed),
Style::default().add_modifier(Modifier::RAPID_BLINK),
Style::default().add_modifier(Modifier::REVERSED | Modifier::CROSSED_OUT),
Style::default().add_modifier(Modifier::HIDDEN),
Style::default().bg(Color::Rgb(9, 9, 9)).fg(Color::White),
];
let symbols = ["a", "世", "\u{301}", " ", "▁"];
let cells: Vec<(u16, u16, Cell)> = styles
.iter()
.enumerate()
.map(|(i, style)| {
let mut cell = Cell::new(symbols[i % symbols.len()]);
cell.set_style(*style);
let x = if i % 3 == 0 { i as u16 + 1 } else { i as u16 };
(x, (i / 4) as u16, cell)
})
.collect();
let mut ours = CrosstermBackend::new(Vec::<u8>::new());
ours.draw(cells.iter().map(|(x, y, cell)| (*x, *y, cell)))
.expect("draw");
let mut expected = Vec::<u8>::new();
let mut reference = Reference::new(&mut expected);
ratatui::backend::Backend::draw(
&mut reference,
cells.iter().map(|(x, y, cell)| (*x, *y, cell)),
)
.expect("draw");
assert_eq!(
String::from_utf8_lossy(&ours.writer),
String::from_utf8_lossy(&expected),
);
}
#[cfg(feature = "scrolling-regions")]
#[test]
fn scrolling_a_region_sets_scrolls_and_resets_it() {
use crossterm::Command;
let mut out = String::new();
ScrollInRegion::up(2..6, 3)
.write_ansi(&mut out)
.expect("up");
assert_eq!(out, "\x1b[3;6r\x1b[3S\x1b[r");
let mut out = String::new();
ScrollInRegion::down(2..6, 1)
.write_ansi(&mut out)
.expect("down");
assert_eq!(out, "\x1b[3;6r\x1b[1T\x1b[r");
let mut out = String::new();
ScrollInRegion::up(2..6, 0)
.write_ansi(&mut out)
.expect("up");
assert_eq!(out, "");
}
}