Skip to main content

tui/diffs/
split_diff.rs

1use crate::diffs::diff::highlight_diff;
2use crate::line::Line;
3use crate::rendering::frame::{FitOptions, Frame, FramePart};
4use crate::rendering::gutter::digit_count;
5use crate::rendering::render_context::ViewContext;
6use crate::span::Span;
7use crate::style::Style;
8
9use crate::{DiffPreview, DiffTag, SplitDiffCell};
10
11const MAX_DIFF_LINES: usize = 20;
12pub const MIN_SPLIT_WIDTH: u16 = 80;
13pub const MIN_GUTTER_WIDTH: usize = 3;
14pub const SEPARATOR: &str = " ";
15pub const SEPARATOR_WIDTH: usize = 1;
16const SEPARATOR_WIDTH_U16: u16 = 1;
17const LEFT_INNER_PAD: usize = 1;
18
19/// Which pane a [`render_cell`] call is rendering
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum Side {
22    Left,
23    Right,
24}
25
26/// Renders a diff preview, choosing split or unified based on terminal width
27/// and whether the diff has removals.
28///
29/// For new files (only additions, no removals), uses unified view since
30/// split view would have an empty left panel.
31pub fn render_diff(preview: &DiffPreview, context: &ViewContext) -> Vec<Line> {
32    let has_removals = preview.lines.iter().any(|l| l.tag == DiffTag::Removed);
33
34    if context.size.width >= MIN_SPLIT_WIDTH && has_removals {
35        highlight_split_diff(preview, context)
36    } else {
37        highlight_diff(preview, context)
38    }
39}
40
41/// Compute the per-pane line-number gutter width: the number of decimal
42/// digits in the largest line number across either pane in `preview`, plus
43/// one for the trailing space, floored at [`MIN_GUTTER_WIDTH`].
44pub fn gutter_width_for_preview(preview: &DiffPreview) -> usize {
45    let max_line_no = preview
46        .rows
47        .iter()
48        .flat_map(|row| {
49            row.left
50                .as_ref()
51                .and_then(|c| c.line_number)
52                .into_iter()
53                .chain(row.right.as_ref().and_then(|c| c.line_number))
54        })
55        .max()
56        .unwrap_or(0);
57    (digit_count(max_line_no) + 1).max(MIN_GUTTER_WIDTH)
58}
59
60fn highlight_split_diff(preview: &DiffPreview, context: &ViewContext) -> Vec<Line> {
61    let theme = &context.theme;
62    let terminal_width = context.size.width as usize;
63    let gutter_width = gutter_width_for_preview(preview);
64    let fixed_overhead = gutter_width * 2 + SEPARATOR_WIDTH + LEFT_INNER_PAD;
65    let usable = terminal_width.saturating_sub(fixed_overhead);
66    let left_content = usable / 2;
67    let right_content = usable - left_content;
68    #[allow(clippy::cast_possible_truncation)]
69    let left_panel_u16 = (gutter_width + left_content + LEFT_INNER_PAD) as u16;
70    #[allow(clippy::cast_possible_truncation)]
71    let right_panel_u16 = (gutter_width + right_content) as u16;
72
73    let mut row_frames: Vec<Frame> = Vec::new();
74    let mut visual_lines = 0usize;
75    let mut rows_consumed = 0usize;
76
77    for row in &preview.rows {
78        let left_frame =
79            render_cell(row.left.as_ref(), left_content, &preview.lang_hint, Side::Left, gutter_width, context);
80        let right_frame =
81            render_cell(row.right.as_ref(), right_content, &preview.lang_hint, Side::Right, gutter_width, context);
82
83        let height = left_frame.lines().len().max(right_frame.lines().len());
84
85        if visual_lines + height > MAX_DIFF_LINES && visual_lines > 0 {
86            break;
87        }
88
89        let sep_line = Line::new(SEPARATOR.to_string());
90        let sep_frame = Frame::new(vec![sep_line; height]);
91        row_frames.push(Frame::hstack([
92            FramePart::new(left_frame, left_panel_u16),
93            FramePart::new(sep_frame, SEPARATOR_WIDTH_U16),
94            FramePart::new(right_frame, right_panel_u16),
95        ]));
96
97        visual_lines += height;
98        rows_consumed += 1;
99    }
100
101    let mut lines = Frame::vstack(row_frames).into_lines();
102
103    if rows_consumed < preview.rows.len() {
104        let remaining = preview.rows.len() - rows_consumed;
105        let mut overflow = Line::default();
106        overflow.push_styled(format!("    ... {remaining} more lines"), theme.muted());
107        lines.push(overflow);
108    }
109
110    lines
111}
112
113fn blank_panel(width: usize) -> Line {
114    let mut line = Line::default();
115    line.push_text(" ".repeat(width));
116    line
117}
118
119pub fn render_cell(
120    cell: Option<&SplitDiffCell>,
121    content_width: usize,
122    lang_hint: &str,
123    side: Side,
124    gutter_width: usize,
125    context: &ViewContext,
126) -> Frame {
127    let theme = &context.theme;
128    let inner_pad = if side == Side::Left { LEFT_INNER_PAD } else { 0 };
129    let panel_width = gutter_width + content_width + inner_pad;
130
131    let Some(cell) = cell else {
132        return Frame::new(vec![blank_panel(panel_width)]);
133    };
134
135    let is_context = cell.tag == DiffTag::Context;
136    let bg = match cell.tag {
137        DiffTag::Removed => Some(theme.diff_removed_bg()),
138        DiffTag::Added => Some(theme.diff_added_bg()),
139        DiffTag::Context => None,
140    };
141
142    let highlighted = context.highlighter().highlight(&cell.content, lang_hint, theme);
143
144    let content_line = if let Some(hl_line) = highlighted.first() {
145        let mut styled_content = Line::default();
146        for span in hl_line.spans() {
147            let mut span_style = span.style();
148            if let Some(bg) = bg {
149                span_style.bg = Some(bg);
150            }
151            if is_context {
152                span_style.dim = true;
153            }
154            styled_content.push_span(Span::with_style(span.text(), span_style));
155        }
156        styled_content
157    } else {
158        let fg = match cell.tag {
159            DiffTag::Removed => theme.diff_removed_fg(),
160            DiffTag::Added => theme.diff_added_fg(),
161            DiffTag::Context => theme.code_fg(),
162        };
163        let mut style = Style::fg(fg);
164        if let Some(bg) = bg {
165            style = style.bg_color(bg);
166        }
167        if is_context {
168            style.dim = true;
169        }
170        Line::with_style(&cell.content, style)
171    };
172    let content_line = match bg {
173        Some(bg) => content_line.with_fill(bg),
174        None => content_line,
175    };
176
177    #[allow(clippy::cast_possible_truncation)]
178    let content_width_u16 = content_width as u16;
179    let extend_to = content_width + inner_pad;
180
181    let gutter_style = Style::fg(theme.muted());
182    let digit_field = gutter_width.saturating_sub(1);
183    let head = match cell.line_number {
184        Some(num) => Line::with_style(format!("{num:>digit_field$} "), gutter_style),
185        None => Line::with_style(" ".repeat(gutter_width), gutter_style),
186    };
187    let tail = Line::new(" ".repeat(gutter_width));
188
189    Frame::new(vec![content_line])
190        .fit(content_width_u16, FitOptions::wrap())
191        .map_lines(move |mut line| {
192            line.extend_bg_to_width(extend_to);
193            line
194        })
195        .prefix(&head, &tail)
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use crate::rendering::line::Line;
202    use crate::{DiffLine, SplitDiffCell, SplitDiffRow};
203
204    fn test_context_with_width(width: u16) -> ViewContext {
205        ViewContext::new((width, 24))
206    }
207
208    fn make_split_preview(rows: Vec<SplitDiffRow>) -> DiffPreview {
209        DiffPreview { lines: vec![], rows, lang_hint: String::new(), start_line: None }
210    }
211
212    fn removed_cell(content: &str, line_num: usize) -> SplitDiffCell {
213        SplitDiffCell { tag: DiffTag::Removed, content: content.to_string(), line_number: Some(line_num) }
214    }
215
216    fn added_cell(content: &str, line_num: usize) -> SplitDiffCell {
217        SplitDiffCell { tag: DiffTag::Added, content: content.to_string(), line_number: Some(line_num) }
218    }
219
220    fn style_at_column(line: &Line, col: usize) -> Style {
221        let mut current = 0;
222        for span in line.spans() {
223            let width = crate::display_width_text(span.text());
224            if col < current + width {
225                return span.style();
226            }
227            current += width;
228        }
229        Style::default()
230    }
231
232    #[test]
233    fn wrapped_split_rows_preserve_neutral_boundary_columns() {
234        let preview = make_split_preview(vec![SplitDiffRow {
235            left: Some(removed_cell("LEFT_MARK", 1)),
236            right: Some(added_cell(&format!("RIGHT_HEAD {} RIGHT_TAIL", "y".repeat(140)), 1)),
237        }]);
238        let ctx = test_context_with_width(100);
239        let lines = highlight_split_diff(&preview, &ctx);
240
241        let first_row = lines
242            .iter()
243            .position(|line| {
244                let text = line.plain_text();
245                text.contains("LEFT_MARK") && text.contains("RIGHT_HEAD")
246            })
247            .expect("expected split row containing both left and right markers");
248
249        let right_start =
250            lines[first_row].plain_text().find("RIGHT_HEAD").expect("expected RIGHT_HEAD marker in first split row");
251
252        let wrapped_row = lines
253            .iter()
254            .enumerate()
255            .skip(first_row + 1)
256            .find_map(|(index, line)| line.plain_text().contains("RIGHT_TAIL").then_some(index))
257            .expect("expected wrapped continuation row containing RIGHT_TAIL marker");
258
259        let wrapped_start = lines[wrapped_row]
260            .plain_text()
261            .find("RIGHT_TAIL")
262            .expect("expected RIGHT_TAIL marker in wrapped continuation row");
263
264        assert!(
265            wrapped_start >= right_start,
266            "wrapped continuation should not start left of original right-pane content start (was {wrapped_start}, expected >= {right_start})"
267        );
268
269        let added_bg = ctx.theme.diff_added_bg();
270        let removed_bg = ctx.theme.diff_removed_bg();
271        let gutter_width = gutter_width_for_preview(&preview);
272        let padding_width = gutter_width + SEPARATOR_WIDTH;
273        assert!(right_start >= padding_width, "right pane content should leave room for separator and gutter");
274        for col in (right_start - padding_width)..right_start {
275            let style = style_at_column(&lines[wrapped_row], col);
276            assert_ne!(style.bg, Some(added_bg), "padding column {col} should not inherit added background");
277            assert_ne!(style.bg, Some(removed_bg), "padding column {col} should not inherit removed background");
278        }
279    }
280
281    #[test]
282    fn both_panels_rendered_with_content() {
283        let preview = make_split_preview(vec![SplitDiffRow {
284            left: Some(removed_cell("old code", 1)),
285            right: Some(added_cell("new code", 1)),
286        }]);
287        let ctx = test_context_with_width(100);
288        let lines = highlight_split_diff(&preview, &ctx);
289        assert_eq!(lines.len(), 1);
290        let text = lines[0].plain_text();
291        assert!(text.contains("old code"), "left panel missing: {text}");
292        assert!(text.contains("new code"), "right panel missing: {text}");
293    }
294
295    #[test]
296    fn long_lines_wrapped_within_terminal_width() {
297        let long = "x".repeat(200);
298        let preview = make_split_preview(vec![SplitDiffRow {
299            left: Some(removed_cell(&long, 1)),
300            right: Some(added_cell(&long, 1)),
301        }]);
302        let ctx = test_context_with_width(100);
303        let lines = highlight_split_diff(&preview, &ctx);
304        assert!(lines.len() > 1, "long line should wrap into multiple visual lines, got {}", lines.len());
305        for line in &lines {
306            let width = line.display_width();
307            assert!(width <= 100, "line width {width} should not exceed terminal width 100");
308        }
309        // Full content should be present across all wrapped lines
310        let all_text: String = lines.iter().map(Line::plain_text).collect();
311        let x_count = all_text.chars().filter(|&c| c == 'x').count();
312        // Both left and right panels contain 200 x's each
313        assert_eq!(x_count, 400, "all content should be present across wrapped lines");
314    }
315
316    #[test]
317    fn truncation_budget_applied() {
318        let rows: Vec<SplitDiffRow> = (0..30)
319            .map(|i| SplitDiffRow {
320                left: Some(removed_cell(&format!("old {i}"), i + 1)),
321                right: Some(added_cell(&format!("new {i}"), i + 1)),
322            })
323            .collect();
324        let preview = make_split_preview(rows);
325        let ctx = test_context_with_width(100);
326        let lines = highlight_split_diff(&preview, &ctx);
327        // Short lines don't wrap, so 20 visual lines + 1 overflow
328        assert_eq!(lines.len(), MAX_DIFF_LINES + 1);
329        let last = lines.last().unwrap().plain_text();
330        assert!(last.contains("more lines"), "overflow text missing: {last}");
331    }
332
333    #[test]
334    fn empty_added_cell_pads_content_with_added_background() {
335        let ctx = test_context_with_width(100);
336        let added_bg = ctx.theme.diff_added_bg();
337        let cell = SplitDiffCell { tag: DiffTag::Added, content: String::new(), line_number: Some(1) };
338        let content_width = 40;
339        let gutter_width = MIN_GUTTER_WIDTH;
340        let frame = render_cell(Some(&cell), content_width, "rs", Side::Right, gutter_width, &ctx);
341        let lines = frame.into_lines();
342        assert_eq!(lines.len(), 1);
343        let row = &lines[0];
344        assert_eq!(
345            row.display_width(),
346            gutter_width + content_width,
347            "row should fill panel width, got {}",
348            row.display_width()
349        );
350        let trailing = row.spans().last().expect("row should have spans");
351        assert_eq!(
352            trailing.style().bg,
353            Some(added_bg),
354            "trailing pad of empty added cell should carry diff_added_bg, got {:?}",
355            trailing.style().bg
356        );
357    }
358
359    #[test]
360    fn empty_preview_produces_no_output() {
361        let preview = make_split_preview(vec![]);
362        let ctx = test_context_with_width(100);
363        let lines = highlight_split_diff(&preview, &ctx);
364        assert!(lines.is_empty());
365    }
366
367    #[test]
368    fn render_diff_dispatches_to_unified_below_80() {
369        let preview = DiffPreview {
370            lines: vec![DiffLine { tag: DiffTag::Removed, content: "old".to_string() }],
371            rows: vec![SplitDiffRow { left: Some(removed_cell("old", 1)), right: None }],
372            lang_hint: String::new(),
373            start_line: None,
374        };
375        let ctx = test_context_with_width(79);
376        let lines = render_diff(&preview, &ctx);
377        // Unified renderer uses prefix "  - "
378        assert!(
379            lines[0].plain_text().contains("- old"),
380            "should use unified renderer below 80: {}",
381            lines[0].plain_text()
382        );
383    }
384
385    #[test]
386    fn new_file_uses_unified_view_even_at_wide_width() {
387        // A new file (only additions, no removals) should use unified view
388        // since split view would have an empty left panel
389        let preview = DiffPreview {
390            lines: vec![
391                DiffLine { tag: DiffTag::Added, content: "fn main() {".to_string() },
392                DiffLine { tag: DiffTag::Added, content: "    println!(\"Hello\");".to_string() },
393                DiffLine { tag: DiffTag::Added, content: "}".to_string() },
394            ],
395            rows: vec![
396                SplitDiffRow { left: None, right: Some(added_cell("fn main() {", 1)) },
397                SplitDiffRow { left: None, right: Some(added_cell("    println!(\"Hello\");", 2)) },
398                SplitDiffRow { left: None, right: Some(added_cell("}", 3)) },
399            ],
400            lang_hint: "rs".to_string(),
401            start_line: None,
402        };
403        let ctx = test_context_with_width(100);
404        let lines = render_diff(&preview, &ctx);
405        // Unified renderer uses prefix "  + "
406        let text = lines[0].plain_text();
407        assert!(text.contains("+ fn main()"), "should use unified renderer for new file: {text}");
408    }
409
410    #[test]
411    fn render_diff_dispatches_to_split_at_80() {
412        let preview = DiffPreview {
413            lines: vec![DiffLine { tag: DiffTag::Removed, content: "old".to_string() }],
414            rows: vec![SplitDiffRow { left: Some(removed_cell("old", 1)), right: None }],
415            lang_hint: String::new(),
416            start_line: None,
417        };
418        let ctx = test_context_with_width(80);
419        let lines = render_diff(&preview, &ctx);
420        let text = lines[0].plain_text();
421        // Split renderer shows line number gutter, not unified "- " prefix
422        assert!(!text.contains("- old"), "should use split renderer at 80: {text}");
423    }
424
425    #[test]
426    fn line_numbers_rendered_when_start_line_set() {
427        let preview = make_split_preview(vec![SplitDiffRow {
428            left: Some(SplitDiffCell { tag: DiffTag::Context, content: "hello".to_string(), line_number: Some(42) }),
429            right: Some(SplitDiffCell { tag: DiffTag::Context, content: "hello".to_string(), line_number: Some(42) }),
430        }]);
431        let ctx = test_context_with_width(100);
432        let lines = highlight_split_diff(&preview, &ctx);
433        let text = lines[0].plain_text();
434        assert!(text.contains("42"), "line number should be shown: {text}");
435    }
436
437    #[test]
438    fn wrapped_row_pads_shorter_side_to_match_height() {
439        // Left side has a long line that wraps, right side is short
440        let long = "a".repeat(200);
441        let preview = make_split_preview(vec![SplitDiffRow {
442            left: Some(removed_cell(&long, 1)),
443            right: Some(added_cell("short", 1)),
444        }]);
445        let ctx = test_context_with_width(100);
446        let lines = highlight_split_diff(&preview, &ctx);
447        assert!(lines.len() > 1, "long left side should produce multiple visual lines");
448        // All lines should have consistent width
449        let first_width = lines[0].display_width();
450        for (i, line) in lines.iter().enumerate() {
451            assert_eq!(line.display_width(), first_width, "line {i} width mismatch");
452        }
453    }
454
455    #[test]
456    fn separator_has_no_background_on_context_row() {
457        let preview = make_split_preview(vec![SplitDiffRow {
458            left: Some(SplitDiffCell { tag: DiffTag::Context, content: "hello".to_string(), line_number: Some(1) }),
459            right: Some(SplitDiffCell { tag: DiffTag::Context, content: "world".to_string(), line_number: Some(1) }),
460        }]);
461        let ctx = test_context_with_width(100);
462        let lines = highlight_split_diff(&preview, &ctx);
463        assert_eq!(lines.len(), 1);
464
465        let gutter_width = gutter_width_for_preview(&preview);
466        let usable = 100usize - (gutter_width * 2 + SEPARATOR_WIDTH + LEFT_INNER_PAD);
467        let left_content = usable / 2;
468        let sep_start = gutter_width + left_content + LEFT_INNER_PAD;
469        let sep_end = sep_start + SEPARATOR_WIDTH;
470        for col in sep_start..sep_end {
471            let style = style_at_column(&lines[0], col);
472            assert!(
473                style.bg.is_none(),
474                "separator column {col} should have no background on context row, got {:?}",
475                style.bg
476            );
477        }
478    }
479
480    #[test]
481    fn blank_gutter_when_line_number_none() {
482        let preview = make_split_preview(vec![SplitDiffRow {
483            left: Some(SplitDiffCell { tag: DiffTag::Removed, content: "old".to_string(), line_number: None }),
484            right: None,
485        }]);
486        let ctx = test_context_with_width(100);
487        let lines = highlight_split_diff(&preview, &ctx);
488        let text = lines[0].plain_text();
489        let blank_gutter = " ".repeat(gutter_width_for_preview(&preview));
490        assert!(text.starts_with(&blank_gutter), "should have blank gutter: {text:?}");
491    }
492
493    #[test]
494    fn left_pane_diff_bg_extends_through_inner_pad_to_separator() {
495        let preview = make_split_preview(vec![SplitDiffRow {
496            left: Some(removed_cell("old", 1)),
497            right: Some(added_cell("new", 1)),
498        }]);
499        let ctx = test_context_with_width(100);
500        let lines = highlight_split_diff(&preview, &ctx);
501        assert_eq!(lines.len(), 1);
502
503        let gutter_width = gutter_width_for_preview(&preview);
504        let usable = 100usize - (gutter_width * 2 + SEPARATOR_WIDTH + LEFT_INNER_PAD);
505        let left_content = usable / 2;
506        let inner_pad_col = gutter_width + left_content + LEFT_INNER_PAD - 1;
507        let style = style_at_column(&lines[0], inner_pad_col);
508        assert_eq!(
509            style.bg,
510            Some(ctx.theme.diff_removed_bg()),
511            "left inner-edge pad column {inner_pad_col} should carry diff_removed_bg, got {:?}",
512            style.bg,
513        );
514    }
515
516    #[test]
517    fn right_pane_line_number_sits_adjacent_to_separator() {
518        let preview = make_split_preview(vec![SplitDiffRow {
519            left: Some(removed_cell("old", 89)),
520            right: Some(added_cell("new", 89)),
521        }]);
522        let ctx = test_context_with_width(100);
523        let lines = highlight_split_diff(&preview, &ctx);
524        assert_eq!(lines.len(), 1);
525
526        let gutter_width = gutter_width_for_preview(&preview);
527        let usable = 100usize - (gutter_width * 2 + SEPARATOR_WIDTH + LEFT_INNER_PAD);
528        let left_content = usable / 2;
529        let right_pane_first_col = gutter_width + left_content + LEFT_INNER_PAD + SEPARATOR_WIDTH;
530        let text = lines[0].plain_text();
531        let chars: Vec<char> = text.chars().collect();
532        assert_eq!(
533            chars[right_pane_first_col], '8',
534            "right pane line# digit '8' should sit at the very first column after SEP (col {right_pane_first_col}); got line: {text:?}",
535        );
536        assert_eq!(
537            chars[right_pane_first_col + 1],
538            '9',
539            "right pane line# digit '9' should follow at col {}",
540            right_pane_first_col + 1
541        );
542    }
543}