Files
filmcraft/crates/ui-egui/tests/native_dialogs_ui.rs
MrCloudy2andClaude Opus 5.5 059376af1c #260: file dialogs on Linux no longer stop the app
Native file dialogs are requested with a continuation (FilmcraftApp::pick for
commands, pick_ui for panel buttons). On Linux the desktop host shows the rfd
dialog on a worker thread (HostHooks::run_dialog) and the result is applied
when it arrives, so the frame loop, playback and the control channel keep
running and GNOME no longer offers to force-quit the window. One dialog at a
time; a path for a dialog closed meanwhile goes nowhere. macOS / Windows keep
their synchronous pickers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:52:07 +02:00

222 lines
8.7 KiB
Rust

//! File dialogs that don't stop the frame loop (#260), headless with a stand-in host runner
//! (`HostHooks::run_dialog`) the test answers by hand: while a dialog is open the app keeps
//! answering requests and drawing, and the chosen path is used when the answer arrives (a menu
//! command, a panel's field), cancelled or lost dialogs do nothing, one dialog is open at a time,
//! and a path for a dialog the user has closed meanwhile goes nowhere. Also: with the synchronous
//! pickers (no runner) a panel's Browse… still fills its field.
use std::path::PathBuf;
use std::sync::mpsc::{Receiver, Sender, channel};
use std::sync::{Arc, Mutex};
use egui_kittest::Harness;
use filmcraft_engine::Session;
use filmcraft_ui_egui::FilmcraftApp;
use filmcraft_ui_egui::control::ControlRequest;
use filmcraft_ui_egui::native_dialogs::FileDialog;
use serde_json::{Value, json};
/// Dialogs the app asked for, with the sender that answers each.
type Asked = Arc<Mutex<Vec<(FileDialog, Sender<Vec<String>>)>>>;
struct Driver {
harness: Harness<'static, FilmcraftApp>,
tx: Sender<ControlRequest>,
asked: Asked,
}
impl Driver {
/// The demo project; `runner`: dialogs go to the stand-in host runner (else synchronous hooks).
fn new(runner: bool) -> Self {
let mut session = Session::default();
session.execute("file.openDemoProject", json!({})).expect("demo project");
let (tx, rx) = channel();
let mut app = FilmcraftApp::new(session).with_control(rx);
let asked: Asked = Arc::default();
if runner {
let a = asked.clone();
app.hooks.run_dialog = Some(Box::new(move |dialog: FileDialog| -> Receiver<Vec<String>> {
let (tx, rx) = channel();
a.lock().unwrap().push((dialog, tx));
rx
}));
}
let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000).build_eframe(move |_cc| app);
let mut d = Driver { harness, tx, asked };
d.frames(4);
d
}
fn frames(&mut self, n: usize) {
for _ in 0..n {
let ctx = self.harness.ctx.clone();
let mut raw = std::mem::take(self.harness.input_mut());
eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw);
*self.harness.input_mut() = raw;
self.harness.step();
}
}
fn call(&mut self, method: &str, params: Value) -> Value {
let (req, reply) = ControlRequest::new(method, params.clone());
self.tx.send(req).unwrap();
for _ in 0..600 {
self.frames(1);
if let Ok(v) = reply.try_recv() {
return v;
}
}
panic!("no reply to {method} {params}");
}
fn ok(&mut self, method: &str, params: Value) -> Value {
let r = self.call(method, params.clone());
assert_eq!(r["ok"], true, "{method} {params}: {r}");
r["result"].clone()
}
fn click(&mut self, id: &str) {
self.ok("ui.click", json!({"id": id}));
self.frames(3);
}
fn app(&mut self) -> &mut FilmcraftApp {
self.harness.state_mut()
}
/// The one dialog asked for so far (taken), with its answering sender.
fn asked(&mut self) -> (FileDialog, Sender<Vec<String>>) {
let mut a = self.asked.lock().unwrap();
assert_eq!(a.len(), 1, "one dialog asked for: {:?}", a.iter().map(|x| &x.0).collect::<Vec<_>>());
a.pop().unwrap()
}
fn items(&mut self) -> usize {
self.app().session.project.items.len()
}
}
/// A one-second 8 kHz mono WAV in Cargo's temporary folder for integration tests.
fn wav() -> PathBuf {
let dir = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("native-dialogs");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("tone.wav");
let (rate, n) = (8000u32, 8000u32);
let mut b = Vec::new();
b.extend_from_slice(b"RIFF");
b.extend_from_slice(&(36 + n * 2).to_le_bytes());
b.extend_from_slice(b"WAVEfmt ");
b.extend_from_slice(&16u32.to_le_bytes());
b.extend_from_slice(&1u16.to_le_bytes());
b.extend_from_slice(&1u16.to_le_bytes());
b.extend_from_slice(&rate.to_le_bytes());
b.extend_from_slice(&(rate * 2).to_le_bytes());
b.extend_from_slice(&2u16.to_le_bytes());
b.extend_from_slice(&16u16.to_le_bytes());
b.extend_from_slice(b"data");
b.extend_from_slice(&(n * 2).to_le_bytes());
for i in 0..n {
b.extend_from_slice(&(((i as f32 * 0.2).sin() * 8000.0) as i16).to_le_bytes());
}
std::fs::write(&path, b).unwrap();
path
}
#[test]
fn the_app_keeps_running_while_the_import_dialog_is_open_and_imports_when_it_answers() {
let mut d = Driver::new(true);
let before = d.items();
// File ▸ Import answers at once: the dialog is open, nothing is imported yet
assert_eq!(d.ok("ui.menu.invoke", json!({"id": "file.import"})), json!({"dialog": "open"}));
assert!(d.app().native_dialog_open());
let (dialog, answer) = d.asked();
assert!(matches!(&dialog, FileDialog::OpenFiles { exts } if exts.iter().any(|e| e == "wav")), "{dialog:?}");
// the frame loop runs and requests are answered while the dialog is open
for _ in 0..5 {
d.ok("ui.inspect", json!({}));
}
d.frames(20);
assert_eq!(d.items(), before);
// the user chooses a file: it is imported on the next frame
answer.send(vec![wav().to_string_lossy().into_owned()]).unwrap();
d.frames(2);
assert!(!d.app().native_dialog_open());
assert_eq!(d.items(), before + 1);
assert!(d.app().session.project.items.values().any(|i| i.name == "tone.wav"));
}
#[test]
fn cancelled_or_lost_dialogs_do_nothing_and_one_dialog_is_open_at_a_time() {
let mut d = Driver::new(true);
let before = d.items();
d.ok("ui.menu.invoke", json!({"id": "file.import"}));
// a second dialog while one is open is refused
let r = d.call("ui.menu.invoke", json!({"id": "file.open"}));
assert_eq!(r["ok"], false, "{r}");
assert!(r.to_string().contains("already open"), "{r}");
let (_, answer) = d.asked();
// cancelled: no path
answer.send(vec![]).unwrap();
d.frames(2);
assert!(!d.app().native_dialog_open());
assert_eq!(d.items(), before);
// the host's dialog went away without an answer (its thread failed): as if cancelled
d.ok("ui.menu.invoke", json!({"id": "file.import"}));
let (_, answer) = d.asked();
drop(answer);
d.frames(2);
assert!(!d.app().native_dialog_open());
assert_eq!(d.items(), before);
// and the next dialog opens normally
assert_eq!(d.ok("ui.menu.invoke", json!({"id": "file.open"})), json!({"dialog": "open"}));
assert!(matches!(d.asked().0, FileDialog::OpenProject));
}
#[test]
fn a_panel_folder_arrives_in_its_open_dialog_and_nowhere_once_it_is_closed() {
let mut d = Driver::new(true);
// Settings ▸ Media Cache ▸ Location ▸ Browse…
d.ok("ui.menu.invoke", json!({"id": "app.settings.mediaCache"}));
d.frames(3);
d.click("settings.mediaCache.location.browse");
let (dialog, answer) = d.asked();
assert!(matches!(dialog, FileDialog::Folder { .. }), "{dialog:?}");
d.frames(10);
answer.send(vec!["/tmp/filmcraft-cache-test".into()]).unwrap();
d.frames(3);
let loc = d.app().ui.settings.as_ref().map(|s| s.values.pointer("/mediaCache/location").cloned());
assert_eq!(loc, Some(Some(json!("/tmp/filmcraft-cache-test"))));
// Browse… again, then Cancel the Settings dialog before the folder comes back: nothing to write into
d.click("settings.mediaCache.location.browse");
let (_, answer) = d.asked();
d.click("settings.cancel");
assert!(d.app().ui.settings.is_none());
answer.send(vec!["/tmp/elsewhere".into()]).unwrap();
d.frames(3);
assert!(d.app().ui.settings.is_none());
assert!(!d.app().native_dialog_open());
assert_ne!(d.app().session.prefs.to_value().pointer("/mediaCache/location"), Some(&json!("/tmp/elsewhere")));
}
#[test]
fn export_location_browse_works_with_the_runner_and_with_synchronous_pickers() {
// runner: the folder arrives later
let mut d = Driver::new(true);
d.ok("ui.set", json!({"mode": "export"}));
d.frames(3);
d.click("export.location.browse");
let (_, answer) = d.asked();
answer.send(vec!["/tmp/exports-a".into()]).unwrap();
d.frames(2);
assert_eq!(d.app().ui.export.location, "/tmp/exports-a");
// synchronous picker (no runner, as on macOS / Windows / other hosts): still filled in
let mut d = Driver::new(false);
d.app().hooks.pick_folder = Some(Box::new(|| Some("/tmp/exports-b".into())));
d.ok("ui.set", json!({"mode": "export"}));
d.frames(3);
d.click("export.location.browse");
assert_eq!(d.app().ui.export.location, "/tmp/exports-b");
assert!(!d.app().native_dialog_open());
}