use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use crate::git::{DiffKind, DiffLine};
use crate::highlight::{CodeHighlighter, Syntax};
const ADD_BG: Color = Color::Rgb(22, 42, 28);
const DEL_BG: Color = Color::Rgb(48, 26, 28);
pub struct DiffRender {
pub rows: Vec<Line<'static>>,
pub to_code: Vec<Option<usize>>,
pub hunk_rows: Vec<usize>,
pub single_column: bool,
split: Option<Vec<SplitRow>>,
split_hunk_rows: Option<Vec<usize>>,
raw: Vec<DiffLine>,
syntax: Syntax,
}
pub struct SplitRow {
pub left: Line<'static>,
pub right: Line<'static>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LineMark {
None,
Added,
Modified,
DeletedAbove,
}
impl DiffRender {
pub fn line_marks(&self, total: usize) -> Vec<LineMark> {
let mut marks = vec![LineMark::None; total];
let mut pending_del = 0usize; let set = |marks: &mut [LineMark], lineno: Option<u32>, m: LineMark| {
if let Some(n) = lineno {
let idx = (n as usize).saturating_sub(1);
if idx < marks.len() && marks[idx] == LineMark::None {
marks[idx] = m;
}
}
};
for dl in &self.raw {
match dl.kind {
DiffKind::Hunk => pending_del = 0,
DiffKind::Del => pending_del += 1,
DiffKind::Add => {
let m = if pending_del > 0 {
LineMark::Modified } else {
LineMark::Added
};
if let Some(n) = dl.new_lineno {
let idx = (n as usize).saturating_sub(1);
if idx < marks.len() {
marks[idx] = m;
}
}
pending_del = pending_del.saturating_sub(1);
}
DiffKind::Context => {
if pending_del > 0 {
set(&mut marks, dl.new_lineno, LineMark::DeletedAbove);
pending_del = 0;
}
}
}
}
if pending_del > 0 && total > 0 && marks[total - 1] == LineMark::None {
marks[total - 1] = LineMark::DeletedAbove;
}
marks
}
pub fn ensure_split(
&mut self,
code_lines: &[Line<'static>],
highlighter: &mut CodeHighlighter,
) {
if self.split.is_some() {
return;
}
let (split, hunks) = build_split(&self.raw, code_lines, highlighter, self.syntax);
self.split = Some(split);
self.split_hunk_rows = Some(hunks);
}
pub fn split_rows(&self) -> Option<&[SplitRow]> {
self.split.as_deref()
}
pub fn row_count(&self, split: bool) -> usize {
if split {
self.split.as_ref().map_or(0, |s| s.len())
} else {
self.rows.len()
}
}
pub fn hunk_rows_for(&self, split: bool) -> &[usize] {
if split {
self.split_hunk_rows.as_deref().unwrap_or(&[])
} else {
&self.hunk_rows
}
}
}
pub fn build(
diff: &[DiffLine],
code_lines: &[Line<'static>],
highlighter: &mut CodeHighlighter,
syntax: Syntax,
) -> DiffRender {
let mut rows = Vec::with_capacity(diff.len());
let mut to_code = Vec::with_capacity(diff.len());
let mut hunk_rows = Vec::new();
for dl in diff {
match dl.kind {
DiffKind::Hunk => {
hunk_rows.push(rows.len());
rows.push(Line::styled(
dl.content.clone(),
Style::default().fg(Color::Cyan),
));
to_code.push(None);
}
DiffKind::Context | DiffKind::Add => {
let bg = (dl.kind == DiffKind::Add).then_some(ADD_BG);
let sign = if dl.kind == DiffKind::Add { '+' } else { ' ' };
let code_idx = dl.new_lineno.map(|n| (n as usize).saturating_sub(1));
let mut spans = vec![gutter(sign, dl.new_lineno, bg)];
if let Some(cl) = code_idx.and_then(|i| code_lines.get(i)) {
for s in &cl.spans {
let style = match bg {
Some(bg) => s.style.bg(bg),
None => s.style,
};
spans.push(Span::styled(s.content.clone(), style));
}
}
rows.push(styled_line(spans, bg));
to_code.push(code_idx);
}
DiffKind::Del => {
let mut spans = vec![gutter('-', None, Some(DEL_BG))];
let hl = highlighter.highlight(syntax, &dl.content);
if let Some(first) = hl.first() {
for s in &first.spans {
spans.push(Span::styled(s.content.clone(), s.style.bg(DEL_BG)));
}
}
rows.push(styled_line(spans, Some(DEL_BG)));
to_code.push(None);
}
}
}
DiffRender {
rows,
to_code,
hunk_rows,
single_column: is_whole_file_change(diff),
split: None,
split_hunk_rows: None,
raw: diff.to_vec(),
syntax,
}
}
fn is_whole_file_change(diff: &[DiffLine]) -> bool {
let (mut add, mut del, mut ctx) = (false, false, false);
for dl in diff {
match dl.kind {
DiffKind::Add => add = true,
DiffKind::Del => del = true,
DiffKind::Context => ctx = true,
DiffKind::Hunk => {}
}
}
!ctx && (add ^ del)
}
fn build_split(
diff: &[DiffLine],
code_lines: &[Line<'static>],
highlighter: &mut CodeHighlighter,
syntax: Syntax,
) -> (Vec<SplitRow>, Vec<usize>) {
let mut split: Vec<SplitRow> = Vec::new();
let mut hunk_rows: Vec<usize> = Vec::new();
let mut pdel: Vec<&DiffLine> = Vec::new();
let mut padd: Vec<&DiffLine> = Vec::new();
for dl in diff {
match dl.kind {
DiffKind::Del => pdel.push(dl),
DiffKind::Add => padd.push(dl),
DiffKind::Hunk => {
drain_changes(&mut split, &mut pdel, &mut padd, code_lines, highlighter, syntax);
hunk_rows.push(split.len());
split.push(SplitRow {
left: Line::styled(dl.content.clone(), Style::default().fg(Color::Cyan)),
right: Line::from(""),
});
}
DiffKind::Context => {
drain_changes(&mut split, &mut pdel, &mut padd, code_lines, highlighter, syntax);
let content = code_line_for(dl.new_lineno, code_lines);
split.push(SplitRow {
left: side_line(dl.old_lineno, ' ', None, content),
right: side_line(dl.new_lineno, ' ', None, content),
});
}
}
}
drain_changes(&mut split, &mut pdel, &mut padd, code_lines, highlighter, syntax);
(split, hunk_rows)
}
fn drain_changes(
split: &mut Vec<SplitRow>,
pdel: &mut Vec<&DiffLine>,
padd: &mut Vec<&DiffLine>,
code_lines: &[Line<'static>],
highlighter: &mut CodeHighlighter,
syntax: Syntax,
) {
let n = pdel.len().max(padd.len());
for i in 0..n {
let left = match pdel.get(i) {
Some(dl) => {
let hl = highlighter.highlight(syntax, &dl.content);
del_line(dl.old_lineno, hl.first())
}
None => Line::from(""),
};
let right = match padd.get(i) {
Some(dl) => side_line(
dl.new_lineno,
'+',
Some(ADD_BG),
code_line_for(dl.new_lineno, code_lines),
),
None => Line::from(""),
};
split.push(SplitRow { left, right });
}
pdel.clear();
padd.clear();
}
fn code_line_for<'a>(lineno: Option<u32>, code_lines: &'a [Line<'static>]) -> Option<&'a Line<'static>> {
lineno.and_then(|n| code_lines.get((n as usize).saturating_sub(1)))
}
fn side_line(
lineno: Option<u32>,
sign: char,
bg: Option<Color>,
content: Option<&Line<'static>>,
) -> Line<'static> {
let mut spans = vec![gutter(sign, lineno, bg)];
if let Some(cl) = content {
for s in &cl.spans {
let style = match bg {
Some(bg) => s.style.bg(bg),
None => s.style,
};
spans.push(Span::styled(s.content.clone(), style));
}
}
styled_line(spans, bg)
}
fn del_line(lineno: Option<u32>, hl_first: Option<&Line<'static>>) -> Line<'static> {
let mut spans = vec![gutter('-', lineno, Some(DEL_BG))];
if let Some(first) = hl_first {
for s in &first.spans {
spans.push(Span::styled(s.content.clone(), s.style.bg(DEL_BG)));
}
}
styled_line(spans, Some(DEL_BG))
}
fn gutter(sign: char, lineno: Option<u32>, bg: Option<Color>) -> Span<'static> {
let n = lineno.map(|n| n.to_string()).unwrap_or_default();
let mut style = Style::default().fg(Color::DarkGray);
if let Some(bg) = bg {
style = style.bg(bg);
}
Span::styled(format!("{n:>4}{sign} "), style)
}
fn styled_line(spans: Vec<Span<'static>>, bg: Option<Color>) -> Line<'static> {
let line = Line::from(spans);
match bg {
Some(bg) => line.style(Style::default().bg(bg)),
None => line,
}
}
#[cfg(test)]
mod tests {
use super::*;
use inkjet::Language;
fn dl(kind: DiffKind, old: Option<u32>, new: Option<u32>, content: &str) -> DiffLine {
DiffLine {
kind,
old_lineno: old,
new_lineno: new,
content: content.to_string(),
}
}
#[test]
fn split_pairs_changes_and_records_hunks() {
let mut h = CodeHighlighter::new();
let code_lines = vec![Line::from("ctx"), Line::from("new1")];
let diff = vec![
dl(DiffKind::Hunk, None, None, "@@ -1,2 +1,2 @@"),
dl(DiffKind::Context, Some(1), Some(1), "ctx"),
dl(DiffKind::Del, Some(2), None, "old"),
dl(DiffKind::Add, None, Some(2), "new1"),
];
let (split, hunks) = build_split(&diff, &code_lines, &mut h, Syntax::Lang(Language::Plaintext));
assert_eq!(split.len(), 3, "split rows");
assert_eq!(hunks, vec![0]);
}
#[test]
fn split_is_lazy_until_ensured() {
let mut h = CodeHighlighter::new();
let code = vec![Line::from("ctx"), Line::from("new1")];
let diff = vec![
dl(DiffKind::Context, Some(1), Some(1), "ctx"),
dl(DiffKind::Del, Some(2), None, "old"),
dl(DiffKind::Add, None, Some(2), "new1"),
];
let mut r = build(&diff, &code, &mut h, Syntax::Lang(Language::Plaintext));
assert!(r.split_rows().is_none(), "split must be lazy");
assert_eq!(r.row_count(true), 0);
r.ensure_split(&code, &mut h);
assert!(r.split_rows().is_some());
assert_eq!(r.row_count(true), r.split_rows().unwrap().len());
}
#[test]
fn line_marks_classify_add_modify_delete() {
let mut h = CodeHighlighter::new();
let code = vec![
Line::from("ctx"),
Line::from("added"),
Line::from("modified"),
Line::from("ctx2"),
];
let diff = vec![
dl(DiffKind::Context, Some(1), Some(1), "ctx"),
dl(DiffKind::Add, None, Some(2), "added"), dl(DiffKind::Del, Some(2), None, "old"),
dl(DiffKind::Add, None, Some(3), "modified"), dl(DiffKind::Del, Some(3), None, "removed"), dl(DiffKind::Context, Some(4), Some(4), "ctx2"),
];
let r = build(&diff, &code, &mut h, Syntax::Lang(Language::Plaintext));
let marks = r.line_marks(4);
assert_eq!(marks[0], LineMark::None);
assert_eq!(marks[1], LineMark::Added);
assert_eq!(marks[2], LineMark::Modified);
assert_eq!(marks[3], LineMark::DeletedAbove);
}
#[test]
fn single_column_for_new_and_deleted_files() {
let mut h = CodeHighlighter::new();
let code = vec![Line::from("x"), Line::from("y")];
let new_file = vec![
dl(DiffKind::Hunk, None, None, "@@ -0,0 +1,2 @@"),
dl(DiffKind::Add, None, Some(1), "x"),
dl(DiffKind::Add, None, Some(2), "y"),
];
assert!(build(&new_file, &code, &mut h, Syntax::Lang(Language::Plaintext)).single_column);
let del_file = vec![dl(DiffKind::Del, Some(1), None, "x")];
assert!(build(&del_file, &code, &mut h, Syntax::Lang(Language::Plaintext)).single_column);
let modified = vec![
dl(DiffKind::Context, Some(1), Some(1), "x"),
dl(DiffKind::Del, Some(2), None, "old"),
dl(DiffKind::Add, None, Some(2), "y"),
];
assert!(!build(&modified, &code, &mut h, Syntax::Lang(Language::Plaintext)).single_column);
}
#[test]
fn split_pads_unequal_del_add() {
let mut h = CodeHighlighter::new();
let code_lines = vec![Line::from("a"), Line::from("b")];
let diff = vec![
dl(DiffKind::Del, Some(1), None, "d1"),
dl(DiffKind::Del, Some(2), None, "d2"),
dl(DiffKind::Add, None, Some(1), "a"),
];
let (split, _) = build_split(&diff, &code_lines, &mut h, Syntax::Lang(Language::Plaintext));
assert_eq!(split.len(), 2, "padded to max(del,add)");
}
}