1use 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#[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#[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 let line = match cell {
280 Some(cell) if width > 0 => Line::from(diff_indicator(cell.tone, self.theme, style)),
281 _ => Line::default(),
282 };
283 pad_line(line.style(style), usize::from(width))
284 }
285
286 fn render_cell(
287 &mut self,
288 row: &PresentedRow,
289 cell: &PresentedCell,
290 width: u16,
291 limit: usize,
292 ) -> Vec<Line<'static>> {
293 let base = self.cell_style(cell);
294 let number = cell
295 .line_number()
296 .map_or_else(String::new, |line| line.to_string());
297 let number_width = number.len().max(4);
298 let gutter = |number: &str| {
299 vec![
300 diff_indicator(cell.tone, self.theme, base),
301 Span::styled(format!("{number:>number_width$} "), base),
302 ]
303 };
304 let width = usize::from(width);
305 let gutter_width = number_width + 2;
306 if width <= gutter_width {
307 return vec![fit_line(
308 Line::from(gutter(&number)).style(base),
309 width,
310 self.tab_width,
311 )];
312 }
313 let spans = cell_highlights(
314 self.highlighter,
315 &self.theme.syntax,
316 self.presentation,
317 row,
318 cell,
319 );
320 let content = highlighted_line(&cell.text, &spans, base).spans;
321 let content_width = width - gutter_width;
322 let wrap = matches!(row.kind, RowKind::Code | RowKind::ExpandedContext);
323 fit(content, content_width, wrap, self.tab_width)
324 .take(limit)
325 .enumerate()
326 .map(|(segment, line)| {
327 let mut line = pad_line(line.style(base), content_width);
328 line.spans
329 .splice(0..0, gutter(if segment == 0 { &number } else { "↪" }));
330 line
331 })
332 .collect()
333 }
334}
335
336fn fit_line(line: Line<'static>, width: usize, tab_width: u16) -> Line<'static> {
338 let base = line.style;
339 let fitted = fit(line.spans, width, false, tab_width)
340 .next()
341 .unwrap_or_default();
342 pad_line(fitted.style(base), width)
343}
344
345fn page_style(theme: &ReviewTheme) -> Style {
346 Style::new().bg(page_color(theme, theme.diff.background))
347}
348
349fn fit(
350 spans: Vec<Span<'_>>,
351 width: usize,
352 wrap: bool,
353 tab_width: u16,
354) -> impl Iterator<Item = Line<'static>> {
355 fit_spans_from(
356 spans,
357 FitOptions {
358 width,
359 wrap,
360 tab_width: usize::from(tab_width),
361 continuation: "",
362 },
363 FitPosition::default(),
364 )
365 .map(|(line, _)| line)
366}
367
368fn pad_line(mut line: Line<'static>, width: usize) -> Line<'static> {
369 let used = line.width();
370 line.spans.push(Span::styled(
371 " ".repeat(width.saturating_sub(used)),
372 line.style,
373 ));
374 line
375}