tui-test-rs 0.1.0-beta.2

In-process terminal automation, inspection, assertions, and recording
Documentation
//! Single-threaded Ghostty terminal and neutral-grid translation.

use std::cell::RefCell;
use std::rc::Rc;

use anyhow::{anyhow, Context, Result};
use compact_str::CompactString;
use ghostty_vt::error::Error as GhosttyError;
use ghostty_vt::render::{CellIterator, CursorVisualStyle, RowIterator};
use ghostty_vt::screen::{Cell as GhosttyCell, CellContentTag, CellWide, GridRef};
use ghostty_vt::style::{Palette, PaletteIndex, RgbColor, Style, StyleColor, Underline};
use ghostty_vt::terminal::{
    ConformanceLevel, DeviceAttributeFeature, DeviceAttributes, DeviceType, Point, PointCoordinate,
    PrimaryDeviceAttributes, SecondaryDeviceAttributes, TertiaryDeviceAttributes,
};
use ghostty_vt::{RenderState, Terminal};

use crate::profile::{xterm_color, ColorSlot, Profile, Rgb};
use crate::terminal::cell::{Attrs, Color, EmuCell, UnderlineStyle, CONTINUATION};
use crate::terminal::emu::CursorShape;

fn to_ghostty_rgb(color: Rgb) -> RgbColor {
    RgbColor {
        r: color.r,
        g: color.g,
        b: color.b,
    }
}

fn from_ghostty_rgb(color: RgbColor) -> Rgb {
    Rgb::new(color.r, color.g, color.b)
}

fn cell_color(color: StyleColor) -> Option<Color> {
    match color {
        StyleColor::None => None,
        StyleColor::Palette(index) => Some(Color::from_index(index.0)),
        StyleColor::Rgb(color) => Some(Color::Rgb(color.r, color.g, color.b)),
    }
}

fn underline(style: Underline) -> UnderlineStyle {
    match style {
        Underline::None => UnderlineStyle::None,
        Underline::Single => UnderlineStyle::Single,
        Underline::Double => UnderlineStyle::Double,
        Underline::Curly => UnderlineStyle::Curly,
        Underline::Dotted => UnderlineStyle::Dotted,
        Underline::Dashed => UnderlineStyle::Dashed,
        _ => UnderlineStyle::Single,
    }
}

fn cell_from_ghostty(cell: GhosttyCell, style: Style, graphemes: &[char]) -> Result<EmuCell> {
    let ch = match cell.wide().context("reading cell width")? {
        CellWide::SpacerTail => CompactString::const_new(CONTINUATION),
        CellWide::SpacerHead => CompactString::const_new(" "),
        CellWide::Narrow | CellWide::Wide if graphemes.is_empty() => CompactString::const_new(" "),
        CellWide::Narrow | CellWide::Wide => graphemes.iter().collect::<String>().into(),
    };

    let bg = match cell.content_tag().context("reading cell content")? {
        CellContentTag::BgColorPalette => Some(Color::from_index(
            cell.bg_color_palette()
                .context("reading cell palette background")?
                .0,
        )),
        CellContentTag::BgColorRgb => {
            let color = cell.bg_color_rgb().context("reading cell RGB background")?;
            Some(Color::Rgb(color.r, color.g, color.b))
        }
        CellContentTag::Codepoint | CellContentTag::CodepointGrapheme => cell_color(style.bg_color),
    };

    let mut attrs = Attrs::empty();
    for (enabled, attr) in [
        (style.bold, Attrs::BOLD),
        (style.faint, Attrs::DIM),
        (style.italic, Attrs::ITALIC),
        (style.inverse, Attrs::INVERSE),
        (style.invisible, Attrs::INVISIBLE),
        (style.strikethrough, Attrs::STRIKE),
        (style.blink, Attrs::BLINK),
    ] {
        attrs.set(attr, enabled);
    }

    Ok(EmuCell {
        ch,
        fg: cell_color(style.fg_color),
        bg,
        underline: underline(style.underline),
        underline_color: cell_color(style.underline_color),
        attrs,
    })
}

fn grid_graphemes(grid: &GridRef<'_>) -> Result<Vec<char>> {
    let mut inline = ['\0'; 8];
    match grid.graphemes(&mut inline) {
        Ok(len) => Ok(inline[..len].to_vec()),
        Err(GhosttyError::OutOfSpace { required }) => {
            let mut chars = vec!['\0'; required];
            let len = grid
                .graphemes(&mut chars)
                .context("reading cell graphemes")?;
            chars.truncate(len);
            Ok(chars)
        }
        Err(error) => Err(error).context("reading cell graphemes"),
    }
}

