From ac982f585620a88073b97b77303237290c09db81 Mon Sep 17 00:00:00 2001 From: Andrew Voynov <37143421+Andrew15-5@users.noreply.github.com> Date: Tue, 10 Sep 2024 16:49:37 +0300 Subject: [PATCH] Unified and fixed `Duration` formatting in the CLI (#4587) Co-authored-by: PgBiel <9021226+PgBiel@users.noreply.github.com> --- crates/typst-cli/src/download.rs | 38 ++-- crates/typst-cli/src/watch.rs | 13 +- crates/typst-utils/src/format.rs | 256 +++++++++++++++++++++++++++ crates/typst-utils/src/lib.rs | 1 + crates/typst/src/foundations/repr.rs | 4 +- 5 files changed, 280 insertions(+), 32 deletions(-) create mode 100644 crates/typst-utils/src/format.rs diff --git a/crates/typst-cli/src/download.rs b/crates/typst-cli/src/download.rs index 8082fa52b..3e3d8294d 100644 --- a/crates/typst-cli/src/download.rs +++ b/crates/typst-cli/src/download.rs @@ -5,6 +5,7 @@ use std::time::{Duration, Instant}; use codespan_reporting::term; use codespan_reporting::term::termcolor::WriteColor; +use typst::utils::format; use typst_kit::download::{DownloadState, Downloader, Progress}; use crate::terminal::{self, TermOut}; @@ -61,7 +62,9 @@ pub fn display_download_progress( let total_downloaded = as_bytes_unit(state.total_downloaded); let speed_h = as_throughput_unit(speed); - let elapsed = time_suffix(Instant::now().saturating_duration_since(state.start_time)); + let elapsed = format::time_starting_with_seconds( + &Instant::now().saturating_duration_since(state.start_time), + ); match state.content_len { Some(content_len) => { @@ -69,11 +72,12 @@ pub fn display_download_progress( let remaining = content_len - state.total_downloaded; let download_size = as_bytes_unit(content_len); - let eta = time_suffix(Duration::from_secs(if speed == 0 { - 0 - } else { - (remaining / speed) as u64 - })); + let eta = + format::time_starting_with_seconds(&Duration::from_secs(if speed == 0 { + 0 + } else { + (remaining / speed) as u64 + })); writeln!( out, "{total_downloaded} / {download_size} ({percent:3.0} %) {speed_h} in {elapsed} ETA: {eta}", @@ -87,26 +91,6 @@ pub fn display_download_progress( Ok(()) } -/// Append a unit-of-time suffix. -fn time_suffix(duration: Duration) -> String { - let secs = duration.as_secs(); - match format_dhms(secs) { - (0, 0, 0, s) => format!("{s:2.0}s"), - (0, 0, m, s) => format!("{m:2.0}m {s:2.0}s"), - (0, h, m, s) => format!("{h:2.0}h {m:2.0}m {s:2.0}s"), - (d, h, m, s) => format!("{d:3.0}d {h:2.0}h {m:2.0}m {s:2.0}s"), - } -} - -/// Format the total amount of seconds into the amount of days, hours, minutes -/// and seconds. -fn format_dhms(sec: u64) -> (u64, u8, u8, u8) { - let (mins, sec) = (sec / 60, (sec % 60) as u8); - let (hours, mins) = (mins / 60, (mins % 60) as u8); - let (days, hours) = (hours / 24, (hours % 24) as u8); - (days, hours, mins, sec) -} - /// Format a given size as a unit of time. Setting `include_suffix` to true /// appends a '/s' (per second) suffix. fn as_bytes_unit(size: usize) -> String { @@ -123,7 +107,7 @@ fn as_bytes_unit(size: usize) -> String { } else if size >= KI { format!("{:5.1} KiB", size / KI) } else { - format!("{size:3.0} B") + format!("{size:3} B") } } diff --git a/crates/typst-cli/src/watch.rs b/crates/typst-cli/src/watch.rs index 348cd005b..eb437ffdd 100644 --- a/crates/typst-cli/src/watch.rs +++ b/crates/typst-cli/src/watch.rs @@ -11,6 +11,7 @@ use ecow::eco_format; use notify::{Event, RecommendedWatcher, RecursiveMode, Watcher as _}; use same_file::is_same_file; use typst::diag::{bail, StrResult}; +use typst::utils::format; use crate::args::{CompileCommand, Input, Output}; use crate::compile::compile_once; @@ -297,10 +298,16 @@ impl Status { fn message(&self) -> String { match self { Self::Compiling => "compiling ...".into(), - Self::Success(duration) => format!("compiled successfully in {duration:.2?}"), - Self::PartialSuccess(duration) => { - format!("compiled with warnings in {duration:.2?}") + Self::Success(duration) => { + format!( + "compiled successfully in {}", + format::time_starting_with_milliseconds(duration, 2) + ) } + Self::PartialSuccess(duration) => format!( + "compiled with warnings in {}", + format::time_starting_with_milliseconds(duration, 2) + ), Self::Error => "compiled with errors".into(), } } diff --git a/crates/typst-utils/src/format.rs b/crates/typst-utils/src/format.rs new file mode 100644 index 000000000..36c6bb50a --- /dev/null +++ b/crates/typst-utils/src/format.rs @@ -0,0 +1,256 @@ +//! Different formatting functions. + +use std::time::Duration; + +/// Returns value with `n` digits after floating point where `n` is `precision`. +/// Standard rounding rules apply (if `n+1`th digit >= 5, round up). +/// +/// If rounding the `value` will have no effect (e.g., it's infinite or NaN), +/// returns `value` unchanged. +/// +/// # Examples +/// +/// ``` +/// # use typst_utils::format::round_with_precision; +/// let rounded = round_with_precision(-0.56553, 2); +/// assert_eq!(-0.57, rounded); +/// ``` +pub fn round_with_precision(value: f64, precision: u8) -> f64 { + // Don't attempt to round the float if that wouldn't have any effect. + // This includes infinite or NaN values, as well as integer values + // with a filled mantissa (which can't have a fractional part). + // Rounding with a precision larger than the amount of digits that can be + // effectively represented would also be a no-op. Given that, the check + // below ensures we won't proceed if `|value| >= 2^53` or if + // `precision >= 15`, which also ensures the multiplication by `offset` + // won't return `inf`, since `2^53 * 10^15` (larger than any possible + // `value * offset` multiplication) does not. + if value.is_infinite() + || value.is_nan() + || value.abs() >= (1_i64 << f64::MANTISSA_DIGITS) as f64 + || precision as u32 >= f64::DIGITS + { + return value; + } + let offset = 10_f64.powi(precision.into()); + assert!((value * offset).is_finite(), "{value} * {offset} is not finite!"); + (value * offset).round() / offset +} + +/// Returns `(days, hours, minutes, seconds, milliseconds, microseconds)`. +fn get_duration_parts(duration: &Duration) -> (u64, u8, u8, u8, u16, u16) { + // In practice we probably don't need nanoseconds. + let micros = duration.as_micros(); + let (millis, micros) = (micros / 1000, (micros % 1000) as u16); + let (sec, millis) = (millis / 1000, (millis % 1000) as u16); + let (mins, sec) = (sec / 60, (sec % 60) as u8); + let (hours, mins) = (mins / 60, (mins % 60) as u8); + let (days, hours) = ((hours / 24) as u64, (hours % 24) as u8); + (days, hours, mins, sec, millis, micros) +} + +/// Format string using `days`, `hours`, `minutes`, `seconds`. +fn format_dhms(days: u64, hours: u8, minutes: u8, seconds: u8) -> String { + match (days, hours, minutes, seconds) { + (0, 0, 0, s) => format!("{s:2} s"), + (0, 0, m, s) => format!("{m:2} m {s:2} s"), + (0, h, m, s) => format!("{h:2} h {m:2} m {s:2} s"), + (d, h, m, s) => format!("{d:3} d {h:2} h {m:2} m {s:2} s"), + } +} + +/// Format string starting with number of seconds and going bigger from there. +/// +/// # Examples +/// +/// ``` +/// # use std::time::Duration; +/// # use typst_utils::format::time_starting_with_seconds; +/// let duration1 = time_starting_with_seconds(&Duration::from_secs(0)); +/// assert_eq!(" 0 s", &duration1); +/// +/// let duration2 = time_starting_with_seconds(&Duration::from_secs( +/// 24 * 60 * 60 * 100 + // days +/// 60 * 60 * 10 + // hours +/// 60 * 10 + // minutes +/// 10 // seconds +/// )); +/// assert_eq!("100 d 10 h 10 m 10 s", &duration2); +/// ``` +pub fn time_starting_with_seconds(duration: &Duration) -> String { + let (days, hours, minutes, seconds, _, _) = get_duration_parts(duration); + format_dhms(days, hours, minutes, seconds) +} + +/// Format string starting with number of milliseconds and going bigger +/// from there. `precision` is how many digits of microseconds +/// from floating point to the right will be preserved (with rounding). +/// Keep in mind that this function will always remove all trailing zeros +/// for microseconds. +/// +/// Note: if duration is 1 second or longer, then output will be identical +/// to [time_starting_with_seconds], which also means that precision, +/// number of milliseconds and microseconds will not be used. +/// +/// # Examples +/// +/// ``` +/// # use std::time::Duration; +/// # use typst_utils::format::time_starting_with_milliseconds; +/// let duration1 = time_starting_with_milliseconds(&Duration::from_micros( +/// 123 * 1000 + // milliseconds +/// 456 // microseconds +/// ), 2); +/// assert_eq!("123.46 ms", &duration1); +/// +/// let duration2 = time_starting_with_milliseconds(&Duration::from_micros( +/// 123 * 1000 // milliseconds +/// ), 2); +/// assert_eq!("123 ms", &duration2); +/// +/// let duration3 = time_starting_with_milliseconds(&Duration::from_secs(1), 2); +/// assert_eq!(" 1 s", &duration3); +/// ``` +pub fn time_starting_with_milliseconds(duration: &Duration, precision: u8) -> String { + let (d, h, m, s, ms, mcs) = get_duration_parts(duration); + match (d, h, m, s) { + (0, 0, 0, 0) => { + let ms_mcs = ms as f64 + mcs as f64 / 1000.0; + format!("{} ms", round_with_precision(ms_mcs, precision)) + } + (d, h, m, s) => format_dhms(d, h, m, s), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_round_with_precision_0() { + let round = |value| round_with_precision(value, 0); + assert_eq!(0.0, round(0.0)); + assert_eq!(-0.0, round(-0.0)); + assert_eq!(0.0, round(0.4)); + assert_eq!(-0.0, round(-0.4)); + assert_eq!(1.0, round(0.56453)); + assert_eq!(-1.0, round(-0.56453)); + } + + #[test] + fn test_round_with_precision_1() { + let round = |value| round_with_precision(value, 1); + assert_eq!(0.0, round(0.0)); + assert_eq!(-0.0, round(-0.0)); + assert_eq!(0.4, round(0.4)); + assert_eq!(-0.4, round(-0.4)); + assert_eq!(0.4, round(0.44)); + assert_eq!(-0.4, round(-0.44)); + assert_eq!(0.6, round(0.56453)); + assert_eq!(-0.6, round(-0.56453)); + assert_eq!(1.0, round(0.96453)); + assert_eq!(-1.0, round(-0.96453)); + } + + #[test] + fn test_round_with_precision_2() { + let round = |value| round_with_precision(value, 2); + assert_eq!(0.0, round(0.0)); + assert_eq!(-0.0, round(-0.0)); + assert_eq!(0.4, round(0.4)); + assert_eq!(-0.4, round(-0.4)); + assert_eq!(0.44, round(0.44)); + assert_eq!(-0.44, round(-0.44)); + assert_eq!(0.44, round(0.444)); + assert_eq!(-0.44, round(-0.444)); + assert_eq!(0.57, round(0.56553)); + assert_eq!(-0.57, round(-0.56553)); + assert_eq!(1.0, round(0.99553)); + assert_eq!(-1.0, round(-0.99553)); + } + + #[test] + fn test_round_with_precision_fuzzy() { + let round = |value| round_with_precision(value, 0); + assert_eq!(f64::INFINITY, round(f64::INFINITY)); + assert_eq!(f64::NEG_INFINITY, round(f64::NEG_INFINITY)); + assert!(round(f64::NAN).is_nan()); + + let max_int = (1_i64 << f64::MANTISSA_DIGITS) as f64; + let f64_digits = f64::DIGITS as u8; + + // max + assert_eq!(max_int, round(max_int)); + assert_eq!(0.123456, round_with_precision(0.123456, f64_digits)); + assert_eq!(max_int, round_with_precision(max_int, f64_digits)); + + // max - 1 + assert_eq!(max_int - 1f64, round(max_int - 1f64)); + assert_eq!(0.123456, round_with_precision(0.123456, f64_digits - 1)); + assert_eq!(max_int - 1f64, round_with_precision(max_int - 1f64, f64_digits)); + assert_eq!(max_int, round_with_precision(max_int, f64_digits - 1)); + assert_eq!(max_int - 1f64, round_with_precision(max_int - 1f64, f64_digits - 1)); + } + + fn duration_from_milli_micro(milliseconds: u16, microseconds: u16) -> Duration { + let microseconds = microseconds as u64; + let milliseconds = 1000 * milliseconds as u64; + Duration::from_micros(milliseconds + microseconds) + } + + #[test] + fn test_time_starting_with_seconds() { + let f = |duration| time_starting_with_seconds(&duration); + fn duration(days: u16, hours: u8, minutes: u8, seconds: u8) -> Duration { + let seconds = seconds as u64; + let minutes = 60 * minutes as u64; + let hours = 60 * 60 * hours as u64; + let days = 24 * 60 * 60 * days as u64; + Duration::from_secs(days + hours + minutes + seconds) + } + assert_eq!(" 0 s", &f(duration(0, 0, 0, 0))); + assert_eq!("59 s", &f(duration(0, 0, 0, 59))); + assert_eq!(" 1 m 12 s", &f(duration(0, 0, 1, 12))); + assert_eq!("59 m 0 s", &f(duration(0, 0, 59, 0))); + assert_eq!(" 5 h 1 m 2 s", &f(duration(0, 5, 1, 2))); + assert_eq!(" 1 d 0 h 0 m 0 s", &f(duration(1, 0, 0, 0))); + assert_eq!(" 69 d 0 h 0 m 0 s", &f(duration(69, 0, 0, 0))); + assert_eq!("100 d 10 h 10 m 10 s", &f(duration(100, 10, 10, 10))); + } + + #[test] + fn test_time_as_ms_with_precision_1() { + let f = |duration| time_starting_with_milliseconds(&duration, 1); + let duration = duration_from_milli_micro; + assert_eq!("123.5 ms", &f(duration(123, 456))); + assert_eq!("123.5 ms", &f(duration(123, 455))); + assert_eq!("123.4 ms", &f(duration(123, 445))); + assert_eq!("123.4 ms", &f(duration(123, 440))); + assert_eq!("123.1 ms", &f(duration(123, 100))); + assert_eq!("123 ms", &f(duration(123, 0))); + } + + #[test] + fn test_time_as_ms_with_precision_2() { + let f = |duration| time_starting_with_milliseconds(&duration, 2); + let duration = duration_from_milli_micro; + assert_eq!("123.46 ms", &f(duration(123, 456))); + assert_eq!("123.46 ms", &f(duration(123, 455))); + assert_eq!("123.45 ms", &f(duration(123, 454))); + assert_eq!("123.45 ms", &f(duration(123, 450))); + assert_eq!("123.1 ms", &f(duration(123, 100))); + assert_eq!("123 ms", &f(duration(123, 0))); + } + + #[test] + fn test_time_as_ms_with_precision_3() { + let f = |duration| time_starting_with_milliseconds(&duration, 3); + let duration = duration_from_milli_micro; + assert_eq!("123.456 ms", &f(duration(123, 456))); + assert_eq!("123.455 ms", &f(duration(123, 455))); + assert_eq!("123.454 ms", &f(duration(123, 454))); + assert_eq!("123.45 ms", &f(duration(123, 450))); + assert_eq!("123.1 ms", &f(duration(123, 100))); + assert_eq!("123 ms", &f(duration(123, 0))); + } +} diff --git a/crates/typst-utils/src/lib.rs b/crates/typst-utils/src/lib.rs index 79d4bb09b..35f1f142a 100644 --- a/crates/typst-utils/src/lib.rs +++ b/crates/typst-utils/src/lib.rs @@ -1,6 +1,7 @@ //! Utilities for Typst. pub mod fat; +pub mod format; #[macro_use] mod macros; diff --git a/crates/typst/src/foundations/repr.rs b/crates/typst/src/foundations/repr.rs index 9c098bd19..3b41c611e 100644 --- a/crates/typst/src/foundations/repr.rs +++ b/crates/typst/src/foundations/repr.rs @@ -3,6 +3,7 @@ use ecow::{eco_format, EcoString}; use crate::foundations::{func, Str, Value}; +use crate::utils::format::round_with_precision; /// The Unicode minus sign. pub const MINUS_SIGN: &str = "\u{2212}"; @@ -84,8 +85,7 @@ pub fn format_float( unit: &str, ) -> EcoString { if let Some(p) = precision { - let offset = 10_f64.powi(p as i32); - value = (value * offset).round() / offset; + value = round_with_precision(value, p); } // Debug for f64 always prints a decimal separator, while Display only does // when necessary.