Skip to main content

tui_file_explorer/app/
api.rs

1use std::{fmt, io, path::PathBuf, time::Instant};
2
3use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
4
5use super::{App, AppOptions, Editor, OperationRequest};
6use crate::{AppState, FileExplorer, SelectionMode, SortMode, Theme};
7
8/// Result of dispatching input through the full application API.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum AppOutcome {
11    /// Non-key or key-release event; no application action was requested.
12    Ignored,
13    /// Input was processed without a separately classified state transition.
14    Continue,
15    /// One or more observable application properties changed.
16    Changed(Vec<AppEvent>),
17    /// User selected a path and requested exit.
18    Selected(PathBuf),
19    /// User dismissed the application.
20    Dismissed,
21    /// Host should suspend rendering and open `path` with `editor`.
22    OpenEditor {
23        /// The file to open.
24        path: PathBuf,
25        /// The editor to open it with.
26        editor: Editor,
27    },
28    /// Host should execute a filesystem operation outside the UI thread.
29    OperationRequested(OperationRequest),
30}
31
32/// Observable state transition emitted by host-driven dispatch.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum AppEvent {
35    /// A pane was opened.
36    PaneAdded {
37        /// Number of panes after the change.
38        pane_count: usize,
39    },
40    /// A pane was closed.
41    PaneClosed {
42        /// Number of panes after the change.
43        pane_count: usize,
44    },
45    /// Keyboard focus moved to another pane.
46    ActivePaneChanged {
47        /// Index of the newly focused pane.
48        index: usize,
49    },
50    /// A pane navigated to another directory.
51    DirectoryChanged {
52        /// Index of the pane that navigated.
53        pane: usize,
54        /// The pane's new current directory.
55        path: PathBuf,
56    },
57    /// The active theme changed.
58    ThemeChanged {
59        /// Index of the new theme in the theme catalogue.
60        index: usize,
61    },
62    /// Single-pane mode was turned on or off.
63    LayoutChanged {
64        /// `true` when only the active pane is shown.
65        single_pane: bool,
66    },
67    /// The preview panel was shown or hidden.
68    PreviewChanged {
69        /// `true` when the preview panel is shown.
70        visible: bool,
71    },
72    /// A running file operation made progress.
73    OperationProgress {
74        /// The operation this update belongs to.
75        id: super::OperationId,
76        /// Items processed so far.
77        completed: usize,
78        /// Total number of items in the operation.
79        total: usize,
80    },
81    /// A file operation finished.
82    OperationCompleted {
83        /// The operation that finished.
84        id: super::OperationId,
85        /// Items processed without error.
86        succeeded: usize,
87        /// Items that failed.
88        failed: usize,
89    },
90    /// A file operation was cancelled before it finished.
91    OperationCancelled {
92        /// The operation that was cancelled.
93        id: super::OperationId,
94    },
95}
96
97command_enum! {
98    /// Semantic command accepted by [`App::dispatch_command`].
99    #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
100    pub enum AppCommand {
101        /// Move the cursor up one row.
102        MoveUp => "move_up",
103        /// Move the cursor down one row.
104        MoveDown => "move_down",
105        /// Move the cursor up one page.
106        PageUp => "page_up",
107        /// Move the cursor down one page.
108        PageDown => "page_down",
109        /// Jump to the first entry.
110        Top => "top",
111        /// Jump to the last entry.
112        Bottom => "bottom",
113        /// Go to the parent directory.
114        Ascend => "ascend",
115        /// Enter the directory under the cursor.
116        Open => "open",
117        /// Select the entry under the cursor, or enter it when directories can't be selected.
118        Confirm => "confirm",
119        /// Focus the next pane.
120        NextPane => "next_pane",
121        /// Focus the previous pane.
122        PreviousPane => "previous_pane",
123        /// Open a new pane.
124        OpenPane => "open_pane",
125        /// Close the active pane.
126        ClosePane => "close_pane",
127        /// Show only the active pane, or all panes again.
128        ToggleSinglePane => "toggle_single_pane",
129        /// Start incremental search.
130        Search => "search",
131        /// Switch to the next sort order.
132        CycleSort => "cycle_sort",
133        /// Show or hide dotfiles.
134        ToggleHidden => "toggle_hidden",
135        /// Show or hide file sizes.
136        ToggleSizes => "toggle_sizes",
137        /// Show or hide directory sizes.
138        ToggleDirSizes => "toggle_dir_sizes",
139        /// Mark or unmark the entry under the cursor.
140        ToggleMark => "toggle_mark",
141        /// Cut the marked entries, or the one under the cursor, to the clipboard.
142        Cut => "cut",
143        /// Paste the clipboard into the active pane's directory.
144        Paste => "paste",
145        /// Delete the marked entries, or the one under the cursor.
146        Delete => "delete",
147        /// Create a directory in the current directory.
148        NewDirectory => "new_directory",
149        /// Create an empty file in the current directory.
150        NewFile => "new_file",
151        /// Rename the entry under the cursor.
152        Rename => "rename",
153        /// Show or hide the preview panel.
154        TogglePreview => "toggle_preview",
155        /// Scroll the preview down.
156        PreviewDown => "preview_down",
157        /// Scroll the preview up.
158        PreviewUp => "preview_up",
159        /// Open the file under the cursor in the external editor.
160        OpenExternalEditor => "open_external_editor",
161        /// Open the file under the cursor in the built-in editor.
162        OpenInlineEditor => "open_inline_editor",
163        /// Show or hide the theme picker.
164        ToggleThemePanel => "toggle_theme_panel",
165        /// Show or hide the options panel.
166        ToggleOptionsPanel => "toggle_options_panel",
167        /// Show or hide the editor picker.
168        ToggleEditorPanel => "toggle_editor_panel",
169        /// Switch to the next theme.
170        NextTheme => "next_theme",
171        /// Switch to the previous theme.
172        PreviousTheme => "previous_theme",
173        /// Turn cd-on-exit on or off.
174        ToggleCdOnExit => "toggle_cd_on_exit",
175        /// Close the explorer without selecting anything.
176        Dismiss => "dismiss",
177        /// Quit the application.
178        Quit => "quit",
179        /// Undo the last file action.
180        Undo => "undo",
181        /// Redo the last undone action.
182        Redo => "redo",
183        /// Rename the marked entries together in a text editor.
184        BulkRename => "bulk_rename",
185        /// Bookmark the current directory, or remove its bookmark.
186        ToggleBookmark => "toggle_bookmark",
187        /// Show the bookmark list.
188        OpenBookmarks => "open_bookmarks",
189        /// Find a file below the current directory by fuzzy name search.
190        FuzzyFind => "fuzzy_find",
191        /// Show or hide git status.
192        ToggleGit => "toggle_git",
193        /// Extract the archive under the cursor.
194        ExtractArchive => "extract_archive",
195    }
196}
197
198impl AppCommand {
199    /// The built-in key that triggers this command.
200    #[must_use]
201    pub fn key_event(self) -> KeyEvent {
202        let (code, modifiers) = match self {
203            Self::MoveUp => (KeyCode::Up, KeyModifiers::NONE),
204            Self::MoveDown => (KeyCode::Down, KeyModifiers::NONE),
205            Self::PageUp => (KeyCode::PageUp, KeyModifiers::NONE),
206            Self::PageDown => (KeyCode::PageDown, KeyModifiers::NONE),
207            Self::Top => (KeyCode::Home, KeyModifiers::NONE),
208            Self::Bottom => (KeyCode::End, KeyModifiers::NONE),
209            Self::Ascend => (KeyCode::Left, KeyModifiers::NONE),
210            Self::Open => (KeyCode::Right, KeyModifiers::NONE),
211            Self::Confirm => (KeyCode::Enter, KeyModifiers::NONE),
212            Self::NextPane => (KeyCode::Tab, KeyModifiers::NONE),
213            Self::PreviousPane => (KeyCode::BackTab, KeyModifiers::SHIFT),
214            Self::OpenPane => (KeyCode::Char('t'), KeyModifiers::CONTROL),
215            Self::ClosePane => (KeyCode::Char('w'), KeyModifiers::CONTROL),
216            Self::ToggleSinglePane => (KeyCode::Char('w'), KeyModifiers::NONE),
217            Self::Search => (KeyCode::Char('/'), KeyModifiers::NONE),
218            Self::CycleSort => (KeyCode::Char('s'), KeyModifiers::NONE),
219            Self::ToggleHidden => (KeyCode::Char('.'), KeyModifiers::NONE),
220            Self::ToggleSizes => (KeyCode::Char('z'), KeyModifiers::NONE),
221            Self::ToggleDirSizes => (KeyCode::Char('Z'), KeyModifiers::SHIFT),
222            Self::ToggleMark => (KeyCode::Char(' '), KeyModifiers::NONE),
223            Self::Cut => (KeyCode::Char('x'), KeyModifiers::NONE),
224            Self::Paste => (KeyCode::Char('p'), KeyModifiers::NONE),
225            Self::Delete => (KeyCode::Char('d'), KeyModifiers::NONE),
226            Self::NewDirectory => (KeyCode::Char('n'), KeyModifiers::NONE),
227            Self::NewFile => (KeyCode::Char('N'), KeyModifiers::NONE),
228            Self::Rename => (KeyCode::Char('r'), KeyModifiers::NONE),
229            Self::TogglePreview => (KeyCode::Char('P'), KeyModifiers::SHIFT),
230            Self::PreviewDown => (KeyCode::Char('j'), KeyModifiers::CONTROL),
231            Self::PreviewUp => (KeyCode::Char('k'), KeyModifiers::CONTROL),
232            Self::OpenExternalEditor => (KeyCode::Char('e'), KeyModifiers::NONE),
233            Self::OpenInlineEditor => (KeyCode::Char('i'), KeyModifiers::NONE),
234            Self::ToggleThemePanel => (KeyCode::Char('T'), KeyModifiers::SHIFT),
235            Self::ToggleOptionsPanel => (KeyCode::Char('O'), KeyModifiers::SHIFT),
236            Self::ToggleEditorPanel => (KeyCode::Char('E'), KeyModifiers::SHIFT),
237            Self::NextTheme => (KeyCode::Char('t'), KeyModifiers::NONE),
238            Self::PreviousTheme => (KeyCode::Char('['), KeyModifiers::NONE),
239            Self::ToggleCdOnExit => (KeyCode::Char('C'), KeyModifiers::SHIFT),
240            Self::Dismiss => (KeyCode::Esc, KeyModifiers::NONE),
241            Self::Quit => (KeyCode::Char('q'), KeyModifiers::NONE),
242            Self::Undo => (KeyCode::Char('u'), KeyModifiers::NONE),
243            Self::Redo => (KeyCode::Char('r'), KeyModifiers::CONTROL),
244            Self::BulkRename => (KeyCode::Char('R'), KeyModifiers::SHIFT),
245            Self::ToggleBookmark => (KeyCode::Char('b'), KeyModifiers::NONE),
246            Self::OpenBookmarks => (KeyCode::Char('B'), KeyModifiers::SHIFT),
247            Self::FuzzyFind => (KeyCode::Char('f'), KeyModifiers::NONE),
248            Self::ToggleGit => (KeyCode::Char('v'), KeyModifiers::NONE),
249            Self::ExtractArchive => (KeyCode::Char('X'), KeyModifiers::SHIFT),
250        };
251        KeyEvent::new(code, modifiers)
252    }
253}
254
255/// One custom key-to-command mapping.
256#[derive(Debug, Clone, Copy, PartialEq, Eq)]
257pub struct KeyBinding {
258    /// The key that triggers the command.
259    pub code: KeyCode,
260    /// Modifiers that must be held with [`code`](Self::code).
261    pub modifiers: KeyModifiers,
262    /// The command the key runs.
263    pub command: AppCommand,
264}
265
266/// Custom key bindings applied by [`App::dispatch_key`].
267#[derive(Debug, Clone, Default, PartialEq, Eq)]
268pub struct KeyBindings {
269    bindings: Vec<KeyBinding>,
270    /// Commands whose built-in key has been replaced via [`Self::remap`].
271    remapped: Vec<AppCommand>,
272}
273
274impl KeyBindings {
275    /// An empty set: every command keeps its built-in key.
276    #[must_use]
277    pub fn new() -> Self {
278        Self::default()
279    }
280
281    /// Add or replace one key mapping.
282    #[must_use]
283    pub fn bind(mut self, code: KeyCode, modifiers: KeyModifiers, command: AppCommand) -> Self {
284        self.bindings
285            .retain(|binding| binding.code != code || binding.modifiers != modifiers);
286        self.bindings.push(KeyBinding {
287            code,
288            modifiers,
289            command,
290        });
291        self
292    }
293
294    /// Remove the mapping for this key, if any.
295    #[must_use]
296    pub fn unbind(mut self, code: KeyCode, modifiers: KeyModifiers) -> Self {
297        self.bindings
298            .retain(|binding| binding.code != code || binding.modifiers != modifiers);
299        self
300    }
301
302    /// Replace the keys of `command` with `chords`.
303    ///
304    /// Unlike [`bind`](Self::bind), this also *disables the built-in key* of
305    /// the command (unless a chord reuses it), so `x` stops cutting once
306    /// `cut` is moved elsewhere.
307    #[must_use]
308    pub fn remap(mut self, command: AppCommand, chords: &[crate::KeyChord]) -> Self {
309        self.bindings.retain(|b| b.command != command);
310        if !self.remapped.contains(&command) {
311            self.remapped.push(command);
312        }
313        for chord in chords {
314            let event = chord.to_event();
315            // Uppercase letters arrive with or without SHIFT depending on the
316            // terminal; accept both.
317            let mut mods = vec![event.modifiers];
318            if matches!(chord.mods, crate::ChordMods::Plain | crate::ChordMods::Any)
319                && let KeyCode::Char(c) = event.code
320                && c.is_uppercase()
321            {
322                mods.push(KeyModifiers::NONE);
323            }
324            for m in mods {
325                self = self.bind(event.code, m, command);
326            }
327        }
328        self
329    }
330
331    /// Translate a physical key into the event the built-in handler expects.
332    ///
333    /// * custom binding  → that command's built-in key,
334    /// * a remapped command's old built-in key → `None` (swallowed),
335    /// * anything else → the key unchanged.
336    #[must_use]
337    pub fn translate(&self, key: KeyEvent) -> Option<KeyEvent> {
338        if let Some(command) = self.resolve(key) {
339            return Some(command.key_event());
340        }
341        let swallowed = self.remapped.iter().any(|c| {
342            let built_in = c.key_event();
343            built_in.code == key.code && built_in.modifiers == key.modifiers
344        });
345        (!swallowed).then_some(key)
346    }
347
348    /// All custom mappings, in the order they were added.
349    pub fn bindings(&self) -> &[KeyBinding] {
350        &self.bindings
351    }
352
353    /// The command custom-bound to `key`, if any.
354    #[must_use]
355    pub fn resolve(&self, key: KeyEvent) -> Option<AppCommand> {
356        self.bindings
357            .iter()
358            .find(|binding| binding.code == key.code && binding.modifiers == key.modifiers)
359            .map(|binding| binding.command)
360    }
361
362    /// `true` when there are no custom mappings and no remapped commands.
363    #[must_use]
364    pub fn is_empty(&self) -> bool {
365        self.bindings.is_empty() && self.remapped.is_empty()
366    }
367}
368
369/// Per-pane construction options used by [`AppBuilder`].
370#[derive(Debug, Clone)]
371pub struct PaneOptions {
372    /// Directory the pane opens in.
373    pub directory: PathBuf,
374    /// Filesystem the pane browses.
375    pub filesystem: crate::SharedFileSystem,
376    /// File extensions to show; empty shows every file.
377    pub extensions: Vec<String>,
378    /// Show dotfiles.
379    pub show_hidden: bool,
380    /// Show file sizes.
381    pub show_sizes: bool,
382    /// Show directory sizes.
383    pub show_dir_sizes: bool,
384    /// Navigation keys for this pane.
385    pub keymap: crate::Keymap,
386    /// Read directories on a background thread.
387    pub async_loading: bool,
388    /// Initial sort order.
389    pub sort_mode: SortMode,
390    /// Rows moved by page up and page down.
391    pub page_size: usize,
392    /// Whether the pane selects files, directories or both.
393    pub selection_mode: SelectionMode,
394}
395
396impl PaneOptions {
397    /// Options for a pane in `directory`, with the defaults: real filesystem, all
398    /// files, hidden files off, sizes on, synchronous loading.
399    pub fn new(directory: impl Into<PathBuf>) -> Self {
400        Self {
401            directory: directory.into(),
402            filesystem: crate::std_filesystem(),
403            extensions: Vec::new(),
404            show_hidden: false,
405            show_sizes: true,
406            show_dir_sizes: true,
407            keymap: crate::Keymap::default(),
408            async_loading: false,
409            sort_mode: SortMode::default(),
410            page_size: crate::PAGE_SIZE,
411            selection_mode: SelectionMode::default(),
412        }
413    }
414
415    /// Browse `filesystem` instead of the real one.
416    #[must_use]
417    pub fn filesystem(mut self, filesystem: crate::SharedFileSystem) -> Self {
418        self.filesystem = filesystem;
419        self
420    }
421
422    /// Also show files with this extension (without the dot).
423    #[must_use]
424    pub fn extension(mut self, extension: impl Into<String>) -> Self {
425        self.extensions.push(extension.into());
426        self
427    }
428
429    /// Show or hide dotfiles.
430    #[must_use]
431    pub fn show_hidden(mut self, show: bool) -> Self {
432        self.show_hidden = show;
433        self
434    }
435
436    /// Show or hide file sizes.
437    #[must_use]
438    pub fn show_sizes(mut self, show: bool) -> Self {
439        self.show_sizes = show;
440        self
441    }
442
443    /// Show or hide directory sizes.
444    #[must_use]
445    pub fn show_dir_sizes(mut self, show: bool) -> Self {
446        self.show_dir_sizes = show;
447        self
448    }
449
450    /// Use `keymap` for navigation in this pane.
451    #[must_use]
452    pub fn keymap(mut self, keymap: crate::Keymap) -> Self {
453        self.keymap = keymap;
454        self
455    }
456
457    /// Read directories on a background thread.
458    pub fn async_loading(mut self, enabled: bool) -> Self {
459        self.async_loading = enabled;
460        self
461    }
462
463    /// Set the initial sort order.
464    pub fn sort_mode(mut self, sort_mode: SortMode) -> Self {
465        self.sort_mode = sort_mode;
466        self
467    }
468
469    /// Set how many rows page up and page down move.
470    #[must_use]
471    pub fn page_size(mut self, page_size: usize) -> Self {
472        self.page_size = page_size.max(1);
473        self
474    }
475
476    /// Set whether the pane selects files, directories or both.
477    #[must_use]
478    pub fn selection_mode(mut self, selection_mode: SelectionMode) -> Self {
479        self.selection_mode = selection_mode;
480        self
481    }
482
483    fn build(self) -> FileExplorer {
484        FileExplorer::builder(self.directory)
485            .filesystem(self.filesystem)
486            .extension_filter(self.extensions)
487            .show_hidden(self.show_hidden)
488            .show_sizes(self.show_sizes)
489            .show_dir_sizes(self.show_dir_sizes)
490            .keymap(self.keymap)
491            .async_loading(self.async_loading)
492            .sort_mode(self.sort_mode)
493            .page_size(self.page_size)
494            .selection_mode(self.selection_mode)
495            .build()
496    }
497}
498
499/// Error returned by [`AppBuilder::build`].
500#[derive(Debug, Clone, PartialEq, Eq)]
501pub enum AppBuildError {
502    /// The application needs at least one pane.
503    NoPanes,
504    /// The theme index is past the end of the theme catalogue.
505    InvalidThemeIndex {
506        /// The requested index.
507        index: usize,
508        /// Number of themes available.
509        theme_count: usize,
510    },
511    /// The pane index is past the last pane.
512    InvalidPaneIndex {
513        /// The requested index.
514        index: usize,
515        /// Number of panes open.
516        pane_count: usize,
517    },
518}
519
520impl fmt::Display for AppBuildError {
521    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
522        match self {
523            Self::NoPanes => write!(f, "an application must contain at least one pane"),
524            Self::InvalidThemeIndex { index, theme_count } => {
525                write!(f, "theme index {index} is outside 0..{theme_count}")
526            }
527            Self::InvalidPaneIndex { index, pane_count } => {
528                write!(f, "pane index {index} is outside 0..{pane_count}")
529            }
530        }
531    }
532}
533
534impl std::error::Error for AppBuildError {}
535
536/// Builder for the complete embeddable application.
537#[derive(Debug, Clone)]
538pub struct AppBuilder {
539    panes: Vec<PaneOptions>,
540    options: AppOptions,
541    key_bindings: KeyBindings,
542    theme_override: Option<Theme>,
543    operation_mode: super::OperationMode,
544}
545
546impl Default for AppBuilder {
547    fn default() -> Self {
548        Self {
549            panes: Vec::new(),
550            options: AppOptions::default(),
551            key_bindings: KeyBindings::default(),
552            theme_override: None,
553            operation_mode: super::OperationMode::Immediate,
554        }
555    }
556}
557
558impl AppBuilder {
559    /// A builder with default options and no panes yet.
560    #[must_use]
561    pub fn new() -> Self {
562        Self::default()
563    }
564
565    /// Add a pane. Without any, [`build`](Self::build) adds one for `.`.
566    #[must_use]
567    pub fn pane(mut self, pane: PaneOptions) -> Self {
568        self.panes.push(pane);
569        self
570    }
571
572    /// Start with the theme at `theme_index` in the theme catalogue.
573    #[must_use]
574    pub fn theme_index(mut self, theme_index: usize) -> Self {
575        self.options.theme_idx = theme_index;
576        self
577    }
578
579    /// Replace the selected preset's palette with a host-defined theme.
580    #[must_use]
581    pub fn theme(mut self, theme: impl Into<Theme>) -> Self {
582        self.theme_override = Some(theme.into());
583        self
584    }
585
586    /// Editor that opens files on `e`.
587    #[must_use]
588    pub fn editor(mut self, editor: Editor) -> Self {
589        self.options.editor = editor;
590        self
591    }
592
593    /// Start with only the active pane shown.
594    #[must_use]
595    pub fn single_pane(mut self, enabled: bool) -> Self {
596        self.options.single_pane = enabled;
597        self
598    }
599
600    /// Print the final directory on dismiss, for the shell wrapper.
601    #[must_use]
602    pub fn cd_on_exit(mut self, enabled: bool) -> Self {
603        self.options.cd_on_exit = enabled;
604        self
605    }
606
607    /// Show the debug log panel and write logs to a file.
608    #[must_use]
609    pub fn verbose(mut self, enabled: bool) -> Self {
610        self.options.verbose = enabled;
611        self
612    }
613
614    /// Use custom key bindings.
615    #[must_use]
616    pub fn key_bindings(mut self, key_bindings: KeyBindings) -> Self {
617        self.key_bindings = key_bindings;
618        self
619    }
620
621    /// Run file operations at once, or hand them to the host.
622    #[must_use]
623    pub fn operation_mode(mut self, operation_mode: super::OperationMode) -> Self {
624        self.operation_mode = operation_mode;
625        self
626    }
627
628    /// Build the [`App`].
629    ///
630    /// # Errors
631    ///
632    /// [`AppBuildError::InvalidThemeIndex`] when the theme index is out of range.
633    pub fn build(mut self) -> Result<App, AppBuildError> {
634        let theme_count = crate::Theme::presets().len();
635        if self.options.theme_idx >= theme_count {
636            return Err(AppBuildError::InvalidThemeIndex {
637                index: self.options.theme_idx,
638                theme_count,
639            });
640        }
641
642        if self.panes.is_empty() {
643            self.panes.push(PaneOptions::new("."));
644        }
645        self.options.pane_dirs = self
646            .panes
647            .iter()
648            .map(|pane| pane.directory.clone())
649            .collect();
650        let pane_options = self.panes;
651        let mut app = App::new(self.options);
652        app.panes = pane_options.into_iter().map(PaneOptions::build).collect();
653        app.key_bindings = self.key_bindings;
654        app.operation_mode = self.operation_mode;
655        if let Some(theme) = self.theme_override {
656            app.themes[app.theme_idx].1 = theme;
657        }
658        Ok(app)
659    }
660}
661
662impl App {
663    /// Start building an application.
664    #[must_use]
665    pub fn builder() -> AppBuilder {
666        AppBuilder::new()
667    }
668
669    /// All panes, in left-to-right order.
670    #[must_use]
671    pub fn panes(&self) -> &[FileExplorer] {
672        &self.panes
673    }
674
675    /// All panes, mutable.
676    pub fn panes_mut(&mut self) -> &mut [FileExplorer] {
677        &mut self.panes
678    }
679
680    /// Replace every pane. The focus moves to the last pane if it was further right.
681    ///
682    /// # Errors
683    ///
684    /// [`AppBuildError::NoPanes`] when `panes` is empty.
685    pub fn replace_panes(&mut self, panes: Vec<FileExplorer>) -> Result<(), AppBuildError> {
686        if panes.is_empty() {
687            return Err(AppBuildError::NoPanes);
688        }
689        self.panes = panes;
690        self.active_idx = self.active_idx.min(self.panes.len() - 1);
691        Ok(())
692    }
693
694    /// Index of the pane with keyboard focus.
695    #[must_use]
696    pub fn active_pane_index(&self) -> usize {
697        self.active_idx
698    }
699
700    /// Move keyboard focus to the pane at `index`.
701    ///
702    /// # Errors
703    ///
704    /// [`AppBuildError::InvalidPaneIndex`] when there is no such pane.
705    pub fn set_active_pane_index(&mut self, index: usize) -> Result<(), AppBuildError> {
706        if index >= self.panes.len() {
707            return Err(AppBuildError::InvalidPaneIndex {
708                index,
709                pane_count: self.panes.len(),
710            });
711        }
712        self.active_idx = index;
713        Ok(())
714    }
715
716    /// Switch to the theme at `index` in the theme catalogue.
717    ///
718    /// # Errors
719    ///
720    /// [`AppBuildError::InvalidThemeIndex`] when there is no such theme.
721    pub fn set_theme_index(&mut self, index: usize) -> Result<(), AppBuildError> {
722        if index >= self.themes.len() {
723            return Err(AppBuildError::InvalidThemeIndex {
724                index,
725                theme_count: self.themes.len(),
726            });
727        }
728        self.theme_idx = index;
729        Ok(())
730    }
731
732    /// Use `keymap` for explorer navigation in every pane (and new ones).
733    pub fn set_keymap(&mut self, keymap: &crate::Keymap) {
734        for pane in &mut self.panes {
735            pane.keymap = keymap.clone();
736        }
737    }
738
739    /// Apply a parsed `keymap.toml`: explorer keys on every pane, app-level
740    /// remaps via [`set_key_bindings`](Self::set_key_bindings).
741    pub fn apply_keymap_config(&mut self, config: &crate::KeymapConfig) {
742        self.set_keymap(&config.explorer);
743        self.key_bindings = config.app.clone();
744    }
745
746    /// The custom key bindings in use.
747    #[must_use]
748    pub fn key_bindings(&self) -> &KeyBindings {
749        &self.key_bindings
750    }
751
752    /// Replace the custom key bindings.
753    pub fn set_key_bindings(&mut self, key_bindings: KeyBindings) {
754        self.key_bindings = key_bindings;
755    }
756
757    /// Dispatch a host-owned terminal event.
758    pub fn dispatch_event(&mut self, event: Event) -> io::Result<AppOutcome> {
759        match event {
760            Event::Key(key) => self.dispatch_key(key),
761            Event::Resize(_, _) => Ok(AppOutcome::Continue),
762            Event::Mouse(_) | Event::FocusGained | Event::FocusLost | Event::Paste(_) => {
763                Ok(AppOutcome::Ignored)
764            }
765        }
766    }
767
768    /// Dispatch a semantic command without synthesizing input in host code.
769    pub fn dispatch_command(&mut self, command: AppCommand) -> io::Result<AppOutcome> {
770        self.dispatch_key_event(command.key_event())
771    }
772
773    /// Dispatch a key using custom bindings, preserving modal/editor priority.
774    pub fn dispatch_key(&mut self, key: KeyEvent) -> io::Result<AppOutcome> {
775        press_only!(key, Ok(AppOutcome::Ignored));
776        let pane_captures_text =
777            self.active_pane().search_active || self.active_pane().has_prompt();
778        let mapped = if self.inline_editor.is_some() || self.modal.is_some() || pane_captures_text {
779            Some(key)
780        } else {
781            self.key_bindings.translate(key)
782        };
783        match mapped {
784            Some(mapped) => self.dispatch_key_event(mapped),
785            None => Ok(AppOutcome::Continue),
786        }
787    }
788
789    fn dispatch_key_event(&mut self, key: KeyEvent) -> io::Result<AppOutcome> {
790        let previous_editor_request = self.open_with_editor.clone();
791        let previous_pane_count = self.panes.len();
792        let previous_active = self.active_idx;
793        let previous_directories: Vec<PathBuf> = self
794            .panes
795            .iter()
796            .map(|pane| pane.current_dir.clone())
797            .collect();
798        let previous_theme = self.theme_idx;
799        let previous_single_pane = self.single_pane;
800        let previous_preview = self.show_preview;
801        let should_exit = self.handle_key(key)?;
802
803        let mut events = Vec::new();
804        match self.panes.len().cmp(&previous_pane_count) {
805            std::cmp::Ordering::Greater => events.push(AppEvent::PaneAdded {
806                pane_count: self.panes.len(),
807            }),
808            std::cmp::Ordering::Less => events.push(AppEvent::PaneClosed {
809                pane_count: self.panes.len(),
810            }),
811            std::cmp::Ordering::Equal => {}
812        }
813        if self.active_idx != previous_active {
814            events.push(AppEvent::ActivePaneChanged {
815                index: self.active_idx,
816            });
817        }
818        for (index, pane) in self.panes.iter().enumerate() {
819            if previous_directories.get(index) != Some(&pane.current_dir) {
820                events.push(AppEvent::DirectoryChanged {
821                    pane: index,
822                    path: pane.current_dir.clone(),
823                });
824            }
825        }
826        if self.theme_idx != previous_theme {
827            events.push(AppEvent::ThemeChanged {
828                index: self.theme_idx,
829            });
830        }
831        if self.single_pane != previous_single_pane {
832            events.push(AppEvent::LayoutChanged {
833                single_pane: self.single_pane,
834            });
835        }
836        if self.show_preview != previous_preview {
837            events.push(AppEvent::PreviewChanged {
838                visible: self.show_preview,
839            });
840        }
841        self.event_queue.extend(events.iter().cloned());
842
843        if let Some(request) = self.queued_operation.take() {
844            return Ok(AppOutcome::OperationRequested(request));
845        }
846        if should_exit {
847            return Ok(self
848                .selected
849                .clone()
850                .map_or(AppOutcome::Dismissed, AppOutcome::Selected));
851        }
852        if self.open_with_editor != previous_editor_request
853            && let Some(path) = self.open_with_editor.clone()
854        {
855            return Ok(AppOutcome::OpenEditor {
856                path,
857                editor: self.editor.clone(),
858            });
859        }
860        if events.is_empty() {
861            Ok(AppOutcome::Continue)
862        } else {
863            Ok(AppOutcome::Changed(events))
864        }
865    }
866
867    /// Drain observable transitions accumulated by dispatch calls.
868    pub fn drain_events(&mut self) -> impl Iterator<Item = AppEvent> + '_ {
869        self.event_queue.drain(..)
870    }
871
872    /// Advance time-based UI state without rendering.
873    pub fn tick(&mut self, now: Instant) {
874        if self
875            .snackbar
876            .as_ref()
877            .is_some_and(|snackbar| now >= snackbar.expires_at)
878        {
879            self.snackbar = None;
880        }
881    }
882
883    /// Refresh preview content for a host-provided viewport.
884    pub fn update_preview(&mut self, width: u16, height: u16) {
885        if !self.show_preview {
886            return;
887        }
888        let current_path = self
889            .active_pane()
890            .current_entry()
891            .map(|entry| entry.path.clone());
892        self.preview_state
893            .update(current_path.as_deref(), width, height);
894    }
895
896    /// Take and clear the external-editor request used by the legacy run loop.
897    pub fn take_editor_request(&mut self) -> Option<(PathBuf, Editor)> {
898        self.open_with_editor
899            .take()
900            .map(|path| (path, self.editor.clone()))
901    }
902
903    /// Capture host-storable application state without writing global files.
904    #[must_use]
905    pub fn snapshot(&self) -> AppState {
906        let active = self.active_pane();
907        AppState {
908            theme: Some(self.theme_name().to_string()),
909            last_dir: self.panes.first().map(|pane| pane.current_dir.clone()),
910            last_dir_right: self.panes.get(1).map(|pane| pane.current_dir.clone()),
911            sort_mode: Some(active.sort_mode),
912            show_hidden: Some(active.show_hidden),
913            show_sizes: Some(active.show_sizes),
914            show_dir_sizes: Some(active.show_dir_sizes),
915            use_trash: Some(self.use_trash),
916            show_git: Some(active.show_git),
917            bookmarks: Some(self.bookmarks.clone()),
918            single_pane: Some(self.single_pane),
919            cd_on_exit: Some(self.cd_on_exit),
920            editor: Some(self.editor.to_key()),
921            active_pane: Some(
922                if self.active_idx == 1 {
923                    "right"
924                } else {
925                    "left"
926                }
927                .into(),
928            ),
929            pane_dirs: Some(
930                self.panes
931                    .iter()
932                    .map(|pane| pane.current_dir.clone())
933                    .collect(),
934            ),
935            active_pane_idx: Some(self.active_idx),
936        }
937    }
938
939    /// Apply host-loaded state while preserving runtime-only UI state.
940    pub fn restore(&mut self, state: &AppState) {
941        let pane_dirs = state
942            .pane_dirs
943            .clone()
944            .filter(|dirs| !dirs.is_empty())
945            .or_else(|| {
946                state.last_dir.clone().map(|left| {
947                    let mut dirs = vec![left];
948                    if let Some(right) = state.last_dir_right.clone() {
949                        dirs.push(right);
950                    }
951                    dirs
952                })
953            });
954        if let Some(dirs) = pane_dirs {
955            let show_hidden = state.show_hidden.unwrap_or(false);
956            let show_sizes = state.show_sizes.unwrap_or(true);
957            let show_dir_sizes = state.show_dir_sizes.unwrap_or(true);
958            let show_git = state.show_git.unwrap_or(false);
959            let sort_mode = state.sort_mode.unwrap_or_default();
960            let filesystem = self
961                .panes
962                .first()
963                .map_or_else(crate::std_filesystem, |pane| pane.filesystem().clone());
964            let keymap = self
965                .panes
966                .first()
967                .map(|pane| pane.keymap.clone())
968                .unwrap_or_default();
969            let async_loading = self.panes.first().is_some_and(|pane| pane.async_loading);
970            self.panes = dirs
971                .into_iter()
972                .map(|directory| {
973                    FileExplorer::builder(directory)
974                        .filesystem(filesystem.clone())
975                        .show_hidden(show_hidden)
976                        .show_sizes(show_sizes)
977                        .show_dir_sizes(show_dir_sizes)
978                        .show_git(show_git)
979                        .async_loading(async_loading)
980                        .keymap(keymap.clone())
981                        .sort_mode(sort_mode)
982                        .build()
983                })
984                .collect();
985        }
986        if let Some(bookmarks) = &state.bookmarks {
987            self.bookmarks.clone_from(bookmarks);
988        }
989        if let Some(index) = state.active_pane_idx {
990            self.active_idx = index.min(self.panes.len().saturating_sub(1));
991        }
992        if let Some(theme) = &state.theme {
993            self.theme_idx = crate::resolve_theme_idx(theme, &self.themes);
994        }
995        if let Some(single_pane) = state.single_pane {
996            self.single_pane = single_pane;
997        }
998        if let Some(cd_on_exit) = state.cd_on_exit {
999            self.cd_on_exit = cd_on_exit;
1000        }
1001        if let Some(editor) = state.editor.as_deref().and_then(Editor::from_key) {
1002            self.editor = editor;
1003        }
1004    }
1005}
1006
1007#[cfg(feature = "theme")]
1008impl crate::nuances::Themed for AppBuilder {
1009    fn apply_theme(self, theme: &crate::nuances::NuancesTheme) -> Self {
1010        self.theme(theme)
1011    }
1012}
1013
1014#[cfg(test)]
1015mod tests {
1016    use super::*;
1017    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1018    use tempfile::tempdir;
1019
1020    #[test]
1021    fn builder_supports_per_pane_options() {
1022        let left = tempdir().unwrap();
1023        let right = tempdir().unwrap();
1024        let app = App::builder()
1025            .pane(PaneOptions::new(left.path()).show_hidden(true))
1026            .pane(PaneOptions::new(right.path()).show_sizes(false))
1027            .build()
1028            .unwrap();
1029        assert_eq!(app.panes().len(), 2);
1030        assert!(app.panes()[0].show_hidden);
1031        assert!(!app.panes()[1].show_sizes);
1032    }
1033
1034    #[test]
1035    fn builder_rejects_invalid_theme() {
1036        let result = App::builder().theme_index(usize::MAX).build();
1037        assert!(matches!(
1038            result,
1039            Err(AppBuildError::InvalidThemeIndex { .. })
1040        ));
1041    }
1042
1043    #[test]
1044    fn custom_binding_dispatches_command() {
1045        let dir = tempdir().unwrap();
1046        let mut app = App::builder()
1047            .pane(PaneOptions::new(dir.path()))
1048            .key_bindings(KeyBindings::new().bind(
1049                KeyCode::F(2),
1050                KeyModifiers::NONE,
1051                AppCommand::ToggleSinglePane,
1052            ))
1053            .build()
1054            .unwrap();
1055        assert!(!app.single_pane);
1056        let outcome = app
1057            .dispatch_key(KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE))
1058            .unwrap();
1059        assert_eq!(
1060            outcome,
1061            AppOutcome::Changed(vec![AppEvent::LayoutChanged { single_pane: true }])
1062        );
1063        assert!(app.single_pane);
1064        assert_eq!(
1065            app.drain_events().collect::<Vec<_>>(),
1066            vec![AppEvent::LayoutChanged { single_pane: true }]
1067        );
1068    }
1069
1070    #[test]
1071    fn custom_bindings_do_not_override_inline_editor_input() {
1072        let dir = tempdir().unwrap();
1073        std::fs::write(dir.path().join("note.txt"), b"").unwrap();
1074        let mut app = App::builder()
1075            .pane(PaneOptions::new(dir.path()))
1076            .key_bindings(KeyBindings::new().bind(
1077                KeyCode::Char('p'),
1078                KeyModifiers::NONE,
1079                AppCommand::ToggleSinglePane,
1080            ))
1081            .build()
1082            .unwrap();
1083        app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE))
1084            .unwrap();
1085        app.dispatch_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE))
1086            .unwrap();
1087        assert!(!app.single_pane);
1088        assert_eq!(app.inline_editor.as_ref().unwrap().lines(), &["p"]);
1089    }
1090
1091    #[test]
1092    fn custom_bindings_do_not_override_search_input() {
1093        let dir = tempdir().unwrap();
1094        std::fs::write(dir.path().join("paper.txt"), b"").unwrap();
1095        let mut app = App::builder()
1096            .pane(PaneOptions::new(dir.path()))
1097            .key_bindings(KeyBindings::new().bind(
1098                KeyCode::Char('p'),
1099                KeyModifiers::NONE,
1100                AppCommand::ToggleSinglePane,
1101            ))
1102            .build()
1103            .unwrap();
1104        app.handle_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE))
1105            .unwrap();
1106        app.dispatch_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE))
1107            .unwrap();
1108        assert!(!app.single_pane);
1109        assert_eq!(app.active_pane().search_query(), "p");
1110    }
1111
1112    #[test]
1113    fn snapshot_round_trips_host_owned_state() {
1114        let left = tempdir().unwrap();
1115        let right = tempdir().unwrap();
1116        let mut app = App::builder()
1117            .pane(PaneOptions::new(left.path()))
1118            .pane(PaneOptions::new(right.path()))
1119            .build()
1120            .unwrap();
1121        app.set_active_pane_index(1).unwrap();
1122        app.single_pane = true;
1123        let snapshot = app.snapshot();
1124
1125        let mut restored = App::new(AppOptions::default());
1126        restored.restore(&snapshot);
1127        assert_eq!(restored.panes().len(), 2);
1128        assert_eq!(restored.active_pane_index(), 1);
1129        assert!(restored.single_pane);
1130    }
1131}