Skip to main content

oxicode/tui_vt/git_tui/
render.rs

1//! Render layer for the git TUI overlay.
2//!
3//! * [`plan_overlay`] / [`RenderPlan`] — pure layout math (chunks per pane)
4//! * [`pair_split_view`] — pair removed + added lines per hunk so the
5//!   side-by-side renderer can align left/right columns with shared context
6//! * [`render_sidebar_rows`] — pure sidebar row builder (text only — no
7//!   ratatui styles) so the layout can be tested without a terminal
8//! * [`minimap_buckets_rows`] — per-row added/removed ratio for the
9//!   right-edge minimap strip
10//! * [`render_overlay_lines`] — ratatui draw fn, consumed by `render_frame`
11//!
12//! The layout math is deliberately separated from the draw step so the
13//! bulk of the test surface stays in pure helpers.
14
15use 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/// Layout plan returned by [`plan_overlay`]. One chunk per pane.
27#[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
38/// Decide how wide each pane should be given the viewport.
39///
40/// Layout (inside the area the caller reserved for the overlay):
41///
42/// ```text
43/// +---------------- header ---------------+
44/// | sidebar | diff pane           | minimap|
45/// +---------------- footer ---------------+
46/// ```
47///
48/// The sidebar is 1/4 of the width with a floor of 20 cols. The minimap
49/// is fixed at 2 cols (the brief's "2-col right edge"). When the body
50/// area is too narrow for both, the minimap collapses to zero width and
51/// the diff pane eats the freed columns.
52pub fn plan_overlay(area: Rect) -> RenderPlan {
53    let outer = Layout::default()
54        .direction(Direction::Vertical)
55        .constraints([
56            Constraint::Length(1), // header
57            Constraint::Min(3),    // body
58            Constraint::Length(1), // footer
59        ])
60        .split(area);
61
62    let header = outer[0];
63    let body = outer[1];
64    let footer = outer[2];
65
66    // Body = sidebar | diff (+ minimap).
67    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    // The commit-mode composer occupies the diff pane entirely; this rect
81    // is informational (it equals `diff`) so the draw fn can re‑split.
82    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/// One paired row of a split view: left = removed-side text, right =
96/// added-side text. Either side may be empty (when a hunk has all
97/// additions or all deletions).
98#[derive(Debug, Default, Clone, PartialEq, Eq)]
99pub struct SplitRow {
100    pub left: Option<String>,
101    pub right: Option<String>,
102}
103
104/// Pair context / removed / added lines for one hunk into left/right rows
105/// suitable for side-by-side rendering.
106///
107/// Algorithm (matches the brief):
108/// * Walk the lines in source order.
109/// * Context → duplicate text into BOTH left and right.
110/// * Removed → push into `left`, leave `right = None`.
111/// * Added → push into `right`, leave `left = None`.
112/// * Adjacent Removed-then-Added (and Added-then-Removed) pairs line up
113///   at the same row index so the visual gutter reads as a unified diff
114///   flipped sideways.
115/// * Lone runs of one side collapse to per-row Nones so the renderer
116///   doesn't waste vertical space on blank halves.
117pub 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/// One row of the sidebar (file list).
141#[derive(Debug, Default, Clone, PartialEq, Eq)]
142pub struct SidebarRow {
143    pub path: String,
144    pub marker: &'static str,
145}
146
147/// Build the sidebar rows. `staged` marks paths with `[S]` (whatever the
148/// user has staged via `toggle_stage`); unmerged entries always get
149/// `[U]`. Order follows the input entries (which is `git status`
150/// porcelain order).
151pub 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            // Selection cursor is communicated via the caller (highlighted
172            // row is `selected`); we keep this fn side-effect-free by
173            // not returning it.
174            let _ = (idx, selected);
175            row
176        })
177        .collect()
178}
179
180/// One bucket of the minimap: ratio of added vs removed lines on a
181/// given screen row (0.0 = pure removal, 1.0 = pure addition).
182#[derive(Debug, Default, Clone, Copy, PartialEq)]
183pub struct MinimapBucket {
184    pub added: usize,
185    pub removed: usize,
186}
187
188impl MinimapBucket {
189    /// 0.0..=1.0 ratio; returns 0.5 for an empty bucket so the renderer
190    /// can default to a neutral color without a branch.
191    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
201/// Bucket a flat list of (line-kind) rows into `rows` slots so the
202/// minimap draws one cell per visible row.
203///
204/// Out-of-bounds rows are clamped (no crash) — the caller may pass a
205/// `rows` count derived from the viewport height, which can shift
206/// between frames as the user resizes.
207pub 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
227/// Flat-row offset (no wrap) of a hunk's header row inside the inline
228/// diff: every hunk renders `1 + lines.len()` rows (header + one row
229/// per diff line). `selected` is clamped into range.
230pub(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
237/// Scroll offset (rows) that brings the selected hunk's header into
238/// the diff pane: `min(header offset, overflow)`. With the header at
239/// the pane top when it fits, and pinned to the last pane-height
240/// window when the hunk sits near the end of the diff. Pane taller
241/// than the whole diff → 0 (no scroll).
242///
243/// Final-review finding 2: `selected_hunk` is mutated by j/k, alt+↓/↑
244/// and g/G but nothing consumed it — the pane always rendered from
245/// row 0, truncating long diffs. The row model assumes the default
246/// no-wrap rendering (one visual row per diff line); under `wrap` the
247/// true visual count can only grow, which makes this scroll
248/// conservative (the highlight still marks the selected hunk).
249pub 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
255/// Footer hint line. Returned as a String (not Line) so the test can
256/// assert content without bringing in ratatui's Style.
257pub 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}
265// Ratatui draw
266// ---------------------------------------------------------------------------
267
268/// Draw the overlay into `frame`. The caller is expected to have
269/// already clipped the frame to a sane area (the brief: overlay
270/// REPLACES the scrollback+composer region entirely — `render_frame`
271/// in `main_loop.rs` handles the area split).
272pub 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    // Commit mode replaces the diff pane with a single-line composer.
280    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    // Apply the whitespace filter before rendering.
345    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            // Split mode tries to pair removed/added by hunk; when the
419            // file lacks removals OR additions we fall back to inline so
420            // the user always sees something useful (v1 honesty over
421            // broken side-by-side — see brief).
422            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        // The selected hunk's header is the cursor: reverse video
448        // marks where j/k / alt+↓/↑ / g/G moved to (finding 2 —
449        // `selected_hunk` was previously mutated but never rendered).
450        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            // Wrap emits one Line per visual segment; truncate is a
472            // single segment. We unify on `Vec<String>` and push one
473            // Line per segment so the gutter stays aligned.
474            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            // One row in the rendered pane can span multiple visual
512            // lines when wrapping; each wrap segment becomes its own
513            // Line so the gutter alignment stays readable.
514            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
532/// Hard-wrap a string into multiple lines of at most `width` columns
533/// (counted via Unicode-width for `char::len_utf8` approximations;
534/// full-width-aware wrapping is out of scope for the v1 overlay).
535/// Returns `vec![text.to_string()]` when the line already fits.
536pub(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
558/// Truncate a string to at most `width` columns; longer lines end in
559/// `…` so the truncation is visually obvious in the diff pane.
560pub(crate) fn truncate_text(text: &str, width: u16) -> String {
561    if width == 0 {
562        return String::new();
563    }
564    // Build greedily. The ellipsis slot is the LAST column — when we'd
565    // have to reject any char AND there's room for an ellipsis (i.e.
566    // we kept at least one real char), replace the trailing char with
567    // '…' so the visible width stays at exactly `width`.
568    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        // `last_kept_cols` is the width BEFORE we wrote the char
595        // we just dropped. The ellipsis occupies 1 column; if the
596        // prefix already filled the column the ellipsis would
597        // take, there's no room and we leave it bare.
598        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// ---------------------------------------------------------------------------
657// Tests (TDD — pure helpers are tested here; the draw fn is exercised in
658// main_loop integration via a smoke test, not unit-tested directly).
659// ---------------------------------------------------------------------------
660
661#[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        // First bucket gets the first 2 entries: 2 added, 0 removed.
719        assert_eq!(b[0].added, 2);
720        assert_eq!(b[0].removed, 0);
721        // Second bucket gets the next 2 entries: 0 added, 1 removed.
722        assert_eq!(b[1].added, 0);
723        assert_eq!(b[1].removed, 1);
724        // Ratio is in [0.0, 1.0].
725        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        // Sidebar is 1/4 of body width, but min 20.
773        assert_eq!(plan.sidebar.width, 25);
774        // Minimap is 2 columns.
775        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        // (c, c). The first segment of each pair goes into the LEFT
809        // half; the second into the RIGHT half. Verify both halves.
810        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        // Context line "c" must appear in BOTH halves (last pair row).
829        assert!(left.contains("c"), "context missing from left: {left:?}");
830        assert!(right.contains("c"), "context missing from right: {right:?}");
831        // And context line "a" (first pair row) must also appear in
832        // both halves.
833        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    /// `wrap_text` breaks long lines on `width` boundaries; short lines
844    /// pass through unchanged.
845    #[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    /// `truncate_text` caps at `width` and adds an ellipsis when it
857    /// had to drop characters. With width=4 the input "abcdefghij"
858    /// fits exactly; to exercise the ellipsis branch we ask for width=3.
859    #[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    /// The footer hint reflects the actual wrap state — the brief
870    /// requires the binding be wired, not inert.
871    #[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    /// 5 hunks × (1 header + 4 lines) = 25 flat rows.
880    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        // Hunk 3's header sits at flat row 15 (3 hunks × 5 rows).
899        assert_eq!(hunk_header_row(&hunks, 3), 15);
900        // Selected 0 → no scroll.
901        assert_eq!(hunk_scroll_offset(&hunks, 0, 6), 0);
902        // Pane 6 rows: overflow = 25 - 6 = 19; offset 15 ≤ 19 → 15,
903        // putting the selected header at the pane top.
904        assert_eq!(hunk_scroll_offset(&hunks, 3, 6), 15);
905        // Hunk 4's header at 20 > overflow 19 → clamped to 19 (the
906        // last full window still shows the header).
907        assert_eq!(hunk_scroll_offset(&hunks, 4, 6), 19);
908        // Pane taller than the whole diff → never scrolls.
909        assert_eq!(hunk_scroll_offset(&hunks, 4, 40), 0);
910        // Out-of-range selection is clamped (same as the last hunk).
911        assert_eq!(hunk_scroll_offset(&hunks, 99, 6), 19);
912        // Empty diff → 0.
913        assert_eq!(hunk_scroll_offset(&[], 0, 6), 0);
914    }
915
916    #[test]
917    fn tall_diff_scrolls_to_selected_hunk_header() {
918        // Final-review finding 2 smoke test: a diff taller than the
919        // pane must render the SELECTED hunk's header. Previously the
920        // Paragraph rendered from row 0 with no scroll, so j/k / g/G
921        // changed `selected_hunk` with nothing visible changing.
922        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        // Selected hunk 4 (old_start = 21) must be on screen…
966        assert!(
967            rendered.contains("-21,4"),
968            "selected hunk header missing from pane: {rendered}"
969        );
970        // …and hunk 0's header (old_start = 1) must have scrolled out.
971        assert!(
972            !rendered.contains("-1,4"),
973            "unselected first hunk still at pane top: {rendered}"
974        );
975    }
976}