Skip to main content

dear_file_browser/
file_style.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3
4/// Kind of filesystem entry for styling.
5#[derive(Clone, Copy, Debug, PartialEq, Eq)]
6pub enum EntryKind {
7    /// Directory.
8    Dir,
9    /// Regular file.
10    File,
11    /// Symbolic link.
12    Link,
13}
14
15/// A style applied to an entry in the file list.
16#[derive(Clone, Debug, Default, PartialEq)]
17pub struct FileStyle {
18    /// Optional text color (RGBA).
19    pub text_color: Option<[f32; 4]>,
20    /// Optional icon prefix rendered before the name.
21    ///
22    /// Note: if your font does not contain emoji glyphs, prefer ASCII icons like `"[DIR]"`.
23    pub icon: Option<String>,
24    /// Optional tooltip shown when the entry is hovered.
25    pub tooltip: Option<String>,
26    /// Optional font token resolved by UI via `FileDialogUiState::file_style_fonts`.
27    pub font_token: Option<String>,
28}
29
30type FileStyleCallbackFn = dyn Fn(&str, EntryKind) -> Option<FileStyle> + Send + Sync + 'static;
31
32/// Callback handle for dynamic style resolution.
33#[derive(Clone)]
34pub struct FileStyleCallback {
35    inner: Arc<FileStyleCallbackFn>,
36}
37
38impl std::fmt::Debug for FileStyleCallback {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        f.debug_struct("FileStyleCallback").finish_non_exhaustive()
41    }
42}
43
44impl FileStyleCallback {
45    /// Create a callback handle from a closure/function.
46    pub fn new<F>(callback: F) -> Self
47    where
48        F: Fn(&str, EntryKind) -> Option<FileStyle> + Send + Sync + 'static,
49    {
50        Self {
51            inner: Arc::new(callback),
52        }
53    }
54
55    fn resolve(&self, name: &str, kind: EntryKind) -> Option<FileStyle> {
56        (self.inner)(name, kind)
57    }
58}
59
60/// Matcher for a style rule.
61#[derive(Clone, Debug, PartialEq, Eq)]
62pub enum StyleMatcher {
63    /// Match any directory.
64    AnyDir,
65    /// Match any file.
66    AnyFile,
67    /// Match any symbolic link.
68    AnyLink,
69    /// Match a file extension (case-insensitive, without leading dot).
70    Extension(String),
71    /// Match by exact base name (case-insensitive).
72    NameEquals(String),
73    /// Match by base name substring (case-insensitive).
74    NameContains(String),
75    /// Match by base name glob (`*` / `?`, case-insensitive).
76    NameGlob(String),
77    /// Match by base name regex (case-insensitive).
78    ///
79    /// IGFD-style wrappers are accepted: `((...))`.
80    /// The compiled regex is cached inside `FileStyleRegistry`.
81    NameRegex(String),
82}
83
84impl StyleMatcher {
85    fn matches(
86        &self,
87        name: &str,
88        name_lower: &str,
89        kind: EntryKind,
90        regex_cache: &mut HashMap<String, regex::Regex>,
91    ) -> bool {
92        match self {
93            Self::AnyDir => matches!(kind, EntryKind::Dir),
94            Self::AnyFile => matches!(kind, EntryKind::File),
95            Self::AnyLink => matches!(kind, EntryKind::Link),
96            Self::Extension(ext) => {
97                if matches!(kind, EntryKind::Dir) {
98                    return false;
99                }
100                has_extension_suffix(name_lower, ext.as_str())
101            }
102            Self::NameEquals(needle) => name_lower == needle.as_str(),
103            Self::NameContains(needle) => name_lower.contains(needle.as_str()),
104            Self::NameGlob(pattern) => wildcard_match(pattern.as_str(), name_lower),
105            Self::NameRegex(pattern) => {
106                let key = pattern.clone();
107                let re = match regex_cache.get(&key) {
108                    Some(v) => v,
109                    None => {
110                        let raw = strip_igfd_regex_wrapping(pattern);
111                        let built = regex::RegexBuilder::new(raw).case_insensitive(true).build();
112                        let Ok(built) = built else {
113                            return false;
114                        };
115                        regex_cache.insert(key.clone(), built);
116                        regex_cache.get(&key).expect("inserted")
117                    }
118                };
119                re.is_match(name)
120            }
121        }
122    }
123}
124
125/// A single style rule (matcher + style).
126#[derive(Clone, Debug, PartialEq)]
127pub struct StyleRule {
128    /// Matching predicate.
129    pub matcher: StyleMatcher,
130    /// Style to apply.
131    pub style: FileStyle,
132}
133
134/// Registry of file styles applied in the in-UI file browser.
135///
136/// Rules are evaluated in insertion order. The first matching rule wins.
137#[derive(Clone, Debug)]
138pub struct FileStyleRegistry {
139    /// Ordered rule list.
140    pub rules: Vec<StyleRule>,
141    /// Optional callback provider for dynamic style resolution.
142    pub callback: Option<FileStyleCallback>,
143    regex_cache: HashMap<String, regex::Regex>,
144}
145
146impl FileStyleRegistry {
147    /// Returns a small, dependency-free style preset that resembles a classic file dialog.
148    ///
149    /// This preset uses ASCII icons only (e.g. `"[DIR]"`) to avoid relying on icon fonts.
150    /// Hosts can override these rules or provide richer icons via an icon font + `font_token`.
151    pub fn igfd_ascii_preset() -> Self {
152        let mut reg = Self::default();
153
154        reg.push_dir_style(FileStyle {
155            text_color: Some([0.90, 0.80, 0.30, 1.0]),
156            icon: Some("[DIR]".into()),
157            tooltip: Some("Directory".into()),
158            font_token: None,
159        });
160
161        reg.push_link_style(FileStyle {
162            text_color: Some([0.80, 0.60, 1.00, 1.0]),
163            icon: Some("[LNK]".into()),
164            tooltip: Some("Symbolic link".into()),
165            font_token: None,
166        });
167
168        // A small set of common image extensions for thumbnail-heavy demos.
169        for ext in ["png", "jpg", "jpeg", "bmp", "gif", "webp"] {
170            reg.push_extension_style(
171                ext,
172                FileStyle {
173                    text_color: Some([0.30, 0.80, 1.00, 1.0]),
174                    icon: Some("[IMG]".into()),
175                    tooltip: Some("Image file".into()),
176                    font_token: None,
177                },
178            );
179        }
180
181        reg
182    }
183
184    /// Invalidate cached compiled regex patterns.
185    ///
186    /// This is called automatically by `push_*` methods. If you mutate `rules` directly,
187    /// call this before rendering.
188    pub fn invalidate_caches(&mut self) {
189        self.regex_cache.clear();
190    }
191
192    /// Add a rule.
193    pub fn push_rule(&mut self, matcher: StyleMatcher, style: FileStyle) {
194        let matcher = normalize_matcher(matcher);
195        self.rules.push(StyleRule { matcher, style });
196        self.invalidate_caches();
197    }
198
199    /// Convenience: style all directories.
200    pub fn push_dir_style(&mut self, style: FileStyle) {
201        self.push_rule(StyleMatcher::AnyDir, style);
202    }
203
204    /// Convenience: style all files.
205    pub fn push_file_style(&mut self, style: FileStyle) {
206        self.push_rule(StyleMatcher::AnyFile, style);
207    }
208
209    /// Convenience: style all symbolic links.
210    pub fn push_link_style(&mut self, style: FileStyle) {
211        self.push_rule(StyleMatcher::AnyLink, style);
212    }
213
214    /// Convenience: style a specific extension (case-insensitive, without leading dot).
215    pub fn push_extension_style(&mut self, ext: impl AsRef<str>, style: FileStyle) {
216        self.push_rule(StyleMatcher::Extension(ext.as_ref().to_string()), style);
217    }
218
219    /// Convenience: style a specific base name (case-insensitive).
220    pub fn push_name_style(&mut self, name: impl AsRef<str>, style: FileStyle) {
221        self.push_rule(StyleMatcher::NameEquals(name.as_ref().to_string()), style);
222    }
223
224    /// Convenience: style entries whose base name contains a substring (case-insensitive).
225    pub fn push_name_contains_style(&mut self, needle: impl AsRef<str>, style: FileStyle) {
226        self.push_rule(
227            StyleMatcher::NameContains(needle.as_ref().to_string()),
228            style,
229        );
230    }
231
232    /// Convenience: style entries whose base name matches a glob (`*` / `?`, case-insensitive).
233    pub fn push_name_glob_style(&mut self, pattern: impl AsRef<str>, style: FileStyle) {
234        self.push_rule(StyleMatcher::NameGlob(pattern.as_ref().to_string()), style);
235    }
236
237    /// Convenience: style entries whose base name matches a regex (case-insensitive).
238    ///
239    /// IGFD-style wrappers are accepted: `((...))`.
240    pub fn push_name_regex_style(&mut self, pattern: impl AsRef<str>, style: FileStyle) {
241        self.push_rule(StyleMatcher::NameRegex(pattern.as_ref().to_string()), style);
242    }
243
244    /// Set a callback provider for dynamic style resolution.
245    pub fn set_callback(&mut self, callback: FileStyleCallback) {
246        self.callback = Some(callback);
247    }
248
249    /// Clear the callback provider.
250    pub fn clear_callback(&mut self) {
251        self.callback = None;
252    }
253
254    /// Resolve a style for an entry.
255    pub fn style_for(&mut self, name: &str, kind: EntryKind) -> Option<&FileStyle> {
256        let name_lower = name.to_lowercase();
257        let rules = &self.rules;
258        let regex_cache = &mut self.regex_cache;
259        rules
260            .iter()
261            .find(|r| r.matcher.matches(name, &name_lower, kind, regex_cache))
262            .map(|r| &r.style)
263    }
264
265    /// Resolve style as an owned value, checking callback first then static rules.
266    pub fn style_for_owned(&mut self, name: &str, kind: EntryKind) -> Option<FileStyle> {
267        if let Some(cb) = &self.callback {
268            if let Some(style) = cb.resolve(name, kind) {
269                return Some(style);
270            }
271        }
272        self.style_for(name, kind).cloned()
273    }
274}
275
276impl Default for FileStyleRegistry {
277    fn default() -> Self {
278        Self {
279            rules: Vec::new(),
280            callback: None,
281            regex_cache: HashMap::new(),
282        }
283    }
284}
285
286fn normalize_matcher(m: StyleMatcher) -> StyleMatcher {
287    match m {
288        StyleMatcher::Extension(ext) => StyleMatcher::Extension(ext.to_lowercase()),
289        StyleMatcher::NameEquals(name) => StyleMatcher::NameEquals(name.to_lowercase()),
290        StyleMatcher::NameContains(needle) => StyleMatcher::NameContains(needle.to_lowercase()),
291        StyleMatcher::NameGlob(pattern) => StyleMatcher::NameGlob(pattern.to_lowercase()),
292        StyleMatcher::NameRegex(pattern) => StyleMatcher::NameRegex(pattern),
293        other => other,
294    }
295}
296
297fn strip_igfd_regex_wrapping(pattern: &str) -> &str {
298    let t = pattern.trim();
299    if t.starts_with("((") && t.ends_with("))") && t.len() >= 4 {
300        &t[2..t.len() - 2]
301    } else {
302        t
303    }
304}
305
306fn has_extension_suffix(name_lower: &str, ext: &str) -> bool {
307    let ext = ext.trim().trim_start_matches('.');
308    if ext.is_empty() {
309        return false;
310    }
311    if !name_lower.ends_with(ext) {
312        return false;
313    }
314    let prefix_len = name_lower.len() - ext.len();
315    if prefix_len == 0 {
316        return false;
317    }
318    name_lower.as_bytes()[prefix_len - 1] == b'.'
319}
320
321fn wildcard_match(pattern: &str, text: &str) -> bool {
322    let p = pattern.as_bytes();
323    let t = text.as_bytes();
324    let (mut pi, mut ti) = (0usize, 0usize);
325    let mut star_pi: Option<usize> = None;
326    let mut star_ti: usize = 0;
327
328    while ti < t.len() {
329        if pi < p.len() && (p[pi] == b'?' || p[pi] == t[ti]) {
330            pi += 1;
331            ti += 1;
332            continue;
333        }
334        if pi < p.len() && p[pi] == b'*' {
335            star_pi = Some(pi);
336            pi += 1;
337            star_ti = ti;
338            continue;
339        }
340        if let Some(sp) = star_pi {
341            pi = sp + 1;
342            star_ti += 1;
343            ti = star_ti;
344            continue;
345        }
346        return false;
347    }
348
349    while pi < p.len() && p[pi] == b'*' {
350        pi += 1;
351    }
352    pi == p.len()
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    #[test]
360    fn extension_match_is_case_insensitive() {
361        let mut reg = FileStyleRegistry::default();
362        reg.push_extension_style(
363            "PNG",
364            FileStyle {
365                text_color: Some([1.0, 0.0, 0.0, 1.0]),
366                icon: None,
367                tooltip: None,
368                font_token: None,
369            },
370        );
371        assert!(
372            reg.style_for("a.png", EntryKind::File)
373                .and_then(|s| s.text_color)
374                .is_some()
375        );
376        assert!(
377            reg.style_for("a.PNG", EntryKind::File)
378                .and_then(|s| s.text_color)
379                .is_some()
380        );
381        assert!(reg.style_for("a.png", EntryKind::Dir).is_none());
382    }
383
384    #[test]
385    fn link_matcher_targets_only_link_entries() {
386        let mut reg = FileStyleRegistry::default();
387        reg.push_link_style(FileStyle {
388            text_color: Some([0.9, 0.5, 0.1, 1.0]),
389            icon: Some("[LNK]".into()),
390            tooltip: None,
391            font_token: None,
392        });
393
394        assert!(reg.style_for("link_to_asset", EntryKind::Link).is_some());
395        assert!(reg.style_for("link_to_asset", EntryKind::File).is_none());
396        assert!(reg.style_for("link_to_asset", EntryKind::Dir).is_none());
397    }
398
399    #[test]
400    fn extension_style_applies_to_link_entries() {
401        let mut reg = FileStyleRegistry::default();
402        reg.push_extension_style(
403            "txt",
404            FileStyle {
405                text_color: Some([0.2, 0.7, 0.9, 1.0]),
406                icon: None,
407                tooltip: None,
408                font_token: None,
409            },
410        );
411
412        assert!(reg.style_for("note.txt", EntryKind::Link).is_some());
413    }
414
415    #[test]
416    fn first_match_wins() {
417        let mut reg = FileStyleRegistry::default();
418        reg.push_file_style(FileStyle {
419            text_color: Some([0.0, 1.0, 0.0, 1.0]),
420            icon: None,
421            tooltip: None,
422            font_token: None,
423        });
424        reg.push_extension_style(
425            "txt",
426            FileStyle {
427                text_color: Some([1.0, 0.0, 0.0, 1.0]),
428                icon: None,
429                tooltip: None,
430                font_token: None,
431            },
432        );
433        let s = reg.style_for("a.txt", EntryKind::File).unwrap();
434        assert_eq!(s.text_color, Some([0.0, 1.0, 0.0, 1.0]));
435    }
436
437    #[test]
438    fn name_contains_matches_case_insensitively() {
439        let mut reg = FileStyleRegistry::default();
440        reg.push_name_contains_style(
441            "read",
442            FileStyle {
443                text_color: Some([0.0, 0.0, 1.0, 1.0]),
444                icon: None,
445                tooltip: None,
446                font_token: None,
447            },
448        );
449        assert!(reg.style_for("README.md", EntryKind::File).is_some());
450        assert!(reg.style_for("readme.txt", EntryKind::File).is_some());
451        assert!(reg.style_for("notes.txt", EntryKind::File).is_none());
452    }
453
454    #[test]
455    fn name_glob_matches_case_insensitively() {
456        let mut reg = FileStyleRegistry::default();
457        reg.push_name_glob_style(
458            "imgui_*.rs",
459            FileStyle {
460                text_color: Some([0.2, 0.8, 0.2, 1.0]),
461                icon: None,
462                tooltip: None,
463                font_token: None,
464            },
465        );
466        assert!(reg.style_for("imgui_demo.rs", EntryKind::File).is_some());
467        assert!(reg.style_for("ImGui_demo.RS", EntryKind::File).is_some());
468        assert!(reg.style_for("demo_imgui.rs", EntryKind::File).is_none());
469    }
470
471    #[test]
472    fn name_regex_matches_case_insensitively() {
473        let mut reg = FileStyleRegistry::default();
474        reg.push_name_regex_style(
475            r"((^imgui_.*\.rs$))",
476            FileStyle {
477                text_color: Some([0.9, 0.6, 0.2, 1.0]),
478                icon: None,
479                tooltip: None,
480                font_token: None,
481            },
482        );
483        assert!(reg.style_for("imgui_demo.rs", EntryKind::File).is_some());
484        assert!(reg.style_for("ImGui_demo.RS", EntryKind::File).is_some());
485        assert!(reg.style_for("demo_imgui.rs", EntryKind::File).is_none());
486    }
487
488    #[test]
489    fn callback_takes_precedence_over_rules() {
490        let mut reg = FileStyleRegistry::default();
491        reg.push_file_style(FileStyle {
492            text_color: Some([0.0, 1.0, 0.0, 1.0]),
493            icon: Some("[R]".into()),
494            tooltip: None,
495            font_token: None,
496        });
497        reg.set_callback(FileStyleCallback::new(|name, kind| {
498            if matches!(kind, EntryKind::File) && name.eq_ignore_ascii_case("a.txt") {
499                Some(FileStyle {
500                    text_color: Some([1.0, 0.0, 0.0, 1.0]),
501                    icon: Some("[C]".into()),
502                    tooltip: Some("from callback".into()),
503                    font_token: Some("icon".into()),
504                })
505            } else {
506                None
507            }
508        }));
509
510        let s = reg.style_for_owned("a.txt", EntryKind::File).unwrap();
511        assert_eq!(s.icon.as_deref(), Some("[C]"));
512        assert_eq!(s.tooltip.as_deref(), Some("from callback"));
513        assert_eq!(s.font_token.as_deref(), Some("icon"));
514    }
515
516    #[test]
517    fn callback_falls_back_to_rules_when_none() {
518        let mut reg = FileStyleRegistry::default();
519        reg.push_name_style(
520            "readme.md",
521            FileStyle {
522                text_color: Some([0.1, 0.2, 0.3, 1.0]),
523                icon: Some("[DOC]".into()),
524                tooltip: None,
525                font_token: None,
526            },
527        );
528        reg.set_callback(FileStyleCallback::new(|_, _| None));
529
530        let s = reg.style_for_owned("README.md", EntryKind::File).unwrap();
531        assert_eq!(s.icon.as_deref(), Some("[DOC]"));
532    }
533}