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