Skip to main content

dear_file_browser/
core.rs

1use std::path::{Path, PathBuf};
2use thiserror::Error;
3
4/// Errors returned when parsing IGFD-style filter strings.
5#[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/// Dialog mode
20#[derive(Clone, Copy, Debug, PartialEq, Eq)]
21pub enum DialogMode {
22    /// Pick a single file
23    OpenFile,
24    /// Pick multiple files
25    OpenFiles,
26    /// Pick a directory
27    PickFolder,
28    /// Save file
29    SaveFile,
30}
31
32/// Backend preference
33#[derive(Clone, Copy, Debug, PartialEq, Eq)]
34pub enum Backend {
35    /// Prefer native when available, fallback to ImGui
36    Auto,
37    /// Force native (rfd) backend
38    Native,
39    /// Force ImGui UI backend
40    ImGui,
41}
42
43impl Default for Backend {
44    fn default() -> Self {
45        Backend::Auto
46    }
47}
48
49/// File filter (e.g., "Images" -> ["png", "jpg"]).
50///
51/// Extensions are matched case-insensitively and should be provided without a
52/// leading dot. Multi-layer extensions are supported by including dots in the
53/// extension string (e.g. `"vcxproj.filters"`).
54///
55/// Advanced patterns (ImGui backend only):
56/// - Wildcards: tokens containing `*` or `?` are treated like IGFD's asterisk filters and matched
57///   against the full extension string (e.g. `".vcx.*"`, `".*.filters"`, `"*.*"`).
58/// - Regex: tokens wrapped in `((` ... `))` are treated as regular expressions and matched
59///   against the full base name (case-insensitive).
60///
61/// The variants created from tuples will be normalized to lowercase
62/// automatically.
63#[derive(Clone, Debug, Default)]
64pub struct FileFilter {
65    /// Filter display name
66    pub name: String,
67    /// Lower-case extensions without dot (e.g., "png", "vcxproj.filters")
68    pub extensions: Vec<String>,
69}
70
71impl FileFilter {
72    /// Create a filter from a name and extensions.
73    ///
74    /// Extensions should be provided without leading dots (e.g. `"png"`,
75    /// `"vcxproj.filters"`). Matching is case-insensitive.
76    pub fn new(name: impl Into<String>, exts: impl Into<Vec<String>>) -> Self {
77        let mut extensions: Vec<String> = exts.into();
78        // Normalize to lowercase so matching is case-insensitive even if callers
79        // provide mixed-case extensions.
80        //
81        // Note: keep regex patterns verbatim; lowercasing can change regex meaning
82        // (e.g. Unicode categories like `\\p{Lu}`).
83        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    /// Parse an ImGuiFileDialog (IGFD) style filter spec into one or more [`FileFilter`]s.
96    ///
97    /// Supported forms:
98    ///
99    /// - Simple list: `".cpp,.h,.hpp"`
100    /// - Collections: `"C/C++{.c,.cpp,.h},Rust{.rs}"`
101    ///
102    /// Notes:
103    /// - Commas inside parentheses `(...)` do not split (IGFD rule 2).
104    /// - Regex tokens `((...))` are preserved verbatim.
105    /// - Whitespace around commas/tokens is ignored.
106    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/// Selection result containing one or more paths
280#[derive(Clone, Debug, Default)]
281pub struct Selection {
282    /// Selected filesystem paths
283    pub paths: Vec<PathBuf>,
284}
285
286impl Selection {
287    /// Returns true when no path was selected.
288    pub fn is_empty(&self) -> bool {
289        self.paths.is_empty()
290    }
291
292    /// Returns the number of selected paths.
293    pub fn len(&self) -> usize {
294        self.paths.len()
295    }
296
297    /// Returns selected paths as a slice.
298    pub fn paths(&self) -> &[PathBuf] {
299        &self.paths
300    }
301
302    /// Consumes the selection and returns owned paths.
303    pub fn into_paths(self) -> Vec<PathBuf> {
304        self.paths
305    }
306
307    /// IGFD-like convenience: get the first selected full path.
308    ///
309    /// This corresponds to `GetFilePathName()` semantics for single selection.
310    /// For multi-selection, this returns the first selected path in stable order.
311    pub fn file_path_name(&self) -> Option<&Path> {
312        self.paths.first().map(PathBuf::as_path)
313    }
314
315    /// IGFD-like convenience: get the first selected base file name.
316    ///
317    /// This corresponds to `GetFileName()` semantics for single selection.
318    /// For multi-selection, this returns the first selected file name.
319    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    /// IGFD-like convenience: get all selected `(file_name, full_path)` pairs.
326    ///
327    /// This is a Rust-friendly equivalent of `GetSelection()`.
328    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/// Errors returned by file dialogs and in-UI browser
344#[derive(Error, Debug)]
345pub enum FileDialogError {
346    /// User cancelled the dialog / browser
347    #[error("cancelled")]
348    Cancelled,
349    /// I/O error
350    #[error("io error: {0}")]
351    Io(#[from] std::io::Error),
352    /// Requested operation unsupported by the chosen backend
353    #[error("unsupported operation for backend")]
354    Unsupported,
355    /// Invalid or non-existing path requested
356    #[error("invalid path: {0}")]
357    InvalidPath(String),
358    /// Platform-specific error or general failure
359    #[error("internal error: {0}")]
360    Internal(String),
361    /// Confirmation blocked by custom validation logic (e.g. custom pane).
362    #[error("validation blocked: {0}")]
363    ValidationBlocked(String),
364}
365
366/// Extension handling policy for SaveFile mode.
367#[derive(Clone, Copy, Debug, PartialEq, Eq)]
368pub enum ExtensionPolicy {
369    /// Keep user-provided extension as-is.
370    KeepUser,
371    /// If the user did not provide an extension, append the active filter's first extension.
372    AddIfMissing,
373    /// Always enforce the active filter's first extension (replace or add).
374    ReplaceByFilter,
375}
376
377/// SaveFile mode policy knobs.
378#[derive(Clone, Copy, Debug, PartialEq, Eq)]
379pub struct SavePolicy {
380    /// Whether to prompt before overwriting an existing file.
381    pub confirm_overwrite: bool,
382    /// How to apply the active filter extension to the save name.
383    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/// Click behavior for directory rows
396#[derive(Copy, Clone, Debug, PartialEq, Eq)]
397pub enum ClickAction {
398    /// Clicking a directory only selects it
399    Select,
400    /// Clicking a directory navigates into it
401    Navigate,
402}
403
404/// Layout style for the in-UI file browser
405#[derive(Copy, Clone, Debug, PartialEq, Eq)]
406pub enum LayoutStyle {
407    /// Standard layout with quick locations + file list
408    Standard,
409    /// Minimal layout with a single file list pane
410    Minimal,
411}
412
413/// Sort keys for file list
414#[derive(Copy, Clone, Debug, PartialEq, Eq)]
415pub enum SortBy {
416    /// Sort by file or directory name
417    Name,
418    /// Sort by IGFD-style "Type" (filter-aware extension).
419    ///
420    /// This mimics ImGuiFileDialog's "Type" column semantics: for multi-dot
421    /// filenames like `archive.tar.gz`, the visible/sortable "Type" depends on
422    /// the currently active filter (e.g. `.gz` vs `.tar.gz`).
423    ///
424    /// Notes:
425    /// - Directory entries have an empty type string.
426    /// - When "All files" is selected (no active filter), the dot depth
427    ///   defaults to 1 (e.g. `.gz`).
428    Type,
429    /// Sort by full extension (multi-layer aware, e.g. `.tar.gz`)
430    Extension,
431    /// Sort by file size (directories first)
432    Size,
433    /// Sort by last modified time
434    Modified,
435}
436
437/// String comparison mode used for sorting.
438#[derive(Copy, Clone, Debug, PartialEq, Eq)]
439pub enum SortMode {
440    /// Natural ordering (e.g. `file2` < `file10`).
441    Natural,
442    /// Simple lexicographic ordering on lowercased strings.
443    Lexicographic,
444}
445
446impl Default for SortMode {
447    fn default() -> Self {
448        Self::Natural
449    }
450}
451
452/// Builder for launching file dialogs
453#[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    /// Create a new builder with the given mode
467    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    /// Choose a backend (Auto by default)
481    pub fn backend(mut self, backend: Backend) -> Self {
482        self.backend = backend;
483        self
484    }
485    /// Set initial directory
486    pub fn directory(mut self, dir: impl Into<PathBuf>) -> Self {
487        self.start_dir = Some(dir.into());
488        self
489    }
490    /// Set default file name (for SaveFile)
491    pub fn default_file_name(mut self, name: impl Into<String>) -> Self {
492        self.default_name = Some(name.into());
493        self
494    }
495    /// Allow multi selection (only for OpenFiles)
496    pub fn multi_select(mut self, yes: bool) -> Self {
497        self.allow_multi = yes;
498        self
499    }
500
501    /// Limit the maximum number of selected files (IGFD `countSelectionMax`-like).
502    ///
503    /// - `0` means "infinite" (no limit).
504    /// - `1` behaves like a single-selection dialog.
505    ///
506    /// Note: native dialogs may not be able to enforce the limit interactively;
507    /// results are clamped best-effort.
508    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    /// Show hidden files in ImGui browser (native follows OS behavior)
516    pub fn show_hidden(mut self, yes: bool) -> Self {
517        self.show_hidden = yes;
518        self
519    }
520    /// Add a filter.
521    ///
522    /// Examples
523    /// ```
524    /// use dear_file_browser::{FileDialog, DialogMode};
525    /// let d = FileDialog::new(DialogMode::OpenFile)
526    ///     .filter(("Images", &["png", "jpg"][..]))
527    ///     .filter(("Rust", &["rs"][..]))
528    ///     .show_hidden(true);
529    /// ```
530    pub fn filter<F: Into<FileFilter>>(mut self, filter: F) -> Self {
531        self.filters.push(filter.into());
532        self
533    }
534    /// Add multiple filters.
535    ///
536    /// The list will be appended to any previously-added filters. Extensions
537    /// are compared case-insensitively and should be provided without dots.
538    ///
539    /// Examples
540    /// ```
541    /// use dear_file_browser::{FileDialog, DialogMode, FileFilter};
542    /// let filters = vec![
543    ///     FileFilter::from(("Images", &["png", "jpg", "jpeg"][..]))
544    /// ];
545    /// let d = FileDialog::new(DialogMode::OpenFiles)
546    ///     .filters(filters)
547    ///     .multi_select(true);
548    /// ```
549    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    /// Parse and add one or more IGFD-style filters.
559    ///
560    /// This is a convenience wrapper over [`FileFilter::parse_igfd`].
561    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    /// Resolve the effective backend
568    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// Default stubs when native feature is disabled
587#[cfg(not(feature = "native-rfd"))]
588impl FileDialog {
589    /// Open a dialog synchronously (blocking). Unsupported without `native-rfd`.
590    pub fn open_blocking(self) -> Result<Selection, FileDialogError> {
591        Err(FileDialogError::Unsupported)
592    }
593    /// Open a dialog asynchronously. Unsupported without `native-rfd`.
594    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}