use serde::{Deserialize, Serialize};
use crate::domain::quantity::Quantity;
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize,
)]
#[serde(rename_all = "lowercase")]
pub enum Notation {
#[default]
Decimal,
Scientific,
SiPrefixed,
}
impl Notation {
pub fn next(self) -> Self {
match self {
Notation::Decimal => Notation::Scientific,
Notation::Scientific => Notation::SiPrefixed,
Notation::SiPrefixed => Notation::Decimal,
}
}
pub fn label(self) -> &'static str {
match self {
Notation::Decimal => "DEC",
Notation::Scientific => "SCI",
Notation::SiPrefixed => "SI",
}
}
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize,
)]
#[serde(rename_all = "lowercase")]
pub enum AngleMode {
#[default]
Rad,
Deg,
}
impl AngleMode {
pub fn toggled(self) -> Self {
match self {
AngleMode::Rad => AngleMode::Deg,
AngleMode::Deg => AngleMode::Rad,
}
}
pub fn label(self) -> &'static str {
match self {
AngleMode::Rad => "RAD",
AngleMode::Deg => "DEG",
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct FormatSettings {
pub notation: Notation,
pub decimals: usize,
pub angle_mode: AngleMode,
pub decimal_separator: char,
pub thousands_separator: String,
pub trim_trailing_zeros: bool,
}
impl FormatSettings {
pub fn input_thousands_char(&self) -> char {
if self.decimal_separator == '.' {
','
} else {
'.'
}
}
pub fn toggle_decimal_separator(&mut self) {
self.decimal_separator = self.input_thousands_char();
}
}
const SI_PREFIXES: &[(i32, &str)] = &[
(-12, "p"),
(-9, "n"),
(-6, "µ"),
(-3, "m"),
(0, ""),
(3, "k"),
(6, "M"),
(9, "G"),
(12, "T"),
];
pub fn format_display(
quantity: &Quantity,
settings: &FormatSettings,
) -> String {
with_unit(format_number(quantity.display_value(), settings), quantity)
}
pub fn format_plain(quantity: &Quantity, settings: &FormatSettings) -> String {
let number = swap_decimal_mark(
&quantity.display_value().to_string(),
settings.decimal_separator,
);
with_unit(number, quantity)
}
fn with_unit(number: String, quantity: &Quantity) -> String {
match quantity.unit_symbol() {
Some(symbol) => format!("{number} {symbol}"),
None => number,
}
}
fn format_number(value: f64, settings: &FormatSettings) -> String {
if !value.is_finite() {
return value.to_string();
}
let formatted = match settings.notation {
Notation::Decimal => format_decimal(value, settings),
Notation::Scientific => format_scientific(value, settings),
Notation::SiPrefixed => format_si(value, settings),
};
if settings.trim_trailing_zeros {
trim_optional_zeros(&formatted, settings.decimal_separator)
} else {
formatted
}
}
fn trim_optional_zeros(formatted: &str, mark: char) -> String {
let Some(mark_index) = formatted.find(mark) else {
return formatted.to_string();
};
let head = &formatted[..mark_index];
let after = &formatted[mark_index + mark.len_utf8()..];
let fraction_len = after.chars().take_while(char::is_ascii_digit).count();
let (fraction, suffix) = after.split_at(fraction_len);
let trimmed = fraction.trim_end_matches('0');
if trimmed.is_empty() {
format!("{head}{suffix}")
} else {
format!("{head}{mark}{trimmed}{suffix}")
}
}
fn format_decimal(value: f64, settings: &FormatSettings) -> String {
let raw = format!("{value:.prec$}", prec = settings.decimals);
let (sign, digits) = split_sign(&raw);
let (int_part, frac_part) = match digits.split_once('.') {
Some((int, frac)) => (int, Some(frac)),
None => (digits, None),
};
let grouped = group_thousands(int_part, &settings.thousands_separator);
let mut out = format!("{sign}{grouped}");
if let Some(frac) = frac_part {
out.push(settings.decimal_separator);
out.push_str(frac);
}
out
}
fn format_scientific(value: f64, settings: &FormatSettings) -> String {
let raw = format!("{value:.prec$e}", prec = settings.decimals);
swap_decimal_mark(&raw, settings.decimal_separator)
}
fn format_si(value: f64, settings: &FormatSettings) -> String {
if value == 0.0 {
return format_decimal(0.0, settings);
}
let exponent = value.abs().log10().floor() as i32;
let bucket = (exponent.div_euclid(3)) * 3;
let Some((_, prefix)) = SI_PREFIXES.iter().find(|(e, _)| *e == bucket)
else {
return format_scientific(value, settings);
};
let mantissa = value / 10f64.powi(bucket);
let raw = format!("{mantissa:.prec$}", prec = settings.decimals);
let mantissa = swap_decimal_mark(&raw, settings.decimal_separator);
if prefix.is_empty() {
mantissa
} else {
format!("{mantissa} {prefix}")
}
}
fn split_sign(formatted: &str) -> (&str, &str) {
match formatted.strip_prefix('-') {
Some(rest) => ("-", rest),
None => ("", formatted),
}
}
fn group_thousands(int_digits: &str, separator: &str) -> String {
if separator.is_empty() || int_digits.len() <= 3 {
return int_digits.to_string();
}
let digits: Vec<char> = int_digits.chars().collect();
let first = digits.len() % 3;
let mut out: String = digits[..first].iter().collect();
for chunk in digits[first..].chunks(3) {
if !out.is_empty() {
out.push_str(separator);
}
out.extend(chunk);
}
out
}
fn swap_decimal_mark(formatted: &str, mark: char) -> String {
if mark == '.' {
return formatted.to_string();
}
formatted.replace('.', &mark.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn settings() -> FormatSettings {
FormatSettings {
notation: Notation::Decimal,
decimals: 3,
angle_mode: AngleMode::Rad,
decimal_separator: '.',
thousands_separator: " ".to_string(),
trim_trailing_zeros: false,
}
}
fn q(value: f64) -> Quantity {
Quantity::dimensionless(value)
}
#[test]
fn decimal_groups_thousands_and_keeps_precision() {
let s = settings();
assert_eq!(format_display(&q(1234.5), &s), "1 234.500");
assert_eq!(format_display(&q(-1_000_000.0), &s), "-1 000 000.000");
assert_eq!(format_display(&q(12.0), &s), "12.000");
}
#[test]
fn decimal_respects_a_comma_mark_and_dot_grouping() {
let mut s = settings();
s.decimal_separator = ',';
s.thousands_separator = ".".to_string();
assert_eq!(format_display(&q(1234.5), &s), "1.234,500");
}
#[test]
fn scientific_uses_the_configured_mark() {
let mut s = settings();
s.notation = Notation::Scientific;
s.decimals = 3;
assert_eq!(format_display(&q(1234.0), &s), "1.234e3");
s.decimal_separator = ',';
assert_eq!(format_display(&q(1234.0), &s), "1,234e3");
}
#[test]
fn si_prefix_picks_engineering_buckets() {
let mut s = settings();
s.notation = Notation::SiPrefixed;
assert_eq!(format_display(&q(1500.0), &s), "1.500 k");
assert_eq!(format_display(&q(0.0033), &s), "3.300 m");
assert_eq!(format_display(&q(2_000_000.0), &s), "2.000 M");
assert_eq!(format_display(&q(42.0), &s), "42.000");
}
#[test]
fn plain_keeps_full_precision_without_grouping() {
let s = settings();
assert_eq!(format_plain(&q(1234.5), &s), "1234.5");
assert_eq!(
format_plain(&q(std::f64::consts::PI), &s),
std::f64::consts::PI.to_string()
);
}
#[test]
fn plain_swaps_the_decimal_mark() {
let mut s = settings();
s.decimal_separator = ',';
assert_eq!(format_plain(&q(1234.5), &s), "1234,5");
}
#[test]
fn trimming_drops_trailing_zeros_across_notations() {
let mut s = settings();
s.trim_trailing_zeros = true;
assert_eq!(format_display(&q(12.0), &s), "12");
assert_eq!(format_display(&q(1234.5), &s), "1 234.5");
assert_eq!(format_display(&q(1.23), &s), "1.23");
s.notation = Notation::Scientific;
assert_eq!(format_display(&q(1200.0), &s), "1.2e3");
assert_eq!(format_display(&q(1000.0), &s), "1e3");
s.notation = Notation::SiPrefixed;
assert_eq!(format_display(&q(1500.0), &s), "1.5 k");
assert_eq!(format_display(&q(42.0), &s), "42");
}
#[test]
fn trimming_respects_a_comma_decimal_mark() {
let mut s = settings();
s.trim_trailing_zeros = true;
s.decimal_separator = ',';
s.thousands_separator = ".".to_string();
assert_eq!(format_display(&q(1234.5), &s), "1.234,5");
assert_eq!(format_display(&q(12.0), &s), "12");
}
#[test]
fn trimming_off_keeps_fixed_decimals() {
let s = settings();
assert_eq!(format_display(&q(12.0), &s), "12.000");
}
#[test]
fn a_quantity_appends_its_unit() {
let s = settings();
let bar = Quantity::new(1230.0, "bar");
assert_eq!(format_display(&bar, &s), "1 230.000 bar");
assert_eq!(format_plain(&bar, &s), "1230 bar");
}
}