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::{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#[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 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}