Merge branch 'main' into fix/interface-size-menu-focus

This commit is contained in:
Yarila
2026-10-22 20:05:21 +05:00
committed by GitHub
12 changed files with 131 additions and 33 deletions
+4 -4
View File
@@ -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' }}
+1
View File
@@ -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)
+1
View File
@@ -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
View File
@@ -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",
+2
View File
@@ -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"] }
+4
View File
@@ -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">
+1
View File
@@ -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 }
+52 -14
View File
@@ -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");
}
}
+1
View File
@@ -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 }
+50 -11
View File
@@ -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");
}
}
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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"