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