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