Skip to main content

max / audiofiles

UI A+: non-blocking native file dialogs Synchronous rfd::FileDialog blocked the egui frame thread while a picker was open. New ui::dialog::DialogManager drives rfd::AsyncFileDialog on a worker thread (pollster::block_on) and applies the result on the GUI thread via draw_browser's poll, so the frame loop no longer blocks. One modal at a time; the pending slot is Mutex-guarded with a Send handler so BrowserState stays Sync (nih-plug). Migrated all 17 browser picker sites (toolbar, file_list[_menus], classifier import/export, export destination, overlays locate, import-source, settings: relocate/create/theme import+export/mirror) through pick_folder/pick_file/ pick_files/save_file helpers. vault_setup (audiofiles-app, one-time pre-library setup, separate state) stays synchronous by design. macOS: AsyncFileDialog marshals the panel onto the main run loop (eframe pumps it) and resolves on the worker thread, staying main-thread-correct. NOTE: needs a smoke-test on the macOS build host (mbp) before launch — the panel/run-loop interaction can't be exercised from Linux. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-22 23:36 UTC
Commit: dac73864cba9a8e0297b625c6499a1dc25b33e64
Parent: adb9b1c
15 files changed, +377 insertions, -121 deletions
M Cargo.lock +1
@@ -427,6 +427,7 @@ dependencies = [
427 427 "objc2-app-kit 0.3.2",
428 428 "objc2-foundation 0.3.2",
429 429 "parking_lot",
430 + "pollster",
430 431 "rfd",
431 432 "rusqlite",
432 433 "serde",
M Cargo.toml +1
@@ -27,6 +27,7 @@ bs1770 = "=1.0.0"
27 27 realfft = "3.5.0"
28 28 toml = "0.8.23"
29 29 rfd = "0.15.4"
30 + pollster = "0.4"
30 31 serde = { version = "1.0.228", features = ["derive"] }
31 32 serde_json = "1.0.149"
32 33 rubato = "0.14"
@@ -20,6 +20,7 @@ dirs = { workspace = true }
20 20 thiserror = { workspace = true }
21 21 toml = { workspace = true }
22 22 rfd = { workspace = true }
23 + pollster = { workspace = true }
23 24 serde = { workspace = true }
24 25 serde_json = { workspace = true }
25 26 rusqlite = { workspace = true }
@@ -31,6 +31,16 @@ pub fn draw_browser(
31 31 ctx.request_repaint();
32 32 }
33 33
34 + // Apply a finished native file dialog on the GUI thread, and keep repainting
35 + // while one is open so its result lands promptly (the picker runs off-thread).
36 + if let Some((handler, paths)) = state.dialogs.take_completed() {
37 + handler(state, paths);
38 + ctx.request_repaint();
39 + }
40 + if state.dialogs.is_open() {
41 + ctx.request_repaint();
42 + }
43 +
34 44 match &state.import_mode {
35 45 ImportMode::None => {
36 46 handle_keyboard(ctx, state);
@@ -129,6 +129,9 @@ pub struct BrowserState {
129 129 pub current_dir: Option<NodeId>,
130 130 pub breadcrumb: Vec<VfsNode>,
131 131 pub contents: ContentsRef,
132 + /// Non-blocking native file dialogs (folder/file pickers, save). Results are
133 + /// applied on the GUI thread via `draw_browser`'s poll.
134 + pub dialogs: crate::ui::dialog::DialogManager,
132 135 pub selection: Selection,
133 136 pub selected_tags: Arc<Vec<String>>,
134 137 /// Per-tag provenance for the selected sample: tag -> (source, rule_id). A tag
@@ -522,6 +525,7 @@ impl BrowserState {
522 525 current_dir: None,
523 526 breadcrumb: Vec::new(),
524 527 contents: ContentsRef::new(contents),
528 + dialogs: crate::ui::dialog::DialogManager::default(),
525 529 selection: Selection::new(),
526 530 selected_tags: Arc::new(Vec::new()),
527 531 selected_tag_sources: std::collections::HashMap::new(),
@@ -798,12 +798,14 @@ fn draw_sharing_header(ui: &mut egui::Ui, state: &mut BrowserState) {
798 798 if widgets::primary_button(ui, "Export to file...")
799 799 .on_hover_text("Save a .afcl file you can share \u{2014} no audio is included")
800 800 .clicked()
801 - && let Some(path) = rfd::FileDialog::new()
802 - .set_file_name(format!("{}.afcl", sanitize_filename(&state.classifier.export_name)))
803 - .add_filter("AF classifier", &["afcl"])
804 - .save_file()
805 801 {
806 - state.classifier_export_afcl(&path);
802 + let file_name = format!("{}.afcl", sanitize_filename(&state.classifier.export_name));
803 + state.dialogs.save_file(
804 + "Export classifier",
805 + file_name,
806 + &[("AF classifier", &["afcl"])],
807 + |s, p| s.classifier_export_afcl(&p),
808 + );
807 809 }
808 810 });
809 811 if !has_content {
@@ -824,11 +826,12 @@ fn draw_sharing_header(ui: &mut egui::Ui, state: &mut BrowserState) {
824 826 if widgets::primary_button(ui, "Import .afcl file...")
825 827 .on_hover_text("Add someone's shared classifier as a removable layer")
826 828 .clicked()
827 - && let Some(path) = rfd::FileDialog::new()
828 - .add_filter("AF classifier", &["afcl"])
829 - .pick_file()
830 829 {
831 - state.classifier_import_afcl(&path);
830 + state.dialogs.pick_file(
831 + "Import classifier",
832 + &[("AF classifier", &["afcl"])],
833 + |s, p| s.classifier_import_afcl(&p),
834 + );
832 835 }
833 836 });
834 837 if let Some(msg) = &state.classifier.last_import_msg {
@@ -0,0 +1,254 @@
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 + std::thread::Builder::new()
74 + .name("file-dialog".to_string())
75 + .spawn(move || {
76 + let _ = tx.send(pollster::block_on(run_dialog(kind)));
77 + })
78 + .ok();
79 + *slot = Some(Pending { rx, handler });
80 + }
81 +
82 + fn owned_filters(filters: &[(&str, &[&str])]) -> Vec<(String, Vec<String>)> {
83 + filters
84 + .iter()
85 + .map(|(n, e)| (n.to_string(), e.iter().map(|x| x.to_string()).collect()))
86 + .collect()
87 + }
88 +
89 + /// Pick a folder; `handler` runs with the chosen path (skipped if cancelled).
90 + pub fn pick_folder<F>(&self, title: impl Into<String>, handler: F)
91 + where
92 + F: FnOnce(&mut BrowserState, PathBuf) + Send + 'static,
93 + {
94 + self.request(
95 + DialogKind::PickFolder { title: title.into(), directory: None },
96 + Box::new(move |s, paths| {
97 + if let Some(p) = paths.into_iter().next() {
98 + handler(s, p);
99 + }
100 + }),
101 + );
102 + }
103 +
104 + /// Pick a folder starting at `directory`; `handler` runs with the chosen path.
105 + pub fn pick_folder_in<F>(&self, title: impl Into<String>, directory: PathBuf, handler: F)
106 + where
107 + F: FnOnce(&mut BrowserState, PathBuf) + Send + 'static,
108 + {
109 + self.request(
110 + DialogKind::PickFolder { title: title.into(), directory: Some(directory) },
111 + Box::new(move |s, paths| {
112 + if let Some(p) = paths.into_iter().next() {
113 + handler(s, p);
114 + }
115 + }),
116 + );
117 + }
118 +
119 + /// Pick a single file; `handler` runs with the chosen path (skipped if cancelled).
120 + pub fn pick_file<F>(&self, title: impl Into<String>, filters: &[(&str, &[&str])], handler: F)
121 + where
122 + F: FnOnce(&mut BrowserState, PathBuf) + Send + 'static,
123 + {
124 + self.request(
125 + DialogKind::PickFile { title: title.into(), filters: Self::owned_filters(filters) },
126 + Box::new(move |s, paths| {
127 + if let Some(p) = paths.into_iter().next() {
128 + handler(s, p);
129 + }
130 + }),
131 + );
132 + }
133 +
134 + /// Pick one or more files; `handler` runs with the chosen paths (skipped if none).
135 + pub fn pick_files<F>(&self, title: impl Into<String>, filters: &[(&str, &[&str])], handler: F)
136 + where
137 + F: FnOnce(&mut BrowserState, Vec<PathBuf>) + Send + 'static,
138 + {
139 + self.request(
140 + DialogKind::PickFiles { title: title.into(), filters: Self::owned_filters(filters) },
141 + Box::new(move |s, paths| {
142 + if !paths.is_empty() {
143 + handler(s, paths);
144 + }
145 + }),
146 + );
147 + }
148 +
149 + /// Choose a save path; `handler` runs with the chosen path (skipped if cancelled).
150 + pub fn save_file<F>(
151 + &self,
152 + title: impl Into<String>,
153 + file_name: impl Into<String>,
154 + filters: &[(&str, &[&str])],
155 + handler: F,
156 + ) where
157 + F: FnOnce(&mut BrowserState, PathBuf) + Send + 'static,
158 + {
159 + self.request(
160 + DialogKind::SaveFile {
161 + title: title.into(),
162 + file_name: file_name.into(),
163 + filters: Self::owned_filters(filters),
164 + },
165 + Box::new(move |s, paths| {
166 + if let Some(p) = paths.into_iter().next() {
167 + handler(s, p);
168 + }
169 + }),
170 + );
171 + }
172 +
173 + /// True while a dialog is open (or its result is awaiting a poll). The frame
174 + /// loop repaints while this holds so the result is applied promptly.
175 + pub fn is_open(&self) -> bool {
176 + self.pending.lock().is_some()
177 + }
178 +
179 + /// If the open dialog has finished, take its handler + result for the caller
180 + /// to apply (on the GUI thread, with `&mut BrowserState`).
181 + pub fn take_completed(&self) -> Option<(DialogHandler, Vec<PathBuf>)> {
182 + let mut slot = self.pending.lock();
183 + let result = match slot.as_ref() {
184 + Some(p) => p.rx.try_recv(),
185 + None => return None,
186 + };
187 + match result {
188 + Ok(paths) => {
189 + let pending = slot.take().expect("pending checked above");
190 + Some((pending.handler, paths))
191 + }
192 + Err(mpsc::TryRecvError::Empty) => None,
193 + // Worker died without sending (shouldn't happen) — clear the slot.
194 + Err(mpsc::TryRecvError::Disconnected) => {
195 + *slot = None;
196 + None
197 + }
198 + }
199 + }
200 + }
201 +
202 + async fn run_dialog(kind: DialogKind) -> Vec<PathBuf> {
203 + fn apply_filters(
204 + mut d: rfd::AsyncFileDialog,
205 + filters: &[(String, Vec<String>)],
206 + ) -> rfd::AsyncFileDialog {
207 + for (name, exts) in filters {
208 + let exts: Vec<&str> = exts.iter().map(String::as_str).collect();
209 + d = d.add_filter(name, &exts);
210 + }
211 + d
212 + }
213 +
214 + match kind {
215 + DialogKind::PickFolder { title, directory } => {
216 + let mut d = rfd::AsyncFileDialog::new().set_title(title);
217 + if let Some(dir) = directory {
218 + d = d.set_directory(dir);
219 + }
220 + d.pick_folder()
221 + .await
222 + .map(|h| vec![h.path().to_path_buf()])
223 + .unwrap_or_default()
224 + }
225 + DialogKind::PickFile { title, filters } => {
226 + let d = apply_filters(rfd::AsyncFileDialog::new().set_title(title), &filters);
227 + d.pick_file()
228 + .await
229 + .map(|h| vec![h.path().to_path_buf()])
230 + .unwrap_or_default()
231 + }
232 + DialogKind::PickFiles { title, filters } => {
233 + let d = apply_filters(rfd::AsyncFileDialog::new().set_title(title), &filters);
234 + d.pick_files()
235 + .await
236 + .map(|hs| hs.iter().map(|h| h.path().to_path_buf()).collect())
237 + .unwrap_or_default()
238 + }
239 + DialogKind::SaveFile {
240 + title,
241 + file_name,
242 + filters,
243 + } => {
244 + let d = apply_filters(
245 + rfd::AsyncFileDialog::new().set_title(title).set_file_name(file_name),
246 + &filters,
247 + );
248 + d.save_file()
249 + .await
250 + .map(|h| vec![h.path().to_path_buf()])
251 + .unwrap_or_default()
252 + }
253 + }
254 + }
@@ -507,18 +507,23 @@ pub fn draw_configure_export(ui: &mut egui::Ui, state: &mut BrowserState) {
507 507
508 508 // --- Destination ---
509 509 ui.label(egui::RichText::new("Destination").strong());
510 + // Capture the browse click inside the import_mode borrow, then request
511 + // the dialog after it ends (the async handler re-borrows import_mode).
512 + let mut browse_from: Option<std::path::PathBuf> = None;
510 513 if let ImportMode::ConfigureExport { ref mut config, .. } = state.import_mode {
511 514 ui.horizontal(|ui| {
512 515 let dest_display = config.destination.display().to_string();
513 516 ui.label(&dest_display);
514 - if ui.button("Browse...").clicked()
515 - && let Some(path) = rfd::FileDialog::new()
516 - .set_title("Export Destination")
517 - .set_directory(&config.destination)
518 - .pick_folder()
519 - {
520 - config.destination = path;
521 - }
517 + if ui.button("Browse...").clicked() {
518 + browse_from = Some(config.destination.clone());
519 + }
520 + });
521 + }
522 + if let Some(start_dir) = browse_from {
523 + state.dialogs.pick_folder_in("Export Destination", start_dir, |s, p| {
524 + if let ImportMode::ConfigureExport { config, .. } = &mut s.import_mode {
525 + config.destination = p;
526 + }
522 527 });
523 528 }
524 529 });
@@ -59,13 +59,11 @@ pub fn draw_file_list(
59 59 ui.horizontal(|ui| {
60 60 widgets::step_number(ui, 1);
61 61 ui.label("Drop a folder of samples onto this window, or click ");
62 - if ui.link("Import").clicked()
63 - && let Some(path) = rfd::FileDialog::new()
64 - .set_title("Quick Import Folder")
65 - .pick_folder()
66 - {
67 - state.quick_import_folder(path);
68 - }
62 + if ui.link("Import").clicked() {
63 + state.dialogs.pick_folder("Quick Import Folder", |s, p| {
64 + s.quick_import_folder(p)
65 + });
66 + }
69 67 });
70 68 ui.add_space(theme::space::SM);
71 69
@@ -111,13 +109,9 @@ pub fn draw_file_list(
111 109 tooltip: Some("Choose a folder of samples to import"),
112 110 }),
113 111 );
114 - if clicked
115 - && let Some(path) = rfd::FileDialog::new()
116 - .set_title("Import folder")
117 - .pick_folder()
118 - {
119 - state.quick_import_folder(path);
120 - }
112 + if clicked {
113 + state.dialogs.pick_folder("Import folder", |s, p| s.quick_import_folder(p));
114 + }
121 115 // Quiet link to bring the welcome screen back if the user dismissed it.
122 116 ui.vertical_centered(|ui| {
123 117 ui.add_space(theme::space::LG);
@@ -412,24 +412,22 @@ pub fn draw_background_context_menu(ui: &mut egui::Ui, state: &mut BrowserState)
412 412 ui.close();
413 413 }
414 414 if ui.button("Import files...").clicked() {
415 - if let Some(paths) = rfd::FileDialog::new()
416 - .set_title("Import files")
417 - .add_filter("Audio", audiofiles_core::util::AUDIO_EXTENSIONS)
418 - .pick_files()
419 - {
420 - for path in paths {
421 - state.import_path(&path);
422 - }
423 - }
415 + state.dialogs.pick_files(
416 + "Import files",
417 + &[("Audio", audiofiles_core::util::AUDIO_EXTENSIONS)],
418 + |s, paths| {
419 + for path in paths {
420 + s.import_path(&path);
421 + }
422 + },
423 + );
424 424 ui.close();
425 425 }
426 426 // C-2: matches the toolbar's "Import folder..." (wizard path). The quick
427 427 // import shortcut is only offered from the toolbar to keep this menu
428 428 // short; users who want quick-import find it there.
429 429 if ui.button("Import folder...").clicked() {
430 - if let Some(path) = rfd::FileDialog::new().pick_folder() {
431 - state.show_import_options(path);
432 - }
430 + state.dialogs.pick_folder("Import folder", |s, p| s.show_import_options(p));
433 431 ui.close();
434 432 }
435 433 if state.selection.count() > 0 {
@@ -29,12 +29,11 @@ pub fn draw_configure_import(ui: &mut egui::Ui, state: &mut BrowserState) {
29 29 .small_button("Change...")
30 30 .on_hover_text("Pick a different source folder")
31 31 .clicked()
32 - && let Some(new_source) = rfd::FileDialog::new()
33 - .set_title("Choose source folder")
34 - .pick_folder()
35 - {
36 - state.change_import_source(new_source);
37 - }
32 + {
33 + state.dialogs.pick_folder("Choose source folder", |s, p| {
34 + s.change_import_source(p)
35 + });
36 + }
38 37 });
39 38 ui.add_space(theme::space::SM);
40 39
@@ -2,6 +2,7 @@
2 2
3 3 pub mod classifier;
4 4 pub mod detail;
5 + pub mod dialog;
5 6 pub mod edit_panel;
6 7 pub mod export_screens;
7 8 pub mod file_list;
@@ -509,12 +509,9 @@ pub fn draw_loose_files_warning(ctx: &egui::Context, state: &mut BrowserState) {
509 509 Some(LooseFilesAction::Cancel) => state.dismiss_loose_files_warning(),
510 510 Some(LooseFilesAction::Purge) => state.purge_missing_loose_files(),
511 511 Some(LooseFilesAction::Locate) => {
512 - if let Some(path) = rfd::FileDialog::new()
513 - .set_title("Locate missing sample files")
514 - .pick_folder()
515 - {
516 - state.locate_missing_loose_files(path);
517 - }
512 + state.dialogs.pick_folder("Locate missing sample files", |s, p| {
513 + s.locate_missing_loose_files(p)
514 + });
518 515 }
519 516 None => {}
520 517 }
@@ -199,14 +199,12 @@ fn draw_storage_section(ui: &mut egui::Ui, state: &mut BrowserState) {
199 199 state.settings.pending_action =
200 200 Some(crate::state::VaultAction::RemoveVault(path));
201 201 }
202 - if let Some(old_path) = relocate_old_path
203 - && let Some(new_path) = rfd::FileDialog::new()
204 - .set_title("Locate library directory")
205 - .pick_folder()
206 - {
207 - state.settings.pending_action =
202 + if let Some(old_path) = relocate_old_path {
203 + state.dialogs.pick_folder("Locate library directory", move |s, new_path| {
204 + s.settings.pending_action =
208 205 Some(crate::state::VaultAction::RelocateVault { old_path, new_path });
209 - }
206 + });
207 + }
210 208
211 209 // Inline rename
212 210 if let Some((ref rename_path, _)) = state.settings.rename_target.clone() {
@@ -344,10 +342,11 @@ fn draw_storage_section(ui: &mut egui::Ui, state: &mut BrowserState) {
344 342 ui.text_edit_singleline(&mut state.settings.create_name);
345 343 });
346 344 ui.horizontal(|ui| {
347 - if ui.button("Choose folder...").clicked()
348 - && let Some(path) = rfd::FileDialog::new().pick_folder() {
349 - state.settings.create_path = Some(path);
350 - }
345 + if ui.button("Choose folder...").clicked() {
346 + state.dialogs.pick_folder("Choose folder", |s, p| {
347 + s.settings.create_path = Some(p);
348 + });
349 + }
351 350 if let Some(ref p) = state.settings.create_path {
352 351 ui.label(
353 352 egui::RichText::new(p.display().to_string())
@@ -853,61 +852,57 @@ fn draw_advanced_section(ui: &mut egui::Ui, state: &mut BrowserState) {
853 852 // Theme import/export
854 853 ui.label(egui::RichText::new("Custom Themes").small().color(theme::content_secondary()));
855 854 ui.horizontal(|ui| {
856 - if ui.button("Import Theme...").clicked()
857 - && let Some(path) = rfd::FileDialog::new()
858 - .add_filter("Theme", &["toml"])
859 - .pick_file()
860 - {
855 + if ui.button("Import Theme...").clicked() {
856 + state.dialogs.pick_file("Import Theme", &[("Theme", &["toml"])], |s, path| {
861 857 let Some(custom_dir) = theme::custom_themes_dir() else {
862 - state.status = "Theme import failed: no custom themes directory available.".to_string();
858 + s.status = "Theme import failed: no custom themes directory available.".to_string();
863 859 return;
864 860 };
865 861 match theme::load_theme(&path) {
866 862 Ok(_colors) => {
867 863 let id = path.file_stem()
868 - .and_then(|s| s.to_str())
864 + .and_then(|os| os.to_str())
869 865 .unwrap_or("custom")
870 866 .to_string();
871 867 if let Err(e) = std::fs::create_dir_all(&custom_dir) {
872 868 tracing::error!("Failed to create custom themes dir: {e}");
873 - state.status = format!("Theme import failed: {e}");
869 + s.status = format!("Theme import failed: {e}");
874 870 } else if let Err(e) = std::fs::copy(&path, custom_dir.join(format!("{id}.toml"))) {
875 871 tracing::error!("Failed to copy theme: {e}");
876 - state.status = format!("Theme import failed: {e}");
872 + s.status = format!("Theme import failed: {e}");
877 873 } else {
878 874 theme::set_theme(&id);
879 - state.current_theme_id = id.clone();
880 - state.save_theme_preference();
881 - state.status = format!("Imported theme: {id}");
875 + s.current_theme_id = id.clone();
876 + s.save_theme_preference();
877 + s.status = format!("Imported theme: {id}");
882 878 }
883 879 }
884 880 Err(e) => {
885 881 tracing::error!("Failed to load theme: {e}");
886 - state.status = format!("Theme import failed: {e}");
882 + s.status = format!("Theme import failed: {e}");
887 883 }
888 884 }
889 - }
890 - if ui.button("Export Current...").clicked()
891 - && let Some(path) = rfd::FileDialog::new()
892 - .set_file_name(format!("{}.toml", state.current_theme_id))
893 - .add_filter("Theme", &["toml"])
894 - .save_file()
895 - {
896 - if let Some(content) = theme::export_theme_content(&state.current_theme_id) {
885 + });
886 + }
887 + if ui.button("Export Current...").clicked() {
888 + let file_name = format!("{}.toml", state.current_theme_id);
889 + state.dialogs.save_file("Export Theme", file_name, &[("Theme", &["toml"])], |s, path| {
890 + if let Some(content) = theme::export_theme_content(&s.current_theme_id) {
897 891 match std::fs::write(&path, content) {
898 892 Ok(()) => {
899 - state.status = format!("Exported theme to {}", path.display());
893 + s.status = format!("Exported theme to {}", path.display());
900 894 }
901 895 Err(e) => {
902 896 tracing::error!("Failed to export theme: {e}");
903 - state.status = format!("Theme export failed: {e}");
897 + s.status = format!("Theme export failed: {e}");
904 898 }
905 899 }
906 900 } else {
907 - tracing::warn!("Theme '{}' not found for export", state.current_theme_id);
908 - state.status = format!("Theme export failed: '{}' not found.", state.current_theme_id);
901 + tracing::warn!("Theme '{}' not found for export", s.current_theme_id);
902 + s.status = format!("Theme export failed: '{}' not found.", s.current_theme_id);
909 903 }
910 - }
904 + });
905 + }
911 906 });
912 907
913 908 // Library mirror (Unix only)
@@ -935,13 +930,11 @@ fn draw_advanced_section(ui: &mut egui::Ui, state: &mut BrowserState) {
935 930 .color(theme::content_muted()),
936 931 )
937 932 .on_hover_text(state.mirror_path.display().to_string());
938 - if ui.small_button("Change...").on_hover_text("Pick a new location for the mirror").clicked()
939 - && let Some(new_path) = rfd::FileDialog::new()
940 - .set_title("Choose library mirror location")
941 - .pick_folder()
942 - {
943 - state.set_mirror_path(new_path);
944 - }
933 + if ui.small_button("Change...").on_hover_text("Pick a new location for the mirror").clicked() {
934 + state.dialogs.pick_folder("Choose library mirror location", |s, p| {
935 + s.set_mirror_path(p)
936 + });
937 + }
945 938 });
946 939 }
947 940 });
@@ -423,32 +423,27 @@ fn draw_breadcrumb(
423 423 if ui.button("Import folder...")
424 424 .on_hover_text("Choose folder, pick a strategy, then import")
425 425 .clicked()
426 - && let Some(path) = rfd::FileDialog::new()
427 - .set_title("Import folder")
428 - .pick_folder()
429 - {
430 - state.show_import_options(path);
431 - }
426 + {
427 + state.dialogs.pick_folder("Import folder", |s, p| s.show_import_options(p));
428 + }
432 429 if ui.button("Quick import folder...")
433 430 .on_hover_text("Import without strategy or tagging review")
434 431 .clicked()
435 - && let Some(path) = rfd::FileDialog::new()
436 - .set_title("Quick import folder")
437 - .pick_folder()
438 - {
439 - state.quick_import_folder(path);
440 - }
432 + {
433 + state.dialogs.pick_folder("Quick import folder", |s, p| s.quick_import_folder(p));
434 + }
441 435 ui.separator();
442 - if ui.button("Import files...").clicked()
443 - && let Some(paths) = rfd::FileDialog::new()
444 - .set_title("Import files")
445 - .add_filter("Audio", audiofiles_core::util::AUDIO_EXTENSIONS)
446 - .pick_files()
447 - {
448 - for path in paths {
449 - state.import_path(&path);
450 - }
451 - }
436 + if ui.button("Import files...").clicked() {
437 + state.dialogs.pick_files(
438 + "Import files",
439 + &[("Audio", audiofiles_core::util::AUDIO_EXTENSIONS)],
440 + |s, paths| {
441 + for path in paths {
442 + s.import_path(&path);
443 + }
444 + },
445 + );
446 + }
452 447 });
453 448
454 449 if ui.button("Export")