use ratatui::{
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame,
};
use super::super::helpers::{
truncate_to_width, truncate_to_width_with_ellipsis, OverlayContext, OVERLAY_BG_COLOR,
};
use super::super::theme;
use crate::app::App;
use crate::compare::CompareTab;
pub(crate) fn render_stats_view_overlay(frame: &mut Frame, app: &App) {
let ctx = OverlayContext::standard(frame);
let overlay_area = ctx.clear(frame);
let inner_width = ctx.inner_width;
let overlay_height = overlay_area.height;
let Some(ref stats) = app.stats_view.cache else {
let paragraph = Paragraph::new(app.language.loading_statistics()).block(
Block::default()
.title(app.language.author_stats())
.borders(Borders::ALL)
.border_style(Style::default().fg(theme::LAVENDER))
.style(Style::default().bg(OVERLAY_BG_COLOR)),
);
frame.render_widget(paragraph, overlay_area);
return;
};
let dim_style = Style::default().fg(theme::SUBTEXT0);
let show_lines = inner_width >= 50;
let visible_lines = overlay_height.saturating_sub(8) as usize;
let mut content = Vec::new();
let summary = if inner_width >= 50 {
format!(
" Total: {} commits, +{} -{} lines",
stats.total_commits, stats.total_insertions, stats.total_deletions
)
} else if inner_width >= 35 {
format!(
" {} commits, +{} -{}",
stats.total_commits, stats.total_insertions, stats.total_deletions
)
} else {
format!(" {} commits", stats.total_commits)
};
content.push(Line::from(vec![Span::styled(
summary,
Style::default().fg(theme::SAPPHIRE),
)]));
content.push(Line::from(""));
let author_width = if show_lines {
20.min(inner_width.saturating_sub(35))
} else {
inner_width.saturating_sub(18).max(8)
};
let lang = app.language;
if show_lines {
content.push(Line::from(vec![
Span::styled(" ", dim_style),
Span::styled(
format!("{:<width$}", lang.header_author(), width = author_width),
dim_style,
),
Span::styled(format!("{:>10}", lang.header_commits()), dim_style),
Span::styled(format!("{:>10}", lang.header_plus_lines()), dim_style),
Span::styled(format!("{:>10}", lang.header_minus_lines()), dim_style),
]));
} else {
content.push(Line::from(vec![
Span::styled(" ", dim_style),
Span::styled(
format!("{:<width$}", lang.header_author(), width = author_width),
dim_style,
),
Span::styled(format!("{:>10}", lang.header_commits()), dim_style),
]));
}
content.push(Line::from(vec![Span::styled(
"─".repeat(inner_width.saturating_sub(2)),
dim_style,
)]));
let scroll_offset = app.stats_view.nav.scroll_offset;
let end_idx = (scroll_offset + visible_lines).min(stats.authors.len());
for (idx, author) in stats
.authors
.iter()
.enumerate()
.skip(scroll_offset)
.take(end_idx - scroll_offset)
{
let is_selected = idx == app.stats_view.nav.selected_index;
let marker = if is_selected { "▶" } else { " " };
let percentage = author.commit_percentage(stats.total_commits);
let line_style = if is_selected {
Style::default()
.fg(theme::YELLOW)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let author_name =
truncate_to_width_with_ellipsis(&author.name, author_width.saturating_sub(2));
if show_lines {
content.push(Line::from(vec![
Span::styled(format!("{} ", marker), line_style),
Span::styled(
format!("{:<width$}", author_name, width = author_width),
line_style,
),
Span::styled(
format!("{:>6} ({:>4.1}%)", author.commit_count, percentage),
line_style,
),
Span::styled(format!("{:>10}", author.insertions), line_style),
Span::styled(format!("{:>10}", author.deletions), line_style),
]));
} else {
content.push(Line::from(vec![
Span::styled(format!("{} ", marker), line_style),
Span::styled(
format!("{:<width$}", author_name, width = author_width),
line_style,
),
Span::styled(
format!("{:>6} ({:>4.1}%)", author.commit_count, percentage),
line_style,
),
]));
}
}
content.push(Line::from(""));
let scroll_indicator = if stats.authors.len() > visible_lines {
format!(
" [{}/{}]",
app.stats_view.nav.selected_index + 1,
stats.authors.len()
)
} else {
String::new()
};
if inner_width >= 40 {
content.push(Line::from(vec![
Span::styled("j/k", Style::default().fg(theme::YELLOW)),
Span::raw(lang.hint_move()),
Span::styled("Enter", Style::default().fg(theme::YELLOW)),
Span::raw(lang.hint_filter()),
Span::styled("q/Esc", Style::default().fg(theme::YELLOW)),
Span::raw(lang.hint_close()),
Span::styled(scroll_indicator, Style::default().fg(theme::SUBTEXT0)),
]));
} else {
content.push(Line::from(vec![
Span::styled("j/k Enter q/Esc", Style::default().fg(theme::SUBTEXT0)),
Span::styled(scroll_indicator, Style::default().fg(theme::SUBTEXT0)),
]));
}
let paragraph = Paragraph::new(content).block(
Block::default()
.title(lang.author_stats())
.borders(Borders::ALL)
.border_style(Style::default().fg(theme::LAVENDER))
.style(Style::default().bg(OVERLAY_BG_COLOR)),
);
frame.render_widget(paragraph, overlay_area);
}
pub(crate) fn render_heatmap_view_overlay(frame: &mut Frame, app: &App) {
let ctx = OverlayContext::standard(frame);
let overlay_area = ctx.clear(frame);
let inner_width = ctx.inner_width;
let overlay_height = overlay_area.height;
let Some(ref heatmap) = app.heatmap_view.cache else {
let paragraph = Paragraph::new(app.language.loading_heatmap()).block(
Block::default()
.title(app.language.file_heatmap())
.borders(Borders::ALL)
.border_style(Style::default().fg(theme::MAUVE))
.style(Style::default().bg(OVERLAY_BG_COLOR)),
);
frame.render_widget(paragraph, overlay_area);
return;
};
let dim_style = Style::default().fg(theme::SUBTEXT0);
let show_changes = inner_width >= 50;
let heat_bar_len = if inner_width >= 60 {
6
} else if inner_width >= 40 {
5
} else {
3
};
let visible_lines = overlay_height.saturating_sub(8) as usize;
let mut content = Vec::new();
let summary = if inner_width >= 30 {
format!(" Total: {} files changed", heatmap.total_files)
} else {
format!(" {} files", heatmap.total_files)
};
content.push(Line::from(vec![Span::styled(
summary,
Style::default().fg(theme::MAUVE),
)]));
content.push(Line::from(""));
let file_width = if show_changes {
inner_width.saturating_sub(20).min(40)
} else {
inner_width.saturating_sub(heat_bar_len + 6)
};
let lang = app.language;
if show_changes {
content.push(Line::from(vec![
Span::styled(" ", dim_style),
Span::styled(
format!("{:<width$}", lang.header_file(), width = file_width),
dim_style,
),
Span::styled(format!("{:>10}", lang.header_changes()), dim_style),
Span::styled(format!(" {}", lang.header_heat()), dim_style),
]));
} else {
content.push(Line::from(vec![
Span::styled(" ", dim_style),
Span::styled(
format!("{:<width$}", lang.header_file(), width = file_width),
dim_style,
),
Span::styled(format!(" {}", lang.header_heat()), dim_style),
]));
}
content.push(Line::from(vec![Span::styled(
"─".repeat(inner_width.saturating_sub(2)),
dim_style,
)]));
let scroll_offset = app.heatmap_view.nav.scroll_offset;
let end_idx = (scroll_offset + visible_lines).min(heatmap.files.len());
for (idx, file) in heatmap
.files
.iter()
.enumerate()
.skip(scroll_offset)
.take(end_idx - scroll_offset)
{
let is_selected = idx == app.heatmap_view.nav.selected_index;
let marker = if is_selected { "▶" } else { " " };
let line_style = if is_selected {
Style::default()
.fg(theme::YELLOW)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let heat_level = file.heat_level();
let heat_color = if heat_level >= 0.8 {
theme::RED
} else if heat_level >= 0.6 {
theme::MAROON
} else if heat_level >= 0.4 {
theme::YELLOW
} else if heat_level >= 0.2 {
theme::GREEN
} else {
theme::SUBTEXT0
};
let file_path = truncate_to_width_with_ellipsis(&file.path, file_width.saturating_sub(2));
let full_bar = file.heat_bar();
let heat_bar = if heat_bar_len >= 6 {
full_bar.to_string()
} else {
truncate_to_width(full_bar, heat_bar_len)
};
if show_changes {
content.push(Line::from(vec![
Span::styled(format!("{} ", marker), line_style),
Span::styled(
format!("{:<width$}", file_path, width = file_width),
line_style,
),
Span::styled(format!("{:>10}", file.change_count), line_style),
Span::raw(" "),
Span::styled(heat_bar, Style::default().fg(heat_color)),
]));
} else {
content.push(Line::from(vec![
Span::styled(format!("{} ", marker), line_style),
Span::styled(
format!("{:<width$}", file_path, width = file_width),
line_style,
),
Span::raw(" "),
Span::styled(heat_bar, Style::default().fg(heat_color)),
]));
}
}
content.push(Line::from(""));
let scroll_indicator = if heatmap.files.len() > visible_lines {
format!(
" [{}/{}]",
app.heatmap_view.nav.selected_index + 1,
heatmap.files.len()
)
} else {
String::new()
};
if inner_width >= 40 {
content.push(Line::from(vec![
Span::styled("j/k", Style::default().fg(theme::YELLOW)),
Span::raw(lang.hint_move()),
Span::styled("Enter", Style::default().fg(theme::YELLOW)),
Span::raw(lang.hint_filter()),
Span::styled("q/Esc", Style::default().fg(theme::YELLOW)),
Span::raw(lang.hint_close()),
Span::styled(scroll_indicator, Style::default().fg(theme::SUBTEXT0)),
]));
} else {
content.push(Line::from(vec![
Span::styled("j/k Enter q/Esc", Style::default().fg(theme::SUBTEXT0)),
Span::styled(scroll_indicator, Style::default().fg(theme::SUBTEXT0)),
]));
}
let paragraph = Paragraph::new(content).block(
Block::default()
.title(app.language.file_heatmap())
.borders(Borders::ALL)
.border_style(Style::default().fg(theme::MAUVE))
.style(Style::default().bg(OVERLAY_BG_COLOR)),
);
frame.render_widget(paragraph, overlay_area);
}
pub(crate) fn render_timeline_view_overlay(frame: &mut Frame, app: &App) {
let ctx = OverlayContext::custom(frame, 60, 16);
let overlay_area = ctx.clear(frame);
let inner_width = ctx.inner_width;
let Some(ref timeline) = app.timeline_cache else {
let paragraph = Paragraph::new(app.language.loading_timeline()).block(
Block::default()
.title(app.language.activity_timeline())
.borders(Borders::ALL)
.border_style(Style::default().fg(theme::GREEN))
.style(Style::default().bg(OVERLAY_BG_COLOR)),
);
frame.render_widget(paragraph, overlay_area);
return;
};
use crate::stats::ActivityTimeline;
let dim_style = Style::default().fg(theme::SUBTEXT0);
let show_hour_labels = inner_width >= 35;
let show_summary = inner_width >= 25;
let cell_step = if inner_width >= 35 { 3 } else { 6 }; let day_label_width = if inner_width >= 30 { 3 } else { 2 };
let mut content = Vec::new();
if show_hour_labels {
let hour_labels = if cell_step == 3 {
if inner_width >= 40 {
format!(
"{:>width$}00 03 06 09 12 15 18 21",
"",
width = day_label_width + 3
)
} else {
format!(
"{:>width$}0 3 6 9 12 15 18 21",
"",
width = day_label_width + 3
)
}
} else {
format!("{:>width$}00 06 12 18", "", width = day_label_width + 3)
};
content.push(Line::from(vec![Span::styled(hour_labels, dim_style)]));
content.push(Line::from(""));
}
for day in 0..7 {
let mut spans = Vec::new();
let day_name = if day_label_width >= 3 {
ActivityTimeline::day_name(day)
} else {
&ActivityTimeline::day_name(day)[..2]
};
spans.push(Span::styled(
format!(" {:>width$} ", day_name, width = day_label_width),
Style::default().fg(theme::TEXT),
));
for hour_start in (0..24).step_by(cell_step) {
let count: usize = (hour_start..hour_start + cell_step)
.map(|h| timeline.grid[day][h])
.sum();
let max_possible = timeline.max_count * cell_step;
let level = if max_possible == 0 {
0.0
} else {
(count as f64 / max_possible as f64).min(1.0)
};
let heat_char = ActivityTimeline::heat_char(level);
let color = if level >= 0.8 {
theme::RED
} else if level >= 0.6 {
theme::MAROON
} else if level >= 0.4 {
theme::YELLOW
} else if level >= 0.2 {
theme::GREEN
} else {
theme::SUBTEXT0
};
let cell_display = if inner_width >= 35 {
format!("{} ", heat_char)
} else {
heat_char.to_string()
};
spans.push(Span::styled(cell_display, Style::default().fg(color)));
}
content.push(Line::from(spans));
}
content.push(Line::from(""));
content.push(Line::from(vec![Span::styled(
"─".repeat(inner_width.saturating_sub(2)),
dim_style,
)]));
if show_summary {
if inner_width >= 40 {
content.push(Line::from(vec![
Span::styled(" Peak: ", Style::default().fg(theme::SAPPHIRE)),
Span::styled(timeline.peak_summary(), Style::default().fg(theme::TEXT)),
]));
}
content.push(Line::from(vec![
Span::styled(" Total: ", Style::default().fg(theme::SAPPHIRE)),
Span::styled(
format!("{} commits", timeline.total_commits),
Style::default().fg(theme::TEXT),
),
]));
} else {
content.push(Line::from(vec![Span::styled(
format!(" {} commits", timeline.total_commits),
Style::default().fg(theme::SAPPHIRE),
)]));
}
content.push(Line::from(""));
if inner_width >= 25 {
content.push(Line::from(vec![
Span::styled(" T/Esc", Style::default().fg(theme::YELLOW)),
Span::raw(": close"),
]));
} else {
content.push(Line::from(vec![Span::styled(
" T/Esc",
Style::default().fg(theme::SUBTEXT0),
)]));
}
let paragraph = Paragraph::new(content).block(
Block::default()
.title(app.language.timeline())
.borders(Borders::ALL)
.border_style(Style::default().fg(theme::GREEN))
.style(Style::default().bg(OVERLAY_BG_COLOR)),
);
frame.render_widget(paragraph, overlay_area);
}
pub(crate) fn render_ownership_view_overlay(frame: &mut Frame, app: &App) {
let ctx = OverlayContext::standard(frame);
let overlay_area = ctx.clear(frame);
let inner_width = ctx.inner_width;
let overlay_height = overlay_area.height;
let Some(ref ownership) = app.ownership_view.cache else {
let paragraph = Paragraph::new(app.language.loading_ownership()).block(
Block::default()
.title(app.language.code_ownership())
.borders(Borders::ALL)
.border_style(Style::default().fg(theme::SKY))
.style(Style::default().bg(OVERLAY_BG_COLOR)),
);
frame.render_widget(paragraph, overlay_area);
return;
};
let dim_style = Style::default().fg(theme::SUBTEXT0);
let show_owner = inner_width >= 45;
let path_width = if show_owner {
inner_width.saturating_sub(25).min(30)
} else {
inner_width.saturating_sub(12)
};
let owner_width = if inner_width >= 55 { 15 } else { 10 };
let max_indent = if inner_width >= 40 { 5 } else { 2 };
let visible_lines = overlay_height.saturating_sub(8) as usize;
let mut content = Vec::new();
let summary = if inner_width >= 30 {
format!(" Total: {} entries", ownership.entries.len())
} else {
format!(" {} entries", ownership.entries.len())
};
content.push(Line::from(vec![Span::styled(
summary,
Style::default().fg(theme::SAPPHIRE),
)]));
content.push(Line::from(""));
let lang = app.language;
if show_owner {
content.push(Line::from(vec![
Span::styled(" ", dim_style),
Span::styled(
format!("{:<width$}", lang.header_path(), width = path_width),
dim_style,
),
Span::styled(
format!("{:<width$}", lang.header_owner(), width = owner_width),
dim_style,
),
Span::styled(format!("{:>5}", "%"), dim_style),
]));
} else {
content.push(Line::from(vec![
Span::styled(" ", dim_style),
Span::styled(
format!("{:<width$}", lang.header_path(), width = path_width),
dim_style,
),
Span::styled(format!("{:>5}", "%"), dim_style),
]));
}
content.push(Line::from(vec![Span::styled(
"─".repeat(inner_width.saturating_sub(2)),
dim_style,
)]));
for (i, entry) in ownership
.entries
.iter()
.enumerate()
.skip(app.ownership_view.nav.scroll_offset)
.take(visible_lines)
{
let is_selected = i == app.ownership_view.nav.selected_index;
let line_style = if is_selected {
Style::default()
.bg(theme::SURFACE2)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let marker = if is_selected { "▶" } else { " " };
let indent = " ".repeat(entry.depth.min(max_indent));
let icon = if entry.is_directory { "📁" } else { "📄" };
let name = entry
.path
.rsplit('/')
.next()
.unwrap_or(&entry.path)
.to_string();
let path_display = format!("{}{} {}", indent, icon, name);
let path_truncated =
truncate_to_width_with_ellipsis(&path_display, path_width.saturating_sub(2));
let percentage = entry.ownership_percentage();
let share_display = format!("{:>4.0}%", percentage);
if show_owner {
let author_display = truncate_to_width_with_ellipsis(
&entry.primary_author,
owner_width.saturating_sub(2),
);
content.push(Line::from(vec![
Span::styled(format!("{} ", marker), line_style),
Span::styled(
format!("{:<width$}", path_truncated, width = path_width),
line_style,
),
Span::styled(
format!("{:<width$}", author_display, width = owner_width),
Style::default().fg(theme::SAPPHIRE),
),
Span::styled(
share_display,
Style::default().fg(if percentage >= 50.0 {
theme::GREEN
} else {
theme::YELLOW
}),
),
]));
} else {
content.push(Line::from(vec![
Span::styled(format!("{} ", marker), line_style),
Span::styled(
format!("{:<width$}", path_truncated, width = path_width),
line_style,
),
Span::styled(
share_display,
Style::default().fg(if percentage >= 50.0 {
theme::GREEN
} else {
theme::YELLOW
}),
),
]));
}
}
content.push(Line::from(""));
let scroll_indicator = if ownership.entries.len() > visible_lines {
format!(
" [{}/{}]",
app.ownership_view.nav.selected_index + 1,
ownership.entries.len()
)
} else {
String::new()
};
if inner_width >= 35 {
content.push(Line::from(vec![
Span::styled("j/k", Style::default().fg(theme::YELLOW)),
Span::raw(lang.hint_move()),
Span::styled("O/Esc", Style::default().fg(theme::YELLOW)),
Span::raw(lang.hint_close()),
Span::styled(scroll_indicator, Style::default().fg(theme::SUBTEXT0)),
]));
} else {
content.push(Line::from(vec![
Span::styled("j/k O/Esc", Style::default().fg(theme::SUBTEXT0)),
Span::styled(scroll_indicator, Style::default().fg(theme::SUBTEXT0)),
]));
}
let paragraph = Paragraph::new(content).block(
Block::default()
.title(app.language.code_ownership())
.borders(Borders::ALL)
.border_style(Style::default().fg(theme::SKY))
.style(Style::default().bg(OVERLAY_BG_COLOR)),
);
frame.render_widget(paragraph, overlay_area);
}
pub(crate) fn render_impact_score_view_overlay(frame: &mut Frame, app: &App) {
let ctx = OverlayContext::standard(frame);
let overlay_area = ctx.clear(frame);
let inner_width = ctx.inner_width;
let overlay_height = overlay_area.height;
let Some(ref analysis) = app.impact_score_view.cache else {
let paragraph = Paragraph::new(app.language.loading_impact_scores()).block(
Block::default()
.title(app.language.impact_scores())
.borders(Borders::ALL)
.border_style(Style::default().fg(theme::LAVENDER))
.style(Style::default().bg(OVERLAY_BG_COLOR)),
);
frame.render_widget(paragraph, overlay_area);
return;
};
let dim_style = Style::default().fg(theme::SUBTEXT0);
let show_author = inner_width >= 50;
let bar_len = if inner_width >= 50 {
6
} else if inner_width >= 35 {
4
} else {
3
};
let hash_len = if inner_width >= 50 {
8
} else if inner_width >= 35 {
7
} else {
5
};
let visible_lines = overlay_height.saturating_sub(8) as usize;
let mut content = Vec::new();
if inner_width >= 55 {
content.push(Line::from(vec![
Span::styled(" Total: ", Style::default().fg(theme::SUBTEXT0)),
Span::styled(
format!("{}", analysis.total_commits),
Style::default().fg(theme::SAPPHIRE),
),
Span::styled(" commits | Avg: ", Style::default().fg(theme::SUBTEXT0)),
Span::styled(
format!("{:.2}", analysis.avg_score),
Style::default().fg(theme::SAPPHIRE),
),
Span::styled(" | High Impact: ", Style::default().fg(theme::SUBTEXT0)),
Span::styled(
format!("{}", analysis.high_impact_count),
Style::default().fg(theme::RED),
),
]));
} else if inner_width >= 35 {
content.push(Line::from(vec![
Span::styled(
format!(" {} commits, ", analysis.total_commits),
Style::default().fg(theme::SAPPHIRE),
),
Span::styled(
format!("High:{}", analysis.high_impact_count),
Style::default().fg(theme::RED),
),
]));
} else {
content.push(Line::from(vec![Span::styled(
format!(" {} commits", analysis.total_commits),
Style::default().fg(theme::SAPPHIRE),
)]));
}
content.push(Line::from(""));
let lang = app.language;
if show_author {
content.push(Line::from(vec![
Span::styled(" ", dim_style),
Span::styled(
format!("{:<width$}", lang.header_score(), width = bar_len + 1),
dim_style,
),
Span::styled(
format!("{:<width$}", lang.header_hash(), width = hash_len + 1),
dim_style,
),
Span::styled(format!("{:<12}", lang.header_author()), dim_style),
Span::styled(lang.header_message(), dim_style),
]));
} else {
content.push(Line::from(vec![
Span::styled(" ", dim_style),
Span::styled(
format!("{:<width$}", lang.header_score_short(), width = bar_len + 1),
dim_style,
),
Span::styled(
format!("{:<width$}", lang.header_hash(), width = hash_len + 1),
dim_style,
),
Span::styled(lang.header_message(), dim_style),
]));
}
content.push(Line::from(vec![Span::styled(
"─".repeat(inner_width.saturating_sub(2)),
dim_style,
)]));
let scroll_offset = app.impact_score_view.nav.scroll_offset;
let end_idx = (scroll_offset + visible_lines).min(analysis.commits.len());
for (idx, commit) in analysis
.commits
.iter()
.enumerate()
.skip(scroll_offset)
.take(end_idx - scroll_offset)
{
let is_selected = idx == app.impact_score_view.nav.selected_index;
let marker = if is_selected { "▶" } else { " " };
let score_color = if commit.score >= 0.7 {
theme::RED
} else if commit.score >= 0.4 {
theme::YELLOW
} else {
theme::GREEN
};
let line_style = if is_selected {
Style::default()
.fg(theme::YELLOW)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let bar = {
let full_bar = commit.score_bar();
truncate_to_width(full_bar, bar_len)
};
let hash_display = truncate_to_width(&commit.commit_hash, hash_len);
let msg_base_offset = if show_author { 30 } else { 18 };
let max_msg_width = inner_width.saturating_sub(msg_base_offset);
let message = truncate_to_width_with_ellipsis(&commit.commit_message, max_msg_width);
if show_author {
let author_name = truncate_to_width_with_ellipsis(&commit.author, 10);
content.push(Line::from(vec![
Span::styled(format!("{} ", marker), line_style),
Span::styled(format!("{} ", bar), Style::default().fg(score_color)),
Span::styled(
format!("{:<width$} ", hash_display, width = hash_len),
line_style,
),
Span::styled(format!("{:<12}", author_name), line_style),
Span::styled(message, line_style),
]));
} else {
content.push(Line::from(vec![
Span::styled(format!("{} ", marker), line_style),
Span::styled(format!("{} ", bar), Style::default().fg(score_color)),
Span::styled(
format!("{:<width$} ", hash_display, width = hash_len),
line_style,
),
Span::styled(message, line_style),
]));
}
}
content.push(Line::from(""));
let scroll_indicator = if analysis.commits.len() > visible_lines {
format!(
" [{}/{}]",
app.impact_score_view.nav.selected_index + 1,
analysis.commits.len()
)
} else {
String::new()
};
if inner_width >= 45 {
content.push(Line::from(vec![
Span::styled("j/k", Style::default().fg(theme::YELLOW)),
Span::raw(lang.hint_move()),
Span::styled("Enter", Style::default().fg(theme::YELLOW)),
Span::raw(lang.hint_detail()),
Span::styled("e", Style::default().fg(theme::YELLOW)),
Span::raw(lang.hint_export()),
Span::styled("I/Esc", Style::default().fg(theme::YELLOW)),
Span::raw(lang.hint_close()),
Span::styled(scroll_indicator, Style::default().fg(theme::SUBTEXT0)),
]));
} else {
content.push(Line::from(vec![
Span::styled("j/k Enter e I/Esc", Style::default().fg(theme::SUBTEXT0)),
Span::styled(scroll_indicator, Style::default().fg(theme::SUBTEXT0)),
]));
}
let paragraph = Paragraph::new(content).block(
Block::default()
.title(app.language.impact_scores())
.borders(Borders::ALL)
.border_style(Style::default().fg(theme::LAVENDER))
.style(Style::default().bg(OVERLAY_BG_COLOR)),
);
frame.render_widget(paragraph, overlay_area);
}
pub(crate) fn render_branch_compare_overlay(frame: &mut Frame, app: &App) {
let Some(ref compare) = app.branch_compare_view.cache else {
return;
};
let ctx = OverlayContext::standard(frame);
let overlay_area = ctx.clear(frame);
let inner_width = ctx.inner_width;
let mut content = Vec::new();
let title = if inner_width >= 40 {
format!(
"Compare: {} ← {}",
compare.base_branch, compare.target_branch
)
} else {
format!(
"{} ← {}",
truncate_to_width_with_ellipsis(&compare.base_branch, 8),
truncate_to_width_with_ellipsis(&compare.target_branch, 8)
)
};
let ahead_style = if app.branch_compare_view.tab == CompareTab::Ahead {
Style::default()
.fg(theme::GREEN)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme::SUBTEXT0)
};
let behind_style = if app.branch_compare_view.tab == CompareTab::Behind {
Style::default().fg(theme::RED).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme::SUBTEXT0)
};
let show_author_line = inner_width >= 45;
let hash_len = if inner_width >= 45 {
8
} else if inner_width >= 30 {
7
} else {
5
};
if inner_width >= 35 {
content.push(Line::from(vec![
Span::styled(
format!(" [Ahead: {}] ", compare.ahead_commits.len()),
ahead_style,
),
Span::styled(
format!("[Behind: {}] ", compare.behind_commits.len()),
behind_style,
),
]));
} else {
content.push(Line::from(vec![
Span::styled(
format!(" [A:{}] ", compare.ahead_commits.len()),
ahead_style,
),
Span::styled(
format!("[B:{}] ", compare.behind_commits.len()),
behind_style,
),
]));
}
content.push(Line::from(""));
let commits = match app.branch_compare_view.tab {
CompareTab::Ahead => &compare.ahead_commits,
CompareTab::Behind => &compare.behind_commits,
};
let visible_height = if show_author_line {
overlay_area.height.saturating_sub(8) as usize
} else {
overlay_area.height.saturating_sub(6) as usize
};
let scroll_offset = app.branch_compare_view.nav.scroll_offset;
let lang = app.language;
if commits.is_empty() {
content.push(Line::from(Span::styled(
lang.no_commits(),
Style::default().fg(theme::SUBTEXT0),
)));
} else {
for (i, commit) in commits.iter().enumerate().skip(scroll_offset) {
if show_author_line {
if i >= scroll_offset + visible_height / 2 {
break;
}
} else if i >= scroll_offset + visible_height {
break;
}
let is_selected = i == app.branch_compare_view.nav.selected_index;
let marker = if is_selected { "▶ " } else { " " };
let style = if is_selected {
Style::default()
.fg(theme::YELLOW)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme::TEXT)
};
let dim_style = if is_selected {
Style::default().fg(theme::YELLOW)
} else {
Style::default().fg(theme::SUBTEXT0)
};
let hash_display = truncate_to_width(&commit.hash, hash_len);
let message_width = inner_width.saturating_sub(hash_len + 5);
let message = truncate_to_width_with_ellipsis(&commit.message, message_width);
content.push(Line::from(vec![
Span::styled(marker, style),
Span::styled(format!("{} ", hash_display), style),
Span::styled(message, style),
]));
if show_author_line {
let now = chrono::Local::now();
let diff = now.signed_duration_since(commit.date);
let time_str = if diff.num_hours() < 1 {
format!("{}m ago", diff.num_minutes().max(1))
} else if diff.num_hours() < 24 {
format!("{}h ago", diff.num_hours())
} else {
format!("{}d ago", diff.num_days())
};
content.push(Line::from(vec![
Span::raw(" "),
Span::styled(
format!("{} ", truncate_to_width_with_ellipsis(&commit.author, 15)),
dim_style,
),
Span::styled(time_str, dim_style),
]));
}
}
}
let actual_visible = if show_author_line {
visible_height / 2
} else {
visible_height
};
let scroll_indicator = if commits.len() > actual_visible {
format!(
" [{}/{}]",
app.branch_compare_view.nav.selected_index + 1,
commits.len()
)
} else {
String::new()
};
content.push(Line::from(""));
if inner_width >= 40 {
content.push(Line::from(vec![
Span::styled("Tab", Style::default().fg(theme::YELLOW)),
Span::raw(lang.hint_switch()),
Span::styled("Enter", Style::default().fg(theme::YELLOW)),
Span::raw(lang.hint_detail()),
Span::styled("Esc", Style::default().fg(theme::YELLOW)),
Span::raw(lang.hint_close()),
Span::styled(scroll_indicator, Style::default().fg(theme::SUBTEXT0)),
]));
} else {
content.push(Line::from(vec![
Span::styled("Tab Enter Esc", Style::default().fg(theme::SUBTEXT0)),
Span::styled(scroll_indicator, Style::default().fg(theme::SUBTEXT0)),
]));
}
let paragraph = Paragraph::new(content).block(
Block::default()
.title(format!(" {} ", title))
.borders(Borders::ALL)
.border_style(Style::default().fg(theme::LAVENDER))
.style(Style::default().bg(OVERLAY_BG_COLOR)),
);
frame.render_widget(paragraph, overlay_area);
}
pub(crate) fn render_change_coupling_view_overlay(frame: &mut Frame, app: &App) {
let ctx = OverlayContext::standard(frame);
let overlay_area = ctx.clear(frame);
let overlay_height = overlay_area.height;
let Some(ref analysis) = app.change_coupling_view.cache else {
let paragraph = Paragraph::new(app.language.loading_change_coupling()).block(
Block::default()
.title(app.language.change_coupling())
.borders(Borders::ALL)
.border_style(Style::default().fg(theme::LAVENDER))
.style(Style::default().bg(OVERLAY_BG_COLOR)),
);
frame.render_widget(paragraph, overlay_area);
return;
};
let dim_style = Style::default().fg(theme::SUBTEXT0);
let inner_width = overlay_area.width.saturating_sub(4) as usize;
let visible_lines = overlay_height.saturating_sub(8) as usize;
let show_bar = inner_width >= 80;
let show_count = inner_width >= 60;
let marker_width = 2; let bar_width = if show_bar { 13 } else { 0 }; let percent_width = 5; let arrow_width = 3; let count_width = if show_count { 8 } else { 0 };
let fixed_width = marker_width + bar_width + percent_width + arrow_width + count_width;
let available_for_files = inner_width.saturating_sub(fixed_width);
let file_width = available_for_files / 2;
let mut content = Vec::new();
if inner_width >= 60 {
content.push(Line::from(vec![
Span::styled(" Total: ", dim_style),
Span::styled(
format!("{}", analysis.couplings.len()),
Style::default().fg(theme::SAPPHIRE),
),
Span::styled(" couplings | High: ", dim_style),
Span::styled(
format!("{}", analysis.high_coupling_count),
Style::default().fg(theme::RED),
),
]));
} else {
content.push(Line::from(vec![Span::styled(
format!("{} couplings", analysis.couplings.len()),
Style::default().fg(theme::SAPPHIRE),
)]));
}
content.push(Line::from(""));
let lang = app.language;
let mut header_spans = vec![Span::styled(" ", dim_style)];
if show_bar {
header_spans.push(Span::styled(lang.header_coupling(), dim_style));
}
header_spans.push(Span::styled(
format!("{:<width$}", lang.header_file(), width = file_width),
dim_style,
));
header_spans.push(Span::styled(" → ", dim_style));
header_spans.push(Span::styled(
format!("{:<width$}", lang.header_coupled_with(), width = file_width),
dim_style,
));
content.push(Line::from(header_spans));
content.push(Line::from(vec![Span::styled(
"─".repeat(inner_width),
dim_style,
)]));
let scroll_offset = app.change_coupling_view.nav.scroll_offset;
let end_idx = (scroll_offset + visible_lines).min(analysis.couplings.len());
for (idx, coupling) in analysis
.couplings
.iter()
.enumerate()
.skip(scroll_offset)
.take(end_idx - scroll_offset)
{
let is_selected = idx == app.change_coupling_view.nav.selected_index;
let marker = if is_selected { "▶" } else { " " };
let coupling_color = if coupling.coupling_percent >= 0.7 {
theme::RED
} else if coupling.coupling_percent >= 0.5 {
theme::YELLOW
} else {
theme::GREEN
};
let line_style = if is_selected {
Style::default()
.fg(theme::YELLOW)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let percent_str = format!("{:>3.0}%", coupling.coupling_percent * 100.0);
let file_name = truncate_to_width_with_ellipsis(&coupling.file, file_width);
let coupled_file_name = truncate_to_width_with_ellipsis(&coupling.coupled_file, file_width);
let mut line_spans = vec![Span::styled(format!("{} ", marker), line_style)];
if show_bar {
let bar = coupling.coupling_bar();
line_spans.push(Span::styled(
format!("{} ", bar),
Style::default().fg(coupling_color),
));
}
line_spans.push(Span::styled(
percent_str,
Style::default().fg(coupling_color),
));
line_spans.push(Span::styled(
format!(" {:<width$}", file_name, width = file_width),
line_style,
));
line_spans.push(Span::styled(" → ", dim_style));
line_spans.push(Span::styled(
format!("{:<width$}", coupled_file_name, width = file_width),
line_style,
));
if show_count {
let count_str = format!(
" ({}/{})",
coupling.co_change_count, coupling.file_change_count
);
line_spans.push(Span::styled(count_str, dim_style));
}
content.push(Line::from(line_spans));
}
content.push(Line::from(""));
let scroll_indicator = if analysis.couplings.len() > visible_lines {
format!(
" [{}/{}]",
app.change_coupling_view.nav.selected_index + 1,
analysis.couplings.len()
)
} else {
String::new()
};
content.push(Line::from(vec![
Span::styled("j/k", Style::default().fg(theme::YELLOW)),
Span::raw(lang.hint_move()),
Span::styled("e", Style::default().fg(theme::YELLOW)),
Span::raw(lang.hint_export()),
Span::styled("C/Esc", Style::default().fg(theme::YELLOW)),
Span::raw(lang.hint_close()),
Span::styled(scroll_indicator, dim_style),
]));
let paragraph = Paragraph::new(content).block(
Block::default()
.title(app.language.change_coupling())
.borders(Borders::ALL)
.border_style(Style::default().fg(theme::LAVENDER))
.style(Style::default().bg(OVERLAY_BG_COLOR)),
);
frame.render_widget(paragraph, overlay_area);
}
pub(crate) fn render_quality_score_view_overlay(frame: &mut Frame, app: &App) {
let ctx = OverlayContext::standard(frame);
let overlay_area = ctx.clear(frame);
let inner_width = ctx.inner_width;
let overlay_height = overlay_area.height;
let Some(ref analysis) = app.quality_score_view.cache else {
let paragraph = Paragraph::new(app.language.loading_quality_scores()).block(
Block::default()
.title(app.language.quality_scores())
.borders(Borders::ALL)
.border_style(Style::default().fg(theme::LAVENDER))
.style(Style::default().bg(OVERLAY_BG_COLOR)),
);
frame.render_widget(paragraph, overlay_area);
return;
};
let dim_style = Style::default().fg(theme::SUBTEXT0);
let show_author = inner_width >= 50;
let bar_len = if inner_width >= 50 {
6
} else if inner_width >= 35 {
4
} else {
3
};
let hash_len = if inner_width >= 50 {
8
} else if inner_width >= 35 {
7
} else {
5
};
let visible_lines = overlay_height.saturating_sub(8) as usize;
let mut content = Vec::new();
if inner_width >= 60 {
content.push(Line::from(vec![
Span::styled(" Total: ", dim_style),
Span::styled(
format!("{}", analysis.total_commits),
Style::default().fg(theme::SAPPHIRE),
),
Span::styled(" commits | Avg: ", dim_style),
Span::styled(
format!("{:.2}", analysis.avg_score),
Style::default().fg(theme::SAPPHIRE),
),
Span::styled(" | High: ", dim_style),
Span::styled(
format!("{}", analysis.high_quality_count),
Style::default().fg(theme::GREEN),
),
Span::styled(" | Low: ", dim_style),
Span::styled(
format!("{}", analysis.low_quality_count),
Style::default().fg(theme::RED),
),
]));
} else if inner_width >= 40 {
content.push(Line::from(vec![
Span::styled(
format!(" {} commits, ", analysis.total_commits),
Style::default().fg(theme::SAPPHIRE),
),
Span::styled(
format!("High:{} ", analysis.high_quality_count),
Style::default().fg(theme::GREEN),
),
Span::styled(
format!("Low:{}", analysis.low_quality_count),
Style::default().fg(theme::RED),
),
]));
} else {
content.push(Line::from(vec![Span::styled(
format!(" {} commits", analysis.total_commits),
Style::default().fg(theme::SAPPHIRE),
)]));
}
content.push(Line::from(""));
let lang = app.language;
if show_author {
content.push(Line::from(vec![
Span::styled(" ", dim_style),
Span::styled(
format!("{:<width$}", lang.header_score(), width = bar_len + 1),
dim_style,
),
Span::styled(
format!("{:<width$}", lang.header_hash(), width = hash_len + 1),
dim_style,
),
Span::styled(format!("{:<12}", lang.header_author()), dim_style),
Span::styled(lang.header_message(), dim_style),
]));
} else {
content.push(Line::from(vec![
Span::styled(" ", dim_style),
Span::styled(
format!("{:<width$}", lang.header_score_short(), width = bar_len + 1),
dim_style,
),
Span::styled(
format!("{:<width$}", lang.header_hash(), width = hash_len + 1),
dim_style,
),
Span::styled(lang.header_message(), dim_style),
]));
}
content.push(Line::from(vec![Span::styled(
"─".repeat(inner_width.saturating_sub(2)),
dim_style,
)]));
let scroll_offset = app.quality_score_view.nav.scroll_offset;
let end_idx = (scroll_offset + visible_lines).min(analysis.commits.len());
for (idx, commit) in analysis
.commits
.iter()
.enumerate()
.skip(scroll_offset)
.take(end_idx - scroll_offset)
{
let is_selected = idx == app.quality_score_view.nav.selected_index;
let marker = if is_selected { "▶" } else { " " };
let score_color = if commit.score >= 0.8 {
theme::GREEN } else if commit.score >= 0.6 {
theme::TEAL } else if commit.score >= 0.4 {
theme::YELLOW } else {
theme::RED };
let line_style = if is_selected {
Style::default()
.fg(theme::YELLOW)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let bar = commit.score_bar();
let bar_str = truncate_to_width(bar, bar_len);
let hash = truncate_to_width(&commit.commit_hash, hash_len);
let author = truncate_to_width(&commit.author, 10);
let used_width = 2 + bar_len + 1 + hash_len + 1 + if show_author { 12 } else { 0 };
let msg_max_width = inner_width.saturating_sub(used_width + 2);
let message = truncate_to_width_with_ellipsis(&commit.commit_message, msg_max_width);
let mut line_spans = vec![
Span::styled(format!("{} ", marker), line_style),
Span::styled(
format!("{:<width$} ", bar_str, width = bar_len),
Style::default().fg(score_color),
),
Span::styled(format!("{:<width$} ", hash, width = hash_len), line_style),
];
if show_author {
line_spans.push(Span::styled(format!("{:<12}", author), dim_style));
}
line_spans.push(Span::styled(message, line_style));
content.push(Line::from(line_spans));
}
content.push(Line::from(""));
let scroll_indicator = if analysis.commits.len() > visible_lines {
format!(
" [{}/{}]",
app.quality_score_view.nav.selected_index + 1,
analysis.commits.len()
)
} else {
String::new()
};
content.push(Line::from(vec![
Span::styled("j/k", Style::default().fg(theme::YELLOW)),
Span::raw(lang.hint_move()),
Span::styled("Enter", Style::default().fg(theme::YELLOW)),
Span::raw(lang.hint_detail()),
Span::styled("e", Style::default().fg(theme::YELLOW)),
Span::raw(lang.hint_export()),
Span::styled("Q/Esc", Style::default().fg(theme::YELLOW)),
Span::raw(lang.hint_close()),
Span::styled(scroll_indicator, dim_style),
]));
let paragraph = Paragraph::new(content).block(
Block::default()
.title(app.language.quality_scores())
.borders(Borders::ALL)
.border_style(Style::default().fg(theme::LAVENDER))
.style(Style::default().bg(OVERLAY_BG_COLOR)),
);
frame.render_widget(paragraph, overlay_area);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::stats::{AuthorStats, FileHeatmap, FileHeatmapEntry, RepoStats};
use chrono::Local;
use ratatui::{backend::TestBackend, Terminal};
fn make_test_app_with_stats() -> App {
let mut app = App::new();
let stats = RepoStats {
authors: vec![AuthorStats {
name: "Alice".to_string(),
commit_count: 10,
insertions: 100,
deletions: 50,
last_commit: Local::now(),
}],
total_commits: 10,
total_insertions: 100,
total_deletions: 50,
};
app.start_stats_view(stats);
app
}
fn make_test_app_with_heatmap() -> App {
let mut app = App::new();
let heatmap = FileHeatmap {
files: vec![FileHeatmapEntry {
path: "src/main.rs".to_string(),
change_count: 5,
max_changes: 5,
}],
total_files: 1,
aggregation_level: Default::default(),
};
app.start_heatmap_view(heatmap);
app
}
#[test]
fn render_stats_view_overlay_no_panic() {
let app = make_test_app_with_stats();
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| render_stats_view_overlay(frame, &app))
.unwrap();
}
#[test]
fn render_stats_view_overlay_loading_no_panic() {
let mut app = App::new();
app.input_mode = crate::app::InputMode::StatsView;
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| render_stats_view_overlay(frame, &app))
.unwrap();
}
#[test]
fn render_heatmap_view_overlay_no_panic() {
let app = make_test_app_with_heatmap();
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| render_heatmap_view_overlay(frame, &app))
.unwrap();
}
#[test]
fn render_heatmap_view_overlay_loading_no_panic() {
let mut app = App::new();
app.input_mode = crate::app::InputMode::HeatmapView;
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| render_heatmap_view_overlay(frame, &app))
.unwrap();
}
#[test]
fn render_timeline_view_overlay_no_panic() {
let app = App::new();
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| render_timeline_view_overlay(frame, &app))
.unwrap();
}
#[test]
fn render_ownership_view_overlay_no_panic() {
let app = App::new();
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| render_ownership_view_overlay(frame, &app))
.unwrap();
}
#[test]
fn render_impact_score_view_overlay_no_panic() {
let app = App::new();
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| render_impact_score_view_overlay(frame, &app))
.unwrap();
}
#[test]
fn render_branch_compare_overlay_no_panic() {
let app = App::new();
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| render_branch_compare_overlay(frame, &app))
.unwrap();
}
#[test]
fn render_change_coupling_view_overlay_no_panic() {
let app = App::new();
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| render_change_coupling_view_overlay(frame, &app))
.unwrap();
}
#[test]
fn render_quality_score_view_overlay_no_panic() {
let app = App::new();
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| render_quality_score_view_overlay(frame, &app))
.unwrap();
}
#[test]
fn render_overlays_minimal_terminal_no_panic() {
let app = make_test_app_with_stats();
let backend = TestBackend::new(20, 10);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| render_stats_view_overlay(frame, &app))
.unwrap();
let app = make_test_app_with_heatmap();
let backend = TestBackend::new(20, 10);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| render_heatmap_view_overlay(frame, &app))
.unwrap();
}
}