use crate::app::{App, BlameDisplay};
use crate::views::{expand_tabs_in_spans, wrap_count_for_spans, TAB_WIDTH};
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use ratatui::Frame;
use time::OffsetDateTime;
const BLAME_GUTTER_PERCENT: u16 = 32;
const CONTENT_GUTTER_WIDTH: u16 = 8;
const BLAME_BAR: &str = "▌";
pub fn render_blame(frame: &mut Frame, app: &mut App, area: Rect) {
if app.current_file_is_binary() {
super::render_binary_empty_state(frame, app, area);
return;
}
app.poll_blame_responses();
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(BLAME_GUTTER_PERCENT),
Constraint::Length(2),
Constraint::Min(0),
])
.split(area);
let blame_area = chunks[0];
let gap_area = chunks[1];
let content_area = chunks[2];
let visible_height = area.height as usize;
let wrap_width = content_area.width.saturating_sub(CONTENT_GUTTER_WIDTH) as usize;
if !app.line_wrap {
app.clamp_horizontal_scroll_cached(wrap_width);
}
if app.line_wrap {
app.handle_search_scroll_if_needed(visible_height, wrap_width);
} else {
app.ensure_active_visible_if_needed(visible_height, wrap_width);
}
let animation_frame = app.animation_frame();
let show_extent = app.stepping && !app.multi_diff.current_navigator().state().is_at_start();
app.multi_diff
.current_navigator()
.set_show_hunk_extent_while_stepping(show_extent);
let view_lines = app.current_view_with_frame(animation_frame);
let scroll_offset = app.render_scroll_offset();
if super::view_debug_enabled() {
super::maybe_log_view_debug(
app,
view_lines.as_ref(),
"blame",
visible_height,
wrap_width,
scroll_offset,
None,
);
}
let now = OffsetDateTime::now_utc().unix_timestamp();
let time_bucket = now / 60;
let text_width = blame_area.width.saturating_sub(2) as usize;
let file_index = app.multi_diff.selected_index;
let state = app.multi_diff.current_navigator().state().clone();
let cache_key = crate::app::BlameRenderKey {
file_index,
current_step: state.current_step,
current_hunk: state.current_hunk,
hunk_preview_mode: state.hunk_preview_mode,
preview_from_backward: state.preview_from_backward,
stepping: app.stepping,
line_wrap: app.line_wrap,
wrap_width,
blame_width: blame_area.width,
view_len: view_lines.len(),
window_start: app.view_window_start(),
animation_frame,
cache_rev: app.blame_cache_revision,
time_bucket,
};
let rebuild_cache = app
.blame_render_cache
.as_ref()
.map(|cache| cache.key != cache_key)
.unwrap_or(true);
if rebuild_cache {
let mut blame_keys: Vec<Option<String>> = Vec::with_capacity(view_lines.len());
let mut blame_texts: Vec<Option<String>> = Vec::with_capacity(view_lines.len());
let mut blame_displays: Vec<Option<BlameDisplay>> = Vec::with_capacity(view_lines.len());
for view_line in view_lines.iter() {
if let Some(display) = app.blame_display_for_view_line(view_line, now) {
blame_keys.push(Some(display.group_key.clone()));
blame_texts.push(Some(display.text.clone()));
blame_displays.push(Some(display));
} else {
blame_keys.push(None);
blame_texts.push(None);
blame_displays.push(None);
}
}
let mut display_texts: Vec<String> = vec![String::new(); view_lines.len()];
let mut extra_rows_after_line: Vec<usize> = vec![0; view_lines.len()];
let mut extra_texts_after_line: Vec<Vec<String>> = vec![Vec::new(); view_lines.len()];
let mut idx = 0usize;
while idx < view_lines.len() {
let key = match &blame_keys[idx] {
Some(key) => key.clone(),
None => {
idx += 1;
continue;
}
};
let mut end = idx + 1;
while end < view_lines.len() && blame_keys[end].as_deref() == Some(&key) {
end += 1;
}
let text = blame_texts[idx].clone().unwrap_or_default();
let wrapped = wrap_blame_text(&text, text_width);
let group_len = end - idx;
for offset in 0..group_len {
if let Some(segment) = wrapped.get(offset) {
display_texts[idx + offset] = segment.clone();
}
}
if wrapped.len() > group_len {
let extras: Vec<String> = wrapped[group_len..].to_vec();
let last_idx = end.saturating_sub(1);
extra_rows_after_line[last_idx] = extras.len();
extra_texts_after_line[last_idx] = extras;
}
idx = end;
}
let mut wrap_counts = Vec::with_capacity(view_lines.len());
if app.line_wrap && wrap_width > 0 {
for view_line in view_lines.iter() {
let mut content_spans = vec![Span::raw(view_line.content.clone())];
content_spans = expand_tabs_in_spans(&content_spans, TAB_WIDTH);
let wrap_count = wrap_count_for_spans(&content_spans, wrap_width);
wrap_counts.push(wrap_count.max(1));
}
} else {
wrap_counts.resize(view_lines.len(), 1);
}
let mut bar_colors: Vec<Option<Color>> = Vec::with_capacity(view_lines.len());
for (idx, view_line) in view_lines.iter().enumerate() {
let display = blame_displays[idx].as_ref();
let color = app.blame_bar_color_for_view_line(view_line, display);
bar_colors.push(color);
}
app.blame_render_cache = Some(crate::app::BlameRenderCache {
key: cache_key,
wrap_counts,
extra_rows_after_line,
extra_texts_after_line,
display_texts,
bar_colors,
});
}
let extra_rows_clone = app
.blame_render_cache
.as_ref()
.map(|cache| cache.extra_rows_after_line.clone())
.unwrap_or_default();
app.blame_extra_rows = Some(extra_rows_clone);
super::render_unified_pane(frame, app, content_area);
app.blame_extra_rows = None;
let cache = match app.blame_render_cache.as_ref() {
Some(cache) => cache,
None => return,
};
let extra_rows_after_line = cache.extra_rows_after_line.clone();
let wrap_counts = cache.wrap_counts.clone();
let scroll_offset = app.render_scroll_offset();
let mut blame_scroll_offset = scroll_offset;
if !app.line_wrap && scroll_offset > 0 {
let max_idx = scroll_offset.min(extra_rows_after_line.len());
let extra_before = extra_rows_after_line[..max_idx]
.iter()
.copied()
.sum::<usize>();
blame_scroll_offset = blame_scroll_offset.saturating_add(extra_before);
}
let visible_start = blame_scroll_offset;
let visible_end = visible_start + visible_height.saturating_sub(1);
let mut display_idx = 0usize;
let mut visible_flags = Vec::with_capacity(view_lines.len());
let mut visible_indices = Vec::new();
for (idx, _view_line) in view_lines.iter().enumerate() {
let wrap_count = wrap_counts.get(idx).copied().unwrap_or(1);
let extra_rows = extra_rows_after_line.get(idx).copied().unwrap_or(0);
let line_start = display_idx;
let line_end = display_idx
.saturating_add(wrap_count)
.saturating_add(extra_rows)
.saturating_sub(1);
let line_visible = line_end >= visible_start && line_start <= visible_end;
visible_flags.push(line_visible);
if line_visible {
visible_indices.push(idx);
}
display_idx = display_idx
.saturating_add(wrap_count)
.saturating_add(extra_rows);
}
app.prefetch_blame_for_view(&view_lines, &visible_indices, visible_height);
let cache = match app.blame_render_cache.as_ref() {
Some(cache) => cache,
None => return,
};
let extra_texts_after_line = &cache.extra_texts_after_line;
let display_texts = &cache.display_texts;
let bar_colors = &cache.bar_colors;
let mut blame_lines: Vec<Line> = Vec::new();
for (idx, _view_line) in view_lines.iter().enumerate() {
let wrap_count = wrap_counts.get(idx).copied().unwrap_or(1);
let line_visible = visible_flags[idx];
let bar_span = match bar_colors.get(idx).copied().flatten() {
Some(color) => Span::styled(BLAME_BAR, Style::default().fg(color)),
None => Span::styled(
BLAME_BAR,
Style::default()
.fg(app.theme.text_muted)
.add_modifier(Modifier::DIM),
),
};
let text_style = Style::default().fg(app.theme.text_muted);
if line_visible {
let text = display_texts.get(idx).cloned().unwrap_or_default();
blame_lines.push(Line::from(vec![
bar_span.clone(),
Span::raw(" "),
Span::styled(text, text_style),
]));
} else {
blame_lines.push(Line::from(Span::raw(" ")));
}
if app.line_wrap && wrap_count > 1 {
for _ in 1..wrap_count {
if line_visible {
blame_lines.push(Line::from(vec![
bar_span.clone(),
Span::raw(" "),
Span::raw(""),
]));
} else {
blame_lines.push(Line::from(Span::raw(" ")));
}
}
}
let extra_rows = extra_rows_after_line.get(idx).copied().unwrap_or(0);
if extra_rows > 0 {
if line_visible {
for extra_text in &extra_texts_after_line[idx] {
blame_lines.push(Line::from(vec![
bar_span.clone(),
Span::raw(" "),
Span::styled(extra_text.clone(), text_style),
]));
}
} else {
for _ in 0..extra_rows {
blame_lines.push(Line::from(Span::raw(" ")));
}
}
}
}
let mut blame_paragraph = Paragraph::new(blame_lines);
if blame_scroll_offset > 0 {
blame_paragraph = blame_paragraph.scroll((blame_scroll_offset as u16, 0));
}
if let Some(bg) = app.theme.background {
blame_paragraph = blame_paragraph.style(Style::default().bg(bg));
}
frame.render_widget(blame_paragraph, blame_area);
if let Some(bg) = app.theme.background {
let gap = Paragraph::new("").style(Style::default().bg(bg));
frame.render_widget(gap, gap_area);
}
}
fn wrap_blame_text(text: &str, width: usize) -> Vec<String> {
if width == 0 {
return vec![String::new()];
}
if text.is_empty() {
return vec![String::new()];
}
let mut lines = Vec::new();
let mut current = String::new();
let mut current_len = 0usize;
for ch in text.chars() {
if current_len >= width {
lines.push(current);
current = String::new();
current_len = 0;
}
current.push(ch);
current_len += 1;
}
if !current.is_empty() {
lines.push(current);
}
if lines.is_empty() {
lines.push(String::new());
}
lines
}