Merge pull request #704 from isaac-levine:file-export-pdf-menu
This commit is contained in:
commit
2f38d0162f
|
|
@ -24,6 +24,7 @@ fn tree() -> Vec<(&'static str, Vec<Entry>)> {
|
|||
("Close", "file.close", Some("CMD+W")),
|
||||
("Save", "file.save", Some("CMD+S")),
|
||||
("Save As…", "file.saveAs", Some("CMD+SHIFT+S")),
|
||||
("Export as PDF…", "file.exportPdf", None),
|
||||
("Export as CSV…", "dialog:saveCopy:csv", None),
|
||||
("Save as Web Page…", "dialog:saveCopy:html", None),
|
||||
("-", "", None),
|
||||
|
|
@ -317,6 +318,15 @@ impl NativeMenu {
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn file_menu_has_export_as_pdf_dispatching_the_command() {
|
||||
let tree = tree();
|
||||
let (_, file) = tree.iter().find(|(title, _)| *title == "File").expect("File menu");
|
||||
let item = file.iter().find(|e| e.0 == "Export as PDF…").expect("Export as PDF… in the File menu");
|
||||
assert_eq!(item.1, "file.exportPdf");
|
||||
assert!(gridcraft_engine::find_command("file.exportPdf").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_choices_use_the_shared_preference_command() {
|
||||
let mut app = SheetApp::new(gridcraft_engine::Session::new(), Default::default());
|
||||
|
|
|
|||
|
|
@ -2054,6 +2054,23 @@ mod tests {
|
|||
assert!(s.execute("file.exportPdf", json!({})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_pdf_without_a_path_returns_bytes_and_never_opens_a_dialog() {
|
||||
let mut s = s();
|
||||
for params in [json!({}), json!({"path": ""}), json!({"path": null})] {
|
||||
let r = s.execute("file.exportPdf", params).unwrap();
|
||||
assert!(r.get("path").is_none(), "no file is written without a path");
|
||||
let bytes = crate::io::base64_decode(r["base64"].as_str().unwrap()).unwrap();
|
||||
assert!(bytes.starts_with(b"%PDF-"));
|
||||
}
|
||||
assert!(s.take_ui_requests().is_empty(), "a programmatic call never queues a dialog");
|
||||
// Headless callers (CLI, MCP) get the same bytes, not an error.
|
||||
assert!(s.execute_headless("file.exportPdf", json!({})).is_ok());
|
||||
// With no workbook open it is a clear error, not a panic.
|
||||
let mut none = Session::new();
|
||||
assert!(none.execute("file.exportPdf", json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bands_unit() {
|
||||
let b = bands(0, 9, |_| 10.0, |_| 35.0, &[], 100);
|
||||
|
|
|
|||
|
|
@ -496,11 +496,39 @@ impl SheetApp {
|
|||
}
|
||||
return self.print_now(params);
|
||||
}
|
||||
// Menu-style Export as PDF with no file named: ask where to save, like Save As does.
|
||||
// Programmatic callers use `run` and keep getting the PDF bytes back.
|
||||
if id == "file.exportPdf" && self.services.pick_save.is_some() && params.get("path").and_then(Json::as_str).is_none_or(str::is_empty) {
|
||||
return self.export_pdf_with_picker(params);
|
||||
}
|
||||
if let Err(e) = self.run(id, params) {
|
||||
self.alert(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks for a `.pdf` path with the save picker, then re-runs `file.exportPdf` with it. Other
|
||||
/// params (range, sheets, fitToPage) are kept. A cancelled picker does nothing.
|
||||
fn export_pdf_with_picker(&mut self, mut params: Json) {
|
||||
if !self.commit_edit(0, 0, false, false) {
|
||||
return;
|
||||
}
|
||||
let title = self.session.active().map(|d| d.display_title()).unwrap_or_else(|| "Book1".into());
|
||||
let stem = std::path::Path::new(&title).file_stem().and_then(|s| s.to_str()).unwrap_or("Book1").to_string();
|
||||
let Some(pick) = &self.services.pick_save else { return };
|
||||
let Some(path) = pick(&format!("{stem}.pdf")) else { return };
|
||||
// The picker may suggest a workbook extension; the export writes a PDF.
|
||||
let path = std::path::Path::new(&path).with_extension("pdf").to_string_lossy().into_owned();
|
||||
match &mut params {
|
||||
Json::Object(map) => {
|
||||
map.insert("path".into(), Json::String(path));
|
||||
}
|
||||
other => *other = json!({"path": path}),
|
||||
}
|
||||
if let Err(e) = self.run("file.exportPdf", params) {
|
||||
self.alert(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows a command error: a toast for an unavailable command, else a message box.
|
||||
fn alert(&mut self, e: String) {
|
||||
if e.contains("is not available right now") {
|
||||
|
|
|
|||
|
|
@ -131,6 +131,9 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) {
|
|||
}
|
||||
let share = small_button(ui, Icon::Share, "Share", "Share: export or save a copy", true);
|
||||
egui::Popup::menu(&share).show(|ui| {
|
||||
if ui.button("Export as PDF…").clicked() {
|
||||
app.run_or_alert("file.exportPdf", json!({}));
|
||||
}
|
||||
for (label, fmt) in
|
||||
[("Save a Copy as Excel Workbook (.xlsx)…", "xlsx"), ("Export as CSV…", "csv"), ("Export as Web Page (.html)…", "html")]
|
||||
{
|
||||
|
|
|
|||
|
|
@ -171,3 +171,49 @@ fn save_as_offers_pdf_and_leaves_the_workbook_file_alone() {
|
|||
assert!(d.path.is_none() && d.is_dirty(), "a PDF is a copy, not the workbook's file");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
fn pdf_picker_harness(dir: &std::path::Path) -> (Arc<Mutex<Vec<String>>>, egui_kittest::Harness<'static, SheetApp>) {
|
||||
let asked = Arc::new(Mutex::new(Vec::<String>::new()));
|
||||
let (capture, target) = (asked.clone(), dir.to_path_buf());
|
||||
let h = harness(Services {
|
||||
pick_save: Some(Box::new(move |suggested| {
|
||||
capture.lock().unwrap().push(suggested.to_string());
|
||||
Some(target.join("Report.xlsx").to_string_lossy().into_owned())
|
||||
})),
|
||||
..Default::default()
|
||||
});
|
||||
(asked, h)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_palette_export_as_pdf_asks_where_to_save() {
|
||||
let dir = std::env::temp_dir().join(format!("gridcraft-export-pdf-palette-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (asked, mut h) = pdf_picker_harness(&dir);
|
||||
h.state_mut().run("cell.set", serde_json::json!({"cell": "A1", "input": "42"})).unwrap();
|
||||
// The palette and menus go through run_or_alert.
|
||||
h.state_mut().run_or_alert("file.exportPdf", serde_json::json!({}));
|
||||
assert!(asked.lock().unwrap().first().is_some_and(|s| s.ends_with(".pdf")), "the picker is offered a .pdf name");
|
||||
assert!(std::fs::read(dir.join("Report.pdf")).unwrap().starts_with(b"%PDF-"), "the PDF is written, whatever extension the picker returned");
|
||||
assert!(h.state().message.is_none());
|
||||
let d = h.state().session.active().unwrap();
|
||||
assert!(d.path.is_none(), "a PDF is a copy, not the workbook's file");
|
||||
// Other params survive the round trip through the picker.
|
||||
h.state_mut().run_or_alert("file.exportPdf", serde_json::json!({"range": "nope"}));
|
||||
assert!(h.state().message.is_some(), "a bad range is reported, not swallowed");
|
||||
// A path in the params skips the picker.
|
||||
let before = asked.lock().unwrap().len();
|
||||
let named = dir.join("Named.pdf");
|
||||
h.state_mut().run_or_alert("file.exportPdf", serde_json::json!({"path": named.to_string_lossy()}));
|
||||
assert_eq!(asked.lock().unwrap().len(), before);
|
||||
assert!(named.exists());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cancelled_export_pdf_picker_writes_nothing() {
|
||||
let mut h = harness(Services { pick_save: Some(Box::new(|_| None)), ..Default::default() });
|
||||
h.state_mut().run_or_alert("file.exportPdf", serde_json::json!({}));
|
||||
assert!(h.state().message.is_none());
|
||||
assert!(h.state_mut().session.take_ui_requests().is_empty());
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue