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