mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-29 12:46:53 -06:00
Merge branch 'print-color' into print-color-x1a
# Conflicts: # Cargo.lock # Cargo.toml # xtask/src/pdf_deps.rs
This commit is contained in:
@@ -0,0 +1 @@
|
||||
mod cli;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"] }],
|
||||
|
||||
@@ -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", ¶ms).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);
|
||||
|
||||
Reference in New Issue
Block a user