From b5342e48cd2efeaa5d337137c26e8015dff29224 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 21:32:05 +0200 Subject: [PATCH] Save and export dialogs start in the document's folder, or the last one used (#1653) --- ROADMAP.md | 1 + apps/designcraft/src/main.rs | 28 ++++++++++- crates/ui-egui/src/dialogs.rs | 2 +- crates/ui-egui/src/lib.rs | 73 +++++++++++++++++++++++++++- crates/ui-egui/src/menus.rs | 16 +++--- crates/ui-egui/src/panels/library.rs | 6 +-- docs/user/guides/export-a-pdf.md | 2 +- 7 files changed, 113 insertions(+), 15 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d3ef17e4..66c24a7e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-23** · Save and export dialogs (Save As, Save a Copy, Export PDF and the other exports, Package, libraries and books) start in the active document's folder once it has been saved, and for a document never saved in the folder of the last file saved or exported, remembered across sessions (#1653). A folder deleted since falls back to the system's choice. No readiness percentage change. - **2026-10-16** · Edit › Keyboard Shortcuts imports a set saved by InDesign's Show Set (.txt, Windows or Mac key names, UTF-8 or UTF-16): matched commands take its Default shortcuts or lose theirs, other commands give up keys it takes, and a report lists what it changed and the items it could not match (`window.importShortcuts`, #1180). Tool keys stay fixed. No readiness percentage change. - **2026-10-22** · A ruler guide dragged with the Selection tools, out of a ruler, on its own or with other selected guides, snaps along its axis as objects do (#1221): to other ruler guides, margins, columns, bleed, page and object edges and centers (with the smart-guide mark) and the document grid, within the snap zone and following Snap to Guides and Smart Guides. Shift snaps to the ruler's ticks and Cmd suspends snapping. The preview line, the position readout and the drop use the same snapped position. No readiness percentage change. - **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change. diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index 13416e4e..e694fe84 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -277,12 +277,27 @@ fn picked_save_path(picked: std::path::PathBuf, ext: Option<&str>) -> std::path: s.into() } +/// The folder a save dialog starts in and the name it suggests. A suggestion with a folder in +/// front (the document's, or the last one used) starts there while that folder still exists; +/// otherwise the system picks, as it did before. +fn save_dialog_start(suggested: &str) -> (Option, String) { + let path = std::path::Path::new(suggested); + let name = path.file_name().map_or_else(|| suggested.to_string(), |n| n.to_string_lossy().to_string()); + let folder = path.parent().filter(|f| !f.as_os_str().is_empty() && f.is_dir()).map(std::path::Path::to_path_buf); + (folder, name) +} + /// The save dialog: the suggested name's extension as its filter, and the extension added to a /// name typed without it. Adding it names another file than the one the dialog checked, so /// replacing an existing one asks again. -fn pick_save(name: &str) -> Option { +fn pick_save(suggested: &str) -> Option { + let (folder, name) = save_dialog_start(suggested); + let name = name.as_str(); let ext = suggested_extension(name); let mut dialog = rfd::FileDialog::new().set_file_name(name); + if let Some(folder) = folder { + dialog = dialog.set_directory(folder); + } if let Some(ext) = ext { dialog = dialog.add_filter(ext.to_ascii_uppercase(), &[ext]); } @@ -606,6 +621,17 @@ mod tests { assert_eq!(save("/d/Brochure.txt", "Untitled.rtf"), PathBuf::from("/d/Brochure.txt"), "another export format is kept"); assert_eq!(save("/d/Package", "Untitled 1.2 Folder"), PathBuf::from("/d/Package"), "a folder has no extension"); } + + /// #1653: the save dialog opens in the folder the suggestion names, unless it is gone. + #[test] + fn save_dialog_starts_in_the_suggested_folder_while_it_exists() { + let folder = std::env::temp_dir(); + let suggested = folder.join("Brochure.pdf"); + assert_eq!(super::save_dialog_start(&suggested.to_string_lossy()), (Some(folder.clone()), "Brochure.pdf".to_string())); + let gone = folder.join(format!("designcraft-gone-{}", std::process::id())).join("Brochure.pdf"); + assert_eq!(super::save_dialog_start(&gone.to_string_lossy()), (None, "Brochure.pdf".to_string()), "a folder deleted since: the system's own"); + assert_eq!(super::save_dialog_start("Untitled 1.2 Folder"), (None, "Untitled 1.2 Folder".to_string()), "a bare name: the system's own"); + } } #[cfg(all(test, unix))] diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 40787954..d80f3cd0 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3337,7 +3337,7 @@ pub fn confirm(app: &mut DesignApp) -> Result { params["title"] = json!(title); } let name = if title.is_empty() { "Export.pdf".to_string() } else { format!("{title}.pdf") }; - match app.services.pick_save.as_mut().and_then(|f| f(&name)) { + match app.pick_save(&name) { Some(path) => { params["path"] = json!(path); crate::menus::export_pdf(app, ¶ms) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 535c8762..0895cdf0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -71,7 +71,8 @@ pub type Inbox = std::sync::Arc>>; pub struct Services { /// Open-file dialog for a purpose (`open`, `place`) → path. pub pick_open: Option, - /// Save dialog with a suggested name → path. + /// Save dialog with a suggested name → path. The name may start with the folder the dialog + /// opens in (see [`DesignApp::pick_save`]). pub pick_save: Option, pub read: Option, pub write: Option, @@ -285,6 +286,10 @@ pub struct UiState { pub custom_workspaces: Vec, /// File › New › Document › Recent: the settings of the last documents made, newest first. pub recent_new_documents: Vec, + /// The folder of the last file saved or exported: where the save dialog starts for a + /// document never saved. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_save_folder: Option, /// The font menus' sample text (a key of `panels::FONT_SAMPLES`) and size (0–2). pub font_sample: String, pub font_preview_size: u8, @@ -412,6 +417,7 @@ impl Default for UiState { workspace: "Essentials".into(), custom_workspaces: Vec::new(), recent_new_documents: Vec::new(), + last_save_folder: None, font_sample: "selected".into(), font_preview_size: 1, new_document_preview: false, @@ -751,6 +757,26 @@ impl DesignApp { self.ui.status = s.into(); } + /// The host's save dialog for `name`, started beside the active document once it has been + /// saved, else in the folder of the last file saved or exported (#1653). The answer's folder + /// is remembered for the next one. + pub(crate) fn pick_save(&mut self, name: &str) -> Option { + use std::path::{Path, PathBuf}; + let folder_of = |path: &str| Path::new(path).parent().filter(|f| !f.as_os_str().is_empty()).map(Path::to_path_buf); + let folder = self + .session + .active() + .and_then(|d| d.path.as_deref()) + .and_then(folder_of) + .or_else(|| self.ui.last_save_folder.as_ref().map(PathBuf::from)); + let suggested = folder.map_or_else(|| name.to_string(), |f| f.join(name).to_string_lossy().to_string()); + let picked = self.services.pick_save.as_mut().and_then(|f| f(&suggested))?; + if let Some(folder) = folder_of(&picked) { + self.ui.last_save_folder = Some(folder.to_string_lossy().to_string()); + } + Some(picked) + } + /// Did an IME composition end outside the IME this frame (a click, a tool switch)? egui's /// `should_interrupt_composition` toggles winit's IME, which doesn't reach the macOS input /// context: it keeps the marked text and types it again into the next composition, so the @@ -1834,6 +1860,51 @@ mod tests { assert_eq!(app.view_info().unit, Unit::Inches); } + /// Save and Export PDF dialogs started in the home folder (#1653): they start beside a saved + /// document, and for one never saved in the folder of the last save or export. + #[test] + fn save_dialogs_start_beside_the_document_or_in_the_last_folder() { + use std::{cell::RefCell, rc::Rc}; + let home = tempfile::tempdir().unwrap(); + let elsewhere = tempfile::tempdir().unwrap(); + let asked: Rc>> = Rc::default(); + let answer: Rc>> = Rc::default(); + let mut app = DesignApp::new(designcraft_engine::Session::new(), Services::default()); + let (seen, reply) = (asked.clone(), answer.clone()); + app.services.pick_save = Some(Box::new(move |name: &str| { + seen.borrow_mut().push(name.to_string()); + reply.borrow().clone() + })); + app.services.write = Some(Box::new(|_: &str, _: &[u8]| Ok(()))); + let last = |asked: &Rc>>| asked.borrow().last().cloned().unwrap_or_default(); + let export_pdf = |app: &mut DesignApp| { + app.ui.dialog = Some(dialogs::Dialog::new("pdfExport", json!({}))); + app.ui.dialog.as_mut().unwrap().fields.insert("title".into(), json!("Report")); + dialogs::confirm(app).unwrap(); + }; + + app.run("file.new", json!({})).unwrap(); + let untitled = format!("{}.designcraft", app.session.active().unwrap().doc.title); + app.run("app.saveDialog", json!({})).unwrap(); + assert_eq!(last(&asked), untitled, "never saved, nothing remembered: the system's own folder"); + + let saved = home.path().join("Brochure.designcraft"); + *answer.borrow_mut() = Some(saved.to_string_lossy().to_string()); + app.run("app.saveDialog", json!({})).unwrap(); + assert_eq!(app.session.active().unwrap().path.as_deref(), Some(&*saved.to_string_lossy())); + // An export into another folder becomes the last folder used. + *answer.borrow_mut() = Some(elsewhere.path().join("Proof.pdf").to_string_lossy().to_string()); + export_pdf(&mut app); + *answer.borrow_mut() = None; + export_pdf(&mut app); + assert_eq!(last(&asked), home.path().join("Report.pdf").to_string_lossy(), "a saved document exports beside itself"); + + app.run("file.new", json!({})).unwrap(); + let untitled = format!("{}.designcraft", app.session.active().unwrap().doc.title); + app.run("app.saveDialog", json!({})).unwrap(); + assert_eq!(last(&asked), elsewhere.path().join(untitled).to_string_lossy(), "a new document starts in the last folder used"); + } + /// An app with one document that has an unsaved edit. fn app_with_unsaved_document() -> DesignApp { let mut app = DesignApp::new(designcraft_engine::Session::new(), Services::default()); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 87537abd..7dfff901 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1071,7 +1071,7 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option { let name = app.session.active().map(|d| format!("{}.designcraft", d.doc.title)).unwrap_or_default(); - if let Some(path) = app.services.pick_save.as_mut().and_then(|f| f(&name)) { + if let Some(path) = app.pick_save(&name) { return Some(app.run("file.saveAs", json!({"path": path}))); } Ok(Value::Null) @@ -1082,7 +1082,7 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option { let name = app.session.active().map(|d| format!("{} copy.designcraft", d.doc.title)).unwrap_or_default(); - if let Some(path) = app.services.pick_save.as_mut().and_then(|f| f(&name)) { + if let Some(path) = app.pick_save(&name) { return Some(app.run("file.saveACopy", json!({"path": path}))); } Ok(Value::Null) @@ -1156,7 +1156,7 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option export_bytes(app, p, "rtf", "file.exportText"), "app.packageDialog" => { let name = app.session.active().map(|d| format!("{} Folder", d.doc.title)).unwrap_or_default(); - match app.services.pick_save.as_mut().and_then(|f| f(&name)) { + match app.pick_save(&name) { Some(dir) => { let r = app.run("file.package", json!({"dir": dir})); if let Ok(v) = &r { @@ -1217,7 +1217,7 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option { let path = match p.get("path").and_then(Value::as_str) { Some(s) => Some(s.to_string()), - None => app.services.pick_save.as_mut().and_then(|f| f("Swatches.ase")), + None => app.pick_save("Swatches.ase"), }; let Some(path) = path else { return Some(Ok(Value::Null)) }; let r = match app.run("swatch.save", json!({})) { @@ -2011,7 +2011,7 @@ fn export_png(app: &mut DesignApp, p: &Value) -> Result { Some(s) => Some(s.to_string()), None => { let name = format!("{}-p{}.png", st.doc.title, page + 1); - app.services.pick_save.as_mut().and_then(|f| f(&name)) + app.pick_save(&name) } }; let Some(path) = path else { return Ok(Value::Null) }; @@ -2029,7 +2029,7 @@ fn export_idml(app: &mut DesignApp, p: &Value) -> Result { Some(s) => Some(s.to_string()), None => { let name = format!("{}.idml", st.doc.title); - app.services.pick_save.as_mut().and_then(|f| f(&name)) + app.pick_save(&name) } }; let Some(path) = path else { return Ok(Value::Null) }; @@ -2049,7 +2049,7 @@ fn export_bytes(app: &mut DesignApp, p: &Value, ext: &str, cmd: &str) -> Result< Some(s) => Some(s.to_string()), None => { let name = format!("{}.{ext}", st.doc.title); - app.services.pick_save.as_mut().and_then(|f| f(&name)) + app.pick_save(&name) } }; let Some(path) = path else { return Ok(Value::Null) }; @@ -2077,7 +2077,7 @@ pub(crate) fn export_pdf(app: &mut DesignApp, p: &Value) -> Result Some(s.to_string()), None => { let name = format!("{}.pdf", st.doc.title); - app.services.pick_save.as_mut().and_then(|f| f(&name)) + app.pick_save(&name) } }; let Some(path) = path else { return Ok(Value::Null) }; diff --git a/crates/ui-egui/src/panels/library.rs b/crates/ui-egui/src/panels/library.rs index 56d6c8ca..65fb1686 100644 --- a/crates/ui-egui/src/panels/library.rs +++ b/crates/ui-egui/src/panels/library.rs @@ -33,7 +33,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { ui.separator(); ui.horizontal(|ui| { if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "New Library…"))).clicked() { - let path = app.services.pick_save.as_mut().and_then(|f| f("Library.dclib")); + let path = app.pick_save("Library.dclib"); let _ = app.run("library.new", json!({"path": path})); } if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Open…"))).clicked() @@ -90,7 +90,7 @@ pub fn book(app: &mut DesignApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); ui.horizontal(|ui| { if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "New Book…"))).clicked() - && let Some(path) = app.services.pick_save.as_mut().and_then(|f| f("Book.dcbook")) + && let Some(path) = app.pick_save("Book.dcbook") && let Err(e) = app.run("book.new", json!({"path": path})) { app.status_error("Book", e); @@ -154,7 +154,7 @@ pub fn book(app: &mut DesignApp, ui: &mut egui::Ui) { let _ = app.run("book.syncStyles", json!({})); } if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Export PDF…"))).clicked() - && let Some(path) = app.services.pick_save.as_mut().and_then(|f| f("Book.pdf")) + && let Some(path) = app.pick_save("Book.pdf") && let Err(e) = app.run("book.exportPdf", json!({"path": path})) { app.status_error("Book", e); diff --git a/docs/user/guides/export-a-pdf.md b/docs/user/guides/export-a-pdf.md index 3bec469c..827f54c4 100644 --- a/docs/user/guides/export-a-pdf.md +++ b/docs/user/guides/export-a-pdf.md @@ -17,7 +17,7 @@ Shortcuts on this page use Ctrl. On macOS, use Cmd instead of Ctrl. 4. Under **Compression**, tick or clear **Compress images**. 5. Under **Marks and Bleeds**, tick **Include document bleed**, **Crop Marks**, **Bleed Marks** or **Page Information** as your printer asks. **Weight:** and **Offset:** set the marks. 6. Under **Advanced**, pick a **Standard:** (**None**, **PDF/X-4** or **PDF/A-2b**) and decide on **Tagged PDF**. -7. Click **OK**, then choose where to save. The file name starts as your document's title with `.pdf`. +7. Click **OK**, then choose where to save. The file name starts as your document's title with `.pdf`. The save dialog opens in your document's folder, or, if the document has never been saved, in the folder you last saved or exported to. ## Presets