#[cfg(feature = "animation")]
use crate::animation::Spring;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Direction {
Up,
Down,
}
#[derive(Debug, Clone)]
pub struct CountUpState {
pub value: f64,
pub is_complete: bool,
}
#[derive(Debug, Clone)]
pub struct CountUp {
pub from: f64,
pub to: f64,
pub direction: Direction,
pub duration: f64,
pub delay: f64,
#[cfg(feature = "animation")]
pub spring: Option<Spring>,
}
impl CountUp {
pub fn new(from: f64, to: f64) -> Self {
Self {
from,
to,
direction: Direction::Up,
duration: 2.0,
delay: 0.0,
#[cfg(feature = "animation")]
spring: None,
}
}
pub fn with_direction(mut self, direction: Direction) -> Self {
self.direction = direction;
self
}
pub fn with_duration(mut self, duration: f64) -> Self {
self.duration = duration.max(0.1); self
}
pub fn with_delay(mut self, delay: f64) -> Self {
self.delay = delay.max(0.0);
self
}
#[cfg(feature = "animation")]
pub fn with_spring(mut self, spring: Spring) -> Self {
self.spring = Some(spring);
self
}
#[cfg(feature = "animation")]
fn calculate_spring(&self) -> Spring {
if let Some(spring) = self.spring {
return spring;
}
let damping = 20.0 + 40.0 * (1.0 / self.duration);
let stiffness = 100.0 * (1.0 / self.duration);
Spring::new()
.damping(damping)
.stiffness(stiffness)
.mass(1.0)
}
#[cfg(feature = "animation")]
pub fn evaluate(&self, t: f64) -> CountUpState {
let effective_t = t - self.delay;
if effective_t < 0.0 {
let initial = match self.direction {
Direction::Up => self.from,
Direction::Down => self.to,
};
return CountUpState {
value: initial,
is_complete: false,
};
}
let spring = self.calculate_spring();
let (displacement, _velocity) = spring.evaluate(effective_t);
let is_complete = spring.is_at_rest(effective_t);
let value = match self.direction {
Direction::Up => {
self.from + (self.to - self.from) * (1.0 - displacement)
}
Direction::Down => {
self.to + (self.from - self.to) * (1.0 - displacement)
}
};
CountUpState { value, is_complete }
}
#[cfg(not(feature = "animation"))]
pub fn evaluate(&self, t: f64) -> CountUpState {
let effective_t = t - self.delay;
let value = if effective_t < 0.0 {
match self.direction {
Direction::Up => self.from,
Direction::Down => self.to,
}
} else {
match self.direction {
Direction::Up => self.to,
Direction::Down => self.from,
}
};
CountUpState {
value,
is_complete: effective_t >= 0.0,
}
}
pub fn get_decimal_places(&self) -> usize {
let from_decimals = Self::count_decimals(self.from);
let to_decimals = Self::count_decimals(self.to);
from_decimals.max(to_decimals)
}
fn count_decimals(num: f64) -> usize {
let s = num.to_string();
if let Some(dot_pos) = s.find('.') {
let decimals = &s[dot_pos + 1..];
if decimals.parse::<i64>().ok().filter(|&d| d != 0).is_some() {
return decimals.len();
}
}
0
}
pub fn format_value(&self, value: f64) -> (String, usize) {
let decimals = self.get_decimal_places();
if decimals > 0 {
(format!("{:.prec$}", value, prec = decimals), decimals)
} else {
(format!("{:.0}", value), 0)
}
}
pub fn format_with_separator(&self, value: f64, separator: &str) -> String {
let (base, _decimals) = self.format_value(value);
if separator.is_empty() {
return base;
}
let parts: Vec<&str> = base.split('.').collect();
let integer_part = parts[0];
let decimal_part = if parts.len() > 1 { parts[1] } else { "" };
let mut formatted = String::new();
let chars: Vec<char> = integer_part.chars().collect();
let len = chars.len();
for (i, &ch) in chars.iter().enumerate() {
formatted.push(ch);
let remaining = len - i - 1;
if remaining > 0 && remaining.is_multiple_of(3) {
formatted.push_str(separator);
}
}
if !decimal_part.is_empty() {
formatted.push('.');
formatted.push_str(decimal_part);
}
formatted
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_direction() {
let up = CountUp::new(0.0, 100.0);
assert_eq!(up.direction, Direction::Up);
let down = CountUp::new(0.0, 100.0).with_direction(Direction::Down);
assert_eq!(down.direction, Direction::Down);
}
#[test]
fn test_count_decimals() {
assert_eq!(CountUp::count_decimals(123.0), 0);
assert_eq!(CountUp::count_decimals(123.45), 2);
assert_eq!(CountUp::count_decimals(0.001), 3);
assert_eq!(CountUp::count_decimals(100.00), 0); }
#[test]
fn test_get_decimal_places() {
let cu1 = CountUp::new(0.0, 100.0);
assert_eq!(cu1.get_decimal_places(), 0);
let cu2 = CountUp::new(0.0, 100.5);
assert_eq!(cu2.get_decimal_places(), 1);
let cu3 = CountUp::new(0.123, 100.0);
assert_eq!(cu3.get_decimal_places(), 3);
let cu4 = CountUp::new(0.1, 100.99);
assert_eq!(cu4.get_decimal_places(), 2);
}
#[test]
fn test_format_value() {
let cu = CountUp::new(0.0, 100.5);
let (formatted, decimals) = cu.format_value(50.25);
assert_eq!(formatted, "50.2");
assert_eq!(decimals, 1);
let cu2 = CountUp::new(0.0, 100.0);
let (formatted2, decimals2) = cu2.format_value(50.0);
assert_eq!(formatted2, "50");
assert_eq!(decimals2, 0);
}
#[test]
fn test_format_with_separator() {
let cu = CountUp::new(0.0, 100000.0);
let formatted = cu.format_with_separator(12345.0, ",");
assert_eq!(formatted, "12,345");
let cu2 = CountUp::new(0.0, 1000000.5);
let formatted2 = cu2.format_with_separator(1234567.5, ",");
assert_eq!(formatted2, "1,234,567.5");
let cu3 = CountUp::new(0.0, 100.0);
let formatted3 = cu3.format_with_separator(100.0, "");
assert_eq!(formatted3, "100");
}
#[test]
fn test_evaluate_before_delay() {
let cu = CountUp::new(0.0, 100.0).with_delay(1.0);
let state = cu.evaluate(0.5);
assert_eq!(state.value, 0.0);
assert!(!state.is_complete);
}
#[test]
#[cfg(feature = "animation")]
fn test_evaluate_up() {
let cu = CountUp::new(0.0, 100.0).with_duration(2.0);
let state_start = cu.evaluate(0.0);
let state_mid = cu.evaluate(1.0);
let state_end = cu.evaluate(10.0);
assert!(state_start.value < 10.0);
assert!(state_mid.value > state_start.value);
assert!(state_mid.value < 100.0);
assert!(state_end.value > 90.0);
assert!(state_end.is_complete);
}
#[test]
#[cfg(feature = "animation")]
fn test_evaluate_down() {
let cu = CountUp::new(0.0, 100.0)
.with_direction(Direction::Down)
.with_duration(2.0);
let state_start = cu.evaluate(0.0);
let state_end = cu.evaluate(5.0);
assert!(state_start.value > 90.0);
assert!(state_end.value < 10.0);
}
#[test]
#[cfg(feature = "animation")]
fn test_custom_spring() {
let spring = Spring::bouncy();
let cu = CountUp::new(0.0, 100.0).with_spring(spring);
let state = cu.evaluate(0.5);
assert!(state.value > 0.0);
}
#[test]
#[cfg(feature = "animation")]
fn test_duration_affects_speed() {
let cu_fast = CountUp::new(0.0, 100.0).with_duration(0.5);
let cu_slow = CountUp::new(0.0, 100.0).with_duration(5.0);
let state_fast = cu_fast.evaluate(0.25);
let state_slow = cu_slow.evaluate(0.25);
assert!(state_fast.value > state_slow.value);
}
#[test]
fn test_with_delay() {
let cu = CountUp::new(0.0, 100.0).with_delay(2.0);
assert_eq!(cu.delay, 2.0);
let cu2 = CountUp::new(0.0, 100.0).with_delay(-1.0);
assert_eq!(cu2.delay, 0.0); }
#[test]
fn test_with_duration() {
let cu = CountUp::new(0.0, 100.0).with_duration(3.5);
assert_eq!(cu.duration, 3.5);
let cu2 = CountUp::new(0.0, 100.0).with_duration(0.01);
assert!(cu2.duration >= 0.1); }
}