diff --git a/apps/vectorcraft/src/host_services.rs b/apps/vectorcraft/src/host_services.rs index 379d6fd24..2ca282d74 100644 --- a/apps/vectorcraft/src/host_services.rs +++ b/apps/vectorcraft/src/host_services.rs @@ -44,19 +44,50 @@ macro_rules! dialog_settings { dialog_settings!(rfd::FileDialog, rfd::AsyncFileDialog); +/// On Linux and the BSDs the dialog is GTK's or the desktop portal's, which match a file type's +/// `*.ext` patterns case-sensitively. +const CASE_SENSITIVE_FILTERS: bool = + cfg!(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd")); + +/// The extensions an open dialog's filter is given for `exts`: with `any_case`, each extension +/// that has a letter is followed by a pattern with each letter as a `[xX]` pair, so the filter for +/// `dib` also lists `image.DIB` and `image.Dib`. +fn open_extensions(exts: &[impl AsRef], any_case: bool) -> Vec { + let mut out = Vec::with_capacity(exts.len() * 2); + for e in exts { + let e = e.as_ref(); + out.push(e.to_string()); + if any_case && e.chars().any(|c| c.is_ascii_alphabetic()) { + out.push( + e.chars() + .map(|c| if c.is_ascii_alphabetic() { format!("[{}{}]", c.to_ascii_lowercase(), c.to_ascii_uppercase()) } else { c.to_string() }) + .collect(), + ); + } + } + out +} + /// `d` set up for `request`: its title, file types, folder and suggested name. fn dialog_for(d: D, request: &PickRequest) -> D { let pick = match request { PickRequest::Open(pick) | PickRequest::Save(pick) => pick, PickRequest::OpenMany => { return fileio::place_filters().fold(d.title(vectorcraft_ui_egui::i18n::t("Place")), |d, (name, exts)| { - d.filter(vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", name), exts) + d.filter( + vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", name), + &open_extensions(exts, CASE_SENSITIVE_FILTERS), + ) }); } PickRequest::Folder => return d, }; + let any_case = CASE_SENSITIVE_FILTERS && matches!(request, PickRequest::Open(_)); 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) + d.filter( + vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", name.as_str()), + &open_extensions(exts, any_case), + ) }); let d = match &pick.folder { Some(folder) => d.folder(folder), @@ -663,3 +694,84 @@ mod open_with_tests { assert_eq!(super::registry_default_path(windows::Win32::System::Registry::HKEY_CURRENT_USER, &key), None, "deleted"); } } + +#[cfg(test)] +mod dialog_tests { + use super::{CASE_SENSITIVE_FILTERS, DialogSettings, FilePick, PickRequest, dialog_for, fileio, open_extensions}; + + /// The file types a dialog was given, by label. + #[derive(Default)] + struct Filters(Vec<(String, Vec)>); + + impl DialogSettings for Filters { + fn parent(self, _window: &winit::window::Window) -> Self { + self + } + fn title(self, _title: &str) -> Self { + self + } + fn filter(mut self, name: &str, exts: &[impl ToString]) -> Self { + self.0.push((name.to_string(), exts.iter().map(ToString::to_string).collect())); + self + } + fn folder(self, _folder: &str) -> Self { + self + } + fn file_name(self, _name: &str) -> Self { + self + } + } + + /// Does the case-sensitive pattern `*.{ext}` match `name`? `ext` may hold literal characters, + /// `*` and `[..]` sets. + fn matches(ext: &str, name: &str) -> bool { + fn here(p: &[char], n: &[char]) -> bool { + match p.split_first() { + None => n.is_empty(), + Some(('*', rest)) => (0..=n.len()).any(|i| here(rest, &n[i..])), + Some(('[', rest)) => { + let Some(end) = rest.iter().position(|&c| c == ']') else { return false }; + n.split_first().is_some_and(|(c, n)| rest[..end].contains(c) && here(&rest[end + 1..], n)) + } + Some((c, rest)) => n.split_first().is_some_and(|(d, n)| d == c && here(rest, n)), + } + } + let pattern: Vec = format!("*.{ext}").chars().collect(); + here(&pattern, &name.chars().collect::>()) + } + + /// Where filters match case-sensitively, every extension of File › Open and Place lists files + /// whose extension is in capitals or mixed case, `image.DIB` and `image.Pns` included. + #[test] + fn open_and_place_filters_list_extensions_in_any_case() { + let all = fileio::open_filters().chain(fileio::place_filters()); + for (label, exts) in all { + let patterns = open_extensions(exts, true); + for ext in exts { + let mut mixed = ext.to_ascii_uppercase(); + mixed.replace_range(..1, &ext[..1]); + for name in [format!("a.{ext}"), format!("a.{}", ext.to_ascii_uppercase()), format!("a.{mixed}")] { + assert!(patterns.iter().any(|p| matches(p, &name)), "{label}: {patterns:?} don't match {name}"); + } + } + } + assert_eq!(open_extensions(&["dib", "jp2", "*"], true), ["dib", "[dD][iI][bB]", "jp2", "[jJ][pP]2", "*"]); + assert_eq!(open_extensions(&["dib", "PNS"], false), ["dib", "PNS"]); + } + + /// Open and Place dialogs get the any-case patterns on Linux and the BSDs; a save dialog's file + /// types stay as listed. + #[test] + fn open_dialogs_get_any_case_patterns_where_filters_are_case_sensitive() { + let dib = vec![FilePick::filter("BMP", &["bmp", "dib"])]; + let pick = FilePick { filters: dib.clone(), ..Default::default() }; + let expected = |any_case: bool| open_extensions(&["bmp", "dib"], any_case); + let opened = dialog_for(Filters::default(), &PickRequest::Open(pick.clone())).0; + assert_eq!(opened[0].1, expected(CASE_SENSITIVE_FILTERS)); + let saved = dialog_for(Filters::default(), &PickRequest::Save(pick)).0; + assert_eq!(saved, dib); + let placed = dialog_for(Filters::default(), &PickRequest::OpenMany).0; + let first = fileio::place_filters().next().map(|(_, exts)| open_extensions(exts, CASE_SENSITIVE_FILTERS)); + assert_eq!(placed.first().map(|(_, exts)| exts.clone()), first); + } +}