lv-tui 0.4.0

A reactive TUI framework for Rust
Documentation
use crate::component::{Component, EventCx, MeasureCx};
use crate::event::Event;
use crate::geom::{Pos, Rect, Size};
use crate::layout::Constraint;
use crate::render::RenderCx;
use crate::style::{Color, Style};

/// A colored unified diff viewer with line numbers.
///
/// Renders additions with green line number and `+` prefix, deletions with
/// red line number and `-` prefix, matching Claude Code's diff style.
/// Supports keyboard scrolling (Up/Down/PageUp/PageDown).
pub struct DiffView {
    lines: Vec<DiffLine>,
    scroll: u16,
    rect: Rect,
}

#[derive(Debug, Clone)]
enum DiffLine {
    Header(String),
    Hunk(String),
    Add(u64, String),       // new_line_number, text after +
    Del(u64, String),       // old_line_number, text after -
    Ctx(u64, String),       // new_line_number, full text
}

impl DiffView {
    /// Parse and render a unified diff string.
    pub fn new(diff_text: &str) -> Self {
        let mut lines = Vec::new();
        let mut old_num: u64 = 0;
        let mut new_num: u64 = 0;

        for line_str in diff_text.lines() {
            if line_str.starts_with("@@") {
                if let Some((o, n)) = parse_hunk(line_str) {
                    old_num = o;
                    new_num = n;
                }
                lines.push(DiffLine::Hunk(line_str.to_string()));
            } else if line_str.starts_with("---") || line_str.starts_with("+++") {
                lines.push(DiffLine::Header(line_str.to_string()));
            } else if line_str.starts_with('+') {
                let text = &line_str[1..];
                lines.push(DiffLine::Add(new_num, text.to_string()));
                new_num += 1;
            } else if line_str.starts_with('-') {
                let text = &line_str[1..];
                lines.push(DiffLine::Del(old_num, text.to_string()));
                old_num += 1;
            } else {
                let text = if line_str.starts_with(' ') { &line_str[1..] } else { line_str };
                lines.push(DiffLine::Ctx(new_num, text.to_string()));
                old_num += 1;
                new_num += 1;
            }
        }
        Self { lines, scroll: 0, rect: Rect::default() }
    }

    pub fn line_count(&self) -> usize { self.lines.len() }
}

fn parse_hunk(header: &str) -> Option<(u64, u64)> {
    let parts: Vec<&str> = header.split_whitespace().collect();
    if parts.len() >= 3 {
        let old = parts[1].trim_start_matches('-').split(',').next()?.parse().ok()?;
        let new = parts[2].trim_start_matches('+').split(',').next()?.parse().ok()?;
        Some((old, new))
    } else {
        None
    }
}

// Column layout:
//  [4-char line number] [space] [prefix] [code]
//   ^^^ gray/default    ^space  ^colored ^normal
const NUM_W: u16 = 4;
const GUTTER_W: u16 = NUM_W + 1; // number + space

impl Component for DiffView {
    fn render(&self, cx: &mut RenderCx) {
        let vp = self.rect;
        let visible = vp.height.max(1) as usize;
        let start = (self.scroll as usize).min(self.lines.len().saturating_sub(visible));
        let end = (start + visible).min(self.lines.len());

        let gray = Style::default().fg(Color::Gray);
        let add_style = Style::default().fg(Color::Green);
        let del_style = Style::default().fg(Color::Red);
        let ctx_style = Style::default();

        for i in start..end {
            let line = &self.lines[i];
            let y = vp.y.saturating_add((i - start) as u16);

            match line {
                DiffLine::Header(s) | DiffLine::Hunk(s) => {
                    cx.buffer.write_text(Pos { x: vp.x, y }, vp, s, &ctx_style);
                }
                DiffLine::Add(n, text) => {
                    let num = format!("{:>4}", n);
                    cx.buffer.write_text(Pos { x: vp.x, y }, vp, &num, &add_style);
                    cx.buffer.write_text(Pos { x: vp.x + 4, y }, vp, " +", &add_style);
                    let code_x = vp.x + GUTTER_W + 2;
                    cx.buffer.write_text(Pos { x: code_x, y }, vp, text, &add_style);
                }
                DiffLine::Del(n, text) => {
                    let num = format!("{:>4}", n);
                    cx.buffer.write_text(Pos { x: vp.x, y }, vp, &num, &del_style);
                    cx.buffer.write_text(Pos { x: vp.x + 4, y }, vp, " -", &del_style);
                    let code_x = vp.x + GUTTER_W + 2;
                    cx.buffer.write_text(Pos { x: code_x, y }, vp, text, &del_style);
                }
                DiffLine::Ctx(n, text) => {
                    let num = format!("{:>4}", n);
                    cx.buffer.write_text(Pos { x: vp.x, y }, vp, &num, &gray);
                    let code_x = vp.x + GUTTER_W + 2;
                    cx.buffer.write_text(Pos { x: code_x, y }, vp, text, &ctx_style);
                }
            }
        }
    }

    fn measure(&self, _c: Constraint, _cx: &mut MeasureCx) -> Size {
        Size { width: 80, height: self.lines.len().max(1) as u16 }
    }

    fn event(&mut self, event: &Event, cx: &mut EventCx) {
        if let Event::Key(key) = event {
            let visible = self.rect.height.max(1) as u16;
            let total = self.lines.len().max(1) as u16;
            let max_scroll = total.saturating_sub(visible);
            match &key.key {
                crate::event::Key::Up => { self.scroll = self.scroll.saturating_sub(1); cx.invalidate_paint(); }
                crate::event::Key::Down => { self.scroll = (self.scroll + 1).min(max_scroll); cx.invalidate_paint(); }
                crate::event::Key::PageUp => { self.scroll = self.scroll.saturating_sub(visible); cx.invalidate_paint(); }
                crate::event::Key::PageDown => { self.scroll = (self.scroll + visible).min(max_scroll); cx.invalidate_paint(); }
                _ => {}
            }
        }
    }

    fn layout(&mut self, rect: Rect, _cx: &mut crate::component::LayoutCx) { self.rect = rect; }
    fn focusable(&self) -> bool { false }
    fn style(&self) -> Style { Style::default() }
}

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

    #[test]
    fn test_parse_diff() {
        let diff = "--- a.txt\n+++ b.txt\n@@ -1 +1 @@\n-old\n+new";
        let dv = DiffView::new(diff);
        assert_eq!(dv.line_count(), 5);
    }

    #[test]
    fn test_line_types() {
        let dv = DiffView::new("-deleted\n+added\n unchanged\n@@ -1 +1 @@");
        assert_eq!(dv.line_count(), 4);
    }
}