Skip to main content

clankerdiff_ratatui/
render.rs

1//! Visible-range Ratatui rendering.
2
3use crate::{
4    DiffReviewState, DiffReviewStatus, FocusPane, RatatuiTheme, RepositoryOperationStatus,
5    annotation::render_annotation_line,
6    drawer::{DrawerEntry, DrawerTree},
7    patch_layout::PatchVisualRow,
8    state::RepositoryPrompt,
9    style::syntax_style,
10    theme_picker::render_theme_picker,
11    ui::{ActionBar, AppFrame, EmptyState, Modal, NoticeTone, render_modal_text},
12    widgets::{render_vertical_scrollbar, rows_and_track},
13};
14use clankerdiff_core::{DiffTone, PresentedCell, PresentedRow, RowKind};
15use clankerdiff_syntax::{HighlightSpan, SyntaxHighlighter};
16use clankerdiff_theme::DiffTheme;
17use ratatui::{
18    buffer::Buffer,
19    layout::{Constraint, Layout, Position, Rect},
20    style::{Modifier, Style},
21    text::{Line, Span},
22    widgets::{Paragraph, StatefulWidget, Widget},
23};
24
25const DRAWER_BREAKPOINT: u16 = 72;
26const DRAWER_MIN_WIDTH: u16 = 20;
27const DRAWER_MAX_WIDTH: u16 = 36;
28const GUTTER_WIDTH: u16 = 6;
29
30/// Embeddable stateful diff review widget.
31#[derive(Debug, Clone)]
32pub struct DiffReviewWidget {
33    title: String,
34    borders: bool,
35}
36
37impl Default for DiffReviewWidget {
38    fn default() -> Self {
39        Self::new()
40    }
41}
42
43impl DiffReviewWidget {
44    /// Creates a bordered widget titled “Diff Review”.
45    #[must_use]
46    pub fn new() -> Self {
47        Self {
48            title: "Diff Review".to_owned(),
49            borders: true,
50        }
51    }
52
53    /// Sets the outer title.
54    #[must_use]
55    pub fn title(mut self, title: impl Into<String>) -> Self {
56        self.title = title.into();
57        self
58    }
59
60    /// Enables or disables the outer border.
61    #[must_use]
62    pub const fn borders(mut self, borders: bool) -> Self {
63        self.borders = borders;
64        self
65    }
66}
67
68impl StatefulWidget for DiffReviewWidget {
69    type State = DiffReviewState;
70
71    fn render(self, area: Rect, buffer: &mut Buffer, state: &mut Self::State) {
72        state.cursor_position = None;
73        let theme = RatatuiTheme::from(&state.theme);
74        let regions = AppFrame::new(&self.title, self.borders, &theme).render(area, buffer);
75        let body = regions.body;
76        let footer = regions.footer;
77        render_body(body, buffer, state, &theme);
78        render_footer(footer, buffer, state, &theme);
79        if state.help {
80            render_help(area, buffer, &theme);
81        }
82        if let Some(picker) = &state.theme_picker {
83            render_theme_picker(area, buffer, picker, &theme);
84        }
85        state.dirty = false;
86    }
87}
88
89fn render_body(area: Rect, buffer: &mut Buffer, state: &mut DiffReviewState, theme: &RatatuiTheme) {
90    let notice = match &state.status {
91        DiffReviewStatus::Loading => Some(("Loading diff…".to_owned(), NoticeTone::Info)),
92        DiffReviewStatus::Error(message) => {
93            Some((format!("Diff unavailable: {message}"), NoticeTone::Error))
94        }
95        DiffReviewStatus::Ready if state.document().files.is_empty() => Some((
96            format!("No changes (scope: {})", state.scope()),
97            NoticeTone::Info,
98        )),
99        DiffReviewStatus::Ready => None,
100    };
101    match notice {
102        Some((text, tone)) => EmptyState::new(&text, tone, theme).render(area, buffer),
103        None => render_document(area, buffer, state, theme),
104    }
105}
106
107fn render_document(
108    area: Rect,
109    buffer: &mut Buffer,
110    state: &mut DiffReviewState,
111    theme: &RatatuiTheme,
112) {
113    let (drawer, patch) = if area.width >= DRAWER_BREAKPOINT {
114        let drawer_width = (area.width / 3).clamp(DRAWER_MIN_WIDTH, DRAWER_MAX_WIDTH);
115        let [drawer, separator, patch] = Layout::horizontal([
116            Constraint::Length(drawer_width),
117            Constraint::Length(1),
118            Constraint::Min(1),
119        ])
120        .areas(area);
121        render_separator(separator, buffer, theme);
122        let drawer_hit = render_drawer(drawer, buffer, state, theme);
123        (drawer_hit, patch)
124    } else {
125        (Rect::default(), area)
126    };
127    state.hit_layout.drawer = drawer;
128    if drawer.is_empty() {
129        state.hit_layout.drawer_stage_column = None;
130    }
131    state.hit_layout.patch = patch;
132    let (patch_rows, patch_track) = rows_and_track(patch, true);
133    state.ensure_presentation(patch_rows.width);
134    render_patch(patch_rows, patch_track, buffer, state, theme);
135}
136
137fn render_separator(area: Rect, buffer: &mut Buffer, theme: &RatatuiTheme) {
138    Paragraph::new("│")
139        .style(Style::new().fg(theme.ui.border).bg(theme.ui.canvas))
140        .render(area, buffer);
141}
142
143#[expect(clippy::too_many_lines, reason = "tree row rendering is kept together")]
144fn render_drawer(
145    area: Rect,
146    buffer: &mut Buffer,
147    state: &mut DiffReviewState,
148    theme: &RatatuiTheme,
149) -> Rect {
150    let (rows, track) = rows_and_track(area, true);
151    state.hit_layout.drawer_stage_column = rows
152        .width
153        .checked_sub(1)
154        .map(|offset| rows.x.saturating_add(offset));
155    state.drawer_height = usize::from(rows.height).max(1);
156    let entry_count = state.drawer.entries().len();
157    state.drawer_scroll = state
158        .drawer_scroll
159        .min(entry_count.saturating_sub(state.drawer_height));
160    if state.take_drawer_follow_request() {
161        if state.drawer_selected < state.drawer_scroll {
162            state.drawer_scroll = state.drawer_selected;
163        } else if state.drawer_selected >= state.drawer_scroll.saturating_add(state.drawer_height) {
164            state.drawer_scroll = state
165                .drawer_selected
166                .saturating_sub(state.drawer_height.saturating_sub(1));
167        }
168    }
169    for (offset, entry) in state
170        .drawer
171        .entries()
172        .iter()
173        .skip(state.drawer_scroll)
174        .take(state.drawer_height)
175        .enumerate()
176    {
177        let y = rows
178            .y
179            .saturating_add(u16::try_from(offset).unwrap_or(u16::MAX));
180        let index = state.drawer_scroll.saturating_add(offset);
181        let row = Rect::new(rows.x, y, rows.width, 1);
182        let [content, stage_area] =
183            Layout::horizontal([Constraint::Min(0), Constraint::Length(2_u16.min(row.width))])
184                .areas(row);
185        let checkbox = Rect::new(
186            stage_area
187                .x
188                .saturating_add(stage_area.width.saturating_sub(1)),
189            y,
190            stage_area.width.min(1),
191            1,
192        );
193        let entry_stage = match entry {
194            DrawerEntry::Directory {
195                name,
196                depth,
197                expanded,
198                ..
199            } => {
200                let marker = if *expanded { "▾" } else { "▸" };
201                Paragraph::new(Line::from(vec![
202                    Span::raw(format!("{}{} ", "  ".repeat(*depth), marker)),
203                    Span::styled(format!("{name}/"), Style::new().fg(theme.ui.accent)),
204                ]))
205                .render(content, buffer);
206                DrawerTree::stage_state_for_entry(state.document(), entry)
207            }
208            DrawerEntry::File {
209                index: file_index,
210                name,
211                depth,
212            } => {
213                let Some(file) = state.document().files.get(*file_index) else {
214                    continue;
215                };
216                let status_color = match file.status {
217                    clankerdiff_core::FileStatus::Added
218                    | clankerdiff_core::FileStatus::Untracked => theme.addition,
219                    clankerdiff_core::FileStatus::Deleted => theme.deletion,
220                    clankerdiff_core::FileStatus::Modified
221                    | clankerdiff_core::FileStatus::Renamed
222                    | clankerdiff_core::FileStatus::Copied => theme.ui.accent,
223                };
224                Paragraph::new(Line::from(vec![
225                    Span::raw("  ".repeat(*depth)),
226                    Span::styled(
227                        file.status.code().to_string(),
228                        Style::new().fg(status_color),
229                    ),
230                    Span::raw(format!(" {name}")),
231                    Span::styled(
232                        format!(" +{} -{}", file.additions(), file.deletions()),
233                        Style::new().fg(theme.ui.text_muted),
234                    ),
235                ]))
236                .render(content, buffer);
237                file.staged
238            }
239        };
240        Paragraph::new(stage_marker(entry_stage)).render(checkbox, buffer);
241
242        if state.drawer_selected == index {
243            buffer.set_style(
244                row,
245                Style::new()
246                    .fg(theme.ui.canvas)
247                    .bg(theme.ui.accent)
248                    .add_modifier(if state.focus == FocusPane::Files {
249                        Modifier::BOLD
250                    } else {
251                        Modifier::default()
252                    }),
253            );
254        }
255    }
256
257    render_vertical_scrollbar(
258        track,
259        buffer,
260        entry_count,
261        usize::from(rows.height),
262        state.drawer_scroll,
263    );
264    rows
265}
266
267const fn stage_marker(state: clankerdiff_core::StageState) -> &'static str {
268    match state {
269        clankerdiff_core::StageState::Unstaged => "☐",
270        clankerdiff_core::StageState::Staged => "☑",
271        clankerdiff_core::StageState::PartiallyStaged => "◩",
272    }
273}
274
275fn render_patch(
276    area: Rect,
277    track: Rect,
278    buffer: &mut Buffer,
279    state: &mut DiffReviewState,
280    theme: &RatatuiTheme,
281) {
282    if area.is_empty() {
283        return;
284    }
285    state.last_height = usize::from(area.height).max(1);
286    if state.take_follow_request() {
287        state.follow_selection();
288    }
289    let Some(visual_layout) = state.patch_visual_layout() else {
290        return;
291    };
292    let last_scroll = visual_layout.len().saturating_sub(state.last_height);
293    state.scroll = state.scroll.min(last_scroll);
294
295    let DiffReviewState {
296        session,
297        theme: diff_theme,
298        highlighter,
299        focus,
300        scroll,
301        visible_rows,
302        cursor_position,
303        ..
304    } = state;
305    visible_rows.clear();
306    let presentation = session.presentation();
307    let selected_row = session.selected_row();
308    let selected_side = session.selected_side();
309    let layout = session.layout();
310
311    for (drawn, visual_index) in (*scroll..visual_layout.len()).enumerate() {
312        let y = area
313            .y
314            .saturating_add(u16::try_from(drawn).unwrap_or(u16::MAX));
315        if y >= area.bottom() {
316            break;
317        }
318        let row_area = Rect::new(area.x, y, area.width, 1);
319        match visual_layout.row(visual_index) {
320            Some(PatchVisualRow::Source(index)) => {
321                let Some(row) = presentation.row(index) else {
322                    continue;
323                };
324                let mut context = CellContext {
325                    theme,
326                    diff_theme,
327                    highlighter,
328                    presentation,
329                    row,
330                };
331                let selected = selected_row == Some(index) && *focus == FocusPane::Diff;
332                render_row(
333                    row_area,
334                    buffer,
335                    &mut context,
336                    index,
337                    row,
338                    &RowStyle {
339                        selected,
340                        selected_side,
341                        layout,
342                        file_stats: file_stats(session, row),
343                    },
344                );
345                visible_rows.push((y, index));
346            }
347            Some(PatchVisualRow::Annotation {
348                source,
349                annotation,
350                line,
351            }) => {
352                render_annotation_line(row_area, buffer, theme, annotation, line);
353                visible_rows.push((y, source));
354                if let Some(column) = annotation.cursor_column(line) {
355                    *cursor_position = Some(Position::new(
356                        area.x
357                            .saturating_add(column)
358                            .min(area.right().saturating_sub(1)),
359                        y,
360                    ));
361                }
362            }
363            None => break,
364        }
365    }
366    render_vertical_scrollbar(
367        track,
368        buffer,
369        visual_layout.len(),
370        usize::from(area.height),
371        *scroll,
372    );
373}
374
375fn file_stats(
376    session: &clankerdiff_core::ReviewSession,
377    row: &PresentedRow,
378) -> Option<(usize, usize)> {
379    (row.kind == RowKind::FileHeader)
380        .then(|| session.document().files.get(row.file_index))
381        .flatten()
382        .map(|file| (file.additions(), file.deletions()))
383}
384
385struct RowStyle {
386    selected: bool,
387    selected_side: clankerdiff_core::DiffSide,
388    layout: clankerdiff_core::Layout,
389    file_stats: Option<(usize, usize)>,
390}
391
392struct CellContext<'a> {
393    theme: &'a RatatuiTheme,
394    diff_theme: &'a DiffTheme,
395    highlighter: &'a mut SyntaxHighlighter,
396    presentation: &'a clankerdiff_core::DiffPresentation,
397    row: &'a PresentedRow,
398}
399
400fn render_row(
401    area: Rect,
402    buffer: &mut Buffer,
403    context: &mut CellContext<'_>,
404    row_index: usize,
405    row: &PresentedRow,
406    style: &RowStyle,
407) {
408    match row.kind {
409        RowKind::FileHeader => {
410            let text = row.primary_cell().map_or("", |cell| cell.text.as_ref());
411            let (additions, deletions) = style.file_stats.unwrap_or_default();
412            Paragraph::new(Line::from(vec![
413                Span::styled(
414                    format!(" {text} "),
415                    Style::new()
416                        .fg(context.theme.ui.accent)
417                        .add_modifier(Modifier::BOLD),
418                ),
419                Span::styled(
420                    format!("+{additions} -{deletions}"),
421                    Style::new().fg(context.theme.ui.text_muted),
422                ),
423            ]))
424            .render(area, buffer);
425        }
426        RowKind::HunkHeader | RowKind::Meta => {
427            Paragraph::new(row.primary_cell().map_or("", |cell| cell.text.as_ref()))
428                .style(
429                    Style::new()
430                        .fg(context.theme.ui.text_muted)
431                        .bg(context.theme.ui.canvas),
432                )
433                .render(area, buffer);
434        }
435        RowKind::ExpandGap => {
436            let text = context.presentation.gap_info(row_index).map_or_else(
437                || " ⋯ unchanged lines".to_owned(),
438                |info| {
439                    let message = if info.unavailable.is_none() {
440                        format!("{} — o expand · O expand all", info.message())
441                    } else {
442                        info.message()
443                    };
444                    format!(" {message}")
445                },
446            );
447            Paragraph::new(text)
448                .style(
449                    Style::new()
450                        .fg(context.theme.ui.text_muted)
451                        .bg(if style.selected {
452                            context.theme.ui.surface_selected
453                        } else {
454                            context.theme.ui.canvas
455                        }),
456                )
457                .render(area, buffer);
458        }
459        RowKind::Code | RowKind::ExpandedContext if style.layout.is_split() => {
460            let left_width = area.width.saturating_sub(1) / 2;
461            let right_width = area.width.saturating_sub(left_width + 1);
462            let [left, separator, right] = Layout::horizontal([
463                Constraint::Length(left_width),
464                Constraint::Length(1),
465                Constraint::Length(right_width),
466            ])
467            .areas(area);
468            let focused = |side| style.selected && style.selected_side == side;
469            render_cell(
470                left,
471                buffer,
472                context,
473                row.left.as_ref(),
474                focused(clankerdiff_core::DiffSide::Old),
475            );
476            render_separator(separator, buffer, context.theme);
477            render_cell(
478                right,
479                buffer,
480                context,
481                row.right.as_ref(),
482                focused(clankerdiff_core::DiffSide::New),
483            );
484        }
485        RowKind::Code | RowKind::ExpandedContext => {
486            render_cell(area, buffer, context, row.primary_cell(), style.selected);
487        }
488    }
489}
490
491fn render_cell(
492    area: Rect,
493    buffer: &mut Buffer,
494    context: &mut CellContext<'_>,
495    cell: Option<&PresentedCell>,
496    selected: bool,
497) {
498    let tone = cell.map_or(DiffTone::Context, |cell| cell.tone);
499    let (foreground, tone_background) = context.theme.tone(tone);
500    let background = if selected {
501        context.theme.ui.surface_selected
502    } else {
503        tone_background
504    };
505    buffer.set_style(area, Style::new().fg(foreground).bg(background));
506    let Some(cell) = cell else {
507        return;
508    };
509    let number = cell
510        .line_number()
511        .map_or_else(String::new, |number| number.to_string());
512    let indicator = match tone {
513        DiffTone::Added | DiffTone::Removed => '▌',
514        DiffTone::Context | DiffTone::Meta => ' ',
515    };
516    let gutter = format!(
517        "{indicator}{number:>width$} ",
518        width = usize::from(GUTTER_WIDTH) - 2,
519    );
520    let highlights = crate::diff_preview::cell_highlights(
521        context.highlighter,
522        context.diff_theme,
523        context.presentation,
524        context.row,
525        cell,
526    );
527    let gutter_foreground = match tone {
528        DiffTone::Added => context.theme.addition,
529        DiffTone::Removed => context.theme.deletion,
530        DiffTone::Context | DiffTone::Meta => context.theme.gutter,
531    };
532    let mut spans = vec![Span::styled(
533        gutter,
534        Style::new().fg(gutter_foreground).bg(background),
535    )];
536    spans.extend(highlighted_spans(&cell.text, &highlights, background));
537    Paragraph::new(Line::from(spans)).render(area, buffer);
538}
539
540fn highlighted_spans<'a>(
541    source: &'a str,
542    highlights: &[HighlightSpan],
543    background: ratatui::style::Color,
544) -> Vec<Span<'a>> {
545    if highlights.is_empty() {
546        return vec![Span::styled(source, Style::new().bg(background))];
547    }
548    let plain = Style::new().bg(background);
549    let mut spans = Vec::new();
550    let mut offset = 0;
551    for highlight in highlights {
552        let start = highlight.range.start.min(source.len());
553        let end = highlight.range.end.min(source.len());
554        if start > offset && source.is_char_boundary(offset) && source.is_char_boundary(start) {
555            spans.push(Span::styled(&source[offset..start], plain));
556        }
557        if end > start && source.is_char_boundary(start) && source.is_char_boundary(end) {
558            spans.push(Span::styled(
559                &source[start..end],
560                syntax_style(highlight.foreground, highlight.font_style, background),
561            ));
562            offset = end;
563        }
564    }
565    if offset < source.len() && source.is_char_boundary(offset) {
566        spans.push(Span::styled(&source[offset..], plain));
567    }
568    spans
569}
570
571fn render_footer(
572    area: Rect,
573    buffer: &mut Buffer,
574    state: &mut DiffReviewState,
575    theme: &RatatuiTheme,
576) {
577    if area.is_empty() {
578        return;
579    }
580    if let Some(prompt) = &state.repository_prompt {
581        match prompt {
582            RepositoryPrompt::Commit { message } => {
583                let prefix = "commit › ";
584                Paragraph::new(format!("{prefix}{message}"))
585                    .style(Style::new().fg(theme.ui.text))
586                    .render(area, buffer);
587                let x = area
588                    .x
589                    .saturating_add(u16::try_from(prefix.len() + message.len()).unwrap_or(u16::MAX))
590                    .min(area.right().saturating_sub(1));
591                state.cursor_position = Some(Position::new(x, area.y));
592            }
593            RepositoryPrompt::Discard { path, status } => {
594                Paragraph::new(format!(
595                    "Discard all staged and unstaged changes to {path} ({status:?})? [y/N]"
596                ))
597                .style(Style::new().fg(theme.deletion))
598                .render(area, buffer);
599            }
600        }
601        return;
602    }
603    if let Some(message) = state.repository_error() {
604        EmptyState::new(message, NoticeTone::Error, theme).render(area, buffer);
605        return;
606    }
607    let hint = if matches!(state.repository_status, RepositoryOperationStatus::Pending) {
608        "Git operation in progress…"
609    } else if state.session.draft().is_some() {
610        "[Enter] save  [Shift-Enter] newline  [Esc] cancel"
611    } else if state.focus == FocusPane::Files {
612        "[j/k] entry  [h/l] fold/open  [S] scope  [t] theme  [?] help"
613    } else if state.layout().is_split() {
614        "[j/k] line  [←/→] side  [o/O] context  [f] full file  [c] comment  [S] scope  [?] help"
615    } else {
616        "[j/k] line  [o/O] context  [f] full file  [c] comment  [s] submit  [S] scope  [h] files"
617    };
618    let review = state.review();
619    let outdated = review.outdated_count();
620    let status = format!(
621        "  {} comment{}{}",
622        review.len(),
623        if review.len() == 1 { "" } else { "s" },
624        if outdated == 0 {
625            String::new()
626        } else {
627            format!(" ({outdated} outdated)")
628        }
629    );
630    ActionBar::new(
631        Line::from(vec![
632            Span::styled(hint, Style::new().fg(theme.ui.text_muted)),
633            Span::styled(status, Style::new().fg(theme.ui.accent)),
634        ]),
635        theme,
636    )
637    .render(area, buffer);
638}
639
640fn render_help(area: Rect, buffer: &mut Buffer, theme: &RatatuiTheme) {
641    let content = Modal::new("Review shortcuts", theme)
642        .hint("? / Esc to close")
643        .render(area, buffer);
644    render_modal_text(
645        content,
646        buffer,
647        "Navigation\n  j/k or arrows   move selection\n  h/l             pane or fold/open\n  Tab             change pane\n  ←/→ in split    change column\n  PgUp/PgDn       move a page\n  o/Enter          expand context\n  O                expand all context\n  f                toggle full-file view\n\nGit\n  Space            stage/unstage file or directory\n  a/A              stage/unstage all\n  C/d              commit/discard file\n  S                cycle scope (unstaged/staged/both)\n\nReview\n  c/e/x            add/edit/delete comment\n  s/y              submit/copy review\n  t                select theme\n  Esc              cancel or close",
648        theme,
649    );
650}