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::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                    },
359                );
360                visible_rows.extend((y..y + height).map(|screen_y| (screen_y, index)));
361            }
362            Some(PatchVisualRow::Annotation {
363                source,
364                annotation,
365                line,
366            }) => {
367                render_annotation_line(row_area, buffer, theme, annotation, line);
368                visible_rows.push((y, source));
369                if let Some(column) = annotation.cursor_column(line) {
370                    *cursor_position = Some(Position::new(
371                        area.x
372                            .saturating_add(column)
373                            .min(area.right().saturating_sub(1)),
374                        y,
375                    ));
376                }
377            }
378            None => break,
379        }
380        visual_index += usize::from(height);
381        y += height;
382    }
383    render_vertical_scrollbar(
384        track,
385        buffer,
386        visual_layout.len(),
387        usize::from(area.height),
388        *scroll,
389    );
390}
391
392fn file_stats(
393    session: &clankerdiff_core::ReviewSession,
394    row: &PresentedRow,
395) -> Option<(usize, usize)> {
396    (row.kind == RowKind::FileHeader)
397        .then(|| session.document().files.get(row.file_index))
398        .flatten()
399        .map(|file| (file.additions(), file.deletions()))
400}
401
402struct RowStyle {
403    selected: bool,
404    selected_side: DiffSide,
405    layout: clankerdiff_core::Layout,
406    file_stats: Option<(usize, usize)>,
407}
408
409struct CellContext<'a> {
410    tab_width: u16,
411    theme: &'a RatatuiTheme,
412    diff_theme: &'a ReviewTheme,
413    highlighter: &'a mut SyntaxHighlighter,
414    presentation: &'a clankerdiff_core::DiffPresentation,
415    row: &'a PresentedRow,
416    source: usize,
417    first_segment: usize,
418    patch_layout: &'a PatchContentLayout,
419}
420
421fn render_row(area: Rect, buffer: &mut Buffer, context: &mut CellContext<'_>, style: &RowStyle) {
422    let row = context.row;
423    match row.kind {
424        RowKind::FileHeader => {
425            let text = row.primary_cell().map_or("", |cell| cell.text.as_ref());
426            let (additions, deletions) = style.file_stats.unwrap_or_default();
427            Paragraph::new(Line::from(vec![
428                Span::styled(
429                    format!(" {text} "),
430                    Style::new()
431                        .fg(context.theme.ui.accent)
432                        .add_modifier(Modifier::BOLD),
433                ),
434                Span::styled(
435                    format!("+{additions} -{deletions}"),
436                    Style::new().fg(context.theme.ui.text_muted),
437                ),
438            ]))
439            .render(area, buffer);
440        }
441        RowKind::HunkHeader | RowKind::Meta => {
442            Paragraph::new(row.primary_cell().map_or("", |cell| cell.text.as_ref()))
443                .style(
444                    Style::new()
445                        .fg(context.theme.ui.text_muted)
446                        .bg(context.theme.ui.canvas),
447                )
448                .render(area, buffer);
449        }
450        RowKind::ExpandGap => {
451            let text = context.presentation.gap_info(context.source).map_or_else(
452                || " ⋯ unchanged lines".to_owned(),
453                |info| {
454                    let message = if info.unavailable.is_none() {
455                        format!("{} — o expand · O expand all", info.message())
456                    } else {
457                        info.message()
458                    };
459                    format!(" {message}")
460                },
461            );
462            Paragraph::new(text)
463                .style(
464                    Style::new()
465                        .fg(context.theme.ui.text_muted)
466                        .bg(if style.selected {
467                            context.theme.ui.surface_selected
468                        } else {
469                            context.theme.ui.canvas
470                        }),
471                )
472                .render(area, buffer);
473        }
474        RowKind::Code | RowKind::ExpandedContext if style.layout.is_split() => {
475            let geometry = context.patch_layout.geometry();
476            let separator_width = u16::from(area.width > 0);
477            let [left, separator, right] = Layout::horizontal([
478                Constraint::Length(geometry.left.width),
479                Constraint::Length(separator_width),
480                Constraint::Length(geometry.right.width),
481            ])
482            .areas(area);
483            let focused = |side| style.selected && style.selected_side == side;
484            render_cell(
485                left,
486                buffer,
487                context,
488                row.left.as_ref(),
489                focused(DiffSide::Old),
490                DiffSide::Old,
491            );
492            render_separator(separator, buffer, context.theme);
493            render_cell(
494                right,
495                buffer,
496                context,
497                row.right.as_ref(),
498                focused(DiffSide::New),
499                DiffSide::New,
500            );
501        }
502        RowKind::Code | RowKind::ExpandedContext => {
503            render_cell(
504                area,
505                buffer,
506                context,
507                row.primary_cell(),
508                style.selected,
509                DiffSide::New,
510            );
511        }
512    }
513}
514
515fn render_cell(
516    area: Rect,
517    buffer: &mut Buffer,
518    context: &mut CellContext<'_>,
519    cell: Option<&PresentedCell>,
520    selected: bool,
521    side: DiffSide,
522) {
523    let tone = cell.map_or(DiffTone::Context, |cell| cell.tone);
524    let (foreground, tone_background) = context.theme.tone(tone);
525    let background = if selected {
526        page_color(context.diff_theme, context.diff_theme.diff.selection)
527    } else {
528        tone_background
529    };
530    buffer.set_style(area, Style::new().fg(foreground).bg(background));
531    let Some(cell) = cell else {
532        return;
533    };
534    let Some(checkpoint) =
535        context
536            .patch_layout
537            .checkpoint(context.source, side, context.first_segment)
538    else {
539        return;
540    };
541    let geometry = match side {
542        DiffSide::Old => context.patch_layout.geometry().left,
543        DiffSide::New => context.patch_layout.geometry().right,
544    };
545    let highlights = crate::diff_preview::cell_highlights(
546        context.highlighter,
547        &context.diff_theme.syntax,
548        context.presentation,
549        context.row,
550        cell,
551    );
552    let source_spans = highlighted_spans(&cell.text, &highlights, background);
553    let lines = fit_spans_from(
554        source_spans,
555        FitOptions {
556            width: geometry.content_width,
557            wrap: true,
558            tab_width: usize::from(context.tab_width),
559            continuation: "",
560        },
561        checkpoint,
562    );
563    for (local_row, (line, _)) in lines.take(usize::from(area.height)).enumerate() {
564        let segment = context.first_segment + local_row;
565        let y = area.y + u16::try_from(local_row).unwrap_or(u16::MAX);
566        let number = if segment == 0 {
567            cell.line_number()
568                .map_or_else(String::new, |number| number.to_string())
569        } else {
570            "↪".to_owned()
571        };
572        let indicator = match tone {
573            DiffTone::Added | DiffTone::Removed => '▌',
574            DiffTone::Context | DiffTone::Meta => ' ',
575        };
576        let gutter = format!(
577            "{indicator}{number:>width$} ",
578            width = usize::from(geometry.gutter_width).saturating_sub(2),
579        );
580        let gutter_foreground = match tone {
581            DiffTone::Added => context.theme.addition,
582            DiffTone::Removed => context.theme.deletion,
583            DiffTone::Context | DiffTone::Meta => context.theme.gutter,
584        };
585        let gutter_foreground = if segment == 0 {
586            gutter_foreground
587        } else {
588            context.theme.ui.text_muted
589        };
590        let gutter_area = Rect::new(area.x, y, geometry.gutter_width.min(area.width), 1);
591        Paragraph::new(Span::styled(
592            gutter,
593            Style::new().fg(gutter_foreground).bg(background),
594        ))
595        .render(gutter_area, buffer);
596        let source_area = Rect::new(
597            area.x.saturating_add(gutter_area.width),
598            y,
599            area.width.saturating_sub(gutter_area.width),
600            1,
601        );
602        Paragraph::new(line).render(source_area, buffer);
603    }
604}
605
606fn highlighted_spans<'a>(
607    source: &'a str,
608    highlights: &[HighlightSpan],
609    background: ratatui::style::Color,
610) -> Vec<Span<'a>> {
611    if highlights.is_empty() {
612        return vec![Span::styled(source, Style::new().bg(background))];
613    }
614    let plain = Style::new().bg(background);
615    let mut spans = Vec::new();
616    let mut offset = 0;
617    for highlight in highlights {
618        let start = highlight.range.start.min(source.len());
619        let end = highlight.range.end.min(source.len());
620        if start > offset && source.is_char_boundary(offset) && source.is_char_boundary(start) {
621            spans.push(Span::styled(&source[offset..start], plain));
622        }
623        if end > start && source.is_char_boundary(start) && source.is_char_boundary(end) {
624            spans.push(Span::styled(
625                &source[start..end],
626                syntax_style(highlight.foreground, highlight.font_style, background),
627            ));
628            offset = end;
629        }
630    }
631    if offset < source.len() && source.is_char_boundary(offset) {
632        spans.push(Span::styled(&source[offset..], plain));
633    }
634    spans
635}
636
637fn render_footer(
638    area: Rect,
639    buffer: &mut Buffer,
640    state: &mut DiffReviewState,
641    theme: &RatatuiTheme,
642) {
643    if area.is_empty() {
644        return;
645    }
646    if let Some(prompt) = &state.repository_prompt {
647        match prompt {
648            RepositoryPrompt::Commit { message } => {
649                let prefix = "commit › ";
650                Paragraph::new(format!("{prefix}{message}"))
651                    .style(Style::new().fg(theme.ui.text))
652                    .render(area, buffer);
653                let x = area
654                    .x
655                    .saturating_add(u16::try_from(prefix.len() + message.len()).unwrap_or(u16::MAX))
656                    .min(area.right().saturating_sub(1));
657                state.cursor_position = Some(Position::new(x, area.y));
658            }
659            RepositoryPrompt::Discard { path, status } => {
660                Paragraph::new(format!(
661                    "Discard all staged and unstaged changes to {path} ({status:?})? [y/N]"
662                ))
663                .style(Style::new().fg(theme.deletion))
664                .render(area, buffer);
665            }
666        }
667        return;
668    }
669    if let Some(message) = state.repository_error() {
670        EmptyState::new(message, NoticeTone::Error, theme).render(area, buffer);
671        return;
672    }
673    let review = state.review();
674    let outdated = review.outdated_count();
675    let status = format!(
676        "  {} comment{}{}",
677        review.len(),
678        if review.len() == 1 { "" } else { "s" },
679        if outdated == 0 {
680            String::new()
681        } else {
682            format!(" ({outdated} outdated)")
683        }
684    );
685    let hint = if matches!(state.repository_status, RepositoryOperationStatus::Pending) {
686        "Git operation in progress…".to_owned()
687    } else if state.session.draft().is_some() {
688        "[Enter] save  [Shift-Enter] newline  [Esc] cancel".to_owned()
689    } else {
690        state.footer_hint(usize::from(area.width).saturating_sub(status.len()))
691    };
692    ActionBar::new(
693        Line::from(vec![
694            Span::styled(hint, Style::new().fg(theme.ui.text_muted)),
695            Span::styled(status, Style::new().fg(theme.ui.accent)),
696        ]),
697        theme,
698    )
699    .render(area, buffer);
700}
701
702fn render_help(area: Rect, buffer: &mut Buffer, state: &DiffReviewState, theme: &RatatuiTheme) {
703    let content = Modal::new("Review shortcuts", ModalSize::Medium, theme)
704        .hint("j/k scroll · ? / Esc close")
705        .render(area, buffer);
706    render_modal_text(
707        content,
708        buffer,
709        state
710            .help_bindings()
711            .skip(state.help_scroll)
712            .map(|binding| format!("{} ({:?})", binding.hint(), binding.scope))
713            .collect::<Vec<_>>()
714            .join("\n"),
715        theme,
716    );
717}