diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index ab406d28..b6ab8aff 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -21,6 +21,16 @@ const OPEN_EXTS: &[&str] = &[ "afdesign", "afphoto", "afpub", ]; +/// Open dialog extensions that also match uppercase and mixed-case names (`IMG_0001.JPG`). +/// On Linux and the BSDs rfd turns each extension into a case-sensitive `*.ext` glob for the XDG +/// portal or zenity, so every letter becomes a character class, as the portal spec suggests +/// (`*.[iI][cC][oO]`). Windows and macOS take literal extensions and ignore case already. +fn open_filter_extensions(extensions: &[&str]) -> Vec { + let case_sensitive_globs = cfg!(all(unix, not(target_os = "macos"))); + let class = |c: char| if c.is_ascii_alphabetic() { format!("[{}{}]", c.to_ascii_lowercase(), c.to_ascii_uppercase()) } else { c.to_string() }; + extensions.iter().map(|ext| if case_sensitive_globs { ext.chars().map(class).collect() } else { ext.to_string() }).collect() +} + /// File › Save As formats: (filter name, extensions). The filter matching the suggested name's /// extension comes first, so a .pcraft document saves as .pcraft by default and everything else /// keeps defaulting to Photoshop. @@ -67,7 +77,7 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>, } let answer: Pin> + Send>> = match request { FileDialogRequest::Open { multiple } => { - let dialog = dialog.add_filter("All Formats", OPEN_EXTS).add_filter("PhotoCraft", &["pcraft"]); + let dialog = dialog.add_filter("All Formats", &open_filter_extensions(OPEN_EXTS)).add_filter("PhotoCraft", &open_filter_extensions(&["pcraft"])); if multiple { let picked = dialog.pick_files(); Box::pin(async move { picked.await.map(|files| FileDialogAnswer::Paths(files.iter().map(path_of).collect())) }) @@ -383,6 +393,44 @@ mod tests { use photocraft_ui_egui::{PhotocraftApp, prefs_ui}; use serde_json::{Value, json}; + #[cfg(all(unix, not(target_os = "macos")))] + #[test] + fn open_filters_cover_uppercase_and_mixed_case_extensions() { + // Matches `text` against a glob of literals and `[..]` classes, as the portal would after `*.`. + fn matches(glob: &str, text: &str) -> bool { + let mut text = text.chars(); + let mut glob = glob.chars(); + while let Some(g) = glob.next() { + let class: String = if g == '[' { glob.by_ref().take_while(|&c| c != ']').collect() } else { g.to_string() }; + if !text.next().is_some_and(|t| class.contains(t)) { + return false; + } + } + text.next().is_none() + } + assert_eq!(open_filter_extensions(&["cr2", "pcraft"]), ["[cC][rR]2", "[pP][cC][rR][aA][fF][tT]"]); + let globs = open_filter_extensions(OPEN_EXTS); + assert_eq!(globs.len(), OPEN_EXTS.len()); + for (ext, glob) in OPEN_EXTS.iter().zip(&globs) { + // Every casing: lower, UPPER, and alternating both ways (JpG, jPg). + let alternating = |upper_first: bool| -> String { + ext.chars().enumerate().map(|(i, c)| if (i % 2 == 0) == upper_first { c.to_ascii_uppercase() } else { c }).collect() + }; + for name in [ext.to_string(), ext.to_ascii_uppercase(), alternating(true), alternating(false)] { + assert!(matches(glob, &name), "{glob} should match .{name}"); + } + assert!(!matches(glob, &format!("{ext}x")) && !matches(glob, &ext[..ext.len() - 1]), "{glob} matches only .{ext}"); + } + assert!(!globs.iter().any(|glob| matches(glob, "txt"))); + } + + #[cfg(not(all(unix, not(target_os = "macos"))))] + #[test] + fn open_filters_keep_literal_extensions_on_windows_and_macos() { + assert_eq!(open_filter_extensions(OPEN_EXTS), OPEN_EXTS); + assert_eq!(open_filter_extensions(&["pcraft"]), ["pcraft"]); + } + #[test] fn xwayland_command_matches_how_photocraft_was_installed() { // Flatpak: WAYLAND_DISPLAY doesn't reach the sandbox's socket choice; flatpak's flags do. diff --git a/contributors/people.toml b/contributors/people.toml index 41d7b4a3..afe2ac1b 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -17,6 +17,9 @@ # To remove your names, delete your entry (or set fields empty) in a PR. The next # contributors.py run, and the next build, drop them. +[people.AlanRockefeller] +sync_github_name = true + [people.echelon] real_name = "Brandon Thomas" display_name = "Brandon Thomas"