1use crate::diffs::diff::highlight_diff;
2use crate::line::Line;
3use crate::rendering::frame::{FitOptions, Frame, FramePart};
4use crate::rendering::gutter::digit_count;
5use crate::rendering::render_context::ViewContext;
6use crate::span::Span;
7use crate::style::Style;
8
9use crate::{DiffPreview, DiffTag, SplitDiffCell};
10
11const MAX_DIFF_LINES: usize = 20;
12pub const MIN_SPLIT_WIDTH: u16 = 80;
13pub const MIN_GUTTER_WIDTH: usize = 3;
14pub const SEPARATOR: &str = " ";
15pub const SEPARATOR_WIDTH: usize = 1;
16const SEPARATOR_WIDTH_U16: u16 = 1;
17const LEFT_INNER_PAD: usize = 1;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum Side {
22 Left,
23 Right,
24}
25
26pub fn render_diff(preview: &DiffPreview, context: &ViewContext) -> Vec<Line> {
32 let has_removals = preview.lines.iter().any(|l| l.tag == DiffTag::Removed);
33
34 if context.size.width >= MIN_SPLIT_WIDTH && has_removals {
35 highlight_split_diff(preview, context)
36 } else {
37 highlight_diff(preview, context)
38 }
39}
40
41pub fn gutter_width_for_preview(preview: &DiffPreview) -> usize {
45 let max_line_no = preview
46 .rows
47 .iter()
48 .flat_map(|row| {
49 row.left
50 .as_ref()
51 .and_then(|c| c.line_number)
52 .into_iter()
53 .chain(row.right.as_ref().and_then(|c| c.line_number))
54 })
55 .max()
56 .unwrap_or(0);
57 (digit_count(max_line_no) + 1).max(MIN_GUTTER_WIDTH)
58}
59
60fn highlight_split_diff(preview: &DiffPreview, context: &ViewContext) -> Vec<Line> {
61 let theme = &context.theme;
62 let terminal_width = context.size.width as usize;
63 let gutter_width = gutter_width_for_preview(preview);
64 let fixed_overhead = gutter_width * 2 + SEPARATOR_WIDTH + LEFT_INNER_PAD;
65 let usable = terminal_width.saturating_sub(fixed_overhead);
66 let left_content = usable / 2;
67 let right_content = usable - left_content;
68 #[allow(clippy::cast_possible_truncation)]
69 let left_panel_u16 = (gutter_width + left_content + LEFT_INNER_PAD) as u16;
70 #[allow(clippy::cast_possible_truncation)]
71 let right_panel_u16 = (gutter_width + right_content) as u16;
72
73 let mut row_frames: Vec<Frame> = Vec::new();
74 let mut visual_lines = 0usize;
75 let mut rows_consumed = 0usize;
76
77 for row in &preview.rows {
78 let left_frame =
79 render_cell(row.left.as_ref(), left_content, &preview.lang_hint, Side::Left, gutter_width, context);
80 let right_frame =
81 render_cell(row.right.as_ref(), right_content, &preview.lang_hint, Side::Right, gutter_width, context);
82
83 let height = left_frame.lines().len().max(right_frame.lines().len());
84
85 if visual_lines + height > MAX_DIFF_LINES && visual_lines > 0 {
86 break;
87 }
88
89 let sep_line = Line::new(SEPARATOR.to_string());
90 let sep_frame = Frame::new(vec![sep_line; height]);
91 row_frames.push(Frame::hstack([
92 FramePart::new(left_frame, left_panel_u16),
93 FramePart::new(sep_frame, SEPARATOR_WIDTH_U16),
94 FramePart::new(right_frame, right_panel_u16),
95 ]));
96
97 visual_lines += height;
98 rows_consumed += 1;
99 }
100
101 let mut lines = Frame::vstack(row_frames).into_lines();
102
103 if rows_consumed < preview.rows.len() {
104 let remaining = preview.rows.len() - rows_consumed;
105 let mut overflow = Line::default();
106 overflow.push_styled(format!(" ... {remaining} more lines"), theme.muted());
107 lines.push(overflow);
108 }
109
110 lines
111}
112
113fn blank_panel(width: usize) -> Line {
114 let mut line = Line::default();
115 line.push_text(" ".repeat(width));
116 line
117}
118
119pub fn render_cell(
120 cell: Option<&SplitDiffCell>,
121 content_width: usize,
122 lang_hint: &str,
123 side: Side,
124 gutter_width: usize,
125 context: &ViewContext,
126) -> Frame {
127 let theme = &context.theme;
128 let inner_pad = if side == Side::Left { LEFT_INNER_PAD } else { 0 };
129 let panel_width = gutter_width + content_width + inner_pad;
130
131 let Some(cell) = cell else {
132 return Frame::new(vec![blank_panel(panel_width)]);
133 };
134
135 let is_context = cell.tag == DiffTag::Context;
136 let bg = match cell.tag {
137 DiffTag::Removed => Some(theme.diff_removed_bg()),
138 DiffTag::Added => Some(theme.diff_added_bg()),
139 DiffTag::Context => None,
140 };
141
142 let highlighted = context.highlighter().highlight(&cell.content, lang_hint, theme);
143
144 let content_line = if let Some(hl_line) = highlighted.first() {
145 let mut styled_content = Line::default();
146 for span in hl_line.spans() {
147 let mut span_style = span.style();
148 if let Some(bg) = bg {
149 span_style.bg = Some(bg);
150 }
151 if is_context {
152 span_style.dim = true;
153 }
154 styled_content.push_span(Span::with_style(span.text(), span_style));
155 }
156 styled_content
157 } else {
158 let fg = match cell.tag {
159 DiffTag::Removed => theme.diff_removed_fg(),
160 DiffTag::Added => theme.diff_added_fg(),
161 DiffTag::Context => theme.code_fg(),
162 };
163 let mut style = Style::fg(fg);
164 if let Some(bg) = bg {
165 style = style.bg_color(bg);
166 }
167 if is_context {
168 style.dim = true;
169 }
170 Line::with_style(&cell.content, style)
171 };
172 let content_line = match bg {
173 Some(bg) => content_line.with_fill(bg),
174 None => content_line,
175 };
176
177 #[allow(clippy::cast_possible_truncation)]
178 let content_width_u16 = content_width as u16;
179 let extend_to = content_width + inner_pad;
180
181 let gutter_style = Style::fg(theme.muted());
182 let digit_field = gutter_width.saturating_sub(1);
183 let head = match cell.line_number {
184 Some(num) => Line::with_style(format!("{num:>digit_field$} "), gutter_style),
185 None => Line::with_style(" ".repeat(gutter_width), gutter_style),
186 };
187 let tail = Line::new(" ".repeat(gutter_width));
188
189 Frame::new(vec![content_line])
190 .fit(content_width_u16, FitOptions::wrap())
191 .map_lines(move |mut line| {
192 line.extend_bg_to_width(extend_to);
193 line
194 })
195 .prefix(&head, &tail)
196}
197
198#[cfg(test)]
199mod tests {
200 use super::*;
201 use crate::rendering::line::Line;
202 use crate::{DiffLine, SplitDiffCell, SplitDiffRow};
203
204 fn test_context_with_width(width: u16) -> ViewContext {
205 ViewContext::new((width, 24))
206 }
207
208 fn make_split_preview(rows: Vec<SplitDiffRow>) -> DiffPreview {
209 DiffPreview { lines: vec![], rows, lang_hint: String::new(), start_line: None }
210 }
211
212 fn removed_cell(content: &str, line_num: usize) -> SplitDiffCell {
213 SplitDiffCell { tag: DiffTag::Removed, content: content.to_string(), line_number: Some(line_num) }
214 }
215
216 fn added_cell(content: &str, line_num: usize) -> SplitDiffCell {
217 SplitDiffCell { tag: DiffTag::Added, content: content.to_string(), line_number: Some(line_num) }
218 }
219
220 fn style_at_column(line: &Line, col: usize) -> Style {
221 let mut current = 0;
222 for span in line.spans() {
223 let width = crate::display_width_text(span.text());
224 if col < current + width {
225 return span.style();
226 }
227 current += width;
228 }
229 Style::default()
230 }
231
232 #[test]
233 fn wrapped_split_rows_preserve_neutral_boundary_columns() {
234 let preview = make_split_preview(vec![SplitDiffRow {
235 left: Some(removed_cell("LEFT_MARK", 1)),
236 right: Some(added_cell(&format!("RIGHT_HEAD {} RIGHT_TAIL", "y".repeat(140)), 1)),
237 }]);
238 let ctx = test_context_with_width(100);
239 let lines = highlight_split_diff(&preview, &ctx);
240
241 let first_row = lines
242 .iter()
243 .position(|line| {
244 let text = line.plain_text();
245 text.contains("LEFT_MARK") && text.contains("RIGHT_HEAD")
246 })
247 .expect("expected split row containing both left and right markers");
248
249 let right_start =
250 lines[first_row].plain_text().find("RIGHT_HEAD").expect("expected RIGHT_HEAD marker in first split row");
251
252 let wrapped_row = lines
253 .iter()
254 .enumerate()
255 .skip(first_row + 1)
256 .find_map(|(index, line)| line.plain_text().contains("RIGHT_TAIL").then_some(index))
257 .expect("expected wrapped continuation row containing RIGHT_TAIL marker");
258
259 let wrapped_start = lines[wrapped_row]
260 .plain_text()
261 .find("RIGHT_TAIL")
262 .expect("expected RIGHT_TAIL marker in wrapped continuation row");
263
264 assert!(
265 wrapped_start >= right_start,
266 "wrapped continuation should not start left of original right-pane content start (was {wrapped_start}, expected >= {right_start})"
267 );
268
269 let added_bg = ctx.theme.diff_added_bg();
270 let removed_bg = ctx.theme.diff_removed_bg();
271 let gutter_width = gutter_width_for_preview(&preview);
272 let padding_width = gutter_width + SEPARATOR_WIDTH;
273 assert!(right_start >= padding_width, "right pane content should leave room for separator and gutter");
274 for col in (right_start - padding_width)..right_start {
275 let style = style_at_column(&lines[wrapped_row], col);
276 assert_ne!(style.bg, Some(added_bg), "padding column {col} should not inherit added background");
277 assert_ne!(style.bg, Some(removed_bg), "padding column {col} should not inherit removed background");
278 }
279 }
280
281 #[test]
282 fn both_panels_rendered_with_content() {
283 let preview = make_split_preview(vec![SplitDiffRow {
284 left: Some(removed_cell("old code", 1)),
285 right: Some(added_cell("new code", 1)),
286 }]);
287 let ctx = test_context_with_width(100);
288 let lines = highlight_split_diff(&preview, &ctx);
289 assert_eq!(lines.len(), 1);
290 let text = lines[0].plain_text();
291 assert!(text.contains("old code"), "left panel missing: {text}");
292 assert!(text.contains("new code"), "right panel missing: {text}");
293 }
294
295 #[test]
296 fn long_lines_wrapped_within_terminal_width() {
297 let long = "x".repeat(200);
298 let preview = make_split_preview(vec![SplitDiffRow {
299 left: Some(removed_cell(&long, 1)),
300 right: Some(added_cell(&long, 1)),
301 }]);
302 let ctx = test_context_with_width(100);
303 let lines = highlight_split_diff(&preview, &ctx);
304 assert!(lines.len() > 1, "long line should wrap into multiple visual lines, got {}", lines.len());
305 for line in &lines {
306 let width = line.display_width();
307 assert!(width <= 100, "line width {width} should not exceed terminal width 100");
308 }
309 let all_text: String = lines.iter().map(Line::plain_text).collect();
311 let x_count = all_text.chars().filter(|&c| c == 'x').count();
312 assert_eq!(x_count, 400, "all content should be present across wrapped lines");
314 }
315
316 #[test]
317 fn truncation_budget_applied() {
318 let rows: Vec<SplitDiffRow> = (0..30)
319 .map(|i| SplitDiffRow {
320 left: Some(removed_cell(&format!("old {i}"), i + 1)),
321 right: Some(added_cell(&format!("new {i}"), i + 1)),
322 })
323 .collect();
324 let preview = make_split_preview(rows);
325 let ctx = test_context_with_width(100);
326 let lines = highlight_split_diff(&preview, &ctx);
327 assert_eq!(lines.len(), MAX_DIFF_LINES + 1);
329 let last = lines.last().unwrap().plain_text();
330 assert!(last.contains("more lines"), "overflow text missing: {last}");
331 }
332
333 #[test]
334 fn empty_added_cell_pads_content_with_added_background() {
335 let ctx = test_context_with_width(100);
336 let added_bg = ctx.theme.diff_added_bg();
337 let cell = SplitDiffCell { tag: DiffTag::Added, content: String::new(), line_number: Some(1) };
338 let content_width = 40;
339 let gutter_width = MIN_GUTTER_WIDTH;
340 let frame = render_cell(Some(&cell), content_width, "rs", Side::Right, gutter_width, &ctx);
341 let lines = frame.into_lines();
342 assert_eq!(lines.len(), 1);
343 let row = &lines[0];
344 assert_eq!(
345 row.display_width(),
346 gutter_width + content_width,
347 "row should fill panel width, got {}",
348 row.display_width()
349 );
350 let trailing = row.spans().last().expect("row should have spans");
351 assert_eq!(
352 trailing.style().bg,
353 Some(added_bg),
354 "trailing pad of empty added cell should carry diff_added_bg, got {:?}",
355 trailing.style().bg
356 );
357 }
358
359 #[test]
360 fn empty_preview_produces_no_output() {
361 let preview = make_split_preview(vec![]);
362 let ctx = test_context_with_width(100);
363 let lines = highlight_split_diff(&preview, &ctx);
364 assert!(lines.is_empty());
365 }
366
367 #[test]
368 fn render_diff_dispatches_to_unified_below_80() {
369 let preview = DiffPreview {
370 lines: vec![DiffLine { tag: DiffTag::Removed, content: "old".to_string() }],
371 rows: vec![SplitDiffRow { left: Some(removed_cell("old", 1)), right: None }],
372 lang_hint: String::new(),
373 start_line: None,
374 };
375 let ctx = test_context_with_width(79);
376 let lines = render_diff(&preview, &ctx);
377 assert!(
379 lines[0].plain_text().contains("- old"),
380 "should use unified renderer below 80: {}",
381 lines[0].plain_text()
382 );
383 }
384
385 #[test]
386 fn new_file_uses_unified_view_even_at_wide_width() {
387 let preview = DiffPreview {
390 lines: vec![
391 DiffLine { tag: DiffTag::Added, content: "fn main() {".to_string() },
392 DiffLine { tag: DiffTag::Added, content: " println!(\"Hello\");".to_string() },
393 DiffLine { tag: DiffTag::Added, content: "}".to_string() },
394 ],
395 rows: vec![
396 SplitDiffRow { left: None, right: Some(added_cell("fn main() {", 1)) },
397 SplitDiffRow { left: None, right: Some(added_cell(" println!(\"Hello\");", 2)) },
398 SplitDiffRow { left: None, right: Some(added_cell("}", 3)) },
399 ],
400 lang_hint: "rs".to_string(),
401 start_line: None,
402 };
403 let ctx = test_context_with_width(100);
404 let lines = render_diff(&preview, &ctx);
405 let text = lines[0].plain_text();
407 assert!(text.contains("+ fn main()"), "should use unified renderer for new file: {text}");
408 }
409
410 #[test]
411 fn render_diff_dispatches_to_split_at_80() {
412 let preview = DiffPreview {
413 lines: vec![DiffLine { tag: DiffTag::Removed, content: "old".to_string() }],
414 rows: vec![SplitDiffRow { left: Some(removed_cell("old", 1)), right: None }],
415 lang_hint: String::new(),
416 start_line: None,
417 };
418 let ctx = test_context_with_width(80);
419 let lines = render_diff(&preview, &ctx);
420 let text = lines[0].plain_text();
421 assert!(!text.contains("- old"), "should use split renderer at 80: {text}");
423 }
424
425 #[test]
426 fn line_numbers_rendered_when_start_line_set() {
427 let preview = make_split_preview(vec![SplitDiffRow {
428 left: Some(SplitDiffCell { tag: DiffTag::Context, content: "hello".to_string(), line_number: Some(42) }),
429 right: Some(SplitDiffCell { tag: DiffTag::Context, content: "hello".to_string(), line_number: Some(42) }),
430 }]);
431 let ctx = test_context_with_width(100);
432 let lines = highlight_split_diff(&preview, &ctx);
433 let text = lines[0].plain_text();
434 assert!(text.contains("42"), "line number should be shown: {text}");
435 }
436
437 #[test]
438 fn wrapped_row_pads_shorter_side_to_match_height() {
439 let long = "a".repeat(200);
441 let preview = make_split_preview(vec![SplitDiffRow {
442 left: Some(removed_cell(&long, 1)),
443 right: Some(added_cell("short", 1)),
444 }]);
445 let ctx = test_context_with_width(100);
446 let lines = highlight_split_diff(&preview, &ctx);
447 assert!(lines.len() > 1, "long left side should produce multiple visual lines");
448 let first_width = lines[0].display_width();
450 for (i, line) in lines.iter().enumerate() {
451 assert_eq!(line.display_width(), first_width, "line {i} width mismatch");
452 }
453 }
454
455 #[test]
456 fn separator_has_no_background_on_context_row() {
457 let preview = make_split_preview(vec![SplitDiffRow {
458 left: Some(SplitDiffCell { tag: DiffTag::Context, content: "hello".to_string(), line_number: Some(1) }),
459 right: Some(SplitDiffCell { tag: DiffTag::Context, content: "world".to_string(), line_number: Some(1) }),
460 }]);
461 let ctx = test_context_with_width(100);
462 let lines = highlight_split_diff(&preview, &ctx);
463 assert_eq!(lines.len(), 1);
464
465 let gutter_width = gutter_width_for_preview(&preview);
466 let usable = 100usize - (gutter_width * 2 + SEPARATOR_WIDTH + LEFT_INNER_PAD);
467 let left_content = usable / 2;
468 let sep_start = gutter_width + left_content + LEFT_INNER_PAD;
469 let sep_end = sep_start + SEPARATOR_WIDTH;
470 for col in sep_start..sep_end {
471 let style = style_at_column(&lines[0], col);
472 assert!(
473 style.bg.is_none(),
474 "separator column {col} should have no background on context row, got {:?}",
475 style.bg
476 );
477 }
478 }
479
480 #[test]
481 fn blank_gutter_when_line_number_none() {
482 let preview = make_split_preview(vec![SplitDiffRow {
483 left: Some(SplitDiffCell { tag: DiffTag::Removed, content: "old".to_string(), line_number: None }),
484 right: None,
485 }]);
486 let ctx = test_context_with_width(100);
487 let lines = highlight_split_diff(&preview, &ctx);
488 let text = lines[0].plain_text();
489 let blank_gutter = " ".repeat(gutter_width_for_preview(&preview));
490 assert!(text.starts_with(&blank_gutter), "should have blank gutter: {text:?}");
491 }
492
493 #[test]
494 fn left_pane_diff_bg_extends_through_inner_pad_to_separator() {
495 let preview = make_split_preview(vec![SplitDiffRow {
496 left: Some(removed_cell("old", 1)),
497 right: Some(added_cell("new", 1)),
498 }]);
499 let ctx = test_context_with_width(100);
500 let lines = highlight_split_diff(&preview, &ctx);
501 assert_eq!(lines.len(), 1);
502
503 let gutter_width = gutter_width_for_preview(&preview);
504 let usable = 100usize - (gutter_width * 2 + SEPARATOR_WIDTH + LEFT_INNER_PAD);
505 let left_content = usable / 2;
506 let inner_pad_col = gutter_width + left_content + LEFT_INNER_PAD - 1;
507 let style = style_at_column(&lines[0], inner_pad_col);
508 assert_eq!(
509 style.bg,
510 Some(ctx.theme.diff_removed_bg()),
511 "left inner-edge pad column {inner_pad_col} should carry diff_removed_bg, got {:?}",
512 style.bg,
513 );
514 }
515
516 #[test]
517 fn right_pane_line_number_sits_adjacent_to_separator() {
518 let preview = make_split_preview(vec![SplitDiffRow {
519 left: Some(removed_cell("old", 89)),
520 right: Some(added_cell("new", 89)),
521 }]);
522 let ctx = test_context_with_width(100);
523 let lines = highlight_split_diff(&preview, &ctx);
524 assert_eq!(lines.len(), 1);
525
526 let gutter_width = gutter_width_for_preview(&preview);
527 let usable = 100usize - (gutter_width * 2 + SEPARATOR_WIDTH + LEFT_INNER_PAD);
528 let left_content = usable / 2;
529 let right_pane_first_col = gutter_width + left_content + LEFT_INNER_PAD + SEPARATOR_WIDTH;
530 let text = lines[0].plain_text();
531 let chars: Vec<char> = text.chars().collect();
532 assert_eq!(
533 chars[right_pane_first_col], '8',
534 "right pane line# digit '8' should sit at the very first column after SEP (col {right_pane_first_col}); got line: {text:?}",
535 );
536 assert_eq!(
537 chars[right_pane_first_col + 1],
538 '9',
539 "right pane line# digit '9' should follow at col {}",
540 right_pane_first_col + 1
541 );
542 }
543}