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#[derive(Debug, Clone, Copy, PartialEq, Default)]
22pub enum ListMode {
23 #[default]
25 Directory,
26 #[allow(dead_code)]
30 Frecent,
31 }
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 SelectNext,
47 SelectPrevious,
48 SelectFirst,
49 SelectLast,
50 ChangeDirectoryToSelectedEntry,
51 ChangeDirectoryToParent,
52 ChangeDirectoryToEntryWithIndex(usize),
53
54 SwitchToListMode(ListMode),
56
57 SwitchToInputMode(InputMode),
59
60 ResetSearchInput,
62 ExitSearchInput,
63 SearchInputBackspace,
64
65 ToggleHelp,
66 Exit,
67}
68
69#[derive(Debug)]
71pub struct App {
72 should_exit: bool,
74
75 list_mode: ListMode,
77
78 entry_list: EntryList,
80
81 list_state: ListState,
83
84 current_directory: PathBuf,
86
87 show_help: bool,
89
90 input_mode: InputMode,
92
93 search_input: SearchInput,
95
96 cursor_position: Option<(u16, u16)>,
98
99 collected_key_combos: Vec<KeyCombo>,
101
102 last_key_press_time: Option<Instant>,
104
105 hotkeys_registry: HotkeysRegistry<InputMode, Action>,
108
109 directory_index: DirectoryIndex,
111}
112
113#[derive(Debug, Default)]
115pub struct SearchInput {
116 value: String,
118
119 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 const INACTIVITY_TIMEOUT: Duration = Duration::from_millis(500);
183
184 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 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 (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 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 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 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 help_paragraph.render(popup_area, buf);
348 }
349
350 fn handle_events(&mut self) -> anyhow::Result<()> {
352 match event::read()? {
353 Event::Key(key_event) if key_event.kind == KeyEventKind::Press => {
356 self.handle_key_event(key_event, key_event.modifiers)?
357 }
358 _ => {}
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 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 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 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 if self.search_input.index > 0 {
452 self.search_input.pop();
453 self.update_filtered_indices();
454 } else {
455 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 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 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 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 _ => {}
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 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 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 self.list_state.selected().is_none() {
731 self.list_state.select_first();
732 }
733
734 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 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 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}