Skip to main content

tess/overlay/
picker.rs

1//! File picker overlay. Lists every file in the working set, supports
2//! type-to-filter, Enter to open, Ctrl-D to drop.
3
4use std::borrow::Cow;
5use std::cell::Cell;
6
7use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
8
9use crate::file_set::FileSet;
10use crate::input::Command;
11use crate::overlay::{Overlay, OverlayContext, OverlayFrame, OverlayOutcome};
12
13pub struct FilePicker {
14    filter: String,
15    cursor: usize,              // index into `visible`
16    visible: Vec<usize>,        // indices into FileSet
17    rows_offset: Cell<usize>,   // first visible row when list overflows screen (interior-mutable so render stays &self)
18    /// Snapshot of each file's last-known top line, indexed parallel to FileSet.
19    /// (Captured at open time and passed to the picker by the caller.)
20    saved_lines: Vec<usize>,
21    /// Path display strings, parallel to FileSet indices, captured at open.
22    paths: Vec<String>,
23    /// Index of the current file in the FileSet at open time (for the
24    /// "← current" annotation).
25    current_index: usize,
26}
27
28impl FilePicker {
29    pub fn new(file_set: &FileSet, saved_lines: Vec<usize>) -> Self {
30        let paths: Vec<String> = (0..file_set.len())
31            .map(|i| file_set.nth(i).map(|p| p.display().to_string()).unwrap_or_default())
32            .collect();
33        let visible: Vec<usize> = (0..file_set.len()).collect();
34        let cursor = file_set.current_index().min(visible.len().saturating_sub(1));
35        Self {
36            filter: String::new(),
37            cursor,
38            visible,
39            rows_offset: Cell::new(0),
40            saved_lines,
41            paths,
42            current_index: file_set.current_index(),
43        }
44    }
45
46    fn recompute_visible(&mut self) {
47        let needle = self.filter.to_lowercase();
48        if needle.is_empty() {
49            self.visible = (0..self.paths.len()).collect();
50        } else {
51            self.visible = (0..self.paths.len())
52                .filter(|&i| self.paths[i].to_lowercase().contains(&needle))
53                .collect();
54        }
55        if self.cursor >= self.visible.len() {
56            self.cursor = self.visible.len().saturating_sub(1);
57        }
58        self.rows_offset.set(0);
59    }
60}
61
62impl Overlay for FilePicker {
63    fn handle_key(&mut self, key: KeyEvent) -> OverlayOutcome {
64        // Ctrl-D: remove highlighted file
65        if key.code == KeyCode::Char('d') && key.modifiers.contains(KeyModifiers::CONTROL) {
66            // Guard on global set count, not the filtered view — :d's semantics.
67            if self.paths.len() <= 1 {
68                return OverlayOutcome::Refuse("can't remove last file");
69            }
70            let target = match self.visible.get(self.cursor) {
71                Some(&t) => t,
72                None => return OverlayOutcome::Stay,
73            };
74            return OverlayOutcome::Apply(Command::DropFileAt(target));
75        }
76        match (key.code, key.modifiers) {
77            (KeyCode::Esc, _) => {
78                if self.filter.is_empty() {
79                    OverlayOutcome::Close
80                } else {
81                    self.filter.clear();
82                    self.recompute_visible();
83                    OverlayOutcome::Stay
84                }
85            }
86            (KeyCode::Up, _) => {
87                self.cursor = self.cursor.saturating_sub(1);
88                OverlayOutcome::Stay
89            }
90            // j/k vim keys require NO modifiers — Shift+k must fall through to
91            // the filter so users can type uppercase letters into the search.
92            (KeyCode::Char('k'), m) if m == KeyModifiers::NONE => {
93                self.cursor = self.cursor.saturating_sub(1);
94                OverlayOutcome::Stay
95            }
96            (KeyCode::Down, _) => {
97                if self.cursor + 1 < self.visible.len() {
98                    self.cursor += 1;
99                }
100                OverlayOutcome::Stay
101            }
102            (KeyCode::Char('j'), m) if m == KeyModifiers::NONE => {
103                if self.cursor + 1 < self.visible.len() {
104                    self.cursor += 1;
105                }
106                OverlayOutcome::Stay
107            }
108            (KeyCode::PageUp, _) => {
109                self.cursor = self.cursor.saturating_sub(10);
110                OverlayOutcome::Stay
111            }
112            (KeyCode::PageDown, _) => {
113                self.cursor = (self.cursor + 10).min(self.visible.len().saturating_sub(1));
114                OverlayOutcome::Stay
115            }
116            (KeyCode::Home, _) => { self.cursor = 0; OverlayOutcome::Stay }
117            (KeyCode::End, _)  => {
118                self.cursor = self.visible.len().saturating_sub(1);
119                OverlayOutcome::Stay
120            }
121            (KeyCode::Enter, _) => {
122                match self.visible.get(self.cursor) {
123                    Some(&i) => OverlayOutcome::CloseAnd(Command::SelectFile(i)),
124                    None => OverlayOutcome::Stay,
125                }
126            }
127            (KeyCode::Backspace, _) => {
128                self.filter.pop();
129                self.recompute_visible();
130                OverlayOutcome::Stay
131            }
132            (KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) && !m.contains(KeyModifiers::ALT) => {
133                self.filter.push(c);
134                self.recompute_visible();
135                OverlayOutcome::Stay
136            }
137            _ => OverlayOutcome::Stay,
138        }
139    }
140
141    fn handle_mouse(&mut self, ev: crossterm::event::MouseEvent, _body_rows: u16) -> OverlayOutcome {
142        use crossterm::event::{MouseButton, MouseEventKind};
143        match ev.kind {
144            MouseEventKind::ScrollDown => {
145                if self.cursor + 1 < self.visible.len() {
146                    self.cursor += 1;
147                }
148                OverlayOutcome::Stay
149            }
150            MouseEventKind::ScrollUp => {
151                self.cursor = self.cursor.saturating_sub(1);
152                OverlayOutcome::Stay
153            }
154            MouseEventKind::Down(MouseButton::Left) => {
155                // Body layout: row 0 title, row 1 blank, row 2+ entries.
156                // rows_offset accounts for how far the list has been scrolled.
157                let row = ev.row as usize;
158                if row < 2 { return OverlayOutcome::Stay; }
159                let visible_idx = row - 2 + self.rows_offset.get();
160                if visible_idx >= self.visible.len() { return OverlayOutcome::Stay; }
161                self.cursor = visible_idx;
162                OverlayOutcome::CloseAnd(Command::SelectFile(self.visible[self.cursor]))
163            }
164            _ => OverlayOutcome::Stay,
165        }
166    }
167
168    fn render(&self, width: u16, height: u16) -> OverlayFrame {
169        let mut body = Vec::with_capacity(height as usize);
170        // Row 0: title
171        let title = if self.filter.is_empty() {
172            format!("Files ({})", self.visible.len())
173        } else {
174            format!(
175                "Files ({} of {} matching \"{}\")",
176                self.visible.len(), self.paths.len(), self.filter,
177            )
178        };
179        body.push(title);
180        body.push(String::new());
181
182        // Compute the column width for filenames.
183        let name_col = self.visible.iter()
184            .map(|&i| self.paths[i].chars().count())
185            .max()
186            .unwrap_or(0)
187            .min(width.saturating_sub(20) as usize);
188
189        // Adjust rows_offset to keep cursor visible (stable: only move when
190        // cursor goes off-screen, not pinned to bottom on every render).
191        let visible_rows = (height as usize).saturating_sub(3); // title + blank + status
192        let mut offset = self.rows_offset.get();
193        if visible_rows > 0 {
194            if self.cursor < offset {
195                // Cursor went off the top: scroll up to put it at the top.
196                offset = self.cursor;
197            } else if self.cursor >= offset + visible_rows {
198                // Cursor went off the bottom: scroll just enough to put it at the bottom.
199                offset = self.cursor + 1 - visible_rows;
200            }
201            // Otherwise: cursor is already visible; leave offset alone.
202        }
203        self.rows_offset.set(offset);
204
205        for (row, &i) in self.visible.iter().enumerate().skip(offset).take(visible_rows) {
206            let is_cursor = row == self.cursor;
207            let is_current = i == self.current_index;
208            let gutter = if is_cursor { ">" } else { " " };
209            let line_n = self.saved_lines.get(i).copied().unwrap_or(0).max(1);
210            let trailer = if is_current { "  \u{2190} current" } else { "" };
211            let path = &self.paths[i];
212            // Truncate paths that overflow the column so the L<line> field
213            // stays on-screen.
214            let path_display: String = if path.chars().count() > name_col && name_col > 0 {
215                let mut s: String = path.chars().take(name_col.saturating_sub(1)).collect();
216                s.push('\u{2026}'); // ellipsis …
217                s
218            } else {
219                path.clone()
220            };
221            body.push(format!(
222                "{gutter} {path_display:<name_col$}  L{line_n}{trailer}",
223            ));
224        }
225
226        let status = "[filter]  \u{2191}\u{2193} Enter  Ctrl-D remove  Esc".to_string();
227        OverlayFrame { body, status }
228    }
229
230    fn title(&self) -> Cow<'_, str> { Cow::Borrowed("Files") }
231
232    fn refresh(&mut self, ctx: OverlayContext) {
233        // Re-snapshot paths from the (possibly mutated) FileSet.
234        self.paths = (0..ctx.file_set.len())
235            .map(|i| ctx.file_set.nth(i).map(|p| p.display().to_string()).unwrap_or_default())
236            .collect();
237        // saved_lines is now stale for removed entries; trim if longer.
238        self.saved_lines.truncate(self.paths.len());
239        while self.saved_lines.len() < self.paths.len() {
240            self.saved_lines.push(0);
241        }
242        self.current_index = ctx.file_set.current_index();
243        self.recompute_visible();
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250    use crossterm::event::{KeyEvent as KE, MouseButton, MouseEvent, MouseEventKind};
251    use std::path::PathBuf;
252
253    fn fs(names: &[&str]) -> FileSet {
254        FileSet::new(names.iter().map(PathBuf::from).collect())
255    }
256
257    fn picker(names: &[&str]) -> FilePicker {
258        FilePicker::new(&fs(names), vec![0; names.len()])
259    }
260
261    fn key(code: KeyCode, mods: KeyModifiers) -> KE {
262        KE::new(code, mods)
263    }
264
265    #[test]
266    fn starts_with_cursor_on_current_file() {
267        let mut f = fs(&["a", "b", "c"]);
268        f.set_current_index(1);
269        let p = FilePicker::new(&f, vec![0, 0, 0]);
270        assert_eq!(p.cursor, 1);
271        assert_eq!(p.visible, vec![0, 1, 2]);
272    }
273
274    #[test]
275    fn down_arrow_moves_cursor() {
276        let mut p = picker(&["a", "b", "c"]);
277        assert!(matches!(p.handle_key(key(KeyCode::Down, KeyModifiers::NONE)), OverlayOutcome::Stay));
278        assert_eq!(p.cursor, 1);
279    }
280
281    #[test]
282    fn up_arrow_at_top_is_clamped() {
283        let mut p = picker(&["a", "b"]);
284        p.handle_key(key(KeyCode::Up, KeyModifiers::NONE));
285        assert_eq!(p.cursor, 0);
286    }
287
288    #[test]
289    fn typing_filters_visible_list() {
290        let mut p = picker(&["alpha", "beta", "alpine"]);
291        p.handle_key(key(KeyCode::Char('a'), KeyModifiers::NONE));
292        p.handle_key(key(KeyCode::Char('l'), KeyModifiers::NONE));
293        assert_eq!(p.filter, "al");
294        assert_eq!(p.visible, vec![0, 2]);
295    }
296
297    #[test]
298    fn filter_is_case_insensitive() {
299        let mut p = picker(&["Alpha", "beta", "ALPINE"]);
300        p.handle_key(key(KeyCode::Char('a'), KeyModifiers::NONE));
301        p.handle_key(key(KeyCode::Char('l'), KeyModifiers::NONE));
302        assert_eq!(p.visible, vec![0, 2]);
303    }
304
305    #[test]
306    fn backspace_trims_filter_and_restores_visibility() {
307        let mut p = picker(&["alpha", "uno"]);
308        p.handle_key(key(KeyCode::Char('a'), KeyModifiers::NONE));
309        assert_eq!(p.visible.len(), 1);
310        p.handle_key(key(KeyCode::Backspace, KeyModifiers::NONE));
311        assert_eq!(p.filter, "");
312        assert_eq!(p.visible, vec![0, 1]);
313    }
314
315    #[test]
316    fn esc_clears_filter_first_then_closes() {
317        let mut p = picker(&["a", "b"]);
318        p.handle_key(key(KeyCode::Char('a'), KeyModifiers::NONE));
319        let first = p.handle_key(key(KeyCode::Esc, KeyModifiers::NONE));
320        assert!(matches!(first, OverlayOutcome::Stay));
321        assert_eq!(p.filter, "");
322        let second = p.handle_key(key(KeyCode::Esc, KeyModifiers::NONE));
323        assert!(matches!(second, OverlayOutcome::Close));
324    }
325
326    #[test]
327    fn enter_emits_select_file_with_visible_index() {
328        let mut p = picker(&["a", "b", "c"]);
329        p.handle_key(key(KeyCode::Down, KeyModifiers::NONE)); // cursor=1
330        let out = p.handle_key(key(KeyCode::Enter, KeyModifiers::NONE));
331        match out {
332            OverlayOutcome::CloseAnd(Command::SelectFile(i)) => assert_eq!(i, 1),
333            other => panic!("expected SelectFile(1), got {other:?}"),
334        }
335    }
336
337    #[test]
338    fn ctrl_d_with_n_equals_1_refuses() {
339        let mut p = picker(&["only"]);
340        let out = p.handle_key(key(KeyCode::Char('d'), KeyModifiers::CONTROL));
341        assert!(matches!(out, OverlayOutcome::Refuse(_)));
342    }
343
344    #[test]
345    fn ctrl_d_with_n_gt_1_applies_drop() {
346        let mut p = picker(&["a", "b"]);
347        let out = p.handle_key(key(KeyCode::Char('d'), KeyModifiers::CONTROL));
348        match out {
349            OverlayOutcome::Apply(Command::DropFileAt(i)) => assert_eq!(i, 0),
350            other => panic!("expected Apply(DropFileAt(0)), got {other:?}"),
351        }
352    }
353
354    #[test]
355    fn cursor_clamped_when_filter_shrinks_visible() {
356        let mut p = picker(&["alpha", "beta", "gamma"]);
357        p.handle_key(key(KeyCode::End, KeyModifiers::NONE));  // cursor=2
358        p.handle_key(key(KeyCode::Char('b'), KeyModifiers::NONE)); // visible=[1]
359        assert_eq!(p.cursor, 0);
360    }
361
362    #[test]
363    fn filter_uses_substring_not_prefix() {
364        // 'log' should match files where 'log' appears anywhere in the path,
365        // not just at the start.
366        let mut p = picker(&["app.rs", "build.log", "src/logger.rs"]);
367        p.handle_key(key(KeyCode::Char('l'), KeyModifiers::NONE));
368        p.handle_key(key(KeyCode::Char('o'), KeyModifiers::NONE));
369        p.handle_key(key(KeyCode::Char('g'), KeyModifiers::NONE));
370        // build.log → contains "log" (substring) ✓
371        // src/logger.rs → contains "log" (substring) ✓
372        // app.rs → no match
373        assert_eq!(p.visible, vec![1, 2], "substring filter should match 'log' anywhere in path");
374    }
375
376    #[test]
377    fn enter_on_empty_visible_is_noop() {
378        // Spec testing-strategy explicitly calls out this case.
379        let mut p = picker(&["alpha", "beta"]);
380        // Filter to nothing.
381        p.handle_key(key(KeyCode::Char('z'), KeyModifiers::NONE));
382        assert!(p.visible.is_empty());
383        let out = p.handle_key(key(KeyCode::Enter, KeyModifiers::NONE));
384        assert!(matches!(out, OverlayOutcome::Stay));
385    }
386
387    #[test]
388    fn refresh_after_drop_rebuilds_visible() {
389        let mut fs = fs(&["a", "b", "c"]);
390        let mut p = FilePicker::new(&fs, vec![0, 0, 0]);
391        p.handle_key(key(KeyCode::Down, KeyModifiers::NONE)); // cursor=1
392        // Simulate the app dispatching DropFileAt(0): FileSet shrinks.
393        fs.delete_current().unwrap();
394        p.refresh(OverlayContext { file_set: &fs });
395        assert_eq!(p.paths.len(), 2);
396        assert!(p.cursor < p.paths.len());
397    }
398
399    #[test]
400    fn render_lists_all_files_with_position() {
401        let p = FilePicker::new(&fs(&["a.log", "b.log"]), vec![1, 42]);
402        let frame = p.render(80, 10);
403        assert_eq!(frame.body[0], "Files (2)");
404        // Row 0 = title; row 1 = blank; row 2 onward = entries.
405        assert!(frame.body.iter().any(|l| l.contains("a.log") && l.contains("L1")));
406        assert!(frame.body.iter().any(|l| l.contains("b.log") && l.contains("L42")));
407    }
408
409    #[test]
410    fn render_marks_current_with_arrow() {
411        let mut f = fs(&["a", "b"]);
412        f.set_current_index(1);
413        let p = FilePicker::new(&f, vec![0, 0]);
414        let frame = p.render(80, 10);
415        let current_line = frame.body.iter().find(|l| l.contains("b")).expect("b line");
416        assert!(current_line.contains("\u{2190} current"), "current marker missing: {current_line:?}");
417    }
418
419    #[test]
420    fn render_title_updates_when_filtering() {
421        let mut p = picker(&["alpha", "beta", "alpine"]);
422        p.handle_key(KE::new(KeyCode::Char('a'), KeyModifiers::NONE));
423        p.handle_key(KE::new(KeyCode::Char('l'), KeyModifiers::NONE));
424        let frame = p.render(80, 10);
425        assert_eq!(frame.body[0], "Files (2 of 3 matching \"al\")");
426    }
427
428    #[test]
429    fn render_status_shows_keybindings() {
430        let p = picker(&["a"]);
431        let frame = p.render(80, 10);
432        assert!(frame.status.contains("Enter"), "status missing Enter hint");
433        assert!(frame.status.contains("Ctrl-D"), "status missing Ctrl-D hint");
434        assert!(frame.status.contains("Esc"), "status missing Esc hint");
435    }
436
437    #[test]
438    fn scroll_offset_keeps_cursor_in_band_stably() {
439        // 20 files, terminal height 8 (visible_rows = 5).
440        let names: Vec<String> = (0..20).map(|n| format!("file_{n:02}")).collect();
441        let refs: Vec<&str> = names.iter().map(String::as_str).collect();
442        let mut p = picker(&refs);
443        // Cursor at 10 — well into the list.
444        for _ in 0..10 {
445            p.handle_key(KE::new(KeyCode::Down, KeyModifiers::NONE));
446        }
447        let _ = p.render(80, 8);   // visible_rows = 5
448        // After scrolling down: offset should put cursor on the bottom row.
449        // cursor = 10, visible_rows = 5 → offset = 6 (cursor at bottom of window).
450        assert_eq!(p.rows_offset.get(), 6);
451
452        // Now scroll up by 2 — cursor = 8. The window should NOT change because
453        // 8 is still inside [6, 6+5).
454        p.handle_key(KE::new(KeyCode::Up, KeyModifiers::NONE));
455        p.handle_key(KE::new(KeyCode::Up, KeyModifiers::NONE));
456        let _ = p.render(80, 8);
457        assert_eq!(p.rows_offset.get(), 6, "window should be stable while cursor is in band");
458
459        // Scroll up enough to go off-screen — cursor below offset.
460        for _ in 0..5 {
461            p.handle_key(KE::new(KeyCode::Up, KeyModifiers::NONE));
462        }
463        // cursor = 3, offset was 6 → new offset = 3.
464        let _ = p.render(80, 8);
465        assert_eq!(p.rows_offset.get(), 3);
466    }
467
468    #[test]
469    fn long_paths_are_truncated_with_ellipsis() {
470        // Make one path that exceeds the column.
471        let p = FilePicker::new(
472            &fs(&["short.rs", "very/long/nested/path/to/some_module.rs"]),
473            vec![0, 0],
474        );
475        // Narrow terminal: 40 cols → name_col = 20 max.
476        let frame = p.render(40, 10);
477        let long_row = frame.body.iter().find(|l| l.contains('\u{2026}')).expect("ellipsis row");
478        // Should contain ellipsis, and the L<line> column should still be visible.
479        assert!(long_row.contains("L1"), "L<line> column should still be visible: {long_row:?}");
480    }
481
482    fn mouse(kind: MouseEventKind, row: u16) -> MouseEvent {
483        MouseEvent { kind, column: 0, row, modifiers: KeyModifiers::NONE }
484    }
485
486    #[test]
487    fn left_click_sets_cursor_and_selects() {
488        // body layout: row 0 = title, row 1 = blank, row 2 = first entry,
489        // row 3 = second entry, ...
490        let mut p = picker(&["a", "b", "c"]);
491        let out = p.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 3), 10);
492        match out {
493            OverlayOutcome::CloseAnd(Command::SelectFile(i)) => assert_eq!(i, 1),
494            other => panic!("expected SelectFile(1), got {other:?}"),
495        }
496    }
497
498    #[test]
499    fn scrollwheel_moves_cursor() {
500        let mut p = picker(&["a", "b", "c"]);
501        p.handle_mouse(mouse(MouseEventKind::ScrollDown, 0), 10);
502        assert_eq!(p.cursor, 1);
503        p.handle_mouse(mouse(MouseEventKind::ScrollUp, 0), 10);
504        assert_eq!(p.cursor, 0);
505    }
506}