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