From 3f729b6481cf97fc45e7f39b70b63bfa869cba45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ju=CC=88rg=20Lehni?= Date: Tue, 20 Oct 2026 05:22:19 +0200 Subject: [PATCH] FilePick filters own their text, so a dialog's file types can be made at run time: FileFilter is (String, Vec), FilePick::filter makes one from literals and FilePick::filters converts the engine's lists; the macOS save panel, the Linux portal and the rfd dialogs take the owned lists. No behaviour changes. --- apps/vectorcraft/src/mac_save_format.rs | 49 ++++++++++--------- apps/vectorcraft/src/main.rs | 11 ++--- apps/vectorcraft/src/portal_save.rs | 6 ++- crates/ui-egui/src/dialogs/effect.rs | 2 +- .../ui-egui/src/dialogs/flattener_presets.rs | 2 +- crates/ui-egui/src/dialogs/graph_data.rs | 2 +- .../ui-egui/src/dialogs/javascript_files.rs | 2 +- crates/ui-egui/src/dialogs/missing_links.rs | 3 +- crates/ui-egui/src/dialogs/pdf_presets.rs | 5 +- .../src/dialogs/perspective_presets.rs | 2 +- crates/ui-egui/src/dialogs/print_presets.rs | 2 +- crates/ui-egui/src/dialogs/recolor.rs | 3 +- crates/ui-egui/src/dialogs/save_options.rs | 7 ++- crates/ui-egui/src/dialogs/script_check.rs | 2 +- crates/ui-egui/src/dialogs/shortcut_import.rs | 3 +- crates/ui-egui/src/io.rs | 18 ++++--- crates/ui-egui/src/lib.rs | 18 ++++++- crates/ui-egui/src/panels/libraries.rs | 2 +- crates/ui-egui/src/panels/library_panel.rs | 2 +- crates/ui-egui/src/panels/links.rs | 4 +- crates/ui-egui/src/panels/separations.rs | 2 +- crates/ui-egui/src/panels/variables.rs | 4 +- crates/ui-egui/src/place.rs | 4 +- crates/ui-egui/src/shortcut_editor.rs | 10 +++- crates/ui-egui/src/tests_picks.rs | 4 +- crates/ui-egui/src/tests_saveext.rs | 20 ++++---- crates/ui-egui/src/tests_script_check.rs | 4 +- 27 files changed, 115 insertions(+), 78 deletions(-) diff --git a/apps/vectorcraft/src/mac_save_format.rs b/apps/vectorcraft/src/mac_save_format.rs index 519c98097..845d44d82 100644 --- a/apps/vectorcraft/src/mac_save_format.rs +++ b/apps/vectorcraft/src/mac_save_format.rs @@ -26,9 +26,7 @@ use objc2_app_kit::{NSModalResponse, NSModalResponseOK, NSPopUpButton, NSSavePan use objc2_foundation::{NSArray, NSObjectProtocol, NSPoint, NSRect, NSSize, NSString, NSURL}; use raw_window_handle::{HasWindowHandle, RawWindowHandle}; use vectorcraft_engine::cmd::fileio; -use vectorcraft_ui_egui::{FilePick, i18n}; - -type Filter = (&'static str, &'static [&'static str]); +use vectorcraft_ui_egui::{FileFilter as Filter, FilePick, i18n}; /// The accessory's height and the room around and between its label and menu, in points. const HEIGHT: f64 = 38.0; @@ -70,7 +68,8 @@ define_class!( /// The file types a Format menu lists: `filters` without "every file" ones. Empty when fewer than /// two remain, and the panel is then left to rfd. fn menu_formats(filters: &[Filter]) -> Vec { - let named: Vec = filters.iter().copied().filter(|(_, extensions)| !extensions.is_empty() && !extensions.contains(&"*")).collect(); + let named: Vec = + filters.iter().filter(|(_, extensions)| !extensions.is_empty() && !extensions.iter().any(|x| x == "*")).cloned().collect(); if named.len() < 2 { Vec::new() } else { named } } @@ -95,10 +94,10 @@ fn filename(name: &str, extension: &str, filters: &[Filter]) -> String { /// The extensions a panel with menu `filters` allows, those of entry `selected` first. AppKit adds /// the first one to a name typed without an extension, and a name typed with another of them is /// saved in that format. -fn allowed_types(filters: &[Filter], selected: usize) -> Vec<&'static str> { - let first = filters.get(selected).map_or(&[][..], |(_, extensions)| *extensions); +fn allowed_types(filters: &[Filter], selected: usize) -> Vec<&str> { + let first = filters.get(selected).map_or(&[][..], |(_, extensions)| extensions.as_slice()); let others = filters.iter().enumerate().filter(|(i, _)| *i != selected).flat_map(|(_, (_, extensions))| extensions.iter()); - first.iter().chain(others).copied().collect() + first.iter().chain(others).map(String::as_str).collect() } /// Set `panel`'s allowed file types ([`allowed_types`]). @@ -184,26 +183,28 @@ fn accessory(mtm: MainThreadMarker, panel: &NSSavePanel, filters: Vec, s mod tests { use super::*; - const FILTERS: &[Filter] = &[("VectorCraft", &["vectorcraft"]), ("SVG", &["svg"]), ("PDF", &["pdf"])]; + fn filters() -> Vec { + FilePick::filters([("VectorCraft", &["vectorcraft"][..]), ("SVG", &["svg"]), ("PDF", &["pdf"])]) + } /// Save, Save As and Save a Copy list every save format with the default first; a panel with one /// named type, or one for every file, gets no menu, and rfd shows it. #[test] fn save_panels_with_several_formats_get_the_menu() { - let save = menu_formats(&fileio::save_filters("vectorcraft")); + let save = menu_formats(&FilePick::filters(fileio::save_filters("vectorcraft"))); assert!(save.len() >= 2, "{save:?}"); - assert_eq!(save.first().map(|(_, e)| *e), Some(&["vectorcraft"][..])); - assert!(menu_formats(&fileio::save_filters("svg")).first().is_some_and(|(_, e)| e.contains(&"svg"))); - assert!(menu_formats(&fileio::template_filters("template")).len() >= 2); - assert!(menu_formats(&[("PDF", &["pdf"])]).is_empty()); - assert!(menu_formats(&[("PDF", &["pdf"]), ("All files", &["*"]), ("Any", &[])]).is_empty()); - assert_eq!(menu_formats(&[("PDF", &["pdf"]), ("All files", &["*"]), ("SVG", &["svg"])]).len(), 2); + assert_eq!(save.first().map(|(_, e)| e.as_slice()), Some(&["vectorcraft".to_owned()][..])); + assert!(menu_formats(&FilePick::filters(fileio::save_filters("svg"))).first().is_some_and(|(_, e)| e.iter().any(|e| e == "svg"))); + assert!(menu_formats(&FilePick::filters(fileio::template_filters("template"))).len() >= 2); + assert!(menu_formats(&FilePick::filters([("PDF", &["pdf"][..])])).is_empty()); + assert!(menu_formats(&FilePick::filters([("PDF", &["pdf"][..]), ("All files", &["*"]), ("Any", &[])])).is_empty()); + assert_eq!(menu_formats(&FilePick::filters([("PDF", &["pdf"][..]), ("All files", &["*"]), ("SVG", &["svg"])])).len(), 2); } #[test] fn the_suggested_name_selects_its_format_and_an_unknown_one_the_default() { for (name, index) in [("Untitled-1", 0), ("a b.SVG", 1), ("art.pdf", 2), ("version.2", 0), ("", 0)] { - assert_eq!(initial_format(name, FILTERS), index, "{name}"); + assert_eq!(initial_format(name, &filters()), index, "{name}"); } } @@ -219,7 +220,7 @@ mod tests { ("photo.png", "photo.svg"), ("", ""), ] { - assert_eq!(filename(name, "svg", FILTERS), expected, "{name}"); + assert_eq!(filename(name, "svg", &filters()), expected, "{name}"); } } @@ -227,19 +228,19 @@ mod tests { /// type. The others stay allowed. #[test] fn the_chosen_format_is_the_first_allowed_type() { - assert_eq!(allowed_types(FILTERS, 0), ["vectorcraft", "svg", "pdf"]); - assert_eq!(allowed_types(FILTERS, 1), ["svg", "vectorcraft", "pdf"]); - assert_eq!(allowed_types(FILTERS, 2), ["pdf", "vectorcraft", "svg"]); - assert_eq!(allowed_types(FILTERS, 9), ["vectorcraft", "svg", "pdf"]); + assert_eq!(allowed_types(&filters(), 0), ["vectorcraft", "svg", "pdf"]); + assert_eq!(allowed_types(&filters(), 1), ["svg", "vectorcraft", "pdf"]); + assert_eq!(allowed_types(&filters(), 2), ["pdf", "vectorcraft", "svg"]); + assert_eq!(allowed_types(&filters(), 9), ["vectorcraft", "svg", "pdf"]); } /// Each entry of the Save As menu gives a name the save command reads as that entry's format. #[test] fn each_menu_entry_names_a_file_the_save_command_writes_in_that_format() { - let filters = menu_formats(&fileio::save_filters("vectorcraft")); + let filters = menu_formats(&FilePick::filters(fileio::save_filters("vectorcraft"))); for (label, extensions) in &filters { - let name = filename("Untitled-1.vectorcraft", extensions[0], &filters); - assert_eq!(fileio::format_for_name(&name).map(|f| f.label), Some(*label), "{name}"); + let name = filename("Untitled-1.vectorcraft", &extensions[0], &filters); + assert_eq!(fileio::format_for_name(&name).map(|f| f.label), Some(label.as_str()), "{name}"); } } diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index a457d9808..d3204c4ca 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -293,7 +293,7 @@ type Parent = Option>; trait DialogSettings: Sized { fn parent(self, window: &winit::window::Window) -> Self; fn title(self, title: &str) -> Self; - fn filter(self, name: &str, exts: &[&str]) -> Self; + fn filter(self, name: &str, exts: &[impl ToString]) -> Self; fn folder(self, folder: &str) -> Self; fn file_name(self, name: &str) -> Self; } @@ -307,7 +307,7 @@ macro_rules! dialog_settings { fn title(self, title: &str) -> Self { self.set_title(title) } - fn filter(self, name: &str, exts: &[&str]) -> Self { + fn filter(self, name: &str, exts: &[impl ToString]) -> Self { self.add_filter(name, exts) } fn folder(self, folder: &str) -> Self { @@ -333,10 +333,9 @@ fn dialog_for(d: D, request: &PickRequest) -> D { } PickRequest::Folder => return d, }; - let d = pick - .filters - .iter() - .fold(d, |d, (name, exts)| d.filter(vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", name), exts)); + let d = pick.filters.iter().fold(d, |d, (name, exts)| { + d.filter(vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", name.as_str()), exts) + }); let d = match &pick.folder { Some(folder) => d.folder(folder), None => d, diff --git a/apps/vectorcraft/src/portal_save.rs b/apps/vectorcraft/src/portal_save.rs index c374c8949..b6362107e 100644 --- a/apps/vectorcraft/src/portal_save.rs +++ b/apps/vectorcraft/src/portal_save.rs @@ -326,7 +326,11 @@ mod tests { let pick = FilePick { name: "Untitled-1.vectorcraft".into(), folder: Some("/home/me/Art".into()), - filters: vec![("VectorCraft", &["vectorcraft"]), ("SVG", &["svg"]), ("PDF-compatible .ai", &["ai"])], + filters: vec![ + FilePick::filter("VectorCraft", &["vectorcraft"]), + FilePick::filter("SVG", &["svg"]), + FilePick::filter("PDF-compatible .ai", &["ai"]), + ], }; SaveRequest::new(&pick, "x11:3a0000f".into()) } diff --git a/crates/ui-egui/src/dialogs/effect.rs b/crates/ui-egui/src/dialogs/effect.rs index 32b11744d..dad882e38 100644 --- a/crates/ui-egui/src/dialogs/effect.rs +++ b/crates/ui-egui/src/dialogs/effect.rs @@ -263,7 +263,7 @@ fn glass_textures(loaded: bool) -> form::Choices { /// it and picked in the Texture menu. fn load_texture(app: &mut VectorcraftApp, d: &mut Dialog) -> Result<(), String> { use vectorcraft_engine::cmd::fileio; - let filters = fileio::FORMATS.iter().filter(|f| f.read && f.raster).map(|f| (f.label, f.extensions)).collect(); + let filters = crate::FilePick::filters(fileio::FORMATS.iter().filter(|f| f.read && f.raster).map(|f| (f.label, f.extensions))); let path = crate::picks::open(app, &crate::FilePick { filters, ..Default::default() }).ok_or("cancelled")?; // The button is off where files can't be read. let Some(read) = app.services.read.as_ref() else { return Ok(()) }; diff --git a/crates/ui-egui/src/dialogs/flattener_presets.rs b/crates/ui-egui/src/dialogs/flattener_presets.rs index 4fbac19a5..5a546b532 100644 --- a/crates/ui-egui/src/dialogs/flattener_presets.rs +++ b/crates/ui-egui/src/dialogs/flattener_presets.rs @@ -189,7 +189,7 @@ fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action, current: &str) -> f(); return Ok(()); } - let pick = crate::FilePick { filters: vec![("Flattener presets", PRESET_EXTS)], ..Default::default() }; + let pick = crate::FilePick { filters: vec![crate::FilePick::filter("Flattener presets", PRESET_EXTS)], ..Default::default() }; let path = crate::picks::open(app, &pick).ok_or("cancelled")?; let r = app.run("flattener.presets.import", json!({ "path": path }))?; if let Some(first) = r["imported"].get(0).and_then(Value::as_str) { diff --git a/crates/ui-egui/src/dialogs/graph_data.rs b/crates/ui-egui/src/dialogs/graph_data.rs index 33227897c..92b7e0139 100644 --- a/crates/ui-egui/src/dialogs/graph_data.rs +++ b/crates/ui-egui/src/dialogs/graph_data.rs @@ -327,7 +327,7 @@ fn revert(app: &mut VectorcraftApp, d: &mut Dialog) -> Result<(), String> { /// Import Data: a tab-separated text file's rows into the sheet. fn import(app: &mut VectorcraftApp, d: &mut Dialog) -> Result<(), String> { - let pick = crate::FilePick { filters: vec![("Text", &["txt", "tsv", "csv"])], ..Default::default() }; + let pick = crate::FilePick { filters: vec![crate::FilePick::filter("Text", &["txt", "tsv", "csv"])], ..Default::default() }; let path = crate::picks::open(app, &pick).ok_or("cancelled")?; // The button is off where files can't be read. let Some(read) = app.services.read.as_ref() else { return Ok(()) }; diff --git a/crates/ui-egui/src/dialogs/javascript_files.rs b/crates/ui-egui/src/dialogs/javascript_files.rs index 6f89feead..6fb734e1d 100644 --- a/crates/ui-egui/src/dialogs/javascript_files.rs +++ b/crates/ui-egui/src/dialogs/javascript_files.rs @@ -43,7 +43,7 @@ const JS_EXTS: &[&str] = &["js"]; /// The open dialog for a JavaScript file. fn pick() -> FilePick { - FilePick { filters: vec![("JavaScript", JS_EXTS)], ..Default::default() } + FilePick { filters: vec![FilePick::filter("JavaScript", JS_EXTS)], ..Default::default() } } /// Choose…: the file picked in the open dialog into File or URL, as a `file:///` URL diff --git a/crates/ui-egui/src/dialogs/missing_links.rs b/crates/ui-egui/src/dialogs/missing_links.rs index 27a9c6791..5ea9d77b8 100644 --- a/crates/ui-egui/src/dialogs/missing_links.rs +++ b/crates/ui-egui/src/dialogs/missing_links.rs @@ -172,7 +172,8 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { let path = match d.str("path") { p if !p.is_empty() => p, _ => { - let pick = crate::FilePick { filters: vectorcraft_engine::cmd::fileio::place_filters().collect(), ..Default::default() }; + let pick = + crate::FilePick { filters: crate::FilePick::filters(vectorcraft_engine::cmd::fileio::place_filters()), ..Default::default() }; crate::picks::open(app, &pick).ok_or("cancelled")? } }; diff --git a/crates/ui-egui/src/dialogs/pdf_presets.rs b/crates/ui-egui/src/dialogs/pdf_presets.rs index c8c442ac3..10f7e0e45 100644 --- a/crates/ui-egui/src/dialogs/pdf_presets.rs +++ b/crates/ui-egui/src/dialogs/pdf_presets.rs @@ -177,7 +177,10 @@ fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action, current: &str) -> f(); return Ok(()); } - let pick = crate::FilePick { filters: vec![("PDF presets", vectorcraft_engine::cmd::pdfcmds::PRESET_EXTS)], ..Default::default() }; + let pick = crate::FilePick { + filters: vec![crate::FilePick::filter("PDF presets", vectorcraft_engine::cmd::pdfcmds::PRESET_EXTS)], + ..Default::default() + }; let path = crate::picks::open(app, &pick).ok_or("cancelled")?; let r = app.run("pdf.preset.import", json!({ "path": path }))?; if let Some(first) = r["imported"].get(0).and_then(Value::as_str) { diff --git a/crates/ui-egui/src/dialogs/perspective_presets.rs b/crates/ui-egui/src/dialogs/perspective_presets.rs index a182e66f8..7432bd3b2 100644 --- a/crates/ui-egui/src/dialogs/perspective_presets.rs +++ b/crates/ui-egui/src/dialogs/perspective_presets.rs @@ -195,7 +195,7 @@ pub(super) fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action, current f(); return Ok(()); } - let pick = crate::FilePick { filters: vec![("Perspective grid presets", PRESET_EXTS)], ..Default::default() }; + let pick = crate::FilePick { filters: vec![crate::FilePick::filter("Perspective grid presets", PRESET_EXTS)], ..Default::default() }; let path = crate::picks::open(app, &pick).ok_or("cancelled")?; let r = app.run("perspective.presets.import", json!({ "path": path }))?; if let Some(first) = r["imported"].get(0).and_then(Value::as_str) { diff --git a/crates/ui-egui/src/dialogs/print_presets.rs b/crates/ui-egui/src/dialogs/print_presets.rs index 747269d11..a8a9f4244 100644 --- a/crates/ui-egui/src/dialogs/print_presets.rs +++ b/crates/ui-egui/src/dialogs/print_presets.rs @@ -157,7 +157,7 @@ pub(super) fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action, current f(); return Ok(()); } - let pick = crate::FilePick { filters: vec![("Print presets", PRESET_EXTS)], ..Default::default() }; + let pick = crate::FilePick { filters: vec![crate::FilePick::filter("Print presets", PRESET_EXTS)], ..Default::default() }; let path = crate::picks::open(app, &pick).ok_or("cancelled")?; let r = app.run("print.presets.import", json!({ "path": path }))?; if let Some(first) = r["imported"].get(0).and_then(Value::as_str) { diff --git a/crates/ui-egui/src/dialogs/recolor.rs b/crates/ui-egui/src/dialogs/recolor.rs index 576007ccf..b02057b40 100644 --- a/crates/ui-egui/src/dialogs/recolor.rs +++ b/crates/ui-egui/src/dialogs/recolor.rs @@ -452,7 +452,8 @@ fn assign_tab(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut // Import a library (.ase, .acb, .gpl, .vcswatches or a document's swatches) and limit to it. if app.services.pick_open.is_some() && widgets::icon_button(ui, "folder-open", tl!("Other Library…"), false, 24.0).clicked() { let r = crate::picks::in_dialog(app, d, |app, d| { - let pick = crate::FilePick { filters: vectorcraft_engine::cmd::fileio::open_filters().collect(), ..Default::default() }; + let pick = + crate::FilePick { filters: crate::FilePick::filters(vectorcraft_engine::cmd::fileio::open_filters()), ..Default::default() }; let Some(path) = crate::picks::open(app, &pick) else { return Ok(()) }; let r = app.run("swatch.library.load", json!({ "path": path }))?; d.fields.insert("limitTo".into(), r["library"].clone()); diff --git a/crates/ui-egui/src/dialogs/save_options.rs b/crates/ui-egui/src/dialogs/save_options.rs index ec9b1c43a..74c6dc6f0 100644 --- a/crates/ui-egui/src/dialogs/save_options.rs +++ b/crates/ui-egui/src/dialogs/save_options.rs @@ -543,7 +543,7 @@ mod tests { assert_eq!((r["pending"].as_str(), r["path"].as_str()), (Some("svgOptions"), Some("art.svg"))); // The save panel offers every save format, the document's own first. let pick = &picks.borrow()[0]; - let labels: Vec<&str> = pick.filters.iter().map(|f| f.0).collect(); + let labels: Vec<&str> = pick.filters.iter().map(|f| f.0.as_str()).collect(); assert_eq!(labels, ["VectorCraft", "VectorCraft Template", "PDF", "SVG", "SVG Compressed", "PDF-compatible .ai"]); assert_eq!(pick.name, "Untitled-1.vectorcraft"); let d = dialog(&app); @@ -698,7 +698,10 @@ mod tests { let pick = &picks.borrow()[1]; assert_eq!(pick.name, "Untitled-1 template.vctemplate"); assert_eq!(pick.folder.as_deref().map(std::path::Path::new), Some(folder.as_path())); - assert_eq!(pick.filters, [("VectorCraft Template", &["vctemplate"][..]), ("PDF-compatible .ait template", &["ait"][..])]); + assert_eq!( + pick.filters, + crate::FilePick::filters([("VectorCraft Template", &["vctemplate"][..]), ("PDF-compatible .ait template", &["ait"][..])]) + ); assert_eq!(written.borrow()[1].0, "copy.vectorcraft.vctemplate", "the picked name, with the template's extension"); } diff --git a/crates/ui-egui/src/dialogs/script_check.rs b/crates/ui-egui/src/dialogs/script_check.rs index eb1eff38e..257227cc6 100644 --- a/crates/ui-egui/src/dialogs/script_check.rs +++ b/crates/ui-egui/src/dialogs/script_check.rs @@ -40,7 +40,7 @@ pub(crate) fn command(app: &mut VectorcraftApp, p: &Value) -> Result Result { Some(path) => path, None if app.services.open_async.is_some() => return crate::io::open_dialog(app).map(|_| Value::Null), None => { - let pick = FilePick { filters: vec![("Shortcut sets", vectorcraft_engine::cmd::shortcutset::EXTS)], ..Default::default() }; + let pick = + FilePick { filters: vec![FilePick::filter("Shortcut sets", vectorcraft_engine::cmd::shortcutset::EXTS)], ..Default::default() }; crate::picks::open(app, &pick).ok_or("cancelled")? } }; diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 9ff9a4d63..61355822f 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -152,7 +152,9 @@ pub fn install_plugin(app: &mut VectorcraftApp, path: Option) -> Result< let path = match path { Some(p) => p, None if app.services.open_async.is_some() => return open_dialog(app).map(|_| Value::Null), - None => pick_open(app, &FilePick { filters: vec![("Plug-ins", vectorcraft_engine::cmd::plugin::EXTS)], ..Default::default() })?, + None => { + pick_open(app, &FilePick { filters: vec![FilePick::filter("Plug-ins", vectorcraft_engine::cmd::plugin::EXTS)], ..Default::default() })? + } }; let r = app.run("plugin.install", json!({ "path": path }))?; app.status(format!("Installed plug-in {}", r["name"].as_str().unwrap_or_default())); @@ -165,7 +167,7 @@ pub fn open_dialog(app: &mut VectorcraftApp) -> Result<(), String> { f(); return Ok(()); } - let path = pick_open(app, &FilePick { filters: fileio::open_filters().collect(), ..Default::default() })?; + let path = pick_open(app, &FilePick { filters: FilePick::filters(fileio::open_filters()), ..Default::default() })?; open_reporting(app, &path).map(|_| ()) } @@ -264,7 +266,7 @@ pub fn new_from_template(app: &mut VectorcraftApp, path: Option) -> Resu Some(p) => p, None if app.services.open_async.is_some() => return open_dialog(app).map(|_| Value::Null), None => { - let filters = std::iter::once(("Templates", TEMPLATE_EXTS)).chain(fileio::open_filters()).collect(); + let filters = FilePick::filters(std::iter::once(("Templates", TEMPLATE_EXTS)).chain(fileio::open_filters())); pick_open(app, &FilePick { folder: fileio::templates_dialog_folder(&app.session.prefs), filters, ..Default::default() })? } }; @@ -367,12 +369,12 @@ const FILE_TYPES: &[(&str, &[&str])] = &[ /// 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])> { +fn file_types(ext: &str) -> Vec { if let Some(f) = fileio::format(ext) { - return vec![(f.label, f.extensions)]; + return vec![FilePick::filter(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() + FilePick::filters(FILE_TYPES.iter().find(|(_, exts)| exts.contains(&ext.as_str())).copied()) } /// `path`, else one picked in a save panel (the web: the suggested name) for the active document @@ -461,10 +463,10 @@ pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bo if is_web(app) && ask_options { return Ok(open_options(app, mode.command(), first.format, &first.name, true)); } - let filters = match mode { + let filters = FilePick::filters(match mode { SaveMode::Template => fileio::template_filters(first.format.id), _ => fileio::save_filters(first.format.id), - }; + }); let pick = FilePick { name: first.name.clone(), folder: first.folder.clone(), filters }; let picked = pick_finished(app, &pick, |picked, file_type| with_picked_type(picked, &first.name, file_type, first.format, mode))?; // The picked file type wins over a `format` param. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index cddd40925..e45553480 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -175,14 +175,18 @@ pub use control::{ControlRequest, ControlResponse, Ticket as ControlTicket}; pub use state::{UiState, View}; pub use sysclip::{ClipboardProbeFactory, SystemClipboard}; +/// A file type a dialog filters by: its label, which shows translated, and its extensions without +/// the dot. +pub type FileFilter = (String, Vec); + /// What a file dialog shows: a suggested file name (save dialogs), the folder to start in and the /// file-type filters. #[derive(Clone, Debug, Default, PartialEq)] pub struct FilePick { pub name: String, pub folder: Option, - /// `(label, extensions without the dot)`, the default type first; empty = every file. - pub filters: Vec<(&'static str, &'static [&'static str])>, + /// The default type first; empty = every file. + pub filters: Vec, } impl FilePick { @@ -190,6 +194,16 @@ impl FilePick { pub fn named(name: &str) -> Self { Self { name: name.to_string(), ..Default::default() } } + + /// The file type `label` with `extensions`. + pub fn filter(label: &str, extensions: &[&str]) -> FileFilter { + (label.to_owned(), extensions.iter().map(|e| (*e).to_owned()).collect()) + } + + /// The file types of `list`, as the engine lists them: `(label, extensions)`. + pub fn filters<'a>(list: impl IntoIterator) -> Vec { + list.into_iter().map(|(label, extensions)| Self::filter(label, extensions)).collect() + } } pub type PickOpen = Box Option>; diff --git a/crates/ui-egui/src/panels/libraries.rs b/crates/ui-egui/src/panels/libraries.rs index 8fa48da92..22401eb53 100644 --- a/crates/ui-egui/src/panels/libraries.rs +++ b/crates/ui-egui/src/panels/libraries.rs @@ -635,7 +635,7 @@ fn import_picked(app: &mut VectorcraftApp) { f(); return; } - let pick = crate::FilePick { filters: vec![("Libraries", LIBRARY_EXTS)], ..Default::default() }; + let pick = crate::FilePick { filters: vec![crate::FilePick::filter("Libraries", LIBRARY_EXTS)], ..Default::default() }; if let Some(path) = crate::picks::open(app, &pick) { let r = import(app, json!({ "path": path })); report(app, r); diff --git a/crates/ui-egui/src/panels/library_panel.rs b/crates/ui-egui/src/panels/library_panel.rs index 32c30bd04..5933f2f7e 100644 --- a/crates/ui-egui/src/panels/library_panel.rs +++ b/crates/ui-egui/src/panels/library_panel.rs @@ -481,7 +481,7 @@ pub(crate) fn pick_library_file(app: &mut VectorcraftApp, path: Option) return Ok(None); } path.or_else(|| { - let pick = crate::FilePick { filters: vectorcraft_engine::cmd::fileio::open_filters().collect(), ..Default::default() }; + let pick = crate::FilePick { filters: crate::FilePick::filters(vectorcraft_engine::cmd::fileio::open_filters()), ..Default::default() }; crate::picks::open(app, &pick) }) .map(Some) diff --git a/crates/ui-egui/src/panels/links.rs b/crates/ui-egui/src/panels/links.rs index 128e7f915..7a990e3b7 100644 --- a/crates/ui-egui/src/panels/links.rs +++ b/crates/ui-egui/src/panels/links.rs @@ -123,7 +123,7 @@ pub(crate) fn relink(app: &mut VectorcraftApp, ids: Vec) { } fn relink_picked(app: &mut VectorcraftApp, ids: Vec) { - let pick = crate::FilePick { filters: vectorcraft_engine::cmd::fileio::place_filters().collect(), ..Default::default() }; + let pick = crate::FilePick { filters: crate::FilePick::filters(vectorcraft_engine::cmd::fileio::place_filters()), ..Default::default() }; let Some(path) = crate::picks::open(app, &pick) else { return }; let r = app.run("links.relink", json!({ "ids": ids, "path": path })); report(app, r, |v| format!("Relinked {} image(s)", v["relinked"].as_array().map_or(0, Vec::len))); @@ -134,7 +134,7 @@ fn relink_picked(app: &mut VectorcraftApp, ids: Vec) { /// say. pub(crate) fn replace(app: &mut VectorcraftApp, ids: Vec) { crate::picks::button(app, move |app| { - let pick = crate::FilePick { filters: vectorcraft_engine::cmd::fileio::place_filters().collect(), ..Default::default() }; + let pick = crate::FilePick { filters: crate::FilePick::filters(vectorcraft_engine::cmd::fileio::place_filters()), ..Default::default() }; let Some(path) = crate::picks::open(app, &pick) else { return }; let r = app.run("links.relink", json!({ "ids": ids.clone(), "path": path, "preserve": "fit" })); report(app, r, |v| format!("Replaced {} image(s)", v["relinked"].as_array().map_or(0, Vec::len))); diff --git a/crates/ui-egui/src/panels/separations.rs b/crates/ui-egui/src/panels/separations.rs index 88d6b7cad..e50db115e 100644 --- a/crates/ui-egui/src/panels/separations.rs +++ b/crates/ui-egui/src/panels/separations.rs @@ -112,7 +112,7 @@ fn settings_section(app: &mut VectorcraftApp, ui: &mut Ui) { if widgets::flat_button(ui, tl!("Load Profile…"), 110.0).clicked() { // Its file dialog, shown off the UI thread, loads the profile when it answers. crate::picks::button(app, |app| { - let pick = crate::FilePick { filters: vec![("ICC Profiles", &["icc", "icm"])], ..Default::default() }; + let pick = crate::FilePick { filters: vec![crate::FilePick::filter("ICC Profiles", &["icc", "icm"])], ..Default::default() }; if let Some(path) = crate::picks::open(app, &pick) { run(app, "color.loadProfile", json!({ "path": path })); } diff --git a/crates/ui-egui/src/panels/variables.rs b/crates/ui-egui/src/panels/variables.rs index 4133b488a..4e8cfb56f 100644 --- a/crates/ui-egui/src/panels/variables.rs +++ b/crates/ui-egui/src/panels/variables.rs @@ -334,7 +334,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { /// Variables panel: Import Data Sets… opens a file dialog for a comma- or tab-separated file and runs `dataset.import` on the chosen file. fn import_datasets(app: &mut VectorcraftApp) { - let pick = crate::FilePick { filters: vec![("CSV", &["csv", "tsv", "txt"])], ..Default::default() }; + let pick = crate::FilePick { filters: vec![crate::FilePick::filter("CSV", &["csv", "tsv", "txt"])], ..Default::default() }; let Some(path) = crate::picks::open(app, &pick) else { return }; if let Err(e) = app.run("dataset.import", json!({"path": path})) { app.status(e); @@ -343,7 +343,7 @@ fn import_datasets(app: &mut VectorcraftApp) { /// Variables panel: Export Data Sets… — pick a path and run `dataset.export`. fn export_datasets(app: &mut VectorcraftApp) { - let pick = crate::FilePick { name: "datasets.csv".into(), filters: vec![("CSV", &["csv"])], ..Default::default() }; + let pick = crate::FilePick { name: "datasets.csv".into(), filters: vec![crate::FilePick::filter("CSV", &["csv"])], ..Default::default() }; let Some(path) = crate::picks::save(app, &pick) else { return }; if let Err(e) = app.run("dataset.export", json!({"path": path})) { app.status(e); diff --git a/crates/ui-egui/src/place.rs b/crates/ui-egui/src/place.rs index cc3022f14..4826f8c8b 100644 --- a/crates/ui-egui/src/place.rs +++ b/crates/ui-egui/src/place.rs @@ -139,7 +139,9 @@ fn pick(app: &mut VectorcraftApp) -> Result<(), String> { let paths = if app.services.pick_open_multi.is_some() { crate::picks::open_many(app) } else { - crate::picks::open(app, &crate::FilePick { filters: fileio::place_filters().collect(), ..Default::default() }).into_iter().collect() + crate::picks::open(app, &crate::FilePick { filters: crate::FilePick::filters(fileio::place_filters()), ..Default::default() }) + .into_iter() + .collect() }; if paths.is_empty() { return Err("cancelled".into()); diff --git a/crates/ui-egui/src/shortcut_editor.rs b/crates/ui-egui/src/shortcut_editor.rs index 7773428e0..28ec31706 100644 --- a/crates/ui-egui/src/shortcut_editor.rs +++ b/crates/ui-egui/src/shortcut_editor.rs @@ -476,7 +476,10 @@ 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 pick = crate::FilePick { filters: vec![("Shortcut sets", &["json"])], ..crate::FilePick::named("VectorCraft Shortcuts.json") }; + let pick = crate::FilePick { + filters: vec![crate::FilePick::filter("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() { @@ -495,7 +498,10 @@ pub fn run_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option String { } /// The file types each save panel was asked to show. -type Shown = Rc>>>; +type Shown = Rc>>>; /// An app with a document whose save panel records the file types it shows and answers the /// suggested name in `/tmp`. @@ -57,7 +57,7 @@ fn panel_app() -> (VectorcraftApp, Shown) { } /// The file types the last save panel showed. -fn last_shown(s: &Shown) -> Vec<(&'static str, &'static [&'static str])> { +fn last_shown(s: &Shown) -> Vec { s.borrow().last().cloned().unwrap_or_default() } @@ -68,26 +68,26 @@ 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"); + assert_eq!(last_shown(&shown), FilePick::filters([("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}"); + assert_eq!(last_shown(&shown), FilePick::filters([(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"); + assert_eq!(last_shown(&shown), FilePick::filters([("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…"); + assert_eq!(last_shown(&shown), FilePick::filters([("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…"); + assert_eq!(last_shown(&shown), FilePick::filters([("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"); + assert_eq!(last_shown(&shown), FilePick::filters([("PostScript", &["ps"][..])]), "Print to a PostScript file"); app.run("shortcuts.export", json!({})).unwrap(); - assert_eq!(last_shown(&shown), [("Shortcut sets", &["json"][..])], "Keyboard Shortcuts › Export…"); + assert_eq!(last_shown(&shown), FilePick::filters([("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), @@ -97,7 +97,7 @@ fn save_panels_show_the_file_type_they_write() { 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…"); + assert_eq!(last_shown(&shown), FilePick::filters([(label, exts)]), "{export}: the presets dialog's Export…"); } } diff --git a/crates/ui-egui/src/tests_script_check.rs b/crates/ui-egui/src/tests_script_check.rs index 053d371e6..ccaa6c7a8 100644 --- a/crates/ui-egui/src/tests_script_check.rs +++ b/crates/ui-egui/src/tests_script_check.rs @@ -129,7 +129,7 @@ fn file_open_of_a_script_checks_it_with_what_it_includes() { })); app.ui.dialog = None; menus::run_ui_command(&mut app, "ui.checkScript", &json!({})).unwrap().unwrap(); - assert_eq!(asked.borrow().as_slice(), [vec![("Scripts", &["jsx", "jsxinc", "jsxbin"][..])]]); + assert_eq!(asked.borrow().as_slice(), [crate::FilePick::filters([("Scripts", &["jsx", "jsxinc", "jsxbin"][..])])]); assert_eq!(report(&app)["name"], "main.jsx"); let _ = std::fs::remove_dir_all(dir); } @@ -172,7 +172,7 @@ fn check_script_asks_off_the_ui_thread_and_needs_no_document() { app.run("file.new", json!({"width": 100, "height": 80})).unwrap(); assert!(app.run("ui.checkScript", json!({})).is_err(), "nothing yet: the dialog is open"); assert!( - matches!(shown.requests().as_slice(), [crate::picks::PickRequest::Open(pick)] if pick.filters == [("Scripts", &["jsx", "jsxinc", "jsxbin"][..])]) + matches!(shown.requests().as_slice(), [crate::picks::PickRequest::Open(pick)] if pick.filters == crate::FilePick::filters([("Scripts", &["jsx", "jsxinc", "jsxbin"][..])])) ); app.run("file.close", json!({})).unwrap(); shown.answer(&[&path.to_string_lossy()]);