Skip to main content

easy_imgui_filechooser/
lib.rs

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