1use std::path::{Path, PathBuf};
16
17use crate::{NativeError, Result};
18
19const MAX_TEXT: usize = 512;
21const MAX_FILTERS: usize = 64;
23
24#[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#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
33pub struct FileDialog {
34 pub title: Option<String>,
35 pub directory: Option<PathBuf>,
37 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
46fn 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 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 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 fn existing_directory(&self) -> Option<&Path> {
95 self.directory.as_deref().filter(|d| d.is_dir())
96 }
97}
98
99const MAX_MESSAGE: usize = 8192;
101
102#[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
112fn clip_message(s: &str) -> String {
115 s.chars().filter(|c| !c.is_control() || *c == '\n' || *c == '\t').take(MAX_MESSAGE).collect()
116}
117
118pub 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 pub fn open_file(&self) -> Result<PathBuf> {
188 picked("the open dialog", || sync(self).pick_file())
189 }
190
191 pub fn open_files(&self) -> Result<Vec<PathBuf>> {
193 picked("the open dialog", || sync(self).pick_files()).and_then(non_empty)
194 }
195
196 pub fn pick_folder(&self) -> Result<PathBuf> {
198 picked("the folder dialog", || sync(self).pick_folder())
199 }
200
201 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 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 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}