Fix #1275: open the commands' file dialogs without blocking the frame on Linux

rfd's Linux backend goes through the XDG desktop portal, and a blocking
`rfd::FileDialog` leaves the main thread waiting on the portal's answer
without pumping winit's events: while the Open dialog is up the window stops
answering the compositor, which offers to force-quit the app ("EffectCraft is
not responding"), until a file is picked.

The non-blocking host hook for the commands' dialogs (`rfd::AsyncFileDialog`
on a worker thread, run when the dialog closes) was macOS-only since #511.
Install it on Linux too. Windows keeps the blocking pickers (its dialog pumps
its own messages), and the panel buttons' pickers (Settings, effect file
pickers) are unchanged.

Checked headlessly against a mock XDG portal: with the hook the main thread
stays in the event loop while a worker blocks in the portal wait, `ui.inspect`
still answers 7.5 s after the dialog opened, and an answered dialog runs
File ▸ Import; without it the main thread itself blocks in the portal wait and
the app answers nothing until the dialog closes.

Co-Authored-By: DeepSeek V4.1 Flash Free <noreply@deepseek.com>
This commit is contained in:
Madhav Agarwal
2026-10-18 00:48:08 +05:30
co-authored by DeepSeek V4.1 Flash Free
parent 2d805d1b69
commit b113278a1c
4 changed files with 50 additions and 13 deletions
+34 -5
View File
@@ -23,8 +23,8 @@ mod control_server;
mod launch_guard;
#[cfg(target_os = "macos")]
mod native_menu;
// Built everywhere so it stays checked; only the macOS app uses it.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
// Built everywhere so it stays checked; the macOS and Linux apps use it.
#[cfg_attr(not(any(target_os = "macos", target_os = "linux")), allow(dead_code))]
mod sheet_dialogs;
mod window;
@@ -122,6 +122,22 @@ fn control_port(name: &str, value: Option<&str>) -> Result<u16, String> {
}
}
/// The commands' file dialogs open without blocking the frame where the host's backend would:
/// on macOS as sheets instead of nested modal loops (#511), on Linux through the desktop
/// portal from a worker thread — a blocking portal dialog leaves the main thread waiting on its
/// answer without pumping events, so the window stops answering the compositor and the desktop
/// offers to force-quit it while the Open dialog is up (#1275). Windows' blocking dialog pumps
/// its own messages, so it keeps [`effectcraft_ui_egui::Hooks::pick_files`] and friends.
fn install_file_dialogs(app: &mut EffectcraftApp, ctx: &egui::Context) {
#[cfg(any(target_os = "macos", target_os = "linux"))]
{
let ctx = ctx.clone();
app.hooks.file_dialog_async = Some(Box::new(move |dialog, done| sheet_dialogs::show(dialog, done, ctx.clone())));
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
let _ = (app, ctx);
}
fn main() -> eframe::Result {
// First, so the panic hook and every start-up warning are recorded. Records go to stderr
// and the session log; Help ▸ Enable Logging writes through this logger too, and warnings
@@ -301,11 +317,11 @@ fn main() -> eframe::Result {
let file = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
d.set_file_name(file).save_file().map(|p| p.to_string_lossy().to_string())
}));
// The commands' file dialogs don't block the frame where the platform's backend
// would (#511, #1275); see [`install_file_dialogs`].
install_file_dialogs(&mut app, &cc.egui_ctx);
#[cfg(target_os = "macos")]
{
// The commands' file dialogs open as sheets instead of nested modal loops (#511).
let ctx = cc.egui_ctx.clone();
app.hooks.file_dialog_async = Some(Box::new(move |dialog, done| sheet_dialogs::show(dialog, done, ctx.clone())));
app.hooks.app_action = Some(Box::new(native_menu::app_action));
app.hooks.clipboard_text = Some(Box::new(native_menu::clipboard_text));
}
@@ -597,4 +613,17 @@ mod tests {
assert_eq!(parse_env(&[], Err(not_utf8)).unwrap_err(), format!("{need}, not `9\u{fffd}`"));
}
}
/// #1275: a blocking desktop-portal dialog on Linux leaves the window not answering the
/// compositor while it is open, so the commands' file dialogs go through the non-blocking
/// host hook there (as they do on macOS, #511). Windows' dialog pumps its own messages.
#[test]
fn commands_file_dialogs_do_not_block_the_frame() {
let mut app = effectcraft_ui_egui::EffectcraftApp::new(effectcraft_engine::Session::default());
super::install_file_dialogs(&mut app, &egui::Context::default());
#[cfg(any(target_os = "macos", target_os = "linux"))]
assert!(app.hooks.file_dialog_async.is_some(), "the commands' file dialogs must not block the frame");
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
assert!(app.hooks.file_dialog_async.is_none(), "the blocking dialog pumps its own messages here");
}
}
+7 -3
View File
@@ -1,10 +1,14 @@
//! File dialogs that don't block the frame (macOS), for `Hooks::file_dialog_async`.
//! File dialogs that don't block the frame (macOS and Linux), for `Hooks::file_dialog_async`.
//!
//! A blocking `rfd::FileDialog` runs a nested modal event loop inside eframe's frame, that is
//! inside winit's event handler. On macOS a file dragged over the window while that loop ran
//! re-entered winit's handler, which panics across the Objective-C boundary and aborts the app
//! (#511). `rfd::AsyncFileDialog` shows the dialog as a sheet on the window and returns at once;
//! the answer arrives when the sheet closes, outside the frame, and the app runs the command then.
//! (#511). On Linux the blocking dialog is worse than slow: rfd uses the XDG desktop portal, so
//! the main thread sits waiting on the portal's answer and never pumps winit's events — the
//! window stops answering the compositor and the desktop offers to force-quit the app while the
//! Open dialog is up (#1275). `rfd::AsyncFileDialog` shows the dialog as a sheet on the window
//! (macOS) or through the portal from a worker thread (Linux) and returns at once; the answer
//! arrives when the dialog closes, outside the frame, and the app runs the command then.
use effectcraft_ui_egui::{DialogDone, FileDialog};
use rfd::{AsyncFileDialog, FileHandle};
+4 -2
View File
@@ -163,8 +163,10 @@ pub struct Hooks {
pub app_action: Option<Box<dyn Fn(&str) -> bool>>,
/// Shows a command's file dialog without blocking the frame and calls `done` with the
/// answer (the host then asks for a repaint). Used in place of the blocking pickers above for
/// the commands' dialogs (File ▸ Import, Open, Save As…) where a modal dialog's nested event
/// loop inside a frame is unsafe: on macOS a drag over the window then aborted the app (#511).
/// the commands' dialogs (File ▸ Import, Open, Save As…) where blocking the frame is unsafe or
/// leaves the window unresponsive: on macOS a nested modal event loop there aborted the app on
/// a drag over the window (#511), and on Linux a blocking desktop-portal dialog never pumps
/// events, so the compositor marks the window as not responding (#1275).
pub file_dialog_async: Option<Box<dyn Fn(FileDialog, DialogDone)>>,
/// Tells the user the graphics device was lost and the window is closing, once. `saved` is
/// the auto-save of unsaved changes (`None`: nothing was unsaved), its path or why it failed.
+5 -3
View File
@@ -1381,9 +1381,11 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option<Res
"mp4", "mov", "m4v", "mkv", "webm", "mxf", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr", "dpx", "cin", "wav", "aif", "aiff", "mp3",
"flac", "ogg", "opus", "svg", "pdf", "ai", "eps", "psd", "psb", "tga", "hdr", "gltf", "glb", "obj", "stl", "fbx", "json", "csv", "tsv",
];
// A host whose modal dialogs would run a nested event loop inside this frame (macOS: a
// drag over the window then re-entered winit's event handler and aborted, #511) shows the
// dialog without blocking; the command continues when the answer arrives.
// A host whose blocking dialogs are unsafe inside this frame shows the dialog without
// blocking — macOS: the nested event loop re-entered winit's handler on a drag over the
// window and aborted the app (#511); Linux: the desktop portal's answer never gets pumped,
// so the compositor marks the window as not responding (#1275) — and the command continues
// when the answer arrives.
if let Some(spawn) = app.hooks.file_dialog_async.as_ref() {
let request = match ask {
Ask::Folder => crate::FileDialog::Folder,