1use super::{Number, Numeric};
5
6impl Number {
7 pub fn format(value: impl Numeric, decimals: u8) -> String {
17 value
18 .number()
19 .map(|number| NumberFormat::english().spell(number, decimals))
20 .unwrap_or_default()
21 }
22
23 pub fn format_in(cx: &topcoat::context::Cx, value: impl Numeric, decimals: u8) -> String {
26 value
27 .number()
28 .map(|number| NumberFormat::of(cx).spell(number, decimals))
29 .unwrap_or_default()
30 }
31
32 pub fn percent(value: impl Numeric, decimals: u8) -> String {
42 let number = Self::format(value, decimals);
43 if number.is_empty() {
44 return number;
45 }
46 format!("{number}%")
47 }
48
49 pub fn percent_in(cx: &topcoat::context::Cx, value: impl Numeric, decimals: u8) -> String {
52 let number = Self::format_in(cx, value, decimals);
53 if number.is_empty() {
54 return number;
55 }
56 crate::t!(cx, "tablo-percent", number = number)
57 }
58}
59
60#[derive(Clone, Copy, Debug, PartialEq, Eq)]
80pub struct Currency {
81 symbol: &'static str,
82 decimals: u8,
83 placement: Placement,
84 minor_units: bool,
85}
86
87#[derive(Clone, Copy, Debug, PartialEq, Eq)]
89enum Placement {
90 Locale,
92 Before,
93 After,
94}
95
96impl Currency {
97 pub const USD: Self = Self::new("$");
99 pub const EUR: Self = Self::new("€");
101 pub const GBP: Self = Self::new("£");
103 pub const JPY: Self = Self::new("¥").decimals(0);
105
106 pub const fn new(symbol: &'static str) -> Self {
108 Self {
109 symbol,
110 decimals: 2,
111 placement: Placement::Locale,
112 minor_units: false,
113 }
114 }
115
116 #[must_use]
118 pub const fn decimals(mut self, decimals: u8) -> Self {
119 self.decimals = decimals;
120 self
121 }
122
123 #[must_use]
125 pub const fn symbol_after(mut self) -> Self {
126 self.placement = Placement::After;
127 self
128 }
129
130 #[must_use]
132 pub const fn symbol_before(mut self) -> Self {
133 self.placement = Placement::Before;
134 self
135 }
136
137 #[must_use]
139 pub const fn minor_units(mut self) -> Self {
140 self.minor_units = true;
141 self
142 }
143
144 pub fn format(&self, value: impl Numeric) -> String {
147 self.spell(value, &NumberFormat::english(), None)
148 }
149
150 pub fn format_in(&self, cx: &topcoat::context::Cx, value: impl Numeric) -> String {
153 self.spell(value, &NumberFormat::of(cx), Some(cx))
154 }
155
156 fn spell(
159 &self,
160 value: impl Numeric,
161 format: &NumberFormat,
162 cx: Option<&topcoat::context::Cx>,
163 ) -> String {
164 let Some(number) = value.number() else {
165 return String::new();
166 };
167 let spelled = match (self.minor_units, number) {
168 (true, Number::Int(minor)) => format_minor_units(minor, self.decimals),
169 (true, Number::Float(minor)) => format_number(
170 Number::Float(minor / 10f64.powi(self.decimals.into())),
171 self.decimals,
172 ),
173 (true, Number::Decimal { mantissa, scale }) => format_number(
174 Number::Decimal {
175 mantissa,
176 scale: scale.saturating_add(self.decimals.into()),
177 },
178 self.decimals,
179 ),
180 (false, number) => format_number(number, self.decimals),
181 };
182 let spelled = format.localize(&spelled);
183 let (sign, digits) = match spelled.strip_prefix('-') {
184 Some(digits) => ("-", digits),
185 None => ("", spelled.as_str()),
186 };
187 match (self.placement, cx) {
188 (Placement::After, _) => format!("{sign}{digits} {}", self.symbol),
189 (Placement::Locale, Some(cx)) => crate::t!(
190 cx,
191 "tablo-money",
192 sign = sign.to_owned(),
193 symbol = self.symbol,
194 amount = digits.to_owned()
195 ),
196 (Placement::Before | Placement::Locale, _) => {
197 format!("{sign}{}{digits}", self.symbol)
198 }
199 }
200 }
201}
202
203fn format_minor_units(minor: i128, decimals: u8) -> String {
205 let (whole, fraction) = match 10i128.checked_pow(decimals.into()) {
207 Some(scale) => (minor.abs() / scale, minor.abs() % scale),
208 None => (0, minor.abs()),
209 };
210 let whole = format_number(Number::Int(whole), 0);
211 let sign = if minor < 0 { "-" } else { "" };
212 if decimals == 0 {
213 return format!("{sign}{whole}");
214 }
215 format!(
216 "{sign}{whole}.{fraction:0width$}",
217 width = usize::from(decimals)
218 )
219}
220
221#[derive(Clone, Debug, PartialEq, Eq)]
224pub(crate) struct NumberFormat {
225 pub(crate) group: String,
226 pub(crate) decimal: String,
227}
228
229impl NumberFormat {
230 pub(crate) fn english() -> Self {
232 Self {
233 group: ",".to_owned(),
234 decimal: ".".to_owned(),
235 }
236 }
237
238 pub(crate) fn of(cx: &topcoat::context::Cx) -> Self {
240 Self {
241 group: crate::t!(cx, "tablo-number-group"),
242 decimal: crate::t!(cx, "tablo-number-decimal"),
243 }
244 }
245
246 pub(crate) fn spell(&self, number: Number, decimals: u8) -> String {
248 self.localize(&format_number(number, decimals))
249 }
250
251 fn localize(&self, english: &str) -> String {
253 if *self == Self::english() {
254 return english.to_owned();
255 }
256 let mut spelled = String::with_capacity(english.len() + 4);
257 for char in english.chars() {
258 match char {
259 ',' => spelled.push_str(&self.group),
260 '.' => spelled.push_str(&self.decimal),
261 other => spelled.push(other),
262 }
263 }
264 spelled
265 }
266}
267
268pub(crate) fn format_number(number: Number, decimals: u8) -> String {
270 let fixed = match number {
271 Number::Int(int) if decimals == 0 => int.to_string(),
272 Number::Int(int) => format!("{int}.{}", "0".repeat(decimals.into())),
273 Number::Float(float) => {
274 let fixed = format!("{float:.*}", usize::from(decimals));
275 if fixed
277 .trim_start_matches('-')
278 .bytes()
279 .all(|b| b == b'0' || b == b'.')
280 {
281 fixed.trim_start_matches('-').to_string()
282 } else {
283 fixed
284 }
285 }
286 Number::Decimal { mantissa, scale } => fixed_decimal(mantissa, scale, decimals),
287 };
288 let (sign, unsigned) = match fixed.strip_prefix('-') {
289 Some(rest) => ("-", rest),
290 None => ("", fixed.as_str()),
291 };
292 let (whole, fraction) = match unsigned.split_once('.') {
293 Some((whole, fraction)) => (whole, Some(fraction)),
294 None => (unsigned, None),
295 };
296 let mut grouped = String::with_capacity(whole.len() + whole.len() / 3);
297 for (index, digit) in whole.chars().enumerate() {
298 if index > 0 && (whole.len() - index) % 3 == 0 {
299 grouped.push(',');
300 }
301 grouped.push(digit);
302 }
303 match fraction {
304 Some(fraction) => format!("{sign}{grouped}.{fraction}"),
305 None => format!("{sign}{grouped}"),
306 }
307}
308
309fn fixed_decimal(mantissa: i128, scale: u32, decimals: u8) -> String {
313 let digits = mantissa.unsigned_abs().to_string();
314 let decimals = u32::from(decimals);
315 let mut units = if scale <= decimals {
317 let zeros = (decimals - scale) as usize;
318 format!("{digits}{}", "0".repeat(zeros))
319 } else {
320 let dropped = usize::try_from(scale - decimals)
322 .ok()
323 .filter(|&dropped| dropped <= digits.len());
324 match dropped {
325 Some(dropped) => {
326 let (kept, rest) = digits.split_at(digits.len() - dropped);
327 let mut kept = if kept.is_empty() { "0" } else { kept }.to_string();
328 let odd = kept.bytes().last().is_some_and(|digit| digit % 2 == 1);
329 let rounds_up = match rest.as_bytes() {
330 [first, tail @ ..] => {
331 *first > b'5'
332 || (*first == b'5' && (tail.iter().any(|&digit| digit != b'0') || odd))
333 }
334 [] => false,
335 };
336 if rounds_up {
337 increment(&mut kept);
338 }
339 kept
340 }
341 None => "0".to_string(),
342 }
343 };
344 let width = decimals as usize + 1;
345 if units.len() < width {
346 units.insert_str(0, &"0".repeat(width - units.len()));
347 }
348 let point = units.len() - decimals as usize;
349 let negative = mantissa < 0 && units.bytes().any(|digit| digit != b'0');
350 let sign = if negative { "-" } else { "" };
351 if decimals == 0 {
352 format!("{sign}{units}")
353 } else {
354 format!("{sign}{}.{}", &units[..point], &units[point..])
355 }
356}
357
358fn increment(digits: &mut String) {
360 let mut bytes = std::mem::take(digits).into_bytes();
361 let mut carry = true;
362 for digit in bytes.iter_mut().rev() {
363 if *digit == b'9' {
364 *digit = b'0';
365 } else {
366 *digit += 1;
367 carry = false;
368 break;
369 }
370 }
371 if carry {
372 bytes.insert(0, b'1');
373 }
374 *digits = String::from_utf8(bytes).expect("ASCII digits stay ASCII");
375}