fn cell_from_grid(grid: &GridRef<'_>) -> Result<EmuCell> {
    let cell = grid.cell().context("reading scrollback cell value")?;
    let graphemes = if matches!(
        cell.wide().context("reading scrollback cell width")?,
        CellWide::SpacerHead | CellWide::SpacerTail
    ) {
        Vec::new()
    } else {
        grid_graphemes(grid)?
    };
    cell_from_ghostty(
        cell,
        grid.style().context("reading scrollback cell style")?,
        &graphemes,
    )
}

#[derive(Clone)]
pub(super) struct Frame {
    pub(super) rows: Vec<Vec<EmuCell>>,
    pub(super) cursor: (u16, u16),
    pub(super) cursor_visible: bool,
    pub(super) cursor_shape: CursorShape,
}

pub(super) struct GhosttyCore {
    terminal: Terminal<'static, 'static>,
    render: RenderState<'static>,
    row_iter: RowIterator<'static>,
    cell_iter: CellIterator<'static>,
    pending: Rc<RefCell<Vec<u8>>>,
    profile: Profile,
    frame: Option<Frame>,
}

impl GhosttyCore {
    pub(super) fn new(cols: u16, rows: u16, profile: Profile) -> Result<Self> {
        let mut terminal = Terminal::new(cols.max(1), rows.max(1)).context("creating terminal")?;
        terminal
            .resize(cols.max(1), rows.max(1), 1, 1)
            .context("initializing terminal cell size")?;
        terminal
            .set_scrollback_max_lines(Some(profile.scrollback))
            .context("setting scrollback limit")?;

        terminal
            .set_default_fg_color(Some(to_ghostty_rgb(profile.colors.foreground)))
            .context("setting foreground")?
            .set_default_bg_color(Some(to_ghostty_rgb(profile.colors.background)))
            .context("setting background")?
            .set_default_cursor_color(Some(to_ghostty_rgb(profile.colors.cursor)))
            .context("setting cursor color")?;

        let ansi = profile.colors.ansi();
        let mut palette = Palette::default();
        for index in 0u8..=255 {
            let color = if index < 16 {
                ansi[index as usize]
            } else {
                xterm_color(index)
            };
            palette.set(PaletteIndex(index), to_ghostty_rgb(color));
        }
        terminal
            .set_default_color_palette(Some(palette))
            .context("setting palette")?;

        let pending = Rc::new(RefCell::new(Vec::new()));
        terminal
            .on_pty_write({
                let pending = Rc::clone(&pending);
                move |_terminal, data| pending.borrow_mut().extend_from_slice(data)
            })
            .context("registering PTY replies")?
            .on_device_attributes(|_terminal| {
                Some(DeviceAttributes {
                    primary: PrimaryDeviceAttributes::new(
                        ConformanceLevel::VT220,
                        &[DeviceAttributeFeature::ANSI_COLOR],
                    ),
                    secondary: SecondaryDeviceAttributes {
                        device_type: DeviceType::VT220,
                        firmware_version: 1,
                        rom_cartridge: 0,
                    },
                    tertiary: TertiaryDeviceAttributes { unit_id: 0 },
                })
            })
            .context("registering device attributes")?;

        Ok(Self {
            terminal,
            render: RenderState::new().context("creating render state")?,
            row_iter: RowIterator::new().context("creating row iterator")?,
            cell_iter: CellIterator::new().context("creating cell iterator")?,
            pending,
            profile,
            frame: None,
        })
    }

    pub(super) fn process(&mut self, bytes: &[u8]) {
        self.terminal.vt_write(bytes);
        self.frame = None;
    }

    pub(super) fn take_pending_writes(&mut self) -> Vec<u8> {
        std::mem::take(&mut *self.pending.borrow_mut())
    }

    pub(super) fn resize(&mut self, cols: u16, rows: u16) -> Result<()> {
        self.terminal
            .resize(cols.max(1), rows.max(1), 1, 1)
            .context("resizing terminal")?;
        self.frame = None;
        Ok(())
    }

