easy_imgui_filechooser/
lib.rs

1/**
2 * A FileChooser widget for easy-imgui.
3 *
4 * This widget does not create a window or a popup. It is up to you to create it in a
5 * proper place.
6 */
7use bytesize::ByteSize;
8use easy_imgui::{self as imgui, CustomRectIndex, id, lbl, lbl_id};
9pub use glob::{self, Pattern};
10use image::DynamicImage;
11use std::io::Result;
12use std::{
13    borrow::Cow,
14    ffi::{OsStr, OsString},
15    fs::DirEntry,
16    path::{Path, PathBuf},
17    time::SystemTime,
18};
19use time::macros::format_description;
20
21#[cfg(feature = "tr")]
22include!(concat!(env!("OUT_DIR"), "/locale/translators.rs"));
23
24/// Sets the language for this widget
25#[cfg(feature = "tr")]
26pub fn set_locale(locale: &str) {
27    translators::set_locale(locale);
28}
29
30/// Sets the language for this widget
31#[cfg(not(feature = "tr"))]
32pub fn set_locale(_locale: &str) {}
33
34#[cfg(feature = "tr")]
35use tr::tr;
36
37#[cfg(not(feature = "tr"))]
38macro_rules! tr {
39    ($($args:tt)*) => { format!($($args)*) };
40}
41
42/// Main widget to create a file chooser.
43///
44/// Create one of these when the widget is opened, and then call `do_ui` for each frame.
45pub struct FileChooser {
46    path: PathBuf,
47    flags: Flags,
48    entries: Vec<FileEntry>,
49    selected: Option<usize>,
50    sort_dirty: bool,
51    visible_dirty: bool,
52    scroll_dirty: bool,
53    show_hidden: bool,
54    popup_dirs: Vec<(PathBuf, bool)>,
55    search_term: String,
56    file_name: OsString,
57    // Index in `filters`. If that is empty, this is unused.
58    active_filter_idx: usize,
59    filters: Vec<Filter>,
60    read_only: bool,
61    visible_entries: Vec<usize>,
62    path_size_overflow: f32,
63}
64
65/// The output of calling `do_ui` each frame.
66pub enum Output {
67    /// The widget is still opened.
68    ///
69    /// Usually you will keep calling `do_ui` unless you have other means of closing it.
70    Continue,
71    /// The widget wants to be closed, or failed scanning the directory.
72    ///
73    /// You could ignore it, but usually there is no reason to do that.
74    Cancel,
75    /// The widget wants to accept a file.
76    ///
77    /// You can check if the selection is acceptable and decide to close or not close it
78    /// as you see appropriate.
79    Ok,
80}
81
82impl std::fmt::Debug for FileChooser {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        f.debug_struct("FileChooser")
85            .field("path", &self.path())
86            .field("file_name", &self.file_name())
87            .field("active_filter", &self.active_filter())
88            .field("read_only", &self.read_only())
89            .finish()
90    }
91}
92
93#[allow(dead_code)]
94#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
95enum FileEntryKind {
96    Parent,
97    Directory,
98    File,
99    Root, // drive letter
100}
101
102struct FileEntry {
103    name: OsString,
104    kind: FileEntryKind,
105    size: Option<u64>,
106    modified: Option<SystemTime>,
107    hidden: bool,
108}
109
110/// An identifier for the filter.
111///
112/// This is given back in `OutputOk` so you can identify the active filter,
113/// if you need it.
114/// You can use the same value for several filters, if you want.
115#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Hash)]
116pub struct FilterId(pub i32);
117
118/// An entry in the "File filter" combo box.
119#[derive(Default, Debug, Clone)]
120pub struct Filter {
121    /// The identifier of the filter.
122    ///
123    /// That of the active filter will be returned in `OutputOk`.
124    pub id: FilterId,
125    /// The text shown in the combo box.
126    pub text: String,
127    /// A list of glob patterns for this filter.
128    ///
129    /// An empty list means "any file".
130    /// Normal patterns are of the form `"*.txt"`, but you may write anything you want.
131    /// They are case-insensitive.
132    pub globs: Vec<glob::Pattern>,
133}
134
135impl Filter {
136    /// Checkes wether a file name matches this filter.
137    pub fn matches(&self, name: impl AsRef<OsStr>) -> bool {
138        let name = name.as_ref().to_string_lossy();
139        let opts = glob::MatchOptions {
140            case_sensitive: false,
141            ..Default::default()
142        };
143        // And empty globs list equals "*", ie, everything.
144        self.globs.is_empty() || self.globs.iter().any(|glob| glob.matches_with(&name, opts))
145    }
146}
147
148#[cfg(target_os = "windows")]
149mod os {
150    use std::path::PathBuf;
151
152    pub struct Roots {
153        drives: u32,
154        letter: u8,
155    }
156
157    impl Roots {
158        pub fn new() -> Roots {
159            let drives = unsafe { windows::Win32::Storage::FileSystem::GetLogicalDrives() };
160            Roots { drives, letter: 0 }
161        }
162    }
163    impl Iterator for Roots {
164        type Item = PathBuf;
165
166        fn next(&mut self) -> Option<PathBuf> {
167            while self.letter < 26 {
168                // A .. Z
169                let bit = 1 << self.letter;
170                let drive = char::from(b'A' + self.letter);
171                self.letter += 1;
172                if (self.drives & bit) != 0 {
173                    return Some(PathBuf::from(format!("{}:\\", drive)));
174                }
175            }
176            None
177        }
178    }
179}
180
181#[cfg(not(target_os = "windows"))]
182mod os {
183    use std::path::PathBuf;
184
185    pub struct Roots(());
186
187    impl Roots {
188        pub fn new() -> Roots {
189            Roots(())
190        }
191    }
192
193    impl Iterator for Roots {
194        type Item = PathBuf;
195
196        // We could return '/' here, that is a root, but it looks quite useless as a choice.
197        fn next(&mut self) -> Option<PathBuf> {
198            None
199        }
200    }
201}
202
203struct EnumSubdirs {
204    dir: std::fs::ReadDir,
205}
206
207impl EnumSubdirs {
208    fn new(path: impl Into<PathBuf>) -> Result<EnumSubdirs> {
209        let path = path.into();
210        let dir = std::fs::read_dir(path)?;
211        Ok(EnumSubdirs { dir })
212    }
213}
214
215impl Iterator for EnumSubdirs {
216    type Item = PathBuf;
217
218    fn next(&mut self) -> Option<Self::Item> {
219        loop {
220            let next = self.dir.next()?.ok()?;
221            let path = next.path();
222            let meta = path.metadata().ok()?;
223            let is_dir = meta.is_dir();
224            if !is_dir {
225                continue;
226            }
227            return Some(path);
228        }
229    }
230}
231
232bitflags::bitflags! {
233    pub struct Flags: u32 {
234        /// Shows the "Read only" check.
235        const SHOW_READ_ONLY = 1;
236    }
237}
238
239impl Default for FileChooser {
240    fn default() -> Self {
241        FileChooser::new()
242    }
243}
244
245impl FileChooser {
246    /// Creates a new default widget.
247    pub fn new() -> FileChooser {
248        FileChooser {
249            path: PathBuf::default(),
250            flags: Flags::empty(),
251            entries: Vec::new(),
252            selected: None,
253            sort_dirty: false,
254            visible_dirty: false,
255            scroll_dirty: false,
256            show_hidden: false,
257            popup_dirs: Vec::new(),
258            search_term: String::new(),
259            file_name: OsString::new(),
260            active_filter_idx: 0,
261            filters: Vec::new(),
262            read_only: false,
263            visible_entries: Vec::new(),
264            path_size_overflow: 0.0,
265        }
266    }
267    /// Adds the given option flags.
268    pub fn add_flags(&mut self, flags: Flags) {
269        self.flags |= flags;
270    }
271    /// Removes the given option flags.
272    pub fn remove_flags(&mut self, flags: Flags) {
273        self.flags &= !flags;
274    }
275    /// Changes the current visible directory.
276    ///
277    /// By default it will be the curent working directory (".").
278    pub fn set_path(&mut self, path: impl AsRef<Path>) -> Result<()> {
279        let path = std::path::absolute(path)?;
280        self.selected = None;
281        // Reuse the entries memory
282        let mut entries = std::mem::take(&mut self.entries);
283        entries.clear();
284        let mut add_entry = |entry: FileEntry| {
285            if self.file_name == entry.name {
286                self.selected = Some(entries.len());
287            }
288            entries.push(entry);
289        };
290        if path.parent().is_some() {
291            add_entry(FileEntry::dot_dot());
292        }
293        for rd in std::fs::read_dir(&path)? {
294            let Ok(rd) = rd else { continue };
295            add_entry(FileEntry::new(&rd));
296        }
297        self.path = path;
298        self.entries = entries;
299        self.sort_dirty = true;
300        self.visible_dirty = true;
301        self.scroll_dirty = true;
302        self.popup_dirs.clear();
303        self.search_term.clear();
304        self.path_size_overflow = 0.0;
305        Ok(())
306    }
307
308    /// Changes the typed file name.
309    ///
310    /// By default it is empty.
311    pub fn set_file_name(&mut self, file_name: impl AsRef<OsStr>) {
312        self.file_name = file_name.as_ref().to_owned();
313
314        if !self.file_name.is_empty() {
315            // Select the better matching filter
316            for (i_f, f) in self.filters.iter().enumerate() {
317                if f.matches(&self.file_name) {
318                    self.active_filter_idx = i_f;
319                    break;
320                }
321            }
322            // Select the better matching entry
323            for (i_entry, entry) in self.entries.iter().enumerate() {
324                if entry.name == self.file_name {
325                    self.selected = Some(i_entry);
326                    break;
327                }
328            }
329        }
330    }
331
332    /// Gets the current showing directory.
333    pub fn path(&self) -> &Path {
334        &self.path
335    }
336
337    /// Gets the current file name.
338    ///
339    /// To get the final selection it is better to use the `OutputOk` struct.
340    /// This is more useful for interactive things, such as previews.
341    pub fn file_name(&self) -> &OsStr {
342        // If the selected item maches the typed name (not counting lossy bits), then
343        // use the original entry.name, that will better represent the original OsString,
344        // and likely the user intent.
345        // This is important only if the file has non-UTF-8 sequences.
346        if let Some(i_sel) = self.selected {
347            let entry = &self.entries[i_sel];
348            if entry.kind == FileEntryKind::File && self.file_name == *entry.name.to_string_lossy()
349            {
350                return &entry.name;
351            }
352        }
353        &self.file_name
354    }
355    /// Gets the current active filter, if any.
356    ///
357    /// It is None only if no filters have been added.
358    pub fn active_filter(&self) -> Option<FilterId> {
359        if self.filters.is_empty() {
360            None
361        } else {
362            Some(self.filters[self.active_filter_idx].id)
363        }
364    }
365    /// Sets the current active filter.
366    ///
367    /// Adding filters can change the active filter, so for best results
368    /// do this after all filter have been added.
369    pub fn set_active_filter(&mut self, filter_id: FilterId) {
370        if let Some(p) = self.filters.iter().position(|f| f.id == filter_id) {
371            self.active_filter_idx = p;
372        }
373    }
374    /// Gets the status of the read-only check box.
375    /// If the SHOW_READ_ONLY flag is not specified, it will return `false`.
376    pub fn read_only(&self) -> bool {
377        self.read_only
378    }
379    /// Combine `path + file_name` and optionally an extension.
380    ///
381    /// The `default_extension` will only be used if `file_name` has no extension
382    /// of its own, and it doesn't exist in disk. This is useful if you want to set
383    /// a default extension depending on the active filter.
384    pub fn full_path(&self, default_extension: Option<&str>) -> PathBuf {
385        let file_name = self.file_name();
386        let mut res = self.path.join(file_name);
387        if let (None, Some(new_ext)) = (res.extension(), default_extension)
388            && !res.exists()
389        {
390            res.set_extension(new_ext);
391        }
392        res
393    }
394
395    #[cfg(target_os = "windows")]
396    fn set_path_super_root(&mut self) {
397        self.path = PathBuf::default();
398        self.entries = os::Roots::new()
399            .map(|path| FileEntry {
400                name: path.into(),
401                kind: FileEntryKind::Root,
402                size: None,
403                modified: None,
404                hidden: false,
405            })
406            .collect();
407        self.selected = None;
408        self.sort_dirty = true;
409        self.visible_dirty = true;
410        self.scroll_dirty = true;
411        self.popup_dirs.clear();
412        self.search_term.clear();
413    }
414
415    /// Adds a filter to the list of filters.
416    pub fn add_filter(&mut self, filter: Filter) {
417        self.filters.push(filter);
418        if !self.file_name.is_empty()
419            && !self.filters[self.active_filter_idx].matches(&self.file_name)
420        {
421            // The filter just inserted
422            if self.filters.last().unwrap().matches(&self.file_name) {
423                self.active_filter_idx = self.filters.len() - 1;
424            }
425        }
426        self.visible_dirty = true;
427    }
428    /// Draws the widget in the current frame.
429    ///
430    /// `params` is a `UiParameters` value that contains additional parameters for the UI.
431    /// The only mandatory parameter is the `CustomAtlas`. If you just want this one, you
432    /// can pass a `&CustomAtlas` directly.
433    pub fn do_ui<'a, A, Params, Preview>(&mut self, ui: &'a imgui::Ui<A>, params: Params) -> Output
434    where
435        Params: Into<UiParameters<'a, Preview>>,
436        Preview: PreviewBuilder<A>,
437    {
438        if self.entries.is_empty() {
439            let res = self.set_path(".");
440            if res.is_err() {
441                return Output::Cancel;
442            }
443        }
444
445        let UiParameters { atlas, mut preview } = params.into();
446        let mut next_path = None;
447        let mut output = Output::Continue;
448
449        let mut my_path = PathBuf::new();
450
451        let style = ui.style();
452        ui.child_config(lbl("path"))
453            //.child_flags(imgui::ChildFlags::AutoResizeY)
454            .size(imgui::Vector2::new(
455                0.0,
456                ui.get_frame_height_with_spacing()
457                    + if self.path_size_overflow < 0.0 {
458                        style.ScrollbarSize
459                    } else {
460                        0.0
461                    },
462            ))
463            .window_flags(imgui::WindowFlags::HorizontalScrollbar)
464            .with(|| {
465                #[cfg(target_os = "windows")]
466                {
467                    let scale = ui.get_font_size() / 16.0;
468                    if ui
469                        .image_button_with_custom_rect_config(id("::super"), atlas.mypc_rr, scale)
470                        .build()
471                    {
472                        self.set_path_super_root();
473                    }
474                    ui.same_line();
475                }
476
477                let mut my_disk = None;
478                'component: for component in self.path.components() {
479                    let piece = 'piece: {
480                        // This is tricky because in normal OSes the root is just '/', but
481                        // in some weird ones, there are multiple roots ('C:\', 'D:\'). We'll just ignore everything except drive letters.
482                        match component {
483                            std::path::Component::Prefix(prefix) => match prefix.kind() {
484                                std::path::Prefix::VerbatimDisk(disk)
485                                | std::path::Prefix::Disk(disk) => {
486                                    my_disk = Some(char::from(disk));
487                                    continue 'component;
488                                }
489                                _ => (),
490                            },
491                            std::path::Component::RootDir => {
492                                if let Some(my_disk) = my_disk.take() {
493                                    let drive_root = format!(
494                                        "{}:{}",
495                                        my_disk,
496                                        component.as_os_str().to_string_lossy()
497                                    );
498                                    let drive_root = OsString::from(drive_root);
499                                    break 'piece Cow::Owned(drive_root);
500                                }
501                            }
502                            _ => (),
503                        }
504                        Cow::Borrowed(component.as_os_str())
505                    };
506
507                    my_path.push(&piece);
508                    if ui
509                        .button_config(lbl_id(piece.to_string_lossy(), my_path.to_string_lossy()))
510                        .build()
511                    {
512                        next_path = Some(my_path.clone());
513                    }
514                    if ui.is_mouse_released(imgui::MouseButton::Right)
515                        && ui.is_item_hovered_ex(imgui::HoveredFlags::AllowWhenBlockedByPopup)
516                    {
517                        let mut popup_dirs = Vec::new();
518                        if let Some(parent) = my_path.parent() {
519                            if let Ok(subdir) = EnumSubdirs::new(parent) {
520                                for d in subdir {
521                                    if d.file_name().is_some() {
522                                        let sel = d == my_path;
523                                        popup_dirs.push((d, sel));
524                                    }
525                                }
526                            }
527                        } else {
528                            for root in os::Roots::new() {
529                                let sel = root == my_path;
530                                popup_dirs.push((root, sel));
531                            }
532                        }
533                        popup_dirs.sort_by(|a, b| a.0.cmp(&b.0));
534                        self.popup_dirs = popup_dirs;
535                        if !self.popup_dirs.is_empty() {
536                            ui.open_popup(id(c"popup_dirs"));
537                        }
538                    }
539                    ui.same_line();
540                }
541                // If the length of the components has changed, scroll to the end, and record the
542                // size overflow.
543                let path_size_overflow = ui.get_content_region_avail().x;
544                if path_size_overflow != self.path_size_overflow {
545                    ui.set_scroll_here_x(1.0);
546                    self.path_size_overflow = path_size_overflow;
547                }
548
549                ui.popup_config(id(c"popup_dirs")).with(|| {
550                    for (dir, sel) in &self.popup_dirs {
551                        let name = dir.file_name().unwrap_or_else(|| dir.as_os_str());
552                        if ui
553                            .selectable_config(lbl_id(
554                                name.to_string_lossy(),
555                                dir.display().to_string(),
556                            ))
557                            .selected(*sel)
558                            .build()
559                        {
560                            next_path = Some(dir.clone());
561                        }
562                    }
563                });
564            });
565
566        ui.text(&tr!("Search"));
567        ui.same_line();
568        ui.set_next_item_width(-ui.get_frame_height_with_spacing() - style.ItemSpacing.x);
569        if ui
570            .input_text_config(lbl_id(c"", c"Search"), &mut self.search_term)
571            .build()
572        {
573            self.visible_dirty = true;
574        }
575
576        ui.same_line();
577        ui.with_push(
578            self.show_hidden.then_some(((
579                imgui::ColorId::Button,
580                style.color(imgui::ColorId::ButtonActive),
581            ),)),
582            || {
583                let scale = ui.get_font_size() / 16.0;
584                if ui
585                    .image_button_with_custom_rect_config(id("hidden"), atlas.hidden_rr, scale)
586                    .build()
587                {
588                    self.show_hidden ^= true;
589                    self.visible_dirty = true;
590                }
591            },
592        );
593
594        let style = ui.style();
595        // Two rows of full controls
596        let reserve = 2.0 * ui.get_frame_height_with_spacing();
597        let preview_width = preview.width();
598        ui.table_config(lbl("FileChooser"), 4)
599            .flags(
600                imgui::TableFlags::RowBg
601                    | imgui::TableFlags::ScrollY
602                    | imgui::TableFlags::Resizable
603                    | imgui::TableFlags::Sortable
604                    | imgui::TableFlags::SizingFixedFit,
605            )
606            .outer_size(imgui::Vector2::new(-preview_width, -reserve))
607            .with(|| {
608                let pad = ui.style().FramePadding;
609                ui.table_setup_column("", imgui::TableColumnFlags::None, 0.00, 0);
610                ui.table_setup_column(
611                    tr!("Name"),
612                    imgui::TableColumnFlags::WidthStretch | imgui::TableColumnFlags::DefaultSort,
613                    0.0,
614                    0,
615                );
616                ui.table_setup_column(
617                    tr!("Size"),
618                    imgui::TableColumnFlags::WidthFixed,
619                    ui.calc_text_size("999.9 GiB").x + 2.0 * pad.x,
620                    0,
621                );
622                ui.table_setup_column(
623                    tr!("Modified"),
624                    imgui::TableColumnFlags::WidthFixed,
625                    ui.calc_text_size("2024-12-31 23:59:59").x + 2.0 * pad.x,
626                    0,
627                );
628                ui.table_setup_scroll_freeze(0, 1);
629                ui.table_headers_row();
630
631                // First we sort the entries in-place, then we filter them into `visible_entries`.
632                // We could do it the other way around, and it might be more efficient some times,
633                // but it probably doesn't matter too much in practice.
634
635                ui.table_with_sort_specs_always(|dirty, specs| {
636                    if dirty || self.sort_dirty {
637                        self.sort_dirty = false;
638                        self.resort_entries(specs);
639                    }
640                    false
641                });
642                if self.visible_dirty {
643                    self.visible_dirty = false;
644                    self.scroll_dirty = true;
645                    self.recompute_visible_entries();
646                }
647
648                let mut clipper = ui.list_clipper(self.visible_entries.len());
649                // If `scroll_dirty` we have to move the scroll to the "best" place.
650                // If there is a selected item, that is the best one, so it has to be added to the
651                // clipper, or it will be skipped.
652                if let (Some(i_sel), true) = (self.selected, self.scroll_dirty)
653                    && let Some(idx) = self.visible_entries.iter().position(|i| *i == i_sel)
654                {
655                    clipper.add_included_range(idx..idx + 1);
656                }
657                clipper.with(|i| {
658                    let i_entry = self.visible_entries[i];
659                    let entry = &self.entries[i_entry];
660
661                    ui.table_next_row(imgui::TableRowFlags::None, 0.0);
662
663                    // File type
664                    ui.table_set_column_index(0);
665                    let icon_rr = match entry.kind {
666                        FileEntryKind::Parent => Some(atlas.parent_rr),
667                        FileEntryKind::Directory => Some(atlas.folder_rr),
668                        FileEntryKind::File => Some(atlas.file_rr),
669                        FileEntryKind::Root => Some(atlas.mypc_rr),
670                    };
671                    if let Some(rr) = icon_rr {
672                        let avail = ui.get_content_region_avail();
673                        let scale = ui.get_font_size() / 16.0;
674                        let img_w = ui.get_custom_rect(rr).unwrap().rect.w as f32;
675                        ui.set_cursor_pos_x(
676                            ui.get_cursor_pos_x() + (avail.x - scale * img_w) / 2.0,
677                        );
678                        ui.image_with_custom_rect_config(rr, scale).build();
679                    }
680
681                    // File name
682                    ui.table_set_column_index(1);
683                    let is_selected = Some(i_entry) == self.selected;
684                    if ui
685                        .selectable_config(entry.name.to_string_lossy().into())
686                        .flags(
687                            imgui::SelectableFlags::SpanAllColumns
688                                | imgui::SelectableFlags::AllowOverlap
689                                | imgui::SelectableFlags::AllowDoubleClick,
690                        )
691                        .selected(is_selected)
692                        .build()
693                    {
694                        // Change the selected file
695                        self.selected = Some(i_entry);
696                        // Copy the selected name to `file_name`. Only regular files, no
697                        // directories.
698                        if entry.kind == FileEntryKind::File {
699                            self.file_name = entry.name.clone();
700                        }
701                        // If double click, confirm the widget.
702                        if ui.is_mouse_double_clicked(easy_imgui::MouseButton::Left) {
703                            match entry.kind {
704                                FileEntryKind::Parent => {
705                                    next_path = self.path.parent().map(|p| p.to_owned());
706                                }
707                                FileEntryKind::Directory | FileEntryKind::Root => {
708                                    next_path = Some(self.path.join(&entry.name));
709                                }
710                                FileEntryKind::File => {
711                                    output = Output::Ok;
712                                }
713                            }
714                        }
715                    }
716
717                    if is_selected && self.scroll_dirty {
718                        self.scroll_dirty = false;
719                        ui.set_scroll_here_y(0.5);
720                    }
721
722                    // File size
723                    ui.table_set_column_index(2);
724                    if let Some(size) = entry.size {
725                        let text = format!("{}", ByteSize(size));
726                        ui.text(&text);
727                    }
728
729                    // File modification time
730                    ui.table_set_column_index(3);
731                    if let Some(modified) = entry.modified {
732                        let tm = time::OffsetDateTime::from(modified);
733                        let s = tm
734                            .format(format_description!(
735                                "[year]-[month]-[day] [hour]:[minute]:[second]"
736                            ))
737                            .unwrap_or_default();
738                        ui.text(&s);
739                    }
740                });
741                if self.scroll_dirty {
742                    self.scroll_dirty = false;
743                    ui.set_scroll_y(0.0);
744                }
745            });
746        if preview_width > 0.0 {
747            ui.same_line();
748            ui.child_config(lbl("preview"))
749                .size(imgui::Vector2::new(0.0, -reserve))
750                .with(|| preview.do_ui(ui, self));
751        }
752
753        ui.text(&tr!("File name"));
754        ui.same_line();
755
756        if self.filters.is_empty() {
757            ui.set_next_item_width(-f32::EPSILON);
758        } else {
759            let filter_width = style.ItemSpacing.x
760                + ui.calc_text_size(&self.filters[self.active_filter_idx].text)
761                    .x
762                + ui.get_frame_height()
763                + style.ItemInnerSpacing.x;
764            // Reasonable minimum default width?
765            let filter_width = filter_width.max(ui.get_font_size() * 10.0);
766            ui.set_next_item_width(-filter_width);
767        }
768
769        if ui.is_window_appearing() {
770            ui.set_keyboard_focus_here(0);
771        }
772        ui.input_os_string_config(lbl_id(c"", c"input"), &mut self.file_name)
773            .build();
774
775        if !self.filters.is_empty() {
776            ui.same_line();
777            ui.set_next_item_width(-f32::EPSILON);
778            if ui.combo(
779                lbl_id(c"", c"Filter"),
780                0..self.filters.len(),
781                |i| &self.filters[i].text,
782                &mut self.active_filter_idx,
783            ) {
784                self.visible_dirty = true;
785            }
786        }
787
788        let font_sz = ui.get_font_size();
789        let can_ok = !self.file_name.is_empty() && !self.path.as_os_str().is_empty();
790        ui.with_disabled(!can_ok, || {
791            if ui
792                .button_config(lbl_id(tr!("OK"), "ok"))
793                .size(imgui::Vector2::new(5.5 * font_sz, 0.0))
794                .build()
795                | ui.shortcut(imgui::Key::Enter)
796                | ui.shortcut(imgui::Key::KeypadEnter)
797            {
798                output = Output::Ok;
799            }
800        });
801        ui.same_line();
802        if ui
803            .button_config(lbl_id(tr!("Cancel"), "cancel"))
804            .size(imgui::Vector2::new(5.5 * font_sz, 0.0))
805            .build()
806            | ui.shortcut(imgui::Key::Escape)
807        {
808            output = Output::Cancel;
809        }
810        if self.flags.contains(Flags::SHOW_READ_ONLY) {
811            ui.same_line();
812            let text = tr!("Read only");
813            let check_width =
814                ui.get_frame_height() + style.ItemInnerSpacing.x + ui.calc_text_size(&text).x;
815            ui.set_cursor_pos_x(
816                ui.get_cursor_pos_x() + ui.get_content_region_avail().x - check_width,
817            );
818            ui.checkbox(text.into(), &mut self.read_only);
819        }
820
821        if let Some(next_path) = next_path {
822            let _ = self.set_path(next_path);
823        }
824
825        output
826    }
827    fn resort_entries(&mut self, specs: &[easy_imgui::TableColumnSortSpec]) {
828        let sel = self.selected.map(|i| self.entries[i].name.clone());
829
830        self.entries.sort_by(|a, b| {
831            use std::cmp::Ordering;
832            // .. is always the first, no matter the sort order
833            use FileEntryKind::Parent;
834            match (a.kind, b.kind) {
835                (Parent, Parent) => return Ordering::Equal,
836                (Parent, _) => return Ordering::Less,
837                (_, Parent) => return Ordering::Greater,
838                (_, _) => (),
839            }
840            for s in specs {
841                let res = match s.index() {
842                    0 => a.kind.cmp(&b.kind),
843                    1 => a.name.cmp(&b.name),
844                    2 => a.size.cmp(&b.size),
845                    3 => a.modified.cmp(&b.modified),
846                    _ => continue,
847                };
848                let res = match s.sort_direction() {
849                    easy_imgui::SortDirection::Ascending => res,
850                    easy_imgui::SortDirection::Descending => res.reverse(),
851                    _ => continue,
852                };
853                if res.is_ne() {
854                    return res;
855                }
856            }
857            Ordering::Equal
858        });
859        // Restore the selected element
860        self.selected = sel.and_then(|n| self.entries.iter().position(|e| e.name == n));
861        // After a sort, recompute the filter because it stores the indices
862        self.visible_dirty = true;
863    }
864    fn recompute_visible_entries(&mut self) {
865        let search_term = self.search_term.to_lowercase();
866        self.visible_entries.clear();
867        self.visible_entries
868            .extend(
869                self.entries
870                    .iter()
871                    .enumerate()
872                    .filter_map(|(i_entry, entry)| {
873                        if !self.show_hidden && entry.hidden {
874                            return None;
875                        }
876                        // Search term applies to both files and directories
877                        if matches!(entry.kind, FileEntryKind::File | FileEntryKind::Directory)
878                            && !search_term.is_empty()
879                            && !entry
880                                .name
881                                .to_string_lossy()
882                                .to_lowercase()
883                                .contains(&search_term)
884                        {
885                            return None;
886                        }
887                        // Filters only apply to regular files
888                        if entry.kind == FileEntryKind::File && !self.filters.is_empty() {
889                            let f = &self.filters[self.active_filter_idx];
890                            if !f.matches(&entry.name) {
891                                return None;
892                            }
893                        }
894                        Some(i_entry)
895                    }),
896            );
897    }
898}
899
900/// Extra arguments for the `FileChooser::do_ui()` function.
901pub struct UiParameters<'a, Preview> {
902    atlas: &'a CustomAtlas,
903    preview: Preview,
904}
905
906/// A trait to build the "preview" section of the UI.
907pub trait PreviewBuilder<A> {
908    /// The width reserved for the preview. Return 0.0 for no preview.
909    fn width(&self) -> f32;
910    /// Builds the UI for the preview.
911    fn do_ui(&mut self, ui: &imgui::Ui<A>, chooser: &FileChooser);
912}
913
914/// A dummy implementation for `PreviewBuilder` that does nothing.
915pub struct NoPreview;
916
917impl<A> PreviewBuilder<A> for NoPreview {
918    fn width(&self) -> f32 {
919        0.0
920    }
921    fn do_ui(&mut self, _ui: &easy_imgui::Ui<A>, _chooser: &FileChooser) {}
922}
923
924impl<'a> UiParameters<'a, NoPreview> {
925    /// Builds a `UiParameters` without preview.
926    pub fn new(atlas: &'a CustomAtlas) -> Self {
927        UiParameters {
928            atlas,
929            preview: NoPreview,
930        }
931    }
932    /// Adds a preview object to this `UiParameters`.
933    pub fn with_preview<A, P: PreviewBuilder<A>>(self, preview: P) -> UiParameters<'a, P> {
934        UiParameters {
935            atlas: self.atlas,
936            preview,
937        }
938    }
939}
940
941/// Converts a `&CustomAtlas` into a UiParameters, with all other values to their default.
942impl<'a> From<&'a CustomAtlas> for UiParameters<'a, NoPreview> {
943    fn from(value: &'a CustomAtlas) -> Self {
944        UiParameters::new(value)
945    }
946}
947
948impl FileEntry {
949    fn new(rd: &DirEntry) -> FileEntry {
950        let name = rd.file_name();
951        let (kind, size, modified, hidden);
952        match rd.path().metadata() {
953            Ok(meta) => {
954                if meta.is_dir() {
955                    kind = FileEntryKind::Directory;
956                    size = None;
957                } else {
958                    kind = FileEntryKind::File;
959                    size = Some(meta.len());
960                }
961                modified = meta.modified().ok();
962                #[cfg(target_os = "windows")]
963                {
964                    use std::os::windows::fs::MetadataExt;
965                    hidden = (meta.file_attributes() & 2) != 0; // FILE_ATTRIBUTE_HIDDEN
966                }
967            }
968            Err(_) => {
969                // Unknown kind, assume a file
970                kind = FileEntryKind::File;
971                size = None;
972                modified = None;
973                #[cfg(target_os = "windows")]
974                {
975                    hidden = false;
976                }
977            }
978        }
979
980        #[cfg(not(target_os = "windows"))]
981        {
982            hidden = if matches!(kind, FileEntryKind::File | FileEntryKind::Directory) {
983                name.as_encoded_bytes().first() == Some(&b'.')
984            } else {
985                false
986            };
987        }
988        FileEntry {
989            name,
990            kind,
991            size,
992            modified,
993            hidden,
994        }
995    }
996
997    fn dot_dot() -> FileEntry {
998        FileEntry {
999            name: "..".into(),
1000            kind: FileEntryKind::Parent,
1001            size: None,
1002            modified: None,
1003            hidden: false,
1004        }
1005    }
1006}
1007
1008macro_rules! image {
1009    ($name:ident = $file:literal) => {
1010        fn $name() -> &'static image::DynamicImage {
1011            static BYTES: &[u8] = include_bytes!($file);
1012            static IMG: std::sync::OnceLock<image::DynamicImage> = std::sync::OnceLock::new();
1013            IMG.get_or_init(|| {
1014                image::load_from_memory_with_format(BYTES, image::ImageFormat::Png).unwrap()
1015            })
1016        }
1017    };
1018}
1019
1020image! {file_img = "file.png"}
1021image! {folder_img = "folder.png"}
1022image! {parent_img = "parent.png"}
1023image! {hidden_img = "hidden.png"}
1024#[cfg(target_os = "windows")]
1025image! {mypc_img = "mypc.png"}
1026
1027/// Custom atlas for the FileChooser widget.
1028///
1029/// In order to get proper icons, you should build one of these when rebuilding your easy-imgui
1030/// atlas.
1031#[derive(Default, Copy, Clone)]
1032pub struct CustomAtlas {
1033    file_rr: CustomRectIndex,
1034    folder_rr: CustomRectIndex,
1035    parent_rr: CustomRectIndex,
1036    hidden_rr: CustomRectIndex,
1037    mypc_rr: CustomRectIndex,
1038}
1039
1040/// Rebuild the custom atlas.
1041///
1042/// Call this on your initialization code and keep the output. You will need it to call
1043/// `do_ui`.
1044pub fn build_custom_atlas(atlas: &mut easy_imgui::FontAtlas) -> CustomAtlas {
1045    use image::GenericImage;
1046
1047    let mut do_rr = |img: &'static DynamicImage| {
1048        atlas.add_custom_rect([img.width(), img.height()], {
1049            |pixels| pixels.copy_from(img, 0, 0).unwrap()
1050        })
1051    };
1052    let file_rr = do_rr(file_img());
1053    let folder_rr = do_rr(folder_img());
1054    let parent_rr = do_rr(parent_img());
1055    let hidden_rr = do_rr(hidden_img());
1056
1057    // This icon is only used on Windows
1058    #[cfg(target_os = "windows")]
1059    let mypc_rr = do_rr(mypc_img());
1060    #[cfg(not(target_os = "windows"))]
1061    let mypc_rr = Default::default();
1062
1063    CustomAtlas {
1064        file_rr,
1065        folder_rr,
1066        parent_rr,
1067        hidden_rr,
1068        mypc_rr,
1069    }
1070}