From 986307f7640beea97fe7a6f3f5d995ee2fb0ee8d Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:57:25 +0200 Subject: [PATCH] Print and Ctrl+P ask where to save the PDF (#5) (#26) PLOT/EXPORTPDF started from the menu, the toolbar, Ctrl+P or the command line had no path, so the PDF was built in memory and dropped. They now open the save dialog (PDF filter first) and write the file there. JSON calls from scripts, the control channel and MCP still never open a dialog. Ctrl+P was shown in the menu but not bound; it is now. --- apps/cadcraft/src/main.rs | 12 +++--- crates/ui-egui/src/menus.rs | 84 +++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 5 deletions(-) diff --git a/apps/cadcraft/src/main.rs b/apps/cadcraft/src/main.rs index 9ec57801..e3539844 100644 --- a/apps/cadcraft/src/main.rs +++ b/apps/cadcraft/src/main.rs @@ -72,11 +72,13 @@ fn services() -> Services { .map(|p| p.to_string_lossy().to_string()) })), pick_save: Some(Box::new(|name: &str| { - rfd::FileDialog::new() - .set_file_name(name) - .add_filter("Drawing (DXF)", &["dxf"]) - .add_filter("SVG", &["svg"]) - .add_filter("PNG", &["png"]) + // The suggested name's type goes first: Windows adds the first filter's extension. + let mut filters = [("Drawing (DXF)", "dxf"), ("SVG", "svg"), ("PNG", "png"), ("PDF", "pdf")]; + let lower = name.to_ascii_lowercase(); + filters.sort_by_key(|(_, ext)| !lower.ends_with(&format!(".{ext}"))); + filters + .iter() + .fold(rfd::FileDialog::new().set_file_name(name), |d, (label, ext)| d.add_filter(*label, &[*ext])) .save_file() .map(|p| p.to_string_lossy().to_string()) })), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 540d633d..27e14122 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -76,6 +76,21 @@ pub fn run_ui_command(app: &mut CadApp, id: &str, params: &Value) -> Option return run_ui_command(app, "ui.saveas", &Value::Null), + // Menu, toolbar, Cmd+P or typed: ask where to save the PDF. JSON calls (scripts, control + // channel, MCP) always carry params, never get here and never open a dialog. + "plot" | "print" | "exportpdf" if params.is_null() && app.session.state().is_ok() => { + let pick = app.services.pick_save.as_ref()?; + let title = app.session.state().map(|s| s.title.clone()).unwrap_or_default(); + let stem = std::path::Path::new(&title).file_stem().map(|s| s.to_string_lossy().to_string()).filter(|s| !s.is_empty()); + let Some(path) = pick(&format!("{}.pdf", stem.unwrap_or_else(|| "Drawing".into()))) else { return Some(Ok(Value::Null)) }; + let cmd = if id == "exportpdf" { "exportpdf" } else { "plot" }; + let r = app.session.execute(cmd, &json!({ "path": path })).map_err(|e| e.to_string()); + app.session.echo(match &r { + Ok(_) => format!("Plotted to {path}"), + Err(e) => e.clone(), + }); + r + } "ui.sample" => { let d = cadcraft_engine::sample::default_sample(); app.session.open_drawing(d, "Bracket", None); @@ -348,6 +363,7 @@ pub fn shortcuts(app: &mut CadApp, ctx: &egui::Context) { (sc(cmd, Key::O), "ui.open"), (sc(cmd_shift, Key::S), "ui.saveas"), (sc(cmd, Key::S), "qsave"), + (sc(cmd, Key::P), "plot"), (sc(cmd, Key::A), "selectall"), (sc(cmd_shift, Key::C), "copybase"), (sc(cmd, Key::C), "copyclip"), @@ -396,3 +412,71 @@ pub fn shortcuts(app: &mut CadApp, ctx: &egui::Context) { } } } + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + use std::rc::Rc; + + use cadcraft_engine::Session; + use cadcraft_engine::cmd::file::{IoHooks, io, set_io}; + use serde_json::{Value, json}; + + use crate::{CadApp, Services}; + + fn fake_plot(_: &cadcraft_doc::Drawing, _: &cadcraft_doc::Space, _: &Value) -> Result, String> { + Ok(b"%PDF-1.4 test".to_vec()) + } + + /// An app whose save dialog records the suggested name and answers `answer`. + fn app_with_picker(answer: Option) -> (CadApp, Rc>>) { + let asked = Rc::new(RefCell::new(Vec::new())); + let log = Rc::clone(&asked); + let services = Services { + pick_open: None, + pick_save: Some(Box::new(move |name: &str| { + log.borrow_mut().push(name.to_string()); + answer.clone() + })), + }; + (CadApp::new(Session::new(), services), asked) + } + + #[test] + fn print_asks_where_to_save_the_pdf() { + set_io(IoHooks { read: |_, _| Err("no reader in tests".into()), write: |_, _| Err("no writer in tests".into()), plot: Some(fake_plot) }); + if io().and_then(|h| h.plot).is_none() { + return; // another test installed hooks without a plotter first + } + let path = std::env::temp_dir().join(format!("cadcraft-print-test-{}.pdf", std::process::id())); + let ps = path.to_string_lossy().to_string(); + let _ = std::fs::remove_file(&path); + let (mut app, asked) = app_with_picker(Some(ps.clone())); + + // Toolbar, menu and Cmd+P all start the command by name. + app.start("plot"); + assert_eq!(*asked.borrow(), vec!["Drawing1.pdf".to_string()]); + assert!(std::fs::read(&path).unwrap().starts_with(b"%PDF")); + assert!(app.session.log.iter().any(|l| l.contains(&ps))); + let _ = std::fs::remove_file(&path); + + app.cmdline("print"); + app.start("exportpdf"); + assert_eq!(asked.borrow().len(), 3); + assert!(path.exists()); + let _ = std::fs::remove_file(&path); + + // Programmatic calls never open a dialog. + let r = app.run("plot", json!({})).unwrap(); + assert!(r["data"].is_string()); + assert_eq!(asked.borrow().len(), 3); + } + + #[test] + fn cancelled_print_writes_nothing() { + let (mut app, asked) = app_with_picker(None); + app.start("plot"); + assert_eq!(asked.borrow().len(), 1); + assert!(app.session.running.is_none()); + } +}