1use std::io;
4use std::path::PathBuf;
5use std::time::Duration;
6
7use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind};
8use ratatui::crossterm::execute;
9use ratatui::crossterm::terminal::{
10 EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
11};
12use ratatui::prelude::*;
13
14use crate::Res;
15use crate::app::{App, Mode};
16use crate::view;
17
18#[derive(Debug, PartialEq)]
20pub(crate) enum Action {
21 Quit,
22 Pick(PathBuf),
23}
24
25struct Restore;
29
30impl Drop for Restore {
31 fn drop(&mut self) {
32 let _ = disable_raw_mode();
33 let _ = execute!(io::stderr(), LeaveAlternateScreen);
34 }
35}
36
37pub fn pick(root: PathBuf) -> Res<Option<PathBuf>> {
39 let mut app = App::new(root);
40 enable_raw_mode()?;
41 let _restore = Restore;
44 execute!(io::stderr(), EnterAlternateScreen)?;
45 event_loop(
46 &mut Terminal::new(CrosstermBackend::new(io::stderr()))?,
47 &mut app,
48 )
49}
50
51const TICK: Duration = Duration::from_millis(80);
54
55const PICK_WAIT: Duration = Duration::from_secs(5);
58
59fn event_loop(
60 term: &mut Terminal<CrosstermBackend<io::Stderr>>,
61 app: &mut App,
62) -> Res<Option<PathBuf>> {
63 let mut dirty = true;
64 loop {
65 if dirty {
66 term.draw(|f| view::draw(f, app))?;
67 dirty = false;
68 }
69 if event::poll(TICK)? {
71 if let Event::Key(k) = event::read()? {
72 if k.kind == KeyEventKind::Press {
73 match on_key(app, k.code) {
74 Some(Action::Quit) => return Ok(None),
75 Some(Action::Pick(p)) => return Ok(Some(p)),
76 None => dirty = true,
77 }
78 }
79 }
80 }
81 if let Some(done) = app.search.take_fresh() {
82 app.apply(done);
83 dirty = true;
84 }
85 }
86}
87
88pub(crate) fn on_key(app: &mut App, code: KeyCode) -> Option<Action> {
89 match app.mode {
90 Mode::Tree => on_tree_key(app, code),
91 _ => on_search_key(app, code),
92 }
93}
94
95fn on_search_key(app: &mut App, code: KeyCode) -> Option<Action> {
98 let (Mode::Find(q) | Mode::Grep(q)) = &app.mode else {
99 return None;
100 };
101 let grep = matches!(app.mode, Mode::Grep(_));
102 let mut q = q.clone();
103 match code {
104 KeyCode::Char(c) => q.push(c),
105 KeyCode::Backspace => {
106 q.pop();
107 }
108 KeyCode::Esc => {
109 app.back_to_tree();
110 return None;
111 }
112 KeyCode::Enter => {
113 if app.search.pending() {
116 if let Some(d) = app.search.wait(PICK_WAIT) {
117 app.apply(d);
118 }
119 }
120 return Some(Action::Pick(app.target()));
121 }
122 KeyCode::Down | KeyCode::Tab => {
123 app.move_by(1);
124 return None;
125 }
126 KeyCode::Up => {
127 app.move_by(-1);
128 return None;
129 }
130 _ => return None,
131 }
132 app.mode = if grep { Mode::Grep(q) } else { Mode::Find(q) };
133 let (Mode::Find(q) | Mode::Grep(q)) = &app.mode else {
135 return None;
136 };
137 let q = q.clone();
138 app.request_search(&q);
139 None
140}
141
142fn on_tree_key(app: &mut App, code: KeyCode) -> Option<Action> {
143 match code {
144 KeyCode::Char('q') | KeyCode::Esc => return Some(Action::Quit),
145 KeyCode::Enter => return Some(Action::Pick(app.target())),
146 KeyCode::Char('j') | KeyCode::Down => app.move_by(1),
147 KeyCode::Char('k') | KeyCode::Up => app.move_by(-1),
148 KeyCode::Char('g') => app.sel = 0,
149 KeyCode::Char('G') => app.sel = app.len().saturating_sub(1),
150 KeyCode::Char('l') | KeyCode::Right | KeyCode::Tab | KeyCode::Char(' ') => app.enter_dir(),
151 KeyCode::Char('h') | KeyCode::Left => app.leave_dir(),
152 KeyCode::Char('.') => app.toggle_hidden(),
153 KeyCode::Char('/') => app.mode = Mode::Find(String::new()),
154 KeyCode::Char('s') => app.mode = Mode::Grep(String::new()),
155 _ => {}
156 }
157 None
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163
164 fn app() -> App {
165 App::new(PathBuf::from(env!("CARGO_MANIFEST_DIR")))
166 }
167
168 #[test]
169 fn q_and_esc_quit_without_choosing() {
170 assert_eq!(on_key(&mut app(), KeyCode::Char('q')), Some(Action::Quit));
171 assert_eq!(on_key(&mut app(), KeyCode::Esc), Some(Action::Quit));
172 }
173
174 #[test]
176 fn enter_picks_a_directory_and_ends_the_loop() {
177 let mut app = app();
178 if let Some(Action::Pick(p)) = on_key(&mut app, KeyCode::Enter) {
179 assert!(p.is_dir(), "{p:?}");
180 } else {
181 panic!("Enter must pick");
182 };
183 }
184
185 #[test]
186 fn l_enters_a_directory_and_h_steps_back_out() {
187 let mut app = app();
188 let root = app.tree.root.clone();
189 app.sel = app
190 .tree
191 .entries
192 .iter()
193 .position(|e| e.is_dir)
194 .expect("src/ exists");
195 let child = app.tree.entries[app.sel].path.clone();
196
197 assert_eq!(on_key(&mut app, KeyCode::Char('l')), None);
198 assert_eq!(app.tree.root, child);
199
200 assert_eq!(on_key(&mut app, KeyCode::Char('h')), None);
201 assert_eq!(app.tree.root, root);
202 assert_eq!(
203 app.tree.entries[app.sel].path, child,
204 "cursor should stay on src/"
205 );
206 }
207
208 #[test]
209 fn slash_and_s_enter_the_two_search_modes() {
210 let mut app = app();
211 on_key(&mut app, KeyCode::Char('/'));
212 assert!(matches!(app.mode, Mode::Find(ref q) if q.is_empty()));
213
214 app.back_to_tree();
215 on_key(&mut app, KeyCode::Char('s'));
216 assert!(matches!(app.mode, Mode::Grep(ref q) if q.is_empty()));
217 }
218
219 #[test]
221 fn letters_type_into_the_query_rather_than_acting() {
222 let mut app = app();
223 on_key(&mut app, KeyCode::Char('/'));
224 for c in ['q', '.', 's'] {
225 assert_eq!(on_key(&mut app, KeyCode::Char(c)), None);
226 }
227 assert!(matches!(app.mode, Mode::Find(ref q) if q == "q.s"));
228
229 on_key(&mut app, KeyCode::Backspace);
230 assert!(matches!(app.mode, Mode::Find(ref q) if q == "q."));
231 }
232
233 #[test]
234 fn esc_leaves_search_and_returns_to_the_tree() {
235 let mut app = app();
236 on_key(&mut app, KeyCode::Char('/'));
237 on_key(&mut app, KeyCode::Char('l'));
238 assert_eq!(on_key(&mut app, KeyCode::Esc), None);
239 assert!(matches!(app.mode, Mode::Tree));
240 assert!(app.hits.is_empty());
241 }
242
243 #[test]
246 fn enter_on_a_file_hit_picks_its_directory() {
247 let mut app = app();
248 on_key(&mut app, KeyCode::Char('/'));
249 for c in "lib.rs".chars() {
250 on_key(&mut app, KeyCode::Char(c));
251 }
252 app.settle(); assert!(!app.hits.is_empty(), "lib.rs must be found");
254 assert!(app.hits.iter().any(|p| p.is_file()));
255
256 if let Some(Action::Pick(p)) = on_key(&mut app, KeyCode::Enter) {
257 assert!(p.is_dir(), "{p:?}");
258 } else {
259 panic!("Enter must pick");
260 };
261 }
262
263 #[test]
266 fn typing_returns_immediately_and_results_land_later() {
267 let mut app = app();
268 on_key(&mut app, KeyCode::Char('/'));
269 for c in "lib".chars() {
270 assert_eq!(on_key(&mut app, KeyCode::Char(c)), None);
271 }
272 assert!(app.search.pending(), "the search should still be running");
273 assert!(app.hits.is_empty(), "hits must not be filled inline");
274
275 app.settle();
276 assert!(!app.search.pending());
277 assert!(!app.hits.is_empty());
278 }
279
280 #[test]
283 fn enter_before_the_results_land_still_picks_a_hit() {
284 let mut app = app();
285 on_key(&mut app, KeyCode::Char('/'));
286 for c in "lib.rs".chars() {
287 on_key(&mut app, KeyCode::Char(c));
288 }
289 assert!(app.search.pending(), "precondition: still searching");
290 assert!(app.hits.is_empty(), "precondition: nothing arrived yet");
291
292 if let Some(Action::Pick(p)) = on_key(&mut app, KeyCode::Enter) {
293 assert_eq!(p, app.tree.root.join("src"), "picked {p:?}");
294 assert_ne!(p, app.tree.root, "fell back to the root");
295 } else {
296 panic!("Enter must pick");
297 };
298 }
299
300 #[test]
302 fn clearing_the_query_empties_the_hits_at_once() {
303 let mut app = app();
304 on_key(&mut app, KeyCode::Char('/'));
305 on_key(&mut app, KeyCode::Char('l'));
306 app.settle();
307 assert!(!app.hits.is_empty());
308
309 on_key(&mut app, KeyCode::Backspace);
310 assert!(matches!(app.mode, Mode::Find(ref q) if q.is_empty()));
311 assert!(app.hits.is_empty(), "an empty query must show nothing");
312 assert!(!app.search.pending(), "nothing to wait for");
313 }
314
315 #[test]
316 fn dot_toggles_hidden_and_reloads() {
317 let mut app = app();
318 assert!(!app.tree.hidden);
319 on_key(&mut app, KeyCode::Char('.'));
320 assert!(app.tree.hidden);
321 on_key(&mut app, KeyCode::Char('.'));
322 assert!(!app.tree.hidden);
323 }
324
325 #[test]
326 fn g_and_shift_g_jump_to_the_ends() {
327 let mut app = app();
328 on_key(&mut app, KeyCode::Char('G'));
329 assert_eq!(app.sel, app.len() - 1);
330 on_key(&mut app, KeyCode::Char('g'));
331 assert_eq!(app.sel, 0);
332 }
333}