1use crate::diffs::diff::highlight_diff;
2use crate::line::Line;
3use crate::rendering::render_context::ViewContext;
4use crate::rendering::soft_wrap::soft_wrap_line;
5use crate::span::Span;
6use crate::style::Style;
7
8use crate::{DiffPreview, DiffTag, SplitDiffCell};
9
10const MAX_DIFF_LINES: usize = 20;
11pub const MIN_SPLIT_WIDTH: u16 = 80;
12pub const GUTTER_WIDTH: usize = 5;
13pub const SEPARATOR: &str = "";
14pub const SEPARATOR_WIDTH: usize = 0;
15const FIXED_OVERHEAD: usize = GUTTER_WIDTH * 2 + SEPARATOR_WIDTH;
16
17pub fn render_diff(preview: &DiffPreview, context: &ViewContext) -> Vec<Line> {
23 let has_removals = preview.lines.iter().any(|l| l.tag == DiffTag::Removed);
24
25 if context.size.width >= MIN_SPLIT_WIDTH && has_removals {
26 highlight_split_diff(preview, context)
27 } else {
28 highlight_diff(preview, context)
29 }
30}
31
32fn highlight_split_diff(preview: &DiffPreview, context: &ViewContext) -> Vec<Line> {
33 let theme = &context.theme;
34 let terminal_width = context.size.width as usize;
35 let usable = terminal_width.saturating_sub(FIXED_OVERHEAD);
36 let left_content = usable / 2;
37 let right_content = usable - left_content;
38 let left_panel = GUTTER_WIDTH + left_content;
39 let right_panel = GUTTER_WIDTH + right_content;
40
41 let mut lines = Vec::new();
42 let mut visual_lines = 0usize;
43 let mut rows_consumed = 0usize;
44
45 for row in &preview.rows {
46 let left_lines = render_cell(row.left.as_ref(), left_content, &preview.lang_hint, context);
47 let right_lines = render_cell(
48 row.right.as_ref(),
49 right_content,
50 &preview.lang_hint,
51 context,
52 );
53
54 let height = left_lines.len().max(right_lines.len());
55
56 if visual_lines + height > MAX_DIFF_LINES && visual_lines > 0 {
57 break;
58 }
59
60 for i in 0..height {
61 let left = left_lines
62 .get(i)
63 .cloned()
64 .unwrap_or_else(|| blank_panel(left_panel));
65 let right = right_lines
66 .get(i)
67 .cloned()
68 .unwrap_or_else(|| blank_panel(right_panel));
69
70 let mut line = left;
71 line.push_styled(SEPARATOR, theme.muted());
72 line.append_line(&right);
73 lines.push(line);
74 }
75
76 visual_lines += height;
77 rows_consumed += 1;
78 }
79
80 if rows_consumed < preview.rows.len() {
81 let remaining = preview.rows.len() - rows_consumed;
82 let mut overflow = Line::default();
83 overflow.push_styled(format!(" ... {remaining} more lines"), theme.muted());
84 lines.push(overflow);
85 }
86
87 lines
88}
89
90pub fn blank_panel(width: usize) -> Line {
91 let mut line = Line::default();
92 line.push_text(" ".repeat(width));
93 line
94}
95
96pub fn render_cell(
97 cell: Option<&SplitDiffCell>,
98 content_width: usize,
99 lang_hint: &str,
100 context: &ViewContext,
101) -> Vec<Line> {
102 let theme = &context.theme;
103 let panel_width = GUTTER_WIDTH + content_width;
104
105 let Some(cell) = cell else {
106 return vec![blank_panel(panel_width)];
107 };
108
109 let is_context = cell.tag == DiffTag::Context;
110 let bg = match cell.tag {
111 DiffTag::Removed => Some(theme.diff_removed_bg()),
112 DiffTag::Added => Some(theme.diff_added_bg()),
113 DiffTag::Context => None,
114 };
115
116 let highlighted = context
118 .highlighter()
119 .highlight(&cell.content, lang_hint, theme);
120
121 let content_line = if let Some(hl_line) = highlighted.first() {
122 let mut styled_content = Line::default();
123 for span in hl_line.spans() {
124 let mut span_style = span.style();
125 if let Some(bg) = bg {
126 span_style.bg = Some(bg);
127 }
128 if is_context {
129 span_style.dim = true;
130 }
131 styled_content.push_span(Span::with_style(span.text(), span_style));
132 }
133 styled_content
134 } else {
135 let fg = match cell.tag {
136 DiffTag::Removed => theme.diff_removed_fg(),
137 DiffTag::Added => theme.diff_added_fg(),
138 DiffTag::Context => theme.code_fg(),
139 };
140 let mut style = Style::fg(fg);
141 if let Some(bg) = bg {
142 style = style.bg_color(bg);
143 }
144 if is_context {
145 style.dim = true;
146 }
147 Line::with_style(&cell.content, style)
148 };
149
150 #[allow(clippy::cast_possible_truncation)]
152 let wrapped = soft_wrap_line(&content_line, content_width as u16);
153
154 wrapped
155 .into_iter()
156 .enumerate()
157 .map(|(i, mut wrapped_line)| {
158 wrapped_line.extend_bg_to_width(content_width);
159 let mut line = Line::default();
160 if i == 0 {
161 if let Some(num) = cell.line_number {
162 line.push_styled(format!("{num:>4} "), theme.muted());
163 } else {
164 line.push_styled(" ", theme.muted());
165 }
166 } else {
167 line.push_text(" ".repeat(GUTTER_WIDTH));
168 }
169 line.append_line(&wrapped_line);
170 line
171 })
172 .collect()
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178 use crate::{DiffLine, SplitDiffCell, SplitDiffRow};
179
180 fn test_context_with_width(width: u16) -> ViewContext {
181 ViewContext::new((width, 24))
182 }
183
184 fn make_split_preview(rows: Vec<SplitDiffRow>) -> DiffPreview {
185 DiffPreview {
186 lines: vec![],
187 rows,
188 lang_hint: String::new(),
189 start_line: None,
190 }
191 }
192
193 fn removed_cell(content: &str, line_num: usize) -> SplitDiffCell {
194 SplitDiffCell {
195 tag: DiffTag::Removed,
196 content: content.to_string(),
197 line_number: Some(line_num),
198 }
199 }
200
201 fn added_cell(content: &str, line_num: usize) -> SplitDiffCell {
202 SplitDiffCell {
203 tag: DiffTag::Added,
204 content: content.to_string(),
205 line_number: Some(line_num),
206 }
207 }
208
209 #[test]
210 fn both_panels_rendered_with_content() {
211 let preview = make_split_preview(vec![SplitDiffRow {
212 left: Some(removed_cell("old code", 1)),
213 right: Some(added_cell("new code", 1)),
214 }]);
215 let ctx = test_context_with_width(100);
216 let lines = highlight_split_diff(&preview, &ctx);
217 assert_eq!(lines.len(), 1);
218 let text = lines[0].plain_text();
219 assert!(text.contains("old code"), "left panel missing: {text}");
220 assert!(text.contains("new code"), "right panel missing: {text}");
221 }
222
223 #[test]
224 fn long_lines_wrapped_within_terminal_width() {
225 let long = "x".repeat(200);
226 let preview = make_split_preview(vec![SplitDiffRow {
227 left: Some(removed_cell(&long, 1)),
228 right: Some(added_cell(&long, 1)),
229 }]);
230 let ctx = test_context_with_width(100);
231 let lines = highlight_split_diff(&preview, &ctx);
232 assert!(
233 lines.len() > 1,
234 "long line should wrap into multiple visual lines, got {}",
235 lines.len()
236 );
237 for line in &lines {
238 let width = line.display_width();
239 assert!(
240 width <= 100,
241 "line width {width} should not exceed terminal width 100"
242 );
243 }
244 let all_text: String = lines.iter().map(|l| l.plain_text()).collect();
246 let x_count = all_text.chars().filter(|&c| c == 'x').count();
247 assert_eq!(
249 x_count, 400,
250 "all content should be present across wrapped lines"
251 );
252 }
253
254 #[test]
255 fn truncation_budget_applied() {
256 let rows: Vec<SplitDiffRow> = (0..30)
257 .map(|i| SplitDiffRow {
258 left: Some(removed_cell(&format!("old {i}"), i + 1)),
259 right: Some(added_cell(&format!("new {i}"), i + 1)),
260 })
261 .collect();
262 let preview = make_split_preview(rows);
263 let ctx = test_context_with_width(100);
264 let lines = highlight_split_diff(&preview, &ctx);
265 assert_eq!(lines.len(), MAX_DIFF_LINES + 1);
267 let last = lines.last().unwrap().plain_text();
268 assert!(last.contains("more lines"), "overflow text missing: {last}");
269 }
270
271 #[test]
272 fn empty_preview_produces_no_output() {
273 let preview = make_split_preview(vec![]);
274 let ctx = test_context_with_width(100);
275 let lines = highlight_split_diff(&preview, &ctx);
276 assert!(lines.is_empty());
277 }
278
279 #[test]
280 fn render_diff_dispatches_to_unified_below_80() {
281 let preview = DiffPreview {
282 lines: vec![DiffLine {
283 tag: DiffTag::Removed,
284 content: "old".to_string(),
285 }],
286 rows: vec![SplitDiffRow {
287 left: Some(removed_cell("old", 1)),
288 right: None,
289 }],
290 lang_hint: String::new(),
291 start_line: None,
292 };
293 let ctx = test_context_with_width(79);
294 let lines = render_diff(&preview, &ctx);
295 assert!(
297 lines[0].plain_text().contains("- old"),
298 "should use unified renderer below 80: {}",
299 lines[0].plain_text()
300 );
301 }
302
303 #[test]
304 fn new_file_uses_unified_view_even_at_wide_width() {
305 let preview = DiffPreview {
308 lines: vec![
309 DiffLine {
310 tag: DiffTag::Added,
311 content: "fn main() {".to_string(),
312 },
313 DiffLine {
314 tag: DiffTag::Added,
315 content: " println!(\"Hello\");".to_string(),
316 },
317 DiffLine {
318 tag: DiffTag::Added,
319 content: "}".to_string(),
320 },
321 ],
322 rows: vec![
323 SplitDiffRow {
324 left: None,
325 right: Some(added_cell("fn main() {", 1)),
326 },
327 SplitDiffRow {
328 left: None,
329 right: Some(added_cell(" println!(\"Hello\");", 2)),
330 },
331 SplitDiffRow {
332 left: None,
333 right: Some(added_cell("}", 3)),
334 },
335 ],
336 lang_hint: "rs".to_string(),
337 start_line: None,
338 };
339 let ctx = test_context_with_width(100);
340 let lines = render_diff(&preview, &ctx);
341 let text = lines[0].plain_text();
343 assert!(
344 text.contains("+ fn main()"),
345 "should use unified renderer for new file: {text}"
346 );
347 }
348
349 #[test]
350 fn render_diff_dispatches_to_split_at_80() {
351 let preview = DiffPreview {
352 lines: vec![DiffLine {
353 tag: DiffTag::Removed,
354 content: "old".to_string(),
355 }],
356 rows: vec![SplitDiffRow {
357 left: Some(removed_cell("old", 1)),
358 right: None,
359 }],
360 lang_hint: String::new(),
361 start_line: None,
362 };
363 let ctx = test_context_with_width(80);
364 let lines = render_diff(&preview, &ctx);
365 let text = lines[0].plain_text();
366 assert!(
368 !text.contains("- old"),
369 "should use split renderer at 80: {text}"
370 );
371 }
372
373 #[test]
374 fn line_numbers_rendered_when_start_line_set() {
375 let preview = make_split_preview(vec![SplitDiffRow {
376 left: Some(SplitDiffCell {
377 tag: DiffTag::Context,
378 content: "hello".to_string(),
379 line_number: Some(42),
380 }),
381 right: Some(SplitDiffCell {
382 tag: DiffTag::Context,
383 content: "hello".to_string(),
384 line_number: Some(42),
385 }),
386 }]);
387 let ctx = test_context_with_width(100);
388 let lines = highlight_split_diff(&preview, &ctx);
389 let text = lines[0].plain_text();
390 assert!(text.contains("42"), "line number should be shown: {text}");
391 }
392
393 #[test]
394 fn wrapped_row_pads_shorter_side_to_match_height() {
395 let long = "a".repeat(200);
397 let preview = make_split_preview(vec![SplitDiffRow {
398 left: Some(removed_cell(&long, 1)),
399 right: Some(added_cell("short", 1)),
400 }]);
401 let ctx = test_context_with_width(100);
402 let lines = highlight_split_diff(&preview, &ctx);
403 assert!(
404 lines.len() > 1,
405 "long left side should produce multiple visual lines"
406 );
407 let first_width = lines[0].display_width();
409 for (i, line) in lines.iter().enumerate() {
410 assert_eq!(line.display_width(), first_width, "line {i} width mismatch");
411 }
412 }
413
414 #[test]
415 fn blank_gutter_when_line_number_none() {
416 let preview = make_split_preview(vec![SplitDiffRow {
417 left: Some(SplitDiffCell {
418 tag: DiffTag::Removed,
419 content: "old".to_string(),
420 line_number: None,
421 }),
422 right: None,
423 }]);
424 let ctx = test_context_with_width(100);
425 let lines = highlight_split_diff(&preview, &ctx);
426 let text = lines[0].plain_text();
427 assert!(
428 text.starts_with(" "),
429 "should have blank gutter: {text:?}"
430 );
431 }
432
433}