Skip to main content

lv_tui/widgets/
diffview.rs

1use crate::component::{Component, EventCx, MeasureCx};
2use crate::event::Event;
3use crate::geom::{Pos, Rect, Size};
4use crate::layout::Constraint;
5use crate::render::RenderCx;
6use crate::style::{Color, Style};
7
8/// A colored unified diff viewer with line numbers.
9///
10/// Renders additions with green line number and `+` prefix, deletions with
11/// red line number and `-` prefix, matching Claude Code's diff style.
12/// Supports keyboard scrolling (Up/Down/PageUp/PageDown).
13pub struct DiffView {
14    lines: Vec<DiffLine>,
15    scroll: u16,
16    rect: Rect,
17}
18
19#[derive(Debug, Clone)]
20enum DiffLine {
21    Header(String),
22    Hunk(String),
23    Add(u64, String),       // new_line_number, text after +
24    Del(u64, String),       // old_line_number, text after -
25    Ctx(u64, String),       // new_line_number, full text
26}
27
28impl DiffView {
29    /// Parse and render a unified diff string.
30    pub fn new(diff_text: &str) -> Self {
31        let mut lines = Vec::new();
32        let mut old_num: u64 = 0;
33        let mut new_num: u64 = 0;
34
35        for line_str in diff_text.lines() {
36            if line_str.starts_with("@@") {
37                if let Some((o, n)) = parse_hunk(line_str) {
38                    old_num = o;
39                    new_num = n;
40                }
41                lines.push(DiffLine::Hunk(line_str.to_string()));
42            } else if line_str.starts_with("---") || line_str.starts_with("+++") {
43                lines.push(DiffLine::Header(line_str.to_string()));
44            } else if line_str.starts_with('+') {
45                let text = &line_str[1..];
46                lines.push(DiffLine::Add(new_num, text.to_string()));
47                new_num += 1;
48            } else if line_str.starts_with('-') {
49                let text = &line_str[1..];
50                lines.push(DiffLine::Del(old_num, text.to_string()));
51                old_num += 1;
52            } else {
53                let text = if line_str.starts_with(' ') { &line_str[1..] } else { line_str };
54                lines.push(DiffLine::Ctx(new_num, text.to_string()));
55                old_num += 1;
56                new_num += 1;
57            }
58        }
59        Self { lines, scroll: 0, rect: Rect::default() }
60    }
61
62    pub fn line_count(&self) -> usize { self.lines.len() }
63}
64
65fn parse_hunk(header: &str) -> Option<(u64, u64)> {
66    let parts: Vec<&str> = header.split_whitespace().collect();
67    if parts.len() >= 3 {
68        let old = parts[1].trim_start_matches('-').split(',').next()?.parse().ok()?;
69        let new = parts[2].trim_start_matches('+').split(',').next()?.parse().ok()?;
70        Some((old, new))
71    } else {
72        None
73    }
74}
75
76// Column layout:
77//  [4-char line number] [space] [prefix] [code]
78//   ^^^ gray/default    ^space  ^colored ^normal
79const NUM_W: u16 = 4;
80const GUTTER_W: u16 = NUM_W + 1; // number + space
81
82impl Component for DiffView {
83    fn render(&self, cx: &mut RenderCx) {
84        let vp = self.rect;
85        let visible = vp.height.max(1) as usize;
86        let start = (self.scroll as usize).min(self.lines.len().saturating_sub(visible));
87        let end = (start + visible).min(self.lines.len());
88
89        let gray = Style::default().fg(Color::Gray);
90        let add_style = Style::default().fg(Color::Green);
91        let del_style = Style::default().fg(Color::Red);
92        let ctx_style = Style::default();
93
94        for i in start..end {
95            let line = &self.lines[i];
96            let y = vp.y.saturating_add((i - start) as u16);
97
98            match line {
99                DiffLine::Header(s) | DiffLine::Hunk(s) => {
100                    cx.buffer.write_text(Pos { x: vp.x, y }, vp, s, &ctx_style);
101                }
102                DiffLine::Add(n, text) => {
103                    let num = format!("{:>4}", n);
104                    cx.buffer.write_text(Pos { x: vp.x, y }, vp, &num, &add_style);
105                    cx.buffer.write_text(Pos { x: vp.x + 4, y }, vp, " +", &add_style);
106                    let code_x = vp.x + GUTTER_W + 2;
107                    cx.buffer.write_text(Pos { x: code_x, y }, vp, text, &add_style);
108                }
109                DiffLine::Del(n, text) => {
110                    let num = format!("{:>4}", n);
111                    cx.buffer.write_text(Pos { x: vp.x, y }, vp, &num, &del_style);
112                    cx.buffer.write_text(Pos { x: vp.x + 4, y }, vp, " -", &del_style);
113                    let code_x = vp.x + GUTTER_W + 2;
114                    cx.buffer.write_text(Pos { x: code_x, y }, vp, text, &del_style);
115                }
116                DiffLine::Ctx(n, text) => {
117                    let num = format!("{:>4}", n);
118                    cx.buffer.write_text(Pos { x: vp.x, y }, vp, &num, &gray);
119                    let code_x = vp.x + GUTTER_W + 2;
120                    cx.buffer.write_text(Pos { x: code_x, y }, vp, text, &ctx_style);
121                }
122            }
123        }
124    }
125
126    fn measure(&self, _c: Constraint, _cx: &mut MeasureCx) -> Size {
127        Size { width: 80, height: self.lines.len().max(1) as u16 }
128    }
129
130    fn event(&mut self, event: &Event, cx: &mut EventCx) {
131        if let Event::Key(key) = event {
132            let visible = self.rect.height.max(1) as u16;
133            let total = self.lines.len().max(1) as u16;
134            let max_scroll = total.saturating_sub(visible);
135            match &key.key {
136                crate::event::Key::Up => { self.scroll = self.scroll.saturating_sub(1); cx.invalidate_paint(); }
137                crate::event::Key::Down => { self.scroll = (self.scroll + 1).min(max_scroll); cx.invalidate_paint(); }
138                crate::event::Key::PageUp => { self.scroll = self.scroll.saturating_sub(visible); cx.invalidate_paint(); }
139                crate::event::Key::PageDown => { self.scroll = (self.scroll + visible).min(max_scroll); cx.invalidate_paint(); }
140                _ => {}
141            }
142        }
143    }
144
145    fn layout(&mut self, rect: Rect, _cx: &mut crate::component::LayoutCx) { self.rect = rect; }
146    fn focusable(&self) -> bool { false }
147    fn style(&self) -> Style { Style::default() }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn test_parse_diff() {
156        let diff = "--- a.txt\n+++ b.txt\n@@ -1 +1 @@\n-old\n+new";
157        let dv = DiffView::new(diff);
158        assert_eq!(dv.line_count(), 5);
159    }
160
161    #[test]
162    fn test_line_types() {
163        let dv = DiffView::new("-deleted\n+added\n unchanged\n@@ -1 +1 @@");
164        assert_eq!(dv.line_count(), 4);
165    }
166}
167