use ratatui::{
buffer::Buffer as TuiBuf,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::StatefulWidget,
};
use crate::config::{Action, Theme};
use crate::diff::hunk::InlineSide;
use crate::diff::layout::{
decision_line_text, line_marker, line_text, DiffLineSource, DiffVisualLine,
};
use crate::diff::{Decision, DiffState};
use crate::input::diff_hint;
use crate::ui::line_render::render_line_from_visual;
#[derive(Debug, Default)]
pub struct DiffViewState {
pub image_snapshots: Vec<crate::ui::ImageLayoutSnapshot>,
pub image_snapshots_key: Option<(usize, Rect, u64)>,
}
pub struct DiffView<'a> {
pub diff: &'a DiffState,
pub theme: &'a Theme,
pub scroll: usize,
}
impl<'a> StatefulWidget for DiffView<'a> {
type State = DiffViewState;
fn render(self, area: Rect, buf: &mut TuiBuf, _view_state: &mut Self::State) {
if area.height == 0 || area.width == 0 {
return;
}
let width = area.width as usize;
let scroll = self.scroll;
self.diff.with_layout(width, |lines, rc| {
let (start_idx, mut skip_first_subrow) = rc.find_visual_row(scroll);
let mut idx = start_idx;
let mut visual_y: u16 = 0;
while idx < lines.len() && visual_y < area.height {
let dvl = &lines[idx];
let line = build_line(self.diff, self.theme, dvl);
let wrap = dvl.source != DiffLineSource::Decision;
let painted =
render_line_from_visual(&line, area, buf, visual_y, wrap, skip_first_subrow);
skip_first_subrow = 0;
if painted == 0 {
break;
}
visual_y = visual_y.saturating_add(painted);
idx += 1;
}
});
}
}
fn build_line(diff: &DiffState, theme: &Theme, dvl: &DiffVisualLine) -> Line<'static> {
if dvl.source == DiffLineSource::ContextRendered {
return diff
.parsed_new
.as_ref()
.and_then(|p| p.lines.get(dvl.rope_line))
.cloned()
.unwrap_or_default();
}
if dvl.source == DiffLineSource::Decision {
let focused = dvl
.hunk_idx
.is_some_and(|hi| diff.hunks[hi].id == diff.focused_id);
let dec = dvl
.hunk_idx
.and_then(|hi| diff.decisions.get(hi).copied())
.unwrap_or(Decision::Pending);
let style = if focused {
let base = match dec {
Decision::Pending => theme.diff_decision_pending,
Decision::Accepted => theme.diff_decision_accepted,
Decision::Rejected => theme.diff_decision_rejected,
};
base.add_modifier(Modifier::BOLD)
} else {
match dec {
Decision::Pending => theme.diff_decision_unfocused,
Decision::Accepted | Decision::Rejected => {
let hue = if dec == Decision::Accepted {
theme.diff_decision_accepted.fg
} else {
theme.diff_decision_rejected.fg
};
let mut s = theme.diff_decision_unfocused.add_modifier(Modifier::DIM);
if let Some(color) = hue {
s = s.fg(color);
}
s
}
}
};
let position = dvl.hunk_idx.map_or(0, |hi| hi + 1);
let total = diff.hunks.len();
let counter_style = Style::default()
.add_modifier(Modifier::DIM)
.remove_modifier(Modifier::BOLD);
let mut spans = decision_divider_spans(theme, dec, focused, diff.read_only);
let counter = if spans.is_empty() {
format!("({position}/{total})")
} else {
format!(" ({position}/{total})")
};
spans.push(Span::styled(counter, counter_style));
return Line::from(spans).style(style);
}
let text = line_text(diff, dvl);
let focused = dvl
.hunk_idx
.is_some_and(|hi| diff.hunks[hi].id == diff.focused_id);
let line_style = match dvl.source {
DiffLineSource::OldDelete if dvl.hunk_idx.is_some() => {
if focused {
theme.diff_delete_line
} else {
theme.diff_delete_line_unfocused
}
}
DiffLineSource::NewAdd if dvl.hunk_idx.is_some() => {
if focused {
theme.diff_add_line
} else {
theme.diff_add_line_unfocused
}
}
_ => Style::default(),
};
let mut body_spans: Vec<Span<'static>> = Vec::new();
let inline_bg = match dvl.source {
DiffLineSource::OldDelete if focused => Some(theme.diff_delete_inline),
DiffLineSource::OldDelete => Some(theme.diff_delete_inline_unfocused),
DiffLineSource::NewAdd if focused => Some(theme.diff_add_inline),
DiffLineSource::NewAdd => Some(theme.diff_add_inline_unfocused),
_ => None,
};
if let (Some(hi), Some(inline_style)) = (dvl.hunk_idx, inline_bg) {
let h = &diff.hunks[hi];
let line_in_hunk = match dvl.source {
DiffLineSource::OldDelete => dvl.rope_line.saturating_sub(h.old_lines.start),
DiffLineSource::NewAdd => dvl.rope_line.saturating_sub(h.new_lines.start),
_ => 0,
};
let want_side = match dvl.source {
DiffLineSource::OldDelete => InlineSide::Old,
DiffLineSource::NewAdd => InlineSide::New,
_ => InlineSide::New,
};
let mut cursor = 0usize;
let chars: Vec<char> = text.chars().collect();
for span in h
.inline
.iter()
.filter(|s| s.line_in_hunk == line_in_hunk && s.side == want_side)
{
let s = span.chars.start.min(chars.len());
let e = span.chars.end.min(chars.len());
if s >= e {
continue;
}
if cursor < s {
let body: String = chars[cursor..s].iter().collect();
body_spans.push(Span::raw(body));
}
let body: String = chars[s..e].iter().collect();
body_spans.push(Span::styled(body, inline_style));
cursor = e;
}
if cursor < chars.len() {
let body: String = chars[cursor..].iter().collect();
body_spans.push(Span::raw(body));
}
if body_spans.is_empty() {
body_spans.push(Span::raw(text.to_owned()));
}
} else {
body_spans.push(Span::raw(text.to_owned()));
}
let mut spans: Vec<Span<'static>> = Vec::with_capacity(body_spans.len() + 1);
spans.push(Span::raw(line_marker(dvl.source)));
spans.extend(body_spans);
Line::from(spans).style(line_style)
}
fn prompt_chip_style(theme: &Theme, accept: bool) -> Style {
let wash = if accept {
theme.diff_add_line
} else {
theme.diff_delete_line
};
let mut chip = Style::default()
.add_modifier(wash.add_modifier)
.add_modifier(Modifier::BOLD)
.fg(theme.normal.fg.unwrap_or(Color::Reset));
if let Some(bg) = wash.bg {
chip = chip.bg(bg);
}
chip
}
fn decision_divider_spans(
theme: &Theme,
decision: Decision,
focused: bool,
read_only: bool,
) -> Vec<Span<'static>> {
if read_only {
return if focused {
vec![Span::raw(">")]
} else {
Vec::new()
};
}
let base = decision_line_text(decision);
if !focused {
return vec![Span::raw(base.to_owned())];
}
match decision {
Decision::Pending => vec![
Span::raw(format!("> {base} ")),
Span::styled(
format!(" Reject [{}] ", diff_hint(&Action::DiffRejectHunk)),
prompt_chip_style(theme, false),
),
Span::raw(" "),
Span::styled(
format!(" Accept [{}] ", diff_hint(&Action::DiffAcceptHunk)),
prompt_chip_style(theme, true),
),
],
Decision::Accepted | Decision::Rejected => vec![Span::raw(format!("> {base}"))],
}
}