diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 52d54ce19..4fda8e1ca 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -132,7 +132,8 @@ use formats::{ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, import_name, writable}; pub use formats::{ DOC_EXTS, DOCX_EXTS, FORMATS, Format, FormatOption, OPEN_EXTS, PLACE_EXTS, RTF_EXTS, TEXT_EXTS, UNASSOCIATED_EXTS, builtin_open_filters, export_extensions, extension, file_stem, format, format_for_name, has_own_title, importer_formats, library_open_filters, open_extensions, - open_filters, place_filters, plugin_formats, read_extensions, readable_format, readable_format_for_name, titled_for_export, writable_format, + open_filters, opens_as_other, place_filters, plugin_formats, read_extensions, readable_format, readable_format_for_name, titled_for_export, + writable_format, }; #[cfg(test)] diff --git a/crates/engine/src/cmd/fileio/formats.rs b/crates/engine/src/cmd/fileio/formats.rs index 40acda270..3ecb456f6 100644 --- a/crates/engine/src/cmd/fileio/formats.rs +++ b/crates/engine/src/cmd/fileio/formats.rs @@ -669,39 +669,83 @@ fn format_filters() -> impl Iterator &'static [&'static str] { +/// Whether File › Open opens a file of extension `ext` (lower case) as something other than a +/// document: a plug-in (installed), a shortcut set, a script (checked), flattener, PDF, print, +/// perspective grid or export presets (imported), a Libraries panel library, or a swatch or graphic +/// style library. +pub fn opens_as_other(ext: &str) -> bool { + [ + crate::cmd::plugin::EXTS, + crate::cmd::shortcutset::EXTS, + crate::cmd::scripts::EXTS, + crate::cmd::flatten::PRESET_EXTS, + crate::cmd::pdfcmds::PRESET_EXTS, + crate::cmd::printpresets::PRESET_EXTS, + crate::cmd::perspgrid::PRESET_EXTS, + screens::PRESET_FILE_EXTS, + crate::cmd::library::LIBRARY_EXTS, + crate::cmd::swatchlib::LIBRARY_EXTS, + std::slice::from_ref(&vectorcraft_doc::style_libs::STYLES_EXT), + ] + .iter() + .any(|exts| exts.contains(&ext)) +} + +/// File › Open's extensions and filters for the installed importer plug-ins. +type ImporterPart = (&'static [&'static str], Vec<(&'static str, &'static [&'static str])>); + +/// File › Open's part for the installed importer plug-ins: the extensions of "All readable files" +/// ([`OPEN_EXTS`], then the importers' extensions in plug-in id order) and one filter per importer +/// that has extensions left, under its name. An importer's extensions leave out those a built-in +/// format reads, which File › Open reads with that format, and those File › Open opens as something +/// else ([`opens_as_other`]). Made once for each set of installed plug-ins, so it lives as long as +/// the dialogs that hold it. +fn importer_open_part() -> ImporterPart { use vectorcraft_plugins::registry; - static MADE: std::sync::Mutex> = std::sync::Mutex::new(None); + static MADE: std::sync::Mutex> = std::sync::Mutex::new(None); let mut made = MADE.lock().unwrap_or_else(|e| e.into_inner()); let revision = registry::revision(); - if let Some((r, exts)) = *made - && r == revision + if let Some((r, part)) = made.as_ref() + && *r == revision { - return exts; + return part.clone(); } let mut exts: Vec<&'static str> = OPEN_EXTS.to_vec(); - for e in importer_formats().iter().flat_map(|f| f.extensions.iter().copied()) { - if !exts.contains(&e) && readable_format(e).is_none() { - exts.push(e); + let mut filters = Vec::new(); + for f in importer_formats() { + let own: Vec<&'static str> = f.extensions.iter().copied().filter(|e| readable_format(e).is_none() && !opens_as_other(e)).collect(); + if own.is_empty() { + continue; } + for &e in &own { + if !exts.contains(&e) { + exts.push(e); + } + } + let own: &'static [&'static str] = if own.len() == f.extensions.len() { f.extensions } else { Box::leak(own.into_boxed_slice()) }; + filters.push((f.label, own)); } let exts: &'static [&'static str] = if exts.len() == OPEN_EXTS.len() { OPEN_EXTS } else { Box::leak(exts.into_boxed_slice()) }; - *made = Some((revision, exts)); - exts + let part = (exts, filters); + *made = Some((revision, part.clone())); + part +} + +/// Every extension File › Open reads as a document: [`OPEN_EXTS`], then the extensions the +/// installed importer plug-ins list that no built-in format reads and that File › Open doesn't open +/// as something else ([`opens_as_other`]), in plug-in id order. +pub fn open_extensions() -> &'static [&'static str] { + importer_open_part().0 } /// File › Open's filters: "All readable files" first, then one per readable format, then one per -/// importer plug-in (under its name), then swatch libraries (which open in the library panel), -/// Libraries panel libraries, flattener, PDF, print and export presets (imported), scripts (checked -/// without running them) and plug-ins (installed). +/// importer plug-in (under its name, with the extensions [`open_extensions`] takes from it), then +/// swatch libraries (which open in the library panel), Libraries panel libraries, flattener, PDF, +/// print and export presets (imported), scripts (checked without running them) and plug-ins +/// (installed). pub fn open_filters() -> impl Iterator { - std::iter::once(("All readable files", open_extensions())) - .chain(format_filters()) - .chain(importer_formats().into_iter().map(|f| (f.label, f.extensions))) - .chain(other_open_filters()) + let (all, importers) = importer_open_part(); + std::iter::once(("All readable files", all)).chain(format_filters()).chain(importers).chain(other_open_filters()) } /// [`open_filters`] without the importer plug-ins: "All readable files" lists [`OPEN_EXTS`]. For diff --git a/crates/engine/tests/plugin_import.rs b/crates/engine/tests/plugin_import.rs index f5cfea80b..7cb8d1a0a 100644 --- a/crates/engine/tests/plugin_import.rs +++ b/crates/engine/tests/plugin_import.rs @@ -157,6 +157,27 @@ fn built_in_formats_keep_their_files() { assert_eq!(fileio::open_extensions().iter().filter(|e| **e == "svg").count(), 1); } +/// File › Open lists an importer's extensions only where it would open the file with the importer: +/// not those a built-in format reads, and not those it opens as a plug-in, a shortcut set, a +/// script, presets or a library. An importer with none of its own gets no filter. +#[test] +fn file_open_lists_only_the_extensions_it_opens_with_an_importer() { + let mut s = Session::new(); + install(&mut s, &tpl::returning(&tpl::importer("test.claimed", r#"["tcl","ase","wasm","kys","jsx","vcstyles","svg"]"#, "{}"), RECT)); + install(&mut s, &tpl::returning(&tpl::importer("test.claimedonly", r#"["ase","gpl"]"#, "{}"), RECT)); + for ext in ["ase", "gpl", "wasm", "kys", "jsx", "vcstyles"] { + assert!(fileio::opens_as_other(ext), "{ext}"); + assert!(!fileio::open_extensions().contains(&ext), "All readable files: {ext}"); + } + assert!(!fileio::opens_as_other("tcl") && !fileio::opens_as_other("svg")); + assert!(fileio::open_extensions().contains(&"tcl")); + let filters: Vec<_> = fileio::open_filters().collect(); + assert!(filters.contains(&("Test test.claimed", &["tcl"][..])), "{filters:?}"); + assert!(!filters.iter().any(|(label, _)| *label == "Test test.claimedonly"), "{filters:?}"); + s.execute("plugin.remove", &json!({"id": "test.claimed"})).unwrap(); + s.execute("plugin.remove", &json!({"id": "test.claimedonly"})).unwrap(); +} + /// Several importers list one extension: the one whose id sorts first opens the file, and /// `importer` picks another (by its format id or its id). #[test] diff --git a/crates/ui-egui/src/dialogs/tests_plugin_import.rs b/crates/ui-egui/src/dialogs/tests_plugin_import.rs index eab73422e..9d75db484 100644 --- a/crates/ui-egui/src/dialogs/tests_plugin_import.rs +++ b/crates/ui-egui/src/dialogs/tests_plugin_import.rs @@ -164,6 +164,47 @@ fn only_file_open_s_dialog_lists_an_importer_s_files() { app.run("plugin.remove", json!({"id": "test.ui.filters"})).unwrap(); } +/// The extensions File › Open's filters leave out of importers' (`fileio::opens_as_other`) are the +/// ones File › Open installs, imports, checks or loads as something other than a document. +#[test] +fn importer_filters_leave_out_what_file_open_opens_as_something_else() { + use vectorcraft_engine::cmd::{self, fileio}; + let lists: [&[&str]; 10] = [ + cmd::plugin::EXTS, + cmd::shortcutset::EXTS, + cmd::scripts::EXTS, + cmd::flatten::PRESET_EXTS, + cmd::pdfcmds::PRESET_EXTS, + cmd::printpresets::PRESET_EXTS, + cmd::perspgrid::PRESET_EXTS, + fileio::SCREEN_EXPORT_PRESET_EXTS, + cmd::library::LIBRARY_EXTS, + cmd::swatchlib::LIBRARY_EXTS, + ]; + for ext in lists.iter().flat_map(|l| l.iter().copied()).chain([vectorcraft_doc::style_libs::STYLES_EXT]) { + assert!(crate::io::opens_as(&format!("a.{ext}")).is_some(), "{ext}"); + assert!(fileio::opens_as_other(ext), "{ext}"); + } + for ext in ["svg", "tcl", "pdf"] { + assert!(crate::io::opens_as(&format!("a.{ext}")).is_none() && !fileio::opens_as_other(ext), "{ext}"); + } + // File › Open's dialog lists an importer's `.tuo` but not its `.ase` (a swatch library there). + let mut app = app(); + install(&mut app, &tpl::module(&tpl::importer("test.ui.claimed", r#"["tuo","ase"]"#, "{}"), tpl::ECHO, "", 1)); + let asked: std::rc::Rc>> = Default::default(); + let seen = asked.clone(); + app.services.pick_open = Some(Box::new(move |pick: &crate::FilePick| { + *seen.borrow_mut() = Some(pick.clone()); + None + })); + let _ = app.run("file.open", json!({})); + let pick = asked.borrow_mut().take().expect("File › Open showed its dialog"); + let own = pick.filters.iter().find(|(label, _)| label == "Test test.ui.claimed").map(|(_, exts)| exts.clone()); + assert_eq!(own, Some(vec!["tuo".to_string()]), "{:?}", pick.filters); + assert!(!pick.filters[0].1.iter().any(|e| e == "ase"), "All readable files: {:?}", pick.filters[0]); + app.run("plugin.remove", json!({"id": "test.ui.claimed"})).unwrap(); +} + /// File › Open reads a file of an extension only an importer lists, and refuses one past the /// importer's input limit before reading it, as Open With does. #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 8a5e83f18..8d77e4d82 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6091,7 +6091,7 @@ id `plugin.`: `effect.apply`, `effect.setParams` and `effect.expandAppearanc `effect.list` lists it. An exporter adds a write-only file format `plugin.` (listed by `document.formats`): `document.export {format: "plugin.", params?, path?}` writes it, as does a `path` ending in one of its extensions that no built-in format has. An importer adds a read-only file format `plugin.` (listed by `document.formats` -with `read: true`, its extensions in `openExtensions`): `document.open {path | name, dataBase64, importer?: +with `read: true`, and `openExtensions` holds the extensions File › Open opens with it, without those a built-in format reads or that File › Open installs, imports or loads as something else, such as `.wasm` or `.ase`): `document.open {path | name, dataBase64, importer?: "plugin.", params?}` (and `open_file {path, importer?, params?}`) opens a file whose extension no built-in format reads, by its content or its extension, with the installed importer that lists the extension (the one whose id sorts first, when several do). `importer` names the importer to use, which must list the file's extension, also one a built-in format reads, such diff --git a/docs/plugins.md b/docs/plugins.md index 7329488df..187add504 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -41,8 +41,8 @@ An exporter adds a write-only format, `plugin.`, which `document.formats` li `document.export {"format": "plugin.", "params"?: {…}, "path"?}` writes it, and so does a `path` ending in one of its extensions that no built-in format has; `selectedOnly` hands it the selection, as for the built-in formats. -An importer adds a read-only format, `plugin.`, which `document.formats` lists with its `plugin` id, its extensions -in `openExtensions`. `document.open {"path"}` (or `{"name", "dataBase64"}`) opens a file with it when no built-in format +An importer adds a read-only format, `plugin.`, which `document.formats` lists with its `plugin` id. `openExtensions` +holds the extensions File › Open opens with the importer (the lists below describe which are left out). `document.open {"path"}` (or `{"name", "dataBase64"}`) opens a file with it when no built-in format reads the file, by its content or by its extension, and the importer lists the file's extension; when several do, the one whose id sorts first opens it. `document.open {"importer": "plugin.", "params"?: {…}}` names the importer, which must list the file's extension, also one a built-in format reads (an importer that lists `svg` opens an `.svg` @@ -60,7 +60,10 @@ heading and buttons in view. Exporters are formats of **File › Export › Expo Artboards is greyed out (the plug-in writes the whole document); one with parameters asks for them in a dialog of the same kind once the file is picked, and its Export button writes the file. Importers' files are listed under File › Open's "All readable files" and under a filter of their own, named after the importer (other open dialogs, such as New -from Template and Load Styles, list only the files the built-in formats read); **File › Open With** lists the +from Template and Load Styles, list only the files the built-in formats read). These lists leave out an importer's +extensions that a built-in format reads, and those File › Open installs, imports or loads as something else (`.wasm`, +`.kys`, `.jsx`, presets, libraries and swatch or graphic style libraries such as `.ase`). File › Open does not give +those files to the importer, and Open With does. **File › Open With** lists the installed importers by name (`file.openWith {"importer", "path"?, "params"?}`), and each opens a file picked among its extensions with that importer (on the web, File › Open's picker opens instead) and adds it to Open Recent. An importer with parameters, given no `params`, asks for them in a dialog of the same kind once the file is picked, and its Open