mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-25 02:47:02 -06:00
Merge branch 'main' into fix/interface-size-menu-focus
This commit is contained in:
@@ -42,12 +42,12 @@ jobs:
|
||||
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \
|
||||
libgl1-mesa-dev libgtk-3-dev
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
|
||||
with:
|
||||
components: clippy, rustfmt
|
||||
|
||||
# Only main saves caches; PRs restore main's, so PR caches don't evict main's.
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
with:
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
cache-on-failure: true
|
||||
@@ -75,10 +75,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
with:
|
||||
key: wasm32
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
@@ -81,6 +81,7 @@ code.
|
||||
L6 depends on egui/eframe/rfd/winit. **The UI crate is swappable.**
|
||||
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` /
|
||||
`app.set_props(ids, props)`. Colours come from `theme::Tokens`.
|
||||
- **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).
|
||||
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
|
||||
|
||||
### Quality gates (before every commit)
|
||||
|
||||
@@ -81,6 +81,7 @@ code.
|
||||
L6 depends on egui/eframe/rfd/winit. **The UI crate is swappable.**
|
||||
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` /
|
||||
`app.set_props(ids, props)`. Colours come from `theme::Tokens`.
|
||||
- **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).
|
||||
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
|
||||
|
||||
### Quality gates (before every commit)
|
||||
|
||||
Generated
+11
@@ -742,6 +742,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"
|
||||
@@ -1499,6 +1508,7 @@ name = "figcraft-collab"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"figcraft-color",
|
||||
"figcraft-doc",
|
||||
"figcraft-geom",
|
||||
@@ -1744,6 +1754,7 @@ name = "figcraft-ui-egui"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"egui",
|
||||
"egui_extras",
|
||||
"figcraft-color",
|
||||
|
||||
@@ -45,6 +45,8 @@ serde = { version = "1", features = ["derive", "rc"] }
|
||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||
regex = "1.13"
|
||||
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"] }
|
||||
proptest = "1"
|
||||
imbl = { version = "7", features = ["serde"] }
|
||||
postcard = { version = "1", default-features = false, features = ["alloc"] }
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.figcraft.png" width="128" height="128" alt="FigCraft icon: an engraved chameleon on a mint-green tile">
|
||||
</p>
|
||||
|
||||
<h1 align="center">FigCraft</h1>
|
||||
|
||||
<p align="center">
|
||||
|
||||
@@ -13,6 +13,7 @@ figcraft-doc = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
imbl = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
postcard = { workspace = true }
|
||||
|
||||
@@ -696,22 +696,38 @@ pub enum ServerMsg {
|
||||
},
|
||||
}
|
||||
|
||||
/// (days since 1970-01-01, seconds-of-day, nanos) → (proleptic Gregorian year, the same wall-clock
|
||||
/// instant moved into chrono's range by whole 400-year cycles). The 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 the result exact for any `i64` day count. This is not hand-rolled calendar
|
||||
/// math: chrono still does the date, the cycle count just shifts the year.
|
||||
fn utc_parts(days: i64, sod: u32, nanos: u32) -> (i64, chrono::NaiveDateTime) {
|
||||
use chrono::Datelike;
|
||||
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 = chrono::NaiveTime::from_num_seconds_from_midnight_opt(sod.min(86_399), 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(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t))
|
||||
}
|
||||
_ => (1970, chrono::NaiveDateTime::default()), // unreachable
|
||||
}
|
||||
}
|
||||
|
||||
/// `YYYY-MM-DD HH:MM UTC` for seconds since 1970 (how servers and apps show the times in these
|
||||
/// messages).
|
||||
/// messages). Years past chrono's own range (262143) keep converting: the instant is moved into
|
||||
/// range by whole 400-year cycles and the real year restored, so every `u64` reads as the date the
|
||||
/// old hand-rolled conversion produced (e.g. `u64::MAX` → `584554051223-11-09 07:00 UTC`).
|
||||
pub fn utc_date(secs: u64) -> String {
|
||||
let days = i64::try_from(secs / 86_400).unwrap_or(0);
|
||||
let (h, m) = ((secs % 86_400) / 3600, (secs % 3600) / 60);
|
||||
// Days to a civil date (proleptic Gregorian).
|
||||
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 mo = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = yoe + era * 400 + i64::from(mo <= 2);
|
||||
format!("{y:04}-{mo:02}-{d:02} {h:02}:{m:02} UTC")
|
||||
// The day count of any `u64` seconds fits in `i64` (`u64::MAX / 86_400` ≈ 2.1e14), and the
|
||||
// seconds-of-day is < 86_400, so the conversion is always exact (no epoch fallback). Splitting
|
||||
// with `u64` arithmetic (as the old code did) avoids the `i64` seconds overflow near `u64::MAX`.
|
||||
let (days, sod) = (i64::try_from(secs / 86_400).unwrap_or(i64::MAX), (secs % 86_400) as u32);
|
||||
let (y, t) = utc_parts(days, sod, 0);
|
||||
// `{y:04}` rather than `%Y`, which writes a `+` before years past 9999 (the old code did not).
|
||||
format!("{y:04}-{}", t.format("%m-%d %H:%M UTC"))
|
||||
}
|
||||
|
||||
/// The binary form of `msg`: [`WIRE_VERSION`], then postcard.
|
||||
@@ -752,3 +768,25 @@ pub fn decode_json<T: DeserializeOwned>(text: &str, max_bytes: usize) -> Result<
|
||||
}
|
||||
serde_json::from_str(text).map_err(|e| Error::Decode(e.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `utc_date` keeps the exact `YYYY-MM-DD HH:MM UTC` output the hand-rolled civil-from-days
|
||||
/// code produced (leap days and all).
|
||||
#[test]
|
||||
fn utc_date_formats_seconds_since_1970() {
|
||||
assert_eq!(utc_date(0), "1970-01-01 00:00 UTC");
|
||||
assert_eq!(utc_date(1_000_000), "1970-01-12 13:46 UTC");
|
||||
assert_eq!(utc_date(951_782_400), "2000-02-29 00:00 UTC");
|
||||
assert_eq!(utc_date(1_709_164_800), "2024-02-29 00:00 UTC");
|
||||
assert_eq!(utc_date(1_791_208_980), "2026-10-05 14:03 UTC");
|
||||
// A year past 9999 keeps the plain zero-padded year (no `+`), as the old `{y:04}` did.
|
||||
assert_eq!(utc_date(253_402_300_800), "10000-01-01 00:00 UTC");
|
||||
// Past chrono's own year range (262143) the instant is moved into range by whole 400-year
|
||||
// cycles and the real year restored, so it keeps the old hand-rolled output instead of
|
||||
// falling back to the epoch.
|
||||
assert_eq!(utc_date(u64::MAX), "584554051223-11-09 07:00 UTC");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ egui = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
image = { workspace = true }
|
||||
egui_extras = { workspace = true }
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use chrono::Datelike;
|
||||
use egui::{Align2, Color32, CornerRadius, Id, Key, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
|
||||
use figcraft_engine::Prefs;
|
||||
use figcraft_format::thumbnail::{FileKind, Preview};
|
||||
@@ -200,18 +201,35 @@ const MONTHS: [&str; 12] = [
|
||||
tr_noop("Dec"),
|
||||
];
|
||||
|
||||
/// `secs` since 1970 as a date in UTC: "Mar 4, 2026".
|
||||
/// (days since 1970-01-01, seconds-of-day, nanos) → (proleptic Gregorian year, the same wall-clock
|
||||
/// instant moved into chrono's range by whole 400-year cycles). The 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 the result exact for any `i64` day count. This is not hand-rolled calendar
|
||||
/// math: chrono still does the date, the cycle count just shifts the year.
|
||||
fn utc_parts(days: i64, sod: u32, nanos: u32) -> (i64, chrono::NaiveDateTime) {
|
||||
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 = chrono::NaiveTime::from_num_seconds_from_midnight_opt(sod.min(86_399), 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(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t))
|
||||
}
|
||||
_ => (1970, chrono::NaiveDateTime::default()), // unreachable
|
||||
}
|
||||
}
|
||||
|
||||
/// `secs` since 1970 as a date in UTC: "Mar 4, 2026". Years past chrono's own range (262143) keep
|
||||
/// converting: the instant is moved into range by whole 400-year cycles and the real year restored,
|
||||
/// so every `u64` reads as the date the old hand-rolled conversion produced.
|
||||
fn date(secs: u64) -> String {
|
||||
// Days to a civil date (Howard Hinnant's days_from_civil, inverted).
|
||||
let z = i64::try_from(secs / 86_400).unwrap_or(0) + 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 day = doy - (153 * mp + 2) / 5 + 1;
|
||||
let month = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let year = yoe + era * 400 + i64::from(month <= 2);
|
||||
// The day count of any `u64` seconds fits in `i64` (`u64::MAX / 86_400` ≈ 2.1e14), so the
|
||||
// conversion is always exact (no epoch fallback). Splitting with `u64` arithmetic (as the old
|
||||
// code did) avoids the `i64` seconds overflow near `u64::MAX`.
|
||||
let (days, sod) = (i64::try_from(secs / 86_400).unwrap_or(i64::MAX), (secs % 86_400) as u32);
|
||||
let (year, dt) = utc_parts(days, sod, 0);
|
||||
let (month, day) = (i64::from(dt.month()), i64::from(dt.day()));
|
||||
let name = usize::try_from(month - 1).ok().and_then(|m| MONTHS.get(m)).map_or("", |m| tr(m));
|
||||
trf("{month} {day}, {year}", &[("month", &name), ("day", &day), ("year", &year)])
|
||||
}
|
||||
@@ -965,3 +983,24 @@ fn list(ui: &mut Ui, t: &Tokens, recents: &mut Recents, files: &[Shown], now: u6
|
||||
arrows(ui, recents, files, 0, 1);
|
||||
action
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `date` keeps the exact "Mon D, YYYY" (UTC) output the hand-rolled days-from-civil code
|
||||
/// produced. With no catalog loaded the month and format strings pass through in English.
|
||||
#[test]
|
||||
fn date_formats_seconds_since_1970_in_utc() {
|
||||
assert_eq!(date(0), "Jan 1, 1970");
|
||||
assert_eq!(date(951_782_400), "Feb 29, 2000");
|
||||
assert_eq!(date(1_709_164_800), "Feb 29, 2024");
|
||||
assert_eq!(date(1_741_046_400), "Mar 4, 2025");
|
||||
// A year past chrono's own `NaiveDate` range (262143): the instant is moved into range by
|
||||
// whole 400-year cycles and the real year restored.
|
||||
assert_eq!(date(253_402_300_800), "Jan 1, 10000");
|
||||
// Past chrono's year range, `u64::MAX` keeps the old hand-rolled output instead of falling
|
||||
// back to the epoch.
|
||||
assert_eq!(date(u64::MAX), "Nov 9, 584554051223");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,7 +121,7 @@ if has appimage; then
|
||||
if [ -z "$TOOL" ]; then
|
||||
TOOL="$CARGO_TARGET_DIR/appimagetool-$ARCH.AppImage"
|
||||
if [ ! -x "$TOOL" ]; then
|
||||
curl -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage"
|
||||
curl --proto '=https' -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage"
|
||||
chmod +x "$TOOL"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -147,7 +147,7 @@ FILES="$HERE" # where the units and the Caddyfile come from
|
||||
from_release() { # download and check the release archive; sets NEW_BIN and FILES
|
||||
local version="$VERSION" name dir
|
||||
if [ -z "$version" ]; then
|
||||
version="$(curl -fsSL "https://api.github.com/repos/$REPO/releases/latest" | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p' | head -n 1)" || return 1
|
||||
version="$(curl --proto '=https' -fsSL "https://api.github.com/repos/$REPO/releases/latest" | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p' | head -n 1)" || return 1
|
||||
[ -n "$version" ] || return 1
|
||||
fi
|
||||
name="figcraft-server-$version-linux-$ARCH"
|
||||
@@ -155,8 +155,8 @@ from_release() { # download and check the release archive; sets NEW_BIN and FILE
|
||||
rm -rf "$dir"
|
||||
mkdir -p "$dir"
|
||||
say "Downloading $name"
|
||||
curl -fsSL -o "$dir/$name.tar.gz" "https://github.com/$REPO/releases/download/v$version/$name.tar.gz" || return 1
|
||||
curl -fsSL -o "$dir/SHA256SUMS.txt" "https://github.com/$REPO/releases/download/v$version/SHA256SUMS.txt" || return 1
|
||||
curl --proto '=https' -fsSL -o "$dir/$name.tar.gz" "https://github.com/$REPO/releases/download/v$version/$name.tar.gz" || return 1
|
||||
curl --proto '=https' -fsSL -o "$dir/SHA256SUMS.txt" "https://github.com/$REPO/releases/download/v$version/SHA256SUMS.txt" || return 1
|
||||
(cd "$dir" && grep " $name.tar.gz\$" SHA256SUMS.txt | sha256sum -c --quiet -) || die "the download doesn't match SHA256SUMS.txt"
|
||||
tar -C "$dir" -xzf "$dir/$name.tar.gz"
|
||||
NEW_BIN="$dir/$name/figcraft-server"
|
||||
|
||||
Reference in New Issue
Block a user