From 7bc9fd72204ad876e63003fdb36b16e70a47fda6 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 16 Oct 2026 22:08:09 -0700 Subject: [PATCH] Save: save panels for files other than documents offer the file type they write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document Info's Save, Save Swatch Library and Save Graphic Style Library to a file, the Libraries panel's Export Library…, the presets dialogs' Export…, the CSS Properties export, Print to a PostScript file and Keyboard Shortcuts › Export… passed no file type to the save panel. On Windows the panel listed "All Files", and neither the Windows nor the macOS panel added the extension to a name typed without one. Each of these saves now passes the one file type it writes, as Save and Export do for document formats. The Windows panel then lists it, and the Windows and macOS panels add its extension. The labels are the ones the open dialogs use for these files (Swatch libraries, Libraries, the presets, Shortcut sets), the Window menu's Graphic Style Libraries, Text for Document Info, CSS and PostScript. --- crates/ui-egui/src/io.rs | 41 +++++++++++++--- crates/ui-egui/src/shortcut_editor.rs | 3 +- crates/ui-egui/src/tests_saveext.rs | 67 +++++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index cbf3bc5e3..6e67578a8 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -286,14 +286,16 @@ pub(crate) fn is_web(app: &VectorcraftApp) -> bool { app.services.download.is_some() } -/// Write `bytes` to `path`, else to a file picked with `name` suggested next to the document (the -/// web downloads them as `name`) → where they went. +/// Write `bytes` to `path`, else to a file picked in a save panel that suggests `name` next to the +/// document and shows its file type, [`file_types`] (the web downloads them as `name`) → where +/// they went. pub(crate) fn write_named(app: &mut VectorcraftApp, path: Option, name: &str, bytes: &[u8]) -> Result { let path = match path { Some(p) => p, None => { let (_, folder) = suggested(app, ""); - pick_path(app, &FilePick { name: name.to_string(), folder, ..Default::default() })? + let filters = file_types(&fileio::extension(name)); + pick_path(app, &FilePick { name: name.to_string(), folder, filters })? } }; write_reporting(app, &path, bytes)?; @@ -344,14 +346,40 @@ fn suggested(app: &VectorcraftApp, ext: &str) -> (String, Option) { (format!("{}.{ext}", fileio::file_stem(&t)), folder.filter(|f| !f.is_empty())) } +/// The save panel's file type, `(label, extensions)`, for the files `write_named` and +/// `target_path` write that are not a document format (`fileio::format` has those). +const FILE_TYPES: &[(&str, &[&str])] = &[ + ("Swatch libraries", &["vcswatches"]), + ("Swatch libraries", &["gpl"]), + ("Swatch libraries", &["ase"]), + ("Graphic Style Libraries", &[vectorcraft_doc::style_libs::STYLES_EXT]), + ("Libraries", vectorcraft_engine::cmd::library::LIBRARY_EXTS), + ("Flattener presets", vectorcraft_engine::cmd::flatten::PRESET_EXTS), + ("PDF presets", vectorcraft_engine::cmd::pdfcmds::PRESET_EXTS), + ("Perspective grid presets", vectorcraft_engine::cmd::perspgrid::PRESET_EXTS), + ("Print presets", vectorcraft_engine::cmd::printpresets::PRESET_EXTS), + ("CSS", &["css"]), + ("PostScript", &["ps"]), +]; + +/// The file type a save panel shows for a file with extension `ext`: its format's, else its +/// [`FILE_TYPES`] row; none for any other extension. +fn file_types(ext: &str) -> Vec<(&'static str, &'static [&'static str])> { + if let Some(f) = fileio::format(ext) { + return vec![(f.label, f.extensions)]; + } + let ext = ext.trim_start_matches('.').to_ascii_lowercase(); + FILE_TYPES.iter().find(|(_, exts)| exts.contains(&ext.as_str())).copied().into_iter().collect() +} + /// `path`, else one picked in a save panel (the web: the suggested name) for the active document -/// with extension `ext`, filtered to that format when it is one. +/// with extension `ext`, filtered to its file type when it has one ([`file_types`]). pub(crate) fn target_path(app: &mut VectorcraftApp, path: Option, ext: &str) -> Result { if let Some(p) = path { return Ok(p); } let (name, folder) = suggested(app, ext); - let filters = fileio::format(ext).map(|f| vec![(f.label, f.extensions)]).unwrap_or_default(); + let filters = file_types(ext); pick_path(app, &FilePick { name, folder, filters }) } @@ -684,7 +712,8 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option Result { let (path, _) = run_to_file(app, id, ext, params)?; app.status(format!("Saved {path}")); diff --git a/crates/ui-egui/src/shortcut_editor.rs b/crates/ui-egui/src/shortcut_editor.rs index 9871d2702..9d0b3aea6 100644 --- a/crates/ui-egui/src/shortcut_editor.rs +++ b/crates/ui-egui/src/shortcut_editor.rs @@ -476,7 +476,8 @@ pub fn run_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option { let v = export_json(&app.ui.shortcut_set, &app.ui.shortcut_overrides); let bytes = serde_json::to_vec_pretty(&v).unwrap_or_default(); - let path = s("path").or_else(|| crate::picks::save(app, &crate::FilePick::named("VectorCraft Shortcuts.json"))); + let pick = crate::FilePick { filters: vec![("Shortcut sets", &["json"])], ..crate::FilePick::named("VectorCraft Shortcuts.json") }; + let path = s("path").or_else(|| crate::picks::save(app, &pick)); match path { Some(path) => match app.services.write.as_mut() { Some(w) => w(&path, &bytes).map(|_| json!({"path": path})), diff --git a/crates/ui-egui/src/tests_saveext.rs b/crates/ui-egui/src/tests_saveext.rs index 949da5c39..c989f06fa 100644 --- a/crates/ui-egui/src/tests_saveext.rs +++ b/crates/ui-egui/src/tests_saveext.rs @@ -34,6 +34,73 @@ fn last(w: &Written) -> String { w.borrow().last().cloned().unwrap_or_default() } +/// The file types each save panel was asked to show. +type Shown = Rc>>>; + +/// An app with a document whose save panel records the file types it shows and answers the +/// suggested name in `/tmp`. +fn panel_app() -> (VectorcraftApp, Shown) { + let shown = Shown::default(); + let s = shown.clone(); + let services = Services { + write: Some(Box::new(|_: &str, _: &[u8]| Ok(()))), + pick_save: Some(Box::new(move |pick: &FilePick| { + s.borrow_mut().push(pick.filters.clone()); + Some(format!("/tmp/{}", pick.name)) + })), + ..Default::default() + }; + let mut app = VectorcraftApp::new(Session::new(), services); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap(); + (app, shown) +} + +/// The file types the last save panel showed. +fn last_shown(s: &Shown) -> Vec<(&'static str, &'static [&'static str])> { + s.borrow().last().cloned().unwrap_or_default() +} + +/// Document Info's, the libraries', the presets', the CSS, the PostScript and the shortcut saves +/// show the one file type they write in the save panel, as Save and Export show theirs. +#[test] +fn save_panels_show_the_file_type_they_write() { + use vectorcraft_engine::cmd::{flatten, pdfcmds, perspgrid, printpresets}; + let (mut app, shown) = panel_app(); + app.run("docInfo.save", json!({})).unwrap(); + assert_eq!(last_shown(&shown), [("Text", &["txt"][..])], "Document Info › Save"); + for format in ["vcswatches", "gpl", "ase", "css"] { + app.run("ui.saveSwatchLibrary", json!({})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("format".into(), json!(format)); + crate::dialogs::confirm(&mut app).unwrap(); + let label = if format == "css" { "CSS" } else { "Swatch libraries" }; + assert_eq!(last_shown(&shown), [(label, &[format][..])], "Save Swatch Library as .{format}"); + } + app.run("ui.saveGraphicStyleLibrary", json!({})).unwrap(); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(last_shown(&shown), [("Graphic Style Libraries", &["vcstyles"][..])], "Save Graphic Style Library"); + let id = app.run("library.create", json!({"name": "Brand"})).unwrap()["id"].as_str().unwrap().to_string(); + io::save_command_output_named(&mut app, "library.export", "Brand.vclibrary", json!({"library": id})).unwrap(); + assert_eq!(last_shown(&shown), [("Libraries", &["vclibrary"][..])], "Libraries panel › Export Library…"); + app.run("css.exportFile", json!({"scope": "all"})).unwrap(); + assert_eq!(last_shown(&shown), [("CSS", &["css"][..])], "CSS Properties › Export All…"); + app.run("file.print", json!({"format": "postscript"})).unwrap(); + assert_eq!(last_shown(&shown), [("PostScript", &["ps"][..])], "Print to a PostScript file"); + app.run("shortcuts.export", json!({})).unwrap(); + assert_eq!(last_shown(&shown), [("Shortcut sets", &["json"][..])], "Keyboard Shortcuts › Export…"); + let presets = [ + ("pdf.preset.save", json!({"name": "Mine"}), "pdf.preset.export", "PDF presets", pdfcmds::PRESET_EXTS), + ("print.presets.save", json!({"name": "Mine"}), "print.presets.export", "Print presets", printpresets::PRESET_EXTS), + ("flattener.presets.save", json!({"name": "Mine"}), "flattener.presets.export", "Flattener presets", flatten::PRESET_EXTS), + ("perspective.presets.save", json!({"name": "Mine"}), "perspective.presets.export", "Perspective grid presets", perspgrid::PRESET_EXTS), + ]; + for (save, params, export, label, exts) in presets { + app.run(save, params).unwrap(); + io::save_command_output(&mut app, export, exts[0], json!({"names": ["Mine"]})).unwrap(); + assert_eq!(last_shown(&shown), [(label, exts)], "{export}: the presets dialog's Export…"); + } +} + #[test] fn saves_add_the_format_extension_to_a_bare_name() { let (mut app, w) = saving_app("/tmp/Untitled-1");