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