tazuna 0.1.0

TUI tool for managing multiple Claude Code sessions in parallel
Documentation
//! Terminal view widget for TUI.
//!
//! Renders PTY output using vt100 parser to ratatui buffer.

use ratatui::{
    buffer::Buffer,
    layout::Rect,
    style::{Color, Modifier, Style},
    widgets::Widget,
};

/// Convert vt100 color to ratatui color
#[must_use]
pub fn vt100_color_to_ratatui(color: vt100::Color) -> Color {
    match color {
        vt100::Color::Default => Color::Reset,
        vt100::Color::Idx(i) => Color::Indexed(i),
        vt100::Color::Rgb(r, g, b) => Color::Rgb(r, g, b),
    }
}

/// Convert vt100 cell to ratatui style
#[must_use]
pub fn cell_to_style(cell: &vt100::Cell) -> Style {
    let mut style = Style::default()
        .fg(vt100_color_to_ratatui(cell.fgcolor()))
        .bg(vt100_color_to_ratatui(cell.bgcolor()));

    if cell.bold() {
        style = style.add_modifier(Modifier::BOLD);
    }
    if cell.italic() {
        style = style.add_modifier(Modifier::ITALIC);
    }
    if cell.underline() {
        style = style.add_modifier(Modifier::UNDERLINED);
    }
    if cell.inverse() {
        style = style.add_modifier(Modifier::REVERSED);
    }

    style
}

/// Terminal view widget
pub struct TerminalView<'a> {
    parser: &'a vt100::Parser,
}

impl<'a> TerminalView<'a> {
    /// Create new terminal view
    #[must_use]
    pub fn new(parser: &'a vt100::Parser) -> Self {
        Self { parser }
    }
}