    fn capture(&mut self) -> Result<Frame> {
        let snapshot = self
            .render
            .update(&self.terminal)
            .context("updating render state")?;
        let cols = snapshot.cols().context("reading viewport width")?;
        let rows = snapshot.rows().context("reading viewport height")?;
        let cursor = (
            self.terminal
                .cursor_x()
                .context("reading cursor column")?
                .min(cols.saturating_sub(1)),
            self.terminal
                .cursor_y()
                .context("reading cursor row")?
                .min(rows.saturating_sub(1)),
        );
        let cursor_visible = snapshot
            .cursor_visible()
            .context("reading cursor visibility")?;
        let cursor_shape = match snapshot
            .cursor_visual_style()
            .context("reading cursor shape")?
        {
            CursorVisualStyle::Bar => CursorShape::Bar,
            CursorVisualStyle::Underline => CursorShape::Underline,
            _ => CursorShape::Block,
        };

        let mut output = Vec::with_capacity(rows as usize);
        let mut row_iter = self
            .row_iter
            .update(&snapshot)
            .context("starting row iteration")?;
        while let Some(row) = row_iter.next() {
            let mut output_row = Vec::with_capacity(cols as usize);
            let mut cells = self
                .cell_iter
                .update(row)
                .context("starting cell iteration")?;
            while let Some(cell) = cells.next() {
                let raw = cell.raw_cell().context("reading cell")?;
                let graphemes = if matches!(
                    raw.wide().context("reading cell width")?,
                    CellWide::SpacerHead | CellWide::SpacerTail
                ) {
                    Vec::new()
                } else {
                    cell.graphemes().context("reading cell graphemes")?
                };
                output_row.push(cell_from_ghostty(
                    raw,
                    cell.style().context("reading cell style")?,
                    &graphemes,
                )?);
            }
            if output_row.len() != cols as usize {
                return Err(anyhow!(
                    "Ghostty returned {} cells for a {cols}-column row",
                    output_row.len()
                ));
            }
            output.push(output_row);
        }
        if output.len() != rows as usize {
            return Err(anyhow!(
                "Ghostty returned {} rows for a {rows}-row viewport",
                output.len()
            ));
        }

        Ok(Frame {
            rows: output,
            cursor,
            cursor_visible,
            cursor_shape,
        })
    }

    pub(super) fn frame(&mut self) -> Result<&Frame> {
        if self.frame.is_none() {
            self.frame = Some(self.capture()?);
        }
        Ok(self.frame.as_ref().expect("frame was populated"))
    }

    pub(super) fn full_rows(&mut self) -> Result<Vec<Vec<EmuCell>>> {
        let cols = self.terminal.cols().context("reading terminal width")?;
        let available = self
            .terminal
            .scrollback_rows()
            .context("reading scrollback size")?;
        let history = available.min(self.profile.scrollback);
        let mut output = Vec::with_capacity(history + self.terminal.rows()? as usize);
        for y in available - history..available {
            let y = u32::try_from(y).context("scrollback exceeds Ghostty coordinates")?;
            let mut row = Vec::with_capacity(cols as usize);
            for x in 0..cols {
                let grid = self
                    .terminal
                    .grid_ref(Point::History(PointCoordinate { x, y }))
                    .context("reading scrollback cell")?;
                row.push(cell_from_grid(&grid)?);
            }
            output.push(row);
        }
        output.extend(self.frame()?.rows.clone());
        Ok(output)
    }

    pub(super) fn size(&self) -> Result<(u16, u16)> {
        Ok((self.terminal.cols()?, self.terminal.rows()?))
    }

    pub(super) fn color(&self, slot: ColorSlot) -> Result<Rgb> {
        let configured = match slot {
            ColorSlot::Indexed(index) => self.profile.colors.ansi().get(index as usize).copied(),
            ColorSlot::Foreground => Some(self.profile.colors.foreground),
            ColorSlot::Background => Some(self.profile.colors.background),
            ColorSlot::Cursor => Some(self.profile.colors.cursor),
        };
        let current = match slot {
            ColorSlot::Indexed(index) => Some(
                self.terminal
                    .color_palette()
                    .context("reading palette")?
                    .get(PaletteIndex(index)),
            ),
            ColorSlot::Foreground => self.terminal.fg_color().context("reading foreground")?,
            ColorSlot::Background => self.terminal.bg_color().context("reading background")?,
            ColorSlot::Cursor => self
                .terminal
                .cursor_color()
                .context("reading cursor color")?,
        };
        current
            .map(from_ghostty_rgb)
            .or(configured)
            .ok_or_else(|| anyhow!("Ghostty returned no color for {slot:?}"))
    }
}

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

    #[test]
    fn renders_wide_cells_into_the_neutral_grid() {
        let mut core = GhosttyCore::new(5, 3, Profile::default()).unwrap();
        core.process("abcd你".as_bytes());
        let rows = &core.frame().unwrap().rows;
        assert_eq!(rows[0][4].ch, " ");
        assert_eq!(rows[1][0].ch, "");
        assert_eq!(rows[1][1].ch, CONTINUATION);
    }

    #[test]
    fn queues_terminal_replies_synchronously() {
        let mut core = GhosttyCore::new(10, 4, Profile::default()).unwrap();
        core.process(b"\x1b[3;5H\x1b[6n");
        assert_eq!(core.take_pending_writes(), b"\x1b[3;5R");
    }
}