Open dialog: upper- and mixed-case extensions on Linux and the BSDs (#1439)
The portal and zenity match rfd's *.ext globs case-sensitively, so IMG_0001.JPG or DSC_0001.NEF didn't show. On Linux and the BSDs each letter of an Open filter extension becomes a [jJ] class; Windows and macOS keep plain extensions. Duplicate #1682 by @switch87 took the same approach. Co-authored-by: switch87 <switch87@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
347a0f9fc9
commit
9b4562f2a0
|
|
@ -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<String> {
|
||||
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<Box<dyn Future<Output = Option<FileDialogAnswer>> + 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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue