Skip to main content

clankerdiff_ratatui/
diff_preview.rs

1//! Compact, bounded rendering for replaceable in-progress diff previews.
2
3use crate::syntax::highlighted_line;
4use clankerdiff_core::{
5    DiffDocument, DiffPresentation, FileDiff, Layout, PresentationOptions, PresentedCell,
6    PresentedRow, RowKind, ViewMode,
7};
8use clankerdiff_syntax::{HighlightSpan, LanguageHint, SyntaxHighlighter, SyntaxTheme};
9use clankerdiff_theme::{ReviewTheme, Rgba};
10use ratatui::{
11    style::{Color, Style},
12    text::{Line, Span},
13};
14use std::sync::Arc;
15use unicode_width::UnicodeWidthChar;
16
17const SPLIT_BREAKPOINT: u16 = 96;
18
19/// Controls compact preview layout and truncation.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub struct DiffPreviewOptions {
22    pub max_content_rows: usize,
23    pub view_mode: ViewMode,
24    pub include_hunk_headers: bool,
25    pub overflow_summary: bool,
26}
27
28impl Default for DiffPreviewOptions {
29    fn default() -> Self {
30        Self {
31            max_content_rows: 20,
32            view_mode: ViewMode::Auto,
33            include_hunk_headers: true,
34            overflow_summary: true,
35        }
36    }
37}
38
39/// Highlights one presented cell, preferring its complete source version, then
40/// its bounded hunk-side sequence, and finally a path-hinted single-line
41/// highlight for synthetic cells and oversized hunks.
42pub(crate) fn cell_highlights(
43    highlighter: &mut SyntaxHighlighter,
44    theme: &SyntaxTheme,
45    presentation: &DiffPresentation,
46    row: &PresentedRow,
47    cell: &PresentedCell,
48) -> Arc<[HighlightSpan]> {
49    let mut syntax = highlighter.with_theme(theme);
50    if let (Some(source), Some(path), Some(line)) = (
51        presentation.source_document(row, cell),
52        presentation.source_path(row, cell),
53        cell.line_number().and_then(|line| line.checked_sub(1)),
54    ) {
55        return syntax
56            .highlight_document(
57                source.sequence_id(),
58                LanguageHint::Path(path),
59                source.text(),
60            )
61            .line_shared(line)
62            .unwrap_or_else(clankerdiff_syntax::empty_spans);
63    }
64    if let Some(sequence) = presentation.hunk_sequence(row, cell) {
65        return syntax
66            .highlight_document_lines(
67                sequence.id,
68                LanguageHint::Path(sequence.path),
69                sequence.lines(),
70            )
71            .line_shared(sequence.target_line)
72            .unwrap_or_else(clankerdiff_syntax::empty_spans);
73    }
74    syntax.highlight_source(LanguageHint::Path(presentation.row_path(row)), &cell.text)
75}
76
77/// Renders one file without constructing review state or executing Git.
78///
79/// Hosts can call this for each replacement snapshot produced by
80/// `FileDiff::from_texts`; no watcher or incremental patch transport is needed.
81#[must_use]
82pub fn render_diff_preview(
83    file: FileDiff,
84    width: u16,
85    theme: &ReviewTheme,
86    highlighter: &mut SyntaxHighlighter,
87    options: DiffPreviewOptions,
88) -> Vec<Line<'static>> {
89    let document = Arc::new(DiffDocument {
90        repo_root: String::new(),
91        files: vec![file],
92    });
93    let presentation = DiffPresentation::new(
94        document,
95        PresentationOptions {
96            view_mode: options.view_mode,
97            split_when_auto: width >= SPLIT_BREAKPOINT,
98            include_file_headers: false,
99        },
100    );
101    let eligible = presentation
102        .rows(0..presentation.row_count())
103        .iter()
104        .filter(|row| options.include_hunk_headers || row.kind != RowKind::HunkHeader)
105        .collect::<Vec<_>>();
106    let shown = eligible.len().min(options.max_content_rows);
107    let mut lines = eligible
108        .iter()
109        .take(shown)
110        .map(|row| {
111            let mut cell_line =
112                |cell, width| render_cell(&presentation, row, cell, width, theme, highlighter);
113            match presentation.layout() {
114                Layout::Unified => row
115                    .primary_cell()
116                    .map_or_else(Line::default, |cell| cell_line(cell, width)),
117                Layout::Split => {
118                    let half = width.saturating_sub(1) / 2;
119                    let left = row
120                        .left
121                        .as_ref()
122                        .map_or_else(Line::default, |cell| cell_line(cell, half));
123                    let right = row
124                        .right
125                        .as_ref()
126                        .map_or_else(Line::default, |cell| cell_line(cell, half));
127                    let mut spans = left.spans;
128                    spans.push(Span::styled("│", Style::new().fg(color(theme.diff.border))));
129                    spans.extend(right.spans);
130                    Line::from(spans)
131                }
132            }
133        })
134        .collect::<Vec<_>>();
135    let overflow = eligible.len().saturating_sub(shown);
136    if options.overflow_summary && overflow > 0 {
137        lines.push(Line::styled(
138            format!("… {overflow} more rows"),
139            Style::new().fg(color(theme.diff.muted)),
140        ));
141    }
142    lines
143}
144
145fn render_cell(
146    presentation: &DiffPresentation,
147    row: &PresentedRow,
148    cell: &PresentedCell,
149    width: u16,
150    theme: &ReviewTheme,
151    highlighter: &mut SyntaxHighlighter,
152) -> Line<'static> {
153    let colors = theme.diff.tone(cell.tone);
154    let base = Style::new()
155        .fg(color(colors.foreground))
156        .bg(color(colors.background));
157    let marker = cell.tone.marker();
158    let number = cell
159        .line_number()
160        .map_or_else(|| "    ".to_owned(), |line| format!("{line:>4}"));
161    let prefix = format!("{number} {marker} ");
162    let available = usize::from(width).saturating_sub(7);
163    let text = truncate_width(&cell.text, available);
164    let spans = cell_highlights(highlighter, &theme.syntax, presentation, row, cell);
165    let clipped_spans = spans
166        .iter()
167        .filter_map(|span| clip_span(span, text.len()))
168        .collect::<Vec<_>>();
169    let mut line = highlighted_line(&text, &clipped_spans, base);
170    line.spans.insert(0, Span::styled(prefix, base));
171    line
172}
173
174fn clip_span(span: &HighlightSpan, source_len: usize) -> Option<HighlightSpan> {
175    let start = span.range.start.min(source_len);
176    let end = span.range.end.min(source_len);
177    (start < end).then_some(HighlightSpan {
178        range: start..end,
179        foreground: span.foreground,
180        font_style: span.font_style,
181    })
182}
183
184fn truncate_width(source: &str, width: usize) -> String {
185    let mut used: usize = 0;
186    source
187        .chars()
188        .take_while(|character| {
189            let next = used.saturating_add(character.width().unwrap_or(0));
190            if next > width {
191                false
192            } else {
193                used = next;
194                true
195            }
196        })
197        .collect()
198}
199
200const fn color(value: Rgba) -> Color {
201    Color::Rgb(value.r, value.g, value.b)
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use clankerdiff_core::testing::DocumentBuilder;
208
209    #[test]
210    fn patch_only_cells_keep_multiline_hunk_context() {
211        let document = DocumentBuilder::new()
212            .file(
213                FileDiff::from_texts("src/a.rs", "", "/* alpha\nbeta\ngamma */\nlet x = 1;\n")
214                    .unwrap(),
215            )
216            .build();
217        let presentation = DiffPresentation::new(document, PresentationOptions::default());
218        let theme = ReviewTheme::default();
219        let mut highlighter = SyntaxHighlighter::default();
220        let mut highlights = |text: &str| {
221            let (row, cell) = presentation
222                .rows(0..presentation.row_count())
223                .iter()
224                .find_map(|row| {
225                    let cell = row.primary_cell()?;
226                    (cell.text.as_ref() == text).then_some((row, cell))
227                })
228                .unwrap();
229            cell_highlights(&mut highlighter, &theme.syntax, &presentation, row, cell)
230        };
231
232        let opener = highlights("/* alpha");
233        let continuation = highlights("beta");
234        assert_eq!(continuation.len(), 1, "one comment span covers the line");
235        assert_eq!(continuation[0].range, 0.."beta".len());
236        assert_eq!(
237            continuation[0].foreground, opener[0].foreground,
238            "the comment continues across hunk lines"
239        );
240    }
241}