use crate::theme::ThemeContext;
use ratatui::style::{Color, Style};
use std::path::Path;
use std::time::Duration;
use ratatui::prelude::Constraint;
use ratatui::prelude::Direction;
use ratatui::prelude::Layout;
use ratatui::prelude::Rect;
use ratatui::text::Span;
use crate::app::GraphDisplayMode;
pub fn format_speed(bits_per_second: u64) -> String {
if bits_per_second < 1_000 {
format!("{} bps", bits_per_second)
} else if bits_per_second < 1_000_000 {
format!("{:.1} Kbps", bits_per_second as f64 / 1_000.0)
} else if bits_per_second < 1_000_000_000 {
format!("{:.2} Mbps", bits_per_second as f64 / 1_000_000.0)
} else {
format!("{:.2} Gbps", bits_per_second as f64 / 1_000_000_000.0)
}
}
pub fn format_bytes(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = 1024 * KB;
const GB: u64 = 1024 * MB;
const TB: u64 = 1024 * GB;
if bytes < KB {
format!("{} B", bytes)
} else if bytes < MB {
format!("{:.2} KB", bytes as f64 / KB as f64)
} else if bytes < GB {
format!("{:.2} MB", bytes as f64 / MB as f64)
} else if bytes < TB {
format!("{:.2} GB", bytes as f64 / GB as f64)
} else {
format!("{:.2} TB", bytes as f64 / TB as f64)
}
}
pub fn format_memory(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = 1024 * KB;
const GB: u64 = 1024 * MB;
if bytes < KB {
format!("{} B", bytes)
} else if bytes < MB {
format!("{:.2} KB", bytes as f64 / KB as f64)
} else if bytes < GB {
format!("{:.2} MB", bytes as f64 / MB as f64)
} else {
format!("{:.2} GB", bytes as f64 / GB as f64)
}
}
pub fn format_time(seconds: u64) -> String {
let mut s = seconds;
let days = s / (24 * 3600);
s %= 24 * 3600;
let hours = s / 3600;
s %= 3600;
let minutes = s / 60;
let remaining_seconds = s % 60;
let mut parts = Vec::new();
if days > 0 {
parts.push(format!("{}d", days));
}
if hours > 0 {
parts.push(format!("{}h", hours));
}
if minutes > 0 {
parts.push(format!("{}m", minutes));
}
if remaining_seconds > 0 || parts.is_empty() {
parts.push(format!("{}s", remaining_seconds));
}
parts.join(" ")
}
pub fn format_duration(duration: Duration) -> String {
if duration == Duration::MAX {
return "∞".to_string();
}
if duration.as_secs() == 0 {
return "Done".to_string();
}
let mut secs = duration.as_secs();
let days = secs / (24 * 3600);
secs %= 24 * 3600;
let hours = secs / 3600;
secs %= 3600;
let minutes = secs / 60;
let seconds = secs % 60;
let mut parts = Vec::new();
if days > 0 {
parts.push(format!("{}d", days));
}
if hours > 0 {
parts.push(format!("{}h", hours));
}
if minutes > 0 && days == 0 {
parts.push(format!("{}m", minutes));
}
if seconds > 0 && days == 0 && hours == 0 {
parts.push(format!("{}s", seconds));
}
if parts.is_empty() {
"Done".to_string()
} else {
parts.join(" ")
}
}
pub fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(r);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(popup_layout[1])[1]
}
pub fn path_to_string(path: Option<&Path>) -> String {
path.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| "Not Set".to_string())
}
pub fn ip_to_color(ctx: &ThemeContext, ip: &str) -> Color {
let colors = ctx.theme.scale.ip_hash;
let hash = ip
.as_bytes()
.iter()
.fold(0u32, |acc, &b| acc.wrapping_add(b as u32));
colors[hash as usize % colors.len()]
}
pub fn speed_to_style(ctx: &ThemeContext, speed_bps: u64) -> Style {
if speed_bps == 0 {
Style::default() } else if speed_bps < 50_000 {
ctx.apply(Style::default().fg(ctx.theme.scale.speed[0]))
} else if speed_bps < 500_000 {
ctx.apply(Style::default().fg(ctx.theme.scale.speed[1]))
} else if speed_bps < 2_000_000 {
ctx.apply(Style::default().fg(ctx.theme.scale.speed[2]))
} else if speed_bps < 10_000_000 {
ctx.apply(Style::default().fg(ctx.theme.scale.speed[3]))
} else if speed_bps < 20_000_000 {
ctx.apply(Style::default().fg(ctx.theme.scale.speed[4]))
} else if speed_bps < 50_000_000 {
ctx.apply(Style::default().fg(ctx.theme.scale.speed[5]))
} else if speed_bps < 100_000_000 {
ctx.apply(Style::default().fg(ctx.theme.scale.speed[6]))
} else {
ctx.apply(Style::default().fg(ctx.theme.scale.speed[7]))
}
}
pub fn truncate_with_ellipsis(s: &str, max_len: usize) -> String {
if s.chars().count() > max_len {
let truncated: String = s.chars().take(max_len.saturating_sub(3)).collect();
format!("{}...", truncated)
} else {
s.to_string()
}
}
pub fn calculate_nice_upper_bound(speed_bps: u64) -> u64 {
if speed_bps == 0 {
return 10_000;
}
let exponent = (speed_bps as f64).log10().floor();
let power_of_10 = 10.0_f64.powf(exponent);
let normalized_speed = (speed_bps as f64) / power_of_10;
let nice_multiplier = if normalized_speed < 1.0 {
1.0
} else if normalized_speed < 1.5 {
1.5
} else if normalized_speed < 2.0 {
2.0
} else if normalized_speed < 2.5 {
2.5
} else if normalized_speed < 3.0 {
3.0
} else if normalized_speed < 4.0 {
4.0
} else if normalized_speed < 5.0 {
5.0
} else if normalized_speed < 6.0 {
6.0
} else if normalized_speed < 7.0 {
7.0
} else if normalized_speed < 8.0 {
8.0
} else if normalized_speed < 9.0 {
9.0
} else {
10.0
};
(nice_multiplier * power_of_10) as u64
}
pub fn format_countdown(duration: Duration) -> String {
if duration == Duration::MAX {
return "N/A".to_string();
}
if duration.as_secs() == 0 {
return "Now".to_string();
}
let secs = duration.as_secs();
let minutes = secs / 60;
let seconds = secs % 60;
let mut parts = Vec::new();
if minutes > 0 {
parts.push(format!("{}m", minutes));
}
if seconds > 0 || parts.is_empty() {
parts.push(format!("{}s", seconds));
}
parts.join(" ").to_string()
}
pub fn format_limit_bps(bps: u64) -> String {
if bps == 0 {
"Unlimited".to_string()
} else {
format_speed(bps)
}
}
pub fn format_graph_time_label(duration_secs: usize) -> String {
const MINUTE: usize = 60;
const HOUR: usize = 60 * MINUTE;
const DAY: usize = 24 * HOUR;
if duration_secs < MINUTE {
format!("-{}s", duration_secs)
} else if duration_secs < HOUR {
format!("-{}m", duration_secs / MINUTE)
} else if duration_secs < DAY {
format!("-{}h", duration_secs / HOUR)
} else {
format!("-{}d", duration_secs / DAY)
}
}
pub fn generate_x_axis_labels(
ctx: &ThemeContext,
graph_mode: GraphDisplayMode,
) -> Vec<Span<'static>> {
let labels_str: Vec<String> = match graph_mode {
GraphDisplayMode::OneMinute => (0..=4)
.map(|i| format_graph_time_label(60 - i * 15))
.collect(),
GraphDisplayMode::FiveMinutes => (0..=5)
.map(|i| format_graph_time_label(300 - i * 60))
.collect(),
GraphDisplayMode::TenMinutes => (0..=5)
.map(|i| format_graph_time_label(600 - i * 120))
.collect(),
GraphDisplayMode::ThirtyMinutes => (0..=6)
.map(|i| format_graph_time_label(1800 - i * 300))
.collect(),
GraphDisplayMode::OneHour => (0..=6)
.map(|i| format_graph_time_label(3600 - i * 600)) .collect(),
GraphDisplayMode::ThreeHours => (0..=6)
.map(|i| format_graph_time_label(3 * 3600 - i * 1800)) .collect(),
GraphDisplayMode::TwelveHours => (0..=4) .map(|i| format_graph_time_label(12 * 3600 - i * 3 * 3600)) .collect(),
GraphDisplayMode::TwentyFourHours => (0..=6)
.map(|i| format_graph_time_label(86400 - i * 14400)) .collect(),
GraphDisplayMode::SevenDays => (0..=7)
.map(|i| format_graph_time_label(7 * 86_400 - i * 86_400)) .collect(),
GraphDisplayMode::ThirtyDays => (0..=6)
.map(|i| format_graph_time_label(30 * 86_400 - i * 5 * 86_400)) .collect(),
GraphDisplayMode::OneYear => (0..=12)
.map(|i| format_graph_time_label(365 * 86_400 - i * 30 * 86_400)) .collect(),
};
let mut x_labels: Vec<Span> = labels_str
.into_iter()
.map(|s| {
Span::styled(
s,
ctx.apply(Style::default().fg(ctx.theme.semantic.subtext0)),
)
})
.collect();
if let Some(last) = x_labels.last_mut() {
*last = Span::styled(
"Now",
ctx.apply(Style::default().fg(ctx.theme.semantic.subtext0)),
);
}
x_labels
}
pub fn parse_peer_id(peer_id: &[u8]) -> String {
if peer_id.len() < 8 {
return "Unknown".to_string();
}
if peer_id[0] == b'-' && peer_id[7] == b'-' {
let client_code = &peer_id[1..3];
let version = &peer_id[3..7];
let client_name = match client_code {
b"TR" => "Transmission",
b"UT" => "µTorrent",
b"qB" => "qBittorrent",
b"AZ" => "Vuze/Azureus",
b"LT" => "libtorrent",
b"DE" => "Deluge",
b"S" | b"SD" => "Shadow",
_ => {
return format!(
"Unknown ({}{})",
String::from_utf8_lossy(client_code),
String::from_utf8_lossy(version)
)
}
};
return format!("{} {}", client_name, String::from_utf8_lossy(version));
}
if peer_id.starts_with(b"M")
&& peer_id[1..8]
.iter()
.all(|c| c.is_ascii_digit() || *c == b'-')
{
return "BitComet".to_string();
}
"Unknown".to_string()
}
pub fn format_latency(duration: Duration) -> String {
let micros = duration.as_micros();
if micros < 1000 {
format!("{} µs", micros)
} else if micros < 1_000_000 {
format!("{:.2} ms", micros as f64 / 1000.0)
} else {
format!("{:.2} s", micros as f64 / 1_000_000.0)
}
}
pub fn format_iops(iops: u32) -> String {
format!("{} ops/s", iops)
}
pub fn sanitize_text(text: &str) -> String {
text.chars()
.map(|c| if c.is_control() { '?' } else { c })
.collect()
}