1use std::path::{Path, PathBuf};
19
20use raw_window_handle::{HasDisplayHandle, HasWindowHandle};
21
22use crate::{NativeError, Result};
23
24const MAX_TEXT: usize = 512;
26const MAX_FILTERS: usize = 64;
28
29#[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#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
38pub struct FileDialog {
39 pub title: Option<String>,
40 pub directory: Option<PathBuf>,
42 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
51fn 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 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 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 fn existing_directory(&self) -> Option<&Path> {
100 self.directory.as_deref().filter(|d| d.is_dir())
101 }
102}
103
104#[cfg(not(target_arch = "wasm32"))]
106const MAX_MESSAGE: usize = 8192;
107
108#[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#[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
125pub 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#[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 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 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 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 pub fn open_file(&self) -> Result<PathBuf> {
236 picked("the open dialog", || sync(self).pick_file())
237 }
238
239 pub fn open_files(&self) -> Result<Vec<PathBuf>> {
241 picked("the open dialog", || sync(self).pick_files()).and_then(non_empty)
242 }
243
244 pub fn pick_folder(&self) -> Result<PathBuf> {
246 picked("the folder dialog", || sync(self).pick_folder())
247 }
248
249 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 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 pub fn open_file(&self) -> Result<PathBuf> {
288 picked("the open dialog", || sync_in(self.dialog, Some(self.parent)).pick_file())
289 }
290
291 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 pub fn pick_folder(&self) -> Result<PathBuf> {
298 picked("the folder dialog", || sync_in(self.dialog, Some(self.parent)).pick_folder())
299 }
300
301 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 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 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 let _ = native::sync_in(&d, Some(&Gone));
448 let p = d.parent(&Gone);
449 assert_eq!(p.dialog, &d);
450 }
451}