diff --git a/Cargo.lock b/Cargo.lock index 146e64530..1c361aef2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1017,6 +1017,19 @@ dependencies = [ "rayon", ] +[[package]] +name = "craft-dialogs" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=1a11a4897865f7410c40d25ca6e452ce399a1a2c#1a11a4897865f7410c40d25ca6e452ce399a1a2c" +dependencies = [ + "block2 0.6.2", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "raw-window-handle", + "rfd", +] + [[package]] name = "craft-docpath" version = "0.1.0" @@ -1595,6 +1608,7 @@ dependencies = [ name = "designcraft-ui-egui" version = "0.14.0" dependencies = [ + "craft-dialogs", "craft-keymap", "craft-prepress", "craft-ui", @@ -1612,6 +1626,7 @@ dependencies = [ "egui", "egui_kittest", "log", + "raw-window-handle", "serde", "serde_json", "tempfile", diff --git a/Cargo.toml b/Cargo.toml index 4b801ca8d..139d06f01 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -101,6 +101,9 @@ egui_kittest = "0.37" # Monotonic clock on native and web (`Instant`); screenshot settlement must not use wall time. web-time = "1" rfd = "0.17" +# Native file and message dialogs that never block the event loop (craft-libs). +craft-dialogs = { git = "https://github.com/storytold/craft-libs", rev = "1a11a4897865f7410c40d25ca6e452ce399a1a2c" } +raw-window-handle = "0.6" arboard = { version = "3.6", default-features = false } [workspace.lints.rust] diff --git a/apps/designcraft-web/src/web.rs b/apps/designcraft-web/src/web.rs index 7e0c8d7d9..19bc3a122 100644 --- a/apps/designcraft-web/src/web.rs +++ b/apps/designcraft-web/src/web.rs @@ -410,8 +410,6 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services { ctx.request_repaint(); }); })), - // Exports ask for a name; the browser decides where the download goes. - pick_save: Some(Box::new(|name: &str| Some(name.to_string()))), write: Some(Box::new(|name: &str, bytes: &[u8]| download(name, bytes))), download: Some(Box::new(|name: &str, bytes: &[u8]| download(name, bytes))), inbox: Some(inbox), diff --git a/apps/designcraft/Cargo.toml b/apps/designcraft/Cargo.toml index 36415546e..d706374c7 100644 --- a/apps/designcraft/Cargo.toml +++ b/apps/designcraft/Cargo.toml @@ -24,6 +24,8 @@ log = { workspace = true } winresource = "0.1" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] +# Not called here: craft-dialogs shows the dialogs. rfd's default features keep its Wayland +# support on, so portal dialogs get the window as their parent. rfd = { workspace = true } arboard = { workspace = true } diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index dc46e65bc..5f056aab3 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -57,7 +57,7 @@ impl eframe::App for App { fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { self.app.raw_input_hook(ctx, raw); } - fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { self.startup.enter(); // `App::ui` is the only signal that a real layout pass ran. A discarded multipass first // pass reports index 0 once, so these once-per-frame counters/menu polls still run once. @@ -96,6 +96,9 @@ impl eframe::App for App { } #[cfg(target_os = "macos")] craft_window::title_bar::sync_movable(ui.ctx(), &mut self.movable); + // After the frame, never inside it: the dialog answers on a later frame. On macOS Save and + // message boxes are sheets on this window. + self.app.show_file_dialogs(Some(&*frame)); } fn on_exit(&mut self) { // A normal exit before the third frame is not a crash. @@ -216,157 +219,8 @@ fn save_prefs(app: &DesignApp) { } } -/// One file-type row in an open dialog. `open_filters` is what `pick_open` applies. -struct OpenFilter { - name: &'static str, - extensions: &'static [&'static str], -} - -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 (JSON, InDesign text)", extensions: &["json", "txt"] }], - "icc" => &[OpenFilter { name: "ICC profile", extensions: &["icc", "icm"] }], - "book" => &[OpenFilter { name: "Book", extensions: &["dcbook"] }], - "xml" => &[OpenFilter { name: "XML", extensions: &["xml"] }], - "library" => &[OpenFilter { name: "Object Library", extensions: &["dclib"] }], - "relink" => &[OpenFilter { - name: "Graphics", - extensions: &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "psd", "svg", "pdf", "ai", "eps"], - }], - "dataMerge" => &[OpenFilter { name: "Data source (CSV, TSV, text, Excel)", extensions: &["csv", "tsv", "tab", "txt", "xlsx"] }], - "place" => &[ - OpenFilter { - name: "Graphics and text", - extensions: &[ - "png", - "jpg", - "jpeg", - "gif", - "webp", - "tif", - "tiff", - "bmp", - "psd", - "svg", - "pdf", - "ai", - "eps", - "txt", - "docx", - "rtf", - "md", - "xlsx", - "idml", - "designcraft", - "mp4", - "m4v", - "mov", - "webm", - "mp3", - "m4a", - "wav", - "ogg", - ], - }, - OpenFilter { - name: "Graphics", - extensions: &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "psd", "svg", "pdf", "ai", "eps"], - }, - OpenFilter { name: "Text (Word, RTF, plain, Excel)", extensions: &["docx", "rtf", "txt", "md", "xlsx"] }, - OpenFilter { name: "Video and sound", extensions: &["mp4", "m4v", "mov", "webm", "mp3", "m4a", "wav", "ogg"] }, - ], - _ => &[ - OpenFilter { name: "DesignCraft, IDML or InDesign", extensions: &["designcraft", "idml", "indd", "indt"] }, - OpenFilter { name: "DesignCraft", extensions: &["designcraft"] }, - OpenFilter { name: "InDesign Markup (IDML)", extensions: &["idml"] }, - OpenFilter { name: "InDesign document or template (INDD, INDT)", extensions: &["indd", "indt"] }, - // Files saved without an extension; `file.open` tells the formats apart by content. - OpenFilter { name: "All files", extensions: &[ALL_FILES] }, - ], - } -} - -/// The "All files" pattern. The macOS panel merges every filter into one list of allowed types, -/// which takes type identifiers as well as extensions: `public.data` admits any file. -const ALL_FILES: &str = if cfg!(target_os = "macos") { "public.data" } else { "*" }; - -/// The extension of the file name a save dialog suggests (`Brochure.designcraft`), if it has one. -/// The dialog filters on it, so the macOS and Windows dialogs add it to a name typed without it. -fn suggested_extension(name: &str) -> Option<&str> { - std::path::Path::new(name).extension().and_then(std::ffi::OsStr::to_str).filter(|e| !e.is_empty() && !e.contains(char::is_whitespace)) -} - -/// The file a save dialog's answer writes: `picked` with the suggested extension added when it has -/// none (Linux dialogs don't add it). A document always ends in `.designcraft`, as `file.save` -/// writes it. -fn picked_save_path(picked: std::path::PathBuf, ext: Option<&str>) -> std::path::PathBuf { - let Some(ext) = ext else { return picked }; - if ext.eq_ignore_ascii_case("designcraft") { - let p = picked.to_string_lossy(); - return designcraft_engine::cmd::with_document_extension(&p).into(); - } - if picked.extension().is_some() || picked.file_name().is_none() { - return picked; - } - let mut s = picked.into_os_string(); - s.push("."); - s.push(ext); - 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(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]); - } - let picked = dialog.save_file()?; - let path = picked_save_path(picked.clone(), ext); - if path != picked && path.exists() { - let file = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); - let answer = rfd::MessageDialog::new() - .set_level(rfd::MessageLevel::Warning) - .set_title("Replace File?") - .set_description(format!("“{file}” already exists. Do you want to replace it?")) - .set_buttons(rfd::MessageButtons::YesNo) - .show(); - if answer != rfd::MessageDialogResult::Yes { - return None; - } - } - Some(path.to_string_lossy().to_string()) -} - fn services() -> Services { Services { - pick_open: Some(Box::new(|purpose: &str| { - let mut dialog = rfd::FileDialog::new(); - for filter in open_filters(purpose) { - dialog = dialog.add_filter(filter.name, filter.extensions); - } - dialog.pick_file().map(|p| p.to_string_lossy().to_string()) - })), - pick_save: Some(Box::new(pick_save)), read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), write: Some(Box::new(|p: &str, b: &[u8]| designcraft_engine::atomic::write_atomic(std::path::Path::new(p), b).map_err(|e| e.to_string()))), clipboard_text: Some(Box::new(|| arboard::Clipboard::new().and_then(|mut c| c.get_text()).ok())), @@ -636,16 +490,6 @@ mod tests { assert!(!super::KEEP_UI_PREFS.load(std::sync::atomic::Ordering::Relaxed), "ui.json can be saved once a copy is kept"); } - #[test] - fn relink_open_dialog_lists_graphics() { - let filters = super::open_filters("relink"); - let exts: Vec<&str> = filters.iter().flat_map(|filter| filter.extensions.iter().copied()).collect(); - for ext in ["png", "jpg", "tif", "psd", "svg", "pdf", "ai", "eps"] { - assert!(exts.contains(&ext), "{ext} is missing from the relink dialog: {exts:?}"); - } - assert!(!exts.contains(&"designcraft") && !exts.contains(&"idml")); - } - #[test] fn control_port_values_that_are_not_ports_are_ignored() { assert_eq!(super::control_port_from("--control", Some("7979".into())), Some(7979)); @@ -655,47 +499,6 @@ mod tests { assert_eq!(super::control_port_from("--control", Some(bad.into())), None, "{bad:?}"); } } - - #[test] - fn data_merge_open_dialog_lists_table_extensions() { - let filters = super::open_filters("dataMerge"); - let exts: Vec<&str> = filters.iter().flat_map(|filter| filter.extensions.iter().copied()).collect(); - for ext in ["csv", "tsv", "tab", "txt", "xlsx"] { - assert!(exts.contains(&ext), "{ext} is missing from the dataMerge dialog: {exts:?}"); - } - assert!(!exts.iter().any(|ext| *ext == "designcraft" || *ext == "idml"), "dataMerge must not fall through to the document filters: {exts:?}"); - let place = super::open_filters("place"); - assert!(place.len() > 1, "the place dialog keeps a filter for each kind of file"); - assert!(place.iter().any(|filter| filter.extensions.contains(&"png"))); - let documents = super::open_filters(""); - for ext in ["designcraft", "idml", "indd", "indt"] { - assert!(documents.first().is_some_and(|filter| filter.extensions.contains(&ext)), "{ext} is missing from the first Open filter"); - } - assert!(documents.last().is_some_and(|filter| filter.extensions == [super::ALL_FILES]), "files without an extension can be picked"); - } - - #[test] - fn save_dialog_answers_get_the_suggested_extension() { - use std::path::PathBuf; - let save = |picked: &str, name: &str| super::picked_save_path(PathBuf::from(picked), super::suggested_extension(name)); - assert_eq!(save("/d/Brochure", "Untitled.designcraft"), PathBuf::from("/d/Brochure.designcraft")); - assert_eq!(save("/d/Brochure.idml", "Untitled.designcraft"), PathBuf::from("/d/Brochure.idml.designcraft")); - assert_eq!(save("/d/Brochure.designcraft", "Untitled.designcraft"), PathBuf::from("/d/Brochure.designcraft")); - assert_eq!(save("/d/Brochure", "Untitled.pdf"), PathBuf::from("/d/Brochure.pdf")); - 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/clippy.toml b/clippy.toml index 3522a15b6..7b45b7aca 100644 --- a/clippy.toml +++ b/clippy.toml @@ -4,5 +4,7 @@ allow-panic-in-tests = true allow-indexing-slicing-in-tests = true disallowed-types = [ { path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" }, + { path = "rfd::FileDialog", reason = "use DesignApp::ask_open/ask_save (craft-dialogs): a blocking dialog inside the frame aborts the app on macOS" }, + { path = "rfd::MessageDialog", reason = "use craft-dialogs' ask_message: a blocking dialog inside the frame aborts the app on macOS" }, ] cognitive-complexity-threshold = 30 diff --git a/crates/engine/src/tests/file_guard.rs b/crates/engine/src/tests/file_guard.rs index d27a13b79..f62e5c338 100644 --- a/crates/engine/src/tests/file_guard.rs +++ b/crates/engine/src/tests/file_guard.rs @@ -34,6 +34,10 @@ const ALLOWED: &[(&str, &str)] = &[ "fonts/src/fontdb/system.rs", "the installed-font scan reads the platform's fixed font folders (`system_font_dirs`: no command parameter or document field names one), and `load_document_fonts` asks the caller's `allow` check about its folder and each font file before opening either", ), + ( + "ui-egui/src/app/file_dialogs.rs", + "the host's own file dialogs: it only tests whether a path the user picked in the system dialog exists (the replace-file question, the folder a Save dialog opens in), then re-runs the command with that path, which the engine checks as usual", + ), ( "mcp/src/tools.rs", "`screenshot` reads back the PNG the connected app just wrote. Connected mode cannot be combined with the roots (the CLI refuses the pair), and the app's own engine checks the path it writes", diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 2ada1d0e9..ffe855f4a 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -49,3 +49,6 @@ toml = "1" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] craft-update = { workspace = true } +# File and message dialogs that never block the frame; the browser build uses its own path. +craft-dialogs = { workspace = true } +raw-window-handle = { workspace = true } diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 85c9264ec..383362594 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -13,6 +13,7 @@ use super::{ mod clipboard; mod commands; mod control; +pub(crate) mod file_dialogs; mod files; mod frame; mod ime; @@ -34,8 +35,18 @@ impl DesignApp { // The app owns the monotonic origin for recovery timing and frame pacing; `now_ms` itself // is process-global so screenshot deadlines stay comparable across hosts. let now = now_ms() / 1000.0; + #[cfg(not(target_arch = "wasm32"))] + let dialog_wake = std::sync::Arc::new(std::sync::OnceLock::new()); DesignApp { pending_pdf: None, + #[cfg(not(target_arch = "wasm32"))] + file_dialogs: file_dialogs::new_dialogs(dialog_wake.clone()), + #[cfg(not(target_arch = "wasm32"))] + asked: Vec::new(), + #[cfg(not(target_arch = "wasm32"))] + held_answers: Vec::new(), + #[cfg(not(target_arch = "wasm32"))] + dialog_wake, session, ui: UiState::default(), services, diff --git a/crates/ui-egui/src/app/file_dialogs.rs b/crates/ui-egui/src/app/file_dialogs.rs new file mode 100644 index 000000000..0d3a2b4da --- /dev/null +++ b/crates/ui-egui/src/app/file_dialogs.rs @@ -0,0 +1,605 @@ +//! File and message dialogs that never block the frame (craft-dialogs). +//! +//! A blocking dialog runs the platform's modal loop inside the frame. On macOS an event that +//! arrives meanwhile (a file dragged from Finder and held over the window) re-enters winit's +//! handler and aborts the app. So a command that needs a path asks for it with +//! [`DesignApp::ask_open`] or [`DesignApp::ask_save`] and returns; the host shows the dialog at the +//! end of the frame ([`DesignApp::show_file_dialogs`]), and the continuation runs with the chosen +//! path on a later frame. A command given a path (control channel, scripts, tests) never asks. +//! +//! The browser build has no native dialogs: Open goes through `Services::open_async` and the +//! inbox, and Save hands the suggested name to `Services::download`. + +use super::{DesignApp, Value, json}; + +/// What runs with the chosen path, on the frame the answer arrives. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) type Then = Box Result>; + +/// Whether an answer still needs the document that was active when the dialog was asked for. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum Bound { + /// The continuation edits or reads the active document: it runs in that document, and not + /// at all once it has closed. + Document, + /// The continuation doesn't depend on a document (opening one, a library, a book). + App, +} + +/// Which dialog was asked for, by whom and for which document. Asking again with all three the +/// same (File › Open chosen twice) brings the open panel to the front instead of queueing a +/// second one; anything else queues. +#[cfg(not(target_arch = "wasm32"))] +#[derive(Clone, PartialEq)] +pub(crate) struct AskKey { + request: craft_dialogs::Request, + /// The document active when the dialog was asked for, when the answer depends on it. + doc: Option, + /// The code that asked (`#[track_caller]` through the asking helpers): Place, Place with + /// Options and Choose Image share one dialog but not what they do with its answer. + origin: &'static std::panic::Location<'static>, +} + +/// A dialog's continuation: what it was asked for and what its answer does. +#[cfg(not(target_arch = "wasm32"))] +pub struct Asked { + key: AskKey, + /// The egui dialog open when it was asked for (Color Settings, Find): its answer may update or + /// replace that one, but waits while any other is open. + dialog: Option, + step: Step, +} + +/// An answer waiting for the egui dialog opened since it was asked for to close, so it doesn't +/// replace it (an unsaved-changes question, Preferences with unsaved edits). +#[cfg(not(target_arch = "wasm32"))] +pub(crate) struct Held { + doc: Option, + dialog: Option, + then: Then, + path: String, +} + +#[cfg(not(target_arch = "wasm32"))] +enum Step { + /// Open: run `then` with the file chosen. + Open(Then), + /// Save: `ext` is added to a name typed without it, then `then` runs, after the replace-file + /// question when the added extension names a file that exists. + Save { ext: Option, then: Then }, + /// The replace-file question about `path`: Yes runs `then` with it. + Replace { path: String, then: Then }, +} + +#[cfg(not(target_arch = "wasm32"))] +pub type Dialogs = craft_dialogs::Dialogs; + +/// A dialog that can't show (no window, no platform dialog) and the reason. +#[cfg(not(target_arch = "wasm32"))] +const DIALOG_UNAVAILABLE: &str = "The file dialog couldn't be shown"; + +/// The browser build has no dialog for this purpose. +#[cfg(target_arch = "wasm32")] +const NO_PICKER: &str = "needs a file picker"; + +/// The dialogs, with a wake-up that repaints once the first frame has given it the context. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn new_dialogs(ctx: std::sync::Arc>) -> Dialogs { + craft_dialogs::Dialogs::new(std::sync::Arc::new(move || { + if let Some(ctx) = ctx.get() { + ctx.request_repaint(); + } + })) +} + +impl DesignApp { + /// Ask for a file to open for `purpose` (`open`, `place`, `relink`…: it sets the file types). + /// `then` runs with its path on a later frame; Cancel runs nothing. + #[track_caller] + pub(crate) fn ask_open( + &mut self, + purpose: &str, + bound: Bound, + then: impl FnOnce(&mut DesignApp, String) -> Result + 'static, + ) -> Result { + #[cfg(not(target_arch = "wasm32"))] + { + let mut dialog = craft_dialogs::FileDialog::open(); + for filter in open_filters(purpose) { + dialog = dialog.filter(filter.name, filter.extensions); + } + self.ask_file(dialog, bound, Step::Open(Box::new(then))); + Ok(Value::Null) + } + #[cfg(target_arch = "wasm32")] + { + let _ = (purpose, bound, then); + Err(NO_PICKER.into()) + } + } + + /// Ask where to save `name`: beside the active document once it has been saved, else in the + /// folder of the last file saved or exported (#1653). `then` runs with the path on a later + /// frame; Cancel runs nothing. In a browser the name is the download's and `then` runs now. + #[track_caller] + pub(crate) fn ask_save( + &mut self, + name: &str, + bound: Bound, + then: impl FnOnce(&mut DesignApp, String) -> Result + 'static, + ) -> Result { + if self.services.download.is_some() { + return then(self, name.to_string()); + } + #[cfg(not(target_arch = "wasm32"))] + { + let suggested = self.suggested_save_path(name); + let (folder, name) = save_dialog_start(&suggested); + let ext = suggested_extension(&name).map(str::to_string); + let mut dialog = craft_dialogs::FileDialog::save().file_name(name.as_str()); + if let Some(folder) = folder { + dialog = dialog.directory(folder); + } + if let Some(ext) = &ext { + dialog = dialog.filter(ext.to_ascii_uppercase(), &[ext.as_str()]); + } + self.ask_file(dialog, bound, Step::Save { ext, then: Box::new(then) }); + Ok(Value::Null) + } + #[cfg(target_arch = "wasm32")] + { + let _ = (bound, then); + Err(NO_PICKER.into()) + } + } + + /// `name` after the folder a save dialog starts in. Only the last part of a name goes after + /// the folder: a title may hold a slash, and an empty name would make the folder itself the + /// suggested file. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn suggested_save_path(&self, name: &str) -> String { + use std::path::{Path, PathBuf}; + 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)); + match (folder, Path::new(name).file_name()) { + (Some(f), Some(file)) => f.join(file).to_string_lossy().to_string(), + _ => name.to_string(), + } + } + + /// Queues `dialog` unless the same code already asked for the same one for the same document + /// and it is still showing or waiting: choosing File › Open again while its panel is up brings + /// that panel to the front instead of showing a second one. + #[cfg(not(target_arch = "wasm32"))] + #[track_caller] + fn ask_file(&mut self, dialog: craft_dialogs::FileDialog, bound: Bound, step: Step) { + let key = + AskKey { request: craft_dialogs::Request::File(dialog.clone()), doc: self.bound_doc(bound), origin: std::panic::Location::caller() }; + if self.asked.contains(&key) { + self.file_dialogs.raise(); + return; + } + self.asked.push(key.clone()); + let dialog_open = self.ui.dialog.as_ref().map(|d| d.id.clone()); + self.file_dialogs.ask(dialog, Asked { key, dialog: dialog_open, step }); + } + + /// A dialog is showing or waiting to show, or its answer hasn't run yet. + pub(crate) fn file_dialogs_pending(&self) -> bool { + #[cfg(not(target_arch = "wasm32"))] + return !self.asked.is_empty() || !self.held_answers.is_empty(); + #[cfg(target_arch = "wasm32")] + false + } + + #[cfg(not(target_arch = "wasm32"))] + fn bound_doc(&self, bound: Bound) -> Option { + match bound { + Bound::Document => self.session.active().map(|d| d.uid), + Bound::App => None, + } + } + + /// Shows the next dialog asked for. The host calls it at the end of every frame with its + /// window: on macOS Save and message boxes are sheets on it. + #[cfg(not(target_arch = "wasm32"))] + pub fn show_file_dialogs(&mut self, parent: Option<&W>) { + self.file_dialogs.show_pending(parent); + } + + /// Runs the continuations of the dialogs that have answered, and of answers held until the + /// egui dialog in their way has closed. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn handle_file_dialog_answers(&mut self) { + use craft_dialogs::{Answer, MessageResult}; + for held in std::mem::take(&mut self.held_answers) { + self.continue_in(held); + } + for (asked, answer) in self.file_dialogs.take_answers() { + let Asked { key, dialog, step } = asked; + if let Some(i) = self.asked.iter().position(|k| *k == key) { + self.asked.remove(i); + } + let doc = key.doc; + match (step, answer) { + (_, Answer::Unavailable(why)) => self.status_error(DIALOG_UNAVAILABLE, why), + (Step::Open(then), Answer::Paths(paths)) => { + if let Some(path) = paths.into_iter().next() { + self.continue_in(Held { doc, dialog, then, path: path.to_string_lossy().to_string() }); + } + } + (Step::Save { ext, then }, Answer::Paths(paths)) => { + let Some(picked) = paths.into_iter().next() else { continue }; + let path = picked_save_path(picked.clone(), ext.as_deref()); + if path != picked && path.exists() { + self.ask_replace(key, dialog, path, then); + } else { + self.saved_to(Held { doc, dialog, then, path: path.to_string_lossy().to_string() }); + } + } + (Step::Replace { path, then }, Answer::Message(MessageResult::Yes)) => self.saved_to(Held { doc, dialog, then, path }), + // Cancel, or No to replacing the file: nothing happens. + _ => {} + } + } + } + + /// The extension added to the name typed names another file than the one the dialog checked, + /// so replacing an existing one asks again. + #[cfg(not(target_arch = "wasm32"))] + fn ask_replace(&mut self, key: AskKey, dialog: Option, path: std::path::PathBuf, then: Then) { + let lang = &self.ui.language; + let file = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + let message = craft_dialogs::Message { + level: craft_dialogs::MessageLevel::Warning, + title: crate::i18n::tr(lang, "Replace File?").to_string(), + description: crate::i18n::tr(lang, "“{file}” already exists. Do you want to replace it?").replace("{file}", &file), + buttons: craft_dialogs::MessageButtons::YesNo, + }; + let key = AskKey { request: craft_dialogs::Request::Message(message.clone()), ..key }; + self.asked.push(key.clone()); + let path = path.to_string_lossy().to_string(); + self.file_dialogs.ask_message(message, Asked { key, dialog, step: Step::Replace { path, then } }); + } + + /// A save path chosen: its folder is where the next save dialog starts. + #[cfg(not(target_arch = "wasm32"))] + fn saved_to(&mut self, answer: Held) { + if let Some(folder) = folder_of(&answer.path) { + self.ui.last_save_folder = Some(folder.to_string_lossy().to_string()); + } + self.continue_in(answer); + } + + /// Runs the answer's continuation in the document the dialog was asked for, made active again + /// if the user moved to another meanwhile; nothing runs once it has closed. While an egui + /// dialog opened since the asking is up, the answer waits for it to close. + #[cfg(not(target_arch = "wasm32"))] + fn continue_in(&mut self, answer: Held) { + if self.ui.dialog.as_ref().is_some_and(|d| Some(&d.id) != answer.dialog.as_ref()) { + self.held_answers.push(answer); + return; + } + let Held { doc, then, path, .. } = answer; + if let Some(uid) = doc { + let Some(index) = self.session.documents().iter().position(|d| d.uid == uid) else { + self.status(crate::i18n::tr(&self.ui.language, "Cancelled: the document the dialog was for has closed")); + return; + }; + if self.session.active().map(|d| d.uid) != Some(uid) + && let Err(e) = self.run("file.activate", json!({"index": index})) + { + self.status(e); + return; + } + } + // As for a UI command: a dialog that replaces Find releases its scope. + let was_find = self.ui.dialog.as_ref().is_some_and(|d| d.id == "findChange"); + if let Err(e) = then(self, path) { + self.status(e); + } + if was_find && self.ui.dialog.as_ref().is_none_or(|d| d.id != "findChange") { + self.session.reset_find_scope(); + } + } +} + +/// `p` with `key` set to the chosen path: a command re-run with the answer to its dialog. +pub(crate) fn with_path(p: &Value, key: &str, path: String) -> Value { + let mut q = if p.is_object() { p.clone() } else { json!({}) }; + q[key] = json!(path); + q +} + +#[cfg(not(target_arch = "wasm32"))] +fn folder_of(path: &str) -> Option { + std::path::Path::new(path).parent().filter(|f| !f.as_os_str().is_empty()).map(std::path::Path::to_path_buf) +} + +/// One file-type row in an open dialog. +#[cfg(not(target_arch = "wasm32"))] +struct OpenFilter { + name: &'static str, + extensions: &'static [&'static str], +} + +#[cfg(not(target_arch = "wasm32"))] +const GRAPHICS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "psd", "svg", "pdf", "ai", "eps"]; + +#[cfg(not(target_arch = "wasm32"))] +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 (JSON, InDesign text)", extensions: &["json", "txt"] }], + "icc" => &[OpenFilter { name: "ICC profile", extensions: &["icc", "icm"] }], + "book" => &[OpenFilter { name: "Book", extensions: &["dcbook"] }], + "xml" => &[OpenFilter { name: "XML", extensions: &["xml"] }], + "dtd" => &[OpenFilter { name: "DTD", extensions: &["dtd"] }], + "library" => &[OpenFilter { name: "Object Library", extensions: &["dclib"] }], + "relink" => &[OpenFilter { name: "Graphics", extensions: GRAPHICS }], + "dataMerge" => &[OpenFilter { name: "Data source (CSV, TSV, text, Excel)", extensions: &["csv", "tsv", "tab", "txt", "xlsx"] }], + "place" => &[ + OpenFilter { + name: "Graphics and text", + extensions: &[ + "png", + "jpg", + "jpeg", + "gif", + "webp", + "tif", + "tiff", + "bmp", + "psd", + "svg", + "pdf", + "ai", + "eps", + "txt", + "docx", + "rtf", + "md", + "xlsx", + "idml", + "designcraft", + "mp4", + "m4v", + "mov", + "webm", + "mp3", + "m4a", + "wav", + "ogg", + ], + }, + OpenFilter { name: "Graphics", extensions: GRAPHICS }, + OpenFilter { name: "Text (Word, RTF, plain, Excel)", extensions: &["docx", "rtf", "txt", "md", "xlsx"] }, + OpenFilter { name: "Video and sound", extensions: &["mp4", "m4v", "mov", "webm", "mp3", "m4a", "wav", "ogg"] }, + ], + _ => &[ + OpenFilter { name: "DesignCraft, IDML or InDesign", extensions: &["designcraft", "idml", "indd", "indt"] }, + OpenFilter { name: "DesignCraft", extensions: &["designcraft"] }, + OpenFilter { name: "InDesign Markup (IDML)", extensions: &["idml"] }, + OpenFilter { name: "InDesign document or template (INDD, INDT)", extensions: &["indd", "indt"] }, + // Files saved without an extension; `file.open` tells the formats apart by content. + OpenFilter { name: "All files", extensions: &[ALL_FILES] }, + ], + } +} + +/// The "All files" pattern. The macOS panel merges every filter into one list of allowed types, +/// which takes type identifiers as well as extensions: `public.data` admits any file. +#[cfg(not(target_arch = "wasm32"))] +const ALL_FILES: &str = if cfg!(target_os = "macos") { "public.data" } else { "*" }; + +/// The extension of the file name a save dialog suggests (`Brochure.designcraft`), if it has one. +/// The dialog filters on it, so the macOS and Windows dialogs add it to a name typed without it. +#[cfg(not(target_arch = "wasm32"))] +fn suggested_extension(name: &str) -> Option<&str> { + std::path::Path::new(name).extension().and_then(std::ffi::OsStr::to_str).filter(|e| !e.is_empty() && !e.contains(char::is_whitespace)) +} + +/// The file a save dialog's answer writes: `picked` with the suggested extension added when it has +/// none (Linux dialogs don't add it). A document always ends in `.designcraft`, as `file.save` +/// writes it. +#[cfg(not(target_arch = "wasm32"))] +fn picked_save_path(picked: std::path::PathBuf, ext: Option<&str>) -> std::path::PathBuf { + let Some(ext) = ext else { return picked }; + if ext.eq_ignore_ascii_case("designcraft") { + let p = picked.to_string_lossy(); + return designcraft_engine::cmd::with_document_extension(&p).into(); + } + if picked.extension().is_some() || picked.file_name().is_none() { + return picked; + } + let mut s = picked.into_os_string(); + s.push("."); + s.push(ext); + 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. +#[cfg(not(target_arch = "wasm32"))] +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) +} + +#[cfg(test)] +pub(crate) mod testing { + use craft_dialogs::{Answer, MessageResult, Request}; + + use super::DesignApp; + + /// No window: dialogs answered by an override need none. + pub(crate) struct NoWindow; + + impl raw_window_handle::HasWindowHandle for NoWindow { + fn window_handle(&self) -> Result, raw_window_handle::HandleError> { + Err(raw_window_handle::HandleError::Unavailable) + } + } + + impl raw_window_handle::HasDisplayHandle for NoWindow { + fn display_handle(&self) -> Result, raw_window_handle::HandleError> { + Err(raw_window_handle::HandleError::Unavailable) + } + } + + /// Every file dialog answers `path` (`None`: Cancel); every message box answers Yes. + pub(crate) fn answer_with(path: Option) -> impl FnMut(&Request) -> Answer + 'static { + move |request| match (request, &path) { + (Request::Message(_), _) => Answer::Message(MessageResult::Yes), + (Request::File(_), Some(path)) => Answer::Paths(vec![path.into()]), + (Request::File(_), None) => Answer::Cancelled, + } + } + + impl DesignApp { + /// From now on, `answer` answers every dialog instead of a native one. + pub(crate) fn answer_dialogs(&mut self, answer: impl FnMut(&Request) -> Answer + 'static) { + self.file_dialogs.set_override(Some(Box::new(answer))); + } + + /// Shows the dialogs asked for (the override answers them) and runs their answers, until + /// none is left: what the frames after the asking one do. + pub(crate) fn settle_dialogs(&mut self) { + for _ in 0..8 { + self.show_file_dialogs(None::<&NoWindow>); + self.handle_file_dialog_answers(); + if !self.file_dialogs.is_open() && self.file_dialogs.queued() == 0 { + return; + } + } + } + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::testing::answer_with; + use super::{ALL_FILES, open_filters, picked_save_path, save_dialog_start, suggested_extension}; + + #[test] + fn relink_open_dialog_lists_graphics() { + let exts: Vec<&str> = open_filters("relink").iter().flat_map(|filter| filter.extensions.iter().copied()).collect(); + for ext in ["png", "jpg", "tif", "psd", "svg", "pdf", "ai", "eps"] { + assert!(exts.contains(&ext), "{ext} is missing from the relink dialog: {exts:?}"); + } + assert!(!exts.contains(&"designcraft") && !exts.contains(&"idml")); + } + + #[test] + fn data_merge_open_dialog_lists_table_extensions() { + let exts: Vec<&str> = open_filters("dataMerge").iter().flat_map(|filter| filter.extensions.iter().copied()).collect(); + for ext in ["csv", "tsv", "tab", "txt", "xlsx"] { + assert!(exts.contains(&ext), "{ext} is missing from the dataMerge dialog: {exts:?}"); + } + assert!(!exts.iter().any(|ext| *ext == "designcraft" || *ext == "idml"), "dataMerge must not fall through to the document filters: {exts:?}"); + let place = open_filters("place"); + assert!(place.len() > 1, "the place dialog keeps a filter for each kind of file"); + assert!(place.iter().any(|filter| filter.extensions.contains(&"png"))); + let documents = open_filters(""); + for ext in ["designcraft", "idml", "indd", "indt"] { + assert!(documents.first().is_some_and(|filter| filter.extensions.contains(&ext)), "{ext} is missing from the first Open filter"); + } + assert!(documents.last().is_some_and(|filter| filter.extensions == [ALL_FILES]), "files without an extension can be picked"); + } + + #[test] + fn save_dialog_answers_get_the_suggested_extension() { + let save = |picked: &str, name: &str| picked_save_path(PathBuf::from(picked), suggested_extension(name)); + assert_eq!(save("/d/Brochure", "Untitled.designcraft"), PathBuf::from("/d/Brochure.designcraft")); + assert_eq!(save("/d/Brochure.idml", "Untitled.designcraft"), PathBuf::from("/d/Brochure.idml.designcraft")); + assert_eq!(save("/d/Brochure.designcraft", "Untitled.designcraft"), PathBuf::from("/d/Brochure.designcraft")); + assert_eq!(save("/d/Brochure", "Untitled.pdf"), PathBuf::from("/d/Brochure.pdf")); + 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!(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!(save_dialog_start(&gone.to_string_lossy()), (None, "Brochure.pdf".to_string()), "a folder deleted since: the system's own"); + assert_eq!(save_dialog_start("Untitled 1.2 Folder"), (None, "Untitled 1.2 Folder".to_string()), "a bare name: the system's own"); + } + + /// Place, Graphic Cell and Choose Image share the Place dialog, but each keeps its own + /// request, as does Place asked for another document; Place asked again for the same one + /// doesn't queue a second dialog. + #[test] + fn different_askers_and_documents_queue_their_own_dialogs() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.answer_dialogs(answer_with(None)); + app.run("file.new", serde_json::json!({})).unwrap(); + app.run("app.placeDialog", serde_json::json!({})).unwrap(); + app.run("app.graphicCell", serde_json::json!({})).unwrap(); + assert_eq!(app.file_dialogs.queued(), 2); + app.run("file.new", serde_json::json!({})).unwrap(); + app.run("app.placeDialog", serde_json::json!({})).unwrap(); + assert_eq!(app.file_dialogs.queued(), 3); + app.run("app.placeDialog", serde_json::json!({})).unwrap(); + assert_eq!(app.file_dialogs.queued(), 3); + } + + /// An answer that arrives while an egui dialog opened since is up (Preferences, an + /// unsaved-changes question) waits for it to close instead of replacing it. + #[test] + fn an_answer_waits_for_a_dialog_opened_after_the_asking() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("Held.designcraft"); + let mut source = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + source.run("file.new", serde_json::json!({})).unwrap(); + source.run("file.saveAs", serde_json::json!({"path": path.to_string_lossy()})).unwrap(); + + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.answer_dialogs(answer_with(Some(path.to_string_lossy().to_string()))); + app.run("app.openDialog", serde_json::json!({})).unwrap(); + app.ui.dialog = Some(crate::dialogs::Dialog::new("preferences", serde_json::json!({}))); + app.settle_dialogs(); + assert!(app.session.documents().is_empty(), "the answer waits"); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("preferences")); + assert!(app.file_dialogs_pending(), "Quit still sees the answer coming"); + app.ui.dialog = None; + app.handle_file_dialog_answers(); + assert_eq!(app.session.documents().len(), 1); + assert!(!app.file_dialogs_pending()); + } + + /// File › Open without a path asks and returns at once; the document opens on a later frame, + /// when the dialog's answer arrives. The frame never waits for the dialog. + #[test] + fn file_open_without_a_path_opens_the_chosen_document_when_the_dialog_answers() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("Chosen.designcraft"); + let mut source = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + source.run("file.new", serde_json::json!({})).unwrap(); + source.run("file.saveAs", serde_json::json!({"path": path.to_string_lossy()})).unwrap(); + + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.answer_dialogs(answer_with(Some(path.to_string_lossy().to_string()))); + app.run("app.openDialog", serde_json::json!({})).unwrap(); + assert!(app.session.documents().is_empty(), "nothing opens before the dialog has answered"); + // Asking again while the dialog is up doesn't queue a second one. + app.run("app.openDialog", serde_json::json!({})).unwrap(); + assert_eq!(app.file_dialogs.queued(), 1); + app.settle_dialogs(); + assert_eq!(app.session.documents().len(), 1, "the chosen document opened once"); + assert_eq!(app.session.active().and_then(|d| d.path.clone()).map(PathBuf::from), Some(path)); + } +} diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 916a47317..49d2bd757 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -1,4 +1,4 @@ -use super::{DesignApp, ImportRequest, ImportedFile, Value, canvas, dialogs, json, shortcut_import}; +use super::{DesignApp, ImportRequest, ImportedFile, Value, canvas, dialogs, file_dialogs, json, shortcut_import}; #[cfg(test)] use super::{Services, Session}; #[cfg(test)] @@ -7,82 +7,59 @@ use crate::Inbox; use crate::test_window; impl DesignApp { - /// 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)); - // Only the last part of a name goes after the folder: a title may hold a slash, and an - // empty name would make the folder itself the suggested file. - let file = Path::new(name).file_name(); - let suggested = match (folder, file) { - (Some(f), Some(file)) => f.join(file).to_string_lossy().to_string(), - _ => name.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()); + /// Ask the host for a file to open (`open`) or place (`place`). A native dialog answers on a + /// later frame ([`Self::open_picked`]); in a browser the file arrives through the inbox. + #[track_caller] + pub fn pick_and_open(&mut self, purpose: &str) -> Result { + if let Some(open) = self.services.open_async.as_mut() { + let request = ImportRequest { purpose: purpose.into(), target: self.session.active().map(|d| d.uid) }; + open(request); + return Ok(Value::Null); } - Some(picked) + let bound = if purpose == "place" { file_dialogs::Bound::Document } else { file_dialogs::Bound::App }; + let purpose_owned = purpose.to_string(); + self.ask_open(purpose, bound, move |app, path| app.open_picked(&purpose_owned, path)) } - /// Ask the host for a file to open (`open`) or place (`place`). Synchronous pickers run the - /// command right away; asynchronous ones (web) deliver the file through the inbox. - pub fn pick_and_open(&mut self, purpose: &str) -> Result { - let target = self.session.active().map(|d| d.uid); - if let Some(pick) = self.services.pick_open.as_mut() { - return match pick(purpose) { - // Image Import Options: a multi-page PDF asks which page. - Some(path) if purpose == "place" && path.to_ascii_lowercase().ends_with(".pdf") => { - let pages = - self.services.read.as_mut().and_then(|r| r(&path).ok()).and_then(|b| designcraft_render::pdf_page_count(&b)).unwrap_or(1); - if pages > 1 { - self.ui.dialog = - Some(dialogs::Dialog::new("pdfImport", json!({"path": path, "page": "1", "pages": pages, "target": target}))); - Ok(Value::Null) - } else { - self.place_file(json!({"path": path})) - } - } - Some(path) if purpose == "place" => self.place_file(json!({"path": path})), - Some(path) => self.open_file("file.open", json!({"path": path})), - None => Ok(Value::Null), - }; + /// Open (`open`) or place (`place`) the file at `path`, chosen in a dialog or passed by a script. + pub(crate) fn open_picked(&mut self, purpose: &str, path: String) -> Result { + // Image Import Options: a multi-page PDF asks which page. + if purpose == "place" && path.to_ascii_lowercase().ends_with(".pdf") { + let target = self.session.active().map(|d| d.uid); + let pages = self.services.read.as_mut().and_then(|r| r(&path).ok()).and_then(|b| designcraft_render::pdf_page_count(&b)).unwrap_or(1); + if pages > 1 { + self.ui.dialog = Some(dialogs::Dialog::new("pdfImport", json!({"path": path, "page": "1", "pages": pages, "target": target}))); + return Ok(Value::Null); + } + return self.place_file(json!({"path": path})); } - let request = self.import_request(purpose); - if let Some(open) = self.services.open_async.as_mut() { - open(request); + if purpose == "place" { + return self.place_file(json!({"path": path})); } - Ok(Value::Null) + self.open_file("file.open", json!({"path": path})) } /// Load Styles: ask the host for a document whose styles to load; `kind` is `paragraph`, - /// `character` or empty for both. A synchronous picker opens the dialog that chooses among - /// the file's styles; an asynchronous one (web) loads them all when the file arrives. + /// `character` or empty for both. A native dialog's answer opens the dialog that chooses + /// among the file's styles; in a browser they all load when the file arrives. + #[track_caller] pub fn pick_styles(&mut self, kind: &str) -> Result { designcraft_engine::cmd::has_doc(&self.session)?; - if let Some(pick) = self.services.pick_open.as_mut() { - let Some(path) = pick("styles") else { return Ok(Value::Null) }; - let found = self.run("style.load.list", json!({"path": path}))?; + if let Some(open) = self.services.open_async.as_mut() { + let purpose = if kind.is_empty() { "styles".to_string() } else { format!("styles:{kind}") }; + open(ImportRequest { purpose, target: self.session.active().map(|d| d.uid) }); + return Ok(Value::Null); + } + let kind = kind.to_string(); + self.ask_open("styles", file_dialogs::Bound::Document, move |app, path| { + let found = app.run("style.load.list", json!({"path": path}))?; let of = |k: &str| if kind.is_empty() || kind == k { found[k].clone() } else { json!([]) }; - self.ui.dialog = Some(dialogs::Dialog::new( + app.ui.dialog = Some(dialogs::Dialog::new( "loadStyles", json!({"path": path, "paragraph": of("paragraph"), "character": of("character"), "conflicts": "redefine"}), )); - return Ok(Value::Null); - } - let request = self.import_request(&if kind.is_empty() { "styles".to_string() } else { format!("styles:{kind}") }); - if let Some(open) = self.services.open_async.as_mut() { - open(request); - } - Ok(Value::Null) + Ok(Value::Null) + }) } pub fn import_request(&self, purpose: &str) -> ImportRequest { @@ -380,8 +357,7 @@ mod import_warning_tests { std::fs::write(&path, idml(true)).unwrap(); let picked = path.to_string_lossy().to_string(); let mut app = app(); - app.services.pick_open = Some(Box::new(move |_| Some(picked.clone()))); - let result = app.run("app.openDialog", json!({})); + let result = app.run("app.openDialog", json!({"path": picked})); std::fs::remove_file(&path).unwrap(); std::fs::remove_dir(path.parent().unwrap()).unwrap(); assert_eq!(result.unwrap()["warnings"].as_array().unwrap().len(), 2); @@ -687,9 +663,10 @@ pub(crate) mod browser_import_tests { let path = std::env::temp_dir().join(format!("designcraft-native-import-{}-{}.pdf", std::process::id(), app.session.doc().unwrap().uid)); std::fs::write(&path, pdf()).unwrap(); let pick_path = path.to_string_lossy().to_string(); - app.services.pick_open = Some(Box::new(move |_| Some(pick_path.clone()))); + app.answer_dialogs(crate::app::file_dialogs::testing::answer_with(Some(pick_path))); app.services.read = Some(Box::new(|path| std::fs::read(path).map_err(|e| e.to_string()))); app.pick_and_open("place").unwrap(); + app.settle_dialogs(); assert_eq!(app.ui.dialog.as_ref().unwrap().id, "pdfImport"); app.run("file.new", json!({"title": "B"})).unwrap(); app.ui.dialog.as_mut().unwrap().fields.insert("page".into(), json!("2")); @@ -744,8 +721,8 @@ mod load_styles_tests { let path = std::env::temp_dir().join(format!("designcraft-load-styles-{}-{test}.designcraft", std::process::id())); std::fs::write(&path, client_bytes()).unwrap(); let picked = path.to_string_lossy().to_string(); - let services = Services { pick_open: Some(Box::new(move |purpose| (purpose == "styles").then(|| picked.clone()))), ..Default::default() }; - let mut app = DesignApp::new(Session::new(), services); + let mut app = DesignApp::new(Session::new(), Services::default()); + app.answer_dialogs(crate::app::file_dialogs::testing::answer_with(Some(picked))); app.run("file.new", json!({})).unwrap(); app.run("style.paragraph.create", json!({"name": "Body", "chars": {"size": 8}})).unwrap(); (app, path) @@ -760,6 +737,7 @@ mod load_styles_tests { fn load_all_text_styles_asks_which_styles_of_the_picked_file_to_load() { let (mut app, path) = app_and_file("all"); app.run("app.loadTextStyles", json!({})).unwrap(); + app.settle_dialogs(); let d = app.ui.dialog.as_mut().expect("the Load Styles dialog"); let row = |name: &str, conflict: bool| json!({"name": name, "conflict": conflict}); assert_eq!(d.id, "loadStyles"); @@ -778,6 +756,7 @@ mod load_styles_tests { fn the_dialog_applies_the_chosen_conflict_rule() { let (mut app, path) = app_and_file("rename"); app.run("app.loadParagraphStyles", json!({})).unwrap(); + app.settle_dialogs(); let d = app.ui.dialog.as_mut().unwrap(); assert_eq!(d.fields["character"], json!([]), "Load Paragraph Styles offers no character styles"); assert_eq!(d.fields["conflicts"], "redefine"); @@ -792,6 +771,7 @@ mod load_styles_tests { fn load_character_styles_offers_only_character_styles() { let (mut app, path) = app_and_file("character"); app.run("app.loadCharacterStyles", json!({})).unwrap(); + app.settle_dialogs(); assert_eq!(app.ui.dialog.as_ref().unwrap().fields["paragraph"], json!([])); dialogs::confirm(&mut app).unwrap(); std::fs::remove_file(&path).unwrap(); @@ -820,18 +800,22 @@ mod load_styles_tests { fn no_file_or_no_document_opens_no_dialog() { let (mut app, path) = app_and_file("none"); std::fs::remove_file(&path).unwrap(); - // The picked file can't be read. - assert!(app.run("app.loadTextStyles", json!({})).is_err()); + // The picked file can't be read: the status line says so. + app.run("app.loadTextStyles", json!({})).unwrap(); + app.settle_dialogs(); assert!(app.ui.dialog.as_ref().is_none_or(|d| d.id != "loadStyles")); + assert!(!app.ui.status.is_empty()); // The picker is cancelled. app.ui.dialog = None; - app.services.pick_open = Some(Box::new(|_| None)); + app.answer_dialogs(crate::app::file_dialogs::testing::answer_with(None)); app.run("app.loadTextStyles", json!({})).unwrap(); + app.settle_dialogs(); assert!(app.ui.dialog.is_none()); // No document: the picker isn't shown at all. app.run("file.close", json!({})).unwrap(); - app.services.pick_open = Some(Box::new(|_| panic!("asked for a file without a document"))); + app.answer_dialogs(|_| panic!("asked for a file without a document")); assert!(app.run("app.loadTextStyles", json!({})).is_err()); + app.settle_dialogs(); } #[test] diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index 66274d709..ceeb28743 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -27,7 +27,11 @@ impl DesignApp { self.update_screenshot_epoch(ctx); self.drain_control(ctx); updates_ui::tick(self, ctx); + #[cfg(not(target_arch = "wasm32"))] + let _ = self.dialog_wake.set(ctx.clone()); if self.fonts_ready && !self.host_order_pending() { + #[cfg(not(target_arch = "wasm32"))] + self.handle_file_dialog_answers(); self.drain_inbox(); self.open_os_documents(); } diff --git a/crates/ui-egui/src/app/quit.rs b/crates/ui-egui/src/app/quit.rs index 7e3686ac6..c1c67a493 100644 --- a/crates/ui-egui/src/app/quit.rs +++ b/crates/ui-egui/src/app/quit.rs @@ -49,10 +49,18 @@ impl DesignApp { ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false)); ctx.send_viewport_cmd(egui::ViewportCommand::Focus); if self.quitting.is_none() { - self.quitting = Some(QuitState { original_active: self.session.active().map(|d| d.uid), accepted: HashMap::new(), pending: None }); + self.quitting = + Some(QuitState { original_active: self.session.active().map(|d| d.uid), accepted: HashMap::new(), pending: None, saving: None }); } } let Some(q) = self.quitting.as_ref() else { return }; + if q.saving.is_some() { + // Waiting for the save dialog; cancelled, it answers without saving and Quit stops. + if !self.file_dialogs_pending() { + self.cancel_quit(); + } + return; + } if let Some(uid) = q.pending { let asking = self.ui.dialog.as_ref().is_some_and(|d| { d.id == "closeDocument" @@ -116,16 +124,16 @@ impl DesignApp { return Ok(Value::Null); }; if !discard { - let saved = self.run("file.activate", json!({"index": i})).and_then(|_| self.run("app.save", json!({}))); + if let Some(q) = self.quitting.as_mut() { + q.pending = None; + q.saving = Some(uid); + } + let saved = self.run("file.activate", json!({"index": i})).and_then(|_| menus::save_then(self, move |app| app.quit_document_saved(uid))); if let Err(e) = saved { self.cancel_quit(); return Err(e); } - if self.session.documents().iter().find(|d| d.uid == uid).is_none_or(|d| d.is_dirty()) { - // No file chosen (or an asynchronous host save has not completed): stop quitting. - self.cancel_quit(); - return Ok(Value::Null); - } + return Ok(Value::Null); } if let Some(q) = self.quitting.as_mut() { if let Some(d) = self.session.documents().iter().find(|d| d.uid == uid) { @@ -136,6 +144,23 @@ impl DesignApp { Ok(Value::Null) } + /// The Save chosen during Quit has written `uid` (at once, or once its save dialog answered): + /// approve its current revision. Still unsaved, Quit stops. + fn quit_document_saved(&mut self, uid: u64) -> Result { + if self.quitting.as_ref().and_then(|q| q.saving) != Some(uid) { + return Ok(Value::Null); + } + let revision = self.session.documents().iter().find(|d| d.uid == uid).filter(|d| !d.is_dirty()).map(|d| d.revision); + match (revision, self.quitting.as_mut()) { + (Some(revision), Some(q)) => { + q.accepted.insert(uid, revision); + q.saving = None; + } + _ => self.cancel_quit(), + } + Ok(Value::Null) + } + /// Show the next unapproved document, or request a final close that revalidates all approvals. fn ask_next_unsaved(&mut self, ctx: &egui::Context) { let Some((i, uid)) = self.next_unapproved_document() else { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index c46a2d343..bb3077103 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -7,6 +7,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value, json}; use crate::DesignApp; +use crate::app::file_dialogs::{Bound, with_path}; use crate::theme::semibold; mod options; @@ -2644,18 +2645,9 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { }); check(ui, &mut d, "bpc", crate::i18n::tr(&app.ui.language, "Use Black Point Compensation")); if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Load Profile…"))).clicked() - && let Some(path) = app.services.pick_open.as_mut().and_then(|f| f("icc")) + && let Err(e) = app.ask_open("icc", Bound::App, load_color_profile) { - match app.session.execute("color.loadProfile", &json!({"path": path})) { - Ok(_) => { - // Replayed in load order at start-up, so the latest load of a path comes last. - app.ui.color_profiles.retain(|p| p != &path); - app.ui.color_profiles.push(path); - let f = app.session.execute("color.settings", &json!({})).unwrap_or_default(); - d.fields.insert("profiles".into(), f["profiles"].clone()); - } - Err(e) => app.status_error("Color Settings", e), - } + app.status_error("Color Settings", e); } } "userDictionary" => user_dictionary(app, ui, &mut d), @@ -3628,19 +3620,29 @@ fn confirm_part3(app: &mut DesignApp, d: Dialog) -> Result { } let stem = if title.is_empty() { doc_name } else { title }; let name = if stem.is_empty() { "Export.pdf".to_string() } else { format!("{stem}.pdf") }; - match app.pick_save(&name) { - Some(path) => { - params["path"] = json!(path); - crate::menus::export_pdf(app, ¶ms) - } - // The user cancelled the save dialog: export nothing. - None => Ok(Value::Null), - } + app.ask_save(&name, Bound::Document, move |app, path| crate::menus::export_pdf(app, &with_path(¶ms, "path", path))) } _ => confirm_part4(app, d), } } +/// Color Settings › Load Profile…: load the chosen profile and list it in the dialog if it is +/// still open. +fn load_color_profile(app: &mut DesignApp, path: String) -> Result { + if let Err(e) = app.session.execute("color.loadProfile", &json!({"path": path})) { + app.status_error("Color Settings", e); + return Ok(Value::Null); + } + // Replayed in load order at start-up, so the latest load of a path comes last. + app.ui.color_profiles.retain(|p| p != &path); + app.ui.color_profiles.push(path); + let f = app.session.execute("color.settings", &json!({})).unwrap_or_default(); + if let Some(d) = app.ui.dialog.as_mut().filter(|d| d.id == "colorSettings") { + d.fields.insert("profiles".into(), f["profiles"].clone()); + } + Ok(Value::Null) +} + /// One slice of the dialogs' Apply; later slices take the ids this one doesn't. fn confirm_part4(app: &mut DesignApp, d: Dialog) -> Result { match d.id.as_str() { @@ -3785,20 +3787,20 @@ fn confirm_part5(app: &mut DesignApp, d: Dialog) -> Result { if d.b("quitting") { return app.confirm_quit_document(uid.ok_or("Missing document in quit confirmation")?, d.b("discard")); } - let index = |app: &DesignApp| app.session.documents().iter().position(|doc| Some(doc.uid) == uid); + let index = move |app: &DesignApp| app.session.documents().iter().position(|doc| Some(doc.uid) == uid); let Some(i) = index(app) else { return Ok(Value::Null) }; - if !d.b("discard") { - app.run("file.activate", json!({"index": i}))?; - app.run("app.save", json!({}))?; - // Still unsaved: the save was cancelled (no file chosen). Keep the document open. - if app.session.documents().get(i).is_none_or(|doc| doc.is_dirty()) { - return Ok(Value::Null); - } - } - match index(app) { - Some(i) => app.run("file.close", json!({"index": i})), - None => Ok(Value::Null), + let discard = d.b("discard"); + // Closes the document once it is saved (or at once, for Don't Save). Still unsaved, the + // save failed: the document stays open. + let close = move |app: &mut DesignApp| match index(app) { + Some(i) if discard || app.session.documents().get(i).is_some_and(|doc| !doc.is_dirty()) => app.run("file.close", json!({"index": i})), + _ => Ok(Value::Null), + }; + if discard { + return close(app); } + app.run("file.activate", json!({"index": i}))?; + crate::menus::save_then(app, close) } "loadStyles" => { let checked = |key: &str| -> Vec<&str> { diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 173520e99..84cbf3bea 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -201,12 +201,16 @@ fn file_info_dialog_stores_the_fields_and_titles_the_pdf() { })); let names: Arc>> = Arc::new(Mutex::new(Vec::new())); let asked = names.clone(); - app.services.pick_save = Some(Box::new(move |name: &str| { - asked.lock().unwrap().push(name.to_string()); - Some("/tmp/designcraft-file-info.pdf".to_string()) - })); + app.answer_dialogs(move |request| match request { + craft_dialogs::Request::File(dialog) => { + asked.lock().unwrap().push(dialog.file_name.clone().unwrap_or_default()); + craft_dialogs::Answer::Paths(vec!["/tmp/designcraft-file-info.pdf".into()]) + } + craft_dialogs::Request::Message(_) => craft_dialogs::Answer::Message(craft_dialogs::MessageResult::Yes), + }); open_pdf_export(&mut app); confirm(&mut app).unwrap(); + app.settle_dialogs(); assert_eq!(names.lock().unwrap().as_slice(), ["flyer-v3.pdf"]); let pdf = String::from_utf8_lossy(&writes.lock().unwrap()[0]).into_owned(); assert!(pdf.contains("/Title(Spring Catalog)") && !pdf.contains("/Title(flyer-v3)"), "the PDF's title is File Info's"); @@ -214,6 +218,7 @@ fn file_info_dialog_stores_the_fields_and_titles_the_pdf() { open_pdf_export(&mut app); setup_fields(&mut app, json!({"title": "flyer-v3"})); confirm(&mut app).unwrap(); + app.settle_dialogs(); let asked = names.lock().unwrap().last().cloned().unwrap_or_default(); assert_eq!(std::path::Path::new(&asked).file_name().and_then(|n| n.to_str()), Some("flyer-v3.pdf"), "{asked}"); let pdf = String::from_utf8_lossy(&writes.lock().unwrap()[1]).into_owned(); @@ -1967,11 +1972,11 @@ fn find_scope_dialog_native_pdf_replacement_releases_bounds() { let seen_read = read.clone(); let pick_path = path.to_string_lossy().to_string(); let read_path = pick_path.clone(); - app.services.pick_open = Some(Box::new(move |purpose| { - assert_eq!(purpose, "place"); + app.answer_dialogs(move |request| { + assert_eq!(place_filter(request), Some("Graphics and text")); seen_pick.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - Some(pick_path.clone()) - })); + craft_dialogs::Answer::Paths(vec![pick_path.clone().into()]) + }); app.services.read = Some(Box::new(move |path| { assert_eq!(path, read_path); seen_read.fetch_add(1, std::sync::atomic::Ordering::SeqCst); @@ -1985,6 +1990,7 @@ fn find_scope_dialog_native_pdf_replacement_releases_bounds() { let active = (app.session.active_index(), app.session.documents().len()); // Exercise engine.execute -> UiRequest::Pick -> native host PDF replacement. scope_control(&mut app, &ctx, "engine.execute", json!({"command": "file.place", "params": {}})); + app.settle_dialogs(); assert_eq!(picked.load(std::sync::atomic::Ordering::SeqCst), 1); assert_eq!(read.load(std::sync::atomic::Ordering::SeqCst), 1); let dialog = app.ui.dialog.as_ref().unwrap(); @@ -2036,17 +2042,23 @@ fn find_scope_dialog_native_pdf_replacement_releases_bounds() { } } +/// The first file type an open dialog offers: Place's is "Graphics and text". +fn place_filter(request: &craft_dialogs::Request) -> Option<&str> { + let craft_dialogs::Request::File(dialog) = request else { return None }; + dialog.filters.first().map(|filter| filter.name.as_str()) +} + #[cfg(not(target_arch = "wasm32"))] #[test] fn find_scope_dialog_native_picker_cancel_retains_bounds() { let (mut app, ctx, screen, sid) = scope_dialog(); let picked = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); let seen = picked.clone(); - app.services.pick_open = Some(Box::new(move |purpose| { - assert_eq!(purpose, "place"); + app.answer_dialogs(move |request| { + assert_eq!(place_filter(request), Some("Graphics and text")); seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - None - })); + craft_dialogs::Answer::Cancelled + }); let read = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); let seen_read = read.clone(); app.services.read = Some(Box::new(move |_| { @@ -2062,6 +2074,7 @@ fn find_scope_dialog_native_picker_cancel_retains_bounds() { let active = (app.session.active_index(), app.session.documents().len()); let fields = app.ui.dialog.as_ref().unwrap().fields.clone(); scope_control(&mut app, &ctx, "engine.execute", json!({"command": "file.place", "params": {}})); + app.settle_dialogs(); assert_eq!(picked.load(std::sync::atomic::Ordering::SeqCst), 1); assert_eq!(read.load(std::sync::atomic::Ordering::SeqCst), 0); assert_eq!(app.ui.dialog.as_ref().unwrap().id, "findChange"); @@ -3087,7 +3100,7 @@ fn confirm_pdf_export_writes_file_and_persists_settings() { sink.lock().unwrap().push((path.to_string(), bytes.to_vec())); Ok(()) })); - app.services.pick_save = Some(Box::new(|_name: &str| Some("/tmp/designcraft-test.pdf".to_string()))); + app.answer_dialogs(crate::app::file_dialogs::testing::answer_with(Some("/tmp/designcraft-test.pdf".into()))); app.ui.dialog = Some(Dialog::new("pdfExport", json!({}))); { @@ -3099,6 +3112,7 @@ fn confirm_pdf_export_writes_file_and_persists_settings() { d.fields.insert("title".into(), json!("Report")); } confirm(&mut app).unwrap(); + app.settle_dialogs(); let got = writes.lock().unwrap(); assert_eq!(got.len(), 1, "confirming the dialog writes exactly one file"); @@ -3160,11 +3174,12 @@ fn confirm_pdf_export_cancelled_save_writes_nothing() { sink.lock().unwrap().push((path.to_string(), bytes.to_vec())); Ok(()) })); - app.services.pick_save = Some(Box::new(|_name: &str| None)); + app.answer_dialogs(crate::app::file_dialogs::testing::answer_with(None)); app.ui.dialog = Some(Dialog::new("pdfExport", json!({}))); let r = confirm(&mut app).unwrap(); assert_eq!(r, Value::Null); + app.settle_dialogs(); assert!(writes.lock().unwrap().is_empty(), "cancelling the save picker must not write"); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f25ee7ed8..0221aa257 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -61,7 +61,7 @@ use app::browser_import_tests; pub use app::opens_as_document; pub use clock::now_ms; pub use key_names::{drop_key_name_text, is_key_name}; -pub use services::{ClipboardFn, DownloadFn, Inbox, OpenAsyncFn, PickFn, ReadFn, Services, WriteFn}; +pub use services::{ClipboardFn, DownloadFn, Inbox, OpenAsyncFn, ReadFn, Services, WriteFn}; pub(crate) use state::second_window_viewport; pub use state::{CanvasCache, HiddenPanels, ImportRequest, ImportedFile, PdfExportSettings, Perf, SavedWorkspace, ScreenMode, Shown, UiState, View}; @@ -120,6 +120,19 @@ struct ShotRequest { pub struct DesignApp { pending_pdf: Option, + /// File and message dialogs: asked for during a frame, shown by the host at its end, answered + /// on a later frame (see `app/file_dialogs.rs`). + #[cfg(not(target_arch = "wasm32"))] + pub(crate) file_dialogs: app::file_dialogs::Dialogs, + /// The dialogs asked for and not yet answered, in order: the same one isn't asked twice. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) asked: Vec, + /// Dialog answers waiting for an egui dialog to close. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) held_answers: Vec, + /// The context the dialogs' wake-up repaints, once the first frame has run. + #[cfg(not(target_arch = "wasm32"))] + dialog_wake: std::sync::Arc>, pub session: Session, pub ui: UiState, pub services: Services, @@ -272,6 +285,8 @@ struct QuitState { original_active: Option, accepted: HashMap, pending: Option, + /// The document whose Save answer went to a save dialog that hasn't answered yet. + saving: Option, } /// Whether CJK-only interface is shown (Preferences › Type › Show CJK Features): the explicit diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b03e2bc38..ffcfa0988 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -31,6 +31,7 @@ pub use keys::{forwarded_shortcuts, shortcuts}; use quick_apply::quick_commands; pub use quick_apply::{QuickItem, command_search, palette, quick_apply_items}; pub use run::run_ui; +pub(crate) use run::save_then; pub use state::{ checked, default_shortcut, enabled, menu_enabled, native_menu_enabled, shortcut_label, shortcut_of, shortcut_text, tool_shortcut_label, }; diff --git a/crates/ui-egui/src/menus/export.rs b/crates/ui-egui/src/menus/export.rs index 56014c25c..2c1946d4f 100644 --- a/crates/ui-egui/src/menus/export.rs +++ b/crates/ui-egui/src/menus/export.rs @@ -1,4 +1,5 @@ use super::{DesignApp, Value, json}; +use crate::app::file_dialogs::{Bound, with_path}; pub(super) fn download_document(app: &mut DesignApp) -> Result { let name = app.session.active().map(|d| format!("{}.designcraft", d.doc.title)).ok_or("no document")?; @@ -10,9 +11,27 @@ pub(super) fn download_document(app: &mut DesignApp) -> Result { app.run("file.saveAs", json!({"path": name})) } +/// Asks where to save `name`, then runs `export` with `p` and the chosen path. +#[track_caller] +fn ask_then_export( + app: &mut DesignApp, + name: &str, + p: Value, + export: impl FnOnce(&mut DesignApp, &Value) -> Result + 'static, +) -> Result { + app.ask_save(name, Bound::Document, move |app, path| export(app, &with_path(&p, "path", path))) +} + pub(super) fn export_png(app: &mut DesignApp, p: &Value) -> Result { let st = app.session.active().ok_or("no document")?; let page = crate::control::page_index(p, crate::canvas::current_page(app).unwrap_or(0), st.doc.page_count())?; + let Some(path) = p.get("path").and_then(Value::as_str).map(str::to_string) else { + // The page in view now is the one exported once the dialog answers. + let name = format!("{}-p{}.png", st.doc.title, page + 1); + let mut q = if p.is_object() { p.clone() } else { json!({}) }; + q["page"] = json!(page); + return ask_then_export(app, &name, q, export_png); + }; let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(2.0); let mut r = designcraft_render::Renderer::new(); let img = r @@ -26,14 +45,6 @@ pub(super) fn export_png(app: &mut DesignApp, p: &Value) -> Result Some(s.to_string()), - None => { - let name = format!("{}-p{}.png", st.doc.title, page + 1); - app.pick_save(&name) - } - }; - let Some(path) = path else { return Ok(Value::Null) }; match app.services.write.as_mut() { Some(w) => w(&path, &png).map(|_| json!({"path": path, "width": img.width, "height": img.height})), None => Err("no writer".into()), @@ -44,14 +55,10 @@ pub(super) fn export_png(app: &mut DesignApp, p: &Value) -> Result Result { let st = app.session.active().ok_or("no document")?; - let path = match p.get("path").and_then(Value::as_str) { - Some(s) => Some(s.to_string()), - None => { - let name = format!("{}.idml", st.doc.title); - app.pick_save(&name) - } + let Some(path) = p.get("path").and_then(Value::as_str).map(str::to_string) else { + let name = format!("{}.idml", st.doc.title); + return ask_then_export(app, &name, p.clone(), export_idml); }; - let Some(path) = path else { return Ok(Value::Null) }; let r = app.run("file.exportIdml", json!({}))?; let bytes = designcraft_engine::cmd::base64_decode(r["base64"].as_str().unwrap_or_default()); match app.services.write.as_mut() { @@ -74,14 +81,11 @@ pub(crate) fn export_epub(app: &mut DesignApp, p: &Value) -> Result Result { let st = app.session.active().ok_or("no document")?; - let path = match p.get("path").and_then(Value::as_str) { - Some(s) => Some(s.to_string()), - None => { - let name = format!("{}.{ext}", st.doc.title); - app.pick_save(&name) - } + let Some(path) = p.get("path").and_then(Value::as_str).map(str::to_string) else { + let name = format!("{}.{ext}", st.doc.title); + let (ext, cmd) = (ext.to_string(), cmd.to_string()); + return ask_then_export(app, &name, p.clone(), move |app, q| export_bytes(app, q, &ext, &cmd)); }; - let Some(path) = path else { return Ok(Value::Null) }; // The caller's options go to the engine command; the path is ours. let mut params = match p { Value::Object(o) => Value::Object(o.iter().filter(|(k, _)| k.as_str() != "path").map(|(k, v)| (k.clone(), v.clone())).collect()), @@ -161,14 +165,10 @@ pub(super) fn separations_view(app: &mut DesignApp, p: &Value) -> Result Result { let st = app.session.active().ok_or("no document")?; - let path = match p.get("path").and_then(Value::as_str) { - Some(s) => Some(s.to_string()), - None => { - let name = format!("{}.pdf", st.doc.title); - app.pick_save(&name) - } + let Some(path) = p.get("path").and_then(Value::as_str).map(str::to_string) else { + let name = format!("{}.pdf", st.doc.title); + return ask_then_export(app, &name, p.clone(), export_pdf); }; - let Some(path) = path else { return Ok(Value::Null) }; let mut params = if p.is_object() { p.clone() } else { json!({}) }; if let Some(o) = params.as_object_mut() { o.remove("path"); diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 1c6d56366..6a8d797b5 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -4,6 +4,7 @@ use super::state::{is_window_control, window_control_available}; use super::tables::{CLASSIC_GROUPS, TYPOGRAPHY_GROUPS, workspace_open_panel}; use super::tree::recent_label; use super::{DesignApp, ScreenMode, Value, json}; +use crate::app::file_dialogs::{Bound, with_path}; /// Type › Tabs: float the Tabs panel (placed above the selected text frame) or close it. fn toggle_tabs_panel(app: &mut DesignApp) { @@ -16,6 +17,39 @@ fn toggle_tabs_panel(app: &mut DesignApp) { } } +/// Asks for a file to open for `purpose`, then runs the UI command `id` again with `p` and the +/// chosen file under `key`. +#[track_caller] +fn rerun_with_path(app: &mut DesignApp, purpose: &str, id: &str, p: &Value, key: &'static str) -> Result { + let (id, p) = (id.to_string(), p.clone()); + app.ask_open(purpose, Bound::Document, move |app, path| run_ui(app, &id, &with_path(&p, key, path)).unwrap_or(Ok(Value::Null))) +} + +/// File › Save As: asks where, saves there, then runs `after`. +#[track_caller] +fn save_as_dialog(app: &mut DesignApp, after: impl FnOnce(&mut DesignApp) -> Result + 'static) -> Result { + let name = app.session.active().map(|d| format!("{}.designcraft", d.doc.title)).unwrap_or_default(); + app.ask_save(&name, Bound::Document, move |app, path| { + app.run("file.saveAs", json!({"path": path}))?; + after(app) + }) +} + +/// Save the active document (a download in a browser; Save As for one never saved), then run +/// `after` once it is written. A save dialog answers on a later frame; Cancel runs nothing. +#[track_caller] +pub(crate) fn save_then(app: &mut DesignApp, after: impl FnOnce(&mut DesignApp) -> Result + 'static) -> Result { + if app.services.download.is_some() { + download_document(app)?; + return after(app); + } + if app.session.active().is_some_and(|d| d.path.is_some()) { + app.run("file.save", json!({}))?; + return after(app); + } + save_as_dialog(app, after) +} + /// Run a UI command; `None` if `id` isn't one. pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option> { let was_find = app.ui.dialog.as_ref().is_some_and(|d| d.id == "findChange"); @@ -85,7 +119,10 @@ fn run_ui_part1(app: &mut DesignApp, id: &str, p: &Value) -> Option app.pick_and_open("open"), + "app.openDialog" => match p.get("path").and_then(Value::as_str) { + Some(path) => app.open_picked("open", path.to_string()), + None => app.pick_and_open("open"), + }, "app.openRecent" => { let index = p.get("index").cloned().unwrap_or(Value::Null); let name = index @@ -96,10 +133,13 @@ fn run_ui_part1(app: &mut DesignApp, id: &str, p: &Value) -> Option app.pick_and_open("place"), + "app.placeDialog" => match p.get("path").and_then(Value::as_str) { + Some(path) => app.open_picked("place", path.to_string()), + None => app.pick_and_open("place"), + }, "app.placeWithOptions" => { // Word/RTF Import Options: style mapping and conflicts before the text lands. - let Some(path) = app.services.pick_open.as_mut().and_then(|f| f("place")) else { return Some(Ok(Value::Null)) }; + let Some(path) = p.get("path").and_then(Value::as_str) else { return Some(rerun_with_path(app, "place", id, p, "path")) }; let l = path.to_lowercase(); if !(l.ends_with(".docx") || l.ends_with(".rtf")) { return Some(app.place_file(json!({"path": path}))); @@ -121,24 +161,21 @@ fn run_ui_part1(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.pick_save(&name) { - return Some(app.run("file.saveAs", json!({"path": path}))); - } - Ok(Value::Null) - } + "app.saveDialog" => match p.get("path").and_then(Value::as_str) { + Some(path) => app.run("file.saveAs", json!({"path": path})), + None => save_as_dialog(app, |_| Ok(Value::Null)), + }, "app.quit" => { app.request_quit(); Ok(Value::Null) } - "app.saveCopyDialog" => { - let name = app.session.active().map(|d| format!("{} copy.designcraft", d.doc.title)).unwrap_or_default(); - if let Some(path) = app.pick_save(&name) { - return Some(app.run("file.saveACopy", json!({"path": path}))); + "app.saveCopyDialog" => match p.get("path").and_then(Value::as_str) { + Some(path) => app.run("file.saveACopy", json!({"path": path})), + None => { + let name = app.session.active().map(|d| format!("{} copy.designcraft", d.doc.title)).unwrap_or_default(); + app.ask_save(&name, Bound::Document, |app, path| app.run("file.saveACopy", json!({"path": path}))) } - Ok(Value::Null) - } + }, "app.exportPng" => export_png(app, p), "app.exportIdml" => export_idml(app, p), // From the menu, EPUB Export Options first; with a path, straight to the file. @@ -208,21 +245,22 @@ fn run_ui_part1(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.pick_save(&name) { - Some(dir) => { - let r = app.run("file.package", json!({"dir": dir})); - if let Ok(v) = &r { - app.status( - crate::i18n::tr(&app.ui.language, "Packaged {count} files into {dir}") - .replace("{count}", &v["files"].as_array().map_or(0, |f| f.len()).to_string()) - .replace("{dir}", v["dir"].as_str().unwrap_or("")), - ); - } - r - } - None => Ok(Value::Null), + let Some(dir) = p.get("dir").and_then(Value::as_str) else { + let name = app.session.active().map(|d| format!("{} Folder", d.doc.title)).unwrap_or_default(); + let p = p.clone(); + return Some(app.ask_save(&name, Bound::Document, move |app, dir| { + run_ui(app, "app.packageDialog", &with_path(&p, "dir", dir)).unwrap_or(Ok(Value::Null)) + })); + }; + let r = app.run("file.package", json!({"dir": dir})); + if let Ok(v) = &r { + app.status( + crate::i18n::tr(&app.ui.language, "Packaged {count} files into {dir}") + .replace("{count}", &v["files"].as_array().map_or(0, |f| f.len()).to_string()) + .replace("{dir}", v["dir"].as_str().unwrap_or("")), + ); } + r } "app.language" => { let Some(v) = p.get("lang").and_then(Value::as_str) else { return Some(app.run("app.preferences", json!({}))) }; @@ -240,29 +278,24 @@ fn run_ui_part1(app: &mut DesignApp, id: &str, p: &Value) -> Option { - let Some(pick) = app.services.pick_open.as_mut() else { return Some(Err("needs a file picker".into())) }; - match pick("place") { - Some(path) => app.run("table.placeGraphic", json!({"path": path})), - None => Ok(Value::Null), - } - } + "app.graphicCell" => match p.get("path").and_then(Value::as_str) { + Some(path) => app.run("table.placeGraphic", json!({"path": path})), + None => rerun_with_path(app, "place", id, p, "path"), + }, "effects.dialog" => { crate::effects_dialog::open(app, p.get("page").and_then(Value::as_str).unwrap_or("transparency")); Ok(Value::Null) } "app.loadSwatches" => { - if let Some(pick) = app.services.pick_open.as_mut() { - return Some(match pick("swatches") { - Some(path) => app.run("swatch.load", json!({"path": path})), - None => Ok(Value::Null), - }); + if let Some(path) = p.get("path").and_then(Value::as_str) { + return Some(app.run("swatch.load", json!({"path": path}))); } let request = app.import_request("swatches"); if let Some(open) = app.services.open_async.as_mut() { open(request); + return Some(Ok(Value::Null)); } - Ok(Value::Null) + app.ask_open("swatches", Bound::Document, |app, path| app.run("swatch.load", json!({"path": path}))) } _ => return None, }) @@ -275,11 +308,12 @@ fn run_ui_part2(app: &mut DesignApp, id: &str, p: &Value) -> Option app.pick_styles("character"), "app.loadTextStyles" => app.pick_styles(""), "app.saveSwatches" => { - let path = match p.get("path").and_then(Value::as_str) { - Some(s) => Some(s.to_string()), - None => app.pick_save("Swatches.ase"), + let Some(path) = p.get("path").and_then(Value::as_str).map(str::to_string) else { + let p = p.clone(); + return Some(app.ask_save("Swatches.ase", Bound::Document, move |app, path| { + run_ui(app, "app.saveSwatches", &with_path(&p, "path", path)).unwrap_or(Ok(Value::Null)) + })); }; - let Some(path) = path else { return Some(Ok(Value::Null)) }; let r = match app.run("swatch.save", json!({})) { Ok(r) => r, Err(e) => return Some(Err(e)), diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index 7f0d49bc0..5e82f6d3f 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -1957,13 +1957,9 @@ fn searched_ui_commands_run_without_parameters_and_keep_preferences() { // A two-page document, a saved workspace and non-default preferences, a host with window // controls, and file dialogs that are dismissed. let setup = |context: usize| { - let services = crate::Services { - pick_open: Some(Box::new(|_| None)), - pick_save: Some(Box::new(|_| None)), - clipboard_text: Some(Box::new(|| None)), - ..Default::default() - }; + let services = crate::Services { clipboard_text: Some(Box::new(|| None)), ..Default::default() }; let mut app = DesignApp::new(designcraft_engine::Session::new(), services); + app.answer_dialogs(crate::app::file_dialogs::testing::answer_with(None)); app.custom_titlebar = true; app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, 700.0))); app.run("file.new", json!({"pages": 2})).unwrap(); diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index eb4094e37..649469f30 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -13,14 +13,14 @@ pub fn shown(app: &DesignApp, id: &str) -> bool { /// UI commands: (id, label, shortcut, params). pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("app.newDocumentDialog", "New Document…", Some("Cmd+N"), "{}"), - ("app.openDialog", "Open…", Some("Cmd+O"), "{}"), + ("app.openDialog", "Open…", Some("Cmd+O"), "{path?} — without a path, choose the file in a dialog"), ("app.openRecent", "Open Recent File", None, "{index} — file.openRecent, with an alert when the file can't be opened"), - ("app.placeDialog", "Place…", Some("Cmd+D"), "{}"), - ("app.saveDialog", "Save As…", Some("Cmd+Shift+S"), "{}"), + ("app.placeDialog", "Place…", Some("Cmd+D"), "{path?} — without a path, choose the file in a dialog"), + ("app.saveDialog", "Save As…", Some("Cmd+Shift+S"), "{path?} — without a path, choose where in a dialog"), ("app.save", "Save", Some("Cmd+S"), "{}"), ("app.quit", "Quit DesignCraft", Some("Cmd+Q"), "{} — close the window; each document with unsaved changes asks first"), - ("app.saveCopyDialog", "Save a Copy…", Some("Cmd+Alt+S"), "{} — choose where to write a copy (the document stays as it is)"), - ("app.loadSwatches", "Load Swatches…", None, "{} — pick a swatch exchange (.ase) file"), + ("app.saveCopyDialog", "Save a Copy…", Some("Cmd+Alt+S"), "{path?} — choose where to write a copy (the document stays as it is)"), + ("app.loadSwatches", "Load Swatches…", None, "{path?} — pick a swatch exchange (.ase) file"), ( "app.loadParagraphStyles", "Load Paragraph Styles…", @@ -35,7 +35,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ None, "{page?: transparency|dropShadow|innerShadow|outerGlow|innerGlow|bevel|satin|feather|directionalFeather|gradientFeather} — the Effects dialog for the selected objects", ), - ("app.packageDialog", "Package…", Some("Cmd+Alt+Shift+P"), "{} — choose the package folder to create (file.package)"), + ("app.packageDialog", "Package…", Some("Cmd+Alt+Shift+P"), "{dir?} — choose the package folder to create (file.package)"), ("app.saveSwatches", "Save Swatches for Exchange…", None, "{path?} — write the colour swatches as .ase"), ("app.exportPng", "Export Page as PNG…", Some("Cmd+E"), "{}"), ("app.exportIdml", "Export IDML…", None, "{path?} — InDesign Markup (IDML) package"), @@ -74,9 +74,9 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ None, "{preset: \"\"|high|medium|low} — PDF export rasterises spreads with transparency at 300 / 150 / 72 ppi", ), - ("app.graphicCell", "Convert Cell to Graphic Cell…", None, "{} — pick an image for the target table cell"), + ("app.graphicCell", "Convert Cell to Graphic Cell…", None, "{path?} — pick an image for the target table cell"), ("app.placeAndLink", "Place and Link", None, "{} — a linked copy of the selected frame's story, beside it"), - ("app.placeWithOptions", "Place with Import Options…", Some("Cmd+Shift+D"), "{} — Word/RTF style mapping before placing"), + ("app.placeWithOptions", "Place with Import Options…", Some("Cmd+Shift+D"), "{path?} — Word/RTF style mapping before placing"), ("app.exportText", "Export Text…", None, "{path?} — the story being edited, as Text Only (.txt) or Rich Text Format (.rtf)"), ("app.exportPdf", "Export PDF…", None, "{path?, …file.exportPdf options} — asks for a path when none is given"), ("app.palette", "Quick Apply…", Some("Cmd+Return"), "{} — search styles and commands"), diff --git a/crates/ui-egui/src/panels/datamerge.rs b/crates/ui-egui/src/panels/datamerge.rs index e42055aec..8920698c4 100644 --- a/crates/ui-egui/src/panels/datamerge.rs +++ b/crates/ui-egui/src/panels/datamerge.rs @@ -4,6 +4,7 @@ use egui::{Color32, Sense, Stroke, vec2}; use serde_json::{Value, json}; use crate::DesignApp; +use crate::app::file_dialogs::Bound; use crate::theme::{Tokens, semibold}; pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { @@ -186,11 +187,13 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } fn select_source(app: &mut DesignApp) { - let Some(path) = app.services.pick_open.as_mut().and_then(|f| f("dataMerge")) else { + let asked = app.ask_open("dataMerge", Bound::Document, |app, path| { + let _ = app.run("data.source.select", json!({"path": path})); + Ok(Value::Null) + }); + if asked.is_err() { app.status(tr(&app.ui.language, "Pass csv, rows, or bytes from the control channel.")); - return; - }; - let _ = app.run("data.source.select", json!({"path": path})); + } } /// Render a localized, named combo and return only a changed selection. diff --git a/crates/ui-egui/src/panels/interactive.rs b/crates/ui-egui/src/panels/interactive.rs index 5c7ae92ed..4d5558ffb 100644 --- a/crates/ui-egui/src/panels/interactive.rs +++ b/crates/ui-egui/src/panels/interactive.rs @@ -3,6 +3,7 @@ use serde_json::{Value, json}; use crate::DesignApp; +use crate::app::file_dialogs::Bound; use crate::theme::Tokens; fn dest_text(lang: &str, d: &Value) -> String { @@ -230,13 +231,10 @@ pub fn tags(app: &mut DesignApp, ui: &mut egui::Ui) { let vid = egui::Id::unique("dtd_problems"); let mut problems: Option> = ui.data(|d| d.get_temp(vid)); ui.horizontal(|ui| { - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Load DTD…"))).clicked() - && let Some(path) = app.services.pick_open.as_mut().and_then(|f| f("dtd")) - { - match app.run("xml.loadDtd", json!({"path": path})) { - Ok(_) => problems = None, - Err(e) => app.status_error("Load DTD", e), - } + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Load DTD…"))).clicked() { + // A new DTD's problems show at the next validation. + problems = None; + ask_then_run(app, "dtd", "Load DTD", |path| ("xml.loadDtd", json!({"path": path}))); } let has_dtd = info["dtd"].as_bool().unwrap_or(false); if ui.add_enabled(has_dtd, egui::Button::new(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Validate")))).clicked() @@ -280,13 +278,26 @@ pub fn tags(app: &mut DesignApp, ui: &mut egui::Ui) { if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Export XML…"))).clicked() { let _ = app.run("app.exportXml", json!({})); } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Import XML…"))).clicked() - && let Some(path) = app.services.pick_open.as_mut().and_then(|f| f("xml")) - && let Err(e) = app.run("file.importXml", json!({"path": path})) - { - app.status_error("Import XML", e); + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Import XML…"))).clicked() { + ask_then_run(app, "xml", "Import XML", |path| ("file.importXml", json!({"path": path}))); + } + }); +} + +/// Asks for a file for `purpose`, then runs the command `command(path)` names; a failure shows +/// in the status line as `feature`'s. +#[track_caller] +fn ask_then_run(app: &mut DesignApp, purpose: &str, feature: &'static str, command: impl FnOnce(String) -> (&'static str, Value) + 'static) { + let asked = app.ask_open(purpose, Bound::Document, move |app, path| { + let (id, params) = command(path); + if let Err(e) = app.run(id, params) { + app.status_error(feature, e); } + Ok(Value::Null) }); + if let Err(e) = asked { + app.status_error(feature, e); + } } /// Object States panel: the selected multi-state object's states (click to show one). @@ -638,11 +649,8 @@ pub fn media(app: &mut DesignApp, ui: &mut egui::Ui) { } ui.horizontal(|ui| { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Poster")); - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Choose Image…"))).clicked() - && let Some(path) = app.services.pick_open.as_mut().and_then(|f| f("place")) - && let Err(e) = app.run("media.options", json!({"poster": {"path": path}})) - { - app.status_error("Media", e); + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Choose Image…"))).clicked() { + ask_then_run(app, "place", "Media", |path| ("media.options", json!({"poster": {"path": path}}))); } if !m["poster"].is_null() && ui.small_button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "None"))).clicked() { let _ = app.run("media.options", json!({"poster": null})); diff --git a/crates/ui-egui/src/panels/library.rs b/crates/ui-egui/src/panels/library.rs index 689b43ffa..2b19770cf 100644 --- a/crates/ui-egui/src/panels/library.rs +++ b/crates/ui-egui/src/panels/library.rs @@ -4,6 +4,7 @@ use serde_json::json; use crate::DesignApp; +use crate::app::file_dialogs::Bound; use crate::theme::Tokens; pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { @@ -33,14 +34,14 @@ 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.pick_save("Library.dclib"); - let _ = app.run("library.new", json!({"path": path})); + let asked = app.ask_save("Library.dclib", Bound::App, |app, path| app.run("library.new", json!({"path": path}))); + report(app, "Library", asked); } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Open…"))).clicked() - && let Some(path) = app.services.pick_open.as_mut().and_then(|f| f("library")) - && let Err(e) = app.run("library.open", json!({"path": path})) - { - app.status_error("Library", e); + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Open…"))).clicked() { + let asked = app.ask_open("library", Bound::App, |app, path| { + app.run("library.open", json!({"path": path})).or_else(|e| report_err(app, "Library", e)) + }); + report(app, "Library", asked); } }); let Ok(items) = app.session.execute("library.list", &json!({})) else { @@ -84,30 +85,57 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } } +/// A dialog that couldn't be asked for, in the status line as `feature`'s error. +fn report(app: &mut DesignApp, feature: &str, asked: Result) { + if let Err(e) = asked { + app.status_error(feature, e); + } +} + +/// A command run with a dialog's answer failed: the status line names `feature`. +fn report_err(app: &mut DesignApp, feature: &str, e: String) -> Result { + app.status_error(feature, e); + Ok(serde_json::Value::Null) +} + +/// Scripts › Load…: the chosen script becomes a new, selected one. Read through the host's +/// reader, like every other file the UI opens: this crate has no file system of its own. +fn load_script(app: &mut DesignApp, path: &str, sel_id: egui::Id) { + let read = app.services.read.as_mut().map_or_else(|| Err("no file access".to_string()), |r| r(path)); + match read.and_then(|bytes| String::from_utf8(bytes).map_err(|e| e.to_string())) { + Ok(text) => { + let name = std::path::Path::new(path).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Script".into()); + app.ui.scripts.push((name, text)); + let sel = app.ui.scripts.len().saturating_sub(1); + if let Some(ctx) = &app.egui_ctx { + ctx.data_mut(|d| d.insert_temp(sel_id, sel)); + } + } + Err(e) => app.status_error("Scripts", e), + } +} + fn book_open_buttons(app: &mut DesignApp, ui: &mut egui::Ui) { ui.horizontal(|ui| { - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "New Book…"))).clicked() - && let Some(path) = app.pick_save("Book.dcbook") - && let Err(e) = app.run("book.new", json!({"path": path})) - { - app.status_error("Book", e); + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "New Book…"))).clicked() { + let asked = app + .ask_save("Book.dcbook", Bound::App, |app, path| app.run("book.new", json!({"path": path})).or_else(|e| report_err(app, "Book", e))); + report(app, "Book", asked); } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Open…"))).clicked() - && let Some(path) = app.services.pick_open.as_mut().and_then(|f| f("book")) - && let Err(e) = app.run("book.open", json!({"path": path})) - { - app.status_error("Book", e); + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Open…"))).clicked() { + let asked = + app.ask_open("book", Bound::App, |app, path| app.run("book.open", json!({"path": path})).or_else(|e| report_err(app, "Book", e))); + report(app, "Book", asked); } }); } fn book_action_buttons(app: &mut DesignApp, ui: &mut egui::Ui) { ui.horizontal_wrapped(|ui| { - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Add Document…"))).clicked() - && let Some(path) = app.services.pick_open.as_mut().and_then(|f| f("open")) - && let Err(e) = app.run("book.add", json!({"path": path})) - { - app.status_error("Book", e); + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Add Document…"))).clicked() { + let asked = + app.ask_open("open", Bound::App, |app, path| app.run("book.add", json!({"path": path})).or_else(|e| report_err(app, "Book", e))); + report(app, "Book", asked); } if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Update Numbering"))).clicked() { let _ = app.run("book.paginate", json!({})); @@ -115,11 +143,11 @@ fn book_action_buttons(app: &mut DesignApp, ui: &mut egui::Ui) { if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Synchronize"))).clicked() { 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.pick_save("Book.pdf") - && let Err(e) = app.run("book.exportPdf", json!({"path": path})) - { - app.status_error("Book", e); + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Export PDF…"))).clicked() { + let asked = app.ask_save("Book.pdf", Bound::App, |app, path| { + app.run("book.exportPdf", json!({"path": path})).or_else(|e| report_err(app, "Book", e)) + }); + report(app, "Book", asked); } }); } @@ -200,20 +228,12 @@ pub fn scripts(app: &mut DesignApp, ui: &mut egui::Ui) { app.ui.scripts.push((format!("Script {}", app.ui.scripts.len() + 1), "# command.id {json} per line\n".into())); sel = app.ui.scripts.len() - 1; } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Load…"))).clicked() - && let Some(path) = app.services.pick_open.as_mut().and_then(|f| f("script")) - { - // Through the host's reader, like every other file the UI opens: this crate has no - // file system of its own. - let read = app.services.read.as_mut().map_or_else(|| Err("no file access".to_string()), |r| r(&path)); - match read.and_then(|bytes| String::from_utf8(bytes).map_err(|e| e.to_string())) { - Ok(text) => { - let name = std::path::Path::new(&path).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Script".into()); - app.ui.scripts.push((name, text)); - sel = app.ui.scripts.len() - 1; - } - Err(e) => app.status_error("Scripts", e), - } + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Load…"))).clicked() { + let asked = app.ask_open("script", Bound::App, move |app, path| { + load_script(app, &path, sel_id); + Ok(serde_json::Value::Null) + }); + report(app, "Scripts", asked); } if ui .add_enabled(!app.ui.scripts.is_empty(), egui::Button::new(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Delete")))) diff --git a/crates/ui-egui/src/panels/properties/links.rs b/crates/ui-egui/src/panels/properties/links.rs index 5084b6cf1..8d13fe06d 100644 --- a/crates/ui-egui/src/panels/properties/links.rs +++ b/crates/ui-egui/src/panels/properties/links.rs @@ -1,4 +1,5 @@ use super::{DesignApp, Sense, Tokens, Value, divider, json, sel_info, units, vec2}; +use crate::app::file_dialogs::Bound; /// Links: status (missing / modified / embedded), page and effective resolution of every placed /// graphic and linked text file; Relink, Go To Link, Update Link, Embed Link for the chosen row. @@ -143,10 +144,15 @@ pub fn links_panel(app: &mut DesignApp, ui: &mut egui::Ui) { p[k] = v; p }; - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Relink…"))).clicked() - && let Some(path) = app.services.pick_open.as_mut().and_then(|pick| pick("relink")) - { - let _ = app.run("links.relink", with("path", json!(path))); + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Relink…"))).clicked() { + let target = with("path", Value::Null); + let asked = app.ask_open("relink", Bound::Document, move |app, path| { + let _ = app.run("links.relink", crate::app::file_dialogs::with_path(&target, "path", path)); + Ok(Value::Null) + }); + if let Err(e) = asked { + app.status_error("Relink", e); + } } if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Go To"))).clicked() { let _ = app.run("links.goTo", target.clone()); @@ -178,12 +184,16 @@ pub fn links_panel(app: &mut DesignApp, ui: &mut egui::Ui) { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Pick a file in the folder holding the moved graphics")); }) .clicked() - && let Some(path) = app.services.pick_open.as_mut().and_then(|pick| pick("relink")) - && let Some(dir) = std::path::Path::new(&path).parent() { - let r = app.run("links.relinkFolder", json!({"dir": dir.to_string_lossy()})); - if let Ok(v) = r { - app.status(crate::i18n::tr(&app.ui.language, "Relinked {count}").replace("{count}", &v["relinked"].to_string())); + let asked = app.ask_open("relink", Bound::Document, |app, path| { + let Some(dir) = std::path::Path::new(&path).parent() else { return Ok(Value::Null) }; + if let Ok(v) = app.run("links.relinkFolder", json!({"dir": dir.to_string_lossy()})) { + app.status(crate::i18n::tr(&app.ui.language, "Relinked {count}").replace("{count}", &v["relinked"].to_string())); + } + Ok(Value::Null) + }); + if let Err(e) = asked { + app.status_error("Relink", e); } } }); diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index d7e88969c..b413e90c4 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -4,8 +4,6 @@ pub type ReadFn = Box Result, String>>; pub type WriteFn = Box Result<(), String>>; -pub type PickFn = Box Option>; - pub type OpenAsyncFn = Box; /// Starts a browser download; an error means no file was handed to the user. @@ -16,21 +14,18 @@ pub type ClipboardFn = Box Option>; /// Files delivered asynchronously with their original operation and document identity. pub type Inbox = std::sync::Arc>>; -/// Platform services injected by the host (desktop or web). +/// Platform services injected by the host (desktop or web). File dialogs aren't among them: the +/// app asks for them itself and the host shows them (`DesignApp::show_file_dialogs`). #[derive(Default)] pub struct Services { - /// Open-file dialog for a purpose (`open`, `place`) → path. - pub pick_open: Option, - /// 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, /// Asynchronous open dialog for a purpose (`open`, `place`); the chosen file arrives later - /// through [`Services::inbox`]. Used when `pick_open` is unset (web). + /// through [`Services::inbox`]. In a browser, which has no native dialogs. pub open_async: Option, /// Hand bytes to the user as a named file (browser download). When set, Save uses it - /// instead of writing to a path. + /// instead of writing to a path, and a file saved under a name goes to the downloads with no + /// save dialog. pub download: Option, /// Files delivered asynchronously, drained every frame with their captured request context. pub inbox: Option, diff --git a/crates/ui-egui/src/shortcuts_dialog.rs b/crates/ui-egui/src/shortcuts_dialog.rs index 797b0cb71..95f8eb10c 100644 --- a/crates/ui-egui/src/shortcuts_dialog.rs +++ b/crates/ui-egui/src/shortcuts_dialog.rs @@ -14,9 +14,10 @@ use std::borrow::Cow; use craft_keymap::ui::{DialogState, Format, Options, Outcome, ShortcutsDialog, Text}; use craft_keymap::{Bindable, Overrides}; -use serde_json::json; +use serde_json::{Value, json}; use crate::DesignApp; +use crate::app::file_dialogs::Bound; use crate::i18n::tr; /// The `app.ui.dialog` id while the dialog is open. @@ -28,7 +29,7 @@ pub static BUILTINS: [(&str, Overrides); 1] = [("Default", Overrides::new())]; /// The name edits of the built-in set are saved under (translated, made unique). pub const CUSTOM_SET: &str = "Custom"; -/// The file-picker purpose of the dialog's Import (`Services::pick_open`, `open_async`). +/// The file-picker purpose of the dialog's Import (`DesignApp::ask_open`, `open_async`). pub const PURPOSE: &str = "shortcuts"; /// A set saved by InDesign's Show Set, read by `shortcut_import`. @@ -176,20 +177,26 @@ pub(crate) fn close(app: &mut DesignApp, ok: bool) -> Result<(), String> { Ok(()) } -/// Import: a native picker reads the file at once; in a browser it arrives later through -/// [`deliver`]. +/// Import: the chosen file is read when the open dialog answers, or arrives later through +/// [`deliver`] in a browser. fn request_import(app: &mut DesignApp, host: &mut Host) { - if let Some(pick) = app.services.pick_open.as_mut() { - let Some(path) = pick(PURPOSE) else { return }; - match crate::shortcut_import::read_file(&path) { - Ok(text) => review(app, host, &text), - Err(e) => host.state.show_error(e), - } - return; - } let request = app.import_request(PURPOSE); if let Some(open) = app.services.open_async.as_mut() { open(request); + return; + } + let asked = app.ask_open(PURPOSE, Bound::App, |app, path| { + // The dialog closed meanwhile: nothing to import into. + let Some(mut host) = app.shortcut_dialog.take() else { return Ok(Value::Null) }; + match crate::shortcut_import::read_file(&path) { + Ok(text) => review(app, &mut host, &text), + Err(e) => host.state.show_error(e), + } + app.shortcut_dialog = Some(host); + Ok(Value::Null) + }); + if let Err(e) = asked { + host.state.show_error(e); } } @@ -228,14 +235,20 @@ fn review(app: &mut DesignApp, host: &mut Host, text: &str) { /// Writes a file the dialog made where the user chooses (a download on the web). fn save_file(app: &mut DesignApp, name: &str, bytes: &[u8]) { let name: String = name.chars().map(|c| if c.is_control() || matches!(c, '/' | '\\' | ':') { '_' } else { c }).collect(); - let Some(path) = app.services.pick_save.as_mut().and_then(|f| f(&name)) else { return }; - let result = match app.services.write.as_mut() { - Some(write) => write(&path, bytes), - None => Err("no writer".into()), - }; - match result { - Ok(()) => app.status(tr(&app.ui.language, "Saved to {path}").replace("{path}", &path)), - Err(e) => app.status(e), + let bytes = bytes.to_vec(); + let asked = app.ask_save(&name, Bound::App, move |app, path| { + let result = match app.services.write.as_mut() { + Some(write) => write(&path, &bytes), + None => Err("no writer".into()), + }; + match result { + Ok(()) => app.status(tr(&app.ui.language, "Saved to {path}").replace("{path}", &path)), + Err(e) => app.status(e), + } + Ok(Value::Null) + }); + if let Err(e) = asked { + app.status(e); } } @@ -424,10 +437,12 @@ mod tests { .unwrap(); let picked = path.to_string_lossy().to_string(); let mut app = app(); - app.services.pick_open = Some(Box::new(move |purpose| (purpose == PURPOSE).then(|| picked.clone()))); + app.answer_dialogs(crate::app::file_dialogs::testing::answer_with(Some(picked))); app.run("app.keyboardShortcuts", json!({})).unwrap(); let mut h = harness(app); click(&mut h, "Import…"); + h.state_mut().settle_dialogs(); + h.run(); assert!(has(&h, "Import “Studio”")); assert!(h.state().ui.shortcuts.is_empty(), "the review changes nothing"); click(&mut h, "Import as New Set"); diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index d554cf0dc..36bac93a9 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -78,26 +78,33 @@ fn save_dialogs_start_beside_the_document_or_in_the_last_folder() { 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.answer_dialogs(move |request| { + let craft_dialogs::Request::File(dialog) = request else { return craft_dialogs::Answer::Cancelled }; + let name = dialog.file_name.clone().unwrap_or_default(); + seen.borrow_mut().push(dialog.directory.as_ref().map_or_else(|| name.clone(), |folder| folder.join(&name).to_string_lossy().to_string())); + reply.borrow().clone().map_or(craft_dialogs::Answer::Cancelled, |path| craft_dialogs::Answer::Paths(vec![path.into()])) + }); 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.settle_dialogs(); + }; + let save_dialog = |app: &mut DesignApp| { + app.run("app.saveDialog", json!({})).unwrap(); + app.settle_dialogs(); }; app.run("file.new", json!({})).unwrap(); let untitled = format!("{}.designcraft", app.session.active().unwrap().doc.title); - app.run("app.saveDialog", json!({})).unwrap(); + save_dialog(&mut app); 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(); + save_dialog(&mut app); 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()); @@ -108,7 +115,7 @@ fn save_dialogs_start_beside_the_document_or_in_the_last_folder() { app.run("file.new", json!({})).unwrap(); let untitled = format!("{}.designcraft", app.session.active().unwrap().doc.title); - app.run("app.saveDialog", json!({})).unwrap(); + save_dialog(&mut app); assert_eq!(last(&asked), elsewhere.path().join(untitled).to_string_lossy(), "a new document starts in the last folder used"); } @@ -116,19 +123,11 @@ fn save_dialogs_start_beside_the_document_or_in_the_last_folder() { /// title with a slash moved the suggestion out of the document's folder. #[test] fn save_dialog_suggestions_keep_the_folder_and_only_a_file_name() { - use std::{cell::RefCell, rc::Rc}; let folder = tempfile::tempdir().unwrap(); - let asked: Rc>> = Rc::default(); let mut app = DesignApp::new(designcraft_engine::Session::new(), Services::default()); - let seen = asked.clone(); - app.services.pick_save = Some(Box::new(move |name: &str| { - seen.borrow_mut().push(name.to_string()); - None - })); app.ui.last_save_folder = Some(folder.path().join("Docs").to_string_lossy().to_string()); - app.pick_save(""); - app.pick_save("Q1/Q2.pdf"); - assert_eq!(*asked.borrow(), ["".to_string(), folder.path().join("Docs").join("Q2.pdf").to_string_lossy().to_string()]); + assert_eq!(app.suggested_save_path(""), ""); + assert_eq!(app.suggested_save_path("Q1/Q2.pdf"), folder.path().join("Docs").join("Q2.pdf").to_string_lossy()); } /// An app with one document that has an unsaved edit. @@ -379,12 +378,13 @@ fn repeated_native_quit_requests_save_and_discard_only_after_all_answers() { let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("saved.designcraft"); let chosen = path.to_string_lossy().to_string(); - app.services.pick_save = Some(Box::new(move |_| Some(chosen.clone()))); + app.answer_dialogs(crate::app::file_dialogs::testing::answer_with(Some(chosen.clone()))); app.session.recovery_dir = Some(directory.path().join("recovery")); app.run("file.recovery.save", json!({})).unwrap(); quit_frame(&mut app, &ctx, true); assert_eq!(dialog_uid(&app), Some(uids[0])); dialogs::confirm(&mut app).unwrap(); // Save the first document. + app.settle_dialogs(); assert!(!app.session.documents()[0].is_dirty()); quit_frame(&mut app, &ctx, false); assert_eq!(dialog_uid(&app), Some(uids[1])); @@ -442,11 +442,13 @@ fn quitting_save_failure_after_discard_keeps_documents_and_can_retry() { .join("document.designcraft"); assert!(!path.parent().unwrap().exists()); let picked = path.to_string_lossy().to_string(); - app.services.pick_save = Some(Box::new(move |_| Some(picked.clone()))); + app.answer_dialogs(crate::app::file_dialogs::testing::answer_with(Some(picked.clone()))); quit_frame(&mut app, &ctx, true); discard_quit_dialog(&mut app); quit_frame(&mut app, &ctx, false); - assert!(dialogs::confirm(&mut app).is_err()); + // The folder is missing: saving there fails and Quit stops. + dialogs::confirm(&mut app).unwrap(); + app.settle_dialogs(); assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); assert_eq!(app.session.documents().len(), 2); assert!(app.session.documents().iter().all(|d| d.is_dirty())); @@ -455,6 +457,37 @@ fn quitting_save_failure_after_discard_keeps_documents_and_can_retry() { assert_eq!(dialog_uid(&app), Some(app.session.documents()[0].uid)); } +/// Quit › Save on a document never saved, then Cancel in the save dialog: Quit stops and the +/// document stays open with its changes. +#[test] +fn quit_save_cancelled_in_the_save_dialog_stops_quitting() { + let ctx = egui::Context::default(); + let mut app = app_with_unsaved_document(); + app.answer_dialogs(crate::app::file_dialogs::testing::answer_with(None)); + quit_frame(&mut app, &ctx, true); + dialogs::confirm(&mut app).unwrap(); // Save + assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close), "Quit waits for the save dialog"); + assert!(app.quitting.is_some()); + app.settle_dialogs(); + assert!(!quit_frame(&mut app, &ctx, false).contains(&egui::ViewportCommand::Close)); + assert!(app.quitting.is_none(), "Quit stopped"); + assert_eq!(app.session.documents().len(), 1); + assert!(app.session.documents()[0].is_dirty()); +} + +/// Close › Save on a document never saved, then Cancel in the save dialog: the document stays +/// open with its changes. +#[test] +fn close_save_cancelled_in_the_save_dialog_keeps_the_document_open() { + let mut app = app_with_unsaved_document(); + app.answer_dialogs(crate::app::file_dialogs::testing::answer_with(None)); + crate::menus::close_document(&mut app, Some(0)); + dialogs::confirm(&mut app).unwrap(); // Save + app.settle_dialogs(); + assert_eq!(app.session.documents().len(), 1); + assert!(app.session.documents()[0].is_dirty()); +} + #[test] fn quitting_save_then_later_cancel_keeps_saved_document_open() { let ctx = egui::Context::default(); @@ -463,9 +496,10 @@ fn quitting_save_then_later_cancel_keeps_saved_document_open() { let first = app.session.documents()[0].uid; let path = std::env::temp_dir().join(format!("designcraft-quit-save-{}-{first}.designcraft", std::process::id())); let picked = path.to_string_lossy().to_string(); - app.services.pick_save = Some(Box::new(move |_| Some(picked.clone()))); + app.answer_dialogs(crate::app::file_dialogs::testing::answer_with(Some(picked.clone()))); quit_frame(&mut app, &ctx, true); dialogs::confirm(&mut app).unwrap(); + app.settle_dialogs(); quit_frame(&mut app, &ctx, false); assert_eq!(app.session.documents().len(), 2); assert!(!app.session.documents()[0].is_dirty()); @@ -516,10 +550,11 @@ fn quitting_pending_save_uses_dialog_uid_after_tab_switch() { let first = app.session.documents()[0].uid; let path = std::env::temp_dir().join(format!("designcraft-quit-uid-{}-{first}.designcraft", std::process::id())); let picked = path.to_string_lossy().to_string(); - app.services.pick_save = Some(Box::new(move |_| Some(picked.clone()))); + app.answer_dialogs(crate::app::file_dialogs::testing::answer_with(Some(picked.clone()))); quit_frame(&mut app, &ctx, true); app.run("file.activate", json!({"index": 1})).unwrap(); dialogs::confirm(&mut app).unwrap(); + app.settle_dialogs(); assert_eq!(app.session.documents()[0].path.as_deref(), path.to_str()); assert!(!app.session.documents()[0].is_dirty()); assert!(app.session.documents()[1].is_dirty()); @@ -839,16 +874,19 @@ fn a_file_dropped_while_a_parent_is_open_lands_on_the_parent() { fn file_open_failures_show_an_alert() { let path = unreadable_file("open.designcraft"); let picked = path.to_string_lossy().to_string(); - let services = Services { pick_open: Some(Box::new(move |_| Some(picked.clone()))), ..Default::default() }; - let mut app = DesignApp::new(Session::new(), services); - let e = app.run("app.openDialog", json!({})).unwrap_err(); - assert_eq!(alert(&app), ("Can't Open the File".to_string(), e)); + let mut app = DesignApp::new(Session::new(), Services::default()); + app.answer_dialogs(crate::app::file_dialogs::testing::answer_with(Some(picked))); + // The error comes with the dialog's answer, on a later frame: the alert and the status line. + app.run("app.openDialog", json!({})).unwrap(); + app.settle_dialogs(); + assert_eq!(alert(&app), ("Can't Open the File".to_string(), app.ui.status.clone())); // Placing it says so too. app.ui.dialog = None; app.run("file.new", json!({})).unwrap(); - let e = app.run("app.placeDialog", json!({})).unwrap_err(); + app.run("app.placeDialog", json!({})).unwrap(); + app.settle_dialogs(); let _ = std::fs::remove_file(&path); - assert_eq!(alert(&app), ("Can't Place the File".to_string(), e)); + assert_eq!(alert(&app), ("Can't Place the File".to_string(), app.ui.status.clone())); } #[test]