From 5f3682fe7ceba1cc9fe51a31b84875091b6d2254 Mon Sep 17 00:00:00 2001 From: txiki739 <111882715+txiki739@users.noreply.github.com> Date: Fri, 9 Oct 2026 10:06:35 +0200 Subject: [PATCH 1/2] #262: file dialogs list extensions in any capitalisation on Linux rfd hands filter extensions to the XDG portal, GTK and Zenity as case-sensitive `*.ext` globs, so `CLIP.MP4` and `clip.Mp4` were hidden. `file_filters::extensions` keeps the given extensions first (the save dialogs' default suffix) and, outside Windows and macOS, adds one bracket pattern per extension (`mp4` -> `[mM][pP]4`), the form the portal's FileChooser docs recommend. Every filtered dialog uses it: import, relink, Open file, Open Project, Save Project and Save As. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 7 ++++ apps/filmcraft/Cargo.toml | 3 ++ apps/filmcraft/README.md | 18 +++++++++ apps/filmcraft/src/file_filters.rs | 62 ++++++++++++++++++++++++++++++ apps/filmcraft/src/main.rs | 36 +++++++++++++---- 5 files changed, 118 insertions(+), 8 deletions(-) create mode 100644 apps/filmcraft/README.md create mode 100644 apps/filmcraft/src/file_filters.rs diff --git a/Cargo.lock b/Cargo.lock index 900806b9..1562edb5 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", @@ -2816,6 +2817,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 8095c787..e3f67a28 100644 --- a/apps/filmcraft/Cargo.toml +++ b/apps/filmcraft/Cargo.toml @@ -27,6 +27,9 @@ rfd = { workspace = true } cpal = "0.16" chrono = { version = "0.4", default-features = false, features = ["clock"] } +[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/README.md b/apps/filmcraft/README.md new file mode 100644 index 00000000..02813d0c --- /dev/null +++ b/apps/filmcraft/README.md @@ -0,0 +1,18 @@ +# FilmCraft desktop host + +The native host connects the shared egui UI to audio devices, file dialogs, the control +server and platform media APIs. See [contributing](../../docs/contributing.md) for running +the app and [the control protocol](../../docs/control-protocol.md) for automation. + +## File dialogs + +All filtered file dialogs use `file_filters::extensions`. On Linux and the BSDs, rfd's +XDG portal and Zenity backends use case-sensitive globs. The helper adds bracket patterns +so camera files such as `shot.MP4` and `shot.Mp4` appear alongside `shot.mp4`. This also +applies to relinking, opening projects and presets, and save dialogs. Literal extensions +remain first for the default save suffix; macOS and Windows receive ordinary extensions. + +`cargo test --release -p filmcraft --bin filmcraft` checks matching and rejection of +filenames, including mixed case and misleading suffixes, and preserves default save +extensions. The filters follow the +[XDG FileChooser contract](https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.FileChooser.html). diff --git a/apps/filmcraft/src/file_filters.rs b/apps/filmcraft/src/file_filters.rs new file mode 100644 index 00000000..516eb598 --- /dev/null +++ b/apps/filmcraft/src/file_filters.rs @@ -0,0 +1,62 @@ +//! Extension filters for native file dialogs. + +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 b9b279a1..73734cf9 100644 --- a/apps/filmcraft/src/main.rs +++ b/apps/filmcraft/src/main.rs @@ -24,6 +24,7 @@ mod args; mod audio; mod audio_in; mod control_server; +mod file_filters; #[cfg(target_os = "macos")] mod native_menu; mod window_raise; @@ -168,16 +169,31 @@ fn main() -> eframe::Result { // Settings ▸ Audio Hardware is applied on the first frame (`apply_prefs`). app.audio = Some(Box::new(audio::CpalOut::new())); app.hooks.pick_files = Some(Box::new(|exts: &[&str]| { - rfd::FileDialog::new().add_filter("Media", exts).pick_files().unwrap_or_default().into_iter().map(|p| p.to_string_lossy().to_string()).collect() + rfd::FileDialog::new() + .add_filter("Media", &file_filters::extensions(exts)) + .pick_files() + .unwrap_or_default() + .into_iter() + .map(|p| p.to_string_lossy().to_string()) + .collect() })); // 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("Media", exts).pick_file().map(|p| p.to_string_lossy().to_string()))); + app.hooks.pick_file_for_relink = Some(Box::new(|exts: &[&str], _hint| { + rfd::FileDialog::new().add_filter("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 Project", &["fcproj"]).set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()) + rfd::FileDialog::new() + .add_filter("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(filter, exts).set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()) + rfd::FileDialog::new() + .add_filter(filter, &file_filters::extensions(exts)) + .set_file_name(name) + .save_file() + .map(|p| p.to_string_lossy().to_string()) })); app.hooks.pick_folder = Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string()))); app.hooks.open_path = Some(Box::new(open_path)); @@ -185,10 +201,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(filter, exts).pick_file().map(|p| p.to_string_lossy().to_string()) + rfd::FileDialog::new().add_filter(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 Project", &file_filters::extensions(&["fcproj"])) + .pick_file() + .map(|p| p.to_string_lossy().to_string()) })); - app.hooks.pick_open_project = - Some(Box::new(|| rfd::FileDialog::new().add_filter("FilmCraft Project", &["fcproj"]).pick_file().map(|p| p.to_string_lossy().to_string()))); #[cfg(target_os = "macos")] { let (rx, update) = native_menu::install(&app, cc.egui_ctx.clone()); From 33f3656071a787e7705fef09d2128620651abe06 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 9 Oct 2026 09:33:38 -0400 Subject: [PATCH 2/2] #262: drop new apps/filmcraft/README.md; explain file filters in module docs Co-Authored-By: Claude Opus 5.5 --- apps/filmcraft/README.md | 18 ------------------ apps/filmcraft/src/file_filters.rs | 8 ++++++++ 2 files changed, 8 insertions(+), 18 deletions(-) delete mode 100644 apps/filmcraft/README.md diff --git a/apps/filmcraft/README.md b/apps/filmcraft/README.md deleted file mode 100644 index 02813d0c..00000000 --- a/apps/filmcraft/README.md +++ /dev/null @@ -1,18 +0,0 @@ -# FilmCraft desktop host - -The native host connects the shared egui UI to audio devices, file dialogs, the control -server and platform media APIs. See [contributing](../../docs/contributing.md) for running -the app and [the control protocol](../../docs/control-protocol.md) for automation. - -## File dialogs - -All filtered file dialogs use `file_filters::extensions`. On Linux and the BSDs, rfd's -XDG portal and Zenity backends use case-sensitive globs. The helper adds bracket patterns -so camera files such as `shot.MP4` and `shot.Mp4` appear alongside `shot.mp4`. This also -applies to relinking, opening projects and presets, and save dialogs. Literal extensions -remain first for the default save suffix; macOS and Windows receive ordinary extensions. - -`cargo test --release -p filmcraft --bin filmcraft` checks matching and rejection of -filenames, including mixed case and misleading suffixes, and preserves default save -extensions. The filters follow the -[XDG FileChooser contract](https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.FileChooser.html). diff --git a/apps/filmcraft/src/file_filters.rs b/apps/filmcraft/src/file_filters.rs index 516eb598..bc7dab59 100644 --- a/apps/filmcraft/src/file_filters.rs +++ b/apps/filmcraft/src/file_filters.rs @@ -1,4 +1,12 @@ //! 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();