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}