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