1use 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#[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 #[must_use]
47 pub fn new() -> Self {
48 Self {
49 title: "Diff Review".to_owned(),
50 borders: true,
51 }
52 }
53
54 #[must_use]
56 pub fn title(mut self, title: impl Into<String>) -> Self {
57 self.title = title.into();
58 self
59 }
60
61 #[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}