From a645127ce991773037b30db62affa1b6b7417b67 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Sat, 24 Oct 2026 13:03:00 +0200 Subject: [PATCH 1/2] i18n: keys main added to fr.rs are in the shared table here: keep one The catalogue test refuses a key in both fr.rs and the shared table (CI). The shared row keeps the French; it matches, or takes fr.rs's text. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/fr.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index 681230d84..adf8898d7 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -105,7 +105,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Em Dash", "Tiret cadratin"), ("Em Space", "Espace cadratin"), ("Email Address:", "Adresse e-mail :"), - ("Email:", "E-mail :"), ("En Dash", "Tiret demi-cadratin"), ("En Space", "Espace demi-cadratin"), ( @@ -254,7 +253,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("PDF import cancelled: the originating document has closed", "Importation PDF annulée : le document d'origine a été fermé"), ("PRs", "PR"), ("Package", "Assemblage"), - ("Packaged {count} files into {dir}", "{count} fichiers assemblés dans {dir}"), ("Page Break", "Saut de page"), ("Page Options", "Options de page"), ("Page Tool", "Outil Page"), @@ -265,7 +263,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Parent Options…", "Options de gabarit…"), ("Pen Tool", "Outil Plume"), ("Pencil Tool", "Outil Crayon"), - ("Phone:", "Téléphone :"), ("Picas", "Picas"), ("Pin Bar Position", "Épingler la position de la barre"), ("Place with Import Options", "Importer avec les options d'importation"), From f756c9174766999031426f72c8acbd536e0a9dc8 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Sat, 24 Oct 2026 13:36:59 +0200 Subject: [PATCH 2/2] Package: missing styles count, same document, Enter in a field, instructions first - A style the font lacks counts as a missing font in Summary and Fonts, as file.package counts it (font_status). - The dialog remembers its document; Package... and Continue refuse when another document became active meanwhile, so the package is the one summarised. - Enter in Address or Instructions makes a new line: the dialog footer no longer takes it for Continue (dialogs::MULTILINE_FOCUS). - Instructions.txt starts with the printing instructions, then the report, as the dialog promises. Found by CodeRabbit. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/package.rs | 7 +- crates/ui-egui/src/dialogs.rs | 8 ++- crates/ui-egui/src/package_dialog.rs | 98 +++++++++++++++++++++++++--- 3 files changed, 100 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index d0ed58b03..de3846114 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -441,11 +441,13 @@ fn package(s: &mut Session, p: &Value) -> Result { root.write(&path, &bytes)?; files.push(path); } - // The report: fonts (missing first), links, preflight, the user's instructions. - let mut r = format!("Package report: {display_title}\n\n"); + // The printing instructions first, as the form gave them; then the report: fonts (missing + // first), links, preflight. + let mut r = String::new(); if let Some(t) = str_param(p, "instructions").filter(|t| !t.trim().is_empty()) { r += &format!("Instructions\n{t}\n\n"); } + r += &format!("Package report: {display_title}\n\n"); r += "Fonts\n"; r += &font_lines.concat(); if !fallback_lines.is_empty() { @@ -629,6 +631,7 @@ mod tests { assert!(out.join("Links").join("logo.svg").exists()); let report = std::fs::read_to_string(out.join("Instructions.txt")).unwrap(); assert!(report.contains("Print on matte.") && report.contains("logo.svg"), "{report}"); + assert!(report.find("Print on matte.") < report.find("Package report:"), "the instructions come first: {report}"); assert!(report.contains("Preflight:"), "{report}"); // The packaged document links to its Links folder. let doc_file = files.iter().find(|f| f.ends_with(".designcraft")).unwrap(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index bc7d28262..e5f608972 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -137,6 +137,10 @@ fn text_field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, w: f32) { } } +/// The temporary-data key a dialog sets, each frame, while one of its multi-line fields has focus: +/// Enter then makes a new line there, and the footer doesn't take it for OK. +pub(crate) const MULTILINE_FOCUS: &str = "dialog_multiline_focus"; + /// Extract once when opened. Cancel is read-only; confirmation adds exactly these colours. pub(crate) fn open_color_theme(app: &mut DesignApp, params: Value) { match app.run("color.extractTheme", params) { @@ -2850,6 +2854,8 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { app.ui.style_preview = d.b("preview"); style_options_preview(app, &mut d); } + // A multi-line field with focus (set by the dialog this frame) takes Enter as a new line. + let multiline = ui.ctx().data_mut(|m| m.remove_temp::(egui::Id::new(MULTILINE_FOCUS))).unwrap_or(false); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui .add( @@ -2857,7 +2863,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { .fill(crate::theme::Tokens::get(ui.ctx()).accent_strong), ) .clicked() - || (!popup_open && ui.input(|i| i.key_pressed(egui::Key::Enter))) + || (!popup_open && !multiline && ui.input(|i| i.key_pressed(egui::Key::Enter))) { result = Some(true); } diff --git a/crates/ui-egui/src/package_dialog.rs b/crates/ui-egui/src/package_dialog.rs index cf42af11d..e064fd1f4 100644 --- a/crates/ui-egui/src/package_dialog.rs +++ b/crates/ui-egui/src/package_dialog.rs @@ -45,13 +45,7 @@ pub fn open(app: &mut DesignApp) -> Result<(), String> { .unwrap_or_default() .iter() .map(|f| { - let status = if f["missing"].as_bool().unwrap_or(false) { - "missing" - } else if f["styleMissing"].as_bool().unwrap_or(false) || f["matchStatus"] == "styleSubstitute" { - "substituted" - } else { - "ok" - }; + let status = font_status(f); let name = format!("{} {}", f["family"].as_str().unwrap_or(""), f["style"].as_str().unwrap_or("")).trim().to_string(); json!({"name": name, "source": f["source"].as_str().unwrap_or(""), "status": status}) }) @@ -81,13 +75,35 @@ pub fn open(app: &mut DesignApp) -> Result<(), String> { .collect() }) .unwrap_or_default(); + let doc = app.session.active().map(|d| d.uid); app.ui.dialog = Some(Dialog::new( ID, - json!({"page": "summary", "fonts": fonts, "links": links, "spot": spot, "instructions": false, "idml": true, "pdf": false, "problemsOnly": false}), + json!({"page": "summary", "doc": doc, "fonts": fonts, "links": links, "spot": spot, "instructions": false, "idml": true, "pdf": false, "problemsOnly": false}), )); Ok(()) } +/// A `font.list` entry's status here: a missing font, or a style its family lacks, is missing +/// (as `file.package` counts them); a style it stands in for is substituted. +fn font_status(f: &Value) -> &'static str { + if f["missing"].as_bool().unwrap_or(false) || f["styleMissing"].as_bool().unwrap_or(false) { + "missing" + } else if f["matchStatus"] == "styleSubstitute" { + "substituted" + } else { + "ok" + } +} + +/// The document the dialog was opened on is still the active one (automation can switch +/// documents while it is open, and the package must be of the one summarised). +fn same_document(app: &DesignApp, d: &Dialog) -> Result<(), String> { + match app.session.active() { + Some(doc) if d.fields.get("doc").and_then(Value::as_u64) == Some(doc.uid) => Ok(()), + _ => Err("the active document changed after the Package dialog opened".into()), + } +} + fn rows(d: &Dialog, key: &str) -> Vec { d.fields.get(key).and_then(Value::as_array).cloned().unwrap_or_default() } @@ -268,9 +284,10 @@ pub fn show(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { /// Package…: the Printing Instructions first when asked for, else the folder. pub fn confirm(app: &mut DesignApp, d: &Dialog) -> Result { + same_document(app, d)?; let (idml, pdf) = (d.b("idml"), d.b("pdf")); if d.b("instructions") { - app.ui.dialog = Some(Dialog::new(INSTRUCTIONS, json!({"idml": idml, "pdf": pdf}))); + app.ui.dialog = Some(Dialog::new(INSTRUCTIONS, json!({"idml": idml, "pdf": pdf, "doc": d.fields.get("doc")}))); return Ok(Value::Null); } package_to_folder(app, idml, pdf, None) @@ -284,9 +301,14 @@ pub fn show_instructions(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { crate::rtl::label(ui, crate::i18n::tr(&lang, label)); let mut text = d.s(key); let edit = if lines > 1 { egui::TextEdit::multiline(&mut text).desired_rows(lines) } else { egui::TextEdit::singleline(&mut text) }; - if ui.add(edit.desired_width(320.0)).changed() { + let resp = ui.add(edit.desired_width(320.0)); + if resp.changed() { d.fields.insert(key.into(), json!(text)); } + // Enter there makes a new line: the footer mustn't take it for Continue. + if lines > 1 && resp.has_focus() { + ui.ctx().data_mut(|m| m.insert_temp(egui::Id::new(crate::dialogs::MULTILINE_FOCUS), true)); + } ui.end_row(); } }); @@ -318,6 +340,7 @@ fn instructions_text(d: &Dialog) -> String { /// Continue: the folder, with the instructions. pub fn confirm_instructions(app: &mut DesignApp, d: &Dialog) -> Result { + same_document(app, d)?; let text = instructions_text(d); package_to_folder(app, d.b("idml"), d.b("pdf"), Some(text).filter(|t| !t.is_empty())) } @@ -481,4 +504,59 @@ mod tests { assert!(app.ui.dialog.is_none()); std::fs::remove_dir_all(dir).unwrap(); } + + /// A style the font's family lacks counts as missing, as `file.package` counts it. + #[test] + fn a_missing_font_style_is_a_missing_font() { + assert_eq!(font_status(&json!({"missing": true})), "missing"); + assert_eq!(font_status(&json!({"missing": false, "styleMissing": true})), "missing"); + assert_eq!(font_status(&json!({"missing": false, "matchStatus": "styleSubstitute"})), "substituted"); + assert_eq!(font_status(&json!({"missing": false})), "ok"); + } + + /// The package is of the document the dialog summarised: when another document became active + /// meanwhile, Package… and Continue refuse, and nothing is written. + #[test] + fn package_refuses_when_the_active_document_changed() { + let dir = temp_dir("switched"); + let mut app = app_with_missing_link(&dir); + let out = dir.join("Packed Folder"); + let picked = out.to_string_lossy().to_string(); + app.services.pick_save = Some(Box::new(move |_| Some(picked.clone()))); + open(&mut app).unwrap(); + let d = app.ui.dialog.take().unwrap(); + app.run("file.new", json!({"title": "Other"})).unwrap(); + assert!(confirm(&mut app, &d).is_err()); + let ins = Dialog::new(INSTRUCTIONS, json!({"idml": true, "pdf": false, "doc": d.fields["doc"]})); + assert!(confirm_instructions(&mut app, &ins).is_err()); + assert!(!out.exists(), "nothing packaged"); + std::fs::remove_dir_all(dir).unwrap(); + } + + /// Enter in Address or Instructions makes a new line there; in a one-line field it continues. + #[test] + fn enter_in_a_multiline_instruction_field_is_a_new_line() { + let dir = temp_dir("enter"); + let mut app = app_with_missing_link(&dir); + let doc = app.session.active().unwrap().uid; + app.ui.dialog = Some(Dialog::new(INSTRUCTIONS, json!({"idml": true, "pdf": false, "doc": doc}))); + let mut h = harness(app); + h.run_steps(3); + let field = |h: &Harness<'static, DesignApp>, n: usize| { + h.query_all_by(|x| x.role() == egui::accesskit::Role::TextInput || x.role() == egui::accesskit::Role::MultilineTextInput) + .nth(n) + .unwrap() + .rect() + .center() + }; + // Address is the third field. + let address = field(&h, 2); + h.event(egui::Event::PointerButton { pos: address, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.event(egui::Event::PointerButton { pos: address, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(2); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(h.state().ui.dialog.as_ref().map(|d| d.id.as_str()), Some(INSTRUCTIONS), "still open"); + std::fs::remove_dir_all(dir).unwrap(); + } }