Skip to main content

cdt_view/
lib.rs

1//! The picker itself: state, view, key loop. Renders on stderr so stdout can
2//! carry the chosen path back to the calling shell.
3use std::io;
4use std::path::{Path, PathBuf};
5
6use cdt_tree::Tree;
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::*;
13use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
14
15type Res<T> = Result<T, Box<dyn std::error::Error>>;
16
17const HELP: &str = "j/k move  l expand  h up  / find  s grep  . hidden  Enter cd  q quit";
18
19enum Mode {
20    Tree,
21    Find(String),
22    Grep(String),
23}
24
25struct App {
26    tree: Tree,
27    hits: Vec<PathBuf>,
28    mode: Mode,
29    sel: usize,
30    msg: String,
31}
32
33impl App {
34    fn new(root: PathBuf) -> Self {
35        App {
36            tree: Tree::new(root, false),
37            hits: Vec::new(),
38            mode: Mode::Tree,
39            sel: 0,
40            msg: String::new(),
41        }
42    }
43
44    fn len(&self) -> usize {
45        match self.mode {
46            Mode::Tree => self.tree.len(),
47            _ => self.hits.len(),
48        }
49    }
50
51    fn selected(&self) -> Option<&Path> {
52        match self.mode {
53            Mode::Tree => self.tree.get(self.sel).map(|e| e.path.as_path()),
54            _ => self.hits.get(self.sel).map(PathBuf::as_path),
55        }
56    }
57
58    /// Directory to cd into: the entry itself if a dir, else its parent.
59    fn target(&self) -> PathBuf {
60        match self.selected() {
61            Some(p) if p.is_dir() => p.to_path_buf(),
62            Some(p) => p.parent().unwrap_or(&self.tree.root).to_path_buf(),
63            None => self.tree.root.clone(),
64        }
65    }
66
67    fn move_by(&mut self, d: isize) {
68        let n = self.len();
69        if n > 0 {
70            self.sel = (self.sel as isize + d).rem_euclid(n as isize) as usize;
71        }
72    }
73
74    fn back_to_tree(&mut self) {
75        self.mode = Mode::Tree;
76        self.hits.clear();
77        self.sel = 0;
78        self.msg.clear();
79    }
80
81    /// `h`: close an open dir, else step out to the parent, else re-root upward.
82    fn collapse_or_up(&mut self) {
83        match self.tree.get(self.sel) {
84            Some(e) if e.is_dir && e.open => self.tree.toggle(self.sel),
85            Some(_) if self.tree.parent_of(self.sel).is_some() => {
86                self.sel = self.tree.parent_of(self.sel).unwrap();
87            }
88            _ => {
89                if self.tree.up() {
90                    self.sel = 0;
91                }
92            }
93        }
94    }
95
96    fn search(&mut self, q: &str) {
97        self.sel = 0;
98        match self.mode {
99            Mode::Grep(_) => match cdt_search::grep(&self.tree.root, q, self.tree.hidden) {
100                Ok(hits) => self.hits = hits,
101                Err(e) => {
102                    self.hits.clear();
103                    self.msg = format!("rg unavailable: {e}");
104                }
105            },
106            _ => self.hits = cdt_search::find_names(&self.tree.root, q, self.tree.hidden),
107        }
108    }
109}
110
111fn draw(f: &mut Frame, app: &App) {
112    let [body, bar] = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(f.area());
113
114    let items: Vec<ListItem> = match app.mode {
115        Mode::Tree => app
116            .tree
117            .entries
118            .iter()
119            .map(|e| {
120                let icon = if !e.is_dir {
121                    "  "
122                } else if e.open {
123                    "▾ "
124                } else {
125                    "▸ "
126                };
127                let style = if e.is_dir {
128                    Style::new().fg(Color::Cyan).bold()
129                } else {
130                    Style::new()
131                };
132                ListItem::new(format!("{}{icon}{}", "  ".repeat(e.depth), e.name())).style(style)
133            })
134            .collect(),
135        _ => app
136            .hits
137            .iter()
138            .map(|p| ListItem::new(cdt_search::label(&app.tree.root, p)))
139            .collect(),
140    };
141
142    let mut state = ListState::default().with_selected(Some(app.sel));
143    f.render_stateful_widget(
144        List::new(items)
145            .block(
146                Block::new()
147                    .borders(Borders::ALL)
148                    .title(format!(" {} ", app.tree.root.display())),
149            )
150            .highlight_style(Style::new().reversed()),
151        body,
152        &mut state,
153    );
154
155    let status = match &app.mode {
156        _ if !app.msg.is_empty() => app.msg.clone(),
157        Mode::Find(q) => format!("find: {q}_  ({} hits)", app.hits.len()),
158        Mode::Grep(q) => format!("grep: {q}_  ({} files)", app.hits.len()),
159        Mode::Tree => HELP.into(),
160    };
161    f.render_widget(
162        Paragraph::new(status).style(Style::new().fg(Color::DarkGray)),
163        bar,
164    );
165}
166
167/// Run the picker. `Ok(None)` means the user quit without choosing.
168pub fn pick(root: PathBuf) -> Res<Option<PathBuf>> {
169    let mut app = App::new(root);
170    enable_raw_mode()?;
171    execute!(io::stderr(), EnterAlternateScreen)?;
172    let picked = event_loop(
173        &mut Terminal::new(CrosstermBackend::new(io::stderr()))?,
174        &mut app,
175    );
176    disable_raw_mode()?;
177    execute!(io::stderr(), LeaveAlternateScreen)?;
178    picked
179}
180
181fn event_loop(
182    term: &mut Terminal<CrosstermBackend<io::Stderr>>,
183    app: &mut App,
184) -> Res<Option<PathBuf>> {
185    loop {
186        term.draw(|f| draw(f, app))?;
187        let Event::Key(k) = event::read()? else {
188            continue;
189        };
190        if k.kind != KeyEventKind::Press {
191            continue;
192        }
193        if let Mode::Find(q) | Mode::Grep(q) = &app.mode {
194            let mut q = q.clone();
195            match k.code {
196                KeyCode::Char(c) => q.push(c),
197                KeyCode::Backspace => {
198                    q.pop();
199                }
200                KeyCode::Esc => {
201                    app.back_to_tree();
202                    continue;
203                }
204                KeyCode::Enter => return Ok(Some(app.target())),
205                KeyCode::Down | KeyCode::Tab => {
206                    app.move_by(1);
207                    continue;
208                }
209                KeyCode::Up => {
210                    app.move_by(-1);
211                    continue;
212                }
213                _ => continue,
214            }
215            app.msg.clear();
216            app.search(&q);
217            app.mode = match app.mode {
218                Mode::Grep(_) => Mode::Grep(q),
219                _ => Mode::Find(q),
220            };
221            continue;
222        }
223        match k.code {
224            KeyCode::Char('q') | KeyCode::Esc => return Ok(None),
225            KeyCode::Enter => return Ok(Some(app.target())),
226            KeyCode::Char('j') | KeyCode::Down => app.move_by(1),
227            KeyCode::Char('k') | KeyCode::Up => app.move_by(-1),
228            KeyCode::Char('g') => app.sel = 0,
229            KeyCode::Char('G') => app.sel = app.len().saturating_sub(1),
230            KeyCode::Char('l') | KeyCode::Right | KeyCode::Tab | KeyCode::Char(' ') => {
231                app.tree.toggle(app.sel)
232            }
233            KeyCode::Char('h') | KeyCode::Left => app.collapse_or_up(),
234            KeyCode::Char('.') => {
235                app.tree.hidden = !app.tree.hidden;
236                app.tree.reload();
237                app.sel = 0;
238            }
239            KeyCode::Char('/') => app.mode = Mode::Find(String::new()),
240            KeyCode::Char('s') => app.mode = Mode::Grep(String::new()),
241            _ => {}
242        }
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    #[test]
251    fn move_by_wraps_and_tolerates_an_empty_list() {
252        let mut app = App::new(PathBuf::from(env!("CARGO_MANIFEST_DIR")));
253        let n = app.len();
254        assert!(n > 0);
255        app.move_by(-1);
256        assert_eq!(app.sel, n - 1);
257        app.move_by(1);
258        assert_eq!(app.sel, 0);
259
260        app.mode = Mode::Find(String::new()); // hits is empty
261        app.move_by(1);
262        assert_eq!(app.sel, 0);
263    }
264
265    #[test]
266    fn target_of_a_file_is_its_directory() {
267        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
268        let mut app = App::new(root.clone());
269        app.mode = Mode::Find(String::new());
270        app.hits = vec![root.join("src/lib.rs")];
271        assert_eq!(app.target(), root.join("src"));
272    }
273}