use crate::tui::app::{App, InputMode, View};
use crate::tui::theme::ThemeColors;
use crate::version_check::VersionStatus;
use chrono::{Datelike, Local};
use ratatui::layout::Margin;
use ratatui::prelude::*;
use ratatui::widgets::{
Block, Cell, Clear, Paragraph, Row, Scrollbar, ScrollbarOrientation, ScrollbarState, Table,
Tabs,
};
pub fn draw(frame: &mut Frame, app: &mut App) {
let area = frame.area();
if area.height < 6 || area.width < 30 {
let msg = Paragraph::new("Terminal too small").alignment(Alignment::Center);
frame.render_widget(msg, area);
return;
}
if app.has_update_banner() {
let chunks = Layout::vertical([
Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Fill(1), Constraint::Length(1), ])
.split(area);
render_title(frame, chunks[0], app);
render_update_banner(frame, chunks[1], app);
render_tabs(frame, chunks[2], app);
render_table(frame, chunks[3], app);
render_status_bar(frame, chunks[4], app);
} else {
let chunks = Layout::vertical([
Constraint::Length(1), Constraint::Length(1), Constraint::Fill(1), Constraint::Length(1), ])
.split(area);
render_title(frame, chunks[0], app);
render_tabs(frame, chunks[1], app);
render_table(frame, chunks[2], app);
render_status_bar(frame, chunks[3], app);
}
match app.input_mode {
InputMode::SnoozeInput => render_snooze_popup(frame, app),
InputMode::Help => render_help_popup(frame, app),
InputMode::ScoreBreakdown => render_score_breakdown_popup(frame, app),
InputMode::Normal => {}
}
if app.is_loading {
render_loading_overlay(frame, app);
}
}
fn render_title(frame: &mut Frame, area: Rect, app: &App) {
let mut spans = vec![Span::styled(
"PR Bro",
Style::default().fg(app.theme_colors.title_color).bold(),
)];
if let Some(remaining) = app.rate_limit_remaining {
let rate_limit_text = format!("API: {} remaining", remaining);
let left_len = "PR Bro".len();
let right_len = rate_limit_text.len();
let padding_len = (area.width as usize).saturating_sub(left_len + right_len);
spans.push(Span::raw(" ".repeat(padding_len)));
spans.push(Span::styled(
rate_limit_text,
Style::default().fg(app.theme_colors.muted),
));
}
let title = Line::from(spans);
frame.render_widget(Paragraph::new(title), area);
}
fn render_update_banner(frame: &mut Frame, area: Rect, app: &App) {
if let VersionStatus::UpdateAvailable { current, latest } = &app.version_status {
let banner_text = Line::from(vec![
Span::styled(
format!(" Update available: v{} -> v{} ", current, latest),
Style::default().fg(app.theme_colors.banner_fg),
),
Span::raw("["),
Span::styled("x", Style::default().fg(app.theme_colors.banner_key).bold()),
Span::styled(
" to dismiss]",
Style::default().fg(app.theme_colors.banner_fg),
),
]);
let banner =
Paragraph::new(banner_text).style(Style::default().bg(app.theme_colors.banner_bg));
frame.render_widget(banner, area);
}
}
fn render_tabs(frame: &mut Frame, area: Rect, app: &App) {
let titles = vec!["Active", "Snoozed"];
let selected = match app.current_view {
View::Active => 0,
View::Snoozed => 1,
};
let tabs = Tabs::new(titles)
.select(selected)
.style(Style::default().fg(app.theme_colors.muted))
.highlight_style(
Style::default()
.fg(app.theme_colors.title_color)
.bold()
.reversed(),
)
.divider(" | ")
.padding(" ", " ");
frame.render_widget(tabs, area);
}
fn render_table(frame: &mut Frame, area: Rect, app: &mut App) {
let prs = app.current_prs();
if prs.is_empty() {
let empty_msg = Paragraph::new("No PRs to review")
.alignment(Alignment::Center)
.block(Block::default());
frame.render_widget(empty_msg, area);
return;
}
let max_score = prs
.iter()
.map(|(_, result)| result.score)
.fold(0.0_f64, f64::max);
let pr_count = prs.len();
let selected_pos = app.table_state.selected().unwrap_or(0);
let (rows, widths, header_cells): (Vec<Row>, Vec<Constraint>, Vec<&str>) =
if matches!(app.current_view, View::Snoozed) {
let rows: Vec<Row> = prs
.iter()
.enumerate()
.map(|(idx, (pr, score_result))| {
let index = format!("{}.", idx + 1);
let score_str = format_score(score_result.score, score_result.incomplete);
let bar_line = score_bar(score_result.score, max_score, 8, &app.theme_colors);
let score_color = app.theme_colors.score_color(score_result.score, max_score);
let mut score_spans = vec![Span::styled(
format!("{:>5} ", score_str),
Style::default().fg(score_color),
)];
score_spans.extend(bar_line.spans);
let score_line = Line::from(score_spans);
let title = pr.title.clone();
let duration = app
.snooze_state
.snoozed_entries()
.get(&pr.url)
.map(|entry| entry.format_remaining())
.unwrap_or_else(|| "unknown".to_string());
let row_style = if idx % 2 == 1 {
Style::default().bg(app.theme_colors.row_alt_bg)
} else {
Style::default()
};
Row::new(vec![
Cell::from(index).style(Style::default().fg(app.theme_colors.index_color)),
Cell::from(score_line),
Cell::from(title),
Cell::from(duration).style(Style::default().fg(app.theme_colors.muted)),
Cell::from(pr.short_ref()),
])
.style(row_style)
})
.collect();
let widths = vec![
Constraint::Length(4), Constraint::Length(16), Constraint::Fill(1), Constraint::Length(12), Constraint::Length(40), ];
let header = vec!["#", "Score", "Title", "Duration", "PR"];
(rows, widths, header)
} else {
let rows: Vec<Row> = prs
.iter()
.enumerate()
.map(|(idx, (pr, score_result))| {
let index = format!("{}.", idx + 1);
let score_str = format_score(score_result.score, score_result.incomplete);
let bar_line = score_bar(score_result.score, max_score, 8, &app.theme_colors);
let score_color = app.theme_colors.score_color(score_result.score, max_score);
let mut score_spans = vec![Span::styled(
format!("{:>5} ", score_str),
Style::default().fg(score_color),
)];
score_spans.extend(bar_line.spans);
let score_line = Line::from(score_spans);
let title = pr.title.clone();
let row_style = if idx % 2 == 1 {
Style::default().bg(app.theme_colors.row_alt_bg)
} else {
Style::default()
};
Row::new(vec![
Cell::from(index).style(Style::default().fg(app.theme_colors.index_color)),
Cell::from(score_line),
Cell::from(title),
Cell::from(pr.short_ref()),
])
.style(row_style)
})
.collect();
let widths = vec![
Constraint::Length(4), Constraint::Length(16), Constraint::Fill(1), Constraint::Length(40), ];
let header = vec!["#", "Score", "Title", "PR"];
(rows, widths, header)
};
let table = Table::new(rows, widths)
.header(
Row::new(header_cells)
.style(app.theme_colors.header_style)
.bottom_margin(1),
)
.row_highlight_style(app.theme_colors.row_selected);
frame.render_stateful_widget(table, area, &mut app.table_state);
let visible_rows = area.height.saturating_sub(2) as usize; if pr_count > visible_rows {
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.thumb_style(Style::default().fg(app.theme_colors.scrollbar_thumb))
.track_style(Style::default().fg(app.theme_colors.scrollbar_track));
let mut scrollbar_state = ScrollbarState::new(pr_count).position(selected_pos);
frame.render_stateful_widget(scrollbar, area, &mut scrollbar_state);
}
}
fn render_status_bar(frame: &mut Frame, area: Rect, app: &App) {
let text = if let Some((ref msg, _)) = app.flash_message {
let msg_color =
if msg.starts_with("Failed") || msg.starts_with("Error") || msg.contains("cancelled") {
app.theme_colors.flash_error
} else if msg.starts_with("Snoozed:")
|| msg.starts_with("Unsnoozed:")
|| msg.starts_with("Re-snoozed:")
|| msg.starts_with("Undid")
|| msg.starts_with("Refreshed")
|| msg.starts_with("Opened:")
{
app.theme_colors.flash_success
} else {
Color::Reset };
Line::from(Span::styled(msg.clone(), Style::default().fg(msg_color)))
} else {
let prs = app.current_prs();
let count = format!("{} PRs", prs.len());
let view_mode = match app.current_view {
View::Active => "Active",
View::Snoozed => "Snoozed",
};
let elapsed = app.last_refresh.elapsed();
let refresh_time = if elapsed.as_secs() < 60 {
format!("refreshed {}s ago", elapsed.as_secs())
} else {
format!("refreshed {}m ago", elapsed.as_secs() / 60)
};
let mut hint_spans = Vec::new();
let hints = match app.current_view {
View::Active => vec![
("j", "/", "k", ":nav "),
("Enter", "", "", ":open "),
("b", "", "", ":breakdown "),
("s", "", "", ":snooze "),
("r", "", "", ":refresh "),
("Tab", "", "", ":snoozed "),
("?", "", "", ":help "),
("q", "", "", ":quit"),
],
View::Snoozed => vec![
("j", "/", "k", ":nav "),
("Enter", "", "", ":open "),
("b", "", "", ":breakdown "),
("s", "", "", ":resnooze "),
("u", "", "", ":unsnooze "),
("r", "", "", ":refresh "),
("Tab", "", "", ":active "),
("?", "", "", ":help "),
("q", "", "", ":quit"),
],
};
for (i, (key1, sep, key2, label)) in hints.iter().enumerate() {
if i > 0 {
hint_spans.push(Span::raw(" "));
}
hint_spans.push(Span::styled(
*key1,
Style::default().fg(app.theme_colors.status_key_color),
));
if !sep.is_empty() {
hint_spans.push(Span::raw(*sep));
hint_spans.push(Span::styled(
*key2,
Style::default().fg(app.theme_colors.status_key_color),
));
}
hint_spans.push(Span::raw(*label));
}
let mut spans = vec![
Span::styled(count, Style::default().fg(app.theme_colors.muted)),
Span::raw(" "),
Span::styled(view_mode, Style::default().fg(app.theme_colors.muted)),
Span::raw(" "),
Span::styled(refresh_time, Style::default().fg(app.theme_colors.muted)),
Span::raw(" "),
];
spans.extend(hint_spans);
Line::from(spans)
};
frame.render_widget(
Paragraph::new(text).style(Style::default().bg(app.theme_colors.status_bar_bg)),
area,
);
}
fn format_score(score: f64, incomplete: bool) -> String {
let formatted = if score >= 1_000_000.0 {
format!("{:.1}M", score / 1_000_000.0)
} else if score >= 1_000.0 {
format!("{:.1}k", score / 1_000.0)
} else {
format!("{:.0}", score)
};
let trimmed = formatted.replace(".0M", "M").replace(".0k", "k");
if incomplete {
format!("{}*", trimmed)
} else {
trimmed
}
}
fn score_bar(
score: f64,
max_score: f64,
width: usize,
theme_colors: &ThemeColors,
) -> Line<'static> {
let ratio = if max_score > 0.0 {
(score / max_score).min(1.0)
} else {
0.0
};
let filled = (ratio * width as f64).round() as usize;
let empty = width.saturating_sub(filled);
let bar_color = theme_colors.score_color(score, max_score);
let mut spans = Vec::new();
if filled > 0 {
spans.push(Span::styled(
"█".repeat(filled),
Style::default().fg(bar_color),
));
}
if empty > 0 {
spans.push(Span::styled(
"░".repeat(empty),
Style::default().fg(theme_colors.bar_empty),
));
}
Line::from(spans)
}
fn render_snooze_popup(frame: &mut Frame, app: &App) {
let popup_area = centered_rect_fixed(40, 7, frame.area());
frame.render_widget(Clear, popup_area);
let block = Block::bordered()
.title("Snooze Duration")
.border_style(Style::default().fg(app.theme_colors.popup_border))
.title_style(app.theme_colors.popup_title)
.style(Style::default().bg(app.theme_colors.popup_bg));
frame.render_widget(block.clone(), popup_area);
let inner = block.inner(popup_area);
let chunks = Layout::vertical([
Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), ])
.split(inner);
let input_line = Line::from(vec![
Span::raw(&app.snooze_input),
Span::styled("|", Style::default().fg(Color::Cyan)),
]);
let input = Paragraph::new(input_line);
frame.render_widget(input, chunks[0]);
let parse_result = if app.snooze_input.trim().is_empty() {
None } else {
Some(humantime::parse_duration(app.snooze_input.trim()))
};
let preview_text = match &parse_result {
None => "indefinite".to_string(),
Some(Ok(d)) => humantime::format_duration(*d).to_string(),
Some(Err(_)) => "invalid duration".to_string(),
};
let preview_color = match &parse_result {
None => app.theme_colors.muted,
Some(Ok(_)) => Color::Green,
Some(Err(_)) => Color::Red,
};
let preview = Paragraph::new(Line::from(vec![
Span::styled("Duration: ", Style::default().fg(app.theme_colors.muted)),
Span::styled(preview_text, Style::default().fg(preview_color)),
]));
frame.render_widget(preview, chunks[1]);
let end_time_text = match &parse_result {
None => "Ends: never".to_string(),
Some(Ok(d)) => {
let now = Local::now();
let end = now + chrono::Duration::from_std(*d).unwrap_or_default();
let days_away = (end.date_naive() - now.date_naive()).num_days();
let time = end.format("%H:%M");
let days_to_week_end = 7 - now.weekday().number_from_monday() as i64;
let date_part = if days_away == 0 {
"today".to_string()
} else if days_away == 1 {
"tomorrow".to_string()
} else if days_away <= days_to_week_end {
format!("this {}", end.format("%A"))
} else if days_away <= days_to_week_end + 7 {
format!("next {}", end.format("%A"))
} else if end.year() == now.year() {
end.format("%b %-d").to_string()
} else {
end.format("%b %-d, %Y").to_string()
};
format!("Ends: {} {}", date_part, time)
}
Some(Err(_)) => String::new(),
};
let end_time = Paragraph::new(end_time_text).style(Style::default().fg(app.theme_colors.muted));
frame.render_widget(end_time, chunks[2]);
let help = Paragraph::new("Enter: confirm | Esc: cancel")
.style(Style::default().fg(app.theme_colors.muted));
frame.render_widget(help, chunks[3]);
}
fn centered_rect_fixed(width: u16, height: u16, area: Rect) -> Rect {
let width = width.min(area.width);
let height = height.min(area.height);
let x = area.x + (area.width.saturating_sub(width)) / 2;
let y = area.y + (area.height.saturating_sub(height)) / 2;
Rect {
x,
y,
width,
height,
}
}
fn render_help_popup(frame: &mut Frame, app: &App) {
let popup_area = centered_rect_fixed(50, 17, frame.area());
frame.render_widget(Clear, popup_area);
let block = Block::bordered()
.title(" Keyboard Shortcuts ")
.border_style(Style::default().fg(app.theme_colors.popup_border))
.title_style(app.theme_colors.popup_title)
.style(Style::default().bg(app.theme_colors.popup_bg));
frame.render_widget(block.clone(), popup_area);
let inner = block.inner(popup_area);
let help_entries: Vec<(&str, &str)> = vec![
("j / Down", "Move down"),
("k / Up", "Move up"),
("Enter / o", "Open PR in browser"),
("b", "Score breakdown"),
("s", "Snooze / re-snooze PR"),
("u", "Unsnooze PR"),
("z", "Undo last action"),
("Tab", "Toggle Active/Snoozed"),
("r", "Refresh PRs (bypasses cache)"),
("?", "Show/hide this help"),
("q / Ctrl-c", "Quit"),
];
let max_key_width = help_entries.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
let mut help_lines: Vec<Line> = help_entries
.iter()
.map(|(key, description)| {
Line::from(vec![
Span::styled(
format!("{:<width$}", key, width = max_key_width + 4),
Style::default()
.fg(app.theme_colors.status_key_color)
.bold(),
),
Span::raw(*description),
])
})
.collect();
help_lines.push(Line::from(""));
help_lines.push(Line::from(Span::styled(
"Press any key to close",
Style::default().fg(app.theme_colors.muted),
)));
let help_text = Paragraph::new(help_lines);
frame.render_widget(help_text, inner);
}
fn render_loading_overlay(frame: &mut Frame, app: &App) {
let popup_area = centered_rect_fixed(30, 3, frame.area());
frame.render_widget(Clear, popup_area);
let block = Block::bordered()
.border_style(Style::default().fg(app.theme_colors.popup_border))
.style(Style::default().bg(app.theme_colors.popup_bg));
frame.render_widget(block.clone(), popup_area);
let inner = block.inner(popup_area);
let spinner_chars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
let spinner = spinner_chars[app.spinner_frame % 10];
let text = if app.active_prs.is_empty() && app.snoozed_prs.is_empty() {
format!("{} Loading PRs...", spinner)
} else {
format!("{} Refreshing...", spinner)
};
let loading_text = Paragraph::new(text)
.alignment(Alignment::Center)
.style(Style::default().fg(app.theme_colors.title_color));
frame.render_widget(loading_text, inner);
}
fn render_score_breakdown_popup(frame: &mut Frame, app: &App) {
let (pr, score_result) = match app.selected_pr().zip(app.selected_score_result()) {
Some(pair) => pair,
None => return, };
let breakdown = &score_result.breakdown;
let num_factors = breakdown.factors.len();
let factor_lines = if num_factors == 0 { 2 } else { num_factors * 2 };
let content_height = 4 + factor_lines + 3;
let popup_height = (content_height as u16).min(frame.area().height.saturating_sub(2));
let popup_area = centered_rect_fixed(57, popup_height + 2, frame.area());
frame.render_widget(Clear, popup_area);
let block = Block::bordered()
.title(" Score Breakdown ")
.border_style(Style::default().fg(app.theme_colors.popup_border))
.title_style(app.theme_colors.popup_title)
.style(Style::default().bg(app.theme_colors.popup_bg));
frame.render_widget(block.clone(), popup_area);
let inner = block.inner(popup_area);
let inner = inner.inner(Margin {
horizontal: 1,
vertical: 1,
});
let mut lines = Vec::new();
lines.push(Line::from(Span::styled(
pr.short_ref(),
Style::default().fg(app.theme_colors.muted),
)));
let max_title_width = (inner.width as usize).saturating_sub(2);
let title = if pr.title.len() > max_title_width {
format!("{}...", &pr.title[..max_title_width.saturating_sub(3)])
} else {
pr.title.clone()
};
lines.push(Line::from(title));
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::raw("Base score: "),
Span::styled(
format!("{:.1}", breakdown.base_score),
Style::default().bold(),
),
]));
if breakdown.factors.is_empty() {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"No scoring factors configured",
Style::default().fg(app.theme_colors.muted),
)));
} else {
for factor in &breakdown.factors {
let after_color = if factor.after > factor.before {
Color::Green
} else if factor.after < factor.before {
Color::Red
} else {
Color::Reset
};
lines.push(Line::from(vec![
Span::styled(
format!("{}: ", factor.label),
Style::default().fg(app.theme_colors.title_color).bold(),
),
Span::styled(
format!("{:.1}", factor.before),
Style::default().fg(app.theme_colors.muted),
),
Span::raw(" -> "),
Span::styled(
format!("{:.1}", factor.after),
Style::default().fg(after_color).bold(),
),
]));
lines.push(Line::from(Span::styled(
format!(" {}", factor.description),
Style::default().fg(app.theme_colors.muted),
)));
}
}
lines.push(Line::from(""));
let max_score = app
.current_prs()
.iter()
.map(|(_, sr)| sr.score)
.fold(0.0_f64, f64::max);
let score_color = app.theme_colors.score_color(score_result.score, max_score);
lines.push(Line::from(vec![
Span::raw("Final score: "),
Span::styled(
format!("{:.1}", score_result.score),
Style::default().fg(score_color).bold(),
),
]));
lines.push(Line::from(Span::styled(
"Esc or d to close",
Style::default().fg(app.theme_colors.muted),
)));
let paragraph = Paragraph::new(lines);
frame.render_widget(paragraph, inner);
}