use ratatui::{
layout::{Constraint, Layout, Rect},
prelude::*,
widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap},
Frame,
};
use super::Theme;
use crate::app::App;
pub fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
let popup_layout = Layout::vertical([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(r);
Layout::horizontal([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(popup_layout[1])[1]
}
pub fn render_provider_popup(frame: &mut Frame, app: &App, theme: &Theme) {
use ratatui::widgets::ListState;
let area = centered_rect(40, 25, frame.area());
frame.render_widget(Clear, area);
let providers = ["cloudflare"];
let items: Vec<ListItem> = providers
.iter()
.enumerate()
.map(|(i, name)| {
let display_name = match *name {
"cloudflare" => "Cloudflare (speed.cloudflare.com)",
_ => *name,
};
let is_current = app.current_provider.to_lowercase() == *name;
let is_selected = i == app.provider_selected_index;
let prefix = if is_current { "● " } else { " " };
let style = if is_selected {
Style::default()
.fg(theme.background)
.bg(theme.primary)
.bold()
} else if is_current {
Style::default().fg(theme.primary).bold()
} else {
Style::default().fg(theme.foreground)
};
ListItem::new(format!("{}{}", prefix, display_name)).style(style)
})
.collect();
let list = List::new(items)
.block(
Block::default()
.title(" Select Provider ")
.title_style(Style::default().fg(theme.primary))
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.border))
.style(Style::default().bg(theme.background)),
)
.highlight_style(Style::default().bg(theme.highlight));
let mut state = ListState::default();
state.select(Some(app.provider_selected_index));
frame.render_stateful_widget(list, area, &mut state);
let instructions = Paragraph::new("[↑/↓] Navigate [Enter] Select [Esc] Cancel")
.style(Style::default().fg(theme.muted))
.alignment(Alignment::Center);
let instructions_area = Rect {
x: area.x,
y: area.y + area.height - 2,
width: area.width,
height: 1,
};
frame.render_widget(instructions, instructions_area);
}
pub fn render_theme_popup(frame: &mut Frame, app: &App, theme: &Theme) {
use crate::ui::ThemeName;
use ratatui::widgets::ListState;
let area = centered_rect(35, 30, frame.area());
frame.render_widget(Clear, area);
let themes = [
(ThemeName::Dark, "Dark", "Deep purple/blue"),
(ThemeName::Light, "Light", "Clean white"),
(ThemeName::Ocean, "Ocean", "GitHub dark"),
(ThemeName::Neon, "Neon", "Cyberpunk"),
];
let items: Vec<ListItem> = themes
.iter()
.enumerate()
.map(|(i, (theme_variant, name, desc))| {
let is_current = app.theme_name == *theme_variant;
let is_selected = i == app.theme_selected_index;
let prefix = if is_current { "● " } else { " " };
let style = if is_selected {
Style::default()
.fg(theme.background)
.bg(theme.primary)
.bold()
} else if is_current {
Style::default().fg(theme.primary).bold()
} else {
Style::default().fg(theme.foreground)
};
ListItem::new(format!("{}{} - {}", prefix, name, desc)).style(style)
})
.collect();
let list = List::new(items)
.block(
Block::default()
.title(" Select Theme ")
.title_style(Style::default().fg(theme.primary))
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.border))
.style(Style::default().bg(theme.background)),
)
.highlight_style(Style::default().bg(theme.highlight));
let mut state = ListState::default();
state.select(Some(app.theme_selected_index));
frame.render_stateful_widget(list, area, &mut state);
let instructions = Paragraph::new("[↑/↓] Navigate [Enter] Select [Esc] Cancel")
.style(Style::default().fg(theme.muted))
.alignment(Alignment::Center);
let instructions_area = Rect {
x: area.x,
y: area.y + area.height - 2,
width: area.width,
height: 1,
};
frame.render_widget(instructions, instructions_area);
}
pub fn render_help_popup(frame: &mut Frame, theme: &Theme) {
let area = centered_rect(60, 70, frame.area());
frame.render_widget(Clear, area);
let help_text = vec![
Line::from(vec![Span::styled(
"Keyboard Shortcuts",
Style::default().fg(theme.primary).bold(),
)]),
Line::from(""),
Line::from(vec![
Span::styled(" s, Enter ", Style::default().fg(theme.accent)),
Span::styled("Start speed test", Style::default().fg(theme.foreground)),
]),
Line::from(vec![
Span::styled(" p ", Style::default().fg(theme.accent)),
Span::styled("Select provider", Style::default().fg(theme.foreground)),
]),
Line::from(vec![
Span::styled(" h ", Style::default().fg(theme.accent)),
Span::styled("View history", Style::default().fg(theme.foreground)),
]),
Line::from(vec![
Span::styled(" d ", Style::default().fg(theme.accent)),
Span::styled("Network diagnostics", Style::default().fg(theme.foreground)),
]),
Line::from(vec![
Span::styled(" e ", Style::default().fg(theme.accent)),
Span::styled(
"Export results to CSV",
Style::default().fg(theme.foreground),
),
]),
Line::from(vec![
Span::styled(" t ", Style::default().fg(theme.accent)),
Span::styled("Change theme", Style::default().fg(theme.foreground)),
]),
Line::from(vec![
Span::styled(" c ", Style::default().fg(theme.accent)),
Span::styled("Open settings", Style::default().fg(theme.foreground)),
]),
Line::from(""),
Line::from(vec![Span::styled(
"Navigation",
Style::default().fg(theme.primary).bold(),
)]),
Line::from(""),
Line::from(vec![
Span::styled(" Tab ", Style::default().fg(theme.accent)),
Span::styled("Next section", Style::default().fg(theme.foreground)),
]),
Line::from(vec![
Span::styled(" ↑/↓, j/k ", Style::default().fg(theme.accent)),
Span::styled("Navigate lists", Style::default().fg(theme.foreground)),
]),
Line::from(vec![
Span::styled(" Esc ", Style::default().fg(theme.accent)),
Span::styled("Go back / Cancel", Style::default().fg(theme.foreground)),
]),
Line::from(vec![
Span::styled(" q ", Style::default().fg(theme.accent)),
Span::styled("Quit application", Style::default().fg(theme.foreground)),
]),
Line::from(""),
Line::from(vec![Span::styled(
"Press any key to close",
Style::default().fg(theme.muted),
)]),
];
let help = Paragraph::new(help_text)
.block(
Block::default()
.title(" Help ")
.title_style(Style::default().fg(theme.primary))
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.border))
.style(Style::default().bg(theme.background)),
)
.wrap(Wrap { trim: true });
frame.render_widget(help, area);
}
#[allow(dead_code)]
pub fn render_progress_bar(frame: &mut Frame, area: Rect, progress: f64, theme: &Theme) {
let filled_width = ((area.width as f64 - 2.0) * progress.clamp(0.0, 1.0)) as u16;
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.border));
let inner = block.inner(area);
frame.render_widget(block, area);
if filled_width > 0 {
let filled_area = Rect {
x: inner.x,
y: inner.y,
width: filled_width,
height: inner.height,
};
let filled = Block::default().style(Style::default().bg(theme.primary));
frame.render_widget(filled, filled_area);
}
}
#[allow(dead_code)]
pub fn render_spinner(frame: &mut Frame, area: Rect, tick: u64, theme: &Theme) {
let spinner_chars = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
let char_idx = (tick as usize / 2) % spinner_chars.len();
let spinner = Paragraph::new(spinner_chars[char_idx].to_string())
.style(Style::default().fg(theme.primary));
frame.render_widget(spinner, area);
}
pub fn render_results_popup(frame: &mut Frame, app: &App, theme: &Theme) {
use crate::app::View;
let area = centered_rect(60, 70, frame.area());
frame.render_widget(Clear, area);
let (results, title) = if app.current_view == View::History {
let idx = app
.history
.len()
.saturating_sub(1)
.min(app.history_selected_index);
let entry = app.history.iter().rev().nth(idx);
(entry, " History Details ")
} else {
(Some(&app.results), " Speed Test Results ")
};
let (download_speed, download_peak, upload_speed, upload_peak, ping, jitter, provider) =
if let Some(r) = results {
(
r.download.as_ref().map(|d| d.speed_mbps).unwrap_or(0.0),
r.download
.as_ref()
.map(|d| d.peak_speed_mbps)
.unwrap_or(0.0),
r.upload.as_ref().map(|u| u.speed_mbps).unwrap_or(0.0),
r.upload.as_ref().map(|u| u.peak_speed_mbps).unwrap_or(0.0),
r.ping.as_ref().map(|p| p.latency_ms).unwrap_or(0.0),
r.ping.as_ref().map(|p| p.jitter_ms).unwrap_or(0.0),
r.provider.clone(),
)
} else {
(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, String::new())
};
let location = results
.and_then(|r| r.connection_info.as_ref())
.and_then(|c| c.location.clone())
.or_else(|| {
app.connection_info
.as_ref()
.and_then(|c| c.location.clone())
})
.unwrap_or_else(|| "-".to_string());
let isp = results
.and_then(|r| r.connection_info.as_ref())
.and_then(|c| c.isp.clone())
.or_else(|| app.connection_info.as_ref().and_then(|c| c.isp.clone()))
.unwrap_or_else(|| "-".to_string());
let lines = vec![
Line::from(""),
Line::from(vec![Span::styled(
" DOWNLOAD",
Style::default().fg(theme.muted),
)]),
Line::from(vec![
Span::styled(
format!(" {:.1}", download_speed),
Style::default().fg(theme.success).bold(),
),
Span::styled(" Mbps", Style::default().fg(theme.success)),
]),
Line::from(vec![Span::styled(
format!(" peak: {:.1} Mbps", download_peak),
Style::default().fg(theme.muted),
)]),
Line::from(""),
Line::from(vec![Span::styled(
" UPLOAD",
Style::default().fg(theme.muted),
)]),
Line::from(vec![
Span::styled(
format!(" {:.1}", upload_speed),
Style::default().fg(theme.secondary).bold(),
),
Span::styled(" Mbps", Style::default().fg(theme.secondary)),
]),
Line::from(vec![Span::styled(
format!(" peak: {:.1} Mbps", upload_peak),
Style::default().fg(theme.muted),
)]),
Line::from(""),
Line::from(vec![Span::styled(
" ────────────────────────────────────────",
Style::default().fg(theme.border),
)]),
Line::from(""),
Line::from(vec![
Span::styled(" Ping: ", Style::default().fg(theme.muted)),
Span::styled(
format!("{:.1} ms", ping),
Style::default().fg(theme.foreground),
),
Span::styled(" Jitter: ", Style::default().fg(theme.muted)),
Span::styled(
format!("{:.1} ms", jitter),
Style::default().fg(theme.foreground),
),
]),
Line::from(""),
Line::from(vec![
Span::styled(" Provider: ", Style::default().fg(theme.muted)),
Span::styled(&provider, Style::default().fg(theme.foreground)),
]),
Line::from(vec![
Span::styled(" Location: ", Style::default().fg(theme.muted)),
Span::styled(&location, Style::default().fg(theme.foreground)),
]),
Line::from(vec![
Span::styled(" ISP: ", Style::default().fg(theme.muted)),
Span::styled(&isp, Style::default().fg(theme.foreground)),
]),
Line::from(""),
Line::from(vec![Span::styled(
" Press any key to close",
Style::default().fg(theme.muted),
)]),
];
let popup = Paragraph::new(lines)
.block(
Block::default()
.title(title)
.title_style(Style::default().fg(theme.primary).bold())
.borders(Borders::ALL)
.border_type(ratatui::widgets::BorderType::Rounded)
.border_style(Style::default().fg(theme.primary))
.style(Style::default().bg(theme.background)),
)
.alignment(Alignment::Center);
frame.render_widget(popup, area);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_centered_rect() {
let outer = Rect::new(0, 0, 100, 50);
let inner = centered_rect(50, 50, outer);
assert!(inner.x > 0);
assert!(inner.y > 0);
assert!(inner.x + inner.width < outer.width);
assert!(inner.y + inner.height < outer.height);
}
}