terminal-control 0.3.0

Control and test terminal applications through stable visible state
Documentation
use serde::{Deserialize, Serialize};
use vt100::{Color as TerminalColor, Screen};

/// Schema version written in every structured terminal frame.
pub const FORMAT_VERSION: u8 = 1;

#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Deserialize, Serialize)]
pub struct Color {
    pub r: u8,
    pub g: u8,
    pub b: u8,
}

pub const DEFAULT_FOREGROUND: Color = Color {
    r: 201,
    g: 209,
    b: 217,
};
pub const DEFAULT_BACKGROUND: Color = Color {
    r: 13,
    g: 17,
    b: 23,
};

impl Color {
    pub fn css(self) -> String {
        format!("#{:02x}{:02x}{:02x}", self.r, self.g, self.b)
    }
}

#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Deserialize, Serialize)]
pub struct Attributes {
    pub bold: bool,
    pub italic: bool,
    pub faint: bool,
    pub invisible: bool,
    pub strikethrough: bool,
    pub overline: bool,
    pub underline: Option<Underline>,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Underline {
    Single,
}

#[derive(Clone, Debug, PartialEq, Eq, Hash, Deserialize, Serialize)]
pub struct Cell {
    pub x: u16,
    pub y: u16,
    pub text: String,
    pub width: u16,
    pub foreground: Color,
    pub background: Color,
    pub attributes: Attributes,
}

#[derive(Clone, Debug, PartialEq, Eq, Hash, Deserialize, Serialize)]
pub struct Cursor {
    pub x: u16,
    pub y: u16,
    pub color: Color,
    pub blinking: bool,
}

#[derive(Clone, Debug, PartialEq, Eq, Hash, Deserialize, Serialize)]
pub struct Frame {
    pub version: u8,
    pub cols: u16,
    pub rows: u16,
    pub foreground: Color,
    pub background: Color,
    pub cursor: Option<Cursor>,
    pub cells: Vec<Cell>,
}

impl Frame {
    pub fn has_visible_content(&self) -> bool {
        self.cells
            .iter()
            .any(|cell| !cell.text.trim().is_empty() || cell.background != self.background)
    }

