Skip to main content

ashlar_native/
dialogs.rs

1//! Open, save and folder pickers (rfd).
2//!
3//! Describe the dialog with [`FileDialog`], then call a blocking picker ([`FileDialog::open_file`],
4//! …) or its async twin ([`FileDialog::open_file_async`], …). A dismissed dialog is
5//! [`NativeError::Cancelled`].
6//!
7//! Threads: on macOS the blocking pickers must run on the main thread (or on another thread while
8//! the main thread is running its event loop); the async pickers run anywhere and are the right
9//! choice inside an event loop. On the web only [`FileDialog::open_bytes_async`] exists (browsers
10//! give the page a file's contents, never its path); everything else is `Unsupported`.
11//!
12//! Parent a dialog to a window with [`FileDialog::parent`]: it then opens over that window (a
13//! sheet on macOS) and keeps it from taking input until it closes.
14//!
15//! [`message`] shows a modal message box, for what must be said before (or without) a window:
16//! a start-up failure, command-line output in an app that has no console.
17
18use std::path::{Path, PathBuf};
19
20use raw_window_handle::{HasDisplayHandle, HasWindowHandle};
21
22use crate::{NativeError, Result};
23
24/// Longest title or file name passed to the OS.
25const MAX_TEXT: usize = 512;
26/// At most this many filters.
27const MAX_FILTERS: usize = 64;
28
29/// One filter row: a name and the extensions it accepts (without dots: `["png", "jpg"]`).
30#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
31pub struct Filter {
32    pub name: String,
33    pub extensions: Vec<String>,
34}
35
36/// What a dialog shows. Every field is optional.
37#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
38pub struct FileDialog {
39    pub title: Option<String>,
40    /// The folder the dialog opens in.
41    pub directory: Option<PathBuf>,
42    /// The suggested name (save dialogs).
43    pub file_name: Option<String>,
44    pub filters: Vec<Filter>,
45}
46
47fn clip(s: &str) -> String {
48    s.chars().filter(|c| !c.is_control()).take(MAX_TEXT).collect()
49}
50
51/// An extension as rfd wants it: no leading dot, no separators, short.
52fn clean_extension(e: &str) -> Option<String> {
53    let e = e.trim().trim_start_matches("*.").trim_start_matches('.');
54    let ok = !e.is_empty() && e.len() <= 32 && e.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '*');
55    ok.then(|| e.to_string())
56}
57
58impl FileDialog {
59    pub fn new() -> Self {
60        Self::default()
61    }
62
63    pub fn title(mut self, title: impl Into<String>) -> Self {
64        self.title = Some(title.into());
65        self
66    }
67
68    pub fn directory(mut self, dir: impl AsRef<Path>) -> Self {
69        self.directory = Some(dir.as_ref().to_path_buf());
70        self
71    }
72
73    pub fn file_name(mut self, name: impl Into<String>) -> Self {
74        self.file_name = Some(name.into());
75        self
76    }
77
78    /// Add a filter row (`filter("Images", &["png", "jpg"])`). Extensions may be written `png`,
79    /// `.png` or `*.png`; malformed ones are dropped, and a row left without extensions is skipped.
80    pub fn filter(mut self, name: impl Into<String>, extensions: &[&str]) -> Self {
81        self.filters.push(Filter { name: name.into(), extensions: extensions.iter().map(|e| e.to_string()).collect() });
82        self
83    }
84
85    /// The filters as they are given to the OS: cleaned, bounded, empty rows dropped.
86    pub fn clean_filters(&self) -> Vec<(String, Vec<String>)> {
87        self.filters
88            .iter()
89            .take(MAX_FILTERS)
90            .filter_map(|f| {
91                let exts: Vec<String> = f.extensions.iter().filter_map(|e| clean_extension(e)).take(64).collect();
92                (!exts.is_empty()).then(|| (clip(&f.name), exts))
93            })
94            .collect()
95    }
96
97    #[cfg(not(target_arch = "wasm32"))]
98    /// The folder to open in, only when it exists (a missing folder makes some dialogs fail).
99    fn existing_directory(&self) -> Option<&Path> {
100        self.directory.as_deref().filter(|d| d.is_dir())
101    }
102}
103
104/// Longest message text passed to the OS.
105#[cfg(not(target_arch = "wasm32"))]
106const MAX_MESSAGE: usize = 8192;
107
108/// How serious a [`message`] is; the OS picks the icon.
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)]
110#[serde(rename_all = "camelCase")]
111pub enum MessageLevel {
112    #[default]
113    Info,
114    Warning,
115    Error,
116}
117
118/// Message text as it is given to the OS: line breaks and tabs kept, other control characters
119/// dropped, at most [`MAX_MESSAGE`] characters.
120#[cfg(not(target_arch = "wasm32"))]
121fn clip_message(s: &str) -> String {
122    s.chars().filter(|c| !c.is_control() || *c == '\n' || *c == '\t').take(MAX_MESSAGE).collect()
123}
124
125/// Show a modal message box with one OK button and wait until it is dismissed. Main thread on
126/// macOS. `Unsupported` on the web.
127pub fn message(level: MessageLevel, title: &str, text: &str) -> Result<()> {
128    #[cfg(not(target_arch = "wasm32"))]
129    {
130        let level = match level {
131            MessageLevel::Info => rfd::MessageLevel::Info,
132            MessageLevel::Warning => rfd::MessageLevel::Warning,
133            MessageLevel::Error => rfd::MessageLevel::Error,
134        };
135        let (title, text) = (clip(title), clip_message(text));
136        crate::guarded("the message box", || {
137            crate::modal::around(|| {
138                rfd::MessageDialog::new().set_level(level).set_title(title).set_description(text).set_buttons(rfd::MessageButtons::Ok).show()
139            });
140            Ok(())
141        })
142    }
143    #[cfg(target_arch = "wasm32")]
144    {
145        let _ = (level, title, text);
146        Err(NativeError::Unsupported)
147    }
148}
149
150/// A [`FileDialog`] shown over a window ([`FileDialog::parent`]). Its pickers block like the
151/// unparented ones, so call them between frames, not inside one.
152#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
153pub struct Parented<'a, W: ?Sized> {
154    dialog: &'a FileDialog,
155    parent: &'a W,
156}
157
158impl FileDialog {
159    /// Show the dialog over `window` (any window with a raw handle, such as `eframe::Frame`).
160    pub fn parent<'a, W: HasWindowHandle + HasDisplayHandle + ?Sized>(&'a self, window: &'a W) -> Parented<'a, W> {
161        Parented { dialog: self, parent: window }
162    }
163}
164
165#[cfg(not(target_arch = "wasm32"))]
166mod native {
167    use super::*;
168
169    fn sync(d: &FileDialog) -> rfd::FileDialog {
170        sync_in::<NoWindow>(d, None)
171    }
172
173    /// Stands in for "no parent window" in [`sync_in`]; never built.
174    enum NoWindow {}
175
176    impl raw_window_handle::HasWindowHandle for NoWindow {
177        fn window_handle(&self) -> std::result::Result<raw_window_handle::WindowHandle<'_>, raw_window_handle::HandleError> {
178            match *self {}
179        }
180    }
181
182    impl raw_window_handle::HasDisplayHandle for NoWindow {
183        fn display_handle(&self) -> std::result::Result<raw_window_handle::DisplayHandle<'_>, raw_window_handle::HandleError> {
184            match *self {}
185        }
186    }
187
188    pub(super) fn sync_in<W: HasWindowHandle + HasDisplayHandle + ?Sized>(d: &FileDialog, parent: Option<&W>) -> rfd::FileDialog {
189        let mut r = rfd::FileDialog::new();
190        // a window without a usable handle (closing, headless) gets an unparented dialog
191        if let Some(w) = parent
192            && w.window_handle().is_ok()
193            && w.display_handle().is_ok()
194        {
195            r = r.set_parent(w);
196        }
197        if let Some(t) = &d.title {
198            r = r.set_title(clip(t));
199        }
200        if let Some(dir) = d.existing_directory() {
201            r = r.set_directory(dir);
202        }
203        if let Some(n) = &d.file_name {
204            r = r.set_file_name(clip(n));
205        }
206        for (name, exts) in d.clean_filters() {
207            r = r.add_filter(name, &exts);
208        }
209        r
210    }
211
212    fn asynch(d: &FileDialog) -> rfd::AsyncFileDialog {
213        let mut r = rfd::AsyncFileDialog::new();
214        if let Some(t) = &d.title {
215            r = r.set_title(clip(t));
216        }
217        if let Some(dir) = d.existing_directory() {
218            r = r.set_directory(dir);
219        }
220        if let Some(n) = &d.file_name {
221            r = r.set_file_name(clip(n));
222        }
223        for (name, exts) in d.clean_filters() {
224            r = r.add_filter(name, &exts);
225        }
226        r
227    }
228
229    fn picked<T>(what: &str, f: impl FnOnce() -> Option<T>) -> Result<T> {
230        crate::guarded(what, || crate::modal::around(f).ok_or(NativeError::Cancelled))
231    }
232
233    impl FileDialog {
234        /// Ask for one existing file.
235        pub fn open_file(&self) -> Result<PathBuf> {
236            picked("the open dialog", || sync(self).pick_file())
237        }
238
239        /// Ask for one or more existing files.
240        pub fn open_files(&self) -> Result<Vec<PathBuf>> {
241            picked("the open dialog", || sync(self).pick_files()).and_then(non_empty)
242        }
243
244        /// Ask for a folder.
245        pub fn pick_folder(&self) -> Result<PathBuf> {
246            picked("the folder dialog", || sync(self).pick_folder())
247        }
248
249        /// Ask where to save. The OS has already asked about replacing an existing file.
250        pub fn save_file(&self) -> Result<PathBuf> {
251            picked("the save dialog", || sync(self).save_file())
252        }
253
254        pub async fn open_file_async(&self) -> Result<PathBuf> {
255            asynch(self).pick_file().await.map(|h| h.path().to_path_buf()).ok_or(NativeError::Cancelled)
256        }
257
258        pub async fn open_files_async(&self) -> Result<Vec<PathBuf>> {
259            let files = asynch(self).pick_files().await.ok_or(NativeError::Cancelled)?;
260            non_empty(files.iter().map(|h| h.path().to_path_buf()).collect())
261        }
262
263        pub async fn pick_folder_async(&self) -> Result<PathBuf> {
264            asynch(self).pick_folder().await.map(|h| h.path().to_path_buf()).ok_or(NativeError::Cancelled)
265        }
266
267        pub async fn save_file_async(&self) -> Result<PathBuf> {
268            asynch(self).save_file().await.map(|h| h.path().to_path_buf()).ok_or(NativeError::Cancelled)
269        }
270
271        /// Ask for one file and read it: `(file name, contents)`. Works on every platform,
272        /// including the web. Files larger than `max_bytes` are refused.
273        pub async fn open_bytes_async(&self, max_bytes: u64) -> Result<(String, Vec<u8>)> {
274            let h = asynch(self).pick_file().await.ok_or(NativeError::Cancelled)?;
275            let path = h.path();
276            let len = std::fs::metadata(path).map_err(|e| NativeError::Os(crate::os_text(e)))?.len();
277            if len > max_bytes {
278                return Err(NativeError::Invalid(format!("the file is larger than {max_bytes} bytes")));
279            }
280            let bytes = std::fs::read(path).map_err(|e| NativeError::Os(crate::os_text(e)))?;
281            Ok((h.file_name(), bytes))
282        }
283    }
284
285    impl<W: HasWindowHandle + HasDisplayHandle + ?Sized> Parented<'_, W> {
286        /// Ask for one existing file, over the parent window.
287        pub fn open_file(&self) -> Result<PathBuf> {
288            picked("the open dialog", || sync_in(self.dialog, Some(self.parent)).pick_file())
289        }
290
291        /// Ask for one or more existing files, over the parent window.
292        pub fn open_files(&self) -> Result<Vec<PathBuf>> {
293            picked("the open dialog", || sync_in(self.dialog, Some(self.parent)).pick_files()).and_then(non_empty)
294        }
295
296        /// Ask for a folder, over the parent window.
297        pub fn pick_folder(&self) -> Result<PathBuf> {
298            picked("the folder dialog", || sync_in(self.dialog, Some(self.parent)).pick_folder())
299        }
300
301        /// Ask where to save, over the parent window.
302        pub fn save_file(&self) -> Result<PathBuf> {
303            picked("the save dialog", || sync_in(self.dialog, Some(self.parent)).save_file())
304        }
305    }
306
307    fn non_empty<T>(v: Vec<T>) -> Result<Vec<T>> {
308        if v.is_empty() { Err(NativeError::Cancelled) } else { Ok(v) }
309    }
310}
311
312#[cfg(target_arch = "wasm32")]
313mod web {
314    use super::*;
315
316    impl<W: ?Sized> Parented<'_, W> {
317        pub fn open_file(&self) -> Result<PathBuf> {
318            Err(NativeError::Unsupported)
319        }
320        pub fn open_files(&self) -> Result<Vec<PathBuf>> {
321            Err(NativeError::Unsupported)
322        }
323        pub fn pick_folder(&self) -> Result<PathBuf> {
324            Err(NativeError::Unsupported)
325        }
326        pub fn save_file(&self) -> Result<PathBuf> {
327            Err(NativeError::Unsupported)
328        }
329    }
330
331    impl FileDialog {
332        pub fn open_file(&self) -> Result<PathBuf> {
333            Err(NativeError::Unsupported)
334        }
335        pub fn open_files(&self) -> Result<Vec<PathBuf>> {
336            Err(NativeError::Unsupported)
337        }
338        pub fn pick_folder(&self) -> Result<PathBuf> {
339            Err(NativeError::Unsupported)
340        }
341        pub fn save_file(&self) -> Result<PathBuf> {
342            Err(NativeError::Unsupported)
343        }
344        pub async fn open_file_async(&self) -> Result<PathBuf> {
345            Err(NativeError::Unsupported)
346        }
347        pub async fn open_files_async(&self) -> Result<Vec<PathBuf>> {
348            Err(NativeError::Unsupported)
349        }
350        pub async fn pick_folder_async(&self) -> Result<PathBuf> {
351            Err(NativeError::Unsupported)
352        }
353        pub async fn save_file_async(&self) -> Result<PathBuf> {
354            Err(NativeError::Unsupported)
355        }
356
357        /// Ask for one file and read it: `(file name, contents)`. Files larger than `max_bytes`
358        /// are refused (the browser has read them by then; the check bounds what the app keeps).
359        pub async fn open_bytes_async(&self, max_bytes: u64) -> Result<(String, Vec<u8>)> {
360            let mut r = rfd::AsyncFileDialog::new();
361            if let Some(t) = &self.title {
362                r = r.set_title(clip(t));
363            }
364            for (name, exts) in self.clean_filters() {
365                r = r.add_filter(name, &exts);
366            }
367            let h = r.pick_file().await.ok_or(NativeError::Cancelled)?;
368            let bytes = h.read().await;
369            if bytes.len() as u64 > max_bytes {
370                return Err(NativeError::Invalid(format!("the file is larger than {max_bytes} bytes")));
371            }
372            Ok((h.file_name(), bytes))
373        }
374    }
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380
381    #[test]
382    fn filters_are_cleaned_and_bounded() {
383        let d = FileDialog::new()
384            .filter("Images", &["png", ".jpg", "*.webp", "", "a/b", "../x"])
385            .filter("Nothing valid", &["/", ""])
386            .filter("All", &["*"]);
387        assert_eq!(
388            d.clean_filters(),
389            vec![("Images".to_string(), vec!["png".to_string(), "jpg".into(), "webp".into()]), ("All".to_string(), vec!["*".to_string()])]
390        );
391        let many = (0..500).fold(FileDialog::new(), |d, i| d.filter(format!("f{i}"), &["txt"]));
392        assert_eq!(many.clean_filters().len(), MAX_FILTERS);
393    }
394
395    #[test]
396    fn message_text_keeps_its_lines_and_is_bounded() {
397        assert_eq!(clip_message("usage:\n\t-h\u{0}\u{1b}[31m"), "usage:\n\t-h[31m");
398        assert_eq!(clip_message(&"y".repeat(100_000)).chars().count(), MAX_MESSAGE);
399        let level: MessageLevel = serde_json::from_str("\"error\"").unwrap_or_default();
400        assert_eq!(level, MessageLevel::Error);
401    }
402
403    #[test]
404    fn hostile_text_is_clipped() {
405        let long = "x".repeat(10_000);
406        assert_eq!(clip(&long).len(), MAX_TEXT);
407        assert_eq!(clip("a\nb\u{0}c"), "abc");
408    }
409
410    #[cfg(not(target_arch = "wasm32"))]
411    #[test]
412    fn a_missing_directory_is_not_passed_on() {
413        let d = FileDialog::new().directory("/definitely/not/here/ashlar");
414        assert!(d.existing_directory().is_none());
415        let tmp = std::env::temp_dir();
416        assert_eq!(FileDialog::new().directory(&tmp).existing_directory(), Some(tmp.as_path()));
417    }
418
419    #[test]
420    fn the_description_round_trips_through_json() {
421        let d = FileDialog::new().title("Export").file_name("clip.mp4").filter("Video", &["mp4"]);
422        let v = serde_json::to_string(&d).unwrap_or_default();
423        let back: FileDialog = serde_json::from_str(&v).unwrap_or_default();
424        assert_eq!(back, d);
425    }
426
427    /// A window that has no handle (closing, or never shown).
428    struct Gone;
429
430    impl HasWindowHandle for Gone {
431        fn window_handle(&self) -> std::result::Result<raw_window_handle::WindowHandle<'_>, raw_window_handle::HandleError> {
432            Err(raw_window_handle::HandleError::Unavailable)
433        }
434    }
435
436    impl HasDisplayHandle for Gone {
437        fn display_handle(&self) -> std::result::Result<raw_window_handle::DisplayHandle<'_>, raw_window_handle::HandleError> {
438            Err(raw_window_handle::HandleError::Unavailable)
439        }
440    }
441
442    #[cfg(not(target_arch = "wasm32"))]
443    #[test]
444    fn a_window_without_a_handle_gets_an_unparented_dialog() {
445        let d = FileDialog::new().title("Open");
446        // building the dialog must not panic or need the handle; nothing is shown
447        let _ = native::sync_in(&d, Some(&Gone));
448        let p = d.parent(&Gone);
449        assert_eq!(p.dialog, &d);
450    }
451}