diff --git a/AGENTS.md b/AGENTS.md index aad29c94..24abee6a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,6 +105,8 @@ reproduce an image (for example, point lists traced from someone else's icon). See `CLAUDE.md`: pure Rust, dependency layering (`cargo xtask layers`), exact `Tick` time, everything is a command, everything is agent-drivable, and the quality gates (`cargo xtask ci`) before every commit. +- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic). + Shared real-file test corpora (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md). diff --git a/CLAUDE.md b/CLAUDE.md index a7c47fe3..e5238694 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,6 +16,7 @@ EffectCraft is a clean-room, open-source, pure-Rust motion graphics and visual e - **Pure Rust.** Media codecs come from FilmCraft crates (git deps behind `crates/media`, see `plan/adr/0001`). - **Layering** (`cargo xtask layers`): nothing below L5 depends on egui/eframe/winit/rfd/cpal; L0–L4 build for wasm32. - **Exact time:** `effectcraft_time::Tick` (254 016 000 000/s). Keyframe times are **layer time**. +- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic). - **Property tree:** everything animatable is a `Property` in the layer's `PropGroup` tree, addressed by paths (`transform/position`, `effects/#1/blurriness`, `@uid`). - **Everything is a command** (`crates/engine`): id, label, menu path, shortcut, params, enabled(), run(). UI, CLI, control channel and MCP dispatch by id. - **Everything is agent-drivable:** every interactive widget registers an automation id; UI state is serde. diff --git a/Cargo.lock b/Cargo.lock index 8585b9b4..9bed551d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -803,6 +803,15 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "num-traits", +] + [[package]] name = "clipboard-win" version = "5.4.1" @@ -1233,6 +1242,7 @@ dependencies = [ name = "effectcraft-effects" version = "0.16.0" dependencies = [ + "chrono", "effectcraft-color", "effectcraft-geom", "effectcraft-keyframe", @@ -1250,6 +1260,7 @@ dependencies = [ name = "effectcraft-engine" version = "0.16.0" dependencies = [ + "chrono", "craft-docpath", "effectcraft-color", "effectcraft-effects", @@ -1651,6 +1662,7 @@ dependencies = [ name = "effectcraft-ui-egui" version = "0.16.0" dependencies = [ + "chrono", "craft-ui", "effectcraft-color", "effectcraft-engine", @@ -6627,6 +6639,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" name = "xtask" version = "0.16.0" dependencies = [ + "chrono", "serde_json", "tempfile", ] diff --git a/Cargo.toml b/Cargo.toml index 7ab23e29..b1573fd7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,6 +52,8 @@ thiserror = "2" serde = { version = "1", features = ["derive", "rc"] } serde_json = { version = "1", features = ["float_roundtrip"] } log = "0.4" +# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md). +chrono = { version = "0.4.45", default-features = false, features = ["std"] } rayon = "1" # std::time::Instant/SystemTime on native; the browser clock on wasm32 (std's panic there). web-time = "1" diff --git a/crates/effects/Cargo.toml b/crates/effects/Cargo.toml index c37d0994..bb26d312 100644 --- a/crates/effects/Cargo.toml +++ b/crates/effects/Cargo.toml @@ -21,4 +21,5 @@ rayon = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } +chrono = { workspace = true } diff --git a/crates/effects/src/textfx.rs b/crates/effects/src/textfx.rs index 34282c27..cb172dfc 100644 --- a/crates/effects/src/textfx.rs +++ b/crates/effects/src/textfx.rs @@ -288,20 +288,39 @@ fn look_from(ctx: &EffectCtx, b: &Buf, on_original_id: &str) -> TextLook { const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; const DAYS: [&str; 7] = ["Saturday", "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]; -/// Proleptic Gregorian date from a day count since 2000-01-01 (a Saturday). +/// `num_days_from_ce` of 2000-01-01: the day count since 2000-01-01 (Numbers' date epoch, a +/// Saturday) rebased so that chrono's `num_days_from_ce` (0001-01-01 is day 1) can convert it. +const CE_FROM_2000: i64 = 730_120; + +/// Days in one 400-year Gregorian cycle; the proleptic calendar repeats after it. +const DAYS_PER_400_YEARS: i64 = 146_097; + +/// Proleptic Gregorian date (year, month, day, Saturday-indexed weekday) from a day count since +/// 2000-01-01 (a Saturday), for any `i64` count. +/// +/// chrono's `NaiveDate` stops near ±262143, but Numbers' `value` slider is a plain scalar with +/// no range guard, so a keyframed count can run past that. The calendar repeats every 400 years, +/// so chrono converts the day within one cycle and the whole cycles are added back as years (via +/// i128 so `i64::MIN`/`MAX` can't overflow); the result matches the pre-chrono count for every +/// year. The weekday comes from the same resolved date, so it can never disagree with it. fn date_from_days(days: i64) -> (i64, u32, u32, usize) { - // Days since 0000-03-01 based civil calendar conversion. - let z = days + 730_425; // 2000-01-01 relative to 0000-03-01 - let era = z.div_euclid(146_097); - let doe = z.rem_euclid(146_097); - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let y = yoe + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = (doy - (153 * mp + 2) / 5 + 1) as u32; - let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; - let y = if m <= 2 { y + 1 } else { y }; - (y, m, d, days.rem_euclid(7) as usize) + use chrono::Datelike; + let from_ce = i128::from(days) + i128::from(CE_FROM_2000); + let (cycles, rest) = (from_ce.div_euclid(i128::from(DAYS_PER_400_YEARS)), from_ce.rem_euclid(i128::from(DAYS_PER_400_YEARS))); + // `rest` is 0..146_097, so it is always a valid `num_days_from_ce` day inside chrono's range. + let date = i32::try_from(rest).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); + // Unreachable fallback: every `rest` in 0..146_097 converts. 2000-01-01 keeps the epoch's date. + let (y, m, d, wd) = match date { + Some(date) => { + let year = i128::from(date.year()) + cycles * 400; + let year = i64::try_from(year).unwrap_or(if year < 0 { i64::MIN } else { i64::MAX }); + // DAYS is Saturday-indexed; chrono counts from Sunday. + let wd = (date.weekday().num_days_from_sunday() as usize + 1) % 7; + (year, date.month(), date.day(), wd) + } + None => (2000, 1, 1, 0), + }; + (y, m, d, wd) } fn timecode(frames: i64, fps: f64) -> String { @@ -1123,6 +1142,10 @@ mod tests { assert_eq!(txt(&[("value", num(255.0)), ("type", Value::Enum(9))], 0.0), "FF"); assert_eq!(txt(&[("value", num(0.0)), ("type", Value::Enum(6))], 0.0), "01/01/00"); assert_eq!(txt(&[("value", num(60.0)), ("type", Value::Enum(8))], 0.0), "Wednesday, March 1, 2000"); + // A keyframed date count past chrono's ±262143 range still renders its real date and a + // matching weekday (275790-09-13 is a Monday), not an epoch-derived stand-in. + assert_eq!(txt(&[("value", num(100_000_000.0)), ("type", Value::Enum(8))], 0.0), "Monday, September 13, 275790"); + assert_eq!(txt(&[("value", num(-100_000_000.0)), ("type", Value::Enum(7))], 0.0), "Apr 20, -271791"); assert_eq!(txt(&[("value", num(1.0)), ("currentTimeDate", Value::Bool(true)), ("decimalPlaces", num(1.0))], 2.0), "3.0"); assert_ne!( txt(&[("value", num(100.0)), ("randomValues", Value::Bool(true))], 0.0), @@ -1130,6 +1153,28 @@ mod tests { ); } + #[test] + fn date_from_days_past_chrono_range() { + // chrono's NaiveDate stops near ±262143, but Numbers' `value` has no range guard, so a + // keyframed day count can run past it. The 400-year cycle keeps the real date (and a + // matching weekday) for every count; values from the pre-chrono formula, both directions, + // both sides of the 2000-01-01 epoch. + for (days, date, day) in [ + (0_i64, (2000, 1, 1), "Saturday"), + (60, (2000, 3, 1), "Wednesday"), + (95_015_644, (262143, 12, 31), "Tuesday"), // chrono's last representable date + (100_000_000, (275790, 9, 13), "Monday"), // past it: used to read as the epoch + (-96_476_616, (-262145, 12, 31), "Monday"), + (-100_000_000, (-271791, 4, 20), "Thursday"), + ] { + let (y, m, d, wd) = super::date_from_days(days); + assert_eq!((y, m, d), date, "days={days}"); + assert_eq!(super::DAYS[wd], day, "days={days}"); + } + // Extremes don't overflow (i128 intermediates). + let _ = (super::date_from_days(i64::MAX), super::date_from_days(i64::MIN)); + } + #[test] fn numbers_draws_glyph_at_position() { let img = Image::new(80, 60); diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 0e26cca5..7104bc1c 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -34,6 +34,7 @@ serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } log = { workspace = true } +chrono = { workspace = true } rayon = { workspace = true } image = { workspace = true } web-time = { workspace = true } diff --git a/crates/engine/src/commands/align_data.rs b/crates/engine/src/commands/align_data.rs index 097bddc2..38d9e784 100644 --- a/crates/engine/src/commands/align_data.rs +++ b/crates/engine/src/commands/align_data.rs @@ -33,14 +33,16 @@ pub struct Stamp { pub dated: bool, } -fn days_from_civil(y: i64, m: i64, d: i64) -> i64 { - let y = if m <= 2 { y - 1 } else { y }; - let era = y.div_euclid(400); - let yoe = y - era * 400; - let mp = (m + 9) % 12; - let doy = (153 * mp + 2) / 5 + d - 1; - let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; - era * 146_097 + doe - 719_468 +/// Days since 1970-01-01 of the proleptic-Gregorian date `(y, m, d)`. `d` is added linearly (as +/// the previous hand-rolled `days_from_civil` did), so a day past the month's end rolls over; +/// `None` when the year/month is out of chrono's range. `m` must be 1..=12. +fn days_from_civil(y: i64, m: i64, d: i64) -> Option { + let year = i32::try_from(y).ok()?; + let month = u32::try_from(m).ok()?; + let first = chrono::NaiveDate::from_ymd_opt(year, month, 1)?; + // Days since the Unix epoch: `num_days_from_ce` counts from 0001-01-01; 719_162 days precede 1970-01-01. + use chrono::Datelike; + i64::from(first.num_days_from_ce()).checked_sub(719_163)?.checked_add(d.checked_sub(1)?) } /// `hh:mm:ss(.fff)` or timecode `hh:mm:ss:ff` / `hh:mm:ss;ff` → seconds. @@ -71,7 +73,7 @@ pub fn parse_stamp(v: &Value, fps: f64) -> Option { // ISO 8601: date [T| ] time [zone]. if s.len() >= 10 && s.as_bytes()[4] == b'-' && s.as_bytes()[7] == b'-' { let (y, m, d) = (s[0..4].parse::().ok()?, s[5..7].parse::().ok()?, s[8..10].parse::().ok()?); - let mut secs = days_from_civil(y, m, d) as f64 * 86_400.0; + let mut secs = days_from_civil(y, m, d)? as f64 * 86_400.0; let rest = s[10..].trim_start_matches(['T', 't', ' ']); if !rest.is_empty() { // Zone: Z or ±hh:mm / ±hhmm. diff --git a/crates/engine/src/logging.rs b/crates/engine/src/logging.rs index 2c89466a..ec5785f7 100644 --- a/crates/engine/src/logging.rs +++ b/crates/engine/src/logging.rs @@ -93,28 +93,34 @@ fn thread_name() -> String { return "main".to_string(); } -/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds) for `ms` milliseconds since 1970. +/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds) for `ms` milliseconds since 1970. A clock set +/// absurdly far ahead keeps its real year (preserved) instead of reading as the epoch. fn timestamp(ms: u64) -> String { - let secs = ms / 1000; - let (days, rem) = (secs / 86_400, secs % 86_400); - let (y, m, d) = civil_from_days(days); - format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, ms % 1000) + let (secs, millis) = (ms / 1000, (ms % 1000) as u32); + let (y, t) = utc_parts(secs, millis * 1_000_000); + // `{:04}` rather than `%Y`, which writes a `+` before years past 9999. + format!("{y:04}-{}", t.format("%m-%dT%H:%M:%S%.3fZ")) } -/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's -/// `civil_from_days`, unsigned since the clock is clamped at the epoch). `days` is at most -/// `u64::MAX / 86_400_000`, so none of the arithmetic below can overflow. -fn civil_from_days(days: u64) -> (u64, u64, u64) { - let z = days + 719_468; - let era = z / 146_097; - let doe = z % 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = yoe + era * 400 + u64::from(m <= 2); - (y, m, d) +/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into +/// chrono's range by whole 400-year cycles). The Gregorian calendar repeats every 400 years +/// (146_097 days), so month, day and time of day come from chrono and only the year is carried, +/// which keeps every input exact instead of collapsing out-of-range years to the epoch. `secs` is +/// `u64` (the clock is clamped at the epoch before 1970), so `days` always fits an `i64`. +fn utc_parts(secs: u64, nanos: u32) -> (i64, chrono::NaiveDateTime) { + use chrono::Datelike; + let (days, sod) = ((secs / 86_400) as i64, (secs % 86_400) as i64); + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400. + let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); + let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999))); + match (date, time) { + (Some(d), Some(t)) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(i64::MAX), d.and_time(t)) + } + _ => (1970, chrono::NaiveDateTime::default()), // unreachable + } } /// One log line (without the newline): ` [] : `. @@ -358,7 +364,12 @@ mod tests { assert_eq!(timestamp(1_709_164_800_000), "2024-02-29T00:00:00.000Z"); assert_eq!(timestamp(951_782_400_000), "2000-02-29T00:00:00.000Z"); assert_eq!(timestamp(1_767_225_599_000), "2025-12-31T23:59:59.000Z"); - let _ = timestamp(u64::MAX); + // Years past 9999 keep the plain zero-padded year (no `+`). + assert_eq!(timestamp(253_402_300_800_000), "10000-01-01T00:00:00.000Z"); + // A clock absurdly far ahead keeps its real year (years far outside chrono's own range are + // preserved) instead of collapsing to the epoch (reviewer rule from the sibling PRs). + assert_eq!(timestamp(10_000_000_000_000_000), "318857-05-20T17:46:40.000Z"); + assert_eq!(timestamp(u64::MAX), "584556019-04-03T14:25:51.615Z"); } /// Each launch keeps the previous runs' logs, so the log of a run that crashed survives. diff --git a/crates/engine/src/media_browser.rs b/crates/engine/src/media_browser.rs index d22875f1..c0e20be7 100644 --- a/crates/engine/src/media_browser.rs +++ b/crates/engine/src/media_browser.rs @@ -264,22 +264,33 @@ pub fn parent(dir: &str) -> Option { std::path::Path::new(dir).parent().map(|p| p.to_string_lossy().to_string()).filter(|p| !p.is_empty()) } -/// Format seconds since the epoch as an ISO 8601 UTC date-time. +/// Format seconds since the epoch as an ISO 8601 UTC date-time. Years far outside chrono's own +/// range are preserved (the old hand-rolled conversion kept them) rather than collapsing to the epoch. pub fn iso_date(secs: u64) -> String { - let days = (secs / 86_400) as i64; - let rem = secs % 86_400; - // Civil-from-days (Howard Hinnant's algorithm). - let z = days + 719_468; - let era = z.div_euclid(146_097); - let doe = z - era * 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let y = yoe + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = if m <= 2 { y + 1 } else { y }; - format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", rem / 3600, rem % 3600 / 60, rem % 60) + let (y, t) = utc_parts(secs); + // `{:04}` rather than `%Y`, which writes a `+` before years past 9999. + format!("{y:04}-{}", t.format("%m-%dT%H:%M:%SZ")) +} + +/// Unix seconds → (proleptic Gregorian year, the same wall-clock instant moved into chrono's range +/// by whole 400-year cycles). The Gregorian calendar repeats every 400 years (146_097 days), so +/// month, day and time of day come from chrono and only the year is carried, which keeps every +/// input exact instead of collapsing out-of-range years to the epoch. `secs` is `u64`, so `days` +/// always fits an `i64`. +fn utc_parts(secs: u64) -> (i64, chrono::NaiveDateTime) { + use chrono::Datelike; + let (days, sod) = ((secs / 86_400) as i64, (secs % 86_400) as i64); + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400. + let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); + let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, 0)); + match (date, time) { + (Some(d), Some(t)) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(i64::MAX), d.and_time(t)) + } + _ => (1970, chrono::NaiveDateTime::default()), // unreachable + } } /// File dates and size: (created, modified, size). @@ -506,5 +517,10 @@ mod tests { assert_eq!(iso_date(0), "1970-01-01T00:00:00Z"); assert_eq!(iso_date(1_700_000_000), "2023-11-14T22:13:20Z"); assert_eq!(iso_date(951_782_400), "2000-02-29T00:00:00Z"); + // Years far outside chrono's own range are preserved (the old hand-rolled conversion kept + // them) rather than collapsing to the epoch. + assert_eq!(iso_date(253_402_300_800), "10000-01-01T00:00:00Z"); + assert_eq!(iso_date(10_000_000_000_000), "318857-05-20T17:46:40Z"); + assert_eq!(iso_date(u64::MAX), "584554051223-11-09T07:00:15Z"); } } diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index be02fef5..549e3ecb 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -26,6 +26,7 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] } serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } +chrono = { workspace = true } rayon = { workspace = true } web-time = { workspace = true } kurbo = { workspace = true } diff --git a/crates/ui-egui/src/panels/home.rs b/crates/ui-egui/src/panels/home.rs index 46bbb44f..338f9ef8 100644 --- a/crates/ui-egui/src/panels/home.rs +++ b/crates/ui-egui/src/panels/home.rs @@ -67,21 +67,32 @@ pub fn recent_entries(prefs: &effectcraft_engine::prefs::Prefs) -> Vec (i64, chrono::NaiveDateTime) { + use chrono::Datelike; + let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400)); + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400. + let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); + let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, 0)); + match (date, time) { + (Some(d), Some(t)) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t)) + } + _ => (1970, chrono::NaiveDateTime::default()), // unreachable + } +} + +/// Unix seconds → `YYYY-MM-DD HH:MM` (UTC). Years far outside chrono's own range are preserved +/// (the old hand-rolled conversion kept them) rather than collapsing to the epoch. pub fn format_utc(secs: i64) -> String { - let days = secs.div_euclid(86_400); - let rem = secs.rem_euclid(86_400); - // Civil date from days since 1970-01-01 (Howard Hinnant's algorithm). - let z = days + 719_468; - let era = z.div_euclid(146_097); - let doe = z - era * 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = yoe + era * 400 + i64::from(m <= 2); - format!("{y:04}-{m:02}-{d:02} {:02}:{:02}", rem / 3600, (rem % 3600) / 60) + let (y, t) = utc_parts(secs); + // `{:04}` rather than `%Y`, which writes a `+` before years past 9999. + format!("{y:04}-{}", t.format("%m-%d %H:%M")) } /// How long ago `then` was, as the Home screen's Opened column shows it ("3 hours ago", then the @@ -95,7 +106,8 @@ pub fn format_ago(now: i64, then: i64) -> String { 3_600..86_400 => plural(d / 3_600, "{} hour ago", "{} hours ago"), 86_400..172_800 => tr("Yesterday").to_string(), 172_800..604_800 => plural(d / 86_400, "{} day ago", "{} days ago"), - _ => format_utc(then).get(..10).unwrap_or_default().to_string(), + // The date part: up to the space, not a fixed 10 bytes, so a year past 9999 stays whole. + _ => format_utc(then).split(' ').next().unwrap_or_default().to_string(), } } @@ -510,6 +522,12 @@ mod tests { assert_eq!(format_utc(0), "1970-01-01 00:00"); assert_eq!(format_utc(1_790_000_000), "2026-09-21 14:13"); assert_eq!(format_utc(951_782_400), "2000-02-29 00:00"); + // Years far outside chrono's own range are preserved (the old hand-rolled conversion kept + // them), including negatives and the i64 extremes, rather than collapsing to the epoch. + assert_eq!(format_utc(10_000_000_000_000), "318857-05-20 17:46"); + assert_eq!(format_utc(-10_000_000_000_000), "-314918-08-13 06:13"); + assert_eq!(format_utc(i64::MAX), "292277026596-12-04 15:30"); + assert_eq!(format_utc(i64::MIN), "-292277022657-01-27 08:29"); let img = egui::ColorImage::new([200, 100], vec![Color32::from_rgb(10, 200, 30); 200 * 100]); let text = encode_thumb(&img); let back = decode_thumb(&text).unwrap(); @@ -533,6 +551,8 @@ mod tests { assert_eq!(format_ago(now, now + 100), "Just now"); assert_eq!((format_size(900), format_size(13_000), format_size(3_500_000)), ("900 B".into(), "13 KB".into(), "3.3 MB".into())); assert_eq!((kind_of("/a/B.ecproj"), kind_of("c.AEP"), kind_of("x")), ("EffectCraft project", "After Effects project", "Project")); + // After a week it is the date, whole even for a five-digit year. + assert_eq!(format_ago(i64::MAX, 253_402_300_800), "10000-01-01"); } /// The Home screen's own text goes through the interface catalog (#574): the Opened and Kind diff --git a/crates/ui-egui/src/panels/render_queue.rs b/crates/ui-egui/src/panels/render_queue.rs index e07efe69..73bd92e8 100644 --- a/crates/ui-egui/src/panels/render_queue.rs +++ b/crates/ui-egui/src/panels/render_queue.rs @@ -35,21 +35,33 @@ pub fn hms(secs: f64) -> String { format!("{}:{:02}:{:02}", s / 3600, s / 60 % 60, s % 60) } -/// Unix seconds → `YYYY-MM-DD hh:mm:ss` (UTC). +/// Unix seconds → (proleptic Gregorian year, the same wall-clock instant moved into chrono's range +/// by whole 400-year cycles). The Gregorian calendar repeats every 400 years (146_097 days), so +/// month, day and time of day come from chrono and only the year is carried, which keeps every +/// input exact instead of collapsing out-of-range years to the epoch. `unix` is `u64`, so `days` +/// always fits an `i64`. +fn utc_parts(unix: u64) -> (i64, chrono::NaiveDateTime) { + use chrono::Datelike; + let (days, sod) = ((unix / 86_400) as i64, (unix % 86_400) as i64); + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400. + let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); + let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, 0)); + match (date, time) { + (Some(d), Some(t)) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(i64::MAX), d.and_time(t)) + } + _ => (1970, chrono::NaiveDateTime::default()), // unreachable + } +} + +/// Unix seconds → `YYYY-MM-DD hh:mm:ss` (UTC). Years far outside chrono's own range are preserved +/// (the old hand-rolled conversion kept them) rather than collapsing to the epoch. pub fn utc_stamp(unix: u64) -> String { - let days = (unix / 86_400) as i64; - let rem = unix % 86_400; - // Civil-from-days (proleptic Gregorian). - let z = days + 719_468; - let era = z.div_euclid(146_097); - let doe = z - era * 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = yoe + era * 400 + i64::from(m <= 2); - format!("{y:04}-{m:02}-{d:02} {:02}:{:02}:{:02}", rem / 3600, rem / 60 % 60, rem % 60) + let (y, t) = utc_parts(unix); + // `{:04}` rather than `%Y`, which writes a `+` before years past 9999. + format!("{y:04}-{}", t.format("%m-%d %H:%M:%S")) } fn status_color(t: &Tokens, s: &RenderStatus) -> Color32 { @@ -807,6 +819,10 @@ mod tests { fn stamps() { assert_eq!(super::utc_stamp(0), "1970-01-01 00:00:00"); assert_eq!(super::utc_stamp(1_700_000_000), "2023-11-14 22:13:20"); + // Years far outside chrono's own range are preserved (the old hand-rolled conversion kept + // them) rather than collapsing to the epoch. + assert_eq!(super::utc_stamp(10_000_000_000_000), "318857-05-20 17:46:40"); + assert_eq!(super::utc_stamp(u64::MAX), "584554051223-11-09 07:00:15"); assert_eq!(super::hms(3725.4), "1:02:05"); } } diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index ea80b285..b9c781bc 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -8,4 +8,5 @@ publish = false [dependencies] serde_json = { workspace = true } +chrono = { workspace = true } tempfile = "3" diff --git a/xtask/src/bench.rs b/xtask/src/bench.rs index b368d942..24f81c1c 100644 --- a/xtask/src/bench.rs +++ b/xtask/src/bench.rs @@ -655,19 +655,25 @@ fn iso_now() -> String { utc(unix_now()) } -/// `YYYY-MM-DD HH:MM UTC` for a Unix time (civil-from-days, no time zone database). +/// `YYYY-MM-DD HH:MM UTC` for a Unix time. Years far outside chrono's own range are preserved +/// (the old hand-rolled conversion kept them) rather than collapsing to the epoch. fn utc(t: u64) -> String { - let (days, secs) = ((t / 86_400) as i64, t % 86_400); - let z = days + 719_468; - let era = z.div_euclid(146_097); - let doe = z.rem_euclid(146_097); - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = yoe + era * 400 + i64::from(m <= 2); - format!("{y:04}-{m:02}-{d:02} {:02}:{:02} UTC", secs / 3600, secs % 3600 / 60) + use chrono::Datelike; + // Reduce by whole 400-year Gregorian cycles (146_097 days) into chrono's range, convert the + // remainder with chrono, and carry the year back; `t` is `u64`, so `days` fits an `i64`. + let (days, sod) = ((t / 86_400) as i64, (t % 86_400) as i64); + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); + let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, 0)); + let (y, dt) = match (date, time) { + (Some(d), Some(ti)) => { + let yy = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(yy).unwrap_or(i64::MAX), d.and_time(ti)) + } + _ => (1970, chrono::NaiveDateTime::default()), + }; + // `{:04}` rather than `%Y`, which writes a `+` before years past 9999. + format!("{y:04}-{}", dt.format("%m-%d %H:%M UTC")) } #[allow(clippy::too_many_arguments)] @@ -1095,6 +1101,17 @@ fn compare_table(old: &BTreeMap, new: &BTreeMap) - mod tests { use super::*; + #[test] + fn utc_preserves_out_of_range_years() { + assert_eq!(utc(0), "1970-01-01 00:00 UTC"); + assert_eq!(utc(1_700_000_000), "2023-11-14 22:13 UTC"); + // Years far outside chrono's own range are preserved (the old hand-rolled conversion kept + // them) rather than collapsing to the epoch. + assert_eq!(utc(253_402_300_800), "10000-01-01 00:00 UTC"); + assert_eq!(utc(10_000_000_000_000), "318857-05-20 17:46 UTC"); + assert_eq!(utc(u64::MAX), "584554051223-11-09 07:00 UTC"); + } + const BSD: &str = " 0.33 real 0.67 user 0.15 sys 743882752 maximum resident set size 0 average shared memory size