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