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;
8use crossterm::style::Color;
9
10use crate::{DiffPreview, DiffTag, SplitDiffEntry};
11
12const MAX_DIFF_LINES: usize = 20;
13pub const MIN_SPLIT_WIDTH: u16 = 80;
14pub const MIN_GUTTER_WIDTH: usize = 3;
15pub const SEPARATOR: &str = " ";
16pub const SEPARATOR_WIDTH: usize = 1;
17const SEPARATOR_WIDTH_U16: u16 = 1;
18const LEFT_INNER_PAD: usize = 1;
19pub const WRAP_MARKER: &str = "↪";
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum Side {
24 Left,
25 Right,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum GutterTint {
31 Neutral,
32 Diff,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub struct SplitLayoutDimensions {
37 pub gutter_width: usize,
38 pub left_content_width: usize,
39 pub right_content_width: usize,
40 pub left_panel_width: u16,
41 pub right_panel_width: u16,
42}
43
44impl SplitLayoutDimensions {
45 pub fn new(total_width: usize, max_line_no: usize) -> Self {
46 let gutter_width = gutter_width_for_max_line(max_line_no);
47 let fixed_overhead = gutter_width * 2 + SEPARATOR_WIDTH + LEFT_INNER_PAD;
48 let usable = total_width.saturating_sub(fixed_overhead);
49 let left_content_width = usable / 2;
50 let right_content_width = usable - left_content_width;
51 Self {
52 gutter_width,
53 left_content_width,
54 right_content_width,
55 left_panel_width: usize_to_u16_saturating(gutter_width + left_content_width + LEFT_INNER_PAD),
56 right_panel_width: usize_to_u16_saturating(gutter_width + right_content_width),
57 }
58 }
59
60 pub fn for_preview(preview: &DiffPreview, total_width: usize) -> Self {
61 Self::new(total_width, max_line_no_for_preview(preview))
62 }
63}
64
65fn gutter_width_for_max_line(max_line_no: usize) -> usize {
66 (digit_count(max_line_no) + 1).max(MIN_GUTTER_WIDTH)
67}
68
69pub fn header_left_padding(max_line_no: usize) -> usize {
70 gutter_width_for_max_line(max_line_no).saturating_sub(1).saturating_sub(digit_count(max_line_no))
71}
72
73fn max_line_no_for_entries(pairs: impl IntoIterator<Item = (Option<usize>, Option<usize>)>) -> usize {
74 pairs.into_iter().flat_map(|(left, right)| left.into_iter().chain(right)).max().unwrap_or(0)
75}
76
77pub fn usize_to_u16_saturating(value: usize) -> u16 {
78 u16::try_from(value).unwrap_or(u16::MAX)
79}
80
81pub fn render_diff(preview: &DiffPreview, context: &ViewContext) -> Vec<Line> {
87 let has_removals =
88 preview.rows.iter().any(|row| row.left.as_ref().is_some_and(|entry| entry.tag == DiffTag::Removed));
89
90 if context.size.width >= MIN_SPLIT_WIDTH && has_removals {
91 highlight_split_diff(preview, context)
92 } else {
93 highlight_diff(preview, context)
94 }
95}
96
97fn max_line_no_for_preview(preview: &DiffPreview) -> usize {
98 max_line_no_for_entries(preview.rows.iter().map(|row| {
99 (row.left.as_ref().and_then(|entry| entry.line_number), row.right.as_ref().and_then(|entry| entry.line_number))
100 }))
101}
102
103fn highlight_split_diff(preview: &DiffPreview, context: &ViewContext) -> Vec<Line> {
104 let theme = &context.theme;
105 let dimensions = SplitLayoutDimensions::for_preview(preview, context.size.width as usize);
106
107 let mut row_frames: Vec<Frame> = Vec::new();
108 let mut visual_lines = 0usize;
109 let mut rows_consumed = 0usize;
110
111 for row in &preview.rows {
112 let left_frame = render_entry(
113 row.left.as_ref(),
114 dimensions.left_content_width,
115 &preview.lang_hint,
116 Side::Left,
117 dimensions.gutter_width,
118 GutterTint::Neutral,
119 context,
120 );
121 let right_frame = render_entry(
122 row.right.as_ref(),
123 dimensions.right_content_width,
124 &preview.lang_hint,
125 Side::Right,
126 dimensions.gutter_width,
127 GutterTint::Neutral,
128 context,
129 );
130
131 let height = left_frame.lines().len().max(right_frame.lines().len());
132
133 if visual_lines + height > MAX_DIFF_LINES && visual_lines > 0 {
134 break;
135 }
136
137 let sep_line = Line::new(SEPARATOR.to_string());
138 let sep_frame = Frame::new(vec![sep_line; height]);
139 row_frames.push(Frame::hstack([
140 FramePart::new(left_frame, dimensions.left_panel_width),
141 FramePart::new(sep_frame, SEPARATOR_WIDTH_U16),
142 FramePart::new(right_frame, dimensions.right_panel_width),
143 ]));
144
145 visual_lines += height;
146 rows_consumed += 1;
147 }
148
149 let mut lines = Frame::vstack(row_frames).into_lines();
150
151 if rows_consumed < preview.rows.len() {
152 let remaining = preview.rows.len() - rows_consumed;
153 let mut overflow = Line::default();
154 overflow.push_styled(format!(" ... {remaining} more lines"), theme.muted());
155 lines.push(overflow);
156 }
157
158 lines
159}
160
161fn blank_panel(width: usize) -> Line {
162 Line::new(" ".repeat(width))
163}
164
165pub fn render_entry(
170 entry: Option<&SplitDiffEntry>,
171 content_width: usize,
172 lang_hint: &str,
173 side: Side,
174 gutter_width: usize,
175 gutter_tint: GutterTint,
176 context: &ViewContext,
177) -> Frame {
178 let theme = &context.theme;
179 let inner_pad = if side == Side::Left { LEFT_INNER_PAD } else { 0 };
180 let panel_width = gutter_width + content_width + inner_pad;
181
182 let Some(entry) = entry else {
183 return Frame::new(vec![blank_panel(panel_width)]);
184 };
185
186 let palette = DiffEntryStyle::for_entry(entry.tag, gutter_tint, theme);
187 let highlighted = context.highlighter().highlight(&entry.content, lang_hint, theme);
188
189 let content_line = if let Some(hl_line) = highlighted.first() {
190 let mut styled_content = Line::default();
191 for span in hl_line.spans() {
192 styled_content.push_span(Span::with_style(span.text(), palette.content_style(span.style())));
193 }
194 styled_content
195 } else {
196 Line::with_style(&entry.content, palette.fallback_content_style())
197 };
198 let content_line = match palette.bg {
199 Some(bg) => content_line.with_fill(bg),
200 None => content_line,
201 };
202
203 let content_width_u16 = usize_to_u16_saturating(content_width);
204 let extend_to = content_width + inner_pad;
205
206 let digit_field = gutter_width.saturating_sub(1);
207 let head = match entry.line_number {
208 Some(num) => Line::with_style(format!("{num:>digit_field$} "), palette.gutter_style),
209 None => Line::with_style(" ".repeat(gutter_width), palette.gutter_style),
210 };
211 let tail = Line::with_style(format!("{WRAP_MARKER:>digit_field$} "), palette.gutter_style.dim());
212
213 Frame::new(vec![content_line])
214 .fit(content_width_u16, FitOptions::wrap())
215 .map_lines(move |mut line| {
216 line.extend_bg_to_width(extend_to);
217 line
218 })
219 .prefix(&head, &tail)
220}
221
222struct DiffEntryStyle {
223 fg: Color,
224 bg: Option<Color>,
225 dim: bool,
226 gutter_style: Style,
227}
228
229impl DiffEntryStyle {
230 fn for_entry(tag: DiffTag, gutter_tint: GutterTint, theme: &crate::Theme) -> Self {
231 let (fg, bg) = match tag {
232 DiffTag::Removed => (theme.diff_removed_fg(), Some(theme.diff_removed_bg())),
233 DiffTag::Added => (theme.diff_added_fg(), Some(theme.diff_added_bg())),
234 DiffTag::Context => (theme.code_fg(), None),
235 };
236 let gutter_style = match (tag, gutter_tint) {
237 (DiffTag::Removed, GutterTint::Diff) => {
238 Style::fg(theme.diff_removed_fg()).bg_color(theme.diff_removed_bg())
239 }
240 (DiffTag::Added, GutterTint::Diff) => Style::fg(theme.diff_added_fg()).bg_color(theme.diff_added_bg()),
241 _ => Style::fg(theme.muted()),
242 };
243 Self { fg, bg, dim: tag == DiffTag::Context, gutter_style }
244 }
245
246 fn content_style(&self, mut style: Style) -> Style {
247 if let Some(bg) = self.bg {
248 style.bg = Some(bg);
249 }
250 if self.dim {
251 style.dim = true;
252 }
253 style
254 }
255
256 fn fallback_content_style(&self) -> Style {
257 self.content_style(Style::fg(self.fg))
258 }
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264 use crate::rendering::line::Line;
265 use crate::{DiffLine, SplitDiffEntry, SplitDiffRow};
266
267 fn test_context_with_width(width: u16) -> ViewContext {
268 ViewContext::new((width, 24))
269 }
270
271 fn make_split_preview(rows: Vec<SplitDiffRow>) -> DiffPreview {
272 DiffPreview { lines: vec![], rows, lang_hint: String::new(), start_line: None }
273 }
274
275 fn gutter_width_for_preview(preview: &DiffPreview) -> usize {
276 gutter_width_for_max_line(max_line_no_for_preview(preview))
277 }
278
279 fn removed_entry(content: &str, line_num: usize) -> SplitDiffEntry {
280 SplitDiffEntry::new(DiffTag::Removed, content, Some(line_num))
281 }
282
283 fn added_entry(content: &str, line_num: usize) -> SplitDiffEntry {
284 SplitDiffEntry::new(DiffTag::Added, content, Some(line_num))
285 }
286
287 fn context_entry(content: &str, line_num: usize) -> SplitDiffEntry {
288 SplitDiffEntry::new(DiffTag::Context, content, Some(line_num))
289 }
290
291 fn style_at_column(line: &Line, col: usize) -> Style {
292 crate::testing::style_at_column(line, col)
293 }
294
295 #[test]
296 fn wrapped_split_rows_preserve_neutral_boundary_columns() {
297 let preview = make_split_preview(vec![SplitDiffRow {
298 left: Some(removed_entry("LEFT_MARK", 1)),
299 right: Some(added_entry(&format!("RIGHT_HEAD {} RIGHT_TAIL", "y".repeat(140)), 1)),
300 }]);
301 let ctx = test_context_with_width(100);
302 let lines = highlight_split_diff(&preview, &ctx);
303
304 let first_row = lines
305 .iter()
306 .position(|line| {
307 let text = line.plain_text();
308 text.contains("LEFT_MARK") && text.contains("RIGHT_HEAD")
309 })
310 .expect("expected split row containing both left and right markers");
311
312 let right_start =
313 lines[first_row].plain_text().find("RIGHT_HEAD").expect("expected RIGHT_HEAD marker in first split row");
314
315 let wrapped_row = lines
316 .iter()
317 .enumerate()
318 .skip(first_row + 1)
319 .find_map(|(index, line)| line.plain_text().contains("RIGHT_TAIL").then_some(index))
320 .expect("expected wrapped continuation row containing RIGHT_TAIL marker");
321
322 let wrapped_start = lines[wrapped_row]
323 .plain_text()
324 .find("RIGHT_TAIL")
325 .expect("expected RIGHT_TAIL marker in wrapped continuation row");
326
327 assert!(
328 wrapped_start >= right_start,
329 "wrapped continuation should not start left of original right-pane content start (was {wrapped_start}, expected >= {right_start})"
330 );
331
332 let added_bg = ctx.theme.diff_added_bg();
333 let removed_bg = ctx.theme.diff_removed_bg();
334 let gutter_width = gutter_width_for_preview(&preview);
335 let padding_width = gutter_width + SEPARATOR_WIDTH;
336 assert!(right_start >= padding_width, "right pane content should leave room for separator and gutter");
337 for col in (right_start - padding_width)..right_start {
338 let style = style_at_column(&lines[wrapped_row], col);
339 assert_ne!(style.bg, Some(added_bg), "padding column {col} should not inherit added background");
340 assert_ne!(style.bg, Some(removed_bg), "padding column {col} should not inherit removed background");
341 }
342 }
343
344 #[test]
345 fn both_panels_rendered_with_content() {
346 let preview = make_split_preview(vec![SplitDiffRow {
347 left: Some(removed_entry("old code", 1)),
348 right: Some(added_entry("new code", 1)),
349 }]);
350 let ctx = test_context_with_width(100);
351 let lines = highlight_split_diff(&preview, &ctx);
352 assert_eq!(lines.len(), 1);
353 let text = lines[0].plain_text();
354 assert!(text.contains("old code"), "left panel missing: {text}");
355 assert!(text.contains("new code"), "right panel missing: {text}");
356 }
357
358 #[test]
359 fn long_lines_wrapped_within_terminal_width() {
360 let long = "x".repeat(200);
361 let preview = make_split_preview(vec![SplitDiffRow {
362 left: Some(removed_entry(&long, 1)),
363 right: Some(added_entry(&long, 1)),
364 }]);
365 let ctx = test_context_with_width(100);
366 let lines = highlight_split_diff(&preview, &ctx);
367 assert!(lines.len() > 1, "long line should wrap into multiple visual lines, got {}", lines.len());
368 for line in &lines {
369 let width = line.display_width();
370 assert!(width <= 100, "line width {width} should not exceed terminal width 100");
371 }
372 let all_text: String = lines.iter().map(Line::plain_text).collect();
374 let x_count = all_text.chars().filter(|&c| c == 'x').count();
375 assert_eq!(x_count, 400, "all content should be present across wrapped lines");
377 }
378
379 #[test]
380 fn truncation_budget_applied() {
381 let rows: Vec<SplitDiffRow> = (0..30)
382 .map(|i| SplitDiffRow {
383 left: Some(removed_entry(&format!("old {i}"), i + 1)),
384 right: Some(added_entry(&format!("new {i}"), i + 1)),
385 })
386 .collect();
387 let preview = make_split_preview(rows);
388 let ctx = test_context_with_width(100);
389 let lines = highlight_split_diff(&preview, &ctx);
390 assert_eq!(lines.len(), MAX_DIFF_LINES + 1);
392 let last = lines.last().unwrap().plain_text();
393 assert!(last.contains("more lines"), "overflow text missing: {last}");
394 }
395
396 #[test]
397 fn empty_added_entry_pads_content_with_added_background() {
398 let ctx = test_context_with_width(100);
399 let added_bg = ctx.theme.diff_added_bg();
400 let entry = SplitDiffEntry::new(DiffTag::Added, String::new(), Some(1));
401 let content_width = 40;
402 let gutter_width = MIN_GUTTER_WIDTH;
403 let frame =
404 render_entry(Some(&entry), content_width, "rs", Side::Right, gutter_width, GutterTint::Neutral, &ctx);
405 let lines = frame.into_lines();
406 assert_eq!(lines.len(), 1);
407 let row = &lines[0];
408 assert_eq!(
409 row.display_width(),
410 gutter_width + content_width,
411 "row should fill panel width, got {}",
412 row.display_width()
413 );
414 let trailing = row.spans().last().expect("row should have spans");
415 assert_eq!(
416 trailing.style().bg,
417 Some(added_bg),
418 "trailing pad of empty added entry should carry diff_added_bg, got {:?}",
419 trailing.style().bg
420 );
421 }
422
423 #[test]
424 fn empty_preview_produces_no_output() {
425 let preview = make_split_preview(vec![]);
426 let ctx = test_context_with_width(100);
427 let lines = highlight_split_diff(&preview, &ctx);
428 assert!(lines.is_empty());
429 }
430
431 #[test]
432 fn render_diff_dispatches_to_unified_below_80() {
433 let preview = DiffPreview {
434 lines: vec![DiffLine { tag: DiffTag::Removed, content: "old".to_string() }],
435 rows: vec![SplitDiffRow { left: Some(removed_entry("old", 1)), right: None }],
436 lang_hint: String::new(),
437 start_line: None,
438 };
439 let ctx = test_context_with_width(79);
440 let lines = render_diff(&preview, &ctx);
441 assert!(
443 lines[0].plain_text().contains("- old"),
444 "should use unified renderer below 80: {}",
445 lines[0].plain_text()
446 );
447 }
448
449 #[test]
450 fn new_file_uses_unified_view_even_at_wide_width() {
451 let preview = DiffPreview {
454 lines: vec![
455 DiffLine { tag: DiffTag::Added, content: "fn main() {".to_string() },
456 DiffLine { tag: DiffTag::Added, content: " println!(\"Hello\");".to_string() },
457 DiffLine { tag: DiffTag::Added, content: "}".to_string() },
458 ],
459 rows: vec![
460 SplitDiffRow { left: None, right: Some(added_entry("fn main() {", 1)) },
461 SplitDiffRow { left: None, right: Some(added_entry(" println!(\"Hello\");", 2)) },
462 SplitDiffRow { left: None, right: Some(added_entry("}", 3)) },
463 ],
464 lang_hint: "rs".to_string(),
465 start_line: None,
466 };
467 let ctx = test_context_with_width(100);
468 let lines = render_diff(&preview, &ctx);
469 let text = lines[0].plain_text();
471 assert!(text.contains("+ fn main()"), "should use unified renderer for new file: {text}");
472 }
473
474 #[test]
475 fn render_diff_dispatches_to_split_at_80() {
476 let preview = DiffPreview {
477 lines: vec![DiffLine { tag: DiffTag::Removed, content: "old".to_string() }],
478 rows: vec![SplitDiffRow { left: Some(removed_entry("old", 1)), right: None }],
479 lang_hint: String::new(),
480 start_line: None,
481 };
482 let ctx = test_context_with_width(80);
483 let lines = render_diff(&preview, &ctx);
484 let text = lines[0].plain_text();
485 assert!(!text.contains("- old"), "should use split renderer at 80: {text}");
487 }
488
489 #[test]
490 fn line_numbers_rendered_when_start_line_set() {
491 let preview = make_split_preview(vec![SplitDiffRow {
492 left: Some(context_entry("hello", 42)),
493 right: Some(context_entry("hello", 42)),
494 }]);
495 let ctx = test_context_with_width(100);
496 let lines = highlight_split_diff(&preview, &ctx);
497 let text = lines[0].plain_text();
498 assert!(text.contains("42"), "line number should be shown: {text}");
499 }
500
501 #[test]
502 fn wrapped_row_pads_shorter_side_to_match_height() {
503 let long = "a".repeat(200);
505 let preview = make_split_preview(vec![SplitDiffRow {
506 left: Some(removed_entry(&long, 1)),
507 right: Some(added_entry("short", 1)),
508 }]);
509 let ctx = test_context_with_width(100);
510 let lines = highlight_split_diff(&preview, &ctx);
511 assert!(lines.len() > 1, "long left side should produce multiple visual lines");
512 let first_width = lines[0].display_width();
514 for (i, line) in lines.iter().enumerate() {
515 assert_eq!(line.display_width(), first_width, "line {i} width mismatch");
516 }
517 }
518
519 #[test]
520 fn separator_has_no_background_on_context_row() {
521 let preview = make_split_preview(vec![SplitDiffRow {
522 left: Some(context_entry("hello", 1)),
523 right: Some(context_entry("world", 1)),
524 }]);
525 let ctx = test_context_with_width(100);
526 let lines = highlight_split_diff(&preview, &ctx);
527 assert_eq!(lines.len(), 1);
528
529 let gutter_width = gutter_width_for_preview(&preview);
530 let usable = 100usize - (gutter_width * 2 + SEPARATOR_WIDTH + LEFT_INNER_PAD);
531 let left_content = usable / 2;
532 let sep_start = gutter_width + left_content + LEFT_INNER_PAD;
533 let sep_end = sep_start + SEPARATOR_WIDTH;
534 for col in sep_start..sep_end {
535 let style = style_at_column(&lines[0], col);
536 assert!(
537 style.bg.is_none(),
538 "separator column {col} should have no background on context row, got {:?}",
539 style.bg
540 );
541 }
542 }
543
544 #[test]
545 fn blank_gutter_when_line_number_none() {
546 let preview = make_split_preview(vec![SplitDiffRow {
547 left: Some(SplitDiffEntry::new(DiffTag::Removed, "old", None)),
548 right: None,
549 }]);
550 let ctx = test_context_with_width(100);
551 let lines = highlight_split_diff(&preview, &ctx);
552 let text = lines[0].plain_text();
553 let blank_gutter = " ".repeat(gutter_width_for_preview(&preview));
554 assert!(text.starts_with(&blank_gutter), "should have blank gutter: {text:?}");
555 }
556
557 #[test]
558 fn left_pane_diff_bg_extends_through_inner_pad_to_separator() {
559 let preview = make_split_preview(vec![SplitDiffRow {
560 left: Some(removed_entry("old", 1)),
561 right: Some(added_entry("new", 1)),
562 }]);
563 let ctx = test_context_with_width(100);
564 let lines = highlight_split_diff(&preview, &ctx);
565 assert_eq!(lines.len(), 1);
566
567 let gutter_width = gutter_width_for_preview(&preview);
568 let usable = 100usize - (gutter_width * 2 + SEPARATOR_WIDTH + LEFT_INNER_PAD);
569 let left_content = usable / 2;
570 let inner_pad_col = gutter_width + left_content + LEFT_INNER_PAD - 1;
571 let style = style_at_column(&lines[0], inner_pad_col);
572 assert_eq!(
573 style.bg,
574 Some(ctx.theme.diff_removed_bg()),
575 "left inner-edge pad column {inner_pad_col} should carry diff_removed_bg, got {:?}",
576 style.bg,
577 );
578 }
579
580 #[test]
581 fn absent_entry_renders_without_background() {
582 let ctx = test_context_with_width(100);
583 let frame = render_entry(None, 40, "", Side::Left, MIN_GUTTER_WIDTH, GutterTint::Diff, &ctx);
584 let line = &frame.into_lines()[0];
585 assert_eq!(style_at_column(line, 0).bg, None, "absent side should blend with the pane background");
586 }
587
588 #[test]
589 fn tinted_gutter_carries_diff_colors() {
590 let ctx = test_context_with_width(100);
591 let frame =
592 render_entry(Some(&removed_entry("old", 7)), 40, "", Side::Left, MIN_GUTTER_WIDTH, GutterTint::Diff, &ctx);
593 let line = &frame.into_lines()[0];
594 let style = style_at_column(line, 0);
595 assert_eq!(style.bg, Some(ctx.theme.diff_removed_bg()), "tinted gutter should carry removed bg");
596 assert_eq!(style.fg, Some(ctx.theme.diff_removed_fg()), "tinted gutter number should use removed fg");
597 }
598
599 #[test]
600 fn inline_preview_gutter_stays_neutral() {
601 let preview = make_split_preview(vec![SplitDiffRow {
602 left: Some(removed_entry("old", 7)),
603 right: Some(added_entry("new", 7)),
604 }]);
605 let ctx = test_context_with_width(100);
606 let lines = highlight_split_diff(&preview, &ctx);
607 let style = style_at_column(&lines[0], 0);
608 assert_eq!(style.bg, None, "inline preview gutter must stay neutral so frame indents do not inherit it");
609 }
610
611 #[test]
612 fn wrapped_continuation_rows_show_wrap_marker() {
613 let preview = make_split_preview(vec![SplitDiffRow {
614 left: Some(removed_entry("short", 1)),
615 right: Some(added_entry(&"y".repeat(140), 1)),
616 }]);
617 let ctx = test_context_with_width(100);
618 let lines = highlight_split_diff(&preview, &ctx);
619 assert!(lines.len() > 1, "long right side should wrap");
620 assert!(
621 lines[1].plain_text().contains(WRAP_MARKER),
622 "continuation row should mark the wrap in its gutter: {:?}",
623 lines[1].plain_text()
624 );
625 }
626
627 #[test]
628 fn right_pane_line_number_sits_adjacent_to_separator() {
629 let preview = make_split_preview(vec![SplitDiffRow {
630 left: Some(removed_entry("old", 89)),
631 right: Some(added_entry("new", 89)),
632 }]);
633 let ctx = test_context_with_width(100);
634 let lines = highlight_split_diff(&preview, &ctx);
635 assert_eq!(lines.len(), 1);
636
637 let gutter_width = gutter_width_for_preview(&preview);
638 let usable = 100usize - (gutter_width * 2 + SEPARATOR_WIDTH + LEFT_INNER_PAD);
639 let left_content = usable / 2;
640 let right_pane_first_col = gutter_width + left_content + LEFT_INNER_PAD + SEPARATOR_WIDTH;
641 let text = lines[0].plain_text();
642 let chars: Vec<char> = text.chars().collect();
643 assert_eq!(
644 chars[right_pane_first_col], '8',
645 "right pane line# digit '8' should sit at the very first column after SEP (col {right_pane_first_col}); got line: {text:?}",
646 );
647 assert_eq!(
648 chars[right_pane_first_col + 1],
649 '9',
650 "right pane line# digit '9' should follow at col {}",
651 right_pane_first_col + 1
652 );
653 }
654}