mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-07 13:23:22 +00:00
File dialogs: Open and Place list files with capitalized extensions on Linux
GTK and the desktop portal match a filter's *.ext patterns case-sensitively, so on Linux and the BSDs files such as image.DIB, photo.PNG or art.Svg did not show under any Open or Place filter, "All readable files" and "All placeable files" included. On those systems each open and place filter now also lists every extension with each letter as a [xX] pair, the form the portal's FileChooser documentation recommends. Save dialogs and the other platforms keep the plain extensions.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user