Skip to main content

tui_file_explorer/
lib.rs

1//! # tui-file-explorer
2//!
3//! A self-contained, keyboard-driven file-browser widget for
4//! [Ratatui](https://ratatui.rs).
5//!
6//! ## Design goals
7//!
8//! * **Layered API** — use [`FileExplorer`], [`DualPane`], or the complete
9//!   `App` behind the `full-app` feature.
10//! * **Lightweight core** — with default features disabled, only `ratatui`,
11//!   `crossterm`, and platform filesystem bindings are required.
12//! * **Host control** — semantic commands, custom filesystems, typed outcomes,
13//!   and area-aware rendering integrate with an existing event loop.
14//! * **Extension filtering** — pass a list of allowed extensions so that only
15//!   relevant files are selectable (e.g. `["iso", "img"]`); directories are
16//!   always navigable.
17//! * **Keyboard-driven** — `↑`/`↓`/`←`/`→` scroll the list, `Enter` / `l`
18//!   to descend or confirm, `Backspace` / `h` to ascend, `/` to search,
19//!   `s` to cycle sort, `n` to create a new folder, `N` to create a new file,
20//!   `Esc` / `q` to dismiss.
21//! * **Searchable** — press `/` to enter incremental search; entries are
22//!   filtered live as you type.  `Esc` clears the query; a second `Esc`
23//!   dismisses the explorer.
24//! * **Sortable** — press `s` to cycle through `Name`, `Size ↓`, and
25//!   `Extension` sort modes, or set one programmatically via
26//!   [`FileExplorer::set_sort_mode`].
27//! * **Themeable** — every colour is overridable via [`Theme`] and
28//!   [`render_themed`].
29//! * **Dual-pane** — [`DualPane`] owns two independent [`FileExplorer`]s,
30//!   manages focus switching (`Tab`), and supports a single-pane toggle (`w`).
31//!
32//! ## Quick start
33//!
34//! ```
35//! use tui_file_explorer::{FileExplorer, ExplorerOutcome, SortMode, render};
36//! use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
37//! # use ratatui::{Terminal, backend::TestBackend};
38//! # let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
39//!
40//! // 1. Create once (e.g. in your App::new).
41//! let mut explorer = FileExplorer::builder(std::env::current_dir().unwrap())
42//!     .allow_extension("iso")
43//!     .allow_extension("img")
44//!     .sort_mode(SortMode::SizeDesc)
45//!     .build();
46//!
47//! // 2. In your Terminal::draw closure:
48//! //    render(&mut explorer, frame, frame.area());
49//!
50//! // 3. In your key-handler:
51//! # let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
52//! match explorer.handle_key(key) {
53//!     ExplorerOutcome::Selected(path) => println!("chosen: {}", path.display()),
54//!     ExplorerOutcome::Dismissed      => { /* close the overlay */ }
55//!     _                               => {}
56//! }
57//! ```
58//!
59//! ## Builder / configuration
60//!
61//! Use [`FileExplorer::builder`] for a more ergonomic construction API:
62//!
63//! ```
64//! use tui_file_explorer::{FileExplorer, SortMode};
65//!
66//! let explorer = FileExplorer::builder(std::env::current_dir().unwrap())
67//!     .allow_extension("rs")
68//!     .allow_extension("toml")
69//!     .show_hidden(true)
70//!     .sort_mode(SortMode::Extension)
71//!     .build();
72//! ```
73//!
74//! ## Incremental search
75//!
76//! Press `/` to activate search mode.  Subsequent keystrokes append to the
77//! query and the entry list is filtered live (case-insensitive substring
78//! match on the file name).  `Backspace` removes the last character; an
79//! extra `Backspace` on an empty query deactivates search.  `Esc` clears
80//! the query and deactivates search without dismissing the explorer; a
81//! second `Esc` (when search is already inactive) dismisses it.
82//!
83//! Search state is also accessible programmatically:
84//!
85//! ```
86//! use tui_file_explorer::FileExplorer;
87//!
88//! let explorer = FileExplorer::new(std::env::current_dir().unwrap(), vec![]);
89//! println!("searching: {}", explorer.is_searching());
90//! println!("query    : {}", explorer.search_query());
91//! ```
92//!
93//! ## Sort modes
94//!
95//! Press `s` to cycle through the three sort modes, or set one directly:
96//!
97//! ```
98//! use tui_file_explorer::{FileExplorer, SortMode};
99//!
100//! let mut explorer = FileExplorer::new(std::env::current_dir().unwrap(), vec![]);
101//! explorer.set_sort_mode(SortMode::SizeDesc); // largest files first
102//!
103//! println!("{}", explorer.sort_mode().label()); // "size ↓"
104//! ```
105//!
106//! ## Theming
107//!
108//! Every colour is customisable via [`Theme`] and [`render_themed`]:
109//!
110//! ```
111//! use tui_file_explorer::{FileExplorer, Theme, render_themed};
112//! use ratatui::style::Color;
113//!
114//! let theme = Theme::default()
115//!     .brand(Color::Magenta)
116//!     .accent(Color::Cyan)
117//!     .dir(Color::LightYellow);
118//!
119//! // terminal.draw(|frame| {
120//! //     render_themed(&mut explorer, frame, frame.area(), &theme);
121//! // });
122//! ```
123//!
124//! ## Named presets
125//!
126//! All 90 `tui-nuances` built-in themes are available as
127//! shared themes, converted with `Theme::from`:
128//!
129//! ```
130//! use tui_file_explorer::Theme;
131//!
132//! let t = Theme::from(&tui_nuances::Theme::dracula());
133//! let t = Theme::from(&tui_nuances::Theme::nord());
134//! let t = Theme::from(&tui_nuances::Theme::catppuccin_mocha());
135//! let t = Theme::from(&tui_nuances::Theme::tokyo_night());
136//! let t = Theme::from(&tui_nuances::Theme::gruvbox());
137//! let t = Theme::from(&tui_nuances::Theme::kanagawa_wave());
138//! let t = Theme::from(&tui_nuances::Theme::oxocarbon());
139//!
140//! // Iterate the full catalogue (name, theme):
141//! for (name, _theme) in Theme::presets() {
142//!     println!("{name}");
143//! }
144//! ```
145//!
146//! The full list is `tui_nuances::Theme::names()` (also `tfe --list-themes`).
147//!
148//! ## Key bindings reference
149//!
150//! | Key | Action |
151//! |-----|--------|
152//! | `↑` / `k` | Move cursor up |
153//! | `↓` / `j` | Move cursor down |
154//! | `PgUp` / `PgDn` | Jump 10 entries |
155//! | `Home` / `g` | Jump to top |
156//! | `End` / `G` | Jump to bottom |
157//! | `→` / `l` / `Enter` | Descend into directory; on a file `→` moves cursor down, `l`/`Enter` confirm |
158//! | `←` / `h` / `Backspace` | Ascend to parent directory |
159//! | `Space` | Toggle space-mark on current entry and advance cursor |
160//! | `/` | Activate incremental search |
161//! | `s` | Cycle sort mode (`Name` → `Size ↓` → `Extension`) |
162//! | `.` | Toggle hidden (dot-file) entries |
163//! | `n` | Create a new folder (type name → `Enter` confirm, `Esc` cancel) |
164//! | `N` | Create a new file   (type name → `Enter` confirm, `Esc` cancel) |
165//! | `r` | Rename current entry (pre-filled with current name → `Enter` confirm, `Esc` cancel) |
166//! | `Esc` | Clear search / cancel mkdir / cancel touch / cancel rename (if active), then dismiss |
167//! | `q` | Dismiss (when search / mkdir / touch / rename is not active) |
168//!
169//! ## Dual-pane quick start
170//!
171//! ```
172//! use tui_file_explorer::{DualPane, DualPaneOutcome, render_dual_pane_themed, Theme};
173//! use crossterm::event::{Event, KeyCode, KeyModifiers, self};
174//! # use ratatui::{Terminal, backend::TestBackend};
175//! # let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
176//!
177//! // 1. Create once — left pane defaults to cwd; right pane mirrors it.
178//! let mut dual = DualPane::builder(std::env::current_dir().unwrap())
179//!     .right_dir(std::path::PathBuf::from("/tmp"))
180//!     .build();
181//!
182//! let theme = Theme::default();
183//!
184//! // 2. In your Terminal::draw closure:
185//! // terminal.draw(|frame| {
186//! //     render_dual_pane_themed(&mut dual, frame, frame.area(), &theme);
187//! // }).unwrap();
188//!
189//! // 3. In your event loop:
190//! # let key = crossterm::event::KeyEvent::new(KeyCode::Down, KeyModifiers::NONE);
191//! match dual.handle_key(key) {
192//!     DualPaneOutcome::Selected(path) => println!("chosen: {}", path.display()),
193//!     DualPaneOutcome::Dismissed      => { /* close the overlay */ }
194//!     _                               => {}
195//! }
196//! ```
197//!
198//! ### Extra key bindings provided by `DualPane`
199//!
200//! | Key     | Action                                    |
201//! |---------|-------------------------------------------|
202//! | `Tab`   | Switch focus between left and right pane  |
203//! | `w`     | Toggle single-pane / dual-pane mode       |
204//! | `Space` | Mark current entry (forwarded to active pane) |
205//!
206//! All standard [`FileExplorer`] bindings continue to work on whichever pane
207//! is currently active.
208//!
209//! ## Folder and file creation
210//!
211//! ### New folder — `n`
212//!
213//! Press `n` to enter mkdir mode.  Type the new folder name, then press
214//! `Enter` to create it (using the configured [`FileSystem`], so nested paths
215//! like `a/b/c` work) or `Esc` to cancel without creating anything.  On success
216//! [`ExplorerOutcome::MkdirCreated`] is returned and the cursor moves to the
217//! new directory.
218//!
219//! ### New file — `N`
220//!
221//! Press `N` (Shift+N) to enter touch mode.  Type the new file name (including
222//! extension), then press `Enter` to create it or `Esc` to cancel.  Parent
223//! directories are created automatically when the name contains `/`.  An
224//! existing file at the target path is left untouched (no truncation).  On
225//! success [`ExplorerOutcome::TouchCreated`] is returned and the cursor moves
226//! to the new file.
227//!
228//! Both modes are also accessible programmatically:
229//!
230//! ```
231//! use tui_file_explorer::FileExplorer;
232//!
233//! let explorer = FileExplorer::new(std::env::current_dir().unwrap(), vec![]);
234//! println!("mkdir active : {}", explorer.is_mkdir_active());
235//! println!("mkdir input  : {}", explorer.mkdir_input());
236//! println!("touch active : {}", explorer.is_touch_active());
237//! println!("touch input  : {}", explorer.touch_input());
238//! ```
239//!
240//! ## Module layout
241//!
242//! | Module      | Contents                                                                                                                                        |
243//! |-------------|-------------------------------------------------------------------------------------------------------------------------------------------------|
244//! | `types`     | [`FsEntry`], [`ExplorerOutcome`], [`SortMode`]                                                                                                  |
245//! | `palette`   | Palette constants (all `pub`) + [`Theme`] builder + 43 named presets                                                                           |
246//! | `explorer`  | [`FileExplorer`], [`FileExplorerBuilder`], [`entry_icon`], [`fmt_size`]                                                                         |
247//! | `filesystem` | [`FileSystem`], [`StdFileSystem`], and virtual backend support                                                                                 |
248//! | `dual_pane` | [`DualPane`], [`DualPaneBuilder`], [`DualPaneActive`], [`DualPaneOutcome`]                                                                      |
249//! | `render`    | [`render()`], [`render_themed`], [`render_dual_pane`], [`render_dual_pane_themed`] — pure rendering, no I/O                                     |
250//! | `preview`   | `PreviewState`, `PreviewContent`, `render_preview` — optional text, image, binary hex dump, and directory preview support                    |
251//! | `inline_editor` | [`InlineEditor`], [`EditorAction`], [`render_inline_editor`] — built-in text editor                                                       |
252
253/// Define a fieldless command enum together with its stable snake_case names
254/// (used by config files): `ALL`, `name()` and `from_name()`.
255macro_rules! command_enum {
256    (
257        $(#[$meta:meta])*
258        $vis:vis enum $name:ident {
259            $( $(#[$vmeta:meta])* $variant:ident => $text:literal ),+ $(,)?
260        }
261    ) => {
262        $(#[$meta])*
263        $vis enum $name {
264            $( $(#[$vmeta])* $variant ),+
265        }
266
267        impl $name {
268            /// Every variant, in declaration order.
269            pub const ALL: &'static [Self] = &[ $( Self::$variant ),+ ];
270
271            /// Stable snake_case name used in config files.
272            #[must_use]
273            pub fn name(self) -> &'static str {
274                match self { $( Self::$variant => $text ),+ }
275            }
276
277            /// Parse a [`name`](Self::name) (case-insensitive, `-` == `_`).
278            #[must_use]
279            pub fn from_name(name: &str) -> Option<Self> {
280                let wanted = name.trim().to_ascii_lowercase().replace('-', "_");
281                Self::ALL.iter().copied().find(|c| c.name() == wanted)
282            }
283        }
284    };
285}
286
287/// Saturating `usize → u16` for terminal geometry (never wraps).
288pub(crate) fn to_u16(n: usize) -> u16 {
289    u16::try_from(n).unwrap_or(u16::MAX)
290}
291
292/// Early-return from a key handler for anything but a key *press*.
293///
294/// On Windows (and terminals that negotiate the kitty keyboard protocol)
295/// crossterm delivers both `Press` and `Release` events for every physical
296/// key. Without this guard handlers run twice per key — double-toggling
297/// marks, double-navigating, and so on.
298macro_rules! press_only {
299    ($key:expr, $ret:expr) => {
300        if $key.kind != ::crossterm::event::KeyEventKind::Press {
301            return $ret;
302        }
303    };
304}
305
306#[cfg(feature = "archive")]
307pub mod archive;
308pub mod bulk_rename;
309pub mod dual_pane;
310pub mod explorer;
311pub mod filesystem;
312pub mod git;
313pub mod inline_editor;
314pub mod keymap;
315pub mod nuances;
316pub mod palette;
317pub mod picker;
318#[cfg(feature = "preview")]
319pub mod preview;
320pub mod render;
321pub mod types;
322pub mod worker;
323
324// ── Full-app modules ──────────────────────────────────────────────────────────
325
326#[cfg(feature = "full-app")]
327pub mod app;
328pub mod fs_util;
329#[cfg(feature = "persistence")]
330pub mod persistence;
331#[cfg(feature = "full-app")]
332pub mod ui;
333
334// ── Convenience re-exports ────────────────────────────────────────────────────
335
336pub use dual_pane::{DualPane, DualPaneActive, DualPaneBuilder, DualPaneCommand, DualPaneOutcome};
337pub use explorer::{
338    ExplorerError, FileExplorer, FileExplorerBuilder, PAGE_SIZE, entry_icon, fmt_size,
339};
340#[cfg(feature = "trash")]
341pub use filesystem::TrashFileSystem;
342pub use filesystem::{
343    FileSystem, SharedFileSystem, StdFileSystem, TRASH_SUPPORTED, VirtualEntry, local_filesystem,
344    std_filesystem,
345};
346pub use nuances::{
347    NuancesSwitcher, NuancesTheme, ThemeBuilder, ThemeProvider, Themed, render_dual_pane_nuanced,
348    render_nuanced,
349};
350pub use palette::Theme;
351pub use render::{
352    FOOTER_HEIGHT, HEADER_HEIGHT, MARK_INDICATOR, SCROLLBAR_CHAR, render, render_dual_pane,
353    render_dual_pane_themed, render_themed,
354};
355pub use tui_nuances;
356pub use types::{
357    DiskUsage, ExplorerCommand, ExplorerOutcome, FsEntry, Prompt, PromptKind, SelectionMode,
358    SortMode,
359};
360
361// ── Full-app re-exports ───────────────────────────────────────────────────────
362
363#[cfg(feature = "full-app")]
364pub use app::{
365    App, AppBuildError, AppBuilder, AppCommand, AppEvent, AppOptions, AppOutcome, ClipOp,
366    ClipboardItem, CopyProgress, Editor, FileOperation, KeyBinding, KeyBindings, Modal,
367    OperationApplyError, OperationFailure, OperationId, OperationMode, OperationProgress,
368    OperationRequest, OperationResult, PaneOptions, Snackbar, execute_operation,
369    execute_operation_with, execute_operation_with_progress,
370};
371pub use fs_util::{copy_dir_all, resolve_output_path};
372pub use git::{GitMark, GitStatus};
373pub use inline_editor::{EditorAction, InlineEditor, render_inline_editor};
374pub use keymap::{ChordMods, KeyChord, Keymap, KeymapError};
375#[cfg(feature = "full-app")]
376pub use keymap::{
377    KeymapConfig, default_keymap_toml, keymap_path, load_keymap_config, parse_keymap_config,
378};
379#[cfg(feature = "persistence")]
380pub use persistence::{
381    AppState, load_state, load_state_from, resolve_theme_idx, save_state, save_state_to,
382    try_load_state_from, try_save_state,
383};
384pub use picker::{Picker, PickerAction, PickerItem, PickerKind, fuzzy_match, render_picker};
385#[cfg(feature = "preview")]
386pub use preview::{PreviewContent, PreviewState, render_preview};
387#[cfg(feature = "full-app")]
388pub use ui::{
389    AppViewOptions, HintLayout, draw, draw_in, draw_in_with_options, render_action_bar,
390    render_copy_progress, render_editor_panel, render_modal, render_modal_for, render_nav_hints,
391    render_options_panel, render_snackbar, render_theme_panel,
392};