1use ratatui::Frame;
16use ratatui::layout::{Constraint, Direction, Layout, Rect};
17use ratatui::style::{Color, Modifier, Style};
18use ratatui::text::{Line, Span};
19use ratatui::widgets::{Block, Borders, Clear, Paragraph};
20
21use super::{
22 DiffFile, DiffLineKind, DiffViewMode, GitTuiState, Hunk, StatusEntry, WhitespaceMode,
23 filter_whitespace,
24};
25
26#[derive(Debug, Clone, Copy)]
28pub struct RenderPlan {
29 pub header: Rect,
30 pub body: Rect,
31 pub footer: Rect,
32 pub sidebar: Rect,
33 pub diff: Rect,
34 pub minimap: Rect,
35 pub commit_form: Rect,
36}
37
38pub fn plan_overlay(area: Rect) -> RenderPlan {
53 let outer = Layout::default()
54 .direction(Direction::Vertical)
55 .constraints([
56 Constraint::Length(1), Constraint::Min(3), Constraint::Length(1), ])
60 .split(area);
61
62 let header = outer[0];
63 let body = outer[1];
64 let footer = outer[2];
65
66 let sidebar_w = (body.width / 4).max(20).min(body.width);
68 let body_cols = Layout::default()
69 .direction(Direction::Horizontal)
70 .constraints([
71 Constraint::Length(sidebar_w),
72 Constraint::Min(10),
73 Constraint::Length(2),
74 ])
75 .split(body);
76
77 let sidebar = body_cols[0];
78 let diff = body_cols[1];
79 let minimap = body_cols[2];
80 let commit_form = diff;
83
84 RenderPlan {
85 header,
86 body,
87 footer,
88 sidebar,
89 diff,
90 minimap,
91 commit_form,
92 }
93}
94
95#[derive(Debug, Default, Clone, PartialEq, Eq)]
99pub struct SplitRow {
100 pub left: Option<String>,
101 pub right: Option<String>,
102}
103
104pub fn pair_split_view(hunk: &Hunk) -> Vec<SplitRow> {
118 let mut rows: Vec<SplitRow> = Vec::with_capacity(hunk.lines.len());
119 for line in &hunk.lines {
120 match line.kind {
121 DiffLineKind::Context => {
122 rows.push(SplitRow {
123 left: Some(line.text.clone()),
124 right: Some(line.text.clone()),
125 });
126 }
127 DiffLineKind::Removed => rows.push(SplitRow {
128 left: Some(line.text.clone()),
129 right: None,
130 }),
131 DiffLineKind::Added => rows.push(SplitRow {
132 left: None,
133 right: Some(line.text.clone()),
134 }),
135 }
136 }
137 rows
138}
139
140#[derive(Debug, Default, Clone, PartialEq, Eq)]
142pub struct SidebarRow {
143 pub path: String,
144 pub marker: &'static str,
145}
146
147pub fn render_sidebar_rows(
152 entries: &[StatusEntry],
153 staged: &std::collections::HashSet<String>,
154 selected: usize,
155) -> Vec<SidebarRow> {
156 entries
157 .iter()
158 .enumerate()
159 .map(|(idx, e)| {
160 let marker = if e.is_unmerged {
161 "[U]"
162 } else if staged.contains(&e.path) {
163 "[S]"
164 } else {
165 ""
166 };
167 let row = SidebarRow {
168 path: e.path.clone(),
169 marker,
170 };
171 let _ = (idx, selected);
175 row
176 })
177 .collect()
178}
179
180#[derive(Debug, Default, Clone, Copy, PartialEq)]
183pub struct MinimapBucket {
184 pub added: usize,
185 pub removed: usize,
186}
187
188impl MinimapBucket {
189 pub fn ratio(&self) -> f32 {
192 let total = self.added + self.removed;
193 if total == 0 {
194 0.5
195 } else {
196 self.added as f32 / total as f32
197 }
198 }
199}
200
201pub fn minimap_buckets_rows(pairs: &[DiffLineKind], rows: usize) -> Vec<MinimapBucket> {
208 let mut buckets = vec![MinimapBucket::default(); rows];
209 if rows == 0 || pairs.is_empty() {
210 return buckets;
211 }
212 let per = (pairs.len() as f32 / rows as f32).ceil() as usize;
213 if per == 0 {
214 return buckets;
215 }
216 for (i, kind) in pairs.iter().enumerate() {
217 let bucket = (i / per).min(rows - 1);
218 match kind {
219 DiffLineKind::Added => buckets[bucket].added += 1,
220 DiffLineKind::Removed => buckets[bucket].removed += 1,
221 DiffLineKind::Context => {}
222 }
223 }
224 buckets
225}
226
227pub(crate) fn hunk_header_row(hunks: &[Hunk], selected: usize) -> usize {
231 hunks[..selected.min(hunks.len())]
232 .iter()
233 .map(|h| 1 + h.lines.len())
234 .sum()
235}
236
237pub fn hunk_scroll_offset(hunks: &[Hunk], selected: usize, pane_height: usize) -> usize {
250 let total: usize = hunks.iter().map(|h| 1 + h.lines.len()).sum();
251 let overflow = total.saturating_sub(pane_height);
252 hunk_header_row(hunks, selected).min(overflow)
253}
254
255pub fn footer_hints(view: DiffViewMode, ws: WhitespaceMode, wrap: bool) -> String {
258 let _ = view;
259 let _ = ws;
260 let wrap_label = if wrap { "wrap" } else { "trunc" };
261 format!(
262 "j/k nav · alt+↓/↑ hunk · ]/[ file · 1-4 view · v sidebar · b ws · w {wrap_label} · s stage · u unstage · c commit · r refresh · q close"
263 )
264}
265pub fn render_overlay_lines(frame: &mut Frame<'_>, area: Rect, state: &GitTuiState) {
273 let plan = plan_overlay(area);
274 frame.render_widget(Clear, area);
275
276 render_header(frame, plan.header, state);
277 render_footer(frame, plan.footer, state);
278
279 if state.commit_mode {
281 render_commit_form(frame, plan.diff, state);
282 } else {
283 render_sidebar(frame, plan.sidebar, state);
284 render_diff_pane(frame, plan.diff, state);
285 render_minimap(frame, plan.minimap, state);
286 }
287}
288
289fn render_header(frame: &mut Frame<'_>, area: Rect, state: &GitTuiState) {
290 let total = state.entries.len();
291 let staged = state.staged.len();
292 let branch = state.branch.as_deref().unwrap_or("detached");
293 let title = format!(
294 " git · {} files · {} staged · {} · q close ",
295 total, staged, branch
296 );
297 let block = Block::default()
298 .borders(Borders::NONE)
299 .title(Line::from(Span::styled(
300 title,
301 Style::default().add_modifier(Modifier::BOLD),
302 )));
303 frame.render_widget(block, area);
304}
305
306fn render_footer(frame: &mut Frame<'_>, area: Rect, state: &GitTuiState) {
307 let hints = footer_hints(state.view, state.ws, state.wrap);
308 let style = Style::default().add_modifier(Modifier::DIM);
309 frame.render_widget(Paragraph::new(Line::from(Span::styled(hints, style))), area);
310}
311
312fn render_sidebar(frame: &mut Frame<'_>, area: Rect, state: &GitTuiState) {
313 let rows = render_sidebar_rows(&state.entries, &state.staged, state.selected_file);
314 let lines: Vec<Line<'_>> = rows
315 .iter()
316 .enumerate()
317 .map(|(i, r)| {
318 let cursor = if i == state.selected_file { "> " } else { " " };
319 let marker = if r.marker.is_empty() {
320 String::new()
321 } else {
322 format!("{} ", r.marker)
323 };
324 let is_selected = i == state.selected_file;
325 let style = if is_selected && state.sidebar_focus {
326 Style::default()
327 .fg(Color::Cyan)
328 .add_modifier(Modifier::BOLD)
329 } else {
330 Style::default()
331 };
332 Line::from(vec![
333 Span::styled(cursor, style),
334 Span::styled(marker, style),
335 Span::styled(r.path.clone(), style),
336 ])
337 })
338 .collect();
339 let block = Block::default().borders(Borders::RIGHT);
340 frame.render_widget(Paragraph::new(lines).block(block), area);
341}
342
343fn render_diff_pane(frame: &mut Frame<'_>, area: Rect, state: &GitTuiState) {
344 let doc = filter_whitespace(&state.doc, state.ws);
346 let block = Block::default().borders(Borders::NONE);
347 let inner = block.inner(area);
348 frame.render_widget(block, area);
349
350 let Some(file) = doc.files.get(state.selected_file) else {
351 frame.render_widget(
352 Paragraph::new(Line::from(Span::styled(
353 "(no files)",
354 Style::default().add_modifier(Modifier::DIM),
355 ))),
356 inner,
357 );
358 return;
359 };
360 if file.binary {
361 frame.render_widget(
362 Paragraph::new(Line::from(Span::styled(
363 "(binary file)",
364 Style::default().add_modifier(Modifier::DIM),
365 ))),
366 inner,
367 );
368 return;
369 }
370
371 match state.view {
372 DiffViewMode::Files => {
373 let lines: Vec<Line<'_>> = doc
374 .files
375 .iter()
376 .map(|f| Line::from(Span::raw(f.path.clone())))
377 .collect();
378 frame.render_widget(Paragraph::new(lines), inner);
379 }
380 DiffViewMode::Hunks => {
381 let lines: Vec<Line<'_>> = file
382 .hunks
383 .iter()
384 .map(|h| {
385 let added = h
386 .lines
387 .iter()
388 .filter(|l| matches!(l.kind, DiffLineKind::Added))
389 .count();
390 let removed = h
391 .lines
392 .iter()
393 .filter(|l| matches!(l.kind, DiffLineKind::Removed))
394 .count();
395 Line::from(Span::styled(
396 format!(
397 "@@ -{},{} +{},{} @@ +{}/-{}",
398 h.old_start,
399 h.lines.len(),
400 h.new_start,
401 h.lines.len(),
402 added,
403 removed
404 ),
405 Style::default().add_modifier(Modifier::BOLD),
406 ))
407 })
408 .collect();
409 frame.render_widget(Paragraph::new(lines), inner);
410 }
411 DiffViewMode::Inline => {
412 let lines = render_inline(file, state.wrap, inner.width, state.selected_hunk);
413 let scroll =
414 hunk_scroll_offset(&file.hunks, state.selected_hunk, inner.height as usize);
415 frame.render_widget(Paragraph::new(lines).scroll((scroll as u16, 0)), inner);
416 }
417 DiffViewMode::Split => {
418 let has_removals = file
423 .hunks
424 .iter()
425 .flat_map(|h| h.lines.iter())
426 .any(|l| matches!(l.kind, DiffLineKind::Removed));
427 let has_additions = file
428 .hunks
429 .iter()
430 .flat_map(|h| h.lines.iter())
431 .any(|l| matches!(l.kind, DiffLineKind::Added));
432 if !has_removals || !has_additions {
433 let lines = render_inline(file, state.wrap, inner.width, state.selected_hunk);
434 let scroll =
435 hunk_scroll_offset(&file.hunks, state.selected_hunk, inner.height as usize);
436 frame.render_widget(Paragraph::new(lines).scroll((scroll as u16, 0)), inner);
437 return;
438 }
439 render_split(frame, inner, file, state.wrap);
440 }
441 }
442}
443
444fn render_inline(file: &DiffFile, wrap: bool, width: u16, selected_hunk: usize) -> Vec<Line<'_>> {
445 let mut lines: Vec<Line<'_>> = Vec::new();
446 for (idx, hunk) in file.hunks.iter().enumerate() {
447 let header_style = if idx == selected_hunk {
451 Style::default().add_modifier(Modifier::BOLD | Modifier::REVERSED)
452 } else {
453 Style::default().add_modifier(Modifier::BOLD)
454 };
455 lines.push(Line::from(Span::styled(
456 format!(
457 "@@ -{},{} +{},{} @@",
458 hunk.old_start,
459 hunk.lines.len(),
460 hunk.new_start,
461 hunk.lines.len()
462 ),
463 header_style,
464 )));
465 for line in &hunk.lines {
466 let (prefix, style) = match line.kind {
467 DiffLineKind::Added => ("+ ", Style::default().fg(Color::Green)),
468 DiffLineKind::Removed => ("- ", Style::default().fg(Color::Red)),
469 DiffLineKind::Context => (" ", Style::default()),
470 };
471 let segments: Vec<String> = if wrap {
475 wrap_text(&line.text, width.saturating_sub(2))
476 } else {
477 vec![truncate_text(&line.text, width.saturating_sub(2))]
478 };
479 for seg in segments {
480 lines.push(Line::from(vec![
481 Span::styled(prefix, style),
482 Span::styled(seg, style),
483 ]));
484 }
485 }
486 }
487 lines
488}
489
490fn render_split(frame: &mut Frame<'_>, inner: Rect, file: &DiffFile, wrap: bool) {
491 let cols = Layout::default()
492 .direction(Direction::Horizontal)
493 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
494 .split(inner);
495 let col_w = cols[0].width.max(cols[1].width);
496 let mut left_lines: Vec<Line<'_>> = Vec::new();
497 let mut right_lines: Vec<Line<'_>> = Vec::new();
498 for hunk in &file.hunks {
499 for row in pair_split_view(hunk) {
500 let (left_text, right_text): (Vec<String>, Vec<String>) = if wrap {
501 (
502 wrap_text(row.left.as_deref().unwrap_or(""), col_w),
503 wrap_text(row.right.as_deref().unwrap_or(""), col_w),
504 )
505 } else {
506 (
507 vec![truncate_text(row.left.as_deref().unwrap_or(""), col_w)],
508 vec![truncate_text(row.right.as_deref().unwrap_or(""), col_w)],
509 )
510 };
511 for line in &left_text {
515 left_lines.push(Line::from(Span::styled(
516 line.clone(),
517 Style::default().fg(Color::Red),
518 )));
519 }
520 for line in &right_text {
521 right_lines.push(Line::from(Span::styled(
522 line.clone(),
523 Style::default().fg(Color::Green),
524 )));
525 }
526 }
527 }
528 frame.render_widget(Paragraph::new(left_lines), cols[0]);
529 frame.render_widget(Paragraph::new(right_lines), cols[1]);
530}
531
532pub(crate) fn wrap_text(text: &str, width: u16) -> Vec<String> {
537 if width == 0 {
538 return vec![String::new()];
539 }
540 let mut out = Vec::new();
541 let mut current = String::new();
542 let mut cols = 0usize;
543 for ch in text.chars() {
544 let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
545 if cols + w > width as usize {
546 out.push(std::mem::take(&mut current));
547 cols = 0;
548 }
549 current.push(ch);
550 cols += w;
551 }
552 if !current.is_empty() || out.is_empty() {
553 out.push(current);
554 }
555 out
556}
557
558pub(crate) fn truncate_text(text: &str, width: u16) -> String {
561 if width == 0 {
562 return String::new();
563 }
564 if width == 1 {
569 return text
570 .chars()
571 .next()
572 .map(|c| c.to_string())
573 .unwrap_or_default();
574 }
575 let target = width as usize;
576 let mut out = String::new();
577 let mut cols = 0usize;
578 let mut last_kept_byte_idx: Option<usize> = None;
579 let mut last_kept_cols: usize = 0;
580 let mut rejected = false;
581 for ch in text.chars() {
582 let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
583 if cols + w > target {
584 rejected = true;
585 break;
586 }
587 last_kept_byte_idx = Some(out.len());
588 last_kept_cols = cols;
589 out.push(ch);
590 cols += w;
591 }
592 if rejected && let Some(idx) = last_kept_byte_idx {
593 out.truncate(idx);
594 if last_kept_cols < target {
599 out.push('\u{2026}');
600 }
601 }
602 out
603}
604
605fn render_minimap(frame: &mut Frame<'_>, area: Rect, state: &GitTuiState) {
606 let doc = filter_whitespace(&state.doc, state.ws);
607 let Some(file) = doc.files.get(state.selected_file) else {
608 return;
609 };
610 let pairs: Vec<DiffLineKind> = file
611 .hunks
612 .iter()
613 .flat_map(|h| h.lines.iter().map(|l| l.kind))
614 .collect();
615 let buckets = minimap_buckets_rows(&pairs, area.height as usize);
616 for (i, b) in buckets.iter().enumerate() {
617 let y = area.y + i as u16;
618 if y >= area.y + area.height {
619 break;
620 }
621 let color = if b.added > b.removed {
622 Color::Green
623 } else if b.removed > b.added {
624 Color::Red
625 } else {
626 Color::DarkGray
627 };
628 let row_area = Rect {
629 x: area.x,
630 y,
631 width: area.width,
632 height: 1,
633 };
634 frame.render_widget(
635 Paragraph::new(Line::from(Span::styled(" ", Style::default().bg(color)))),
636 row_area,
637 );
638 }
639}
640
641fn render_commit_form(frame: &mut Frame<'_>, area: Rect, state: &GitTuiState) {
642 let block = Block::default()
643 .borders(Borders::ALL)
644 .title(" commit message (Enter commit · Esc cancel) ");
645 let text = if state.commit_msg.is_empty() {
646 Line::from(Span::styled(
647 "(type commit message)",
648 Style::default().add_modifier(Modifier::DIM),
649 ))
650 } else {
651 Line::from(Span::raw(state.commit_msg.clone()))
652 };
653 frame.render_widget(Paragraph::new(text).block(block), area);
654}
655
656#[cfg(test)]
662mod tests {
663 use super::*;
664 use crate::tui_vt::git_tui::diff_doc::DiffLine;
665 use ratatui::Terminal;
666 use ratatui::backend::TestBackend;
667
668 fn hunk_fixture() -> Hunk {
669 Hunk {
670 old_start: 1,
671 new_start: 1,
672 lines: vec![
673 DiffLine {
674 kind: DiffLineKind::Context,
675 text: "a".to_string(),
676 },
677 DiffLine {
678 kind: DiffLineKind::Removed,
679 text: "b-old".to_string(),
680 },
681 DiffLine {
682 kind: DiffLineKind::Added,
683 text: "b-new".to_string(),
684 },
685 DiffLine {
686 kind: DiffLineKind::Context,
687 text: "c".to_string(),
688 },
689 ],
690 }
691 }
692
693 #[test]
694 fn split_view_pairs_removed_and_added_by_hunk() {
695 let h = hunk_fixture();
696 let rows = pair_split_view(&h);
697 assert_eq!(rows.len(), 4);
698 assert_eq!(rows[0].left.as_deref(), Some("a"));
699 assert_eq!(rows[0].right.as_deref(), Some("a"));
700 assert_eq!(rows[1].left.as_deref(), Some("b-old"));
701 assert_eq!(rows[1].right, None);
702 assert_eq!(rows[2].left, None);
703 assert_eq!(rows[2].right.as_deref(), Some("b-new"));
704 assert_eq!(rows[3].left.as_deref(), Some("c"));
705 assert_eq!(rows[3].right.as_deref(), Some("c"));
706 }
707
708 #[test]
709 fn minimap_buckets_rows_groups_by_row() {
710 let pairs = vec![
711 DiffLineKind::Added,
712 DiffLineKind::Added,
713 DiffLineKind::Removed,
714 DiffLineKind::Context,
715 ];
716 let b = minimap_buckets_rows(&pairs, 2);
717 assert_eq!(b.len(), 2);
718 assert_eq!(b[0].added, 2);
720 assert_eq!(b[0].removed, 0);
721 assert_eq!(b[1].added, 0);
723 assert_eq!(b[1].removed, 1);
724 assert!(b[0].ratio() > 0.5);
726 assert!(b[1].ratio() < 0.5);
727 }
728
729 #[test]
730 fn footer_hints_include_commit_and_close() {
731 let s = footer_hints(DiffViewMode::Inline, WhitespaceMode::Off, false);
732 assert!(s.contains("commit"), "footer missing commit hint: {s}");
733 assert!(s.contains("close"), "footer missing close hint: {s}");
734 assert!(s.contains("stage"), "footer missing stage hint: {s}");
735 }
736
737 #[test]
738 fn sidebar_rows_marks_staged_and_unmerged() {
739 let entries = vec![
740 StatusEntry {
741 path: "a.txt".into(),
742 old_path: None,
743 xy: ['M', ' '],
744 is_rename: false,
745 is_unmerged: false,
746 },
747 StatusEntry {
748 path: "b.txt".into(),
749 old_path: None,
750 xy: ['U', 'U'],
751 is_rename: false,
752 is_unmerged: true,
753 },
754 ];
755 let mut staged = std::collections::HashSet::new();
756 staged.insert("a.txt".to_string());
757 let rows = render_sidebar_rows(&entries, &staged, 0);
758 assert_eq!(rows[0].marker, "[S]");
759 assert_eq!(rows[1].marker, "[U]");
760 }
761
762 #[test]
763 fn plan_overlay_splits_into_three_rows() {
764 let plan = plan_overlay(Rect {
765 x: 0,
766 y: 0,
767 width: 100,
768 height: 24,
769 });
770 assert_eq!(plan.header.height, 1);
771 assert_eq!(plan.footer.height, 1);
772 assert_eq!(plan.sidebar.width, 25);
774 assert_eq!(plan.minimap.width, 2);
776 }
777
778 #[test]
779 fn render_split_paints_both_columns() {
780 let backend = TestBackend::new(40, 6);
781 let mut terminal = Terminal::new(backend).expect("backend");
782 let file = DiffFile {
783 path: "demo.txt".to_string(),
784 old_path: None,
785 hunks: vec![hunk_fixture()],
786 binary: false,
787 };
788 let area = Rect {
789 x: 0,
790 y: 0,
791 width: 40,
792 height: 6,
793 };
794 terminal
795 .draw(|f| render_split(f, area, &file, false))
796 .expect("draw");
797 let buf = terminal.backend().buffer();
798 let mut rows: Vec<String> = Vec::new();
799 for y in 0..buf.area().height {
800 let mut line = String::new();
801 for x in 0..buf.area().width {
802 if let Some(c) = buf.cell((x, y)) {
803 line.push_str(c.symbol());
804 }
805 }
806 rows.push(line);
807 }
808 let left: String = rows
811 .iter()
812 .map(|r| r.chars().take(20).collect::<String>())
813 .collect::<Vec<_>>()
814 .join("\n");
815 let right: String = rows
816 .iter()
817 .map(|r| r.chars().skip(20).collect::<String>())
818 .collect::<Vec<_>>()
819 .join("\n");
820 assert!(
821 left.contains("b-old"),
822 "removed text missing from left column: {left:?}"
823 );
824 assert!(
825 right.contains("b-new"),
826 "added text missing from right column: {right:?}"
827 );
828 assert!(left.contains("c"), "context missing from left: {left:?}");
830 assert!(right.contains("c"), "context missing from right: {right:?}");
831 assert!(
834 left.contains("a"),
835 "first context missing from left: {left:?}"
836 );
837 assert!(
838 right.contains("a"),
839 "first context missing from right: {right:?}"
840 );
841 }
842
843 #[test]
846 fn wrap_text_breaks_at_width() {
847 let short = wrap_text("hello", 10);
848 assert_eq!(short, vec!["hello".to_string()]);
849 let long = wrap_text("abcdefghij", 4);
850 assert_eq!(
851 long,
852 vec!["abcd".to_string(), "efgh".to_string(), "ij".to_string()]
853 );
854 }
855
856 #[test]
860 fn truncate_text_caps_with_ellipsis() {
861 let short = truncate_text("hi", 10);
862 assert_eq!(short, "hi");
863 let long = truncate_text("abcdefghij", 3);
864 assert_eq!(long, "ab\u{2026}");
865 let exact = truncate_text("abcd", 4);
866 assert_eq!(exact, "abcd");
867 }
868
869 #[test]
872 fn footer_hints_reflects_wrap_state() {
873 let trunc = footer_hints(DiffViewMode::Inline, WhitespaceMode::Off, false);
874 assert!(trunc.contains("trunc"), "trunc label missing: {trunc}");
875 let wrapped = footer_hints(DiffViewMode::Inline, WhitespaceMode::Off, true);
876 assert!(wrapped.contains("wrap"), "wrap label missing: {wrapped}");
877 }
878
879 fn five_hunks() -> Vec<Hunk> {
881 (0..5)
882 .map(|i| Hunk {
883 old_start: 1 + i * 5,
884 new_start: 1 + i * 5,
885 lines: (0..4)
886 .map(|_| DiffLine {
887 kind: DiffLineKind::Context,
888 text: "ctx".to_string(),
889 })
890 .collect(),
891 })
892 .collect()
893 }
894
895 #[test]
896 fn hunk_scroll_offset_pins_selected_header_into_view() {
897 let hunks = five_hunks();
898 assert_eq!(hunk_header_row(&hunks, 3), 15);
900 assert_eq!(hunk_scroll_offset(&hunks, 0, 6), 0);
902 assert_eq!(hunk_scroll_offset(&hunks, 3, 6), 15);
905 assert_eq!(hunk_scroll_offset(&hunks, 4, 6), 19);
908 assert_eq!(hunk_scroll_offset(&hunks, 4, 40), 0);
910 assert_eq!(hunk_scroll_offset(&hunks, 99, 6), 19);
912 assert_eq!(hunk_scroll_offset(&[], 0, 6), 0);
914 }
915
916 #[test]
917 fn tall_diff_scrolls_to_selected_hunk_header() {
918 let backend = TestBackend::new(60, 10);
923 let mut terminal = Terminal::new(backend).expect("backend");
924 let file = DiffFile {
925 path: "tall.txt".to_string(),
926 old_path: None,
927 hunks: five_hunks(),
928 binary: false,
929 };
930 let state = GitTuiState {
931 doc: crate::tui_vt::git_tui::DiffDocument { files: vec![file] },
932 entries: Vec::new(),
933 view: DiffViewMode::Inline,
934 ws: WhitespaceMode::Off,
935 selected_file: 0,
936 selected_hunk: 4,
937 sidebar_focus: false,
938 staged: std::collections::HashSet::new(),
939 commit_mode: false,
940 commit_msg: String::new(),
941 needs_refresh: false,
942 width: 60,
943 height: 10,
944 branch: None,
945 wrap: false,
946 };
947 let area = Rect {
948 x: 0,
949 y: 0,
950 width: 60,
951 height: 10,
952 };
953 terminal
954 .draw(|f| render_diff_pane(f, area, &state))
955 .expect("draw");
956 let buf = terminal.backend().buffer();
957 let mut rendered = String::new();
958 for y in 0..buf.area().height {
959 for x in 0..buf.area().width {
960 if let Some(c) = buf.cell((x, y)) {
961 rendered.push_str(c.symbol());
962 }
963 }
964 }
965 assert!(
967 rendered.contains("-21,4"),
968 "selected hunk header missing from pane: {rendered}"
969 );
970 assert!(
972 !rendered.contains("-1,4"),
973 "unselected first hunk still at pane top: {rendered}"
974 );
975 }
976}