glog-tui 0.1.2

An interactive git log and git show browser
use ratatui::{
    style::{Color, Modifier, Style},
    text::{Line, Span},
};

pub fn normalized_line(input: &str) -> Line<'static> {
    let parsed = parse_line(input);
    if parsed
        .spans
        .iter()
        .all(|span| span.content.chars().all(char::is_whitespace))
    {
        Line::default()
    } else {
        parsed
    }
}

pub fn parse_line(input: &str) -> Line<'static> {
    let mut spans = Vec::new();
    let mut style = Style::default();
    let mut rest = input;
    while let Some(start) = rest.find("\x1b[") {
        if start > 0 {
            spans.push(Span::styled(rest[..start].to_owned(), style));
        }
        let sequence = &rest[start + 2..];
        let Some(end) = sequence.find('m') else {
            spans.push(Span::styled(rest[start..].to_owned(), style));
            rest = "";
            break;
        };
        apply_sgr(&mut style, &sequence[..end]);
        rest = &sequence[end + 1..];
    }
    if !rest.is_empty() {
        spans.push(Span::styled(rest.to_owned(), style));
    }
    Line::from(spans)
}

pub fn plain(input: &str) -> String {
    parse_line(input)
        .spans
        .into_iter()
        .map(|span| span.content.into_owned())
        .collect()
}

fn apply_sgr(style: &mut Style, params: &str) {
    let values: Vec<u16> = if params.is_empty() {
        vec![0]
    } else {
        params.split(';').filter_map(|v| v.parse().ok()).collect()
    };
    let mut i = 0;
    while i < values.len() {
        match values[i] {
            0 => *style = Style::default(),
            1 => *style = style.add_modifier(Modifier::BOLD),
            2 => *style = style.add_modifier(Modifier::DIM),
            3 => *style = style.add_modifier(Modifier::ITALIC),
            4 => *style = style.add_modifier(Modifier::UNDERLINED),
            7 => *style = style.add_modifier(Modifier::REVERSED),
            22 => *style = style.remove_modifier(Modifier::BOLD | Modifier::DIM),
            23 => *style = style.remove_modifier(Modifier::ITALIC),
            24 => *style = style.remove_modifier(Modifier::UNDERLINED),
            27 => *style = style.remove_modifier(Modifier::REVERSED),
            30..=37 => style.fg = Some(basic(values[i] - 30, false)),
            39 => style.fg = None,
            40..=47 => style.bg = Some(basic(values[i] - 40, false)),
            49 => style.bg = None,
            90..=97 => style.fg = Some(basic(values[i] - 90, true)),
            100..=107 => style.bg = Some(basic(values[i] - 100, true)),
            38 | 48 if values.get(i + 1) == Some(&5) && values.get(i + 2).is_some() => {
                let color = Color::Indexed(values[i + 2] as u8);
                if values[i] == 38 {
                    style.fg = Some(color)
                } else {
                    style.bg = Some(color)
                }
                i += 2;
            }
            38 | 48 if values.get(i + 1) == Some(&2) && values.get(i + 4).is_some() => {
                let color = Color::Rgb(
                    values[i + 2] as u8,
                    values[i + 3] as u8,
                    values[i + 4] as u8,
                );
                if values[i] == 38 {
                    style.fg = Some(color)
                } else {
                    style.bg = Some(color)
                }
                i += 4;
            }
            _ => {}
        }
        i += 1;
    }
}

fn basic(index: u16, bright: bool) -> Color {
    match (index, bright) {
        (0, false) => Color::Black,
        (1, false) => Color::Red,
        (2, false) => Color::Green,
        (3, false) => Color::Yellow,
        (4, false) => Color::Blue,
        (5, false) => Color::Magenta,
        (6, false) => Color::Cyan,
        (7, false) => Color::Gray,
        (0, true) => Color::DarkGray,
        (1, true) => Color::LightRed,
        (2, true) => Color::LightGreen,
        (3, true) => Color::LightYellow,
        (4, true) => Color::LightBlue,
        (5, true) => Color::LightMagenta,
        (6, true) => Color::LightCyan,
        _ => Color::White,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn strips_and_styles_ansi() {
        let line = parse_line("a\x1b[31mred\x1b[0mz");
        assert_eq!(
            line.spans
                .iter()
                .map(|s| s.content.as_ref())
                .collect::<String>(),
            "aredz"
        );
        assert_eq!(line.spans[1].style.fg, Some(Color::Red));
    }

    #[test]
    fn normalizes_whitespace_only_lines() {
        let parsed: Vec<_> = "message\n    \nbody".lines().map(normalized_line).collect();
        assert_eq!(parsed.len(), 3);
        assert!(parsed[1].spans.is_empty());
    }
}