use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
prelude::*,
widgets::{Block, Borders, Paragraph, Wrap},
Frame,
};
use super::{gauge::GaugeWidget, graph::GraphWidget, Theme};
use crate::app::{App, TestPhase};
pub fn create_main_layout(area: Rect) -> Vec<Rect> {
Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(10),
Constraint::Length(3),
])
.split(area)
.to_vec()
}
pub fn render_header(frame: &mut Frame, area: Rect, app: &App, theme: &Theme) {
let title = format!(" SPEEDTEST TUI v{} ", env!("CARGO_PKG_VERSION"));
let title_len = title.len();
let provider_status = if app.is_connected {
format!("[{}] ● Connected", app.current_provider)
} else {
format!("[{}] ○ Disconnected", app.current_provider)
};
let provider_len = provider_status.len();
let padding = area
.width
.saturating_sub(title_len as u16 + provider_len as u16 + 2) as usize;
let header_text = Line::from(vec![
Span::styled(title, Style::default().fg(theme.primary).bold()),
Span::raw(" ".repeat(padding)),
Span::styled(
provider_status,
Style::default().fg(if app.is_connected {
theme.success
} else {
theme.muted
}),
),
]);
let header = Paragraph::new(header_text)
.block(
Block::default()
.borders(Borders::BOTTOM)
.border_style(Style::default().fg(theme.border)),
)
.style(Style::default().bg(theme.background));
frame.render_widget(header, area);
}
pub fn render_main_view(frame: &mut Frame, area: Rect, app: &App, theme: &Theme) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(12),
Constraint::Length(3),
Constraint::Length(4),
])
.split(area);
let top_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
.split(chunks[0]);
render_gauge_area(frame, top_chunks[0], app, theme);
render_graph_area(frame, top_chunks[1], app, theme);
render_stats_bar(frame, chunks[1], app, theme);
render_results(frame, chunks[2], app, theme);
}
fn render_gauge_area(frame: &mut Frame, area: Rect, app: &App, theme: &Theme) {
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.border))
.style(Style::default().bg(theme.background));
let inner = block.inner(area);
frame.render_widget(block, area);
let (current_speed, phase_label) = match app.test_phase {
TestPhase::Idle => (0.0, "IDLE"),
TestPhase::Connecting => (0.0, "CONNECTING"),
TestPhase::Ping => (0.0, "PING"),
TestPhase::Download => (app.current_download_speed, "DOWNLOAD"),
TestPhase::Upload => (app.current_upload_speed, "UPLOAD"),
TestPhase::Complete => {
let speed = app
.results
.download
.as_ref()
.map(|d| d.speed_mbps)
.unwrap_or(0.0);
(speed, "COMPLETE")
}
};
let gauge = GaugeWidget::new(current_speed, app.gauge_max_scale)
.with_label(phase_label)
.with_theme(theme);
gauge.render(frame, inner);
}
fn render_graph_area(frame: &mut Frame, area: Rect, app: &App, theme: &Theme) {
let title = match app.test_phase {
TestPhase::Download => " Download Speed ",
TestPhase::Upload => " Upload Speed ",
_ => " Speed Over Time ",
};
let block = Block::default()
.title(title)
.title_style(Style::default().fg(theme.primary))
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.border))
.style(Style::default().bg(theme.background));
let inner = block.inner(area);
frame.render_widget(block, area);
let graph = GraphWidget::new(&app.speed_samples, app.gauge_max_scale).with_theme(theme);
graph.render(frame, inner);
}
fn render_stats_bar(frame: &mut Frame, area: Rect, app: &App, theme: &Theme) {
let ping = app.results.ping.as_ref();
let ping_text = ping
.map(|p| format!("{:.0} ms", p.latency_ms))
.unwrap_or_else(|| "-".to_string());
let jitter_text = ping
.map(|p| format!("{:.1} ms", p.jitter_ms))
.unwrap_or_else(|| "-".to_string());
let loss_text = ping
.map(|p| format!("{:.0}%", p.packet_loss_percent))
.unwrap_or_else(|| "-".to_string());
let stats = Line::from(vec![
Span::styled(" ● Ping: ", Style::default().fg(theme.muted)),
Span::styled(ping_text, Style::default().fg(theme.foreground)),
Span::styled(" ● Jitter: ", Style::default().fg(theme.muted)),
Span::styled(jitter_text, Style::default().fg(theme.foreground)),
Span::styled(" ● Packet Loss: ", Style::default().fg(theme.muted)),
Span::styled(loss_text, Style::default().fg(theme.foreground)),
]);
let stats_widget = Paragraph::new(stats)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.border)),
)
.style(Style::default().bg(theme.highlight));
frame.render_widget(stats_widget, area);
}
fn render_results(frame: &mut Frame, area: Rect, app: &App, theme: &Theme) {
let download = app.results.download.as_ref();
let upload = app.results.upload.as_ref();
let download_speed = download
.map(|d| format!("{:.1} Mbps", d.speed_mbps))
.unwrap_or_else(|| "-".to_string());
let download_peak = download
.map(|d| format!("(peak: {:.1})", d.peak_speed_mbps))
.unwrap_or_default();
let upload_speed = upload
.map(|u| format!("{:.1} Mbps", u.speed_mbps))
.unwrap_or_else(|| "-".to_string());
let upload_peak = upload
.map(|u| format!("(peak: {:.1})", u.peak_speed_mbps))
.unwrap_or_default();
let results_text = vec![
Line::from(vec![
Span::styled(" ↓ Download: ", Style::default().fg(theme.success)),
Span::styled(download_speed, Style::default().fg(theme.foreground).bold()),
Span::styled(
format!(" {}", download_peak),
Style::default().fg(theme.muted),
),
]),
Line::from(vec![
Span::styled(" ↑ Upload: ", Style::default().fg(theme.secondary)),
Span::styled(upload_speed, Style::default().fg(theme.foreground).bold()),
Span::styled(
format!(" {}", upload_peak),
Style::default().fg(theme.muted),
),
]),
];
let results_widget = Paragraph::new(results_text)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.border)),
)
.style(Style::default().bg(theme.background));
frame.render_widget(results_widget, area);
}
pub fn render_footer(frame: &mut Frame, area: Rect, app: &App, theme: &Theme) {
let keybindings = if app.is_testing {
"[Esc] Cancel"
} else {
"[S]tart [P]rovider [H]istory [D]iagnostics [E]xport [T]heme [?]Help [Q]uit"
};
let footer = Paragraph::new(keybindings)
.style(Style::default().fg(theme.muted))
.alignment(Alignment::Center)
.block(
Block::default()
.borders(Borders::TOP)
.border_style(Style::default().fg(theme.border)),
);
frame.render_widget(footer, area);
}
pub fn render_settings_view(frame: &mut Frame, area: Rect, app: &App, theme: &Theme) {
let block = Block::default()
.title(" Settings ")
.title_style(Style::default().fg(theme.primary))
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.border))
.style(Style::default().bg(theme.background));
let inner = block.inner(area);
frame.render_widget(block, area);
let settings_text = vec![
Line::from(vec![
Span::styled("Theme: ", Style::default().fg(theme.muted)),
Span::styled(
app.theme_name.as_str().to_string(),
Style::default().fg(theme.foreground),
),
]),
Line::from(""),
Line::from(vec![
Span::styled("Provider: ", Style::default().fg(theme.muted)),
Span::styled(&app.current_provider, Style::default().fg(theme.foreground)),
]),
Line::from(""),
Line::from(vec![
Span::styled("Test Duration: ", Style::default().fg(theme.muted)),
Span::styled(
format!("{} seconds", app.test_duration),
Style::default().fg(theme.foreground),
),
]),
];
let settings = Paragraph::new(settings_text).wrap(Wrap { trim: true });
frame.render_widget(settings, inner);
}