Skip to main content

max / audiofiles

9.0 KB · 271 lines History Blame Raw
1 //! Non-blocking native file dialogs.
2 //!
3 //! The synchronous `rfd::FileDialog` blocks the egui frame thread while open. This
4 //! drives `rfd::AsyncFileDialog` instead: the picker is requested with a handler
5 //! closure, run on a worker thread via `pollster::block_on`, and its result is
6 //! applied on the GUI thread on a later frame.
7 //!
8 //! **macOS:** native file panels must run on the main thread. `AsyncFileDialog`
9 //! marshals the panel onto the main run loop (which eframe pumps each frame) and
10 //! resolves the future on the worker thread, so this stays main-thread-correct.
11 //! (The earlier sync dialog also ran on the main thread; the only change is that
12 //! the frame loop no longer blocks while the panel is open.) **NOTE: verify on the
13 //! macOS build host (mbp) before launch, the panel/run-loop interaction can't be
14 //! exercised from Linux.**
15 //!
16 //! One dialog at a time (file panels are modal), so a single pending slot. The
17 //! pending slot is behind a `Mutex` and the handler is `Send` so `BrowserState`
18 //! stays `Sync` (required by nih-plug), matching the classifier worker handle.
19
20 use std::path::PathBuf;
21 use std::sync::mpsc;
22
23 use parking_lot::Mutex;
24
25 use crate::state::BrowserState;
26
27 /// What kind of picker to show. Titles/filters are owned so the request can move
28 /// to the worker thread.
29 pub enum DialogKind {
30 PickFolder {
31 title: String,
32 directory: Option<PathBuf>,
33 },
34 PickFile {
35 title: String,
36 filters: Vec<(String, Vec<String>)>,
37 },
38 PickFiles {
39 title: String,
40 filters: Vec<(String, Vec<String>)>,
41 },
42 SaveFile {
43 title: String,
44 file_name: String,
45 filters: Vec<(String, Vec<String>)>,
46 },
47 }
48
49 /// Applied on the GUI thread with the picked paths (empty = cancelled). `Send` so
50 /// the manager (and `BrowserState`) stays `Sync`; runs on the GUI thread only.
51 type DialogHandler = Box<dyn FnOnce(&mut BrowserState, Vec<PathBuf>) + Send>;
52
53 struct Pending {
54 rx: mpsc::Receiver<Vec<PathBuf>>,
55 handler: DialogHandler,
56 }
57
58 /// Owns the in-flight file dialog (if any) and routes its result to a handler.
59 #[derive(Default)]
60 pub struct DialogManager {
61 pending: Mutex<Option<Pending>>,
62 }
63
64 impl DialogManager {
65 /// Open a file dialog and register `handler` to run with the result. Ignored
66 /// if a dialog is already open (panels are modal, one at a time).
67 pub fn request(&self, kind: DialogKind, handler: DialogHandler) {
68 let mut slot = self.pending.lock();
69 if slot.is_some() {
70 return;
71 }
72 let (tx, rx) = mpsc::channel();
73 let _ = audiofiles_core::worker_runtime::spawn_detached("file-dialog", move || {
74 let _ = tx.send(pollster::block_on(run_dialog(kind)));
75 });
76 *slot = Some(Pending { rx, handler });
77 }
78
79 fn owned_filters(filters: &[(&str, &[&str])]) -> Vec<(String, Vec<String>)> {
80 filters
81 .iter()
82 .map(|(n, e)| {
83 (
84 n.to_string(),
85 e.iter().map(std::string::ToString::to_string).collect(),
86 )
87 })
88 .collect()
89 }
90
91 /// Pick a folder; `handler` runs with the chosen path (skipped if cancelled).
92 pub fn pick_folder<F>(&self, title: impl Into<String>, handler: F)
93 where
94 F: FnOnce(&mut BrowserState, PathBuf) + Send + 'static,
95 {
96 self.request(
97 DialogKind::PickFolder {
98 title: title.into(),
99 directory: None,
100 },
101 Box::new(move |s, paths| {
102 if let Some(p) = paths.into_iter().next() {
103 handler(s, p);
104 }
105 }),
106 );
107 }
108
109 /// Pick a folder starting at `directory`; `handler` runs with the chosen path.
110 pub fn pick_folder_in<F>(&self, title: impl Into<String>, directory: PathBuf, handler: F)
111 where
112 F: FnOnce(&mut BrowserState, PathBuf) + Send + 'static,
113 {
114 self.request(
115 DialogKind::PickFolder {
116 title: title.into(),
117 directory: Some(directory),
118 },
119 Box::new(move |s, paths| {
120 if let Some(p) = paths.into_iter().next() {
121 handler(s, p);
122 }
123 }),
124 );
125 }
126
127 /// Pick a single file; `handler` runs with the chosen path (skipped if cancelled).
128 pub fn pick_file<F>(&self, title: impl Into<String>, filters: &[(&str, &[&str])], handler: F)
129 where
130 F: FnOnce(&mut BrowserState, PathBuf) + Send + 'static,
131 {
132 self.request(
133 DialogKind::PickFile {
134 title: title.into(),
135 filters: Self::owned_filters(filters),
136 },
137 Box::new(move |s, paths| {
138 if let Some(p) = paths.into_iter().next() {
139 handler(s, p);
140 }
141 }),
142 );
143 }
144
145 /// Pick one or more files; `handler` runs with the chosen paths (skipped if none).
146 pub fn pick_files<F>(&self, title: impl Into<String>, filters: &[(&str, &[&str])], handler: F)
147 where
148 F: FnOnce(&mut BrowserState, Vec<PathBuf>) + Send + 'static,
149 {
150 self.request(
151 DialogKind::PickFiles {
152 title: title.into(),
153 filters: Self::owned_filters(filters),
154 },
155 Box::new(move |s, paths| {
156 if !paths.is_empty() {
157 handler(s, paths);
158 }
159 }),
160 );
161 }
162
163 /// Choose a save path; `handler` runs with the chosen path (skipped if cancelled).
164 pub fn save_file<F>(
165 &self,
166 title: impl Into<String>,
167 file_name: impl Into<String>,
168 filters: &[(&str, &[&str])],
169 handler: F,
170 ) where
171 F: FnOnce(&mut BrowserState, PathBuf) + Send + 'static,
172 {
173 self.request(
174 DialogKind::SaveFile {
175 title: title.into(),
176 file_name: file_name.into(),
177 filters: Self::owned_filters(filters),
178 },
179 Box::new(move |s, paths| {
180 if let Some(p) = paths.into_iter().next() {
181 handler(s, p);
182 }
183 }),
184 );
185 }
186
187 /// True while a dialog is open (or its result is awaiting a poll). The frame
188 /// loop repaints while this holds so the result is applied promptly.
189 pub fn is_open(&self) -> bool {
190 self.pending.lock().is_some()
191 }
192
193 /// If the open dialog has finished, take its handler + result for the caller
194 /// to apply (on the GUI thread, with `&mut BrowserState`).
195 pub fn take_completed(&self) -> Option<(DialogHandler, Vec<PathBuf>)> {
196 let mut slot = self.pending.lock();
197 let result = match slot.as_ref() {
198 Some(p) => p.rx.try_recv(),
199 None => return None,
200 };
201 match result {
202 Ok(paths) => {
203 let pending = slot.take().expect("pending checked above");
204 Some((pending.handler, paths))
205 }
206 Err(mpsc::TryRecvError::Empty) => None,
207 // Worker died without sending (shouldn't happen), clear the slot.
208 Err(mpsc::TryRecvError::Disconnected) => {
209 *slot = None;
210 None
211 }
212 }
213 }
214 }
215
216 async fn run_dialog(kind: DialogKind) -> Vec<PathBuf> {
217 fn apply_filters(
218 mut d: rfd::AsyncFileDialog,
219 filters: &[(String, Vec<String>)],
220 ) -> rfd::AsyncFileDialog {
221 for (name, exts) in filters {
222 let exts: Vec<&str> = exts.iter().map(String::as_str).collect();
223 d = d.add_filter(name, &exts);
224 }
225 d
226 }
227
228 match kind {
229 DialogKind::PickFolder { title, directory } => {
230 let mut d = rfd::AsyncFileDialog::new().set_title(title);
231 if let Some(dir) = directory {
232 d = d.set_directory(dir);
233 }
234 d.pick_folder()
235 .await
236 .map(|h| vec![h.path().to_path_buf()])
237 .unwrap_or_default()
238 }
239 DialogKind::PickFile { title, filters } => {
240 let d = apply_filters(rfd::AsyncFileDialog::new().set_title(title), &filters);
241 d.pick_file()
242 .await
243 .map(|h| vec![h.path().to_path_buf()])
244 .unwrap_or_default()
245 }
246 DialogKind::PickFiles { title, filters } => {
247 let d = apply_filters(rfd::AsyncFileDialog::new().set_title(title), &filters);
248 d.pick_files()
249 .await
250 .map(|hs| hs.iter().map(|h| h.path().to_path_buf()).collect())
251 .unwrap_or_default()
252 }
253 DialogKind::SaveFile {
254 title,
255 file_name,
256 filters,
257 } => {
258 let d = apply_filters(
259 rfd::AsyncFileDialog::new()
260 .set_title(title)
261 .set_file_name(file_name),
262 &filters,
263 );
264 d.save_file()
265 .await
266 .map(|h| vec![h.path().to_path_buf()])
267 .unwrap_or_default()
268 }
269 }
270 }
271