Merge branch 'print-color' into print-color-x1a

# Conflicts:
#	Cargo.lock
#	Cargo.toml
#	xtask/src/pdf_deps.rs
This commit is contained in:
aiongg
2026-10-23 08:16:07 +00:00
155 changed files with 6908 additions and 1059 deletions
+1
View File
@@ -0,0 +1 @@
mod cli;
+2
View File
@@ -395,6 +395,8 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
let ctx = ctx.clone();
let dialog = if request.purpose == "swatches" {
rfd::AsyncFileDialog::new().add_filter("Swatch Exchange (ASE)", &["ase"])
} else if request.purpose == "shortcuts" {
rfd::AsyncFileDialog::new().add_filter("Shortcut set (text)", &["txt"])
} else if request.purpose == "place" {
rfd::AsyncFileDialog::new().add_filter("Graphics", IMAGE_EXTS)
} else {
+22 -19
View File
@@ -90,27 +90,16 @@ fn matches(name: &str, target: &str) -> bool {
}
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
/// A clock far past chrono's year range still converts (the calendar repeats every 400 years), so
/// logging never panics and never silently resets to the epoch for a convertible instant.
pub fn timestamp(t: SystemTime) -> String {
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
let secs = since.as_secs();
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, since.subsec_millis())
}
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch).
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)
// Pre-1970 is clamped to the epoch by `unwrap_or_default` above. `as_secs` is `u64`; a value
// past `i64::MAX` seconds saturates rather than falling back to the epoch.
let secs = i64::try_from(since.as_secs()).unwrap_or(i64::MAX);
let (y, t) = designcraft_engine::doc::time::utc_parts(secs, since.subsec_nanos());
// `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
format!("{y:04}-{}", t.format("%m-%dT%H:%M:%S%.3fZ"))
}
fn numbered(dir: &Path, n: usize) -> PathBuf {
@@ -317,6 +306,20 @@ mod tests {
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_704_067_199_999)), "2023-12-31T23:59:59.999Z");
// Before the epoch (a clock set wrong) clamps instead of panicking.
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
// Past chrono's year range still converts via the 400-year cycle (the old code did too,
// through `civil_from_days`): never a silent reset to the epoch. Platform-safe: Windows'
// SystemTime can't hold this instant, so it is only checked where it exists.
// `u64::MAX / 2` seconds == `i64::MAX` seconds → year 292277026596 (old output).
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
}
// Years past 9999 keep the plain zero-padded year (no `+`).
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
// 10_000_000_000_000 s is far beyond chrono's range; old output is year 318857.
// Windows' SystemTime holds only up to about year 30828, so this is checked where it fits.
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
}
}
#[test]
+1
View File
@@ -187,6 +187,7 @@ fn open_filters(purpose: &str) -> &'static [OpenFilter] {
match purpose {
"swatches" => &[OpenFilter { name: "Swatch Exchange (ASE)", extensions: &["ase"] }],
"script" => &[OpenFilter { name: "Script", extensions: &["dcscript", "txt", "json"] }],
"shortcuts" => &[OpenFilter { name: "Shortcut set (text)", extensions: &["txt"] }],
"icc" => &[OpenFilter { name: "ICC profile", extensions: &["icc", "icm"] }],
"book" => &[OpenFilter { name: "Book", extensions: &["dcbook"] }],
"xml" => &[OpenFilter { name: "XML", extensions: &["xml"] }],
+49 -15
View File
@@ -27,6 +27,9 @@ pub struct NativeMenu {
shown_for: (String, bool),
/// File › Open Recent, rebuilt when the list of recent files changes.
recent: Option<RecentMenu>,
/// The commands with key equivalents and the shortcut each was given: the menu is rebuilt
/// when one changes, so a shortcut moved to another command leaves its old item.
keys: Vec<(String, Option<String>)>,
}
struct RecentMenu {
@@ -50,21 +53,28 @@ fn menu_title(app: &DesignApp, label: &str) -> String {
menus::native_label(designcraft_ui_egui::i18n::tr(&app.ui.language, label))
}
/// A command's default shortcut, as the in-window menus show it.
fn shortcut(id: &str) -> Option<&'static str> {
menus::ui_label(id).and_then(|(_, sc)| sc).or_else(|| designcraft_engine::find_command(id).and_then(|c| c.shortcut))
/// A command's shortcut in effect (the user's, else the default), as the in-window menus show it.
fn shortcut(app: &DesignApp, id: &str) -> Option<String> {
menus::shortcut_of(app, id)
}
impl NativeMenu {
pub fn install(app: &mut DesignApp, ctx: &egui::Context) -> Self {
// A click in the menu bar reaches the app with no input over the window, so nothing would
// draw the frame that runs the command until the pointer or a key comes back: wake egui.
// muda keeps the first handler for the life of the process, so this channel serves every
// menu bar built later (`build_bar`).
let (tx, events) = channel();
let repaint = ctx.clone();
MenuEvent::set_event_handler(Some(move |e: MenuEvent| {
let _ = tx.send(e);
repaint.request_repaint();
}));
Self::build_bar(app, events)
}
/// The menu bar for the current language and settings, fed by `events`.
fn build_bar(app: &mut DesignApp, events: Receiver<MenuEvent>) -> Self {
let menu = Menu::new();
let mut items = HashMap::new();
let mut counter = 0usize;
@@ -75,12 +85,24 @@ impl NativeMenu {
for entry in menus::MAC_APP_MENU {
let _ = match *entry {
AppMenuItem::Cmd { id, label, command } => {
let sc = shortcut(command).and_then(accel);
let sc = shortcut(app, command).as_deref().and_then(accel);
let item = MenuItem::with_id(id, menu_title(app, label), true, sc);
let appended = app_menu.append(&item);
items.insert(id.into(), (command.into(), Value::Null, Handle::Plain(item)));
appended
}
AppMenuItem::Language => {
let sub = Submenu::new(menu_title(app, "Language"), true);
for (code, name) in designcraft_ui_egui::i18n::LANGUAGES {
let id = format!("dc-language-{}", if code.is_empty() { "en" } else { code });
let params = serde_json::json!({ "lang": code });
let checked = menus::checked(app, "app.language", &params).unwrap_or(false);
let item = CheckMenuItem::with_id(id.clone(), menus::native_label(name), true, checked, None);
let _ = sub.append(&item);
items.insert(id, ("app.language".into(), params, Handle::Check(item)));
}
app_menu.append(&sub)
}
AppMenuItem::Sep => app_menu.append(&PredefinedMenuItem::separator()),
AppMenuItem::Services => app_menu.append(&PredefinedMenuItem::services(None)),
AppMenuItem::Hide => app_menu.append(&PredefinedMenuItem::hide(None)),
@@ -96,15 +118,19 @@ impl NativeMenu {
}
menu.init_for_nsapp();
app.native_menu = true;
app.native_shortcuts = items
.values()
.filter_map(|(id, p, _)| {
let sc = shortcut(id)?;
(p.is_null() && accel(sc).is_some()).then(|| id.clone())
})
.collect();
let keys: Vec<(String, Option<String>)> =
items.values().filter(|(_, p, _)| p.is_null()).map(|(id, _, _)| (id.clone(), shortcut(app, id))).collect();
app.native_shortcuts = keys.iter().filter(|(_, sc)| sc.as_deref().and_then(accel).is_some()).map(|(id, _)| id.clone()).collect();
let recent = recent.map(|submenu| RecentMenu { submenu, shown: Vec::new(), ids: Vec::new(), separator: None });
Self { _menu: menu, items, events, last_refresh: 0.0, shown_for: (app.ui.language.clone(), designcraft_ui_egui::cjk_features(app)), recent }
Self {
_menu: menu,
items,
events,
last_refresh: 0.0,
shown_for: (app.ui.language.clone(), designcraft_ui_egui::cjk_features(app)),
recent,
keys,
}
}
/// Put the current recent files at the top of File › Open Recent when they changed.
@@ -153,13 +179,21 @@ impl NativeMenu {
// back by Cancel): the system menu bar follows at once, like the panels (the new menu
// replaces the old one as the application's menu before that one is dropped).
if app.ui.language != self.shown_for.0 || designcraft_ui_egui::cjk_features(app) != self.shown_for.1 {
*self = Self::install(app, ctx);
let events = std::mem::replace(&mut self.events, channel().1);
*self = Self::build_bar(app, events);
}
let now = designcraft_ui_egui::now_ms();
if now - self.last_refresh < 250.0 {
return;
}
self.last_refresh = now;
// A shortcut set in the Keyboard Shortcuts dialog: macOS gives key equivalents to the
// menu bar before the window sees the key, so the bar must carry the new keys.
if self.keys.iter().any(|(id, sc)| shortcut(app, id) != *sc) {
let events = std::mem::replace(&mut self.events, channel().1);
*self = Self::build_bar(app, events);
return;
}
self.refresh_recent(app);
for (cmd, params, h) in self.items.values() {
let en = menus::native_menu_enabled(app, ctx, cmd);
@@ -199,10 +233,10 @@ fn build(
}
// Hidden CJK commands; `NativeMenu::poll` rebuilds the bar when Show CJK Features changes.
Item::Cmd { id, .. } if !menus::shown(app, id) => {}
Item::Cmd { label, id, params, shortcut } => {
Item::Cmd { label, id, params, .. } => {
*counter += 1;
let mid = format!("dc{counter}");
let sc = if params.is_null() { shortcut.and_then(accel) } else { None };
let sc = if params.is_null() { shortcut(app, id).as_deref().and_then(accel) } else { None };
let label = menu_title(app, label);
let handle = if menus::checked(app, id, params).is_some() {
let c = CheckMenuItem::with_id(mid.clone(), label, true, false, sc);