1use crate::syntax::highlighted_line;
4use clankerdiff_core::{
5 DiffDocument, DiffPresentation, FileDiff, Layout, PresentationOptions, PresentedCell,
6 PresentedRow, RowKind, ViewMode,
7};
8use clankerdiff_syntax::{HighlightSpan, LanguageHint, SyntaxHighlighter, SyntaxTheme};
9use clankerdiff_theme::{ReviewTheme, Rgba};
10use ratatui::{
11 style::{Color, Style},
12 text::{Line, Span},
13};
14use std::sync::Arc;
15use unicode_width::UnicodeWidthChar;
16
17const SPLIT_BREAKPOINT: u16 = 96;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub struct DiffPreviewOptions {
22 pub max_content_rows: usize,
23 pub view_mode: ViewMode,
24 pub include_hunk_headers: bool,
25 pub overflow_summary: bool,
26}
27
28impl Default for DiffPreviewOptions {
29 fn default() -> Self {
30 Self {
31 max_content_rows: 20,
32 view_mode: ViewMode::Auto,
33 include_hunk_headers: true,
34 overflow_summary: true,
35 }
36 }
37}
38
39pub(crate) fn cell_highlights(
43 highlighter: &mut SyntaxHighlighter,
44 theme: &SyntaxTheme,
45 presentation: &DiffPresentation,
46 row: &PresentedRow,
47 cell: &PresentedCell,
48) -> Arc<[HighlightSpan]> {
49 let mut syntax = highlighter.with_theme(theme);
50 if let (Some(source), Some(path), Some(line)) = (
51 presentation.source_document(row, cell),
52 presentation.source_path(row, cell),
53 cell.line_number().and_then(|line| line.checked_sub(1)),
54 ) {
55 return syntax
56 .highlight_document(
57 source.sequence_id(),
58 LanguageHint::Path(path),
59 source.text(),
60 )
61 .line_shared(line)
62 .unwrap_or_else(clankerdiff_syntax::empty_spans);
63 }
64 if let Some(sequence) = presentation.hunk_sequence(row, cell) {
65 return syntax
66 .highlight_document_lines(
67 sequence.id,
68 LanguageHint::Path(sequence.path),
69 sequence.lines(),
70 )
71 .line_shared(sequence.target_line)
72 .unwrap_or_else(clankerdiff_syntax::empty_spans);
73 }
74 syntax.highlight_source(LanguageHint::Path(presentation.row_path(row)), &cell.text)
75}
76
77#[must_use]
82pub fn render_diff_preview(
83 file: FileDiff,
84 width: u16,
85 theme: &ReviewTheme,
86 highlighter: &mut SyntaxHighlighter,
87 options: DiffPreviewOptions,
88) -> Vec<Line<'static>> {
89 let document = Arc::new(DiffDocument {
90 repo_root: String::new(),
91 files: vec![file],
92 });
93 let presentation = DiffPresentation::new(
94 document,
95 PresentationOptions {
96 view_mode: options.view_mode,
97 split_when_auto: width >= SPLIT_BREAKPOINT,
98 include_file_headers: false,
99 },
100 );
101 let eligible = presentation
102 .rows(0..presentation.row_count())
103 .iter()
104 .filter(|row| options.include_hunk_headers || row.kind != RowKind::HunkHeader)
105 .collect::<Vec<_>>();
106 let shown = eligible.len().min(options.max_content_rows);
107 let mut lines = eligible
108 .iter()
109 .take(shown)
110 .map(|row| {
111 let mut cell_line =
112 |cell, width| render_cell(&presentation, row, cell, width, theme, highlighter);
113 match presentation.layout() {
114 Layout::Unified => row
115 .primary_cell()
116 .map_or_else(Line::default, |cell| cell_line(cell, width)),
117 Layout::Split => {
118 let half = width.saturating_sub(1) / 2;
119 let left = row
120 .left
121 .as_ref()
122 .map_or_else(Line::default, |cell| cell_line(cell, half));
123 let right = row
124 .right
125 .as_ref()
126 .map_or_else(Line::default, |cell| cell_line(cell, half));
127 let mut spans = left.spans;
128 spans.push(Span::styled("│", Style::new().fg(color(theme.diff.border))));
129 spans.extend(right.spans);
130 Line::from(spans)
131 }
132 }
133 })
134 .collect::<Vec<_>>();
135 let overflow = eligible.len().saturating_sub(shown);
136 if options.overflow_summary && overflow > 0 {
137 lines.push(Line::styled(
138 format!("… {overflow} more rows"),
139 Style::new().fg(color(theme.diff.muted)),
140 ));
141 }
142 lines
143}
144
145fn render_cell(
146 presentation: &DiffPresentation,
147 row: &PresentedRow,
148 cell: &PresentedCell,
149 width: u16,
150 theme: &ReviewTheme,
151 highlighter: &mut SyntaxHighlighter,
152) -> Line<'static> {
153 let colors = theme.diff.tone(cell.tone);
154 let base = Style::new()
155 .fg(color(colors.foreground))
156 .bg(color(colors.background));
157 let marker = cell.tone.marker();
158 let number = cell
159 .line_number()
160 .map_or_else(|| " ".to_owned(), |line| format!("{line:>4}"));
161 let prefix = format!("{number} {marker} ");
162 let available = usize::from(width).saturating_sub(7);
163 let text = truncate_width(&cell.text, available);
164 let spans = cell_highlights(highlighter, &theme.syntax, presentation, row, cell);
165 let clipped_spans = spans
166 .iter()
167 .filter_map(|span| clip_span(span, text.len()))
168 .collect::<Vec<_>>();
169 let mut line = highlighted_line(&text, &clipped_spans, base);
170 line.spans.insert(0, Span::styled(prefix, base));
171 line
172}
173
174fn clip_span(span: &HighlightSpan, source_len: usize) -> Option<HighlightSpan> {
175 let start = span.range.start.min(source_len);
176 let end = span.range.end.min(source_len);
177 (start < end).then_some(HighlightSpan {
178 range: start..end,
179 foreground: span.foreground,
180 font_style: span.font_style,
181 })
182}
183
184fn truncate_width(source: &str, width: usize) -> String {
185 let mut used: usize = 0;
186 source
187 .chars()
188 .take_while(|character| {
189 let next = used.saturating_add(character.width().unwrap_or(0));
190 if next > width {
191 false
192 } else {
193 used = next;
194 true
195 }
196 })
197 .collect()
198}
199
200const fn color(value: Rgba) -> Color {
201 Color::Rgb(value.r, value.g, value.b)
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207 use clankerdiff_core::testing::DocumentBuilder;
208
209 #[test]
210 fn patch_only_cells_keep_multiline_hunk_context() {
211 let document = DocumentBuilder::new()
212 .file(
213 FileDiff::from_texts("src/a.rs", "", "/* alpha\nbeta\ngamma */\nlet x = 1;\n")
214 .unwrap(),
215 )
216 .build();
217 let presentation = DiffPresentation::new(document, PresentationOptions::default());
218 let theme = ReviewTheme::default();
219 let mut highlighter = SyntaxHighlighter::default();
220 let mut highlights = |text: &str| {
221 let (row, cell) = presentation
222 .rows(0..presentation.row_count())
223 .iter()
224 .find_map(|row| {
225 let cell = row.primary_cell()?;
226 (cell.text.as_ref() == text).then_some((row, cell))
227 })
228 .unwrap();
229 cell_highlights(&mut highlighter, &theme.syntax, &presentation, row, cell)
230 };
231
232 let opener = highlights("/* alpha");
233 let continuation = highlights("beta");
234 assert_eq!(continuation.len(), 1, "one comment span covers the line");
235 assert_eq!(continuation[0].range, 0.."beta".len());
236 assert_eq!(
237 continuation[0].foreground, opener[0].foreground,
238 "the comment continues across hunk lines"
239 );
240 }
241}