use diffler_core::highlight::StyledRange;
use ratatui::Frame;
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use crate::app::App;
use crate::app::file::FileView;
use crate::keymap::Action;
use crate::theme::Theme;
use crate::ui::{Hint, cursor_line, status_bar};
const HINTS: &[Hint] = &[
Hint::Leaf(&[Action::ToggleBlame], "blame"),
Hint::Leaf(&[Action::Open], "commit"),
Hint::Leaf(&[Action::OpenEditor], "editor"),
Hint::Leaf(&[Action::Help], "help"),
];
const BLAME_WIDTH: usize = 27;
const AUTHOR_WIDTH: usize = 12;
pub fn draw(frame: &mut Frame<'_>, app: &mut App) {
let (header, body, bar) = super::screen_chrome_with_header(frame, app, HINTS);
let theme = app.theme.clone();
let now = app.now_unix;
let search = app.search.as_ref().map(|s| {
(0..app.file.as_ref().map_or(0, |v| v.lines.len()))
.map(|index| s.ranges_for(index))
.collect::<Vec<_>>()
});
let Some(view) = app.file.as_mut() else {
frame.render_widget(
Paragraph::new(Line::styled(" loading…", theme.dim_style())),
body,
);
frame.render_widget(
Paragraph::new(status_bar(app, bar.width)).style(Style::new().bg(app.theme.panel)),
bar,
);
return;
};
view.viewport = body.height;
frame.render_widget(Paragraph::new(header_line(view, &theme)), header);
let height = body.height.max(1) as usize;
view.scroll = super::scroll_to_cursor(view.cursor, view.scroll, height, view.lines.len());
let gutter = view.lines.len().to_string().len().max(3);
let lines: Vec<Line<'static>> = (view.scroll..view.lines.len())
.take(height)
.map(|index| {
let ranges = search
.as_ref()
.and_then(|all| all.get(index).cloned())
.unwrap_or_default();
let line = row_line(&theme, view, index, gutter, body.width, &ranges, now);
if index == view.cursor {
cursor_line(line, &theme, body.width)
} else {
line
}
})
.collect();
frame.render_widget(Paragraph::new(lines), body);
frame.render_widget(
Paragraph::new(status_bar(app, bar.width)).style(Style::new().bg(app.theme.panel)),
bar,
);
}
fn header_line(view: &FileView, theme: &Theme) -> Line<'static> {
let mut spans = vec![Span::styled(
format!(" {}", view.path),
Style::new().fg(theme.accent),
)];
spans.push(Span::styled(
format!(" {} lines", view.lines.len()),
theme.dim_style(),
));
if let Some(span) = view.cursor_commit() {
spans.push(Span::styled(
format!(" â–¸ {} {}", span.oid7, span.summary),
theme.dim_style(),
));
}
Line::from(spans)
}
fn row_line(
theme: &Theme,
view: &FileView,
index: usize,
gutter: usize,
width: u16,
search: &[(std::ops::Range<usize>, bool)],
now: i64,
) -> Line<'static> {
let mut spans = vec![Span::raw(" ")];
if view.show_blame {
spans.push(blame_span(theme, view, index, now));
}
spans.push(Span::styled(
format!("{:>gutter$} ", index + 1),
theme.dim_style(),
));
let text = view.lines.get(index).cloned().unwrap_or_default();
let syntax = view.highlights.get(index).map(Vec::as_slice);
spans.extend(text_spans(theme, &text, syntax, search));
let used: usize = spans.iter().map(Span::width).sum();
if let Some(pad) = (width as usize).checked_sub(used) {
spans.push(Span::raw(" ".repeat(pad)));
}
Line::from(spans)
}
fn blame_span(theme: &Theme, view: &FileView, index: usize, now: i64) -> Span<'static> {
if !view.starts_span(index) {
return Span::raw(" ".repeat(BLAME_WIDTH));
}
let Some(span) = view.span_at(index) else {
return Span::raw(" ".repeat(BLAME_WIDTH));
};
if !span.committed {
return Span::styled(
format!("{:<BLAME_WIDTH$}", " uncommitted"),
theme.dim_style(),
);
}
let author = super::elide(&span.author, AUTHOR_WIDTH);
let text = format!(
"{} {:<AUTHOR_WIDTH$} {:>4} ",
span.oid7,
author,
super::relative_time(now, span.time_unix)
);
Span::styled(format!("{text:<BLAME_WIDTH$}"), Style::new().fg(theme.dim))
}
fn text_spans(
theme: &Theme,
text: &str,
syntax: Option<&[StyledRange]>,
search: &[(std::ops::Range<usize>, bool)],
) -> Vec<Span<'static>> {
let bg_at = |byte: usize| {
search
.iter()
.find(|(range, _)| range.contains(&byte))
.map(|(_, current)| {
if *current {
theme.search_current
} else {
theme.search
}
})
};
let fg_at = |byte: usize| {
syntax
.unwrap_or_default()
.iter()
.find(|styled| styled.range.contains(&byte))
};
let mut spans: Vec<Span<'static>> = Vec::new();
let mut current: Option<(Style, String)> = None;
for (byte, ch) in text.char_indices() {
let styled = fg_at(byte);
let mut style = Style::new().fg(styled.map_or(theme.fg, |s| {
let (r, g, b) = s.fg;
Color::Rgb(r, g, b)
}));
if styled.is_some_and(|s| s.bold) {
style = style.add_modifier(ratatui::style::Modifier::BOLD);
}
if styled.is_some_and(|s| s.italic) {
style = style.add_modifier(ratatui::style::Modifier::ITALIC);
}
if let Some(bg) = bg_at(byte) {
style = style.bg(bg);
}
match current.as_mut() {
Some((open, buffer)) if *open == style => buffer.push(ch),
_ => {
if let Some((open, buffer)) = current.take() {
spans.push(Span::styled(buffer, open));
}
current = Some((style, ch.to_string()));
}
}
}
if let Some((open, buffer)) = current {
spans.push(Span::styled(buffer, open));
}
spans
}
#[cfg(test)]
mod tests {
use diffler_core::vcs::BlameSpan;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use super::*;
use crate::config::LoadedConfig;
use crate::test_support::standard_fixture;
fn span(start: u32, count: u32, oid7: &str, author: &str, summary: &str) -> BlameSpan {
BlameSpan {
start_line: start,
line_count: count,
oid: format!("{oid7}0000000000000000000000000000000"),
oid7: oid7.to_owned(),
author: author.to_owned(),
time_unix: 0,
summary: summary.to_owned(),
committed: true,
}
}
fn app_with_file(show_blame: bool) -> App {
let fixture = standard_fixture();
let mut app = App::new(fixture.review(), LoadedConfig::default());
app.now_unix = 86_400 * 30;
let view = FileView::new(
"src/lib.rs".to_owned(),
"fn main() {\n let total = 1;\n println!(\"{total}\");\n}\n",
Vec::new(),
vec![
span(1, 2, "aaaaaaa", "reviewer", "first"),
span(3, 2, "bbbbbbb", "other", "second"),
],
show_blame,
);
app.install_file(view, None);
app
}
#[test]
fn renders_the_file_with_its_blame_column() {
let mut app = app_with_file(true);
let backend = TestBackend::new(72, 9);
let mut terminal = Terminal::new(backend).expect("terminal");
terminal.draw(|f| draw(f, &mut app)).expect("draw");
insta::assert_snapshot!(terminal.backend());
}
#[test]
fn renders_without_the_blame_column_when_toggled_off() {
let mut app = app_with_file(false);
let backend = TestBackend::new(72, 9);
let mut terminal = Terminal::new(backend).expect("terminal");
terminal.draw(|f| draw(f, &mut app)).expect("draw");
insta::assert_snapshot!(terminal.backend());
}
fn spans(count: u32, start: u32) -> Vec<BlameSpan> {
vec![BlameSpan {
start_line: start,
line_count: count,
oid: "a".repeat(40),
oid7: "aaaaaaa".to_owned(),
author: "reviewer".to_owned(),
time_unix: 0,
summary: "base".to_owned(),
committed: true,
}]
}
#[test]
fn only_the_first_line_of_a_run_prints_its_commit() {
let view = FileView::new(
"a.rs".to_owned(),
"one\ntwo\nthree\n",
Vec::new(),
spans(3, 1),
true,
);
let theme = Theme::github_dark();
assert!(blame_span(&theme, &view, 0, 0).content.contains("aaaaaaa"));
assert_eq!(blame_span(&theme, &view, 1, 0).content.trim(), "");
assert_eq!(blame_span(&theme, &view, 2, 0).content.trim(), "");
}
}