use ratatui::{
Frame,
layout::{Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, Paragraph},
};
use crate::tui::app::{App, DiffLine, DiffLineKind};
use super::super::ui::{C_WHITE, C_SUBTLE, C_DIM, C_GREEN, C_RED};
const BG_ADDED: Color = Color::Rgb(20, 50, 20);
const BG_REMOVED: Color = Color::Rgb(50, 15, 15);
const BG_ADDED_HL: Color = Color::Rgb(30, 90, 30);
const BG_REMOVED_HL: Color = Color::Rgb(90, 25, 25);
const C_HUNK: Color = Color::Rgb(100, 160, 220);
const BG_HUNK: Color = Color::Rgb(15, 25, 40);
fn lcs(a: &[char], b: &[char]) -> Vec<Vec<usize>> {
let (m, n) = (a.len(), b.len());
let mut dp = vec![vec![0usize; n + 1]; m + 1];
for i in 1..=m {
for j in 1..=n {
dp[i][j] = if a[i-1] == b[j-1] { dp[i-1][j-1] + 1 } else { dp[i-1][j].max(dp[i][j-1]) };
}
}
dp
}
fn char_diff(removed: &str, added: &str) -> (Vec<bool>, Vec<bool>) {
let a: Vec<char> = removed.chars().collect();
let b: Vec<char> = added.chars().collect();
let dp = lcs(&a, &b);
let mut hl_a = vec![true; a.len()];
let mut hl_b = vec![true; b.len()];
let mut i = a.len();
let mut j = b.len();
while i > 0 && j > 0 {
if a[i-1] == b[j-1] {
hl_a[i-1] = false;
hl_b[j-1] = false;
i -= 1; j -= 1;
} else if dp[i-1][j] >= dp[i][j-1] {
i -= 1;
} else {
j -= 1;
}
}
(hl_a, hl_b)
}
fn line_no_span(no: Option<u32>) -> Span<'static> {
match no {
Some(n) => Span::styled(format!("{:>4} ", n), Style::default().fg(C_DIM)),
None => Span::styled(" ", Style::default().fg(C_DIM)),
}
}
fn render_plain_line(line: &DiffLine, fg: Color, bg: Color, prefix: &'static str) -> ListItem<'static> {
let content: String = line.content.clone();
ListItem::new(Line::from(vec![
line_no_span(line.line_no),
Span::styled(prefix, Style::default().fg(fg).bg(bg)),
Span::styled(content, Style::default().fg(fg).bg(bg)),
]))
}
fn render_highlighted_pair(
removed: &DiffLine,
added: &DiffLine,
) -> (ListItem<'static>, ListItem<'static>) {
let (hl_rem, hl_add) = char_diff(&removed.content, &added.content);
let rem_spans = build_highlighted_spans(&removed.content, &hl_rem, C_RED, BG_REMOVED, BG_REMOVED_HL);
let add_spans = build_highlighted_spans(&added.content, &hl_add, C_GREEN, BG_ADDED, BG_ADDED_HL);
let mut rem_line = vec![line_no_span(removed.line_no), Span::styled("- ", Style::default().fg(C_RED).bg(BG_REMOVED))];
rem_line.extend(rem_spans);
let mut add_line = vec![line_no_span(added.line_no), Span::styled("+ ", Style::default().fg(C_GREEN).bg(BG_ADDED))];
add_line.extend(add_spans);
(ListItem::new(Line::from(rem_line)), ListItem::new(Line::from(add_line)))
}
fn build_highlighted_spans(text: &str, highlights: &[bool], fg: Color, bg: Color, bg_hl: Color) -> Vec<Span<'static>> {
let chars: Vec<char> = text.chars().collect();
let mut spans = vec![];
let mut i = 0;
while i < chars.len() {
let hl = highlights[i];
let mut j = i + 1;
while j < chars.len() && highlights[j] == hl { j += 1; }
let chunk: String = chars[i..j].iter().collect();
spans.push(Span::styled(chunk, Style::default().fg(fg).bg(if hl { bg_hl } else { bg })));
i = j;
}
spans
}
pub fn render(f: &mut Frame, app: &App) {
let area = f.area();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(1),
Constraint::Length(1),
])
.split(area);
let bc = app.brand_color();
let inner_w = chunks[0].width.saturating_sub(2) as usize;
let title_str = "⛩ diff";
let file_str = app.diff.title.clone();
let pad = inner_w.saturating_sub(title_str.chars().count() + file_str.chars().count() + 3);
f.render_widget(
Paragraph::new(Line::from(vec![
Span::raw(" "),
Span::styled(title_str, Style::default().fg(bc).add_modifier(Modifier::BOLD)),
Span::raw(" ".repeat(pad)),
Span::styled(file_str, Style::default().fg(C_SUBTLE)),
Span::raw(" "),
]))
.block(Block::default()
.borders(Borders::ALL)
.border_type(app.border_type())
.border_style(Style::default().fg(bc))),
chunks[0],
);
let lines = &app.diff.lines;
let mut items: Vec<ListItem> = vec![];
let mut i = 0;
while i < lines.len() {
let line = &lines[i];
match line.kind {
DiffLineKind::Header => {
items.push(ListItem::new(Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled(line.content.clone(), Style::default().fg(bc).add_modifier(Modifier::BOLD)),
])));
i += 1;
}
DiffLineKind::HunkHeader => {
items.push(ListItem::new(Line::from(vec![
Span::styled(" ", Style::default().bg(BG_HUNK)),
Span::styled(line.content.clone(), Style::default().fg(C_HUNK).bg(BG_HUNK).add_modifier(Modifier::BOLD)),
])).style(Style::default().bg(BG_HUNK)));
i += 1;
}
DiffLineKind::Removed => {
if i + 1 < lines.len() && lines[i + 1].kind == DiffLineKind::Added {
let (rem_item, add_item) = render_highlighted_pair(&lines[i], &lines[i + 1]);
items.push(rem_item);
items.push(add_item);
i += 2;
} else {
items.push(render_plain_line(line, C_RED, BG_REMOVED, "- "));
i += 1;
}
}
DiffLineKind::Added => {
items.push(render_plain_line(line, C_GREEN, BG_ADDED, "+ "));
i += 1;
}
DiffLineKind::Context => {
items.push(ListItem::new(Line::from(vec![
line_no_span(line.line_no),
Span::styled(" ", Style::default()),
Span::styled(line.content.clone(), Style::default().fg(C_SUBTLE)),
])));
i += 1;
}
}
}
let visible: Vec<ListItem> = items.into_iter().skip(app.diff.scroll).collect();
f.render_widget(
List::new(visible)
.block(Block::default()
.borders(Borders::ALL)
.border_type(app.border_type())
.border_style(Style::default().fg(bc))),
chunks[1],
);
let total = app.diff.lines.len();
let pct = if total == 0 { 0 } else { (app.diff.scroll * 100) / total.max(1) };
f.render_widget(
Paragraph::new(Line::from(vec![
Span::raw(" "),
Span::styled("[↑↓/jk]", Style::default().fg(bc)),
Span::styled(" scroll ", Style::default().fg(C_SUBTLE)),
Span::styled("[Esc]", Style::default().fg(bc)),
Span::styled(" back ", Style::default().fg(C_SUBTLE)),
Span::styled(format!("{}% {}/{} lines", pct, app.diff.scroll, total), Style::default().fg(C_DIM)),
])),
chunks[2],
);
}