Skip to main content

wisp/components/
patch_renderer.rs

1use crate::components::app::git_diff_mode::PatchLineRef;
2use crate::git_diff::{FileDiff, PatchLineKind};
3use tui::{Color, Line, Span, Style, ViewContext, soft_wrap_line};
4
5pub fn build_patch_lines(
6    file: &FileDiff,
7    right_width: usize,
8    context: &ViewContext,
9) -> (Vec<Line>, Vec<Option<PatchLineRef>>) {
10    let theme = &context.theme;
11    let lang_hint = lang_hint_from_path(&file.path);
12    let mut patch_lines = Vec::new();
13    let mut patch_refs = Vec::new();
14
15    let max_line_no = file
16        .hunks
17        .iter()
18        .flat_map(|h| &h.lines)
19        .filter_map(|l| l.old_line_no.into_iter().chain(l.new_line_no).max())
20        .max()
21        .unwrap_or(0);
22    let gutter_width = digit_count(max_line_no);
23
24    for (hunk_idx, hunk) in file.hunks.iter().enumerate() {
25        if hunk_idx > 0 {
26            patch_lines.push(Line::default());
27            patch_refs.push(None);
28        }
29
30        for (line_idx, pl) in hunk.lines.iter().enumerate() {
31            let mut line = Line::default();
32
33            match pl.kind {
34                PatchLineKind::HunkHeader => {
35                    line.push_with_style(
36                        &pl.text,
37                        Style::fg(theme.info()).bold().bg_color(theme.code_bg()),
38                    );
39                }
40                PatchLineKind::Context => {
41                    let old_str = format_line_no(pl.old_line_no, gutter_width);
42                    let new_str = format_line_no(pl.new_line_no, gutter_width);
43                    line.push_with_style(
44                        format!("{old_str} {new_str}   "),
45                        Style::fg(theme.text_secondary()),
46                    );
47                    append_syntax_spans(&mut line, &pl.text, lang_hint, None, context);
48                }
49                PatchLineKind::Added => {
50                    let old_str = " ".repeat(gutter_width);
51                    let new_str = format_line_no(pl.new_line_no, gutter_width);
52                    let bg = Some(theme.diff_added_bg());
53                    let style = Style::fg(theme.diff_added_fg()).bg_color(theme.diff_added_bg());
54                    line.push_with_style(format!("{old_str} {new_str} + "), style);
55                    append_syntax_spans(&mut line, &pl.text, lang_hint, bg, context);
56                }
57                PatchLineKind::Removed => {
58                    let old_str = format_line_no(pl.old_line_no, gutter_width);
59                    let new_str = " ".repeat(gutter_width);
60                    let bg = Some(theme.diff_removed_bg());
61                    let style =
62                        Style::fg(theme.diff_removed_fg()).bg_color(theme.diff_removed_bg());
63                    line.push_with_style(format!("{old_str} {new_str} - "), style);
64                    append_syntax_spans(&mut line, &pl.text, lang_hint, bg, context);
65                }
66                PatchLineKind::Meta => {
67                    line.push_with_style(&pl.text, Style::fg(theme.text_secondary()).italic());
68                }
69            }
70
71            #[allow(clippy::cast_possible_truncation)]
72            let wrapped = soft_wrap_line(&line, right_width as u16);
73            for (i, mut wrapped_line) in wrapped.into_iter().enumerate() {
74                wrapped_line.extend_bg_to_width(right_width);
75                patch_lines.push(wrapped_line);
76                if i == 0 {
77                    patch_refs.push(Some(PatchLineRef {
78                        hunk_index: hunk_idx,
79                        line_index: line_idx,
80                    }));
81                } else {
82                    patch_refs.push(None);
83                }
84            }
85        }
86    }
87
88    (patch_lines, patch_refs)
89}
90
91pub(crate) fn lang_hint_from_path(path: &str) -> &str {
92    path.rsplit('.').next().unwrap_or("")
93}
94
95pub(crate) fn append_syntax_spans(
96    line: &mut Line,
97    text: &str,
98    lang_hint: &str,
99    bg_override: Option<Color>,
100    context: &ViewContext,
101) {
102    let spans = context
103        .highlighter()
104        .highlight(text, lang_hint, &context.theme);
105    if let Some(content) = spans.first() {
106        for span in content.spans() {
107            let mut span_style = span.style();
108            if let Some(bg) = bg_override {
109                span_style.bg = Some(bg);
110            }
111            line.push_span(Span::with_style(span.text(), span_style));
112        }
113    } else {
114        line.push_text(text);
115    }
116}
117
118pub(crate) fn format_line_no(line_no: Option<usize>, width: usize) -> String {
119    match line_no {
120        Some(n) => format!("{n:>width$}"),
121        None => " ".repeat(width),
122    }
123}
124
125pub(crate) fn digit_count(mut n: usize) -> usize {
126    if n == 0 {
127        return 1;
128    }
129    let mut count = 0;
130    while n > 0 {
131        count += 1;
132        n /= 10;
133    }
134    count
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use crate::git_diff::{FileDiff, FileStatus, Hunk, PatchLine};
141    use tui::display_width_text;
142
143    fn make_file(lines: Vec<PatchLine>) -> FileDiff {
144        FileDiff {
145            old_path: Some("test.rs".to_string()),
146            path: "test.rs".to_string(),
147            status: FileStatus::Modified,
148            hunks: vec![Hunk {
149                header: "@@ -1,1 +1,1 @@".to_string(),
150                old_start: 1,
151                old_count: 1,
152                new_start: 1,
153                new_count: 1,
154                lines,
155            }],
156            binary: false,
157        }
158    }
159
160    #[test]
161    fn long_lines_soft_wrapped_to_right_width() {
162        let long_content = "x".repeat(200);
163        let file = make_file(vec![
164            PatchLine {
165                kind: PatchLineKind::HunkHeader,
166                text: "@@ -1,1 +1,1 @@".to_string(),
167                old_line_no: None,
168                new_line_no: None,
169            },
170            PatchLine {
171                kind: PatchLineKind::Added,
172                text: long_content,
173                old_line_no: None,
174                new_line_no: Some(1),
175            },
176        ]);
177        let context = ViewContext::new((120, 24));
178        let right_width = 60;
179        let (lines, refs) = build_patch_lines(&file, right_width, &context);
180
181        // The long line should have wrapped into multiple visual lines
182        assert!(
183            lines.len() > 2,
184            "long line should wrap, got {} lines",
185            lines.len()
186        );
187
188        // No visual line should exceed right_width
189        for (i, line) in lines.iter().enumerate() {
190            let w = line.display_width();
191            assert!(
192                w <= right_width,
193                "line {i} width {w} exceeds right_width {right_width}: {}",
194                line.plain_text()
195            );
196        }
197
198        // First wrapped line gets the ref, continuations get None
199        assert!(refs[1].is_some(), "first wrapped line should have a ref");
200        for i in 2..lines.len() {
201            assert!(
202                refs[i].is_none(),
203                "continuation line {i} should have None ref"
204            );
205        }
206    }
207
208    #[test]
209    fn short_lines_not_wrapped() {
210        let file = make_file(vec![
211            PatchLine {
212                kind: PatchLineKind::HunkHeader,
213                text: "@@ -1,1 +1,1 @@".to_string(),
214                old_line_no: None,
215                new_line_no: None,
216            },
217            PatchLine {
218                kind: PatchLineKind::Context,
219                text: "short".to_string(),
220                old_line_no: Some(1),
221                new_line_no: Some(1),
222            },
223        ]);
224        let context = ViewContext::new((120, 24));
225        let (lines, refs) = build_patch_lines(&file, 80, &context);
226
227        assert_eq!(lines.len(), 2, "short lines should not wrap");
228        assert!(refs[0].is_some());
229        assert!(refs[1].is_some());
230    }
231
232    #[test]
233    fn wrapped_lines_extend_bg_to_width() {
234        let long_content = "x".repeat(200);
235        let file = make_file(vec![PatchLine {
236            kind: PatchLineKind::Added,
237            text: long_content,
238            old_line_no: None,
239            new_line_no: Some(1),
240        }]);
241        let context = ViewContext::new((120, 24));
242        let right_width = 60;
243        let (lines, _) = build_patch_lines(&file, right_width, &context);
244
245        // All lines from the added line should have consistent width due to bg extension
246        for line in &lines {
247            let w = display_width_text(&line.plain_text());
248            assert_eq!(
249                w,
250                right_width,
251                "line should be padded to right_width: {}",
252                line.plain_text()
253            );
254        }
255    }
256
257    #[test]
258    fn digit_count_works() {
259        assert_eq!(digit_count(0), 1);
260        assert_eq!(digit_count(1), 1);
261        assert_eq!(digit_count(9), 1);
262        assert_eq!(digit_count(10), 2);
263        assert_eq!(digit_count(99), 2);
264        assert_eq!(digit_count(100), 3);
265        assert_eq!(digit_count(999), 3);
266    }
267
268    #[test]
269    fn lang_hint_extracts_extension() {
270        assert_eq!(lang_hint_from_path("src/main.rs"), "rs");
271        assert_eq!(lang_hint_from_path("foo.py"), "py");
272        assert_eq!(lang_hint_from_path("Makefile"), "Makefile");
273        assert_eq!(lang_hint_from_path("a/b/c.tsx"), "tsx");
274    }
275}