impl Widget for TerminalView<'_> {
    #[allow(clippy::cast_possible_truncation)]
    fn render(self, area: Rect, buf: &mut Buffer) {
        if area.width == 0 || area.height == 0 {
            return;
        }

        // Clear area first (ratatui buffer retains previous frame content)
        for y in area.y..area.y + area.height {
            for x in area.x..area.x + area.width {
                buf[(x, y)].set_symbol(" ").set_style(Style::default());
            }
        }

        let screen = self.parser.screen();
        let screen_rows = screen.size().0 as usize;
        let screen_cols = screen.size().1 as usize;

        // vt100's set_scrollback() handles scrollback access internally
        // Just render from the bottom of the visible screen
        let visible_rows = area.height as usize;
        let start_row = screen_rows.saturating_sub(visible_rows);

        for (dy, row) in (start_row..screen_rows.min(start_row + visible_rows)).enumerate() {
            for col in 0..screen_cols.min(area.width as usize) {
                let cell = screen.cell(row as u16, col as u16);
                if let Some(cell) = cell {
                    let x = area.x + col as u16;
                    let y = area.y + dy as u16;

                    if x < area.x + area.width && y < area.y + area.height {
                        let style = cell_to_style(cell);
                        let contents = cell.contents();
                        // Use space for empty cells (vt100 returns "" for cleared cells)
                        let symbol = if contents.is_empty() { " " } else { &contents };
                        buf[(x, y)].set_symbol(symbol).set_style(style);
                    }
                }
            }
        }
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use super::*;

    #[test]
    fn test_color_conversion_default() {
        assert_eq!(vt100_color_to_ratatui(vt100::Color::Default), Color::Reset);
    }

    #[test]
    fn test_color_conversion_indexed() {
        assert_eq!(
            vt100_color_to_ratatui(vt100::Color::Idx(1)),
            Color::Indexed(1)
        );
        assert_eq!(
            vt100_color_to_ratatui(vt100::Color::Idx(255)),
            Color::Indexed(255)
        );
    }

    #[test]
    fn test_color_conversion_rgb() {
        assert_eq!(
            vt100_color_to_ratatui(vt100::Color::Rgb(100, 150, 200)),
            Color::Rgb(100, 150, 200)
        );
    }

    #[test]
    fn test_render_empty_screen() {
        let parser = vt100::Parser::new(24, 80, 0);
        let view = TerminalView::new(&parser);
        let area = Rect::new(0, 0, 40, 10);
        let mut buf = Buffer::empty(area);

        // Should not panic
        view.render(area, &mut buf);
    }

    #[test]
    fn test_render_hello_world() {
        let mut parser = vt100::Parser::new(24, 80, 0);
        parser.process(b"Hello, World!");

        let view = TerminalView::new(&parser);
        let area = Rect::new(0, 0, 80, 24);
        let mut buf = Buffer::empty(area);

        view.render(area, &mut buf);

        // Check that "Hello, World!" appears in buffer
        let mut output = String::new();
        for x in 0..13u16 {
            output.push_str(buf[(x, 0)].symbol());
        }
        assert_eq!(output, "Hello, World!");
    }

    #[test]
    fn test_render_ansi_colors() {
        let mut parser = vt100::Parser::new(24, 80, 0);
        // Red text: ESC[31m
        parser.process(b"\x1b[31mRed\x1b[0m");

        let view = TerminalView::new(&parser);
        let area = Rect::new(0, 0, 80, 24);
        let mut buf = Buffer::empty(area);

        view.render(area, &mut buf);

        // Check first cell has red foreground (indexed color 1)
        let cell = &buf[(0, 0)];
        assert_eq!(cell.fg, Color::Indexed(1));
    }

    #[test]
    fn test_render_with_scroll() {
        let mut parser = vt100::Parser::new(5, 80, 100);
        for i in 0..10 {
            parser.process(format!("Line {i}\n").as_bytes());
        }

        parser.set_scrollback(2);
        let view = TerminalView::new(&parser);
        let area = Rect::new(0, 0, 80, 5);
        let mut buf = Buffer::empty(area);
        view.render(area, &mut buf);
        parser.set_scrollback(0);
    }

    #[test]
    fn test_cell_to_style_modifiers() {
        let mut parser = vt100::Parser::new(24, 80, 0);
        // Bold text: ESC[1m
        parser.process(b"\x1b[1mBold\x1b[0m");

        let screen = parser.screen();
        let cell = screen.cell(0, 0).unwrap();
        let style = cell_to_style(cell);

        assert!(style.add_modifier.contains(Modifier::BOLD));
    }

    #[test]
    fn test_render_after_clear() {
        let mut parser = vt100::Parser::new(24, 80, 0);
        parser.process(b"Hello, World!");

        // Clear screen: ESC[2J (clear display) + ESC[H (cursor home)
        parser.process(b"\x1b[2J\x1b[H");

        let view = TerminalView::new(&parser);
        let area = Rect::new(0, 0, 80, 24);
        let mut buf = Buffer::empty(area);

        // Pre-fill buffer with 'X' to simulate previous frame content
        for y in 0..24u16 {
            for x in 0..80u16 {
                buf[(x, y)].set_symbol("X");
            }
        }

        view.render(area, &mut buf);

        // After clear + render, should be spaces not 'X'
        assert_eq!(buf[(0, 0)].symbol(), " ");
        assert_eq!(buf[(13, 0)].symbol(), " "); // Where '!' was
    }

    #[test]
    fn test_cell_to_style_italic() {
        let mut parser = vt100::Parser::new(24, 80, 0);
        // Italic text: ESC[3m
        parser.process(b"\x1b[3mItalic\x1b[0m");

        let screen = parser.screen();
        let cell = screen.cell(0, 0).unwrap();
        let style = cell_to_style(cell);

        assert!(style.add_modifier.contains(Modifier::ITALIC));
    }

    #[test]
    fn test_cell_to_style_underline() {
        let mut parser = vt100::Parser::new(24, 80, 0);
        // Underline text: ESC[4m
        parser.process(b"\x1b[4mUnderline\x1b[0m");

        let screen = parser.screen();
        let cell = screen.cell(0, 0).unwrap();
        let style = cell_to_style(cell);

        assert!(style.add_modifier.contains(Modifier::UNDERLINED));
    }

    #[test]
    fn test_cell_to_style_inverse() {
        let mut parser = vt100::Parser::new(24, 80, 0);
        // Inverse text: ESC[7m
        parser.process(b"\x1b[7mInverse\x1b[0m");

        let screen = parser.screen();
        let cell = screen.cell(0, 0).unwrap();
        let style = cell_to_style(cell);

        assert!(style.add_modifier.contains(Modifier::REVERSED));
    }

    #[test]
    fn test_render_with_large_scroll_offset() {
        let mut parser = vt100::Parser::new(5, 80, 0);
        parser.process(b"Line 1\nLine 2\nLine 3");

        // Large scrollback value should not panic
        parser.set_scrollback(100);
        let view = TerminalView::new(&parser);
        let area = Rect::new(0, 0, 80, 5);
        let mut buf = Buffer::empty(area);
        view.render(area, &mut buf);
        parser.set_scrollback(0);
    }

    #[test]
    fn test_vt100_set_scrollback_shows_history() {
        let mut parser = vt100::Parser::new(5, 20, 100);
        // Write 10 lines to 5-row screen with 100-line scrollback
        for i in 0..10 {
            parser.process(format!("Line {i}\n").as_bytes());
        }

        // Without scrollback set, screen shows latest content (Line 5-9)
        let contents = parser.screen().contents();
        assert!(contents.contains("Line 9"), "Should see Line 9: {contents}");
        assert!(
            !contents.contains("Line 0"),
            "Should NOT see Line 0 without scrollback: {contents}"
        );

        // With scrollback set to 5, should see earlier history
        parser.set_scrollback(5);
        let scrolled_contents = parser.screen().contents();
        assert!(
            scrolled_contents.contains("Line 4"),
            "Should see Line 4 with scrollback=5: {scrolled_contents}"
        );

        // Reset scrollback
        parser.set_scrollback(0);
    }

    mod snapshots {
        use super::*;
        use crate::tui::test_utils::render_to_snapshot;
        use insta::assert_snapshot;

        #[test]
        fn empty_screen() {
            // Parser size matches render area for visible content
            let parser = vt100::Parser::new(3, 20, 0);
            let view = TerminalView::new(&parser);
            assert_snapshot!(render_to_snapshot(view, 20, 3));
        }

        #[test]
        fn plain_text() {
            let mut parser = vt100::Parser::new(3, 20, 0);
            parser.process(b"Hello, World!");
            let view = TerminalView::new(&parser);
            assert_snapshot!(render_to_snapshot(view, 20, 3));
        }

        #[test]
        fn ansi_colored_text() {
            let mut parser = vt100::Parser::new(1, 15, 0);
            // Red "ERROR" + Reset + Green " OK"
            parser.process(b"\x1b[31mERROR\x1b[0m \x1b[32mOK\x1b[0m");
            let view = TerminalView::new(&parser);
            assert_snapshot!(render_to_snapshot(view, 15, 1));
        }

        #[test]
        fn ansi_bold_text() {
            let mut parser = vt100::Parser::new(1, 15, 0);
            parser.process(b"\x1b[1mBold\x1b[0m Normal");
            let view = TerminalView::new(&parser);
            assert_snapshot!(render_to_snapshot(view, 15, 1));
        }

        #[test]
        fn multi_line_content() {
            let mut parser = vt100::Parser::new(5, 10, 0);
            parser.process(b"Line 1\nLine 2\nLine 3");
            let view = TerminalView::new(&parser);
            assert_snapshot!(render_to_snapshot(view, 10, 5));
        }
    }
}