Skip to main content

cdt_view/
run.rs

1//! Terminal lifecycle and the key loop. `on_key` holds the whole key table and
2//! touches no IO, so the bindings are testable without a terminal.
3use 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/// What a keypress asks the loop to do. `None` from `on_key` means "redraw".
19#[derive(Debug, PartialEq)]
20pub(crate) enum Action {
21    Quit,
22    Pick(PathBuf),
23}
24
25/// Puts the terminal back on the way out, on every path: a clean pick, an
26/// early `?`, or a panic unwinding through the loop. Without it a crash leaves
27/// the caller's shell in raw mode on the alternate screen.
28struct 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
37/// Run the picker. `Ok(None)` means the user quit without choosing.
38pub fn pick(root: PathBuf) -> Res<Option<PathBuf>> {
39    let mut app = App::new(root);
40    enable_raw_mode()?;
41    // Armed only once raw mode is actually on, so a failure above restores
42    // nothing it did not change.
43    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
51/// How long to wait for a key before looking for search results. Short enough
52/// that hits appear promptly, long enough to idle at no measurable cost.
53const TICK: Duration = Duration::from_millis(80);
54
55/// How long Enter will wait for a search that has not answered yet. Long
56/// enough for a big tree, short enough that a wedged `rg` still lets you out.
57const 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        // Poll rather than block: a search finishing is also a reason to redraw.
70        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
95/// In search mode every plain character is query text, so `q`, `/` and `.`
96/// type rather than act. Enter picks, Esc goes back to the tree.
97fn 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            // The result still in flight is the one being chosen. Waiting for
114            // it beats picking from an empty list, which would cd to the root.
115            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    // Mode is set first so the worker is told which backend the new query wants.
134    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    /// The core contract: Enter hands back a directory and ends the loop.
175    #[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    /// `q` must type into the query instead of quitting once searching.
220    #[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    /// A find hit for a file resolves to its directory, which is what makes
244    /// "Enter on a file" cd to the folder holding it.
245    #[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(); // the worker answers off the key loop now
253        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    /// The whole point of the worker: a keystroke returns before the search
264    /// does, so the UI never stalls on a large tree.
265    #[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    /// Enter pressed before the hits land must still pick the hit, not fall
281    /// back to the root because the list happened to be empty at that instant.
282    #[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    /// Backspacing the query away clears the list without a round trip.
301    #[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}