Merge pull request #5644 from dotCooCoo/fix/linux-dialog-filters-any-case

File dialogs: Open and Place list files with capitalized extensions on Linux
This commit is contained in:
BFlat
2026-11-04 23:22:01 +07:00
committed by GitHub
+114 -2
View File
@@ -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<str>], any_case: bool) -> Vec<String> {
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: DialogSettings>(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<String>)>);
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<char> = format!("*.{ext}").chars().collect();
here(&pattern, &name.chars().collect::<Vec<_>>())
}
/// 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);
}
}