Files
Ely-GPUI-Components/src/typography/format.rs
T
ZacharyZhang-NY b35530c80d T02 typography: text styles, keys, formatters, fit, links, math, motion text
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.
2026-09-25 01:12:52 -04:00

259 lines
8.9 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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());
}
}