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