use crate::github::PendingReview;
use crate::tui::app::{App, PrAction, Tab};
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{
Block, Borders, Cell, Clear, List, ListDirection, ListItem, Paragraph, Row, Table,
};
use ratatui::Frame;
pub struct Ui;
impl Ui {
pub fn draw(frame: &mut Frame, app: &mut App) {
let size = frame.area();
let areas = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(25), Constraint::Min(60), ])
.split(size);
Self::draw_sidebar(frame, app, areas[0]);
Self::draw_main_content(frame, app, areas[1]);
if app.show_action_menu {
Self::draw_action_menu(frame, app, size);
}
else if app.show_help {
Self::draw_help_overlay(frame, app, size);
}
if app.loading {
if app.reviews.is_empty() {
Self::draw_loading_indicator(frame, app, size);
} else {
Self::draw_refresh_spinner(frame, app, size);
}
}
if let Some(info) = &app.info {
Self::draw_info_message(frame, info, size);
}
if let Some(error) = &app.error {
Self::draw_error_message(frame, error, size);
}
}
fn draw_sidebar(frame: &mut Frame, app: &App, area: Rect) {
let tabs = &app.tabs;
let active_tab = &app.active_tab;
let tab_items: Vec<ListItem> = tabs
.iter()
.map(|tab| {
let icon = tab.icon();
let name = format!(" {}", tab);
let is_active = *tab == *active_tab;
let style = if is_active {
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD)
.bg(Color::DarkGray)
} else {
Style::default().fg(Color::Gray)
};
let content = Span::styled(format!("{} {}", icon, name), style);
ListItem::new(content)
})
.collect();
let list = List::new(tab_items)
.direction(ListDirection::TopToBottom)
.block(
Block::default()
.title(Span::styled(
" Commands ",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Blue)),
)
.highlight_style(
Style::default()
.fg(Color::Black)
.add_modifier(Modifier::BOLD)
.bg(Color::LightCyan),
)
.highlight_symbol("> ");
let active_index = tabs.iter().position(|t| *t == *active_tab).unwrap_or(0);
let mut state = ratatui::widgets::ListState::default();
state.select(Some(active_index));
frame.render_stateful_widget(list, area, &mut state);
Self::draw_sidebar_footer(frame, area);
}
fn draw_sidebar_footer(frame: &mut Frame, area: Rect) {
let footer_text = vec![
Span::styled("↑↓ ", Color::Gray),
Span::styled("Navigate ", Color::White),
Span::styled("Tab ", Color::Gray),
Span::styled("Switch ", Color::White),
Span::styled("q ", Color::Gray),
Span::styled("Quit", Color::White),
];
let footer = Paragraph::new(Line::from(footer_text))
.block(Block::default())
.alignment(Alignment::Center);
let footer_area = Rect {
x: area.x,
y: area.y + area.height.saturating_sub(1),
width: area.width,
height: 1,
};
frame.render_widget(footer, footer_area);
}
fn draw_main_content(frame: &mut Frame, app: &App, area: Rect) {
let areas = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(2), Constraint::Min(10), Constraint::Length(2), ])
.split(area);
Self::draw_header(frame, app, areas[0]);
match app.active_tab {
Tab::Statistics => Self::draw_statistics(frame, app, areas[1]),
_ => Self::draw_pr_list(frame, app, areas[1]),
}
Self::draw_main_footer(frame, app, areas[2]);
}
fn draw_header(frame: &mut Frame, app: &App, area: Rect) {
let tab_name = format!("{}", app.active_tab);
let refresh_info = if let Some(last) = app.last_refresh {
let elapsed = last.signed_duration_since(chrono::Utc::now());
let seconds = elapsed.num_seconds().unsigned_abs();
format!(" | Last refresh: {}s ago", seconds)
} else {
String::new()
};
let title = Span::styled(
format!("{} {}", tab_name, refresh_info),
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
);
let header = Paragraph::new(title)
.block(
Block::default()
.title(Span::styled(
format!(" {} ", app.active_tab.icon()),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
))
.borders(Borders::BOTTOM)
.border_style(Style::default().fg(Color::Blue)),
)
.alignment(Alignment::Left);
frame.render_widget(header, area);
}
fn draw_pr_list(frame: &mut Frame, app: &App, area: Rect) {
let filtered_reviews = app.filtered_reviews();
let rows: Vec<Row> = filtered_reviews
.iter()
.map(|pr| {
let age = Self::format_duration(pr.created_at);
let age_days = (chrono::Utc::now() - pr.created_at).num_days();
let size = format!("+{}/-{}", pr.additions, pr.deletions);
let draft = if pr.draft { " [DRAFT]" } else { "" };
let (number_style, title_style, age_style) = if pr.draft {
(
Style::default().fg(Color::Gray),
Style::default()
.fg(Color::Gray)
.add_modifier(Modifier::ITALIC),
Style::default().fg(Color::Gray),
)
} else if age_days > 7 {
(
Style::default().fg(Color::Red),
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
Style::default().fg(Color::Red),
)
} else if age_days > 3 {
(
Style::default().fg(Color::Yellow),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
Style::default().fg(Color::Yellow),
)
} else {
(
Style::default().fg(Color::Green),
Style::default().fg(Color::White),
Style::default().fg(Color::Green),
)
};
Row::new(vec![
Cell::from(Span::styled(pr.pr_number.to_string(), number_style)),
Cell::from(Span::styled(
format!("{}{}", pr.pr_title, draft),
title_style,
)),
Cell::from(Span::styled(
pr.pr_author.as_str(),
Style::default().fg(Color::Cyan),
)),
Cell::from(Span::styled(
pr.repo.as_str(),
Style::default().fg(Color::Magenta),
)),
Cell::from(Span::styled(age, age_style)),
Cell::from(Span::styled(size, Style::default().fg(Color::Blue))),
])
})
.collect();
let widths = &[
Constraint::Length(5), Constraint::Min(40), Constraint::Length(15), Constraint::Length(20), Constraint::Length(8), Constraint::Length(10), ];
let table = Table::new(rows, widths)
.header(
Row::new(vec!["#", "Title", "Author", "Repo", "Age", "Changes"])
.style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)
.bottom_margin(1),
)
.block(
Block::default()
.borders(Borders::NONE)
.padding(ratatui::widgets::Padding::horizontal(1)),
)
.row_highlight_style(
Style::default()
.fg(Color::Black)
.bg(Color::LightGreen)
.add_modifier(Modifier::BOLD),
)
.column_spacing(1);
let selected_index = app.filtered_reviews().iter().position(|&pr| {
let pr_ptr = pr as *const PendingReview;
let selected_ptr = &app.reviews[app.selected_pr] as *const PendingReview;
std::ptr::eq(pr_ptr, selected_ptr)
});
let mut state = ratatui::widgets::TableState::default();
if let Some(index) = selected_index {
state.select(Some(index));
}
frame.render_stateful_widget(table, area, &mut state);
if !app.filter.is_empty() {
Self::draw_filter_input(frame, app, area);
}
}
fn draw_filter_input(frame: &mut Frame, app: &App, area: Rect) {
let input = format!("/{}", app.filter);
let filter_widget = Paragraph::new(input)
.block(
Block::default()
.title("Filter (press Esc to clear)")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow)),
)
.style(Style::default().fg(Color::Yellow));
let filter_area = Rect {
x: area.x,
y: area.y,
width: area.width.min(50),
height: 3,
};
frame.render_widget(Clear, filter_area);
frame.render_widget(filter_widget, filter_area);
}
fn draw_statistics(frame: &mut Frame, app: &App, area: Rect) {
let reviews = &app.reviews;
let total_prs = reviews.len();
let draft_prs = reviews.iter().filter(|pr| pr.draft).count();
let total_additions: u64 = reviews.iter().map(|pr| pr.additions).sum();
let total_deletions: u64 = reviews.iter().map(|pr| pr.deletions).sum();
let authors: std::collections::HashSet<_> =
reviews.iter().map(|pr| &pr.pr_author).collect();
let repos: std::collections::HashSet<_> = reviews.iter().map(|pr| &pr.repo).collect();
let stats_text = vec![
Line::from(vec![
Span::styled("Total PRs: ", Color::Cyan),
Span::styled(format!("{}", total_prs), Color::White),
]),
Line::from(vec![
Span::styled("Draft PRs: ", Color::Cyan),
Span::styled(format!("{}", draft_prs), Color::Yellow),
]),
Line::from(vec![
Span::styled("Total +Lines: ", Color::Cyan),
Span::styled(format!("{}", total_additions), Color::Green),
]),
Line::from(vec![
Span::styled("Total -Lines: ", Color::Cyan),
Span::styled(format!("{}", total_deletions), Color::Red),
]),
Line::from(vec![
Span::styled("Unique Authors: ", Color::Cyan),
Span::styled(format!("{}", authors.len()), Color::White),
]),
Line::from(vec![
Span::styled("Repositories: ", Color::Cyan),
Span::styled(format!("{}", repos.len()), Color::White),
]),
];
let stats = Paragraph::new(stats_text)
.block(
Block::default()
.title("Statistics")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Gray)),
)
.alignment(Alignment::Left);
frame.render_widget(stats, area);
}
fn draw_main_footer(frame: &mut Frame, app: &App, area: Rect) {
let filtered_count = app.filtered_reviews().len();
let total_count = app.reviews.len();
let status_text = if app.filter.is_empty() {
format!("{} PRs", filtered_count)
} else {
format!("{} of {} PRs (filtered)", filtered_count, total_count)
};
let next_refresh = if app.loading {
String::from("Refreshing...")
} else {
let duration = app.next_refresh_duration();
format!("Refresh in {}s", duration.as_secs())
};
let mut footer_parts = vec![
Span::styled(status_text, Color::White),
Span::styled(" | ", Color::Gray),
Span::styled(next_refresh, Color::Cyan),
];
if !app.filter.is_empty() {
footer_parts.extend(vec![
Span::styled(" | ", Color::Gray),
Span::styled("🔍 ", Color::Yellow),
Span::styled(&app.filter, Color::LightYellow),
]);
}
footer_parts.extend(vec![
Span::styled(" | ", Color::Gray),
Span::styled("r ", Color::Yellow),
Span::styled("Refresh ", Color::White),
Span::styled("| ", Color::Gray),
Span::styled("/ ", Color::Yellow),
Span::styled("Filter ", Color::White),
Span::styled("| ", Color::Gray),
Span::styled("Enter ", Color::Yellow),
Span::styled("Actions ", Color::White),
Span::styled("| ", Color::Gray),
Span::styled("? ", Color::Yellow),
Span::styled("Help ", Color::White),
]);
let footer = Paragraph::new(Line::from(footer_parts))
.block(
Block::default()
.borders(Borders::TOP)
.border_style(Style::default().fg(Color::Blue)),
)
.alignment(Alignment::Left);
frame.render_widget(footer, area);
}
fn draw_help_overlay(frame: &mut Frame, _app: &App, size: Rect) {
let help_text = Text::from(vec![
Line::from(vec![Span::styled(
"PRCtrl TUI - Keyboard Shortcuts",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)]),
Line::from(vec![]),
Line::from(vec![Span::styled(
"Navigation:",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)]),
Line::from(vec![
Span::styled(" j / ↓ ", Color::Green),
Span::styled(" - Move down", Color::White),
]),
Line::from(vec![
Span::styled(" k / ↑ ", Color::Green),
Span::styled(" - Move up", Color::White),
]),
Line::from(vec![
Span::styled(" Tab ", Color::Green),
Span::styled(" - Next tab", Color::White),
]),
Line::from(vec![
Span::styled(" Shift+Tab ", Color::Green),
Span::styled(" - Previous tab", Color::White),
]),
Line::from(vec![]),
Line::from(vec![Span::styled(
"Actions:",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)]),
Line::from(vec![
Span::styled(" r ", Color::Green),
Span::styled(" - Refresh", Color::White),
]),
Line::from(vec![
Span::styled(" / ", Color::Green),
Span::styled(" - Start filtering", Color::White),
]),
Line::from(vec![
Span::styled(" Esc ", Color::Green),
Span::styled(" - Clear filter / Close help", Color::White),
]),
Line::from(vec![
Span::styled(" Enter ", Color::Green),
Span::styled(" - Select PR", Color::White),
]),
Line::from(vec![
Span::styled(" ? ", Color::Green),
Span::styled(" - Toggle help", Color::White),
]),
Line::from(vec![]),
Line::from(vec![Span::styled(
"Quit:",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)]),
Line::from(vec![
Span::styled(" q ", Color::Green),
Span::styled(" - Quit", Color::White),
]),
Line::from(vec![
Span::styled(" Esc ", Color::Green),
Span::styled(" - Quit", Color::White),
]),
]);
let help_widget = Paragraph::new(help_text).block(
Block::default()
.title("Help")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan)),
);
let help_area = Rect {
x: size.x + size.width / 4,
y: size.y + size.height / 4,
width: size.width / 2,
height: size.height / 2,
};
frame.render_widget(Clear, help_area);
frame.render_widget(help_widget, help_area);
}
fn draw_loading_indicator(frame: &mut Frame, app: &App, size: Rect) {
use ratatui::symbols::border;
let spinner = Self::spinner(app.spinner_frame);
let loading_text = vec![
Line::from(vec![
Span::styled(spinner, Color::LightYellow),
Span::styled(" Loading PRs...", Color::Cyan),
]),
Line::from(vec![
Span::styled(" ", Color::Gray), ]),
Line::from(Span::styled(
"Please wait while we fetch your reviews",
Color::Gray,
)),
];
let loading = Paragraph::new(loading_text)
.block(
Block::default()
.title(Span::styled(
" Loading ",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Blue))
.border_set(border::ROUNDED),
)
.alignment(Alignment::Center);
let loading_width = 40.min(size.width.saturating_sub(4));
let loading_height = 5.min(size.height.saturating_sub(4));
let loading_area = Rect {
x: (size.width.saturating_sub(loading_width)) / 2,
y: (size.height.saturating_sub(loading_height)) / 2,
width: loading_width,
height: loading_height,
};
frame.render_widget(Clear, loading_area);
frame.render_widget(loading, loading_area);
}
fn draw_refresh_spinner(frame: &mut Frame, app: &App, size: Rect) {
let spinner = Self::spinner(app.spinner_frame);
let spinner_text = vec![
Span::styled(spinner, Color::LightYellow),
Span::styled(" Loading...", Color::Cyan),
];
let spinner_widget = Paragraph::new(Line::from(spinner_text))
.block(Block::default())
.alignment(Alignment::Right);
let spinner_area = Rect {
x: size.width.saturating_sub(25),
y: size.height.saturating_sub(1),
width: 25,
height: 1,
};
frame.render_widget(spinner_widget, spinner_area);
}
fn draw_info_message(frame: &mut Frame, info: &str, size: Rect) {
use ratatui::symbols::border;
let info_text = vec![
Span::styled("ℹ️ ", Color::Cyan),
Span::styled(info, Color::LightCyan),
];
let info_widget = Paragraph::new(Line::from(info_text))
.block(
Block::default()
.title(Span::styled(
" Info ",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Blue))
.border_set(border::ROUNDED),
)
.alignment(Alignment::Left);
let info_area = Rect {
x: size.x + size.width / 4,
y: size.y + size.height / 3,
width: size.width / 2,
height: 5,
};
frame.render_widget(Clear, info_area);
frame.render_widget(info_widget, info_area);
}
fn draw_error_message(frame: &mut Frame, error: &str, size: Rect) {
let error_text = vec![
Span::styled("⚠️ ", Color::Red),
Span::styled(error, Color::LightRed),
];
let error_widget = Paragraph::new(Line::from(error_text))
.block(
Block::default()
.title(Span::styled(
" Error ",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Red)),
)
.alignment(Alignment::Left);
let error_area = Rect {
x: size.x + size.width / 4,
y: size.y + size.height / 2,
width: size.width / 2,
height: 5,
};
frame.render_widget(Clear, error_area);
frame.render_widget(error_widget, error_area);
}
fn spinner(frame: usize) -> &'static str {
const SPINNER_FRAMES: [&str; 4] = ["⠋", "⠙", "⠹", "⠸"];
SPINNER_FRAMES[frame % SPINNER_FRAMES.len()]
}
fn format_duration(timestamp: chrono::DateTime<chrono::Utc>) -> String {
let now = chrono::Utc::now();
let duration = now.signed_duration_since(timestamp);
if duration.num_days() > 0 {
format!("{}d", duration.num_days())
} else if duration.num_hours() > 0 {
format!("{}h", duration.num_hours())
} else if duration.num_minutes() > 0 {
format!("{}m", duration.num_minutes())
} else {
format!("{}s", duration.num_seconds())
}
}
fn draw_action_menu(frame: &mut Frame, app: &App, area: Rect) {
use ratatui::symbols::border;
let actions = PrAction::all();
let popup_width = 50;
let popup_height = actions.len() as u16 + 5;
let popup_area = Rect {
x: (area.width.saturating_sub(popup_width)) / 2,
y: (area.height.saturating_sub(popup_height)) / 2,
width: popup_width.min(area.width),
height: popup_height.min(area.height),
};
let block = Block::default()
.title(Span::styled(
" Actions ",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
))
.title_alignment(Alignment::Center)
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Blue))
.border_set(border::ROUNDED);
frame.render_widget(Clear, popup_area);
frame.render_widget(block, popup_area);
let action_items: Vec<ListItem> = actions
.iter()
.map(|action| {
let icon = action.icon();
let display = action.display();
let content = Line::from(vec![
Span::styled(icon, Style::default().fg(Color::Cyan)),
Span::styled(" ", Style::default()),
Span::styled(display, Style::default().fg(Color::White)),
]);
ListItem::new(content)
})
.collect();
let list = List::new(action_items)
.direction(ListDirection::TopToBottom)
.highlight_style(
Style::default()
.fg(Color::Black)
.bg(Color::LightGreen)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("❯ ");
if let Some(pr) = app.selected_pr_item() {
let pr_info = Span::styled(
format!("PR #{} - {}", pr.pr_number, pr.pr_title),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
let info_area = Rect {
x: popup_area.x + 2,
y: popup_area.y + 1,
width: popup_area.width.saturating_sub(4),
height: 1,
};
frame.render_widget(Paragraph::new(Line::from(pr_info)), info_area);
}
let inner_area = Rect {
x: popup_area.x + 2,
y: popup_area.y + 3, width: popup_area.width.saturating_sub(4),
height: popup_area.height.saturating_sub(5), };
frame.render_stateful_widget(
list,
inner_area,
&mut ratatui::widgets::ListState::default().with_selected(Some(app.selected_action)),
);
let help_text = Span::styled(
"↑/↓ Navigate | Enter Select | Esc Cancel",
Style::default()
.fg(Color::Gray)
.add_modifier(Modifier::ITALIC),
);
let help_area = Rect {
x: popup_area.x + 2,
y: popup_area.y + popup_area.height - 2,
width: popup_area.width.saturating_sub(4),
height: 1,
};
frame.render_widget(Paragraph::new(Line::from(help_text)), help_area);
}
}