//! Non-blocking native file dialogs. //! //! The synchronous `rfd::FileDialog` blocks the egui frame thread while open. This //! drives `rfd::AsyncFileDialog` instead: the picker is requested with a handler //! closure, run on a worker thread via `pollster::block_on`, and its result is //! applied on the GUI thread on a later frame. //! //! **macOS:** native file panels must run on the main thread. `AsyncFileDialog` //! marshals the panel onto the main run loop (which eframe pumps each frame) and //! resolves the future on the worker thread, so this stays main-thread-correct. //! (The earlier sync dialog also ran on the main thread; the only change is that //! the frame loop no longer blocks while the panel is open.) **NOTE: verify on the //! macOS build host (mbp) before launch, the panel/run-loop interaction can't be //! exercised from Linux.** //! //! One dialog at a time (file panels are modal), so a single pending slot. The //! pending slot is behind a `Mutex` and the handler is `Send` so `BrowserState` //! stays `Sync` (required by nih-plug), matching the classifier worker handle. use std::path::PathBuf; use std::sync::mpsc; use parking_lot::Mutex; use crate::state::BrowserState; /// What kind of picker to show. Titles/filters are owned so the request can move /// to the worker thread. pub enum DialogKind { PickFolder { title: String, directory: Option, }, PickFile { title: String, filters: Vec<(String, Vec)>, }, PickFiles { title: String, filters: Vec<(String, Vec)>, }, SaveFile { title: String, file_name: String, filters: Vec<(String, Vec)>, }, } /// Applied on the GUI thread with the picked paths (empty = cancelled). `Send` so /// the manager (and `BrowserState`) stays `Sync`; runs on the GUI thread only. type DialogHandler = Box) + Send>; struct Pending { rx: mpsc::Receiver>, handler: DialogHandler, } /// Owns the in-flight file dialog (if any) and routes its result to a handler. #[derive(Default)] pub struct DialogManager { pending: Mutex>, } impl DialogManager { /// Open a file dialog and register `handler` to run with the result. Ignored /// if a dialog is already open (panels are modal, one at a time). pub fn request(&self, kind: DialogKind, handler: DialogHandler) { let mut slot = self.pending.lock(); if slot.is_some() { return; } let (tx, rx) = mpsc::channel(); let _ = audiofiles_core::worker_runtime::spawn_detached("file-dialog", move || { let _ = tx.send(pollster::block_on(run_dialog(kind))); }); *slot = Some(Pending { rx, handler }); } fn owned_filters(filters: &[(&str, &[&str])]) -> Vec<(String, Vec)> { filters .iter() .map(|(n, e)| { ( n.to_string(), e.iter().map(std::string::ToString::to_string).collect(), ) }) .collect() } /// Pick a folder; `handler` runs with the chosen path (skipped if cancelled). pub fn pick_folder(&self, title: impl Into, handler: F) where F: FnOnce(&mut BrowserState, PathBuf) + Send + 'static, { self.request( DialogKind::PickFolder { title: title.into(), directory: None, }, Box::new(move |s, paths| { if let Some(p) = paths.into_iter().next() { handler(s, p); } }), ); } /// Pick a folder starting at `directory`; `handler` runs with the chosen path. pub fn pick_folder_in(&self, title: impl Into, directory: PathBuf, handler: F) where F: FnOnce(&mut BrowserState, PathBuf) + Send + 'static, { self.request( DialogKind::PickFolder { title: title.into(), directory: Some(directory), }, Box::new(move |s, paths| { if let Some(p) = paths.into_iter().next() { handler(s, p); } }), ); } /// Pick a single file; `handler` runs with the chosen path (skipped if cancelled). pub fn pick_file(&self, title: impl Into, filters: &[(&str, &[&str])], handler: F) where F: FnOnce(&mut BrowserState, PathBuf) + Send + 'static, { self.request( DialogKind::PickFile { title: title.into(), filters: Self::owned_filters(filters), }, Box::new(move |s, paths| { if let Some(p) = paths.into_iter().next() { handler(s, p); } }), ); } /// Pick one or more files; `handler` runs with the chosen paths (skipped if none). pub fn pick_files(&self, title: impl Into, filters: &[(&str, &[&str])], handler: F) where F: FnOnce(&mut BrowserState, Vec) + Send + 'static, { self.request( DialogKind::PickFiles { title: title.into(), filters: Self::owned_filters(filters), }, Box::new(move |s, paths| { if !paths.is_empty() { handler(s, paths); } }), ); } /// Choose a save path; `handler` runs with the chosen path (skipped if cancelled). pub fn save_file( &self, title: impl Into, file_name: impl Into, filters: &[(&str, &[&str])], handler: F, ) where F: FnOnce(&mut BrowserState, PathBuf) + Send + 'static, { self.request( DialogKind::SaveFile { title: title.into(), file_name: file_name.into(), filters: Self::owned_filters(filters), }, Box::new(move |s, paths| { if let Some(p) = paths.into_iter().next() { handler(s, p); } }), ); } /// True while a dialog is open (or its result is awaiting a poll). The frame /// loop repaints while this holds so the result is applied promptly. pub fn is_open(&self) -> bool { self.pending.lock().is_some() } /// If the open dialog has finished, take its handler + result for the caller /// to apply (on the GUI thread, with `&mut BrowserState`). pub fn take_completed(&self) -> Option<(DialogHandler, Vec)> { let mut slot = self.pending.lock(); let result = match slot.as_ref() { Some(p) => p.rx.try_recv(), None => return None, }; match result { Ok(paths) => { let pending = slot.take().expect("pending checked above"); Some((pending.handler, paths)) } Err(mpsc::TryRecvError::Empty) => None, // Worker died without sending (shouldn't happen), clear the slot. Err(mpsc::TryRecvError::Disconnected) => { *slot = None; None } } } } async fn run_dialog(kind: DialogKind) -> Vec { fn apply_filters( mut d: rfd::AsyncFileDialog, filters: &[(String, Vec)], ) -> rfd::AsyncFileDialog { for (name, exts) in filters { let exts: Vec<&str> = exts.iter().map(String::as_str).collect(); d = d.add_filter(name, &exts); } d } match kind { DialogKind::PickFolder { title, directory } => { let mut d = rfd::AsyncFileDialog::new().set_title(title); if let Some(dir) = directory { d = d.set_directory(dir); } d.pick_folder() .await .map(|h| vec![h.path().to_path_buf()]) .unwrap_or_default() } DialogKind::PickFile { title, filters } => { let d = apply_filters(rfd::AsyncFileDialog::new().set_title(title), &filters); d.pick_file() .await .map(|h| vec![h.path().to_path_buf()]) .unwrap_or_default() } DialogKind::PickFiles { title, filters } => { let d = apply_filters(rfd::AsyncFileDialog::new().set_title(title), &filters); d.pick_files() .await .map(|hs| hs.iter().map(|h| h.path().to_path_buf()).collect()) .unwrap_or_default() } DialogKind::SaveFile { title, file_name, filters, } => { let d = apply_filters( rfd::AsyncFileDialog::new() .set_title(title) .set_file_name(file_name), &filters, ); d.save_file() .await .map(|h| vec![h.path().to_path_buf()]) .unwrap_or_default() } } }