use super::{Number, Numeric};
impl Number {
pub fn format(value: impl Numeric, decimals: u8) -> String {
value
.number()
.map(|number| NumberFormat::english().spell(number, decimals))
.unwrap_or_default()
}
pub fn format_in(cx: &topcoat::context::Cx, value: impl Numeric, decimals: u8) -> String {
value
.number()
.map(|number| NumberFormat::of(cx).spell(number, decimals))
.unwrap_or_default()
}
pub fn percent(value: impl Numeric, decimals: u8) -> String {
let number = Self::format(value, decimals);
if number.is_empty() {
return number;
}
format!("{number}%")
}
pub fn percent_in(cx: &topcoat::context::Cx, value: impl Numeric, decimals: u8) -> String {
let number = Self::format_in(cx, value, decimals);
if number.is_empty() {
return number;
}
crate::t!(cx, "tablo-percent", number = number)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Currency {
symbol: &'static str,
decimals: u8,
placement: Placement,
minor_units: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Placement {
Locale,
Before,
After,
}
impl Currency {
pub const USD: Self = Self::new("$");
pub const EUR: Self = Self::new("€");
pub const GBP: Self = Self::new("£");
pub const JPY: Self = Self::new("¥").decimals(0);
pub const fn new(symbol: &'static str) -> Self {
Self {
symbol,
decimals: 2,
placement: Placement::Locale,
minor_units: false,
}
}
#[must_use]
pub const fn decimals(mut self, decimals: u8) -> Self {
self.decimals = decimals;
self
}
#[must_use]
pub const fn symbol_after(mut self) -> Self {
self.placement = Placement::After;
self
}
#[must_use]
pub const fn symbol_before(mut self) -> Self {
self.placement = Placement::Before;
self
}
#[must_use]
pub const fn minor_units(mut self) -> Self {
self.minor_units = true;
self
}
pub fn format(&self, value: impl Numeric) -> String {
self.spell(value, &NumberFormat::english(), None)
}
pub fn format_in(&self, cx: &topcoat::context::Cx, value: impl Numeric) -> String {
self.spell(value, &NumberFormat::of(cx), Some(cx))
}
fn spell(
&self,
value: impl Numeric,
format: &NumberFormat,
cx: Option<&topcoat::context::Cx>,
) -> String {
let Some(number) = value.number() else {
return String::new();
};
let spelled = match (self.minor_units, number) {
(true, Number::Int(minor)) => format_minor_units(minor, self.decimals),
(true, Number::Float(minor)) => format_number(
Number::Float(minor / 10f64.powi(self.decimals.into())),
self.decimals,
),
(true, Number::Decimal { mantissa, scale }) => format_number(
Number::Decimal {
mantissa,
scale: scale.saturating_add(self.decimals.into()),
},
self.decimals,
),
(false, number) => format_number(number, self.decimals),
};
let spelled = format.localize(&spelled);
let (sign, digits) = match spelled.strip_prefix('-') {
Some(digits) => ("-", digits),
None => ("", spelled.as_str()),
};
match (self.placement, cx) {
(Placement::After, _) => format!("{sign}{digits} {}", self.symbol),
(Placement::Locale, Some(cx)) => crate::t!(
cx,
"tablo-money",
sign = sign.to_owned(),
symbol = self.symbol,
amount = digits.to_owned()
),
(Placement::Before | Placement::Locale, _) => {
format!("{sign}{}{digits}", self.symbol)
}
}
}
}
fn format_minor_units(minor: i128, decimals: u8) -> String {
let (whole, fraction) = match 10i128.checked_pow(decimals.into()) {
Some(scale) => (minor.abs() / scale, minor.abs() % scale),
None => (0, minor.abs()),
};
let whole = format_number(Number::Int(whole), 0);
let sign = if minor < 0 { "-" } else { "" };
if decimals == 0 {
return format!("{sign}{whole}");
}
format!(
"{sign}{whole}.{fraction:0width$}",
width = usize::from(decimals)
)
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct NumberFormat {
pub(crate) group: String,
pub(crate) decimal: String,
}
impl NumberFormat {
pub(crate) fn english() -> Self {
Self {
group: ",".to_owned(),
decimal: ".".to_owned(),
}
}
pub(crate) fn of(cx: &topcoat::context::Cx) -> Self {
Self {
group: crate::t!(cx, "tablo-number-group"),
decimal: crate::t!(cx, "tablo-number-decimal"),
}
}
pub(crate) fn spell(&self, number: Number, decimals: u8) -> String {
self.localize(&format_number(number, decimals))
}
fn localize(&self, english: &str) -> String {
if *self == Self::english() {
return english.to_owned();
}
let mut spelled = String::with_capacity(english.len() + 4);
for char in english.chars() {
match char {
',' => spelled.push_str(&self.group),
'.' => spelled.push_str(&self.decimal),
other => spelled.push(other),
}
}
spelled
}
}
pub(crate) fn format_number(number: Number, decimals: u8) -> String {
let fixed = match number {
Number::Int(int) if decimals == 0 => int.to_string(),
Number::Int(int) => format!("{int}.{}", "0".repeat(decimals.into())),
Number::Float(float) => {
let fixed = format!("{float:.*}", usize::from(decimals));
if fixed
.trim_start_matches('-')
.bytes()
.all(|b| b == b'0' || b == b'.')
{
fixed.trim_start_matches('-').to_string()
} else {
fixed
}
}
Number::Decimal { mantissa, scale } => fixed_decimal(mantissa, scale, decimals),
};
let (sign, unsigned) = match fixed.strip_prefix('-') {
Some(rest) => ("-", rest),
None => ("", fixed.as_str()),
};
let (whole, fraction) = match unsigned.split_once('.') {
Some((whole, fraction)) => (whole, Some(fraction)),
None => (unsigned, None),
};
let mut grouped = String::with_capacity(whole.len() + whole.len() / 3);
for (index, digit) in whole.chars().enumerate() {
if index > 0 && (whole.len() - index) % 3 == 0 {
grouped.push(',');
}
grouped.push(digit);
}
match fraction {
Some(fraction) => format!("{sign}{grouped}.{fraction}"),
None => format!("{sign}{grouped}"),
}
}
fn fixed_decimal(mantissa: i128, scale: u32, decimals: u8) -> String {
let digits = mantissa.unsigned_abs().to_string();
let decimals = u32::from(decimals);
let mut units = if scale <= decimals {
let zeros = (decimals - scale) as usize;
format!("{digits}{}", "0".repeat(zeros))
} else {
let dropped = usize::try_from(scale - decimals)
.ok()
.filter(|&dropped| dropped <= digits.len());
match dropped {
Some(dropped) => {
let (kept, rest) = digits.split_at(digits.len() - dropped);
let mut kept = if kept.is_empty() { "0" } else { kept }.to_string();
let odd = kept.bytes().last().is_some_and(|digit| digit % 2 == 1);
let rounds_up = match rest.as_bytes() {
[first, tail @ ..] => {
*first > b'5'
|| (*first == b'5' && (tail.iter().any(|&digit| digit != b'0') || odd))
}
[] => false,
};
if rounds_up {
increment(&mut kept);
}
kept
}
None => "0".to_string(),
}
};
let width = decimals as usize + 1;
if units.len() < width {
units.insert_str(0, &"0".repeat(width - units.len()));
}
let point = units.len() - decimals as usize;
let negative = mantissa < 0 && units.bytes().any(|digit| digit != b'0');
let sign = if negative { "-" } else { "" };
if decimals == 0 {
format!("{sign}{units}")
} else {
format!("{sign}{}.{}", &units[..point], &units[point..])
}
}
fn increment(digits: &mut String) {
let mut bytes = std::mem::take(digits).into_bytes();
let mut carry = true;
for digit in bytes.iter_mut().rev() {
if *digit == b'9' {
*digit = b'0';
} else {
*digit += 1;
carry = false;
break;
}
}
if carry {
bytes.insert(0, b'1');
}
*digits = String::from_utf8(bytes).expect("ASCII digits stay ASCII");
}