Headings to overlines, Code, Kbd and KbdCombo on gpui keystrokes, Highlight, MiddleEllipsis and EllipsisTooltip, links, eight formatted values on jiff with a typographic minus, CopyableText, SelectableText, Emoji, a TeX subset, rolling AnimatedNumber, Typewriter, tonal GradientText, ShimmerText. Gallery probes now hug their targets. Codex gpt-6-astra (max): PASS in round 2.
259 lines
8.9 KiB
Rust
259 lines
8.9 KiB
Rust
use jiff::{Timestamp, fmt::strtime, tz::TimeZone};
|
||
|
||
/// Typographic minus; aligns with plus in tabular figures.
|
||
pub const MINUS: char = '\u{2212}';
|
||
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub struct Separators {
|
||
pub group: char,
|
||
pub decimal: char,
|
||
}
|
||
|
||
impl Separators {
|
||
pub const EN: Self = Self {
|
||
group: ',',
|
||
decimal: '.',
|
||
};
|
||
}
|
||
|
||
/// Groups thousands. Non-finite values print as Rust prints them.
|
||
pub fn number(value: f64, decimals: usize, separators: Separators) -> String {
|
||
if !value.is_finite() {
|
||
return value.to_string();
|
||
}
|
||
let fixed = format!("{:.*}", decimals, value.abs());
|
||
let (int, fraction) = match fixed.split_once('.') {
|
||
Some((int, fraction)) => (int, Some(fraction)),
|
||
None => (fixed.as_str(), None),
|
||
};
|
||
let mut out = String::new();
|
||
if value < 0.0 && fixed.chars().any(|ch| ch.is_ascii_digit() && ch != '0') {
|
||
out.push(MINUS);
|
||
}
|
||
out.push_str(&group_digits(int, separators.group));
|
||
if let Some(fraction) = fraction {
|
||
out.push(separators.decimal);
|
||
out.push_str(fraction);
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Inserts `group` every three digits from the right.
|
||
pub fn group_digits(digits: &str, group: char) -> String {
|
||
let mut out = String::with_capacity(digits.len() + digits.len() / 3);
|
||
for (ix, digit) in digits.chars().enumerate() {
|
||
if ix > 0 && (digits.len() - ix).is_multiple_of(3) {
|
||
out.push(group);
|
||
}
|
||
out.push(digit);
|
||
}
|
||
out
|
||
}
|
||
|
||
/// ISO 4217 code to symbol and minor units. Unknown codes print as a prefix.
|
||
pub fn currency(amount: f64, code: &str) -> String {
|
||
let (symbol, decimals, spaced) = match code {
|
||
"USD" => ("$", 2, false),
|
||
"EUR" => ("€", 2, false),
|
||
"GBP" => ("£", 2, false),
|
||
"JPY" => ("¥", 0, false),
|
||
"CNY" => ("¥", 2, false),
|
||
"KRW" => ("₩", 0, false),
|
||
"INR" => ("₹", 2, false),
|
||
_ => (code, 2, true),
|
||
};
|
||
let body = number(amount.abs(), decimals, Separators::EN);
|
||
let negative = amount < 0.0 && body.chars().any(|ch| ch.is_ascii_digit() && ch != '0');
|
||
let sign = if negative {
|
||
MINUS.to_string()
|
||
} else {
|
||
String::new()
|
||
};
|
||
let gap = if spaced { " " } else { "" };
|
||
format!("{sign}{symbol}{gap}{body}")
|
||
}
|
||
|
||
/// `ratio` 0.1234 reads "12.34%". `signed` adds a plus to gains.
|
||
pub fn percent(ratio: f64, decimals: usize, signed: bool) -> String {
|
||
let body = number(ratio * 100.0, decimals, Separators::EN);
|
||
let gain = signed && ratio > 0.0 && body.chars().any(|ch| ch.is_ascii_digit() && ch != '0');
|
||
format!("{}{body}%", if gain { "+" } else { "" })
|
||
}
|
||
|
||
/// Decimal units (KB = 1000) or binary (KiB = 1024).
|
||
pub fn file_size(bytes: u64, binary: bool) -> String {
|
||
let (base, units) = if binary {
|
||
(1024.0, ["B", "KiB", "MiB", "GiB", "TiB", "PiB"])
|
||
} else {
|
||
(1000.0, ["B", "KB", "MB", "GB", "TB", "PB"])
|
||
};
|
||
let mut value = bytes as f64;
|
||
let mut unit = 0;
|
||
while value >= base && unit < units.len() - 1 {
|
||
value /= base;
|
||
unit += 1;
|
||
}
|
||
if unit == 0 {
|
||
return format!("{bytes} B");
|
||
}
|
||
let decimals = if value < 10.0 { 1 } else { 0 };
|
||
let shown = format!("{value:.decimals$}");
|
||
if shown.parse::<f64>().is_ok_and(|rounded| rounded >= base) && unit < units.len() - 1 {
|
||
return format!("1.0 {}", units[unit + 1]);
|
||
}
|
||
format!("{shown} {}", units[unit])
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub enum DurationStyle {
|
||
/// `01:23:45`, or `23:45` under an hour.
|
||
Clock,
|
||
/// `1h 23m`, `4m 5s`.
|
||
Compact,
|
||
}
|
||
|
||
pub fn duration(seconds: u64, style: DurationStyle) -> String {
|
||
let (days, hours, minutes, secs) = (
|
||
seconds / 86_400,
|
||
seconds / 3_600,
|
||
seconds / 60 % 60,
|
||
seconds % 60,
|
||
);
|
||
match style {
|
||
DurationStyle::Clock if hours > 0 => format!("{hours:02}:{minutes:02}:{secs:02}"),
|
||
DurationStyle::Clock => format!("{minutes:02}:{secs:02}"),
|
||
DurationStyle::Compact if days > 0 => format!("{days}d {}h", hours % 24),
|
||
DurationStyle::Compact if hours > 0 => format!("{hours}h {minutes}m"),
|
||
DurationStyle::Compact if minutes > 0 => format!("{minutes}m {secs}s"),
|
||
DurationStyle::Compact => format!("{secs}s"),
|
||
}
|
||
}
|
||
|
||
/// "1 file", "12,345 files".
|
||
pub fn plural(count: u64, one: &str, other: &str) -> String {
|
||
let word = if count == 1 { one } else { other };
|
||
format!(
|
||
"{} {word}",
|
||
group_digits(&count.to_string(), Separators::EN.group)
|
||
)
|
||
}
|
||
|
||
/// "just now", "3 minutes ago", "in 2 days".
|
||
pub fn relative(then: Timestamp, now: Timestamp) -> String {
|
||
let delta = now.as_second() - then.as_second();
|
||
let seconds = delta.unsigned_abs();
|
||
if seconds < 45 {
|
||
return "just now".to_string();
|
||
}
|
||
let round = |unit: u64| (seconds + unit / 2) / unit;
|
||
let (count, unit) = match seconds {
|
||
s if s < 90 => (1, "minute"),
|
||
s if s < 45 * 60 => (round(60), "minute"),
|
||
s if s < 90 * 60 => (1, "hour"),
|
||
s if s < 22 * 3_600 => (round(3_600), "hour"),
|
||
s if s < 36 * 3_600 => (1, "day"),
|
||
s if s < 26 * 86_400 => (round(86_400), "day"),
|
||
s if s < 45 * 86_400 => (1, "month"),
|
||
s if s < 320 * 86_400 => (round(30 * 86_400), "month"),
|
||
s if s < 548 * 86_400 => (1, "year"),
|
||
_ => (round(365 * 86_400), "year"),
|
||
};
|
||
let label = plural(count, unit, &format!("{unit}s"));
|
||
if delta < 0 {
|
||
format!("in {label}")
|
||
} else {
|
||
format!("{label} ago")
|
||
}
|
||
}
|
||
|
||
/// strftime in `zone`; a bad pattern is an error.
|
||
pub fn datetime(at: Timestamp, zone: &TimeZone, pattern: &str) -> Result<String, jiff::Error> {
|
||
strtime::format(pattern, &at.to_zoned(zone.clone()))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn number_groups_and_rounds() {
|
||
assert_eq!(number(1234567.891, 2, Separators::EN), "1,234,567.89");
|
||
assert_eq!(number(999.5, 0, Separators::EN), "1,000");
|
||
assert_eq!(number(12.0, 0, Separators::EN), "12");
|
||
assert_eq!(number(-1234.5, 1, Separators::EN), "−1,234.5");
|
||
assert_eq!(number(-0.001, 2, Separators::EN), "0.00");
|
||
let de = Separators {
|
||
group: '.',
|
||
decimal: ',',
|
||
};
|
||
assert_eq!(number(1234.5, 2, de), "1.234,50");
|
||
assert_eq!(number(f64::NAN, 2, Separators::EN), "NaN");
|
||
}
|
||
|
||
#[test]
|
||
fn currency_uses_symbols_and_minor_units() {
|
||
assert_eq!(currency(1234.5, "USD"), "$1,234.50");
|
||
assert_eq!(currency(-42.0, "EUR"), "−€42.00");
|
||
assert_eq!(currency(1500.4, "JPY"), "¥1,500");
|
||
assert_eq!(currency(9.99, "CHF"), "CHF 9.99");
|
||
}
|
||
|
||
#[test]
|
||
fn percent_signs_gains_only_when_asked() {
|
||
assert_eq!(percent(0.1234, 2, false), "12.34%");
|
||
assert_eq!(percent(0.05, 1, true), "+5.0%");
|
||
assert_eq!(percent(-0.031, 1, true), "−3.1%");
|
||
assert_eq!(percent(0.0, 1, true), "0.0%");
|
||
}
|
||
|
||
#[test]
|
||
fn file_size_picks_units_and_carries() {
|
||
assert_eq!(file_size(512, false), "512 B");
|
||
assert_eq!(file_size(1536, false), "1.5 KB");
|
||
assert_eq!(file_size(15_360, false), "15 KB");
|
||
assert_eq!(file_size(999_999, false), "1.0 MB");
|
||
assert_eq!(file_size(1536, true), "1.5 KiB");
|
||
assert_eq!(file_size(3 * 1024 * 1024 * 1024, true), "3.0 GiB");
|
||
}
|
||
|
||
#[test]
|
||
fn duration_in_both_styles() {
|
||
assert_eq!(duration(5_025, DurationStyle::Clock), "01:23:45");
|
||
assert_eq!(duration(1_425, DurationStyle::Clock), "23:45");
|
||
assert_eq!(duration(5_025, DurationStyle::Compact), "1h 23m");
|
||
assert_eq!(duration(245, DurationStyle::Compact), "4m 5s");
|
||
assert_eq!(duration(183_600, DurationStyle::Compact), "2d 3h");
|
||
assert_eq!(duration(12, DurationStyle::Compact), "12s");
|
||
}
|
||
|
||
#[test]
|
||
fn plural_counts() {
|
||
assert_eq!(plural(1, "file", "files"), "1 file");
|
||
assert_eq!(plural(0, "file", "files"), "0 files");
|
||
assert_eq!(plural(12_345, "file", "files"), "12,345 files");
|
||
let huge = plural(9_007_199_254_740_993, "file", "files");
|
||
assert_eq!(huge, "9,007,199,254,740,993 files");
|
||
}
|
||
|
||
#[test]
|
||
fn relative_reads_both_directions() {
|
||
let now = Timestamp::from_second(1_000_000_000).unwrap();
|
||
let ago = |s: i64| relative(Timestamp::from_second(1_000_000_000 - s).unwrap(), now);
|
||
assert_eq!(ago(10), "just now");
|
||
assert_eq!(ago(60), "1 minute ago");
|
||
assert_eq!(ago(180), "3 minutes ago");
|
||
assert_eq!(ago(7_200), "2 hours ago");
|
||
assert_eq!(ago(3 * 86_400), "3 days ago");
|
||
assert_eq!(ago(3 * 365 * 86_400), "3 years ago");
|
||
assert_eq!(ago(-120), "in 2 minutes");
|
||
}
|
||
|
||
#[test]
|
||
fn datetime_formats_in_zone() {
|
||
let at = Timestamp::from_second(0).unwrap();
|
||
let formatted = datetime(at, &TimeZone::UTC, "%Y-%m-%d %H:%M").unwrap();
|
||
assert_eq!(formatted, "1970-01-01 00:00");
|
||
assert!(datetime(at, &TimeZone::UTC, "%Y %").is_err());
|
||
}
|
||
}
|