UI: a tool chosen with no document open asks for a file and runs on it (#1100)

Clicking a tool in the tool panel or palette that needs a document, with
none open, used to show only "Open a document first". It now shows the
open picker (titled with the tool's name), opens the chosen file and runs
the tool on it. Encrypted files run the tool once the password is in;
cancelling runs nothing. Menus and shortcuts keep the old message. The web
build opens the file only.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michelle
2026-10-11 12:17:33 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 2912b21d33
commit 3b7a01eac5
2 changed files with 89 additions and 8 deletions
+56 -8
View File
@@ -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<RecoveryMeta>,
/// 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<String, String> {
+33
View File
@@ -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();