Skip to main content

ratatui_termina/
lib.rs

1// show the feature flags in the generated documentation
2#![cfg_attr(docsrs, feature(doc_cfg))]
3#![doc(
4    html_logo_url = "https://raw.githubusercontent.com/ratatui/ratatui/main/assets/logo.png",
5    html_favicon_url = "https://raw.githubusercontent.com/ratatui/ratatui/main/assets/favicon.ico"
6)]
7#![warn(missing_docs)]
8//! Render Ratatui frames through a [`termina::Terminal`].
9//!
10//! [`TerminaBackend`] writes Termina CSI/SGR escape sequences for Ratatui's [`Backend`] contract:
11//! drawing cells, moving and querying the cursor, clearing regions, flushing output, and reading
12//! terminal size. It wraps a caller-provided [`termina::Terminal`], so applications can keep using
13//! Termina's event reader and typed terminal protocol surface alongside Ratatui rendering.
14//! The Termina crate is re-exported as `ratatui_termina::termina`, so callers can use the same
15//! Termina types as the backend.
16//!
17//! The backend does not enter raw mode, switch to the alternate screen, enable bracketed paste, or
18//! install cleanup by itself. Configure those terminal modes with Termina before creating the
19//! backend and restore them when the session ends. The `termina` example in this crate shows the
20//! direct setup path with a small key-event loop.
21//!
22//! [`Backend`]: ratatui_core::backend::Backend
23#![cfg_attr(feature = "document-features", doc = "\n## Features")]
24#![cfg_attr(feature = "document-features", doc = document_features::document_features!())]
25
26use std::fmt::{self, Write as FmtWrite};
27use std::io::{self, Write};
28
29use ratatui_core::backend::{Backend, ClearType, WindowSize};
30use ratatui_core::buffer::Cell;
31use ratatui_core::layout::{Position, Size};
32use ratatui_core::style::{Color, Modifier, Style};
33pub use termina;
34use termina::escape::csi::{
35    Csi, Cursor, DecPrivateMode, DecPrivateModeCode, Edit, EraseInDisplay, EraseInLine, Mode, Sgr,
36    SgrAttributes, SgrModifiers,
37};
38use termina::style::{Blink, ColorSpec, Intensity, RgbColor, Underline};
39use termina::{Event, OneBased, Terminal};
40
41macro_rules! decset {
42    ($mode:ident) => {{
43        let mode = DecPrivateMode::Code(DecPrivateModeCode::$mode);
44        Csi::Mode(Mode::SetDecPrivateMode(mode))
45    }};
46}
47
48macro_rules! decreset {
49    ($mode:ident) => {{
50        let mode = DecPrivateMode::Code(DecPrivateModeCode::$mode);
51        Csi::Mode(Mode::ResetDecPrivateMode(mode))
52    }};
53}
54
55/// A [`Backend`] implementation that renders through a [`termina::Terminal`].
56///
57/// `TerminaBackend` writes typed Termina escape sequences for drawing, cursor control, clearing,
58/// and terminal-size queries.
59///
60/// This backend does not automatically enable raw mode or switch to the alternate screen. Use
61/// Termina's terminal APIs and typed escape sequences to configure those application-level modes
62/// before drawing.
63///
64/// # Example
65///
66/// ```rust,ignore
67/// use ratatui::Terminal;
68/// use ratatui::backend::TerminaBackend;
69/// use ratatui::termina::{PlatformTerminal, Terminal as _};
70///
71/// let mut output = PlatformTerminal::new()?;
72/// output.enter_raw_mode()?;
73///
74/// let backend = TerminaBackend::new(output);
75/// let mut terminal = Terminal::new(backend)?;
76///
77/// terminal.draw(|frame| {
78///     // -- snip --
79/// })?;
80/// # std::io::Result::Ok(())
81/// ```
82pub struct TerminaBackend<T>
83where
84    T: Terminal,
85{
86    terminal: T,
87}
88
89impl<T> TerminaBackend<T>
90where
91    T: Terminal,
92{
93    /// Creates a backend that writes to the given Termina terminal.
94    pub const fn new(terminal: T) -> Self {
95        Self { terminal }
96    }
97
98    /// Returns the wrapped terminal.
99    #[instability::unstable(
100        feature = "backend-writer",
101        issue = "https://github.com/ratatui/ratatui/pull/991"
102    )]
103    pub const fn terminal(&self) -> &T {
104        &self.terminal
105    }
106
107    /// Returns the wrapped terminal as a mutable reference.
108    ///
109    /// Direct writes can desynchronize Ratatui's diffing buffers from the visible terminal. Clear
110    /// the terminal or force a full redraw before relying on Ratatui's next diff.
111    #[instability::unstable(
112        feature = "backend-writer",
113        issue = "https://github.com/ratatui/ratatui/pull/991"
114    )]
115    pub const fn terminal_mut(&mut self) -> &mut T {
116        &mut self.terminal
117    }
118}
119
120impl<T> Write for TerminaBackend<T>
121where
122    T: Terminal,
123{
124    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
125        self.terminal.write(buf)
126    }
127
128    fn flush(&mut self) -> io::Result<()> {
129        self.terminal.flush()
130    }
131}
132
133impl<T> Backend for TerminaBackend<T>
134where
135    T: Terminal,
136{
137    type Error = io::Error;
138
139    fn draw<'a, I>(&mut self, content: I) -> io::Result<()>
140    where
141        I: Iterator<Item = (u16, u16, &'a Cell)>,
142    {
143        let mut string = String::with_capacity(content.size_hint().0 * 3);
144        let mut fg = Color::Reset;
145        let mut bg = Color::Reset;
146        #[cfg(feature = "underline-color")]
147        let mut underline_color = Color::Reset;
148        let mut modifier = Modifier::empty();
149        let mut last_pos: Option<Position> = None;
150        for (x, y, cell) in content {
151            if !matches!(last_pos, Some(p) if x == p.x + 1 && y == p.y) {
152                let command = Csi::Cursor(cursor_position(Position { x, y })?);
153                write!(string, "{command}").unwrap();
154            }
155            last_pos = Some(Position { x, y });
156
157            let mut attributes = SgrAttributes::default();
158            if cell.fg != fg {
159                attributes.foreground = Some(cell.fg.into_termina());
160                fg = cell.fg;
161            }
162            if cell.bg != bg {
163                attributes.background = Some(cell.bg.into_termina());
164                bg = cell.bg;
165            }
166            #[cfg(feature = "underline-color")]
167            if cell.underline_color != underline_color {
168                attributes.underline_color = Some(cell.underline_color.into_termina());
169                underline_color = cell.underline_color;
170            }
171            if cell.modifier != modifier {
172                attributes.modifiers = ModifierDiff {
173                    from: modifier,
174                    to: cell.modifier,
175                }
176                .into_termina();
177                modifier = cell.modifier;
178            }
179            if !attributes.is_empty() {
180                write!(string, "{}", Csi::Sgr(Sgr::Attributes(attributes))).unwrap();
181            }
182
183            string.push_str(cell.symbol());
184        }
185
186        write!(self.terminal, "{string}{}", Csi::Sgr(Sgr::Reset))
187    }
188
189    fn hide_cursor(&mut self) -> io::Result<()> {
190        let command = decreset!(ShowCursor);
191        write!(self.terminal, "{command}")?;
192        self.terminal.flush()
193    }
194
195    fn show_cursor(&mut self) -> io::Result<()> {
196        let command = decset!(ShowCursor);
197        write!(self.terminal, "{command}")?;
198        self.terminal.flush()
199    }
200
201    fn get_cursor_position(&mut self) -> io::Result<Position> {
202        let command = Csi::Cursor(Cursor::RequestActivePositionReport);
203        write!(self.terminal, "{command}")?;
204        self.terminal.flush()?;
205        let event = self.terminal.read(|event| {
206            matches!(
207                event,
208                Event::Csi(Csi::Cursor(Cursor::ActivePositionReport { .. }))
209            )
210        })?;
211        let Event::Csi(Csi::Cursor(Cursor::ActivePositionReport { line, col })) = event else {
212            return Err(io::Error::other(
213                "termina returned a non-cursor-position event",
214            ));
215        };
216        Ok(Position {
217            x: col.get_zero_based(),
218            y: line.get_zero_based(),
219        })
220    }
221
222    fn set_cursor_position<P: Into<Position>>(&mut self, position: P) -> io::Result<()> {
223        let command = Csi::Cursor(cursor_position(position.into())?);
224        write!(self.terminal, "{command}")?;
225        self.terminal.flush()
226    }
227
228    fn clear(&mut self) -> io::Result<()> {
229        self.clear_region(ClearType::All)
230    }
231
232    fn clear_region(&mut self, clear_type: ClearType) -> io::Result<()> {
233        let edit = match clear_type {
234            ClearType::All => Edit::EraseInDisplay(EraseInDisplay::EraseDisplay),
235            ClearType::AfterCursor => Edit::EraseInDisplay(EraseInDisplay::EraseToEndOfDisplay),
236            ClearType::BeforeCursor => Edit::EraseInDisplay(EraseInDisplay::EraseToStartOfDisplay),
237            ClearType::CurrentLine => Edit::EraseInLine(EraseInLine::EraseLine),
238            ClearType::UntilNewLine => Edit::EraseInLine(EraseInLine::EraseToEndOfLine),
239        };
240        let command = Csi::Edit(edit);
241        write!(self.terminal, "{command}")?;
242        self.terminal.flush()
243    }
244
245    fn append_lines(&mut self, n: u16) -> io::Result<()> {
246        for _ in 0..n {
247            writeln!(self.terminal)?;
248        }
249        self.terminal.flush()
250    }
251
252    fn size(&self) -> io::Result<Size> {
253        let size = self.terminal.get_dimensions()?;
254        Ok(Size::new(size.cols, size.rows))
255    }
256
257    fn window_size(&mut self) -> io::Result<WindowSize> {
258        let size = self.terminal.get_dimensions()?;
259        Ok(WindowSize {
260            columns_rows: Size::new(size.cols, size.rows),
261            pixels: Size::new(
262                size.pixel_width.unwrap_or_default(),
263                size.pixel_height.unwrap_or_default(),
264            ),
265        })
266    }
267
268    fn flush(&mut self) -> io::Result<()> {
269        self.terminal.flush()
270    }
271
272    #[cfg(feature = "scrolling-regions")]
273    fn scroll_region_up(&mut self, region: std::ops::Range<u16>, amount: u16) -> io::Result<()> {
274        let margins = Csi::Cursor(set_top_and_bottom_margins(region)?);
275        let scroll = Csi::Edit(Edit::ScrollUp(amount.into()));
276        let reset = Csi::Cursor(reset_top_and_bottom_margins());
277        write!(self.terminal, "{margins}{scroll}{reset}")?;
278        self.terminal.flush()
279    }
280
281    #[cfg(feature = "scrolling-regions")]
282    fn scroll_region_down(&mut self, region: std::ops::Range<u16>, amount: u16) -> io::Result<()> {
283        let margins = Csi::Cursor(set_top_and_bottom_margins(region)?);
284        let scroll = Csi::Edit(Edit::ScrollDown(amount.into()));
285        let reset = Csi::Cursor(reset_top_and_bottom_margins());
286        write!(self.terminal, "{margins}{scroll}{reset}")?;
287        self.terminal.flush()
288    }
289}
290
291fn cursor_position(position: Position) -> io::Result<Cursor> {
292    Ok(Cursor::Position {
293        line: one_based(position.y)?,
294        col: one_based(position.x)?,
295    })
296}
297
298fn one_based(n: u16) -> io::Result<OneBased> {
299    n.checked_add(1)
300        .and_then(OneBased::new)
301        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "position exceeds u16::MAX - 1"))
302}
303
304#[cfg(feature = "scrolling-regions")]
305fn set_top_and_bottom_margins(region: std::ops::Range<u16>) -> io::Result<Cursor> {
306    Ok(Cursor::SetTopAndBottomMargins {
307        top: one_based(region.start)?,
308        bottom: OneBased::new(region.end).ok_or_else(|| {
309            io::Error::new(io::ErrorKind::InvalidInput, "scroll region end cannot be 0")
310        })?,
311    })
312}
313
314#[cfg(feature = "scrolling-regions")]
315const fn reset_top_and_bottom_margins() -> Cursor {
316    Cursor::SetTopAndBottomMargins {
317        top: OneBased::from_zero_based(0),
318        bottom: OneBased::new(u16::MAX).expect("u16::MAX is non-zero"),
319    }
320}
321
322/// A trait for converting a Ratatui type to a Termina type.
323pub trait IntoTermina<T> {
324    /// Converts the ratatui type to a Termina type.
325    fn into_termina(self) -> T;
326}
327
328/// A trait for converting a Termina type to a Ratatui type.
329pub trait FromTermina<T> {
330    /// Converts the Termina type to a ratatui type.
331    fn from_termina(value: T) -> Self;
332}
333
334struct ModifierDiff {
335    from: Modifier,
336    to: Modifier,
337}
338
339impl IntoTermina<ColorSpec> for Color {
340    fn into_termina(self) -> ColorSpec {
341        match self {
342            Self::Reset => ColorSpec::Reset,
343            Self::Black => ColorSpec::BLACK,
344            Self::Red => ColorSpec::RED,
345            Self::Green => ColorSpec::GREEN,
346            Self::Yellow => ColorSpec::YELLOW,
347            Self::Blue => ColorSpec::BLUE,
348            Self::Magenta => ColorSpec::MAGENTA,
349            Self::Cyan => ColorSpec::CYAN,
350            Self::Gray => ColorSpec::WHITE,
351            Self::DarkGray => ColorSpec::BRIGHT_BLACK,
352            Self::LightRed => ColorSpec::BRIGHT_RED,
353            Self::LightGreen => ColorSpec::BRIGHT_GREEN,
354            Self::LightYellow => ColorSpec::BRIGHT_YELLOW,
355            Self::LightBlue => ColorSpec::BRIGHT_BLUE,
356            Self::LightMagenta => ColorSpec::BRIGHT_MAGENTA,
357            Self::LightCyan => ColorSpec::BRIGHT_CYAN,
358            Self::White => ColorSpec::BRIGHT_WHITE,
359            Self::Indexed(i) => ColorSpec::PaletteIndex(i),
360            Self::Rgb(r, g, b) => ColorSpec::TrueColor(RgbColor::new(r, g, b).into()),
361        }
362    }
363}
364
365impl IntoTermina<SgrAttributes> for Style {
366    fn into_termina(self) -> SgrAttributes {
367        SgrAttributes {
368            foreground: self.fg.map(IntoTermina::into_termina),
369            background: self.bg.map(IntoTermina::into_termina),
370            #[cfg(feature = "underline-color")]
371            underline_color: self.underline_color.map(IntoTermina::into_termina),
372            modifiers: ModifierDiff {
373                from: self.sub_modifier,
374                to: self.add_modifier,
375            }
376            .into_termina(),
377            ..Default::default()
378        }
379    }
380}
381
382impl FromTermina<ColorSpec> for Color {
383    fn from_termina(value: ColorSpec) -> Self {
384        match value {
385            ColorSpec::Reset => Self::Reset,
386            ColorSpec::PaletteIndex(i) => match i {
387                0 => Self::Black,
388                1 => Self::Red,
389                2 => Self::Green,
390                3 => Self::Yellow,
391                4 => Self::Blue,
392                5 => Self::Magenta,
393                6 => Self::Cyan,
394                7 => Self::Gray,
395                8 => Self::DarkGray,
396                9 => Self::LightRed,
397                10 => Self::LightGreen,
398                11 => Self::LightYellow,
399                12 => Self::LightBlue,
400                13 => Self::LightMagenta,
401                14 => Self::LightCyan,
402                15 => Self::White,
403                _ => Self::Indexed(i),
404            },
405            ColorSpec::TrueColor(color) => Self::Rgb(color.red, color.green, color.blue),
406        }
407    }
408}
409
410impl IntoTermina<SgrModifiers> for ModifierDiff {
411    fn into_termina(self) -> SgrModifiers {
412        let removed = self.from - self.to;
413        let added = self.to - self.from;
414        let mut modifiers = SgrModifiers::empty();
415
416        if removed.contains(Modifier::BOLD) || removed.contains(Modifier::DIM) {
417            modifiers |= SgrModifiers::INTENSITY_NORMAL;
418        }
419        if removed.contains(Modifier::ITALIC) {
420            modifiers |= SgrModifiers::NO_ITALIC;
421        }
422        if removed.contains(Modifier::UNDERLINED) {
423            modifiers |= SgrModifiers::UNDERLINE_NONE;
424        }
425        if removed.contains(Modifier::SLOW_BLINK) || removed.contains(Modifier::RAPID_BLINK) {
426            modifiers |= SgrModifiers::BLINK_NONE;
427        }
428        if removed.contains(Modifier::REVERSED) {
429            modifiers |= SgrModifiers::NO_REVERSE;
430        }
431        if removed.contains(Modifier::HIDDEN) {
432            modifiers |= SgrModifiers::NO_INVISIBLE;
433        }
434        if removed.contains(Modifier::CROSSED_OUT) {
435            modifiers |= SgrModifiers::NO_STRIKE_THROUGH;
436        }
437
438        if added.contains(Modifier::BOLD) {
439            modifiers |= SgrModifiers::INTENSITY_BOLD;
440        }
441        if added.contains(Modifier::DIM) {
442            modifiers |= SgrModifiers::INTENSITY_DIM;
443        }
444        if added.contains(Modifier::ITALIC) {
445            modifiers |= SgrModifiers::ITALIC;
446        }
447        if added.contains(Modifier::UNDERLINED) {
448            modifiers |= SgrModifiers::UNDERLINE_SINGLE;
449        }
450        if added.contains(Modifier::SLOW_BLINK) {
451            modifiers |= SgrModifiers::BLINK_SLOW;
452        }
453        if added.contains(Modifier::RAPID_BLINK) {
454            modifiers |= SgrModifiers::BLINK_RAPID;
455        }
456        if added.contains(Modifier::REVERSED) {
457            modifiers |= SgrModifiers::REVERSE;
458        }
459        if added.contains(Modifier::HIDDEN) {
460            modifiers |= SgrModifiers::INVISIBLE;
461        }
462        if added.contains(Modifier::CROSSED_OUT) {
463            modifiers |= SgrModifiers::STRIKE_THROUGH;
464        }
465
466        modifiers
467    }
468}
469
470impl FromTermina<Intensity> for Modifier {
471    fn from_termina(value: Intensity) -> Self {
472        match value {
473            Intensity::Normal => Self::empty(),
474            Intensity::Bold => Self::BOLD,
475            Intensity::Dim => Self::DIM,
476        }
477    }
478}
479
480impl FromTermina<Underline> for Modifier {
481    fn from_termina(value: Underline) -> Self {
482        match value {
483            Underline::None => Self::empty(),
484            _ => Self::UNDERLINED,
485        }
486    }
487}
488
489impl FromTermina<Blink> for Modifier {
490    fn from_termina(value: Blink) -> Self {
491        match value {
492            Blink::None => Self::empty(),
493            Blink::Slow => Self::SLOW_BLINK,
494            Blink::Rapid => Self::RAPID_BLINK,
495        }
496    }
497}
498
499impl<T> fmt::Debug for TerminaBackend<T>
500where
501    T: Terminal + fmt::Debug,
502{
503    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
504        f.debug_struct("TerminaBackend")
505            .field("terminal", &self.terminal)
506            .finish()
507    }
508}
509
510#[cfg(test)]
511mod tests {
512    use std::time::Duration;
513
514    use ratatui_core::buffer::Cell;
515    use termina::EventReader;
516    use termina::escape::csi::Csi;
517
518    use super::*;
519
520    #[derive(Debug)]
521    struct MockTerminal {
522        output: Vec<u8>,
523        size: termina::WindowSize,
524        events: Vec<Event>,
525    }
526
527    impl MockTerminal {
528        fn new() -> Self {
529            Self {
530                output: Vec::new(),
531                size: termina::WindowSize {
532                    cols: 80,
533                    rows: 24,
534                    pixel_width: Some(800),
535                    pixel_height: Some(480),
536                },
537                events: Vec::new(),
538            }
539        }
540
541        fn with_event(mut self, event: Event) -> Self {
542            self.events.push(event);
543            self
544        }
545
546        fn output(&self) -> String {
547            String::from_utf8_lossy(&self.output).into_owned()
548        }
549    }
550
551    impl Write for MockTerminal {
552        fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
553            self.output.extend_from_slice(buf);
554            Ok(buf.len())
555        }
556
557        fn flush(&mut self) -> io::Result<()> {
558            Ok(())
559        }
560    }
561
562    impl Terminal for MockTerminal {
563        fn enter_raw_mode(&mut self) -> io::Result<()> {
564            Ok(())
565        }
566
567        fn enter_cooked_mode(&mut self) -> io::Result<()> {
568            Ok(())
569        }
570
571        fn get_dimensions(&self) -> io::Result<termina::WindowSize> {
572            Ok(self.size)
573        }
574
575        fn event_reader(&self) -> EventReader {
576            unimplemented!("backend tests do not use event_reader")
577        }
578
579        fn poll<F: Fn(&Event) -> bool>(
580            &self,
581            filter: F,
582            _timeout: Option<Duration>,
583        ) -> io::Result<bool> {
584            Ok(self.events.iter().any(filter))
585        }
586
587        fn read<F: Fn(&Event) -> bool>(&self, filter: F) -> io::Result<Event> {
588            self.events
589                .iter()
590                .find(|event| filter(event))
591                .cloned()
592                .ok_or_else(|| io::Error::new(io::ErrorKind::WouldBlock, "no matching event"))
593        }
594
595        fn set_panic_hook(
596            &mut self,
597            _f: impl Fn(&mut termina::PlatformHandle) + Send + Sync + 'static,
598        ) {
599        }
600    }
601
602    fn backend() -> TerminaBackend<MockTerminal> {
603        TerminaBackend::new(MockTerminal::new())
604    }
605
606    #[test]
607    fn writes_cursor_visibility_commands() {
608        let mut backend = backend();
609        backend.hide_cursor().unwrap();
610        backend.show_cursor().unwrap();
611
612        let hide_cursor = decreset!(ShowCursor);
613        let show_cursor = decset!(ShowCursor);
614        assert_eq!(
615            backend.terminal.output(),
616            format!("{hide_cursor}{show_cursor}")
617        );
618    }
619
620    #[test]
621    fn reads_cursor_position_reports() {
622        let event = Event::Csi(Csi::Cursor(Cursor::ActivePositionReport {
623            line: OneBased::new(5).unwrap(),
624            col: OneBased::new(7).unwrap(),
625        }));
626        let mut backend = TerminaBackend::new(MockTerminal::new().with_event(event));
627
628        assert_eq!(backend.get_cursor_position().unwrap(), Position::new(6, 4));
629        let request = Csi::Cursor(Cursor::RequestActivePositionReport);
630        assert_eq!(backend.terminal.output(), request.to_string());
631    }
632
633    #[test]
634    fn rejects_non_cursor_position_reports() {
635        let event = Event::FocusIn;
636        let mut backend = TerminaBackend::new(MockTerminal::new().with_event(event));
637
638        let error = backend.get_cursor_position().unwrap_err();
639        assert_eq!(error.kind(), io::ErrorKind::WouldBlock);
640    }
641
642    #[test]
643    fn sets_cursor_position() {
644        let mut backend = backend();
645        backend.set_cursor_position(Position::new(3, 4)).unwrap();
646
647        let position = Cursor::Position {
648            line: OneBased::new(5).unwrap(),
649            col: OneBased::new(4).unwrap(),
650        };
651        assert_eq!(backend.terminal.output(), Csi::Cursor(position).to_string());
652    }
653
654    #[test]
655    fn rejects_cursor_position_overflow() {
656        let mut backend = backend();
657
658        let error = backend.set_cursor_position(Position::new(u16::MAX, 0));
659        assert_eq!(error.unwrap_err().kind(), io::ErrorKind::InvalidInput);
660    }
661
662    #[test]
663    fn clears_regions() {
664        let mut backend = backend();
665        backend.clear_region(ClearType::All).unwrap();
666        backend.clear_region(ClearType::AfterCursor).unwrap();
667        backend.clear_region(ClearType::BeforeCursor).unwrap();
668        backend.clear_region(ClearType::CurrentLine).unwrap();
669        backend.clear_region(ClearType::UntilNewLine).unwrap();
670
671        let expected = [
672            Csi::Edit(Edit::EraseInDisplay(EraseInDisplay::EraseDisplay)),
673            Csi::Edit(Edit::EraseInDisplay(EraseInDisplay::EraseToEndOfDisplay)),
674            Csi::Edit(Edit::EraseInDisplay(EraseInDisplay::EraseToStartOfDisplay)),
675            Csi::Edit(Edit::EraseInLine(EraseInLine::EraseLine)),
676            Csi::Edit(Edit::EraseInLine(EraseInLine::EraseToEndOfLine)),
677        ]
678        .into_iter()
679        .map(|command| command.to_string())
680        .collect::<String>();
681        assert_eq!(backend.terminal.output(), expected);
682    }
683
684    #[test]
685    fn reports_terminal_size() {
686        let mut backend = backend();
687
688        assert_eq!(backend.size().unwrap(), Size::new(80, 24));
689        assert_eq!(
690            backend.window_size().unwrap(),
691            WindowSize {
692                columns_rows: Size::new(80, 24),
693                pixels: Size::new(800, 480),
694            }
695        );
696    }
697
698    #[test]
699    fn appends_lines() {
700        let mut backend = backend();
701        backend.append_lines(3).unwrap();
702
703        assert_eq!(backend.terminal.output(), "\n\n\n");
704    }
705
706    #[test]
707    fn draws_cells_with_grouped_sgr_attributes() {
708        let mut backend = backend();
709        let mut cell = Cell::new("x");
710        cell.set_style(
711            Style::new()
712                .fg(Color::Red)
713                .bg(Color::Blue)
714                .add_modifier(Modifier::BOLD),
715        );
716        let content = [(2, 3, &cell)];
717
718        backend.draw(content.into_iter()).unwrap();
719
720        let output = backend.terminal.output();
721        let cursor = Csi::Cursor(cursor_position(Position::new(2, 3)).unwrap());
722        assert!(output.starts_with(&cursor.to_string()));
723        assert!(output.contains('x'));
724        assert!(output.ends_with(&Csi::Sgr(Sgr::Reset).to_string()));
725    }
726
727    #[test]
728    fn converts_ratatui_colors_to_termina_colors() {
729        assert_eq!(Color::Reset.into_termina(), ColorSpec::Reset);
730        assert_eq!(Color::Red.into_termina(), ColorSpec::RED);
731        assert_eq!(
732            Color::Indexed(42).into_termina(),
733            ColorSpec::PaletteIndex(42)
734        );
735        assert_eq!(
736            Color::Rgb(1, 2, 3).into_termina(),
737            ColorSpec::TrueColor(RgbColor::new(1, 2, 3).into())
738        );
739    }
740
741    #[test]
742    fn converts_termina_colors_to_ratatui_colors() {
743        assert_eq!(Color::from_termina(ColorSpec::Reset), Color::Reset);
744        assert_eq!(Color::from_termina(ColorSpec::PaletteIndex(1)), Color::Red);
745        assert_eq!(
746            Color::from_termina(ColorSpec::TrueColor(RgbColor::new(1, 2, 3).into())),
747            Color::Rgb(1, 2, 3)
748        );
749    }
750
751    #[test]
752    fn converts_modifier_diffs_to_sgr_modifiers() {
753        let from = Modifier::BOLD | Modifier::ITALIC | Modifier::UNDERLINED;
754        let to = Modifier::DIM | Modifier::REVERSED | Modifier::CROSSED_OUT;
755        let modifiers = ModifierDiff { from, to }.into_termina();
756
757        assert!(modifiers.contains(SgrModifiers::INTENSITY_NORMAL));
758        assert!(modifiers.contains(SgrModifiers::NO_ITALIC));
759        assert!(modifiers.contains(SgrModifiers::UNDERLINE_NONE));
760        assert!(modifiers.contains(SgrModifiers::INTENSITY_DIM));
761        assert!(modifiers.contains(SgrModifiers::REVERSE));
762        assert!(modifiers.contains(SgrModifiers::STRIKE_THROUGH));
763    }
764
765    #[test]
766    fn converts_termina_modifiers_to_ratatui_modifiers() {
767        assert_eq!(Modifier::from_termina(Intensity::Normal), Modifier::empty());
768        assert_eq!(Modifier::from_termina(Intensity::Bold), Modifier::BOLD);
769        assert_eq!(Modifier::from_termina(Underline::None), Modifier::empty());
770        assert_eq!(
771            Modifier::from_termina(Underline::Single),
772            Modifier::UNDERLINED
773        );
774        assert_eq!(Modifier::from_termina(Blink::None), Modifier::empty());
775        assert_eq!(Modifier::from_termina(Blink::Rapid), Modifier::RAPID_BLINK);
776    }
777
778    #[cfg(feature = "scrolling-regions")]
779    #[test]
780    fn scrolls_regions() {
781        let mut backend = backend();
782        backend.scroll_region_up(1..4, 2).unwrap();
783        backend.scroll_region_down(1..4, 3).unwrap();
784
785        let margins = Cursor::SetTopAndBottomMargins {
786            top: OneBased::new(2).unwrap(),
787            bottom: OneBased::new(4).unwrap(),
788        };
789        let reset = reset_top_and_bottom_margins();
790        let up = Csi::Edit(Edit::ScrollUp(2_u16.into()));
791        let down = Csi::Edit(Edit::ScrollDown(3_u16.into()));
792        let expected = format!(
793            "{}{up}{}{}{down}{}",
794            Csi::Cursor(margins.clone()),
795            Csi::Cursor(reset.clone()),
796            Csi::Cursor(margins),
797            Csi::Cursor(reset)
798        );
799        assert_eq!(backend.terminal.output(), expected);
800    }
801
802    #[cfg(feature = "scrolling-regions")]
803    #[test]
804    fn rejects_zero_ended_scroll_regions() {
805        let mut backend = backend();
806
807        let error = backend.scroll_region_up(0..0, 1).unwrap_err();
808        assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
809    }
810
811    #[test]
812    fn csi_helpers_use_one_based_coordinates() {
813        assert_eq!(
814            cursor_position(Position::new(1, 2)).unwrap(),
815            Cursor::Position {
816                line: OneBased::new(3).unwrap(),
817                col: OneBased::new(2).unwrap(),
818            }
819        );
820        assert_eq!(one_based(0).unwrap(), OneBased::new(1).unwrap());
821    }
822}