    pub fn text(&self) -> String {
        let mut rows =
            vec![vec![String::from(" "); usize::from(self.cols)]; usize::from(self.rows)];
        for cell in &self.cells {
            if cell.text.is_empty() || cell.x >= self.cols || cell.y >= self.rows {
                continue;
            }
            rows[usize::from(cell.y)][usize::from(cell.x)] = cell.text.clone();
            if cell.width == 2 && cell.x + 1 < self.cols {
                rows[usize::from(cell.y)][usize::from(cell.x + 1)].clear();
            }
        }
        rows.into_iter()
            .map(|line| line.join("").trim_end().to_owned())
            .collect::<Vec<_>>()
            .join("\n")
            .trim_end()
            .to_owned()
    }
}

pub fn from_screen(screen: &Screen) -> Frame {
    let (rows, cols) = screen.size();
    let foreground = DEFAULT_FOREGROUND;
    let background = DEFAULT_BACKGROUND;
    let mut cells = Vec::new();
    for y in 0..rows {
        for x in 0..cols {
            let Some(cell) = screen.cell(y, x) else {
                continue;
            };
            if cell.is_wide_continuation() {
                continue;
            }
            let mut cell_foreground = resolve_color(cell.fgcolor(), foreground);
            let mut cell_background = resolve_color(cell.bgcolor(), background);
            if cell.inverse() {
                std::mem::swap(&mut cell_foreground, &mut cell_background);
            }
            let attributes = Attributes {
                bold: cell.bold(),
                italic: cell.italic(),
                faint: cell.dim(),
                invisible: false,
                strikethrough: false,
                overline: false,
                underline: cell.underline().then_some(Underline::Single),
            };
            let text = cell.contents().to_owned();
            if !text.is_empty() || cell_background != background || has_attributes(&attributes) {
                cells.push(Cell {
                    x,
                    y,
                    text,
                    width: if cell.is_wide() { 2 } else { 1 },
                    foreground: cell_foreground,
                    background: cell_background,
                    attributes,
                });
            }
        }
    }
    let (cursor_y, cursor_x) = screen.cursor_position();
    Frame {
        version: FORMAT_VERSION,
        cols,
        rows,
        foreground,
        background,
        cursor: (!screen.hide_cursor()).then_some(Cursor {
            x: cursor_x,
            y: cursor_y,
            color: foreground,
            blinking: false,
        }),
        cells,
    }
}

fn has_attributes(attributes: &Attributes) -> bool {
    attributes.bold || attributes.italic || attributes.faint || attributes.underline.is_some()
}

fn resolve_color(color: TerminalColor, default: Color) -> Color {
    match color {
        TerminalColor::Default => default,
        TerminalColor::Rgb(r, g, b) => Color { r, g, b },
        TerminalColor::Idx(index) => indexed_color(index),
    }
}

fn indexed_color(index: u8) -> Color {
    const ANSI: [Color; 16] = [
        Color { r: 0, g: 0, b: 0 },
        Color {
            r: 205,
            g: 49,
            b: 49,
        },
        Color {
            r: 13,
            g: 188,
            b: 121,
        },
        Color {
            r: 229,
            g: 229,
            b: 16,
        },
        Color {
            r: 36,
            g: 114,
            b: 200,
        },
        Color {
            r: 188,
            g: 63,
            b: 188,
        },
        Color {
            r: 17,
            g: 168,
            b: 205,
        },
        Color {
            r: 229,
            g: 229,
            b: 229,
        },
        Color {
            r: 102,
            g: 102,
            b: 102,
        },
        Color {
            r: 241,
            g: 76,
            b: 76,
        },
        Color {
            r: 35,
            g: 209,
            b: 139,
        },
        Color {
            r: 245,
            g: 245,
            b: 67,
        },
        Color {
            r: 59,
            g: 142,
            b: 234,
        },
        Color {
            r: 214,
            g: 112,
            b: 214,
        },
        Color {
            r: 41,
            g: 184,
            b: 219,
        },
        Color {
            r: 255,
            g: 255,
            b: 255,
        },
    ];
    if index < 16 {
        return ANSI[usize::from(index)];
    }
    if index >= 232 {
        let value = 8 + (index - 232) * 10;
        return Color {
            r: value,
            g: value,
            b: value,
        };
    }
    let value = index - 16;
    let channel = |component: u8| {
        if component == 0 {
            0
        } else {
            55 + component * 40
        }
    };
    Color {
        r: channel(value / 36),
        g: channel((value % 36) / 6),
        b: channel(value % 6),
    }
}

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

    #[test]
    fn extracts_truecolor_backgrounds_and_text() {
        let mut parser = vt100::Parser::new(3, 20, 0);
        parser.process(b"\x1b[48;2;30;34;42m\x1b[38;2;196;215;240m Hi \x1b[0m");

        let frame = from_screen(parser.screen());

        assert_eq!(frame.text(), " Hi");
        assert_eq!(
            frame.cells[0].background,
            Color {
                r: 30,
                g: 34,
                b: 42
            }
        );
        assert_eq!(
            frame.cells[0].foreground,
            Color {
                r: 196,
                g: 215,
                b: 240
            }
        );
    }

    #[test]
    fn maps_xterm_color_cube_values() {
        assert_eq!(
            indexed_color(1),
            Color {
                r: 205,
                g: 49,
                b: 49
            }
        );
        assert_eq!(
            indexed_color(214),
            Color {
                r: 255,
                g: 175,
                b: 0
            }
        );
        assert_eq!(
            indexed_color(244),
            Color {
                r: 128,
                g: 128,
                b: 128
            }
        );
    }

    #[test]
    fn background_paint_is_visible_content() {
        let mut parser = vt100::Parser::new(1, 2, 0);
        parser.process(b"\x1b[48;2;30;34;42m ");

        assert!(from_screen(parser.screen()).has_visible_content());
    }

    #[test]
    fn text_ignores_out_of_bounds_external_cells() {
        let mut frame = from_screen(vt100::Parser::new(1, 1, 0).screen());
        frame.cells.push(Cell {
            x: 2,
            y: 0,
            text: "x".to_owned(),
            width: 1,
            foreground: DEFAULT_FOREGROUND,
            background: DEFAULT_BACKGROUND,
            attributes: Attributes::default(),
        });

        assert_eq!(frame.text(), "");
    }
}