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