Skip to main content

tui_file_explorer/explorer/
keys.rs

1use super::{background::AfterLoad, *};
2
3impl ExplorerCommand {
4    /// The command bound to `key` in the built-in keymap, if any.
5    #[must_use]
6    pub fn from_key(key: KeyEvent) -> Option<Self> {
7        Keymap::builtin().resolve(key)
8    }
9}
10
11// ── Text prompts (rename / touch / mkdir) ─────────────────────────────────────
12
13impl PromptKind {
14    fn outcome(self, from: PathBuf, to: PathBuf) -> ExplorerOutcome {
15        match self {
16            Self::Rename => ExplorerOutcome::RenameCompleted { from, to },
17            Self::Touch => ExplorerOutcome::TouchCreated(to),
18            Self::Mkdir => ExplorerOutcome::MkdirCreated(to),
19        }
20    }
21}
22
23impl FileExplorer {
24    /// Feed one key to the open prompt. Always consumes the event.
25    fn handle_prompt_key(&mut self, key: KeyEvent) -> ExplorerOutcome {
26        let Some(prompt) = self.prompt.as_mut() else {
27            return ExplorerOutcome::Pending;
28        };
29        match key.code {
30            // Printable character (no modifiers, or Shift only) → append.
31            KeyCode::Char(c)
32                if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT =>
33            {
34                prompt.text.push(c);
35            }
36            KeyCode::Backspace => {
37                prompt.text.pop();
38            }
39            KeyCode::Enter => return self.commit_prompt(),
40            // Esc cancels without committing.
41            KeyCode::Esc => self.prompt = None,
42            // Any other key stays in the prompt and is swallowed.
43            _ => {}
44        }
45        ExplorerOutcome::Pending
46    }
47
48    /// Apply the prompt's text to the filesystem and close it.
49    fn commit_prompt(&mut self) -> ExplorerOutcome {
50        let Some(Prompt { kind, text }) = self.prompt.take() else {
51            return ExplorerOutcome::Pending;
52        };
53        let name = text.trim();
54        if name.is_empty() {
55            return ExplorerOutcome::Pending;
56        }
57
58        let target = self.current_dir.join(name);
59        let mut from = target.clone();
60        let result = match kind {
61            PromptKind::Rename => {
62                // The source is whatever is under the cursor *before* reload.
63                let Some(src) = self.entries.get(self.cursor).map(|e| e.path.clone()) else {
64                    return ExplorerOutcome::Pending;
65                };
66                from = src.clone();
67                self.filesystem.rename(&src, &target)
68            }
69            PromptKind::Touch => self.filesystem.create_file(&target),
70            PromptKind::Mkdir => self.filesystem.create_dir_all(&target),
71        };
72
73        match result {
74            Ok(()) => {
75                self.reload_with(AfterLoad {
76                    select: Some(target.clone()),
77                    cursor: None,
78                });
79                kind.outcome(from, target)
80            }
81            Err(e) => {
82                self.status = format!("{} failed: {e}", kind.verb());
83                ExplorerOutcome::Pending
84            }
85        }
86    }
87
88    /// Navigate to `path`: a directory is entered; for a file, its parent
89    /// directory is opened with the file highlighted.
90    ///
91    /// Clears search and marks (the listing changes wholesale).
92    pub fn go_to(&mut self, path: &Path) {
93        let (dir, select) = if self.filesystem.is_dir(path) {
94            (path.to_path_buf(), None)
95        } else {
96            match path.parent() {
97                Some(parent) => (parent.to_path_buf(), Some(path)),
98                None => return,
99            }
100        };
101        self.current_dir = dir;
102        self.search_active = false;
103        self.search_query.clear();
104        self.marked.clear();
105        self.cursor = 0;
106        self.scroll_offset = 0;
107        self.reload_with(AfterLoad {
108            select: select.map(Path::to_path_buf),
109            cursor: None,
110        });
111    }
112
113    /// Move the cursor onto `path` if it is visible.
114    pub fn select_path(&mut self, path: &Path) {
115        if let Some(idx) = self.entries.iter().position(|e| e.path == path) {
116            self.cursor = idx;
117        }
118    }
119
120    /// Re-read the directory but keep the cursor on (or near) its old row.
121    fn reload_keep_cursor(&mut self) {
122        // `reload` already restores the row (clamped), also when deferred.
123        self.reload();
124    }
125
126    /// Reset the list position after the visible entries changed wholesale.
127    fn reload_from_top(&mut self) {
128        self.cursor = 0;
129        self.scroll_offset = 0;
130        self.reload();
131    }
132}
133
134// ── Public key / command entry points ─────────────────────────────────────────
135
136impl FileExplorer {
137    /// Dispatch a semantic command without coupling host code to key bindings.
138    ///
139    /// Routed through [`handle_key`](Self::handle_key) so active prompts and
140    /// incremental search see it exactly like the equivalent key press.
141    pub fn handle_command(&mut self, command: ExplorerCommand) -> ExplorerOutcome {
142        self.handle_key(self.keymap.canonical_key(command))
143    }
144
145    /// Handle one key press and report what happened.
146    pub fn handle_key(&mut self, key: KeyEvent) -> ExplorerOutcome {
147        press_only!(key, ExplorerOutcome::Pending);
148
149        // An open rename / touch / mkdir prompt owns the keyboard.
150        if self.prompt.is_some() {
151            return self.handle_prompt_key(key);
152        }
153
154        // While searching, printable characters feed the query rather than
155        // triggering shortcuts. Navigation keys fall through so the filtered
156        // list stays usable.
157        if self.search_active {
158            match key.code {
159                KeyCode::Char(c) if key.modifiers.is_empty() => {
160                    self.search_query.push(c);
161                    self.reload_from_top();
162                    return ExplorerOutcome::Pending;
163                }
164                KeyCode::Backspace => {
165                    if self.search_query.is_empty() {
166                        // Nothing left to erase — deactivate search.
167                        self.search_active = false;
168                    } else {
169                        self.search_query.pop();
170                        self.reload_from_top();
171                    }
172                    return ExplorerOutcome::Pending;
173                }
174                // First Esc cancels search; a second (inactive) one dismisses.
175                KeyCode::Esc => {
176                    self.search_active = false;
177                    self.search_query.clear();
178                    self.reload_from_top();
179                    return ExplorerOutcome::Pending;
180                }
181                _ => {}
182            }
183        }
184
185        match self.keymap.resolve(key) {
186            Some(command) => self.execute(command),
187            None => ExplorerOutcome::Unhandled,
188        }
189    }
190
191    /// Run `command` directly (no prompt / search interception).
192    fn execute(&mut self, command: ExplorerCommand) -> ExplorerOutcome {
193        use ExplorerCommand as C;
194        match command {
195            C::Dismiss => return ExplorerOutcome::Dismissed,
196            C::MoveUp => self.move_up(),
197            C::MoveDown => self.move_down(),
198            C::PageUp => (0..self.page_size.min(self.entries.len())).for_each(|_| self.move_up()),
199            C::PageDown => {
200                (0..self.page_size.min(self.entries.len())).for_each(|_| self.move_down())
201            }
202            C::Top => {
203                self.cursor = 0;
204                self.scroll_offset = 0;
205            }
206            C::Bottom => {
207                if !self.entries.is_empty() {
208                    self.cursor = self.entries.len() - 1;
209                }
210            }
211            C::Ascend => self.ascend(),
212            // Navigate / Confirm may select a file, so they report outcomes.
213            C::Navigate => return self.navigate(),
214            C::Confirm => return self.confirm(),
215            C::ToggleHidden => {
216                self.show_hidden = !self.show_hidden;
217                self.reload_keep_cursor();
218            }
219            C::ToggleSizes => self.show_sizes = !self.show_sizes,
220            C::ToggleDirSizes => self.show_dir_sizes = !self.show_dir_sizes,
221            C::Search => self.search_active = true,
222            C::CycleSort => {
223                self.sort_mode = self.sort_mode.next();
224                self.reload_keep_cursor();
225            }
226            C::ToggleMark => self.toggle_mark(),
227            C::NewDirectory => self.open_prompt(PromptKind::Mkdir, ""),
228            C::NewFile => self.open_prompt(PromptKind::Touch, ""),
229            // Pre-fill with the current name so it can be edited in place.
230            C::Rename => {
231                if let Some(entry) = self.entries.get(self.cursor) {
232                    let name = entry.name.clone();
233                    self.open_prompt(PromptKind::Rename, name);
234                }
235            }
236        }
237        ExplorerOutcome::Pending
238    }
239
240    // ── Internal navigation helpers ───────────────────────────────────────────
241
242    pub(crate) fn move_up(&mut self) {
243        if self.cursor > 0 {
244            self.cursor -= 1;
245        }
246        // If entries shrank (e.g. external deletion) clamp to valid range.
247        self.clamp_cursor();
248    }
249
250    pub(crate) fn move_down(&mut self) {
251        let last = self.entries.len().saturating_sub(1);
252        if !self.entries.is_empty() && self.cursor < last {
253            self.cursor += 1;
254        }
255        self.clamp_cursor();
256    }
257
258    /// Clamp `cursor` and `scroll_offset` so they never exceed the current
259    /// entries length.  Safe to call at any time — a no-op when everything is
260    /// already in range.
261    pub(crate) fn clamp_cursor(&mut self) {
262        let max = self.entries.len().saturating_sub(1);
263        if self.cursor > max {
264            self.cursor = max;
265        }
266        if self.scroll_offset > self.cursor {
267            self.scroll_offset = self.cursor;
268        }
269    }
270
271    pub(crate) fn ascend(&mut self) {
272        if let Some(parent) = self.current_dir.parent().map(std::path::Path::to_path_buf) {
273            let prev = self.current_dir.clone();
274            self.current_dir = parent;
275            self.cursor = 0;
276            self.scroll_offset = 0;
277            // Clear search and marks when navigating to a different directory.
278            self.search_active = false;
279            self.search_query.clear();
280            self.marked.clear();
281            // Land the cursor on the directory we just came from.
282            self.reload_with(AfterLoad {
283                select: Some(prev),
284                cursor: None,
285            });
286        } else {
287            // Already at root — stay put, do nothing.
288            self.status = "Already at the filesystem root.".to_string();
289        }
290    }
291
292    /// Navigate into the highlighted entry without ever exiting the TUI.
293    ///
294    /// - **Directory** → descend (same as `confirm` on a dir).
295    /// - **File** → move the cursor down one step so the user can keep
296    ///   browsing without accidentally triggering a selection/exit.
297    fn navigate(&mut self) -> ExplorerOutcome {
298        let Some(entry) = self.entries.get(self.cursor) else {
299            return ExplorerOutcome::Pending;
300        };
301
302        if entry.is_dir {
303            let path = entry.path.clone();
304            self.search_active = false;
305            self.search_query.clear();
306            self.marked.clear();
307            self.navigate_to(path);
308        } else {
309            self.move_down();
310        }
311        ExplorerOutcome::Pending
312    }
313
314    fn confirm(&mut self) -> ExplorerOutcome {
315        let Some(entry) = self.entries.get(self.cursor) else {
316            return ExplorerOutcome::Pending;
317        };
318
319        if entry.is_dir {
320            if matches!(
321                self.selection_mode,
322                SelectionMode::Directories | SelectionMode::Any
323            ) {
324                return ExplorerOutcome::Selected(entry.path.clone());
325            }
326            let path = entry.path.clone();
327            // Clear search and marks when descending into a subdirectory.
328            self.search_active = false;
329            self.search_query.clear();
330            self.marked.clear();
331            self.navigate_to(path);
332            ExplorerOutcome::Pending
333        } else if self.selection_mode == SelectionMode::Directories {
334            self.status = "Only directories can be selected.".into();
335            ExplorerOutcome::Pending
336        } else {
337            // All visible files already passed the extension filter in load_entries,
338            // so every non-directory entry is unconditionally selectable here.
339            ExplorerOutcome::Selected(entry.path.clone())
340        }
341    }
342}