1use std::collections::HashMap;
2use std::sync::Arc;
3
4#[derive(Clone, Copy, Debug, PartialEq, Eq)]
6pub enum EntryKind {
7 Dir,
9 File,
11 Link,
13}
14
15#[derive(Clone, Debug, Default, PartialEq)]
17pub struct FileStyle {
18 pub text_color: Option<[f32; 4]>,
20 pub icon: Option<String>,
24 pub tooltip: Option<String>,
26 pub font_token: Option<String>,
28}
29
30type FileStyleCallbackFn = dyn Fn(&str, EntryKind) -> Option<FileStyle> + Send + Sync + 'static;
31
32#[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 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#[derive(Clone, Debug, PartialEq, Eq)]
62pub enum StyleMatcher {
63 AnyDir,
65 AnyFile,
67 AnyLink,
69 Extension(String),
71 NameEquals(String),
73 NameContains(String),
75 NameGlob(String),
77 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#[derive(Clone, Debug, PartialEq)]
127pub struct StyleRule {
128 pub matcher: StyleMatcher,
130 pub style: FileStyle,
132}
133
134#[derive(Clone, Debug)]
138pub struct FileStyleRegistry {
139 pub rules: Vec<StyleRule>,
141 pub callback: Option<FileStyleCallback>,
143 regex_cache: HashMap<String, regex::Regex>,
144}
145
146impl FileStyleRegistry {
147 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 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 pub fn invalidate_caches(&mut self) {
189 self.regex_cache.clear();
190 }
191
192 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 pub fn push_dir_style(&mut self, style: FileStyle) {
201 self.push_rule(StyleMatcher::AnyDir, style);
202 }
203
204 pub fn push_file_style(&mut self, style: FileStyle) {
206 self.push_rule(StyleMatcher::AnyFile, style);
207 }
208
209 pub fn push_link_style(&mut self, style: FileStyle) {
211 self.push_rule(StyleMatcher::AnyLink, style);
212 }
213
214 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 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 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 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 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 pub fn set_callback(&mut self, callback: FileStyleCallback) {
246 self.callback = Some(callback);
247 }
248
249 pub fn clear_callback(&mut self) {
251 self.callback = None;
252 }
253
254 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 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}