Skip to main content

clankerdiff_ratatui/
diff_preview.rs

1//! Compact, bounded rendering for replaceable in-progress diff previews.
2
3use crate::{
4    color::{layered_style, page_color},
5    syntax::highlighted_line,
6    text::{FitOptions, fit_spans},
7};
8use clankerdiff_core::{
9    DiffDocument, DiffPresentation, FileDiff, Layout, PresentationOptions, PresentedCell,
10    PresentedRow, RowKind, ViewMode,
11};
12use clankerdiff_syntax::{HighlightSpan, LanguageHint, SyntaxHighlighter, SyntaxTheme};
13use clankerdiff_theme::{Fingerprint, ReviewTheme};
14use ratatui::{
15    style::Style,
16    text::{Line, Span},
17};
18use std::sync::Arc;
19
20const SPLIT_BREAKPOINT: u16 = 96;
21
22/// Controls compact preview layout and truncation.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub struct DiffPreviewOptions {
25    pub max_content_rows: usize,
26    pub view_mode: ViewMode,
27    pub include_hunk_headers: bool,
28    pub overflow_summary: bool,
29}
30
31impl Default for DiffPreviewOptions {
32    fn default() -> Self {
33        Self {
34            max_content_rows: 20,
35            view_mode: ViewMode::Auto,
36            include_hunk_headers: true,
37            overflow_summary: true,
38        }
39    }
40}
41
42#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
43pub struct DiffPreviewStats {
44    pub presentations_built: usize,
45    pub rows_generated: usize,
46    pub cache_hits: usize,
47}
48
49type PresentationKey = (ViewMode, bool);
50type RenderKey = (u16, DiffPreviewOptions, Fingerprint);
51
52#[derive(Debug)]
53pub struct DiffPreviewState {
54    document: Arc<DiffDocument>,
55    presentation: Option<(PresentationKey, DiffPresentation)>,
56    rendered: Option<(RenderKey, Arc<[Line<'static>]>)>,
57    stats: DiffPreviewStats,
58}
59
60impl DiffPreviewState {
61    #[must_use]
62    pub fn new(file: FileDiff) -> Self {
63        Self {
64            document: preview_document(file),
65            presentation: None,
66            rendered: None,
67            stats: DiffPreviewStats::default(),
68        }
69    }
70
71    pub fn set_file(&mut self, file: FileDiff) {
72        *self = Self {
73            stats: self.stats,
74            ..Self::new(file)
75        };
76    }
77
78    pub fn take_stats(&mut self) -> DiffPreviewStats {
79        std::mem::take(&mut self.stats)
80    }
81
82    pub fn render(
83        &mut self,
84        width: u16,
85        theme: &ReviewTheme,
86        highlighter: &mut SyntaxHighlighter,
87        options: DiffPreviewOptions,
88    ) -> Arc<[Line<'static>]> {
89        let key = (width, options, theme.revision());
90        if let Some((cached, rows)) = &self.rendered
91            && *cached == key
92        {
93            self.stats.cache_hits += 1;
94            return Arc::clone(rows);
95        }
96        let presentation_key = (options.view_mode, width >= SPLIT_BREAKPOINT);
97        let presentation = match &mut self.presentation {
98            Some((cached, presentation)) if *cached == presentation_key => presentation,
99            slot => {
100                self.stats.presentations_built += 1;
101                let presentation = preview_presentation(Arc::clone(&self.document), width, options);
102                &slot.insert((presentation_key, presentation)).1
103            }
104        };
105        let rows: Arc<[Line<'static>]> =
106            render_preview_rows(presentation, width, theme, highlighter, options).into();
107        self.stats.rows_generated += rows.len();
108        self.rendered = Some((key, Arc::clone(&rows)));
109        rows
110    }
111}
112
113fn preview_document(file: FileDiff) -> Arc<DiffDocument> {
114    Arc::new(DiffDocument {
115        repo_root: String::new(),
116        files: vec![file],
117    })
118}
119
120fn preview_presentation(
121    document: Arc<DiffDocument>,
122    width: u16,
123    options: DiffPreviewOptions,
124) -> DiffPresentation {
125    DiffPresentation::new(
126        document,
127        PresentationOptions {
128            view_mode: options.view_mode,
129            split_when_auto: width >= SPLIT_BREAKPOINT,
130            include_file_headers: false,
131        },
132    )
133}
134
135/// Highlights one presented cell, preferring its complete source version, then
136/// its bounded hunk-side sequence, and finally a path-hinted single-line
137/// highlight for synthetic cells and oversized hunks.
138pub(crate) fn cell_highlights(
139    highlighter: &mut SyntaxHighlighter,
140    theme: &SyntaxTheme,
141    presentation: &DiffPresentation,
142    row: &PresentedRow,
143    cell: &PresentedCell,
144) -> Arc<[HighlightSpan]> {
145    let mut syntax = highlighter.with_theme(theme);
146    if let (Some(source), Some(path), Some(line)) = (
147        presentation.source_document(row, cell),
148        presentation.source_path(row, cell),
149        cell.line_number().and_then(|line| line.checked_sub(1)),
150    ) {
151        return syntax
152            .highlight_document(
153                source.sequence_id(),
154                LanguageHint::Path(path),
155                source.text(),
156            )
157            .line_shared(line)
158            .unwrap_or_else(clankerdiff_syntax::empty_spans);
159    }
160    if let Some(sequence) = presentation.hunk_sequence(row, cell) {
161        return syntax
162            .highlight_document_lines(
163                sequence.id,
164                LanguageHint::Path(sequence.path),
165                sequence.lines(),
166            )
167            .line_shared(sequence.target_line)
168            .unwrap_or_else(clankerdiff_syntax::empty_spans);
169    }
170    syntax.highlight_source(LanguageHint::Path(presentation.row_path(row)), &cell.text)
171}
172
173/// Renders one file without constructing review state or executing Git.
174///
175/// Hosts can call this for each replacement snapshot produced by
176/// `FileDiff::from_texts`; no watcher or incremental patch transport is needed.
177#[must_use]
178pub fn render_diff_preview(
179    file: FileDiff,
180    width: u16,
181    theme: &ReviewTheme,
182    highlighter: &mut SyntaxHighlighter,
183    options: DiffPreviewOptions,
184) -> Vec<Line<'static>> {
185    let presentation = preview_presentation(preview_document(file), width, options);
186    render_preview_rows(&presentation, width, theme, highlighter, options)
187}
188
189fn render_preview_rows(
190    presentation: &DiffPresentation,
191    width: u16,
192    theme: &ReviewTheme,
193    highlighter: &mut SyntaxHighlighter,
194    options: DiffPreviewOptions,
195) -> Vec<Line<'static>> {
196    if width == 0 {
197        return Vec::new();
198    }
199    let eligible = presentation
200        .rows(0..presentation.row_count())
201        .iter()
202        .filter(|row| options.include_hunk_headers || row.kind != RowKind::HunkHeader)
203        .collect::<Vec<_>>();
204    let shown = eligible.len().min(options.max_content_rows);
205    let mut lines = eligible
206        .iter()
207        .take(shown)
208        .map(|row| {
209            let mut cell_line =
210                |cell, width| render_cell(presentation, row, cell, width, theme, highlighter);
211            match presentation.layout() {
212                Layout::Unified => row
213                    .primary_cell()
214                    .map_or_else(Line::default, |cell| cell_line(cell, width)),
215                Layout::Split => {
216                    let half = width.saturating_sub(1) / 2;
217                    let right_width = width.saturating_sub(1).saturating_sub(half);
218                    let blank = |width| {
219                        Line::styled(
220                            " ".repeat(usize::from(width)),
221                            Style::new().bg(page_color(theme, theme.diff.background)),
222                        )
223                    };
224                    let left = row
225                        .left
226                        .as_ref()
227                        .map_or_else(|| blank(half), |cell| cell_line(cell, half));
228                    let right = row
229                        .right
230                        .as_ref()
231                        .map_or_else(|| blank(right_width), |cell| cell_line(cell, right_width));
232                    let mut spans = left.spans;
233                    spans.push(Span::styled(
234                        "│",
235                        Style::new().fg(page_color(theme, theme.diff.border)),
236                    ));
237                    spans.extend(right.spans);
238                    Line::from(spans)
239                }
240            }
241        })
242        .collect::<Vec<_>>();
243    let overflow = eligible.len().saturating_sub(shown);
244    if options.overflow_summary && overflow > 0 {
245        lines.push(fit_line(
246            Line::styled(
247                format!("… {overflow} more rows"),
248                Style::new()
249                    .fg(page_color(theme, theme.diff.muted))
250                    .bg(page_color(theme, theme.diff.background)),
251            ),
252            usize::from(width),
253        ));
254    }
255    lines
256}
257
258fn render_cell(
259    presentation: &DiffPresentation,
260    row: &PresentedRow,
261    cell: &PresentedCell,
262    width: u16,
263    theme: &ReviewTheme,
264    highlighter: &mut SyntaxHighlighter,
265) -> Line<'static> {
266    let colors = theme.diff.tone(cell.tone);
267    let base = layered_style(colors.foreground, colors.background, theme.diff.background);
268    let marker = cell.tone.marker();
269    let number = cell
270        .line_number()
271        .map_or_else(|| "    ".to_owned(), |line| format!("{line:>4}"));
272    let prefix = format!("{number} {marker} ");
273    let spans = cell_highlights(highlighter, &theme.syntax, presentation, row, cell);
274    let mut line = highlighted_line(&cell.text, &spans, base);
275    line.spans.insert(0, Span::styled(prefix, base));
276    line.style = base;
277    fit_line(line, usize::from(width))
278}
279
280/// Clips a line to `width` cells and pads it with the line's base style.
281fn fit_line(line: Line<'static>, width: usize) -> Line<'static> {
282    let base = line.style;
283    let mut fitted = fit_spans(
284        line.spans,
285        FitOptions {
286            width,
287            wrap: false,
288            tab_width: 4,
289            continuation: "",
290        },
291    )
292    .into_iter()
293    .next()
294    .unwrap_or_default();
295    let used = fitted.width();
296    fitted
297        .spans
298        .push(Span::styled(" ".repeat(width.saturating_sub(used)), base));
299    fitted.style(base)
300}