use ratatui::{
prelude::*,
widgets::canvas::{Canvas, Line as CanvasLine, Points},
Frame,
};
use std::f64::consts::PI;
use super::Theme;
pub struct GaugeWidget<'a> {
current_speed: f64,
max_scale: f64,
label: &'a str,
theme: Option<&'a Theme>,
}
impl<'a> GaugeWidget<'a> {
pub fn new(current_speed: f64, max_scale: f64) -> Self {
Self {
current_speed,
max_scale,
label: "",
theme: None,
}
}
pub fn with_label(mut self, label: &'a str) -> Self {
self.label = label;
self
}
pub fn with_theme(mut self, theme: &'a Theme) -> Self {
self.theme = Some(theme);
self
}
pub fn render(self, frame: &mut Frame, area: Rect) {
let default_theme = Theme::default();
let theme = self.theme.unwrap_or(&default_theme);
let canvas = Canvas::default()
.x_bounds([-1.2, 1.2])
.y_bounds([-0.3, 1.1])
.background_color(theme.background)
.paint(|ctx| {
let segments = 50;
let start_angle = PI;
let end_angle = 0.0;
for i in 0..segments {
let t1 = i as f64 / segments as f64;
let t2 = (i + 1) as f64 / segments as f64;
let angle1 = start_angle + (end_angle - start_angle) * t1;
let angle2 = start_angle + (end_angle - start_angle) * t2;
ctx.draw(&CanvasLine {
x1: angle1.cos(),
y1: angle1.sin(),
x2: angle2.cos(),
y2: angle2.sin(),
color: theme.speed_color(t1),
});
}
let tick_scales = calculate_tick_scales(self.max_scale);
for (value, _) in &tick_scales {
let normalized = *value / self.max_scale;
let angle = start_angle + (end_angle - start_angle) * normalized;
ctx.draw(&CanvasLine {
x1: angle.cos() * 0.85,
y1: angle.sin() * 0.85,
x2: angle.cos() * 0.95,
y2: angle.sin() * 0.95,
color: theme.foreground,
});
}
let normalized_speed = (self.current_speed / self.max_scale).clamp(0.0, 1.0);
let needle_angle = start_angle + (end_angle - start_angle) * normalized_speed;
ctx.draw(&CanvasLine {
x1: 0.0,
y1: 0.0,
x2: needle_angle.cos() * 0.75,
y2: needle_angle.sin() * 0.75,
color: theme.primary,
});
ctx.draw(&Points {
coords: &[(0.0, 0.0)],
color: theme.accent,
});
});
frame.render_widget(canvas, area);
let speed_text = format!("{:.1}", self.current_speed);
let center_x = area.x + area.width / 2;
let center_y = area.y + area.height / 2 + 2;
if center_y < area.y + area.height && center_x >= area.x {
let speed_span =
Span::styled(&speed_text, Style::default().fg(theme.foreground).bold());
let speed_widget =
ratatui::widgets::Paragraph::new(speed_span).alignment(Alignment::Center);
let speed_area = Rect {
x: area.x,
y: center_y.saturating_sub(1),
width: area.width,
height: 1,
};
frame.render_widget(speed_widget, speed_area);
let unit_span = Span::styled("Mbps", Style::default().fg(theme.muted));
let unit_widget =
ratatui::widgets::Paragraph::new(unit_span).alignment(Alignment::Center);
let unit_area = Rect {
x: area.x,
y: center_y,
width: area.width,
height: 1,
};
frame.render_widget(unit_widget, unit_area);
if !self.label.is_empty() {
let label_span = Span::styled(self.label, Style::default().fg(theme.secondary));
let label_widget =
ratatui::widgets::Paragraph::new(label_span).alignment(Alignment::Center);
let label_area = Rect {
x: area.x,
y: center_y + 1,
width: area.width,
height: 1,
};
frame.render_widget(label_widget, label_area);
}
}
let tick_scales = calculate_tick_scales(self.max_scale);
render_scale_labels(frame, area, &tick_scales, theme);
}
}
fn calculate_tick_scales(max_scale: f64) -> Vec<(f64, String)> {
let scales = if max_scale <= 10.0 {
vec![0.0, 2.5, 5.0, 7.5, 10.0]
} else if max_scale <= 50.0 {
vec![0.0, 10.0, 20.0, 30.0, 40.0, 50.0]
} else if max_scale <= 100.0 {
vec![0.0, 25.0, 50.0, 75.0, 100.0]
} else if max_scale <= 250.0 {
vec![0.0, 50.0, 100.0, 150.0, 200.0, 250.0]
} else if max_scale <= 500.0 {
vec![0.0, 100.0, 200.0, 300.0, 400.0, 500.0]
} else if max_scale <= 1000.0 {
vec![0.0, 250.0, 500.0, 750.0, 1000.0]
} else {
vec![
0.0,
max_scale / 4.0,
max_scale / 2.0,
max_scale * 3.0 / 4.0,
max_scale,
]
};
scales
.into_iter()
.filter(|&v| v <= max_scale)
.map(|v| {
let label = if v >= 1000.0 {
format!("{:.0}G", v / 1000.0)
} else {
format!("{:.0}", v)
};
(v, label)
})
.collect()
}
fn render_scale_labels(frame: &mut Frame, area: Rect, scales: &[(f64, String)], theme: &Theme) {
if area.height < 3 || area.width < 10 {
return;
}
let left_pos = (area.width as f32 * 0.08) as u16;
let right_pos = (area.width as f32 * 0.92) as u16;
let zero_area = Rect {
x: area.x + left_pos.saturating_sub(2),
y: area.y + area.height - 2,
width: 4,
height: 1,
};
let zero = ratatui::widgets::Paragraph::new("0")
.style(Style::default().fg(theme.muted))
.alignment(Alignment::Center);
frame.render_widget(zero, zero_area);
if let Some((_, label)) = scales.last() {
let label_width = label.len() as u16 + 1;
let max_area = Rect {
x: area.x + right_pos.saturating_sub(label_width / 2),
y: area.y + area.height - 2,
width: label_width,
height: 1,
};
let max = ratatui::widgets::Paragraph::new(label.as_str())
.style(Style::default().fg(theme.muted))
.alignment(Alignment::Center);
frame.render_widget(max, max_area);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tick_scales() {
let scales = calculate_tick_scales(100.0);
assert!(!scales.is_empty());
assert_eq!(scales[0].0, 0.0);
}
#[test]
fn test_gauge_widget() {
let gauge = GaugeWidget::new(50.0, 100.0).with_label("TEST");
assert_eq!(gauge.current_speed, 50.0);
assert_eq!(gauge.max_scale, 100.0);
assert_eq!(gauge.label, "TEST");
}
}