use super::app::{App, TestingState, View};
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{
Block, Borders, Gauge, List, ListItem, Paragraph, Row, Table,
Wrap,
},
Frame,
};
pub fn draw(f: &mut Frame, app: &App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(0), Constraint::Length(3), ])
.split(f.size());
draw_header(f, chunks[0], app);
match app.current_view {
View::Dashboard => draw_dashboard(f, chunks[1], app),
View::Testing => draw_testing(f, chunks[1], app),
View::History => draw_history(f, chunks[1], app),
View::Config => draw_config(f, chunks[1], app),
View::StaticMirrors => draw_static_mirrors(f, chunks[1], app),
View::Help => draw_help(f, chunks[1], app),
}
draw_footer(f, chunks[2], app);
}
fn draw_header(f: &mut Frame, area: Rect, app: &App) {
let title = vec![
Span::styled(
" SMirrors ",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw("- Automatic Mirror List Updater "),
];
let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
let time_display = format!(" {} ", now);
let service_status = if app.service_running {
Span::styled(
" [Service: Running] ",
Style::default().fg(Color::Green),
)
} else {
Span::styled(
" [Service: Stopped] ",
Style::default().fg(Color::Red),
)
};
let header_content = Line::from(vec![
title[0].clone(),
title[1].clone(),
Span::raw(" ".repeat(area.width.saturating_sub(60) as usize)),
service_status,
Span::raw(time_display),
]);
let header = Paragraph::new(header_content)
.block(Block::default().borders(Borders::ALL))
.alignment(Alignment::Left);
f.render_widget(header, area);
}
fn draw_dashboard(f: &mut Frame, area: Rect, app: &App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(7), Constraint::Min(10), Constraint::Length(5), ])
.split(area);
draw_status_panel(f, chunks[0], app);
draw_mirrors_list(f, chunks[1], app);
draw_quick_actions(f, chunks[2], app);
}
fn draw_status_panel(f: &mut Frame, area: Rect, app: &App) {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(33),
Constraint::Percentage(33),
Constraint::Percentage(34),
])
.split(area);
let total_mirrors = app.mirrors.len();
let static_mirrors = app.static_mirrors.len();
let mirror_text = vec![
Line::from(Span::styled(
format!("Total: {}", total_mirrors),
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
)),
Line::from(format!("Static: {}", static_mirrors)),
Line::from(format!(
"Dynamic: {}",
total_mirrors.saturating_sub(static_mirrors)
)),
];
let mirror_panel = Paragraph::new(mirror_text)
.block(
Block::default()
.title("Mirrors")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::White)),
)
.alignment(Alignment::Center);
f.render_widget(mirror_panel, chunks[0]);
let last_update_text = if let Some(update) = app.last_update {
let duration = chrono::Utc::now()
.signed_duration_since(update);
let time_ago = if duration.num_days() > 0 {
format!("{} days ago", duration.num_days())
} else if duration.num_hours() > 0 {
format!("{} hours ago", duration.num_hours())
} else if duration.num_minutes() > 0 {
format!("{} minutes ago", duration.num_minutes())
} else {
"Just now".to_string()
};
vec![
Line::from(Span::styled(
time_ago,
Style::default().fg(Color::Green).add_modifier(Modifier::BOLD),
)),
Line::from(update.format("%Y-%m-%d").to_string()),
Line::from(update.format("%H:%M:%S").to_string()),
]
} else {
vec![
Line::from(Span::styled(
"Never",
Style::default().fg(Color::Yellow),
)),
Line::from(""),
Line::from("No updates yet"),
]
};
let update_panel = Paragraph::new(last_update_text)
.block(
Block::default()
.title("Last Update")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::White)),
)
.alignment(Alignment::Center);
f.render_widget(update_panel, chunks[1]);
let test_text = match &app.testing_state {
TestingState::Idle => vec![
Line::from(Span::styled(
"Idle",
Style::default().fg(Color::Gray),
)),
Line::from(""),
Line::from("Ready to test"),
],
TestingState::InProgress { completed, total, .. } => vec![
Line::from(Span::styled(
"Testing...",
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
)),
Line::from(format!("{} / {}", completed, total)),
Line::from(format!("{}%", (completed * 100) / (*total).max(1))),
],
TestingState::Completed => vec![
Line::from(Span::styled(
"Completed",
Style::default().fg(Color::Green).add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(format!("{} results", app.test_results.len())),
],
};
let test_panel = Paragraph::new(test_text)
.block(
Block::default()
.title("Testing")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::White)),
)
.alignment(Alignment::Center);
f.render_widget(test_panel, chunks[2]);
}
fn draw_mirrors_list(f: &mut Frame, area: Rect, app: &App) {
let header = Row::new(vec!["URL", "Country", "Speed", "Latency", "Score"])
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))
.bottom_margin(1);
let rows: Vec<Row> = app
.mirrors
.iter()
.map(|m| {
let url = m.hostname();
let country = m.country.clone().unwrap_or_else(|| "N/A".to_string());
let speed = m.format_speed();
let latency = m.format_latency();
let score = m.format_score();
let style = if m.is_static {
Style::default().fg(Color::Cyan)
} else {
Style::default()
};
Row::new(vec![url, country, speed, latency, score]).style(style)
})
.collect();
let widths = [
Constraint::Percentage(40),
Constraint::Percentage(15),
Constraint::Percentage(15),
Constraint::Percentage(15),
Constraint::Percentage(15),
];
let table = Table::new(rows, widths)
.header(header)
.block(
Block::default()
.title(format!("Mirrors ({}/{})",
app.list_state.selected().map(|i| i + 1).unwrap_or(0),
app.mirrors.len()))
.borders(Borders::ALL),
)
.highlight_style(
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
);
f.render_widget(table, area);
}
fn draw_quick_actions(f: &mut Frame, area: Rect, app: &App) {
let actions = vec![
Line::from(vec![
Span::styled("T", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::raw(": Test Mirrors "),
Span::styled("U", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::raw(": Update "),
Span::styled("S", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::raw(": Static Mirrors "),
Span::styled("H", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::raw(": History "),
Span::styled("C", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::raw(": Config"),
]),
];
let actions_widget = Paragraph::new(actions)
.block(
Block::default()
.title("Quick Actions")
.borders(Borders::ALL),
)
.alignment(Alignment::Center);
f.render_widget(actions_widget, area);
}
fn draw_testing(f: &mut Frame, area: Rect, app: &App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(10), ])
.split(area);
match &app.testing_state {
TestingState::InProgress { completed, total, current_mirror } => {
let progress = (*completed as f64 / *total as f64).min(1.0);
let gauge = Gauge::default()
.block(Block::default().title("Testing Progress").borders(Borders::ALL))
.gauge_style(Style::default().fg(Color::Yellow))
.label(format!("{}/{} - {}", completed, total, current_mirror))
.ratio(progress);
f.render_widget(gauge, chunks[0]);
}
TestingState::Completed => {
let gauge = Gauge::default()
.block(Block::default().title("Testing Progress").borders(Borders::ALL))
.gauge_style(Style::default().fg(Color::Green))
.label("Completed")
.ratio(1.0);
f.render_widget(gauge, chunks[0]);
}
TestingState::Idle => {
let msg = Paragraph::new("Press 't' to start testing")
.block(Block::default().title("Testing").borders(Borders::ALL))
.alignment(Alignment::Center);
f.render_widget(msg, chunks[0]);
}
}
if !app.test_results.is_empty() {
let header = Row::new(vec!["Mirror", "Speed", "Latency", "Score", "Status"])
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))
.bottom_margin(1);
let rows: Vec<Row> = app
.test_results
.iter()
.map(|r| {
let url = r.mirror.hostname();
let speed = r.mirror.format_speed();
let latency = r.mirror.format_latency();
let score = r.mirror.format_score();
let status = if r.success {
Span::styled("OK", Style::default().fg(Color::Green))
} else {
Span::styled("FAIL", Style::default().fg(Color::Red))
};
Row::new(vec![
url,
speed,
latency,
score,
status.content.to_string(),
])
})
.collect();
let widths = [
Constraint::Percentage(40),
Constraint::Percentage(15),
Constraint::Percentage(15),
Constraint::Percentage(15),
Constraint::Percentage(15),
];
let table = Table::new(rows, widths)
.header(header)
.block(
Block::default()
.title(format!("Test Results ({})", app.test_results.len()))
.borders(Borders::ALL),
);
f.render_widget(table, chunks[1]);
} else {
let msg = Paragraph::new("No test results yet")
.block(Block::default().title("Results").borders(Borders::ALL))
.alignment(Alignment::Center);
f.render_widget(msg, chunks[1]);
}
}
fn draw_history(f: &mut Frame, area: Rect, app: &App) {
if app.history.is_empty() {
let msg = Paragraph::new("No update history available")
.block(Block::default().title("Update History").borders(Borders::ALL))
.alignment(Alignment::Center);
f.render_widget(msg, area);
return;
}
let items: Vec<ListItem> = app
.history
.iter()
.map(|record| {
let status_icon = if record.success { "✓" } else { "✗" };
let status_color = if record.success { Color::Green } else { Color::Red };
let content = vec![Line::from(vec![
Span::styled(status_icon, Style::default().fg(status_color)),
Span::raw(" "),
Span::raw(record.updated_at.format("%Y-%m-%d %H:%M:%S").to_string()),
Span::raw(" - "),
Span::raw(format!("{} mirrors changed", record.mirrors_changed)),
])];
ListItem::new(content)
})
.collect();
let list = List::new(items)
.block(
Block::default()
.title(format!("Update History ({})", app.history.len()))
.borders(Borders::ALL),
)
.highlight_style(
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
);
f.render_stateful_widget(list, area, &mut app.history_list_state.clone());
}
fn draw_config(f: &mut Frame, area: Rect, app: &App) {
let config_items = vec![
format!("Update Interval: {}", app.config.general.update_interval),
format!("Auto Update: {}", app.config.general.auto_update),
format!("Concurrent Tests: {}", app.config.general.concurrent_tests),
format!("Timeout: {} seconds", app.config.general.timeout),
format!("Retries: {}", app.config.general.retries),
format!("Speed Weight: {:.2}", app.config.testing.speed_weight),
format!("Latency Weight: {:.2}", app.config.testing.latency_weight),
format!("Test File Size: {}", app.config.testing.test_file_size),
format!("Max Mirrors: {}", app.config.testing.max_mirrors),
format!("Min Score: {:.2}", app.config.testing.min_score),
format!("Auto Detect Distro: {}", app.config.distro.auto_detect),
format!("Preserve Comments: {}", app.config.distro.preserve_comments),
format!("Create Backup: {}", app.config.distro.create_backup),
format!("Log Level: {}", app.config.logging.level),
format!("Log Format: {}", app.config.logging.format),
];
let items: Vec<ListItem> = config_items
.into_iter()
.enumerate()
.map(|(i, item)| {
let style = if i == app.selected_config_item {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
ListItem::new(item).style(style)
})
.collect();
let list = List::new(items)
.block(
Block::default()
.title("Configuration (↑/↓ to navigate, Enter to edit, 's' to save)")
.borders(Borders::ALL),
)
.highlight_style(
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
);
f.render_widget(list, area);
}
fn draw_static_mirrors(f: &mut Frame, area: Rect, app: &App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(10), Constraint::Length(3), ])
.split(area);
if app.static_mirrors.is_empty() {
let msg = Paragraph::new("No static mirrors configured\n\nPress 'a' to add a mirror")
.block(Block::default().title("Static Mirrors").borders(Borders::ALL))
.alignment(Alignment::Center);
f.render_widget(msg, chunks[0]);
} else {
let items: Vec<ListItem> = app
.static_mirrors
.iter()
.map(|m| {
let content = vec![Line::from(vec![
Span::styled("●", Style::default().fg(Color::Cyan)),
Span::raw(" "),
Span::raw(m.url_string()),
])];
ListItem::new(content)
})
.collect();
let list = List::new(items)
.block(
Block::default()
.title(format!("Static Mirrors ({}) - 'a' to add, 'd' to delete",
app.static_mirrors.len()))
.borders(Borders::ALL),
)
.highlight_style(
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
);
f.render_stateful_widget(list, chunks[0], &mut app.list_state.clone());
}
if app.input_mode {
let input = Paragraph::new(app.input_buffer.as_str())
.block(
Block::default()
.title("Enter mirror URL (Esc to cancel, Enter to add)")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow)),
);
f.render_widget(input, chunks[1]);
} else {
let help = Paragraph::new("Press 'a' to add mirror, 'd' to delete selected")
.block(Block::default().borders(Borders::ALL))
.alignment(Alignment::Center);
f.render_widget(help, chunks[1]);
}
}
fn draw_help(f: &mut Frame, area: Rect, _app: &App) {
let help_text = vec![
Line::from(Span::styled(
"SMirrors Keyboard Shortcuts",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(vec![
Span::styled("Global:", Style::default().fg(Color::Yellow)),
]),
Line::from(" q/Esc - Quit application"),
Line::from(" ? - Show this help"),
Line::from(" 1-6 - Switch views"),
Line::from(""),
Line::from(vec![
Span::styled("Navigation:", Style::default().fg(Color::Yellow)),
]),
Line::from(" ↑/k - Move up"),
Line::from(" ↓/j - Move down"),
Line::from(" Enter - Select/Activate"),
Line::from(""),
Line::from(vec![
Span::styled("Dashboard:", Style::default().fg(Color::Yellow)),
]),
Line::from(" t - Start testing mirrors"),
Line::from(" u - Update mirror list"),
Line::from(" r - Refresh data"),
Line::from(""),
Line::from(vec![
Span::styled("Testing:", Style::default().fg(Color::Yellow)),
]),
Line::from(" t - Start/restart test"),
Line::from(" c - Cancel test"),
Line::from(""),
Line::from(vec![
Span::styled("Static Mirrors:", Style::default().fg(Color::Yellow)),
]),
Line::from(" a - Add mirror"),
Line::from(" d - Delete selected mirror"),
Line::from(""),
Line::from(vec![
Span::styled("Configuration:", Style::default().fg(Color::Yellow)),
]),
Line::from(" s - Save configuration"),
Line::from(" r - Reload configuration"),
Line::from(""),
Line::from(vec![
Span::styled("Views:", Style::default().fg(Color::Yellow)),
]),
Line::from(" 1 - Dashboard"),
Line::from(" 2 - Testing"),
Line::from(" 3 - History"),
Line::from(" 4 - Configuration"),
Line::from(" 5 - Static Mirrors"),
Line::from(" 6 - Help"),
];
let help = Paragraph::new(help_text)
.block(Block::default().title("Help").borders(Borders::ALL))
.wrap(Wrap { trim: true });
f.render_widget(help, area);
}
fn draw_footer(f: &mut Frame, area: Rect, app: &App) {
let keybindings = match app.current_view {
View::Dashboard => "q: Quit | ?: Help | t: Test | u: Update | ↑↓: Navigate | 1-6: Views",
View::Testing => "q: Quit | ?: Help | t: Start Test | ↑↓: Navigate | 1-6: Views",
View::History => "q: Quit | ?: Help | ↑↓: Navigate | 1-6: Views",
View::Config => "q: Quit | ?: Help | s: Save | r: Reload | ↑↓: Navigate | 1-6: Views",
View::StaticMirrors => "q: Quit | ?: Help | a: Add | d: Delete | ↑↓: Navigate | 1-6: Views",
View::Help => "q: Quit | Esc: Back | 1-6: Views",
};
let mut footer_spans = vec![Span::raw(keybindings)];
if let Some(ref msg) = app.status_message {
footer_spans.push(Span::raw(" | "));
footer_spans.push(Span::styled(
msg,
Style::default().fg(Color::Green),
));
} else if let Some(ref msg) = app.error_message {
footer_spans.push(Span::raw(" | "));
footer_spans.push(Span::styled(
msg,
Style::default().fg(Color::Red),
));
}
let footer = Paragraph::new(Line::from(footer_spans))
.block(Block::default().borders(Borders::ALL))
.alignment(Alignment::Center);
f.render_widget(footer, area);
}