Merge pull request #390 from txiki739/fix/linux-file-dialog-extensions

#262: file dialogs list extensions in any capitalisation on Linux
This commit is contained in:
Brandon Thomas 2026-10-10 08:09:51 -04:00 committed by GitHub
commit 7f81d6b063
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 93 additions and 6 deletions

7
Cargo.lock generated
View File

@ -1674,6 +1674,7 @@ dependencies = [
"filmcraft-engine",
"filmcraft-platform",
"filmcraft-ui-egui",
"glob",
"log",
"muda",
"objc2 0.6.4",
@ -2826,6 +2827,12 @@ dependencies = [
"vello_common",
]
[[package]]
name = "glob"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
[[package]]
name = "glow"
version = "0.17.0"

View File

@ -28,6 +28,9 @@ cpal = "0.16"
chrono = { version = "0.4", default-features = false, features = ["clock"] }
sys-locale = "0.3"
[dev-dependencies]
glob = "0.3"
[target.'cfg(target_os = "macos")'.dependencies]
muda = "0.21"
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSProcessInfo", "NSString", "NSArray"] }

View File

@ -0,0 +1,70 @@
//! Extension filters for native file dialogs.
//!
//! Every filtered file dialog (import, relink, open file/preset, open/save project) builds
//! its filter with [`extensions`]. On Linux and the BSDs, rfd's XDG portal, GTK and Zenity
//! backends turn each extension into a case-sensitive `*.{ext}` glob, so camera files such
//! as `shot.MP4` or `shot.Mp4` would be hidden. The helper appends one bracket pattern per
//! extension (`mp4` -> `[mM][pP]4`) after the literal extensions, which stay first so save
//! dialogs keep their normal default suffix. macOS and Windows dialogs already ignore case
//! and receive the plain list.
pub fn extensions(exts: &[&str]) -> Vec<String> {
let extensions: Vec<_> = exts.iter().map(|ext| (*ext).to_owned()).collect();
// XDG portal/Zenity globs are case-sensitive. Keep the literal extensions first so
// Save dialogs can still infer a normal default suffix, then cover mixed case too.
// https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.FileChooser.html
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
{
let mut extensions = extensions;
for ext in exts {
if !ext.bytes().any(|b| b.is_ascii_alphabetic()) {
continue;
}
let mut pattern = String::new();
for c in ext.chars() {
if c.is_ascii_alphabetic() {
pattern.extend(['[', c.to_ascii_lowercase(), c.to_ascii_uppercase(), ']']);
} else {
pattern.push(c);
}
}
extensions.push(pattern);
}
extensions
}
// Native macOS and Windows filters take ordinary extensions.
#[cfg(any(target_os = "windows", target_os = "macos"))]
extensions
}
#[cfg(test)]
mod tests {
use super::extensions;
// rfd's XDG portal and Zenity backends turn each extension into `*.{ext}`.
// Exercise those case-sensitive patterns against filenames, as the picker does.
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
#[test]
fn camera_files_are_visible_in_every_capitalization() {
let filters: Vec<_> = extensions(&["mp4", "mov", "m2ts", "wav", "fcproj"]).iter().map(|ext| glob::Pattern::new(&format!("*.{ext}")).unwrap()).collect();
for name in ["camera.mp4", "camera.MP4", "camera.Mp4", "clip.MOV", "clip.moV", "take.M2TS", "sound.WaV", "Edit.FCPROJ", "Edit.FcProj"] {
assert!(filters.iter().any(|f| f.matches(name)), "the picker hides {name}");
}
for name in ["camera.mp4.bak", "camera.mp44", "camera.xmp4", "notes.txt", "mp4"] {
assert!(!filters.iter().any(|f| f.matches(name)), "the picker admits {name}");
}
}
#[test]
fn save_filters_keep_the_literal_default_extension() {
assert_eq!(extensions(&["mp4", "mov"]).first().map(String::as_str), Some("mp4"));
assert!(extensions(&[]).is_empty());
assert_eq!(extensions(&["*", "", "123"]), ["*", "", "123"]);
}
#[cfg(any(target_os = "windows", target_os = "macos"))]
#[test]
fn native_filters_receive_only_literal_extensions() {
assert_eq!(extensions(&["mp4", "mov", "fcproj"]), ["mp4", "mov", "fcproj"]);
}
}

View File

@ -28,6 +28,7 @@ mod args;
mod audio;
mod audio_in;
mod control_server;
mod file_filters;
#[cfg(any(target_os = "windows", test))]
mod graphics;
mod logging;
@ -199,7 +200,7 @@ fn main() -> eframe::Result {
app.audio = Some(Box::new(audio::CpalOut::new()));
app.hooks.pick_files = Some(Box::new(|exts: &[&str]| {
rfd::FileDialog::new()
.add_filter(filmcraft_ui_egui::i18n::t("Media"), exts)
.add_filter(filmcraft_ui_egui::i18n::t("Media"), &file_filters::extensions(exts))
.pick_files()
.unwrap_or_default()
.into_iter()
@ -208,18 +209,21 @@ fn main() -> eframe::Result {
}));
// Link Media ▸ Locate…, Attach Proxies, Reconnect Full Resolution: one path, not imported.
app.hooks.pick_file_for_relink = Some(Box::new(|exts: &[&str], _hint| {
rfd::FileDialog::new().add_filter(filmcraft_ui_egui::i18n::t("Media"), exts).pick_file().map(|p| p.to_string_lossy().to_string())
rfd::FileDialog::new()
.add_filter(filmcraft_ui_egui::i18n::t("Media"), &file_filters::extensions(exts))
.pick_file()
.map(|p| p.to_string_lossy().to_string())
}));
app.hooks.pick_save = Some(Box::new(|name: &str| {
rfd::FileDialog::new()
.add_filter(filmcraft_ui_egui::i18n::t("FilmCraft Project"), &["fcproj"])
.add_filter(filmcraft_ui_egui::i18n::t("FilmCraft Project"), &file_filters::extensions(&["fcproj"]))
.set_file_name(name)
.save_file()
.map(|p| p.to_string_lossy().to_string())
}));
app.hooks.pick_save_as = Some(Box::new(|filter: &str, exts: &[&str], name: &str| {
rfd::FileDialog::new()
.add_filter(filmcraft_ui_egui::i18n::t(filter), exts)
.add_filter(filmcraft_ui_egui::i18n::t(filter), &file_filters::extensions(exts))
.set_file_name(name)
.save_file()
.map(|p| p.to_string_lossy().to_string())
@ -238,11 +242,14 @@ fn main() -> eframe::Result {
window_raise::raise_without_focus();
}));
app.hooks.pick_open_file = Some(Box::new(|filter: &str, exts: &[&str]| {
rfd::FileDialog::new().add_filter(filmcraft_ui_egui::i18n::t(filter), exts).pick_file().map(|p| p.to_string_lossy().to_string())
rfd::FileDialog::new()
.add_filter(filmcraft_ui_egui::i18n::t(filter), &file_filters::extensions(exts))
.pick_file()
.map(|p| p.to_string_lossy().to_string())
}));
app.hooks.pick_open_project = Some(Box::new(|| {
rfd::FileDialog::new()
.add_filter(filmcraft_ui_egui::i18n::t("FilmCraft Project"), &["fcproj"])
.add_filter(filmcraft_ui_egui::i18n::t("FilmCraft Project"), &file_filters::extensions(&["fcproj"]))
.pick_file()
.map(|p| p.to_string_lossy().to_string())
}));