use std::collections::VecDeque;
use std::time::Instant;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::Frame;
use crate::app::state::{AppState, Screen};
use crate::engine::models::TestPhase;
use super::animation::{AnimatedValue, SpringValue};
use super::screens;
use super::theme::Theme;
use super::theme_registry::ThemeRegistry;
use super::widgets::footer;
use super::widgets::header;
const NEEDLE_STIFFNESS: f64 = 60.0;
const NEEDLE_DAMPING: f64 = 9.0;
const RATIO_RATE: f64 = 10.0;
const TRAIL_LENGTH: usize = 8;
pub const SWEEP_SECONDS: f64 = 1.2;
#[derive(Debug, Clone)]
pub struct Motion {
pub download_bps: f64,
pub upload_bps: f64,
pub download_ratio: f64,
pub upload_ratio: f64,
pub download_trail: Vec<f64>,
pub upload_trail: Vec<f64>,
pub sweep_ratio: Option<f64>,
}
struct NeedleAnim {
spring: SpringValue,
trail: VecDeque<f64>,
}
impl NeedleAnim {
fn new() -> Self {
Self {
spring: SpringValue::new(NEEDLE_STIFFNESS, NEEDLE_DAMPING),
trail: VecDeque::with_capacity(TRAIL_LENGTH),
}
}
fn reset(&mut self) {
self.spring.snap(0.0);
self.trail.clear();
}
fn advance(&mut self, target: f64, dt: f64) -> f64 {
self.spring.set_target(target);
let value = self.spring.tick(dt).max(0.0);
if self.trail.len() == TRAIL_LENGTH {
self.trail.pop_front();
}
self.trail.push_back(value);
value
}
}
pub struct Renderer {
registry: ThemeRegistry,
animation_speed: f64,
download: NeedleAnim,
upload: NeedleAnim,
download_ratio: AnimatedValue,
upload_ratio: AnimatedValue,
last_phase: Option<TestPhase>,
sweep_started: Option<Instant>,
}
impl Renderer {
#[must_use]
pub fn new(registry: ThemeRegistry, animation_speed: f64) -> Self {
Self {
registry,
animation_speed,
download: NeedleAnim::new(),
upload: NeedleAnim::new(),
download_ratio: AnimatedValue::new(RATIO_RATE * animation_speed),
upload_ratio: AnimatedValue::new(RATIO_RATE * animation_speed),
last_phase: None,
sweep_started: None,
}
}
#[must_use]
pub fn theme_names(&self) -> Vec<String> {
self.registry.names()
}
pub fn render(&mut self, frame: &mut Frame, state: &AppState, dt: f64) {
let motion = self.advance(state, dt);
let theme = self.theme(state.theme_index);
let area = frame.area();
super::wallpaper::render(frame, area, &theme.colors, &state.settings.wallpaper);
let [header_area, body_area, footer_area] = Layout::vertical([
Constraint::Length(2),
Constraint::Min(0),
Constraint::Length(2),
])
.areas(area);
header::render(
frame,
header_area,
&theme,
&format!("{} · {}", state.provider_name, state.server_name()),
state.client_info.as_deref(),
);
self.render_body(frame, body_area, &theme, state, &motion);
footer::render_frame(frame, footer_area, &theme, state);
}
fn advance(&mut self, state: &AppState, dt: f64) -> Motion {
let dt = dt * self.animation_speed;
if state.phase != self.last_phase {
match state.phase {
Some(TestPhase::Download) => {
self.download.reset();
self.sweep_started = Some(Instant::now());
}
Some(TestPhase::Upload) => {
self.upload.reset();
self.sweep_started = Some(Instant::now());
}
_ => self.sweep_started = None,
}
self.last_phase = state.phase;
}
let sweep_ratio = self.sweep_started.and_then(|started| {
let t = started.elapsed().as_secs_f64() / SWEEP_SECONDS;
if t >= 1.0 {
self.sweep_started = None;
None
} else {
Some(sweep_position(t))
}
});
let download_bps = self.download.advance(state.download.current_bps, dt);
let upload_bps = self.upload.advance(state.upload.current_bps, dt);
self.download_ratio.set_target(state.download.ratio);
self.upload_ratio.set_target(state.upload.ratio);
Motion {
download_bps,
upload_bps,
download_ratio: self.download_ratio.tick(dt).clamp(0.0, 1.0),
upload_ratio: self.upload_ratio.tick(dt).clamp(0.0, 1.0),
download_trail: self.download.trail.iter().copied().collect(),
upload_trail: self.upload.trail.iter().copied().collect(),
sweep_ratio,
}
}
fn theme(&self, index: usize) -> Theme {
self.registry.resolve(index)
}
fn render_body(
&self,
frame: &mut Frame,
area: Rect,
theme: &Theme,
state: &AppState,
motion: &Motion,
) {
let screen = state.screen;
if screen.is_overlay() {
self.render_base(frame, area, theme, state, motion, state.return_to);
}
match screen {
Screen::Help => screens::help::render(frame, area, theme),
Screen::Settings => screens::settings::render(frame, area, theme, state),
Screen::ServerSelect => screens::server_select::render(frame, area, theme, state),
Screen::ThemeSelect => {
screens::theme_select::render(frame, area, theme, state, &self.registry);
}
Screen::Trends => screens::trends::render(frame, area, theme, state),
base => self.render_base(frame, area, theme, state, motion, base),
}
}
fn render_base(
&self,
frame: &mut Frame,
area: Rect,
theme: &Theme,
state: &AppState,
motion: &Motion,
screen: Screen,
) {
match screen {
Screen::Splash => {
let status = if state.servers_loading {
"locating servers…"
} else {
"starting test…"
};
screens::splash::render(frame, area, theme, state.tick, status);
}
Screen::Testing => screens::testing::render(frame, area, theme, state, motion),
Screen::Results => screens::results::render(frame, area, theme, state),
Screen::Error => {
let message = state.error.as_deref().unwrap_or("unknown error");
screens::error::render(frame, area, theme, message);
}
_ => screens::splash::render(frame, area, theme, state.tick, ""),
}
}
}
fn sweep_position(t: f64) -> f64 {
let t = t.clamp(0.0, 1.0);
let leg = if t < 0.5 { t / 0.5 } else { (1.0 - t) / 0.5 };
leg * leg * (3.0 - 2.0 * leg)
}