1use 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#[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#[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
260fn 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}