dear_file_browser/
core.rs

1use std::path::PathBuf;
2use thiserror::Error;
3
4/// Dialog mode
5#[derive(Clone, Copy, Debug, PartialEq, Eq)]
6pub enum DialogMode {
7    /// Pick a single file
8    OpenFile,
9    /// Pick multiple files
10    OpenFiles,
11    /// Pick a directory
12    PickFolder,
13    /// Save file
14    SaveFile,
15}
16
17/// Backend preference
18#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19pub enum Backend {
20    /// Prefer native when available, fallback to ImGui
21    Auto,
22    /// Force native (rfd) backend
23    Native,
24    /// Force ImGui UI backend
25    ImGui,
26}
27
28impl Default for Backend {
29    fn default() -> Self {
30        Backend::Auto
31    }
32}
33
34/// File filter (e.g., "Images" -> ["png", "jpg"]).
35///
36/// Extensions are matched case-insensitively and should be provided without a
37/// leading dot. The variants created from tuples will be normalized to
38/// lowercase automatically.
39#[derive(Clone, Debug, Default)]
40pub struct FileFilter {
41    /// Filter display name
42    pub name: String,
43    /// Lower-case extensions without dot (e.g., "png")
44    pub extensions: Vec<String>,
45}
46
47impl FileFilter {
48    /// Create a filter from a name and extensions.
49    ///
50    /// Extensions should be provided without dots (e.g. "png"). Matching is
51    /// case-insensitive.
52    pub fn new(name: impl Into<String>, exts: impl Into<Vec<String>>) -> Self {
53        let mut extensions: Vec<String> = exts.into();
54        // Normalize to lowercase so matching is case-insensitive even if callers
55        // provide mixed-case extensions.
56        for ext in &mut extensions {
57            *ext = ext.to_lowercase();
58        }
59        Self {
60            name: name.into(),
61            extensions,
62        }
63    }
64}
65
66impl From<(&str, &[&str])> for FileFilter {
67    fn from(value: (&str, &[&str])) -> Self {
68        Self {
69            name: value.0.to_owned(),
70            extensions: value.1.iter().map(|s| s.to_lowercase()).collect(),
71        }
72    }
73}
74
75/// Selection result containing one or more paths
76#[derive(Clone, Debug, Default)]
77pub struct Selection {
78    /// Selected filesystem paths
79    pub paths: Vec<PathBuf>,
80}
81
82/// Errors returned by file dialogs and in-UI browser
83#[derive(Error, Debug)]
84pub enum FileDialogError {
85    /// User cancelled the dialog / browser
86    #[error("cancelled")]
87    Cancelled,
88    /// I/O error
89    #[error("io error: {0}")]
90    Io(#[from] std::io::Error),
91    /// Requested operation unsupported by the chosen backend
92    #[error("unsupported operation for backend")]
93    Unsupported,
94    /// Invalid or non-existing path requested
95    #[error("invalid path: {0}")]
96    InvalidPath(String),
97    /// Platform-specific error or general failure
98    #[error("internal error: {0}")]
99    Internal(String),
100}
101
102/// Click behavior for directory rows
103#[derive(Copy, Clone, Debug, PartialEq, Eq)]
104pub enum ClickAction {
105    /// Clicking a directory only selects it
106    Select,
107    /// Clicking a directory navigates into it
108    Navigate,
109}
110
111/// Layout style for the in-UI file browser
112#[derive(Copy, Clone, Debug, PartialEq, Eq)]
113pub enum LayoutStyle {
114    /// Standard layout with quick locations + file list
115    Standard,
116    /// Minimal layout with a single file list pane
117    Minimal,
118}
119
120/// Sort keys for file list
121#[derive(Copy, Clone, Debug, PartialEq, Eq)]
122pub enum SortBy {
123    /// Sort by file or directory name
124    Name,
125    /// Sort by file size (directories first)
126    Size,
127    /// Sort by last modified time
128    Modified,
129}
130
131/// Builder for launching file dialogs
132#[derive(Clone, Debug)]
133pub struct FileDialog {
134    pub(crate) backend: Backend,
135    pub(crate) mode: DialogMode,
136    pub(crate) start_dir: Option<PathBuf>,
137    pub(crate) default_name: Option<String>,
138    pub(crate) allow_multi: bool,
139    pub(crate) filters: Vec<FileFilter>,
140    pub(crate) show_hidden: bool,
141}
142
143impl FileDialog {
144    /// Create a new builder with the given mode
145    pub fn new(mode: DialogMode) -> Self {
146        Self {
147            backend: Backend::Auto,
148            mode,
149            start_dir: None,
150            default_name: None,
151            allow_multi: matches!(mode, DialogMode::OpenFiles),
152            filters: Vec::new(),
153            show_hidden: false,
154        }
155    }
156
157    /// Choose a backend (Auto by default)
158    pub fn backend(mut self, backend: Backend) -> Self {
159        self.backend = backend;
160        self
161    }
162    /// Set initial directory
163    pub fn directory(mut self, dir: impl Into<PathBuf>) -> Self {
164        self.start_dir = Some(dir.into());
165        self
166    }
167    /// Set default file name (for SaveFile)
168    pub fn default_file_name(mut self, name: impl Into<String>) -> Self {
169        self.default_name = Some(name.into());
170        self
171    }
172    /// Allow multi selection (only for OpenFiles)
173    pub fn multi_select(mut self, yes: bool) -> Self {
174        self.allow_multi = yes;
175        self
176    }
177    /// Show hidden files in ImGui browser (native follows OS behavior)
178    pub fn show_hidden(mut self, yes: bool) -> Self {
179        self.show_hidden = yes;
180        self
181    }
182    /// Add a filter.
183    ///
184    /// Examples
185    /// ```
186    /// use dear_file_browser::{FileDialog, DialogMode};
187    /// let d = FileDialog::new(DialogMode::OpenFile)
188    ///     .filter(("Images", &["png", "jpg"][..]))
189    ///     .filter(("Rust", &["rs"][..]))
190    ///     .show_hidden(true);
191    /// ```
192    pub fn filter<F: Into<FileFilter>>(mut self, filter: F) -> Self {
193        self.filters.push(filter.into());
194        self
195    }
196    /// Add multiple filters.
197    ///
198    /// The list will be appended to any previously-added filters. Extensions
199    /// are compared case-insensitively and should be provided without dots.
200    ///
201    /// Examples
202    /// ```
203    /// use dear_file_browser::{FileDialog, DialogMode, FileFilter};
204    /// let filters = vec![
205    ///     FileFilter::from(("Images", &["png", "jpg", "jpeg"][..]))
206    /// ];
207    /// let d = FileDialog::new(DialogMode::OpenFiles)
208    ///     .filters(filters)
209    ///     .multi_select(true);
210    /// ```
211    pub fn filters<I, F>(mut self, filters: I) -> Self
212    where
213        I: IntoIterator<Item = F>,
214        F: Into<FileFilter>,
215    {
216        self.filters.extend(filters.into_iter().map(Into::into));
217        self
218    }
219
220    /// Resolve the effective backend
221    pub(crate) fn effective_backend(&self) -> Backend {
222        match self.backend {
223            Backend::Native => Backend::Native,
224            Backend::ImGui => Backend::ImGui,
225            Backend::Auto => {
226                #[cfg(feature = "native-rfd")]
227                {
228                    return Backend::Native;
229                }
230                #[cfg(not(feature = "native-rfd"))]
231                {
232                    return Backend::ImGui;
233                }
234            }
235        }
236    }
237}
238
239// Default stubs when native feature is disabled
240#[cfg(not(feature = "native-rfd"))]
241impl FileDialog {
242    /// Open a dialog synchronously (blocking). Unsupported without `native-rfd`.
243    pub fn open_blocking(self) -> Result<Selection, FileDialogError> {
244        Err(FileDialogError::Unsupported)
245    }
246    /// Open a dialog asynchronously. Unsupported without `native-rfd`.
247    pub async fn open_async(self) -> Result<Selection, FileDialogError> {
248        Err(FileDialogError::Unsupported)
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    #[test]
257    fn file_filter_new_normalizes_extensions_to_lowercase() {
258        let f = FileFilter::new(
259            "Images",
260            vec!["PNG".to_string(), "Jpg".to_string(), "gif".to_string()],
261        );
262        assert_eq!(f.extensions, vec!["png", "jpg", "gif"]);
263    }
264}