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//! [`message`] shows a modal message box, for what must be said before (or without) a window:
13//! a start-up failure, command-line output in an app that has no console.
14
15use std::path::{Path, PathBuf};
16
17use crate::{NativeError, Result};
18
19/// Longest title or file name passed to the OS.
20const MAX_TEXT: usize = 512;
21/// At most this many filters.
22const MAX_FILTERS: usize = 64;
23
24/// One filter row: a name and the extensions it accepts (without dots: `["png", "jpg"]`).
25#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
26pub struct Filter {
27    pub name: String,
28    pub extensions: Vec<String>,
29}
30
31/// What a dialog shows. Every field is optional.
32#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
33pub struct FileDialog {
34    pub title: Option<String>,
35    /// The folder the dialog opens in.
36    pub directory: Option<PathBuf>,
37    /// The suggested name (save dialogs).
38    pub file_name: Option<String>,
39    pub filters: Vec<Filter>,
40}
41
42fn clip(s: &str) -> String {
43    s.chars().filter(|c| !c.is_control()).take(MAX_TEXT).collect()
44}
45
46/// An extension as rfd wants it: no leading dot, no separators, short.
47fn clean_extension(e: &str) -> Option<String> {
48    let e = e.trim().trim_start_matches("*.").trim_start_matches('.');
49    let ok = !e.is_empty() && e.len() <= 32 && e.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '*');
50    ok.then(|| e.to_string())
51}
52
53impl FileDialog {
54    pub fn new() -> Self {
55        Self::default()
56    }
57
58    pub fn title(mut self, title: impl Into<String>) -> Self {
59        self.title = Some(title.into());
60        self
61    }
62
63    pub fn directory(mut self, dir: impl AsRef<Path>) -> Self {
64        self.directory = Some(dir.as_ref().to_path_buf());
65        self
66    }
67
68    pub fn file_name(mut self, name: impl Into<String>) -> Self {
69        self.file_name = Some(name.into());
70        self
71    }
72
73    /// Add a filter row (`filter("Images", &["png", "jpg"])`). Extensions may be written `png`,
74    /// `.png` or `*.png`; malformed ones are dropped, and a row left without extensions is skipped.
75    pub fn filter(mut self, name: impl Into<String>, extensions: &[&str]) -> Self {
76        self.filters.push(Filter { name: name.into(), extensions: extensions.iter().map(|e| e.to_string()).collect() });
77        self
78    }
79
80    /// The filters as they are given to the OS: cleaned, bounded, empty rows dropped.
81    pub fn clean_filters(&self) -> Vec<(String, Vec<String>)> {
82        self.filters
83            .iter()
84            .take(MAX_FILTERS)
85            .filter_map(|f| {
86                let exts: Vec<String> = f.extensions.iter().filter_map(|e| clean_extension(e)).take(64).collect();
87                (!exts.is_empty()).then(|| (clip(&f.name), exts))
88            })
89            .collect()
90    }
91
92    #[cfg(not(target_arch = "wasm32"))]
93    /// The folder to open in, only when it exists (a missing folder makes some dialogs fail).
94    fn existing_directory(&self) -> Option<&Path> {
95        self.directory.as_deref().filter(|d| d.is_dir())
96    }
97}
98
99/// Longest message text passed to the OS.
100const MAX_MESSAGE: usize = 8192;
101
102/// How serious a [`message`] is; the OS picks the icon.
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)]
104#[serde(rename_all = "camelCase")]
105pub enum MessageLevel {
106    #[default]
107    Info,
108    Warning,
109    Error,
110}
111
112/// Message text as it is given to the OS: line breaks and tabs kept, other control characters
113/// dropped, at most [`MAX_MESSAGE`] characters.
114fn clip_message(s: &str) -> String {
115    s.chars().filter(|c| !c.is_control() || *c == '\n' || *c == '\t').take(MAX_MESSAGE).collect()
116}
117
118/// Show a modal message box with one OK button and wait until it is dismissed. Main thread on
119/// macOS. `Unsupported` on the web.
120pub fn message(level: MessageLevel, title: &str, text: &str) -> Result<()> {
121    #[cfg(not(target_arch = "wasm32"))]
122    {
123        let level = match level {
124            MessageLevel::Info => rfd::MessageLevel::Info,
125            MessageLevel::Warning => rfd::MessageLevel::Warning,
126            MessageLevel::Error => rfd::MessageLevel::Error,
127        };
128        let (title, text) = (clip(title), clip_message(text));
129        crate::guarded("the message box", || {
130            crate::modal::around(|| {
131                rfd::MessageDialog::new().set_level(level).set_title(title).set_description(text).set_buttons(rfd::MessageButtons::Ok).show()
132            });
133            Ok(())
134        })
135    }
136    #[cfg(target_arch = "wasm32")]
137    {
138        let _ = (level, title, text);
139        Err(NativeError::Unsupported)
140    }
141}
142
143#[cfg(not(target_arch = "wasm32"))]
144mod native {
145    use super::*;
146
147    fn sync(d: &FileDialog) -> rfd::FileDialog {
148        let mut r = rfd::FileDialog::new();
149        if let Some(t) = &d.title {
150            r = r.set_title(clip(t));
151        }
152        if let Some(dir) = d.existing_directory() {
153            r = r.set_directory(dir);
154        }
155        if let Some(n) = &d.file_name {
156            r = r.set_file_name(clip(n));
157        }
158        for (name, exts) in d.clean_filters() {
159            r = r.add_filter(name, &exts);
160        }
161        r
162    }
163
164    fn asynch(d: &FileDialog) -> rfd::AsyncFileDialog {
165        let mut r = rfd::AsyncFileDialog::new();
166        if let Some(t) = &d.title {
167            r = r.set_title(clip(t));
168        }
169        if let Some(dir) = d.existing_directory() {
170            r = r.set_directory(dir);
171        }
172        if let Some(n) = &d.file_name {
173            r = r.set_file_name(clip(n));
174        }
175        for (name, exts) in d.clean_filters() {
176            r = r.add_filter(name, &exts);
177        }
178        r
179    }
180
181    fn picked<T>(what: &str, f: impl FnOnce() -> Option<T>) -> Result<T> {
182        crate::guarded(what, || crate::modal::around(f).ok_or(NativeError::Cancelled))
183    }
184
185    impl FileDialog {
186        /// Ask for one existing file.
187        pub fn open_file(&self) -> Result<PathBuf> {
188            picked("the open dialog", || sync(self).pick_file())
189        }
190
191        /// Ask for one or more existing files.
192        pub fn open_files(&self) -> Result<Vec<PathBuf>> {
193            picked("the open dialog", || sync(self).pick_files()).and_then(non_empty)
194        }
195
196        /// Ask for a folder.
197        pub fn pick_folder(&self) -> Result<PathBuf> {
198            picked("the folder dialog", || sync(self).pick_folder())
199        }
200
201        /// Ask where to save. The OS has already asked about replacing an existing file.
202        pub fn save_file(&self) -> Result<PathBuf> {
203            picked("the save dialog", || sync(self).save_file())
204        }
205
206        pub async fn open_file_async(&self) -> Result<PathBuf> {
207            asynch(self).pick_file().await.map(|h| h.path().to_path_buf()).ok_or(NativeError::Cancelled)
208        }
209
210        pub async fn open_files_async(&self) -> Result<Vec<PathBuf>> {
211            let files = asynch(self).pick_files().await.ok_or(NativeError::Cancelled)?;
212            non_empty(files.iter().map(|h| h.path().to_path_buf()).collect())
213        }
214
215        pub async fn pick_folder_async(&self) -> Result<PathBuf> {
216            asynch(self).pick_folder().await.map(|h| h.path().to_path_buf()).ok_or(NativeError::Cancelled)
217        }
218
219        pub async fn save_file_async(&self) -> Result<PathBuf> {
220            asynch(self).save_file().await.map(|h| h.path().to_path_buf()).ok_or(NativeError::Cancelled)
221        }
222
223        /// Ask for one file and read it: `(file name, contents)`. Works on every platform,
224        /// including the web. Files larger than `max_bytes` are refused.
225        pub async fn open_bytes_async(&self, max_bytes: u64) -> Result<(String, Vec<u8>)> {
226            let h = asynch(self).pick_file().await.ok_or(NativeError::Cancelled)?;
227            let path = h.path();
228            let len = std::fs::metadata(path).map_err(|e| NativeError::Os(crate::os_text(e)))?.len();
229            if len > max_bytes {
230                return Err(NativeError::Invalid(format!("the file is larger than {max_bytes} bytes")));
231            }
232            let bytes = std::fs::read(path).map_err(|e| NativeError::Os(crate::os_text(e)))?;
233            Ok((h.file_name(), bytes))
234        }
235    }
236
237    fn non_empty<T>(v: Vec<T>) -> Result<Vec<T>> {
238        if v.is_empty() { Err(NativeError::Cancelled) } else { Ok(v) }
239    }
240}
241
242#[cfg(target_arch = "wasm32")]
243mod web {
244    use super::*;
245
246    impl FileDialog {
247        pub fn open_file(&self) -> Result<PathBuf> {
248            Err(NativeError::Unsupported)
249        }
250        pub fn open_files(&self) -> Result<Vec<PathBuf>> {
251            Err(NativeError::Unsupported)
252        }
253        pub fn pick_folder(&self) -> Result<PathBuf> {
254            Err(NativeError::Unsupported)
255        }
256        pub fn save_file(&self) -> Result<PathBuf> {
257            Err(NativeError::Unsupported)
258        }
259        pub async fn open_file_async(&self) -> Result<PathBuf> {
260            Err(NativeError::Unsupported)
261        }
262        pub async fn open_files_async(&self) -> Result<Vec<PathBuf>> {
263            Err(NativeError::Unsupported)
264        }
265        pub async fn pick_folder_async(&self) -> Result<PathBuf> {
266            Err(NativeError::Unsupported)
267        }
268        pub async fn save_file_async(&self) -> Result<PathBuf> {
269            Err(NativeError::Unsupported)
270        }
271
272        /// Ask for one file and read it: `(file name, contents)`. Files larger than `max_bytes`
273        /// are refused (the browser has read them by then; the check bounds what the app keeps).
274        pub async fn open_bytes_async(&self, max_bytes: u64) -> Result<(String, Vec<u8>)> {
275            let mut r = rfd::AsyncFileDialog::new();
276            if let Some(t) = &self.title {
277                r = r.set_title(clip(t));
278            }
279            for (name, exts) in self.clean_filters() {
280                r = r.add_filter(name, &exts);
281            }
282            let h = r.pick_file().await.ok_or(NativeError::Cancelled)?;
283            let bytes = h.read().await;
284            if bytes.len() as u64 > max_bytes {
285                return Err(NativeError::Invalid(format!("the file is larger than {max_bytes} bytes")));
286            }
287            Ok((h.file_name(), bytes))
288        }
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    #[test]
297    fn filters_are_cleaned_and_bounded() {
298        let d = FileDialog::new()
299            .filter("Images", &["png", ".jpg", "*.webp", "", "a/b", "../x"])
300            .filter("Nothing valid", &["/", ""])
301            .filter("All", &["*"]);
302        assert_eq!(
303            d.clean_filters(),
304            vec![("Images".to_string(), vec!["png".to_string(), "jpg".into(), "webp".into()]), ("All".to_string(), vec!["*".to_string()])]
305        );
306        let many = (0..500).fold(FileDialog::new(), |d, i| d.filter(format!("f{i}"), &["txt"]));
307        assert_eq!(many.clean_filters().len(), MAX_FILTERS);
308    }
309
310    #[test]
311    fn message_text_keeps_its_lines_and_is_bounded() {
312        assert_eq!(clip_message("usage:\n\t-h\u{0}\u{1b}[31m"), "usage:\n\t-h[31m");
313        assert_eq!(clip_message(&"y".repeat(100_000)).chars().count(), MAX_MESSAGE);
314        let level: MessageLevel = serde_json::from_str("\"error\"").unwrap_or_default();
315        assert_eq!(level, MessageLevel::Error);
316    }
317
318    #[test]
319    fn hostile_text_is_clipped() {
320        let long = "x".repeat(10_000);
321        assert_eq!(clip(&long).len(), MAX_TEXT);
322        assert_eq!(clip("a\nb\u{0}c"), "abc");
323    }
324
325    #[cfg(not(target_arch = "wasm32"))]
326    #[test]
327    fn a_missing_directory_is_not_passed_on() {
328        let d = FileDialog::new().directory("/definitely/not/here/ashlar");
329        assert!(d.existing_directory().is_none());
330        let tmp = std::env::temp_dir();
331        assert_eq!(FileDialog::new().directory(&tmp).existing_directory(), Some(tmp.as_path()));
332    }
333
334    #[test]
335    fn the_description_round_trips_through_json() {
336        let d = FileDialog::new().title("Export").file_name("clip.mp4").filter("Video", &["mp4"]);
337        let v = serde_json::to_string(&d).unwrap_or_default();
338        let back: FileDialog = serde_json::from_str(&v).unwrap_or_default();
339        assert_eq!(back, d);
340    }
341}