diff --git a/Cargo.lock b/Cargo.lock index 216711f5..188823bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/apps/filmcraft/Cargo.toml b/apps/filmcraft/Cargo.toml index a6a979de..89c1ef97 100644 --- a/apps/filmcraft/Cargo.toml +++ b/apps/filmcraft/Cargo.toml @@ -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"] } diff --git a/apps/filmcraft/src/file_filters.rs b/apps/filmcraft/src/file_filters.rs new file mode 100644 index 00000000..bc7dab59 --- /dev/null +++ b/apps/filmcraft/src/file_filters.rs @@ -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 { + 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"]); + } +} diff --git a/apps/filmcraft/src/main.rs b/apps/filmcraft/src/main.rs index af0bb891..d297d37f 100644 --- a/apps/filmcraft/src/main.rs +++ b/apps/filmcraft/src/main.rs @@ -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()) }));