tui_file_dialog/
lib.rs

1//! This is a tui-rs extension for a file dialog popup.
2//!
3//! ## Usage
4//!
5//! See the `examples` directory on how to use this extension. Run
6//!
7//! ```
8//! cargo run --example demo
9//! ```
10//!
11//! to see it in action.
12//!
13//! First, add a file dialog to the TUI app:
14//!
15//! ```rust
16//! use tui_file_dialog::FileDialog;
17//!
18//! struct App {
19//!     // Other fields of the App...
20//!
21//!     file_dialog: FileDialog
22//! }
23//! ```
24//!
25//! If you want to use the default key bindings provided by this crate, just wrap
26//! the event handler of your app in the [`bind_keys!`] macro.
27//!
28//! ```rust
29//! use tui_file_dialog::bind_keys;
30//!
31//! fn run_app<B: Backend>(terminal: &mut Terminal<B>, mut app: App) -> io::Result<()> {
32//!     loop {
33//!         terminal.draw(|f| ui(f, &mut app))?;
34//!
35//!         bind_keys!(
36//!             // Expression to use to access the file dialog.
37//!             app.file_dialog,
38//!             // Event handler of the app, when the file dialog is closed.
39//!             if let Event::Key(key) = event::read()? {
40//!                 match key.code {
41//!                     KeyCode::Char('q') => {
42//!                         return Ok(());
43//!                     }
44//!                     _ => {}
45//!                 }
46//!             }
47//!         )
48//!     }
49//! }
50//! ```
51//!
52//! Finally, draw the file dialog:
53//!
54//! ```rust
55//! fn ui<B: Backend>(f: &mut Frame<B>, app: &mut App) {
56//!     // Other UI drawing code...
57//!
58//!     app.file_dialog.draw(f);
59//! }
60//! ```
61//!
62//! ## Limitations
63//!
64//! I've started this crate with a minimalistic approach and new functionality will
65//! be added on a use-case basis. For example, it is currently not possible to add
66//! styling to the file dialog and just a boring, minimalist block with a list is
67//! used to render it.
68use std::{cmp, ffi::OsString, fs, io::Result, iter, path::PathBuf};
69use tui::{
70    backend::Backend,
71    layout::{Constraint, Direction, Layout, Rect},
72    style::{Color, Modifier, Style},
73    widgets::{Block, Borders, List, ListItem, ListState},
74    Frame,
75};
76
77/// A pattern that can be used to filter the displayed files.
78pub enum FilePattern {
79    /// Filter by file extension. This filter is case insensitive.
80    Extension(String),
81    /// Filter by substring. This filter is case sensitive.
82    Substring(String),
83}
84
85/// The file dialog.
86///
87/// This manages the state of the file dialog. After selecting a file, the absolute path to that
88/// file will be stored in the file dialog.
89///
90/// The file dialog is opened with the current working directory by default. To start the file
91/// dialog with a different directory, use [`FileDialog::set_dir`].
92pub struct FileDialog {
93    /// The file that was selected when the file dialog was open the last time.
94    ///
95    /// This will reset when re-opening the file dialog.
96    pub selected_file: Option<PathBuf>,
97
98    width: u16,
99    height: u16,
100
101    filter: Option<FilePattern>,
102    open: bool,
103    current_dir: PathBuf,
104    show_hidden: bool,
105
106    list_state: ListState,
107    items: Vec<String>,
108}
109
110impl FileDialog {
111    /// Create a new file dialog.
112    ///
113    /// The width and height are the size of the file dialog in percent of the terminal size. They
114    /// are clamped to 100%.
115    pub fn new(width: u16, height: u16) -> Result<Self> {
116        let mut s = Self {
117            width: cmp::min(width, 100),
118            height: cmp::min(height, 100),
119
120            selected_file: None,
121
122            filter: None,
123            open: false,
124            current_dir: PathBuf::from(".").canonicalize().unwrap(),
125            show_hidden: false,
126
127            list_state: ListState::default(),
128            items: vec![],
129        };
130
131        s.update_entries()?;
132
133        Ok(s)
134    }
135
136    /// The directory to open the file dialog in.
137    pub fn set_dir(&mut self, dir: PathBuf) -> Result<()> {
138        self.current_dir = dir.canonicalize()?;
139        self.update_entries()
140    }
141    /// Sets the filter to use when browsing files.
142    pub fn set_filter(&mut self, filter: FilePattern) -> Result<()> {
143        self.filter = Some(filter);
144        self.update_entries()
145    }
146    /// Removes the filter.
147    pub fn reset_filter(&mut self) -> Result<()> {
148        self.filter.take();
149        self.update_entries()
150    }
151    /// Toggles whether hidden files should be shown.
152    ///
153    /// This only checks whether the file name starts with a dot.
154    pub fn toggle_show_hidden(&mut self) -> Result<()> {
155        self.show_hidden = !self.show_hidden;
156        self.update_entries()
157    }
158
159    /// Opens the file dialog.
160    pub fn open(&mut self) {
161        self.selected_file.take();
162        self.open = true;
163    }
164    /// Closes the file dialog.
165    pub fn close(&mut self) {
166        self.open = false;
167    }
168    /// Returns whether the file dialog is currently open.
169    pub fn is_open(&self) -> bool {
170        self.open
171    }
172    /// Draws the file dialog in the TUI application.
173    pub fn draw<B: Backend>(&mut self, f: &mut Frame<B>) {
174        if self.open {
175            let block = Block::default()
176                .title(format!("{}", self.current_dir.to_string_lossy()))
177                .borders(Borders::ALL);
178            let list_items: Vec<ListItem> = self
179                .items
180                .iter()
181                .map(|s| ListItem::new(s.as_str()))
182                .collect();
183
184            let list = List::new(list_items).block(block).highlight_style(
185                Style::default()
186                    .bg(Color::LightGreen)
187                    .add_modifier(Modifier::BOLD),
188            );
189
190            let area = centered_rect(self.width, self.height, f.size());
191            f.render_stateful_widget(list, area, &mut self.list_state);
192        }
193    }
194
195    /// Goes to the next item in the file list.
196    pub fn next(&mut self) {
197        let i = match self.list_state.selected() {
198            Some(i) => cmp::min(self.items.len() - 1, i + 1),
199            None => cmp::min(self.items.len().saturating_sub(1), 1),
200        };
201        self.list_state.select(Some(i));
202    }
203    /// Goes to the previous item in the file list.
204    pub fn previous(&mut self) {
205        let i = match self.list_state.selected() {
206            Some(i) => i.saturating_sub(1),
207            None => 0,
208        };
209        self.list_state.select(Some(i));
210    }
211    /// Moves one directory up.
212    pub fn up(&mut self) -> Result<()> {
213        self.current_dir.pop();
214        self.update_entries()
215    }
216
217    /// Selects an item in the file list.
218    ///
219    /// If the item is a directory, the file dialog will move into that directory. If the item is a
220    /// file, the file dialog will close and the path to the file will be stored in
221    /// [`FileDialog::selected_file`].
222    pub fn select(&mut self) -> Result<()> {
223        let Some(selected) = self.list_state.selected() else {
224            self.next();
225            return Ok(());
226        };
227
228        let path = self.current_dir.join(&self.items[selected]);
229        if path.is_file() {
230            self.selected_file = Some(path);
231            self.close();
232            return Ok(());
233        }
234
235        self.current_dir = path;
236        self.update_entries()
237    }
238
239    /// Updates the entries in the file list. This function is called automatically when necessary.
240    fn update_entries(&mut self) -> Result<()> {
241        self.items = iter::once("..".to_string())
242            .chain(
243                fs::read_dir(&self.current_dir)?
244                    .flatten()
245                    .filter(|e| {
246                        let e = e.path();
247                        if e.file_name()
248                            .map_or(false, |n| n.to_string_lossy().starts_with('.'))
249                        {
250                            return self.show_hidden;
251                        }
252                        if e.is_dir() || self.filter.is_none() {
253                            return true;
254                        }
255                        match self.filter.as_ref().unwrap() {
256                            FilePattern::Extension(ext) => e.extension().map_or(false, |e| {
257                                e.to_ascii_lowercase() == OsString::from(ext.to_ascii_lowercase())
258                            }),
259                            FilePattern::Substring(substr) => e
260                                .file_name()
261                                .map_or(false, |n| n.to_string_lossy().contains(substr)),
262                        }
263                    })
264                    .map(|file| {
265                        let file_name = file.file_name();
266                        if matches!(file.file_type(), Ok(t) if t.is_dir()) {
267                            format!("{}/", file_name.to_string_lossy())
268                        } else {
269                            file_name.to_string_lossy().to_string()
270                        }
271                    }),
272            )
273            .collect();
274        self.items.sort_by(|a, b| {
275            if a == ".." {
276                return cmp::Ordering::Less;
277            }
278            if b == ".." {
279                return cmp::Ordering::Greater;
280            }
281            match (a.chars().last().unwrap(), b.chars().last().unwrap()) {
282                ('/', '/') => a.cmp(b),
283                ('/', _) => cmp::Ordering::Less,
284                (_, '/') => cmp::Ordering::Greater,
285                _ => a.cmp(b),
286            }
287        });
288        self.list_state.select(None);
289        self.next();
290        Ok(())
291    }
292}
293
294/// Macro to automatically overwrite the default key bindings of the app, when the file dialog is
295/// open.
296///
297/// This macro only works inside of a function that returns a [`std::io::Result`] or a result that
298/// has an error type that implements [`From<std::io::Error>`].
299///
300/// Default bindings:
301///
302/// | Key | Action |
303/// | --- | --- |
304/// | `q`, `Esc` | Close the file dialog. |
305/// | `j`, `Down` | Move down in the file list. |
306/// | `k`, `Up` | Move up in the file list. |
307/// | `Enter` | Select the current item. |
308/// | `u` | Move one directory up. |
309/// | `I` | Toggle showing hidden files. |
310///
311/// ## Example
312///
313/// ```ignore
314/// bind_keys!(
315///     // Expression to use to access the file dialog.
316///     app.file_dialog,
317///     // Event handler of the app, when the file dialog is closed.
318///     if let Event::Key(key) = event::read()? {
319///         match key.code {
320///             KeyCode::Char('q') => {
321///                 return Ok(());
322///             }
323///             _ => {}
324///         }
325///     }
326/// )
327/// ```
328#[macro_export]
329macro_rules! bind_keys {
330    ($file_dialog:expr, $e:expr) => {{
331        if $file_dialog.is_open() {
332            use ::crossterm::event::{self, Event, KeyCode};
333            // File dialog events
334            if let Event::Key(key) = event::read()? {
335                match key.code {
336                    KeyCode::Char('q') | KeyCode::Esc => {
337                        $file_dialog.close();
338                    }
339                    KeyCode::Char('I') => $file_dialog.toggle_show_hidden()?,
340                    KeyCode::Enter => {
341                        $file_dialog.select()?;
342                    }
343                    KeyCode::Char('u') => {
344                        $file_dialog.up()?;
345                    }
346                    KeyCode::Up | KeyCode::Char('k') => {
347                        $file_dialog.previous();
348                    }
349                    KeyCode::Down | KeyCode::Char('j') => {
350                        $file_dialog.next();
351                    }
352                    _ => {}
353                }
354            }
355        } else {
356            $e
357        }
358    }};
359}
360
361/// Helper function to create a centered rectangle in the TUI app.
362fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
363    let popup_layout = Layout::default()
364        .direction(Direction::Vertical)
365        .constraints(
366            [
367                Constraint::Percentage((100 - percent_y) / 2),
368                Constraint::Percentage(percent_y),
369                Constraint::Percentage((100 - percent_y) / 2),
370            ]
371            .as_ref(),
372        )
373        .split(r);
374
375    Layout::default()
376        .direction(Direction::Horizontal)
377        .constraints(
378            [
379                Constraint::Percentage((100 - percent_x) / 2),
380                Constraint::Percentage(percent_x),
381                Constraint::Percentage((100 - percent_x) / 2),
382            ]
383            .as_ref(),
384        )
385        .split(popup_layout[1])[1]
386}