use ratatui::{
prelude::*,
style::Stylize,
text::{Line, Span},
widgets::Paragraph,
};
use crate::model::{CompareInfo, DiffDisplayFormat, DiffLine, DiffLineKind, Notification};
use crate::ui::{components, theme};
use super::DiffView;
impl DiffView {
pub fn render(&self, frame: &mut Frame, area: Rect, notification: Option<&Notification>) {
if self.compare_info.is_some() {
self.render_compare(frame, area, notification);
} else {
self.render_normal(frame, area, notification);
}
}
fn render_normal(&self, frame: &mut Frame, area: Rect, notification: Option<&Notification>) {
let desc_lines = self.description_line_count();
let max_header = area.height.saturating_sub(2); let header_height = ((1 + 2 + desc_lines) as u16).min(max_header).max(1);
let chunks = Layout::vertical([
Constraint::Length(header_height), Constraint::Length(1), Constraint::Min(1), ])
.split(area);
self.render_header(frame, chunks[0], notification);
self.render_context_bar(frame, chunks[1]);
self.render_diff_content(frame, chunks[2]);
}
fn render_compare(&self, frame: &mut Frame, area: Rect, notification: Option<&Notification>) {
let compare_info = self.compare_info.as_ref().unwrap();
let header_height = 5_u16.min(area.height.saturating_sub(2));
let chunks = Layout::vertical([
Constraint::Length(header_height),
Constraint::Length(1),
Constraint::Min(1),
])
.split(area);
self.render_compare_header(frame, chunks[0], compare_info, notification);
self.render_context_bar(frame, chunks[1]);
self.render_diff_content(frame, chunks[2]);
}
fn render_header(&self, frame: &mut Frame, area: Rect, notification: Option<&Notification>) {
let format_suffix = match self.display_format {
DiffDisplayFormat::ColorWords => String::new(),
fmt => format!(" [{}]", fmt.label()),
};
let title = Line::from(vec![
Span::raw(" Tij - Diff View ").bold(),
Span::raw("["),
Span::styled(
self.revision.chars().take(8).collect::<String>(),
Style::default().fg(theme::log_view::CHANGE_ID),
),
Span::raw("]"),
Span::styled(format_suffix, Style::default().fg(Color::Yellow).bold()),
Span::raw(" "),
])
.centered();
let title_width = title.width();
let available_for_notif = area.width.saturating_sub(title_width as u16 + 4) as usize;
let notif_line = notification
.filter(|n| !n.is_expired())
.map(|n| components::build_notification_title(n, Some(available_for_notif)))
.filter(|line| !line.spans.is_empty());
let mut header_text = vec![
Line::from(vec![
Span::raw("Commit: "),
Span::styled(
self.content.commit_id.chars().take(40).collect::<String>(),
Style::default().fg(theme::log_view::CHANGE_ID),
),
]),
Line::from(vec![
Span::raw("Author: "),
Span::raw(&self.content.author),
Span::raw(" "),
Span::styled(
&self.content.timestamp,
Style::default().fg(Color::DarkGray),
),
]),
];
if self.content.description.is_empty() {
header_text.push(Line::from(vec![Span::styled(
"(no description)",
Style::default().fg(Color::DarkGray).italic(),
)]));
} else {
for line in self.content.description.lines() {
header_text.push(Line::from(vec![Span::styled(
line.to_string(),
Style::default().fg(Color::White).bold(),
)]));
}
}
let block = if let Some(notif) = notif_line {
components::header_block(title).title(notif.right_aligned())
} else {
components::header_block(title)
};
let header = Paragraph::new(header_text).block(block);
frame.render_widget(header, area);
}
fn render_compare_header(
&self,
frame: &mut Frame,
area: Rect,
compare_info: &CompareInfo,
notification: Option<&Notification>,
) {
let format_suffix = match self.display_format {
DiffDisplayFormat::ColorWords => String::new(),
fmt => format!(" [{}]", fmt.label()),
};
let title_text = format!(" Tij - Compare Diff{} ", format_suffix);
let title = Line::from(title_text).bold().cyan().centered();
let title_width = title.width();
let available_for_notif = area.width.saturating_sub(title_width as u16 + 4) as usize;
let notif_line = notification
.filter(|n| !n.is_expired())
.map(|n| components::build_notification_title(n, Some(available_for_notif)))
.filter(|line| !line.spans.is_empty());
let from = &compare_info.from;
let to = &compare_info.to;
let from_bookmarks = if from.bookmarks.is_empty() {
String::new()
} else {
format!(" ({})", from.bookmarks.join(", "))
};
let to_bookmarks = if to.bookmarks.is_empty() {
String::new()
} else {
format!(" ({})", to.bookmarks.join(", "))
};
let from_desc = if from.description.is_empty() {
"(no description)".to_string()
} else {
from.description.clone()
};
let to_desc = if to.description.is_empty() {
"(no description)".to_string()
} else {
to.description.clone()
};
let header_text = vec![
Line::from(vec![
Span::styled("From: ", Style::default().fg(Color::Red).bold()),
Span::styled(
from.change_id.to_string(),
Style::default().fg(theme::log_view::CHANGE_ID),
),
Span::styled(from_bookmarks, Style::default().fg(Color::Magenta)),
Span::raw(format!(" {} ", from.author)),
Span::raw(from_desc),
]),
Line::from(vec![
Span::styled("To: ", Style::default().fg(Color::Green).bold()),
Span::styled(
to.change_id.to_string(),
Style::default().fg(theme::log_view::CHANGE_ID),
),
Span::styled(to_bookmarks, Style::default().fg(Color::Magenta)),
Span::raw(format!(" {} ", to.author)),
Span::raw(to_desc),
]),
Line::from(vec![Span::styled(
format!("{} file(s) changed", self.file_count()),
Style::default().fg(Color::DarkGray),
)]),
];
let block = if let Some(notif) = notif_line {
components::header_block(title).title(notif.right_aligned())
} else {
components::header_block(title)
};
let header = Paragraph::new(header_text).block(block);
frame.render_widget(header, area);
}
fn render_context_bar(&self, frame: &mut Frame, area: Rect) {
let file_info = if self.file_count() > 0 {
let file_name = self.current_file_name().unwrap_or("(unknown)");
format!(
" {} [{}/{}]",
file_name,
self.current_file_index + 1,
self.file_count()
)
} else {
" (no files)".to_string()
};
let bar = Paragraph::new(Line::from(vec![Span::styled(
file_info,
Style::default().fg(Color::Cyan).bold(),
)]))
.block(components::side_borders_block());
frame.render_widget(bar, area);
}
fn render_diff_content(&self, frame: &mut Frame, area: Rect) {
let inner_height = area.height as usize;
if self.content.lines.is_empty() {
let empty_msg = components::no_changes_state().block(components::side_borders_block());
frame.render_widget(empty_msg, area);
return;
}
let lines: Vec<Line> = self
.content
.lines
.iter()
.skip(self.scroll_offset)
.take(inner_height)
.map(|diff_line| self.render_diff_line(diff_line))
.collect();
let diff = Paragraph::new(lines).block(components::side_borders_block());
frame.render_widget(diff, area);
}
fn render_diff_line(&self, line: &DiffLine) -> Line<'static> {
let show_line_nums = self.display_format == DiffDisplayFormat::ColorWords;
match line.kind {
DiffLineKind::FileHeader => Line::from(Span::styled(
format!("── {} ──", line.content),
Style::default().fg(theme::diff_view::FILE_HEADER).bold(),
)),
DiffLineKind::Separator => Line::from(""),
DiffLineKind::Context => {
if show_line_nums {
let line_nums = self.format_line_numbers(line.line_numbers);
Line::from(vec![
Span::styled(
line_nums,
Style::default().fg(theme::diff_view::LINE_NUMBER),
),
Span::raw(" "),
Span::raw(line.content.clone()),
])
} else {
Line::from(Span::raw(format!(" {}", line.content)))
}
}
DiffLineKind::Added => {
if show_line_nums {
let line_nums = self.format_line_numbers(line.line_numbers);
Line::from(vec![
Span::styled(
line_nums,
Style::default().fg(theme::diff_view::LINE_NUMBER),
),
Span::styled(" +", Style::default().fg(theme::diff_view::ADDED)),
Span::styled(
line.content.clone(),
Style::default().fg(theme::diff_view::ADDED),
),
])
} else {
Line::from(Span::styled(
format!(" +{}", line.content),
Style::default().fg(theme::diff_view::ADDED),
))
}
}
DiffLineKind::Deleted => {
if show_line_nums {
let line_nums = self.format_line_numbers(line.line_numbers);
Line::from(vec![
Span::styled(
line_nums,
Style::default().fg(theme::diff_view::LINE_NUMBER),
),
Span::styled(" -", Style::default().fg(theme::diff_view::DELETED)),
Span::styled(
line.content.clone(),
Style::default().fg(theme::diff_view::DELETED),
),
])
} else {
Line::from(Span::styled(
format!(" -{}", line.content),
Style::default().fg(theme::diff_view::DELETED),
))
}
}
}
}
fn format_line_numbers(&self, line_nums: Option<(Option<usize>, Option<usize>)>) -> String {
match line_nums {
Some((old, new)) => {
let old_str = old
.map(|n| format!("{:4}", n))
.unwrap_or_else(|| " ".to_string());
let new_str = new
.map(|n| format!("{:4}", n))
.unwrap_or_else(|| " ".to_string());
format!("{} {}", old_str, new_str)
}
None => " ".to_string(),
}
}
}