1use std::path::{Path, PathBuf};
2use thiserror::Error;
3
4#[derive(Clone, Debug, Error, PartialEq, Eq)]
6#[error("invalid IGFD filter spec: {message}")]
7pub struct IgfdFilterParseError {
8 message: String,
9}
10
11impl IgfdFilterParseError {
12 fn new(message: impl Into<String>) -> Self {
13 Self {
14 message: message.into(),
15 }
16 }
17}
18
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
21pub enum DialogMode {
22 OpenFile,
24 OpenFiles,
26 PickFolder,
28 SaveFile,
30}
31
32#[derive(Clone, Copy, Debug, PartialEq, Eq)]
34pub enum Backend {
35 Auto,
37 Native,
39 ImGui,
41}
42
43impl Default for Backend {
44 fn default() -> Self {
45 Backend::Auto
46 }
47}
48
49#[derive(Clone, Debug, Default)]
64pub struct FileFilter {
65 pub name: String,
67 pub extensions: Vec<String>,
69}
70
71impl FileFilter {
72 pub fn new(name: impl Into<String>, exts: impl Into<Vec<String>>) -> Self {
77 let mut extensions: Vec<String> = exts.into();
78 for token in &mut extensions {
84 if is_regex_token(token) {
85 continue;
86 }
87 *token = token.to_lowercase();
88 }
89 Self {
90 name: name.into(),
91 extensions,
92 }
93 }
94
95 pub fn parse_igfd(spec: &str) -> Result<Vec<FileFilter>, IgfdFilterParseError> {
107 let spec = spec.trim();
108 if spec.is_empty() {
109 return Ok(Vec::new());
110 }
111
112 let parts = split_igfd_commas(spec);
113 let mut out: Vec<FileFilter> = Vec::new();
114 let mut loose_tokens: Vec<String> = Vec::new();
115
116 for part in parts {
117 let part = part.trim();
118 if part.is_empty() {
119 continue;
120 }
121
122 if let Some((label, inner)) = parse_igfd_collection(part)? {
123 if !loose_tokens.is_empty() {
124 out.push(FileFilter::new(
125 if out.is_empty() {
126 spec.to_string()
127 } else {
128 "Custom".to_string()
129 },
130 std::mem::take(&mut loose_tokens),
131 ));
132 }
133 out.push(FileFilter::new(label, inner));
134 } else {
135 loose_tokens.push(part.to_string());
136 }
137 }
138
139 if !loose_tokens.is_empty() {
140 out.push(FileFilter::new(
141 if out.is_empty() {
142 spec.to_string()
143 } else {
144 "Custom".to_string()
145 },
146 loose_tokens,
147 ));
148 }
149
150 Ok(out)
151 }
152}
153
154impl From<(&str, &[&str])> for FileFilter {
155 fn from(value: (&str, &[&str])) -> Self {
156 Self {
157 name: value.0.to_owned(),
158 extensions: value
159 .1
160 .iter()
161 .map(|s| {
162 if is_regex_token(s) {
163 (*s).to_string()
164 } else {
165 s.to_lowercase()
166 }
167 })
168 .collect(),
169 }
170 }
171}
172
173fn is_regex_token(token: &str) -> bool {
174 let t = token.trim();
175 t.starts_with("((") && t.ends_with("))") && t.len() >= 4
176}
177
178fn split_igfd_commas(input: &str) -> Vec<&str> {
179 let bytes = input.as_bytes();
180 let mut out: Vec<&str> = Vec::new();
181 let mut start = 0usize;
182 let mut brace_depth: i32 = 0;
183 let mut paren_depth: i32 = 0;
184
185 let mut i = 0usize;
186 while i < bytes.len() {
187 match bytes[i] {
188 b'{' => brace_depth += 1,
189 b'}' => brace_depth = (brace_depth - 1).max(0),
190 b'(' => paren_depth += 1,
191 b')' => paren_depth = (paren_depth - 1).max(0),
192 b',' if brace_depth == 0 && paren_depth == 0 => {
193 out.push(&input[start..i]);
194 start = i + 1;
195 }
196 _ => {}
197 }
198 i += 1;
199 }
200 out.push(&input[start..]);
201 out
202}
203
204fn parse_igfd_collection(
205 part: &str,
206) -> Result<Option<(String, Vec<String>)>, IgfdFilterParseError> {
207 let bytes = part.as_bytes();
208 let mut brace_depth: i32 = 0;
209 let mut paren_depth: i32 = 0;
210 let mut open_idx: Option<usize> = None;
211 let mut close_idx: Option<usize> = None;
212
213 let mut i = 0usize;
214 while i < bytes.len() {
215 match bytes[i] {
216 b'{' if brace_depth == 0 && paren_depth == 0 => {
217 open_idx = Some(i);
218 brace_depth = 1;
219 }
220 b'{' => brace_depth += 1,
221 b'}' => {
222 brace_depth = (brace_depth - 1).max(0);
223 if brace_depth == 0 && open_idx.is_some() {
224 close_idx = Some(i);
225 break;
226 }
227 }
228 b'(' => paren_depth += 1,
229 b')' => paren_depth = (paren_depth - 1).max(0),
230 _ => {}
231 }
232 i += 1;
233 }
234
235 let Some(open) = open_idx else {
236 return Ok(None);
237 };
238 let Some(close) = close_idx else {
239 return Err(IgfdFilterParseError::new(
240 "unterminated '{' in filter collection",
241 ));
242 };
243
244 let label = part[..open].trim();
245 if label.is_empty() {
246 return Err(IgfdFilterParseError::new(
247 "collection label is empty (expected 'Name{...}')",
248 ));
249 }
250 let tail = part[close + 1..].trim();
251 if !tail.is_empty() {
252 return Err(IgfdFilterParseError::new(
253 "unexpected trailing characters after '}'",
254 ));
255 }
256
257 let inner = part[open + 1..close].trim();
258 if inner.is_empty() {
259 return Err(IgfdFilterParseError::new(
260 "collection has no filters (empty '{...}')",
261 ));
262 }
263
264 let mut tokens: Vec<String> = Vec::new();
265 for t in split_igfd_commas(inner) {
266 let t = t.trim();
267 if t.is_empty() {
268 continue;
269 }
270 tokens.push(t.to_string());
271 }
272 if tokens.is_empty() {
273 return Err(IgfdFilterParseError::new("collection has no filters"));
274 }
275
276 Ok(Some((label.to_string(), tokens)))
277}
278
279#[derive(Clone, Debug, Default)]
281pub struct Selection {
282 pub paths: Vec<PathBuf>,
284}
285
286impl Selection {
287 pub fn is_empty(&self) -> bool {
289 self.paths.is_empty()
290 }
291
292 pub fn len(&self) -> usize {
294 self.paths.len()
295 }
296
297 pub fn paths(&self) -> &[PathBuf] {
299 &self.paths
300 }
301
302 pub fn into_paths(self) -> Vec<PathBuf> {
304 self.paths
305 }
306
307 pub fn file_path_name(&self) -> Option<&Path> {
312 self.paths.first().map(PathBuf::as_path)
313 }
314
315 pub fn file_name(&self) -> Option<&str> {
320 self.file_path_name()
321 .and_then(Path::file_name)
322 .and_then(|v| v.to_str())
323 }
324
325 pub fn selection_named_paths(&self) -> Vec<(String, PathBuf)> {
329 self.paths
330 .iter()
331 .map(|path| {
332 let name = path
333 .file_name()
334 .and_then(|v| v.to_str())
335 .map(ToOwned::to_owned)
336 .unwrap_or_else(|| path.display().to_string());
337 (name, path.clone())
338 })
339 .collect()
340 }
341}
342
343#[derive(Error, Debug)]
345pub enum FileDialogError {
346 #[error("cancelled")]
348 Cancelled,
349 #[error("io error: {0}")]
351 Io(#[from] std::io::Error),
352 #[error("unsupported operation for backend")]
354 Unsupported,
355 #[error("invalid path: {0}")]
357 InvalidPath(String),
358 #[error("internal error: {0}")]
360 Internal(String),
361 #[error("validation blocked: {0}")]
363 ValidationBlocked(String),
364}
365
366#[derive(Clone, Copy, Debug, PartialEq, Eq)]
368pub enum ExtensionPolicy {
369 KeepUser,
371 AddIfMissing,
373 ReplaceByFilter,
375}
376
377#[derive(Clone, Copy, Debug, PartialEq, Eq)]
379pub struct SavePolicy {
380 pub confirm_overwrite: bool,
382 pub extension_policy: ExtensionPolicy,
384}
385
386impl Default for SavePolicy {
387 fn default() -> Self {
388 Self {
389 confirm_overwrite: true,
390 extension_policy: ExtensionPolicy::AddIfMissing,
391 }
392 }
393}
394
395#[derive(Copy, Clone, Debug, PartialEq, Eq)]
397pub enum ClickAction {
398 Select,
400 Navigate,
402}
403
404#[derive(Copy, Clone, Debug, PartialEq, Eq)]
406pub enum LayoutStyle {
407 Standard,
409 Minimal,
411}
412
413#[derive(Copy, Clone, Debug, PartialEq, Eq)]
415pub enum SortBy {
416 Name,
418 Type,
429 Extension,
431 Size,
433 Modified,
435}
436
437#[derive(Copy, Clone, Debug, PartialEq, Eq)]
439pub enum SortMode {
440 Natural,
442 Lexicographic,
444}
445
446impl Default for SortMode {
447 fn default() -> Self {
448 Self::Natural
449 }
450}
451
452#[derive(Clone, Debug)]
454pub struct FileDialog {
455 pub(crate) backend: Backend,
456 pub(crate) mode: DialogMode,
457 pub(crate) start_dir: Option<PathBuf>,
458 pub(crate) default_name: Option<String>,
459 pub(crate) allow_multi: bool,
460 pub(crate) max_selection: Option<usize>,
461 pub(crate) filters: Vec<FileFilter>,
462 pub(crate) show_hidden: bool,
463}
464
465impl FileDialog {
466 pub fn new(mode: DialogMode) -> Self {
468 Self {
469 backend: Backend::Auto,
470 mode,
471 start_dir: None,
472 default_name: None,
473 allow_multi: matches!(mode, DialogMode::OpenFiles),
474 max_selection: None,
475 filters: Vec::new(),
476 show_hidden: false,
477 }
478 }
479
480 pub fn backend(mut self, backend: Backend) -> Self {
482 self.backend = backend;
483 self
484 }
485 pub fn directory(mut self, dir: impl Into<PathBuf>) -> Self {
487 self.start_dir = Some(dir.into());
488 self
489 }
490 pub fn default_file_name(mut self, name: impl Into<String>) -> Self {
492 self.default_name = Some(name.into());
493 self
494 }
495 pub fn multi_select(mut self, yes: bool) -> Self {
497 self.allow_multi = yes;
498 self
499 }
500
501 pub fn max_selection(mut self, max: usize) -> Self {
509 self.max_selection = if max == 0 { None } else { Some(max) };
510 if max == 1 {
511 self.allow_multi = false;
512 }
513 self
514 }
515 pub fn show_hidden(mut self, yes: bool) -> Self {
517 self.show_hidden = yes;
518 self
519 }
520 pub fn filter<F: Into<FileFilter>>(mut self, filter: F) -> Self {
531 self.filters.push(filter.into());
532 self
533 }
534 pub fn filters<I, F>(mut self, filters: I) -> Self
550 where
551 I: IntoIterator<Item = F>,
552 F: Into<FileFilter>,
553 {
554 self.filters.extend(filters.into_iter().map(Into::into));
555 self
556 }
557
558 pub fn filters_igfd(mut self, spec: impl AsRef<str>) -> Result<Self, IgfdFilterParseError> {
562 let parsed = FileFilter::parse_igfd(spec.as_ref())?;
563 self.filters.extend(parsed);
564 Ok(self)
565 }
566
567 pub(crate) fn effective_backend(&self) -> Backend {
569 match self.backend {
570 Backend::Native => Backend::Native,
571 Backend::ImGui => Backend::ImGui,
572 Backend::Auto => {
573 #[cfg(feature = "native-rfd")]
574 {
575 Backend::Native
576 }
577 #[cfg(not(feature = "native-rfd"))]
578 {
579 Backend::ImGui
580 }
581 }
582 }
583 }
584}
585
586#[cfg(not(feature = "native-rfd"))]
588impl FileDialog {
589 pub fn open_blocking(self) -> Result<Selection, FileDialogError> {
591 Err(FileDialogError::Unsupported)
592 }
593 pub async fn open_async(self) -> Result<Selection, FileDialogError> {
595 Err(FileDialogError::Unsupported)
596 }
597}
598
599#[cfg(test)]
600mod tests {
601 use super::*;
602
603 #[test]
604 fn file_filter_new_normalizes_extensions_to_lowercase_but_preserves_regex() {
605 let f = FileFilter::new(
606 "Images",
607 vec![
608 "PNG".to_string(),
609 "Jpg".to_string(),
610 "gif".to_string(),
611 "((\\p{Lu}+))".to_string(),
612 ],
613 );
614 assert_eq!(f.extensions, vec!["png", "jpg", "gif", "((\\p{Lu}+))"]);
615 }
616
617 #[test]
618 fn parse_igfd_simple_list_becomes_single_filter() {
619 let v = FileFilter::parse_igfd(".cpp,.h,.hpp").unwrap();
620 assert_eq!(v.len(), 1);
621 assert_eq!(v[0].name, ".cpp,.h,.hpp");
622 assert_eq!(v[0].extensions, vec![".cpp", ".h", ".hpp"]);
623 }
624
625 #[test]
626 fn parse_igfd_collections_build_multiple_filters() {
627 let v = FileFilter::parse_igfd("C/C++{.c,.cpp,.h},Rust{.rs}").unwrap();
628 assert_eq!(v.len(), 2);
629 assert_eq!(v[0].name, "C/C++");
630 assert_eq!(v[0].extensions, vec![".c", ".cpp", ".h"]);
631 assert_eq!(v[1].name, "Rust");
632 assert_eq!(v[1].extensions, vec![".rs"]);
633 }
634
635 #[test]
636 fn parse_igfd_does_not_split_commas_inside_parentheses() {
637 let v = FileFilter::parse_igfd("C files(png, jpg){.png,.jpg}").unwrap();
638 assert_eq!(v.len(), 1);
639 assert_eq!(v[0].name, "C files(png, jpg)");
640 }
641
642 #[test]
643 fn parse_igfd_regex_token_can_contain_commas() {
644 let v = FileFilter::parse_igfd("Rx{((a,b)),.txt}").unwrap();
645 assert_eq!(v.len(), 1);
646 assert_eq!(v[0].extensions, vec!["((a,b))", ".txt"]);
647 }
648
649 #[test]
650 fn selection_convenience_accessors_for_single_path() {
651 let sel = Selection {
652 paths: vec![PathBuf::from("/tmp/demo.txt")],
653 };
654 assert!(!sel.is_empty());
655 assert_eq!(sel.len(), 1);
656 assert_eq!(sel.file_name(), Some("demo.txt"));
657 assert_eq!(sel.file_path_name(), Some(Path::new("/tmp/demo.txt")));
658 assert_eq!(sel.paths(), &[PathBuf::from("/tmp/demo.txt")]);
659 }
660
661 #[test]
662 fn selection_named_paths_for_multi_selection() {
663 let sel = Selection {
664 paths: vec![PathBuf::from("/a/one.txt"), PathBuf::from("/b/two.bin")],
665 };
666 let pairs = sel.selection_named_paths();
667 assert_eq!(pairs.len(), 2);
668 assert_eq!(pairs[0].0, "one.txt");
669 assert_eq!(pairs[0].1, PathBuf::from("/a/one.txt"));
670 assert_eq!(pairs[1].0, "two.bin");
671 assert_eq!(pairs[1].1, PathBuf::from("/b/two.bin"));
672 }
673
674 #[test]
675 fn selection_into_paths_moves_owned_paths() {
676 let sel = Selection {
677 paths: vec![PathBuf::from("a"), PathBuf::from("b")],
678 };
679 let out = sel.into_paths();
680 assert_eq!(out, vec![PathBuf::from("a"), PathBuf::from("b")]);
681 }
682}