diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 372cf8b8d..7ccd7777a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -368,6 +368,9 @@ pub struct PasswordPrompt { /// The bytes are a recovery snapshot: once the document is open it takes the snapshot's /// path and recovery entry. Cancelling the prompt drops the association with it (#813). recovered: Option, + /// The tool that asked for this file (a tool chosen with no document open): it runs once + /// the document is unlocked. + then: Option<&'static str>, } #[derive(serde::Serialize, serde::Deserialize)] @@ -937,7 +940,7 @@ impl PdfCraftApp { Err(e @ (OpenError::NeedsPassword | OpenError::WrongPassword)) => { let error = matches!(e, OpenError::WrongPassword).then(|| "Incorrect password. Try again.".to_string()); self.password_prompt = - Some(PasswordPrompt { name: name.to_string(), path, bytes, input: String::new(), error, activate, recovered: None }); + Some(PasswordPrompt { name: name.to_string(), path, bytes, input: String::new(), error, activate, recovered: None, then: None }); return Ok(()); } Err(e) => return Err(e.to_string()), @@ -1133,11 +1136,16 @@ impl PdfCraftApp { if let Some(meta) = &p.recovered { self.finish_recovery(meta); } + if let Some(command) = p.then { + self.execute(command); + } } - // Wrong password: the new prompt is for the same bytes, so it keeps the snapshot. + // Wrong password: the new prompt is for the same bytes, so it keeps the snapshot + // and the tool waiting for it. Ok(()) => { if let Some(prompt) = self.password_prompt.as_mut() { prompt.recovered = p.recovered; + prompt.then = p.then; } } } @@ -1167,14 +1175,16 @@ impl PdfCraftApp { } } + /// The File ▸ Open picker: PDFs, and images and text that become PDFs. + #[cfg(not(target_arch = "wasm32"))] + fn open_picker() -> rfd::AsyncFileDialog { + rfd::AsyncFileDialog::new().add_filter("PDF", &["pdf"]).add_filter(tl!("Images and text (converted to PDF)"), &create_ui::CONVERTIBLE) + } + pub fn open_dialog(&mut self) { self.startup_superseded = true; #[cfg(not(target_arch = "wasm32"))] - self.pick( - pickers::PickFor::Open, - rfd::AsyncFileDialog::new().add_filter("PDF", &["pdf"]).add_filter(tl!("Images and text (converted to PDF)"), &create_ui::CONVERTIBLE), - false, - ); + self.pick(pickers::PickFor::Open, Self::open_picker(), false); // Browsers pick files asynchronously; the bytes arrive through `inbox`. #[cfg(target_arch = "wasm32")] { @@ -1324,7 +1334,19 @@ impl PdfCraftApp { /// Run a catalogue command. Commands that aren't implemented yet say which milestone ships them. pub fn run_command(&mut self, command: &str) { - if pdfcraft_engine::commands::command(command).is_some() { + if let Some(spec) = pdfcraft_engine::commands::command(command) { + use pdfcraft_engine::commands::Needs; + // A tool that needs a document, chosen with none open, asks for the file and then + // runs on it, instead of only saying to open one first. + let needs_document = !matches!(spec.needs, Needs::Nothing | Needs::Undo | Needs::Redo); + let is_tool = pdfcraft_engine::catalog::TOOL_GROUPS + .iter() + .flat_map(|g| g.sections.iter().flat_map(|s| s.items.iter())) + .any(|i| i.command == spec.id); + if self.active.is_none() && needs_document && is_tool && !self.command_enabled(spec) { + self.open_then_run(spec); + return; + } self.execute(command); return; } @@ -1342,6 +1364,32 @@ impl PdfCraftApp { self.notify_fmt("`{command}` {when}", &[("command", command), ("when", &when)]); } + /// Ask for a file to open, then run `spec` on it once it is open (or unlocked). Nothing runs + /// when the picker is cancelled or the file doesn't open. + #[cfg(not(target_arch = "wasm32"))] + fn open_then_run(&mut self, spec: &'static pdfcraft_engine::commands::CommandSpec) { + self.startup_superseded = true; + let dialog = Self::open_picker().set_title(tl!(spec.label).trim_end_matches('…')); + let command = spec.id; + self.ask_one(pickers::Ask::File(dialog), None, move |app, path| { + let path = path.to_string_lossy().into_owned(); + let before = app.views.len(); + app.open_path(&path); + if let Some(prompt) = app.password_prompt.as_mut().filter(|p| p.path.as_deref() == Some(path.as_str())) { + // Encrypted: the tool runs once the password is in. + prompt.then = Some(command); + } else if app.views.len() > before && app.active.is_some() { + app.execute(command); + } + }); + } + + /// Browsers read the picked file asynchronously; it opens, and the tool can be chosen again. + #[cfg(target_arch = "wasm32")] + fn open_then_run(&mut self, _spec: &'static pdfcraft_engine::commands::CommandSpec) { + self.open_dialog(); + } + /// Today in `fmt` (or Preferences ▸ Date format), with month and weekday names in the date /// language, or the interface language when it follows that. pub fn date_text(&self, fmt: Option<&str>) -> Result { diff --git a/crates/ui-egui/tests/commands.rs b/crates/ui-egui/tests/commands.rs index f12a32203..73e347e48 100644 --- a/crates/ui-egui/tests/commands.rs +++ b/crates/ui-egui/tests/commands.rs @@ -154,6 +154,39 @@ fn disabled_commands_explain_themselves() { assert!(!app.execute("no.such.command")); } +#[test] +fn a_tool_chosen_with_no_document_asks_for_one_and_runs_on_it() { + let dir = std::env::temp_dir().join(format!("pdfcraft-tool-open-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("picked.pdf"); + std::fs::write(&path, fixture(2)).unwrap(); + let pick = path.to_string_lossy().into_owned(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.pick_override = Some(vec![pick]); + app + }); + h.run_steps(2); + h.state_mut().run_command("export.text"); + assert!(h.state().toast.as_ref().is_none_or(|t| t.0 != "Open a document first")); + h.run_steps(3); + assert!(h.state().active.is_some(), "the picked file is open"); + assert!(matches!(h.state().dialog, Some(Dialog::Export(_))), "and the tool runs on it: {:?}", h.state().dialog); + // Cancelled: nothing opens and nothing runs. + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.pick_override = Some(Vec::new()); + app + }); + h.run_steps(2); + h.state_mut().run_command("export.text"); + h.run_steps(3); + assert!(h.state().active.is_none() && h.state().dialog.is_none()); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn shortcuts_run_registered_commands() { let mut h = harness();