tiny_dc/
app.rs

1use std::{
2    env, fmt,
3    ops::Deref,
4    path::{Path, PathBuf},
5    time::{Duration, Instant},
6};
7
8use anyhow::Ok;
9use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
10use ratatui::{prelude::*, widgets::*};
11use symbols::border;
12
13use crate::{
14    entry::{EntryKind, EntryList, EntryRenderData},
15    hotkeys::{HotkeysRegistry, KeyCombo, PREFERRED_KEY_COMBOS_IN_ORDER},
16    index::DirectoryIndex,
17};
18
19/// Enum representing whether the system is currently showing a directory listing or paths from the
20/// database.
21#[derive(Debug, Clone, Copy, PartialEq, Default)]
22pub enum ListMode {
23    /// The system is currently showing a directory listing.
24    #[default]
25    Directory,
26    // TODO: Implement this mode
27    /// The system is currently showing paths from the database that have been accessed frequently
28    /// and recently.
29    #[allow(dead_code)]
30    Frecent,
31    // TODO: Implement this mode
32    // /// The system is currently showing the user's bookmarks.
33    // #[allow(dead_code)]
34    // Bookmark,
35}
36
37#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
38pub enum InputMode {
39    Normal,
40    Search,
41}
42
43#[derive(Debug, Clone, Copy)]
44pub enum Action {
45    // Traverse the list
46    SelectNext,
47    SelectPrevious,
48    SelectFirst,
49    SelectLast,
50    ChangeDirectoryToSelectedEntry,
51    ChangeDirectoryToParent,
52    ChangeDirectoryToEntryWithIndex(usize),
53
54    // Change the list mode
55    SwitchToListMode(ListMode),
56
57    // Change Input Mode
58    SwitchToInputMode(InputMode),
59
60    // Search Actions
61    ResetSearchInput,
62    ExitSearchInput,
63    SearchInputBackspace,
64
65    ToggleHelp,
66    Exit,
67}
68
69/// The main application struct, will hold the state of the application.
70#[derive(Debug)]
71pub struct App {
72    /// A boolean used to signal if the app should exit
73    should_exit: bool,
74
75    /// The current mode of the list
76    list_mode: ListMode,
77
78    /// A list representing the entries in the current working directory
79    entry_list: EntryList,
80
81    /// The list state, used to keep track of the selected item
82    list_state: ListState,
83
84    /// The current directory that the user is in
85    current_directory: PathBuf,
86
87    /// A boolean used to signal if the help popup should be shown
88    show_help: bool,
89
90    /// Current input mode
91    input_mode: InputMode,
92
93    /// The search input
94    search_input: SearchInput,
95
96    /// The cursor position
97    cursor_position: Option<(u16, u16)>,
98
99    /// The buffer of user collected keycodes
100    collected_key_combos: Vec<KeyCombo>,
101
102    /// The last time a key was pressed, this is used to determine when to reset the key sequence
103    last_key_press_time: Option<Instant>,
104
105    /// The hotkeys registry, used to store system and entry hotkeys as well as register new ones
106    /// and assign dynamically shortcuts to entries
107    hotkeys_registry: HotkeysRegistry<InputMode, Action>,
108
109    /// The path to the directory index file
110    directory_index: DirectoryIndex,
111}
112
113/// The search input struct, used to store the search input value and the current index.
114#[derive(Debug, Default)]
115pub struct SearchInput {
116    /// The search input value
117    value: String,
118
119    /// Search input character index
120    index: usize,
121}
122
123impl SearchInput {
124    pub fn clear(&mut self) {
125        self.value.clear();
126        self.index = 0;
127    }
128
129    pub fn push(&mut self, c: char) {
130        self.value.push(c);
131        self.index += 1;
132    }
133
134    pub fn pop(&mut self) {
135        self.value.pop();
136        self.index -= 1;
137    }
138}
139
140impl Deref for SearchInput {
141    type Target = String;
142
143    fn deref(&self) -> &Self::Target {
144        &self.value
145    }
146}
147
148impl AsRef<str> for SearchInput {
149    fn as_ref(&self) -> &str {
150        &self.value
151    }
152}
153
154impl fmt::Display for SearchInput {
155    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
156        write!(f, "{}", self.value)
157    }
158}
159
160impl Default for App {
161    fn default() -> Self {
162        Self {
163            should_exit: false,
164            list_mode: ListMode::Directory,
165            entry_list: EntryList::default(),
166            list_state: ListState::default(),
167            current_directory: PathBuf::new(),
168            show_help: false,
169            input_mode: InputMode::Normal,
170            search_input: SearchInput::default(),
171            cursor_position: None,
172            collected_key_combos: Vec::new(),
173            last_key_press_time: None,
174            hotkeys_registry: HotkeysRegistry::new_with_default_system_hotkeys(),
175            directory_index: DirectoryIndex::default(),
176        }
177    }
178}
179
180impl App {
181    /// This timeout is used to determine when a key sequence should be reset due to inactivity.
182    const INACTIVITY_TIMEOUT: Duration = Duration::from_millis(500);
183
184    /// Tries to create a new instance of the application in a given list mode.
185    pub fn try_new(mode: ListMode, directory_index: DirectoryIndex) -> anyhow::Result<Self> {
186        let path = env::current_dir()?;
187
188        match mode {
189            ListMode::Directory => {
190                let mut app = App {
191                    directory_index,
192                    ..Default::default()
193                };
194                app.change_directory(path)?;
195                Ok(app)
196            }
197            ListMode::Frecent => {
198                let mut app = App {
199                    directory_index,
200                    list_mode: ListMode::Frecent,
201                    ..Default::default()
202                };
203                app.change_list_mode(ListMode::Frecent)?;
204                Ok(app)
205            }
206        }
207    }
208
209    /// Changes the current directory and sorts the entries in the new directory.
210    pub fn change_directory<T: AsRef<Path>>(&mut self, path: T) -> anyhow::Result<()> {
211        let entries = std::fs::read_dir(path.as_ref())?;
212        let mut entry_list = EntryList::try_from(entries)?;
213
214        entry_list.items.sort_by(|a, b| {
215            match (&a.kind, &b.kind) {
216                (EntryKind::Directory, EntryKind::Directory)
217                | (EntryKind::File { .. }, EntryKind::File { .. }) => a
218                    .name
219                    .to_lowercase()
220                    .partial_cmp(&b.name.to_lowercase())
221                    .unwrap(),
222                // Otherwise, put folders first
223                (EntryKind::Directory, EntryKind::File { .. }) => std::cmp::Ordering::Less,
224                (EntryKind::File { .. }, EntryKind::Directory) => std::cmp::Ordering::Greater,
225            }
226        });
227
228        self.list_state = ListState::default();
229        self.should_exit = false;
230        self.list_mode = ListMode::Directory;
231        self.entry_list = entry_list;
232        self.current_directory = path.as_ref().to_path_buf();
233        self.search_input.clear();
234
235        Ok(())
236    }
237
238    pub fn change_to_frecent(&mut self) -> anyhow::Result<()> {
239        let entries = self.directory_index.get_all_entries_ordered_by_rank();
240        let entry_list = EntryList::try_from(entries)?;
241
242        self.list_state = ListState::default();
243        self.should_exit = false;
244        self.list_mode = ListMode::Frecent;
245        self.entry_list = entry_list;
246        self.current_directory = env::current_dir()?;
247        self.search_input.clear();
248
249        Ok(())
250    }
251
252    fn change_list_mode(&mut self, mode: ListMode) -> anyhow::Result<()> {
253        if self.list_mode == mode {
254            return Ok(());
255        }
256
257        self.list_mode = mode;
258
259        match self.list_mode {
260            ListMode::Directory => self.change_directory(self.current_directory.clone()),
261            ListMode::Frecent => self.change_to_frecent(),
262        }
263    }
264
265    /// Runs the application's main loop until the user quits.
266    pub fn run<B: Backend>(&mut self, terminal: &mut Terminal<B>) -> anyhow::Result<PathBuf> {
267        while !self.should_exit {
268            terminal.draw(|frame| self.draw(frame))?;
269            self.handle_events()?;
270        }
271
272        Ok(self.current_directory.clone())
273    }
274
275    fn draw(&mut self, frame: &mut Frame) {
276        frame.render_widget(&mut *self, frame.area());
277
278        // After rendering, set the cursor position if needed
279        if let Some((x, y)) = self.cursor_position {
280            frame.set_cursor_position(Position::new(x, y));
281        }
282    }
283
284    fn render_help_popup(&self, buf: &mut Buffer) {
285        let size = buf.area();
286
287        // Define the popup area (e.g., centered and smaller than full screen)
288        let popup_area = Rect {
289            x: size.width / 4,
290            y: size.height / 4,
291            width: size.width / 2,
292            height: size.height / 2,
293        };
294
295        let block = Block::default()
296            .title(" Help ")
297            .title_style(Style::default().bold().fg(Color::Red))
298            .borders(Borders::ALL)
299            .border_type(BorderType::Plain);
300
301        let help_paragraph = Paragraph::new(Text::from(vec![
302            Line::from("Key Bindings:"),
303            Line::from(""),
304            Line::from(vec![
305                Span::styled("> j/k or ↓/↑", Style::default().fg(Color::Yellow)),
306                Span::raw(" - Move down/up"),
307            ]),
308            Line::from(vec![
309                Span::styled("> gg/G or Home/End", Style::default().fg(Color::Yellow)),
310                Span::raw(" - Go to top/bottom"),
311            ]),
312            Line::from(vec![
313                Span::styled("> Ctrl + d/f", Style::default().fg(Color::Yellow)),
314                Span::raw(" - Switch category (d)irectory or (f)recent"),
315            ]),
316            Line::from(vec![
317                Span::styled("> Enter, l or →", Style::default().fg(Color::Yellow)),
318                Span::raw(" - Go into directory"),
319            ]),
320            Line::from(vec![
321                Span::styled("> h or ←", Style::default().fg(Color::Yellow)),
322                Span::raw(" - Go up a directory"),
323            ]),
324            Line::from(vec![
325                Span::styled("> ?", Style::default().fg(Color::Yellow)),
326                Span::raw(" - Toggle help"),
327            ]),
328            Line::from(vec![
329                Span::styled("> q or Esc", Style::default().fg(Color::Yellow)),
330                Span::raw(" - Quit"),
331            ]),
332            Line::from(vec![
333                Span::styled("> /", Style::default().fg(Color::Yellow)),
334                Span::raw(" - Search"),
335            ]),
336            Line::from(vec![
337                Span::styled("> _", Style::default().fg(Color::Yellow)),
338                Span::raw(" - Reset search"),
339            ]),
340        ]))
341        .reset()
342        .block(block)
343        .wrap(Wrap { trim: true })
344        .alignment(Alignment::Left);
345
346        // Render the help popup in the buffer
347        help_paragraph.render(popup_area, buf);
348    }
349
350    /// Updates the application's state based on the user input.
351    fn handle_events(&mut self) -> anyhow::Result<()> {
352        match event::read()? {
353            // It's important to check that the event is a key press event as crossterm also emits
354            // key release and repeat events on Windows
355            Event::Key(key_event) if key_event.kind == KeyEventKind::Press => {
356                self.handle_key_event(key_event, key_event.modifiers)?
357            }
358            // Ignore the rest
359            _ => {}
360        }
361
362        Ok(())
363    }
364
365    fn change_directory_to_entry_index(&mut self, index: usize) -> anyhow::Result<()> {
366        let entries = self.entry_list.get_filtered_entries();
367        let selected_entry = entries.get(index);
368
369        if let Some(selected_entry) = selected_entry {
370            if selected_entry.kind == EntryKind::Directory {
371                self.change_directory(selected_entry.path.clone())?;
372            } else {
373                // The user has selected a file, exit
374                self.should_exit = true;
375            }
376        }
377
378        Ok(())
379    }
380
381    fn update_filtered_indices(&mut self) {
382        self.entry_list.update_filtered_indices(&self.search_input);
383        self.list_state = ListState::default();
384    }
385
386    /// Handles a key event with the given key and modifiers, it will perform the appropriate
387    /// action based on the current input mode and registered hotkeys.
388    pub fn handle_key_event(
389        &mut self,
390        key: KeyEvent,
391        modifiers: KeyModifiers,
392    ) -> anyhow::Result<()> {
393        if key.kind != KeyEventKind::Press {
394            return Ok(());
395        }
396
397        match self.input_mode {
398            InputMode::Search => self.handle_key_event_for_search_mode(key, modifiers),
399            InputMode::Normal => self.handle_key_event_for_normal_mode(key, modifiers),
400        }
401    }
402
403    fn handle_key_event_for_search_mode(
404        &mut self,
405        key: KeyEvent,
406        modifiers: KeyModifiers,
407    ) -> anyhow::Result<()> {
408        // We check for inactivity here so that we can support key sequences
409        if let Some(t) = self.last_key_press_time {
410            if t.elapsed() >= Self::INACTIVITY_TIMEOUT {
411                for key_combo in self.collected_key_combos.iter() {
412                    if let KeyCode::Char(c) = key_combo.key_code {
413                        self.search_input.push(c);
414                    }
415                }
416
417                if let KeyCode::Char(c) = key.code {
418                    self.search_input.push(c);
419                }
420
421                self.update_filtered_indices();
422                self.collected_key_combos.clear();
423                self.last_key_press_time = None;
424
425                return Ok(());
426            }
427        }
428
429        self.last_key_press_time = Some(Instant::now());
430
431        let key_combo = KeyCombo::from((key.code, modifiers));
432        self.collected_key_combos.push(key_combo);
433
434        let maybe_node = self
435            .hotkeys_registry
436            .get_hotkey_node(InputMode::Search, &self.collected_key_combos);
437
438        if let Some(node) = maybe_node {
439            if let Some(action) = node.value {
440                self.collected_key_combos.clear();
441                self.last_key_press_time = None;
442
443                match action {
444                    Action::ChangeDirectoryToEntryWithIndex(index) => {
445                        self.change_directory_to_entry_index(index)?;
446                        self.input_mode = InputMode::Normal;
447                        self.search_input.clear();
448                    }
449                    Action::SearchInputBackspace => {
450                        // Remove character from the search input
451                        if self.search_input.index > 0 {
452                            self.search_input.pop();
453                            self.update_filtered_indices();
454                        } else {
455                            // Exit search mode
456                            self.input_mode = InputMode::Normal;
457                        }
458                    }
459                    Action::SelectNext => {
460                        self.list_state.select_next();
461                    }
462                    Action::SelectPrevious => {
463                        self.list_state.select_previous();
464                    }
465                    Action::ExitSearchInput => {
466                        self.input_mode = InputMode::Normal;
467                    }
468                    Action::ChangeDirectoryToSelectedEntry => {
469                        if let Some(filtered_indices) = &self.entry_list.filtered_indices {
470                            if !filtered_indices.is_empty() {
471                                self.input_mode = InputMode::Normal;
472                                self.search_input.clear();
473                                let entry_index = self.list_state.selected().unwrap_or_default();
474                                self.change_directory_to_entry_index(entry_index)?;
475                            }
476                        }
477                    }
478                    _ => {}
479                }
480            }
481
482            return Ok(());
483        }
484
485        // We're at a point where the user has started a sequence, but the sequence didn't
486        // match with anything, in which case we should unroll the sequence into the search
487        // input
488        if self.collected_key_combos.len() > 1 {
489            for key_combo in self.collected_key_combos.iter() {
490                if let KeyCode::Char(c) = key_combo.key_code {
491                    self.search_input.push(c);
492                }
493            }
494        } else if let KeyCode::Char(c) = key.code {
495            self.search_input.push(c);
496        }
497
498        self.update_filtered_indices();
499        self.collected_key_combos.clear();
500        self.last_key_press_time = None;
501
502        Ok(())
503    }
504
505    fn handle_key_event_for_normal_mode(
506        &mut self,
507        key: KeyEvent,
508        modifiers: KeyModifiers,
509    ) -> anyhow::Result<()> {
510        // We check for inactivity here so that we can support key sequences
511        if let Some(t) = self.last_key_press_time {
512            if t.elapsed() >= Self::INACTIVITY_TIMEOUT {
513                self.collected_key_combos.clear();
514                self.last_key_press_time = None;
515            }
516        }
517
518        self.last_key_press_time = Some(Instant::now());
519
520        self.collected_key_combos
521            .push(KeyCombo::from((key.code, modifiers)));
522
523        let maybe_action = self
524            .hotkeys_registry
525            .get_hotkey_value(InputMode::Normal, &self.collected_key_combos);
526
527        let Some(&action) = maybe_action else {
528            return Ok(());
529        };
530
531        self.collected_key_combos.clear();
532        self.last_key_press_time = None;
533
534        match action {
535            Action::SelectNext => {
536                self.show_help = false;
537                self.list_state.select_next();
538            }
539            Action::SelectPrevious => {
540                self.show_help = false;
541                self.list_state.select_previous();
542            }
543            Action::SelectFirst => {
544                self.show_help = false;
545                self.list_state.select_first();
546            }
547            Action::SelectLast => {
548                self.show_help = false;
549                self.list_state.select_last();
550            }
551            Action::SwitchToListMode(mode) => {
552                self.show_help = false;
553                self.change_list_mode(mode)?;
554            }
555            Action::ToggleHelp => {
556                self.show_help = !self.show_help;
557            }
558            Action::SwitchToInputMode(mode) => {
559                self.show_help = false;
560                self.input_mode = mode;
561                self.search_input.clear();
562                self.update_filtered_indices();
563            }
564            Action::ResetSearchInput => {
565                // clear the search input while in search mode
566                self.search_input.clear();
567                self.update_filtered_indices();
568            }
569            Action::ChangeDirectoryToSelectedEntry => {
570                self.show_help = false;
571                let entry_index = self.list_state.selected().unwrap_or_default();
572                self.change_directory_to_entry_index(entry_index)?;
573            }
574            Action::ChangeDirectoryToParent => {
575                self.show_help = false;
576
577                if let Some(parent) = self.current_directory.clone().parent() {
578                    self.change_directory(parent)?;
579                }
580            }
581            Action::ChangeDirectoryToEntryWithIndex(index) => {
582                self.show_help = false;
583                self.change_directory_to_entry_index(index)?;
584            }
585            Action::Exit => {
586                if self.show_help {
587                    self.show_help = false;
588                } else if self.search_input.is_empty() {
589                    self.should_exit = true;
590                } else {
591                    self.search_input.clear();
592                    self.update_filtered_indices();
593                }
594            }
595            // Ignore the rest
596            _ => {}
597        }
598
599        Ok(())
600    }
601
602    pub fn get_sub_header_title(&self) -> String {
603        match &self.list_mode {
604            ListMode::Directory => self.current_directory.to_string_lossy().into_owned(),
605            ListMode::Frecent => "Most accessed paths".into(),
606        }
607    }
608
609    fn render_header(area: Rect, buf: &mut Buffer) {
610        let app_version = env!("CARGO_PKG_VERSION");
611
612        let line = Line::from(vec![
613            Span::styled("Tiny DC", Style::default().bold()),
614            Span::styled(format!(" v{}", app_version), Style::default().dark_gray()),
615        ]);
616
617        Paragraph::new(line).centered().render(area, buf);
618    }
619
620    fn render_selected_tab_title(&mut self, area: Rect, buf: &mut Buffer) {
621        let line = Line::from(vec![
622            Span::styled("|>", Style::default().dark_gray()),
623            Span::raw(" "),
624            Span::styled(self.get_sub_header_title(), Style::default().green()),
625        ]);
626
627        Paragraph::new(Text::from(vec![line])).render(area, buf);
628    }
629
630    fn render_footer(&mut self, area: Rect, buf: &mut Buffer) {
631        let input = format!(" /{input}", input = self.search_input);
632
633        if self.input_mode == InputMode::Search {
634            Paragraph::new(input)
635                .style(Style::default().fg(Color::Yellow))
636                .alignment(Alignment::Left)
637                .render(area, buf);
638
639            // Calculate the cursor poisition and account for the space and '/' characters
640            let cursor_x = area.x + 2 + self.search_input.index as u16;
641            let cursor_y = area.y;
642
643            self.cursor_position = Some((cursor_x, cursor_y));
644        } else {
645            if self.search_input.is_empty() {
646                let select_index = match self.list_mode {
647                    ListMode::Directory => 0,
648                    ListMode::Frecent => 1,
649                };
650
651                let block = Block::default().borders(Borders::NONE);
652                block.render(area, buf);
653
654                let chunks = Layout::default()
655                    .direction(Direction::Horizontal)
656                    .constraints(
657                        [
658                            Constraint::Length(6),
659                            Constraint::Min(1),
660                            Constraint::Length(16),
661                        ]
662                        .as_ref(),
663                    )
664                    .split(area);
665
666                Text::from(Span::styled(
667                    "Ctrl + ",
668                    Style::default().fg(Color::DarkGray),
669                ))
670                .alignment(Alignment::Left)
671                .render(chunks[0], buf);
672
673                Tabs::new(["(d)irectory", "(f)recent"])
674                    .highlight_style(Style::default().fg(Color::Green))
675                    .select(select_index)
676                    .render(chunks[1], buf);
677
678                Paragraph::new("Press ? for help ").render(chunks[2], buf);
679            } else {
680                Paragraph::new(input).left_aligned().render(area, buf);
681            }
682
683            self.cursor_position = None;
684        }
685    }
686
687    fn render_list(&mut self, area: Rect, buf: &mut Buffer) {
688        let block = Block::new()
689            .borders(Borders::ALL)
690            .border_set(border::THICK)
691            .border_style(Style::new().fg(Color::DarkGray));
692
693        let entries = self.entry_list.get_filtered_entries();
694
695        let mut entry_render_data: Vec<EntryRenderData> = entries
696            .into_iter()
697            .map(|x| EntryRenderData::from_entry(x, &self.search_input))
698            .collect();
699
700        if self.input_mode == InputMode::Normal
701            || (self.input_mode == InputMode::Search && !self.search_input.is_empty())
702        {
703            self.hotkeys_registry
704                .assign_hotkeys(&mut entry_render_data, &PREFERRED_KEY_COMBOS_IN_ORDER);
705        } else {
706            self.hotkeys_registry.clear_entry_hotkeys();
707        }
708
709        let items: Vec<ListItem> = entry_render_data.into_iter().map(ListItem::from).collect();
710
711        if items.is_empty() {
712            let empty_results_text = if self.search_input.is_empty() {
713                String::from("Nothing here but digital thumbleweeds.")
714            } else {
715                format!("No results found for '{query}'", query = self.search_input)
716            };
717
718            Paragraph::new(empty_results_text)
719                .block(block)
720                .render(area, buf);
721        } else {
722            // Create a List from all list items and highlight the currently selected one
723            let list = List::new(items)
724                .block(block)
725                .highlight_style(Style::new().bg(Color::Gray).fg(Color::Black))
726                .highlight_symbol(">")
727                .highlight_spacing(HighlightSpacing::Always);
728
729            // If no item is selected, preselect the first item
730            if self.list_state.selected().is_none() {
731                self.list_state.select_first();
732            }
733
734            // We need to disambiguate this trait method as both `Widget` and `StatefulWidget` share
735            // the same method name `render`.
736            StatefulWidget::render(list, area, buf, &mut self.list_state);
737        }
738    }
739}
740
741impl Widget for &mut App {
742    fn render(self, area: Rect, buf: &mut Buffer)
743    where
744        Self: Sized,
745    {
746        let [header_area, selected_tab_title_area, main_area, footer_area] = Layout::vertical([
747            Constraint::Length(1),
748            Constraint::Length(1),
749            Constraint::Fill(1),
750            Constraint::Length(1),
751        ])
752        .areas(area);
753
754        let [list_area] = Layout::vertical([Constraint::Fill(1)]).areas(main_area);
755
756        App::render_header(header_area, buf);
757
758        self.render_footer(footer_area, buf);
759        self.render_selected_tab_title(selected_tab_title_area, buf);
760        self.render_list(list_area, buf);
761
762        if self.show_help {
763            self.render_help_popup(buf);
764        }
765    }
766}
767
768#[cfg(test)]
769mod tests {
770    use crate::entry::Entry;
771
772    use super::*;
773
774    use insta::assert_snapshot;
775    use ratatui::{backend::TestBackend, Terminal};
776
777    fn create_test_app() -> App {
778        App {
779            current_directory: PathBuf::from("/home/user"),
780            list_mode: ListMode::Directory,
781            entry_list: EntryList {
782                items: vec![
783                    Entry {
784                        path: PathBuf::from("/home/user/.git/"),
785                        kind: EntryKind::Directory,
786                        name: ".git".into(),
787                    },
788                    Entry {
789                        path: PathBuf::from("/home/user/dir1/"),
790                        kind: EntryKind::Directory,
791                        name: "dir1".into(),
792                    },
793                    Entry {
794                        path: PathBuf::from("/home/user/.gitignore"),
795                        kind: EntryKind::File { extension: None },
796                        name: ".gitignore".into(),
797                    },
798                    Entry {
799                        path: PathBuf::from("/home/user/Cargo.toml"),
800                        kind: EntryKind::File {
801                            extension: Some("toml".into()),
802                        },
803                        name: "Cargo.toml".into(),
804                    },
805                ],
806                ..Default::default()
807            },
808            ..Default::default()
809        }
810    }
811
812    #[test]
813    fn renders_correctly() {
814        let mut app = create_test_app();
815        let mut terminal = Terminal::new(TestBackend::new(80, 9)).unwrap();
816
817        terminal
818            .draw(|frame| frame.render_widget(&mut app, frame.area()))
819            .unwrap();
820
821        assert_snapshot!(terminal.backend());
822    }
823
824    #[test]
825    fn renders_correctly_with_help_popup() {
826        let mut app = create_test_app();
827        app.show_help = true;
828
829        let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
830
831        terminal
832            .draw(|frame| frame.render_widget(&mut app, frame.area()))
833            .unwrap();
834
835        assert_snapshot!(terminal.backend());
836    }
837
838    #[test]
839    fn renders_correctly_with_help_popup_after_key_event() {
840        let mut app = create_test_app();
841        app.handle_key_event(KeyCode::Char('?').into(), KeyModifiers::NONE)
842            .unwrap();
843
844        let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
845
846        terminal
847            .draw(|frame| frame.render_widget(&mut app, frame.area()))
848            .unwrap();
849
850        assert_snapshot!(terminal.backend());
851    }
852
853    #[test]
854    fn renders_correctly_without_help_popup_after_key_event_toggle() {
855        let mut app = create_test_app();
856        app.show_help = true;
857        app.handle_key_event(KeyCode::Char('?').into(), KeyModifiers::NONE)
858            .unwrap();
859
860        let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
861
862        terminal
863            .draw(|frame| frame.render_widget(&mut app, frame.area()))
864            .unwrap();
865
866        assert_snapshot!(terminal.backend());
867    }
868
869    #[test]
870    fn renders_correctly_with_search_input_after_key_events() {
871        let mut app = create_test_app();
872        app.handle_key_event(KeyCode::Char('/').into(), KeyModifiers::NONE)
873            .unwrap();
874        app.handle_key_event(KeyCode::Char('t').into(), KeyModifiers::NONE)
875            .unwrap();
876        app.handle_key_event(KeyCode::Char('e').into(), KeyModifiers::NONE)
877            .unwrap();
878        app.handle_key_event(KeyCode::Char('s').into(), KeyModifiers::NONE)
879            .unwrap();
880        app.handle_key_event(KeyCode::Char('t').into(), KeyModifiers::NONE)
881            .unwrap();
882
883        let mut terminal = Terminal::new(TestBackend::new(80, 9)).unwrap();
884
885        terminal
886            .draw(|frame| frame.render_widget(&mut app, frame.area()))
887            .unwrap();
888
889        assert_snapshot!(terminal.backend());
890    }
891
892    #[test]
893    fn renders_correctly_with_search_input() {
894        let mut app = create_test_app();
895        app.input_mode = InputMode::Search;
896        app.search_input.value = "test".into();
897        app.search_input.index = 4;
898
899        let mut terminal = Terminal::new(TestBackend::new(80, 9)).unwrap();
900
901        terminal
902            .draw(|frame| frame.render_widget(&mut app, frame.area()))
903            .unwrap();
904
905        assert_snapshot!(terminal.backend());
906    }
907
908    #[test]
909    fn first_item_is_preselected_after_render() {
910        let mut app = create_test_app();
911        let mut buffer = Buffer::empty(Rect::new(0, 0, 79, 10));
912
913        assert_eq!(app.list_state.selected(), None);
914
915        app.render(buffer.area, &mut buffer);
916
917        assert_eq!(app.list_state.selected(), Some(0));
918    }
919
920    #[test]
921    fn handle_key_event() {
922        let mut app = create_test_app();
923
924        // Make sure we have 4 items
925        assert_eq!(app.entry_list.len(), 4);
926
927        let _ = app.handle_key_event(KeyCode::Char('q').into(), KeyModifiers::NONE);
928        assert!(app.should_exit);
929
930        let _ = app.handle_key_event(KeyCode::Esc.into(), KeyModifiers::NONE);
931        assert!(app.should_exit);
932
933        let _ = app.handle_key_event(KeyCode::Char('j').into(), KeyModifiers::NONE);
934        assert_eq!(app.list_state.selected(), Some(0));
935
936        let _ = app.handle_key_event(KeyCode::Down.into(), KeyModifiers::NONE);
937        assert_eq!(app.list_state.selected(), Some(1));
938
939        // press down so that we can go back up more than once
940        let _ = app.handle_key_event(KeyCode::Down.into(), KeyModifiers::NONE);
941
942        let _ = app.handle_key_event(KeyCode::Char('k').into(), KeyModifiers::NONE);
943        assert_eq!(app.list_state.selected(), Some(1));
944
945        let _ = app.handle_key_event(KeyCode::Up.into(), KeyModifiers::NONE);
946        assert_eq!(app.list_state.selected(), Some(0));
947
948        let _ = app.handle_key_event(KeyCode::Char('G').into(), KeyModifiers::SHIFT);
949        assert_eq!(app.list_state.selected(), Some(usize::MAX));
950
951        let _ = app.handle_key_event(KeyCode::Char('g').into(), KeyModifiers::NONE);
952        let _ = app.handle_key_event(KeyCode::Char('g').into(), KeyModifiers::NONE);
953        assert_eq!(app.list_state.selected(), Some(0));
954
955        let _ = app.handle_key_event(KeyCode::End.into(), KeyModifiers::NONE);
956        assert_eq!(app.list_state.selected(), Some(usize::MAX));
957
958        let _ = app.handle_key_event(KeyCode::Home.into(), KeyModifiers::NONE);
959        assert_eq!(app.list_state.selected(), Some(0));
960
961        let _ = app.handle_key_event(KeyCode::Char('d').into(), KeyModifiers::CONTROL);
962        assert_eq!(app.list_mode, ListMode::Directory);
963
964        let _ = app.handle_key_event(KeyCode::Char('f').into(), KeyModifiers::CONTROL);
965        assert_eq!(app.list_mode, ListMode::Frecent);
966
967        let _ = app.handle_key_event(KeyCode::Char('d').into(), KeyModifiers::CONTROL);
968        assert_eq!(app.list_mode, ListMode::Directory);
969
970        let _ = app.handle_key_event(KeyCode::Char('?').into(), KeyModifiers::NONE);
971        assert!(app.show_help);
972
973        let _ = app.handle_key_event(KeyCode::Char('/').into(), KeyModifiers::NONE);
974        assert_eq!(app.input_mode, InputMode::Search);
975
976        let _ = app.handle_key_event(KeyCode::Esc.into(), KeyModifiers::NONE);
977        assert_eq!(app.input_mode, InputMode::Normal);
978    }
979
980    #[test]
981    fn search_input_backspace() {
982        let mut app = create_test_app();
983        app.input_mode = InputMode::Search;
984        app.search_input.value = "test".into();
985        app.search_input.index = 4;
986
987        let _ = app.handle_key_event(KeyCode::Backspace.into(), KeyModifiers::NONE);
988        assert_eq!(app.search_input.value, "tes".to_string());
989        assert_eq!(app.search_input.index, 3);
990
991        let _ = app.handle_key_event(KeyCode::Backspace.into(), KeyModifiers::NONE);
992        assert_eq!(app.search_input.value, "te".to_string());
993        assert_eq!(app.search_input.index, 2);
994
995        let _ = app.handle_key_event(KeyCode::Backspace.into(), KeyModifiers::NONE);
996        assert_eq!(app.search_input.value, "t".to_string());
997        assert_eq!(app.search_input.index, 1);
998
999        let _ = app.handle_key_event(KeyCode::Backspace.into(), KeyModifiers::NONE);
1000        assert_eq!(app.search_input.value, "".to_string());
1001        assert_eq!(app.search_input.index, 0);
1002
1003        let _ = app.handle_key_event(KeyCode::Backspace.into(), KeyModifiers::NONE);
1004        assert_eq!(app.search_input.value, "".to_string());
1005        assert_eq!(app.search_input.index, 0);
1006    }
1007
1008    #[test]
1009    fn search_input_backspace_with_no_input() {
1010        let mut app = create_test_app();
1011        app.input_mode = InputMode::Search;
1012        app.search_input.value = "".into();
1013        app.search_input.index = 0;
1014
1015        let _ = app.handle_key_event(KeyCode::Backspace.into(), KeyModifiers::NONE);
1016        assert_eq!(app.input_mode, InputMode::Normal);
1017    }
1018
1019    #[test]
1020    fn search_works_correctly() {
1021        let mut app = create_test_app();
1022        app.input_mode = InputMode::Search;
1023
1024        let _ = app.handle_key_event(KeyCode::Char('g').into(), KeyModifiers::NONE);
1025        let _ = app.handle_key_event(KeyCode::Char('i').into(), KeyModifiers::NONE);
1026        let _ = app.handle_key_event(KeyCode::Char('t').into(), KeyModifiers::NONE);
1027
1028        assert_eq!(app.search_input.value, "git".to_string());
1029        assert_eq!(app.search_input.index, 3);
1030
1031        app.update_filtered_indices();
1032
1033        assert_eq!(app.entry_list.filtered_indices, Some(vec![0, 2]));
1034    }
1035
1036    #[test]
1037    fn search_renders_correctly() {
1038        let mut app = create_test_app();
1039        app.input_mode = InputMode::Search;
1040
1041        let _ = app.handle_key_event(KeyCode::Char('g').into(), KeyModifiers::NONE);
1042        let _ = app.handle_key_event(KeyCode::Char('i').into(), KeyModifiers::NONE);
1043        let _ = app.handle_key_event(KeyCode::Char('t').into(), KeyModifiers::NONE);
1044
1045        let mut terminal = Terminal::new(TestBackend::new(80, 9)).unwrap();
1046
1047        terminal
1048            .draw(|frame| frame.render_widget(&mut app, frame.area()))
1049            .unwrap();
1050
1051        assert_snapshot!(terminal.backend());
1052    }
1053}