1use crate::key::{Binding, matches};
19use bubbletea::{Cmd, KeyMsg, Message, Model, WindowSizeMsg};
20use lipgloss::{Color, Style};
21use std::path::{Path, PathBuf};
22use std::sync::atomic::{AtomicU64, Ordering};
23
24static NEXT_ID: AtomicU64 = AtomicU64::new(1);
26
27fn next_id() -> u64 {
28 NEXT_ID.fetch_add(1, Ordering::Relaxed)
29}
30
31#[derive(Debug, Clone)]
33pub struct DirEntry {
34 pub name: String,
36 pub path: PathBuf,
38 pub is_dir: bool,
40 pub is_symlink: bool,
42 pub size: u64,
44 pub mode: String,
46}
47
48#[derive(Debug, Clone)]
50pub struct ReadDirMsg {
51 pub id: u64,
53 pub entries: Vec<DirEntry>,
55}
56
57#[derive(Debug, Clone)]
59pub struct ReadDirErrMsg {
60 pub id: u64,
62 pub error: String,
64}
65
66#[derive(Debug, Clone)]
68pub struct KeyMap {
69 pub goto_top: Binding,
71 pub goto_last: Binding,
73 pub down: Binding,
75 pub up: Binding,
77 pub page_up: Binding,
79 pub page_down: Binding,
81 pub back: Binding,
83 pub open: Binding,
85 pub select: Binding,
87}
88
89impl Default for KeyMap {
90 fn default() -> Self {
91 Self {
92 goto_top: Binding::new().keys(&["g"]).help("g", "first"),
93 goto_last: Binding::new().keys(&["G"]).help("G", "last"),
94 down: Binding::new()
95 .keys(&["j", "down", "ctrl+n"])
96 .help("j", "down"),
97 up: Binding::new().keys(&["k", "up", "ctrl+p"]).help("k", "up"),
98 page_up: Binding::new().keys(&["K", "pgup"]).help("pgup", "page up"),
99 page_down: Binding::new()
100 .keys(&["J", "pgdown"])
101 .help("pgdown", "page down"),
102 back: Binding::new()
103 .keys(&["h", "backspace", "left", "esc"])
104 .help("h", "back"),
105 open: Binding::new()
106 .keys(&["l", "right", "enter"])
107 .help("l", "open"),
108 select: Binding::new().keys(&["enter"]).help("enter", "select"),
109 }
110 }
111}
112
113#[derive(Debug, Clone)]
115pub struct Styles {
116 pub disabled_cursor: Style,
118 pub cursor: Style,
120 pub symlink: Style,
122 pub directory: Style,
124 pub file: Style,
126 pub disabled_file: Style,
128 pub permission: Style,
130 pub selected: Style,
132 pub disabled_selected: Style,
134 pub file_size: Style,
136 pub empty_directory: Style,
138}
139
140impl Default for Styles {
141 fn default() -> Self {
142 Self {
143 disabled_cursor: Style::new().foreground_color(Color::from("247")),
144 cursor: Style::new().foreground_color(Color::from("212")),
145 symlink: Style::new().foreground_color(Color::from("36")),
146 directory: Style::new().foreground_color(Color::from("99")),
147 file: Style::new(),
148 disabled_file: Style::new().foreground_color(Color::from("243")),
149 permission: Style::new().foreground_color(Color::from("244")),
150 selected: Style::new().foreground_color(Color::from("212")).bold(),
151 disabled_selected: Style::new().foreground_color(Color::from("247")),
152 file_size: Style::new().foreground_color(Color::from("240")),
153 empty_directory: Style::new().foreground_color(Color::from("240")),
154 }
155 }
156}
157
158#[derive(Debug, Clone)]
160pub struct FilePicker {
161 id: u64,
163 pub root: Option<PathBuf>,
165 pub path: Option<PathBuf>,
167 current_directory: PathBuf,
169 pub allowed_types: Vec<String>,
171 pub key_map: KeyMap,
173 files: Vec<DirEntry>,
175 pub show_permissions: bool,
177 pub show_size: bool,
179 pub show_hidden: bool,
181 pub dir_allowed: bool,
183 pub file_allowed: bool,
185 selected: usize,
187 selected_stack: Vec<usize>,
189 min: usize,
191 max: usize,
193 min_stack: Vec<usize>,
195 max_stack: Vec<usize>,
197 pub height: usize,
199 pub auto_height: bool,
201 pub cursor_char: String,
203 pub styles: Styles,
205}
206
207impl Default for FilePicker {
208 fn default() -> Self {
209 Self::new()
210 }
211}
212
213impl FilePicker {
214 #[must_use]
216 pub fn new() -> Self {
217 Self {
218 id: next_id(),
219 root: None,
220 path: None,
221 current_directory: PathBuf::from("."),
222 allowed_types: Vec::new(),
223 key_map: KeyMap::default(),
224 files: Vec::new(),
225 show_permissions: true,
226 show_size: true,
227 show_hidden: false,
228 dir_allowed: false,
229 file_allowed: true,
230 selected: 0,
231 selected_stack: Vec::new(),
232 min: 0,
233 max: 0,
234 min_stack: Vec::new(),
235 max_stack: Vec::new(),
236 height: 0,
237 auto_height: true,
238 cursor_char: ">".to_string(),
239 styles: Styles::default(),
240 }
241 }
242
243 #[must_use]
245 pub fn id(&self) -> u64 {
246 self.id
247 }
248
249 #[must_use]
251 pub fn current_directory(&self) -> &Path {
252 &self.current_directory
253 }
254
255 pub fn set_root(&mut self, root: impl AsRef<Path>) {
257 self.root = Some(root.as_ref().to_path_buf());
258 if let Some(root) = &self.root
260 && !self.current_directory.starts_with(root)
261 {
262 self.current_directory = root.clone();
263 }
264 }
265
266 pub fn set_current_directory(&mut self, path: impl AsRef<Path>) {
268 let path = path.as_ref();
269 if let Some(root) = &self.root
270 && !path.starts_with(root)
271 {
272 self.current_directory = root.clone();
274 return;
275 }
276 self.current_directory = path.to_path_buf();
277 }
278
279 pub fn set_height(&mut self, height: usize) {
281 self.height = height;
282 self.clamp_viewport();
283 }
284
285 pub fn set_allowed_types(&mut self, types: Vec<String>) {
287 self.allowed_types = types;
288 }
289
290 #[must_use]
292 pub fn selected_path(&self) -> Option<&Path> {
293 self.path.as_deref()
294 }
295
296 #[must_use]
298 pub fn highlighted_entry(&self) -> Option<&DirEntry> {
299 self.files.get(self.selected)
300 }
301
302 #[must_use]
304 pub fn init(&self) -> Option<Cmd> {
305 Some(self.read_dir_cmd())
306 }
307
308 fn read_dir_cmd(&self) -> Cmd {
310 let id = self.id;
311 let path = self.current_directory.clone();
312 let show_hidden = self.show_hidden;
313
314 Cmd::new(move || match read_directory(&path, show_hidden) {
315 Ok(entries) => Message::new(ReadDirMsg { id, entries }),
316 Err(e) => Message::new(ReadDirErrMsg {
317 id,
318 error: e.to_string(),
319 }),
320 })
321 }
322
323 fn can_select(&self, name: &str) -> bool {
325 if self.allowed_types.is_empty() {
326 return true;
327 }
328 self.allowed_types.iter().any(|ext| name.ends_with(ext))
329 }
330
331 fn is_selectable(&self, entry: &DirEntry) -> bool {
333 if entry.is_dir {
334 self.dir_allowed
335 } else {
336 self.file_allowed && self.can_select(&entry.name)
337 }
338 }
339
340 fn clamp_viewport(&mut self) {
342 let len = self.files.len();
343 if len == 0 {
344 self.selected = 0;
345 self.min = 0;
346 self.max = 0;
347 return;
348 }
349
350 if self.selected >= len {
351 self.selected = len.saturating_sub(1);
352 }
353
354 let height = self.height.max(1);
355 self.min = self.min.min(self.selected);
356 self.max = self.min + height.saturating_sub(1);
357 if self.max >= len {
358 self.max = len.saturating_sub(1);
359 self.min = self.max.saturating_sub(height.saturating_sub(1));
360 }
361 }
362
363 fn push_view(&mut self) {
365 self.selected_stack.push(self.selected);
366 self.min_stack.push(self.min);
367 self.max_stack.push(self.max);
368 }
369
370 fn pop_view(&mut self) -> Option<(usize, usize, usize)> {
372 if let (Some(sel), Some(min), Some(max)) = (
373 self.selected_stack.pop(),
374 self.min_stack.pop(),
375 self.max_stack.pop(),
376 ) {
377 Some((sel, min, max))
378 } else {
379 None
380 }
381 }
382
383 pub fn did_select_file(&self, msg: &Message) -> Option<PathBuf> {
385 if let Some(key) = msg.downcast_ref::<KeyMsg>() {
386 let key_str = key.to_string();
387 if matches(&key_str, &[&self.key_map.select])
388 && let Some(entry) = self.files.get(self.selected)
389 && self.is_selectable(entry)
390 {
391 return Some(entry.path.clone());
392 }
393 }
394 None
395 }
396
397 pub fn did_select_disabled_file(&self, msg: &Message) -> Option<PathBuf> {
399 if let Some(key) = msg.downcast_ref::<KeyMsg>() {
400 let key_str = key.to_string();
401 if matches(&key_str, &[&self.key_map.select])
402 && let Some(entry) = self.files.get(self.selected)
403 && !self.is_selectable(entry)
404 {
405 return Some(entry.path.clone());
406 }
407 }
408 None
409 }
410
411 pub fn update(&mut self, msg: Message) -> Option<Cmd> {
413 if let Some(read_msg) = msg.downcast_ref::<ReadDirMsg>() {
415 if read_msg.id != self.id {
416 return None;
417 }
418 self.files = read_msg.entries.clone();
419 self.clamp_viewport();
420 return None;
421 }
422
423 if let Some(size) = msg.downcast_ref::<WindowSizeMsg>() {
425 if self.auto_height {
426 self.height = (size.height as usize).saturating_sub(5);
427 }
428 self.clamp_viewport();
429 return None;
430 }
431
432 if let Some(key) = msg.downcast_ref::<KeyMsg>() {
434 let key_str = key.to_string();
435
436 if matches(&key_str, &[&self.key_map.goto_top]) {
437 self.selected = 0;
438 self.min = 0;
439 self.max = self.height.saturating_sub(1);
440 } else if matches(&key_str, &[&self.key_map.goto_last]) {
441 self.selected = self.files.len().saturating_sub(1);
442 self.min = self.files.len().saturating_sub(self.height);
443 self.max = self.files.len().saturating_sub(1);
444 } else if matches(&key_str, &[&self.key_map.down]) {
445 if self.selected < self.files.len().saturating_sub(1) {
446 self.selected += 1;
447 if self.selected > self.max {
448 self.min += 1;
449 self.max += 1;
450 }
451 }
452 } else if matches(&key_str, &[&self.key_map.up]) {
453 if self.selected > 0 {
454 self.selected -= 1;
455 if self.selected < self.min {
456 self.min = self.min.saturating_sub(1);
457 self.max = self.max.saturating_sub(1);
458 }
459 }
460 } else if matches(&key_str, &[&self.key_map.page_down]) {
461 self.selected =
462 (self.selected + self.height).min(self.files.len().saturating_sub(1));
463 self.min += self.height;
464 self.max += self.height;
465 if self.max >= self.files.len() {
466 self.max = self.files.len().saturating_sub(1);
467 self.min = self.max.saturating_sub(self.height);
468 }
469 } else if matches(&key_str, &[&self.key_map.page_up]) {
470 self.selected = self.selected.saturating_sub(self.height);
471 self.min = self.min.saturating_sub(self.height);
472 self.max = self.max.saturating_sub(self.height);
473 if self.min == 0 {
474 self.max = self
475 .height
476 .saturating_sub(1)
477 .min(self.files.len().saturating_sub(1));
478 }
479 } else if matches(&key_str, &[&self.key_map.back]) {
480 let at_root = if let Some(root) = &self.root {
483 self.current_directory == *root
484 } else {
485 false
486 };
487
488 if !at_root {
489 if let Some(parent) = self.current_directory.parent() {
490 self.current_directory = parent.to_path_buf();
491 }
492 if let Some((sel, min, max)) = self.pop_view() {
493 self.selected = sel;
494 self.min = min;
495 self.max = max;
496 } else {
497 self.selected = 0;
498 self.min = 0;
499 self.max = self.height.saturating_sub(1);
500 }
501 return Some(self.read_dir_cmd());
502 }
503 } else {
504 let is_select = matches(&key_str, &[&self.key_map.select]);
505 let is_open = matches(&key_str, &[&self.key_map.open]);
506 if !is_select && !is_open {
507 return None;
508 }
509
510 if self.files.is_empty() {
511 return None;
512 }
513
514 let entry = &self.files[self.selected];
515 let is_dir = entry.is_dir;
516
517 if is_select {
518 self.path = None;
519 }
520
521 if is_select && self.is_selectable(entry) {
523 self.path = Some(entry.path.clone());
524 }
525
526 if is_open && is_dir {
528 self.current_directory = entry.path.clone();
529 self.push_view();
530 self.selected = 0;
531 self.min = 0;
532 self.max = self.height.saturating_sub(1);
533 return Some(self.read_dir_cmd());
534 }
535 }
536 }
537
538 None
539 }
540
541 #[must_use]
543 pub fn view(&self) -> String {
544 if self.files.is_empty() {
545 return self.styles.empty_directory.render("No files found.");
546 }
547
548 let mut lines = Vec::new();
549
550 for (i, entry) in self.files.iter().enumerate() {
551 if i < self.min || i > self.max {
552 continue;
553 }
554
555 let disabled = !self.is_selectable(entry);
556
557 if i == self.selected {
558 let mut parts = Vec::new();
560
561 if self.show_permissions {
562 parts.push(format!(" {}", entry.mode));
563 }
564 if self.show_size {
565 parts.push(format!("{:>7}", format_size(entry.size)));
566 }
567 parts.push(format!(" {}", entry.name));
568 if entry.is_symlink {
569 parts.push(" →".to_string());
570 }
571
572 let content = parts.join("");
573
574 if disabled {
575 lines.push(format!(
576 "{}{}",
577 self.styles.disabled_selected.render(&self.cursor_char),
578 self.styles.disabled_selected.render(&content)
579 ));
580 } else {
581 lines.push(format!(
582 "{}{}",
583 self.styles.cursor.render(&self.cursor_char),
584 self.styles.selected.render(&content)
585 ));
586 }
587 } else {
588 let style = if entry.is_dir {
590 &self.styles.directory
591 } else if entry.is_symlink {
592 &self.styles.symlink
593 } else if disabled {
594 &self.styles.disabled_file
595 } else {
596 &self.styles.file
597 };
598
599 let mut parts = vec![" ".to_string()]; if self.show_permissions {
602 parts.push(format!(" {}", self.styles.permission.render(&entry.mode)));
603 }
604 if self.show_size {
605 parts.push(
606 self.styles
607 .file_size
608 .render(&format!("{:>7}", format_size(entry.size))),
609 );
610 }
611 parts.push(format!(" {}", style.render(&entry.name)));
612 if entry.is_symlink {
613 parts.push(" →".to_string());
614 }
615
616 lines.push(parts.join(""));
617 }
618 }
619
620 while lines.len() < self.height {
622 lines.push(String::new());
623 }
624
625 lines.join("\n")
626 }
627}
628
629impl Model for FilePicker {
630 fn init(&self) -> Option<Cmd> {
632 FilePicker::init(self)
633 }
634
635 fn update(&mut self, msg: Message) -> Option<Cmd> {
637 FilePicker::update(self, msg)
638 }
639
640 fn view(&self) -> String {
642 FilePicker::view(self)
643 }
644}
645
646fn read_directory(path: &Path, show_hidden: bool) -> std::io::Result<Vec<DirEntry>> {
648 let mut entries = Vec::new();
649
650 for entry in std::fs::read_dir(path)? {
651 let entry = entry?;
652 let name = entry.file_name().to_string_lossy().to_string();
653
654 if !show_hidden && name.starts_with('.') {
656 continue;
657 }
658
659 let metadata = entry.metadata()?;
660 let file_type = entry.file_type()?;
661 let is_symlink = file_type.is_symlink();
662
663 let mode = format_mode(&metadata, is_symlink);
664
665 entries.push(DirEntry {
666 name,
667 path: entry.path(),
668 is_dir: file_type.is_dir(),
669 is_symlink: file_type.is_symlink(),
670 size: metadata.len(),
671 mode,
672 });
673 }
674
675 entries.sort_by(|a, b| match (a.is_dir, b.is_dir) {
677 (true, false) => std::cmp::Ordering::Less,
678 (false, true) => std::cmp::Ordering::Greater,
679 _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
680 });
681
682 Ok(entries)
683}
684
685#[cfg(unix)]
687fn format_mode(metadata: &std::fs::Metadata, is_symlink: bool) -> String {
688 use std::os::unix::fs::PermissionsExt;
689 let mode = metadata.permissions().mode();
690
691 let file_type = if metadata.is_dir() {
692 'd'
693 } else if is_symlink {
694 'l'
695 } else {
696 '-'
697 };
698
699 let user = format!(
700 "{}{}{}",
701 if mode & 0o400 != 0 { 'r' } else { '-' },
702 if mode & 0o200 != 0 { 'w' } else { '-' },
703 if mode & 0o100 != 0 { 'x' } else { '-' }
704 );
705 let group = format!(
706 "{}{}{}",
707 if mode & 0o040 != 0 { 'r' } else { '-' },
708 if mode & 0o020 != 0 { 'w' } else { '-' },
709 if mode & 0o010 != 0 { 'x' } else { '-' }
710 );
711 let other = format!(
712 "{}{}{}",
713 if mode & 0o004 != 0 { 'r' } else { '-' },
714 if mode & 0o002 != 0 { 'w' } else { '-' },
715 if mode & 0o001 != 0 { 'x' } else { '-' }
716 );
717
718 format!("{}{}{}{}", file_type, user, group, other)
719}
720
721#[cfg(not(unix))]
722fn format_mode(metadata: &std::fs::Metadata, is_symlink: bool) -> String {
723 let file_type = if metadata.is_dir() {
724 'd'
725 } else if is_symlink {
726 'l'
727 } else {
728 '-'
729 };
730 let readonly = if metadata.permissions().readonly() {
731 "r--"
732 } else {
733 "rw-"
734 };
735 format!("{}{}{}{}", file_type, readonly, readonly, readonly)
736}
737
738fn format_size(size: u64) -> String {
740 const KB: u64 = 1024;
741 const MB: u64 = KB * 1024;
742 const GB: u64 = MB * 1024;
743
744 if size >= GB {
745 format!("{:.1}G", size as f64 / GB as f64)
746 } else if size >= MB {
747 format!("{:.1}M", size as f64 / MB as f64)
748 } else if size >= KB {
749 format!("{:.1}K", size as f64 / KB as f64)
750 } else {
751 format!("{}B", size)
752 }
753}
754
755#[cfg(test)]
756mod tests {
757 use super::*;
758
759 #[test]
760 fn test_filepicker_new() {
761 let fp = FilePicker::new();
762 assert!(fp.allowed_types.is_empty());
763 assert!(fp.show_permissions);
764 assert!(fp.show_size);
765 assert!(!fp.show_hidden);
766 assert!(fp.file_allowed);
767 assert!(!fp.dir_allowed);
768 }
769
770 #[test]
771 fn test_filepicker_unique_ids() {
772 let fp1 = FilePicker::new();
773 let fp2 = FilePicker::new();
774 assert_ne!(fp1.id(), fp2.id());
775 }
776
777 #[test]
778 fn test_filepicker_set_current_directory() {
779 let mut fp = FilePicker::new();
780 fp.set_current_directory("/tmp");
781 assert_eq!(fp.current_directory(), Path::new("/tmp"));
782 }
783
784 #[test]
785 fn test_filepicker_set_height() {
786 let mut fp = FilePicker::new();
787 fp.set_height(20);
788 assert_eq!(fp.height, 20);
789 }
790
791 #[test]
792 fn test_filepicker_allowed_types() {
793 let mut fp = FilePicker::new();
794 fp.set_allowed_types(vec![".txt".to_string(), ".md".to_string()]);
795
796 assert!(fp.can_select("readme.txt"));
797 assert!(fp.can_select("notes.md"));
798 assert!(!fp.can_select("image.png"));
799 }
800
801 #[test]
802 fn test_filepicker_all_types_allowed() {
803 let fp = FilePicker::new();
804 assert!(fp.can_select("anything.xyz"));
805 }
806
807 #[test]
808 fn test_format_size() {
809 assert_eq!(format_size(0), "0B");
810 assert_eq!(format_size(512), "512B");
811 assert_eq!(format_size(1024), "1.0K");
812 assert_eq!(format_size(1536), "1.5K");
813 assert_eq!(format_size(1048576), "1.0M");
814 assert_eq!(format_size(1073741824), "1.0G");
815 }
816
817 #[test]
818 fn test_filepicker_navigation_stack() {
819 let mut fp = FilePicker::new();
820
821 fp.selected = 5;
822 fp.min = 2;
823 fp.max = 10;
824
825 fp.push_view();
826
827 fp.selected = 0;
828 fp.min = 0;
829 fp.max = 5;
830
831 let (sel, min, max) = fp.pop_view().unwrap();
832 assert_eq!(sel, 5);
833 assert_eq!(min, 2);
834 assert_eq!(max, 10);
835 }
836
837 #[test]
838 fn test_filepicker_view_empty() {
839 let fp = FilePicker::new();
840 let view = fp.view();
841 assert!(view.contains("No files"));
842 }
843
844 #[test]
845 fn test_keymap_default() {
846 let km = KeyMap::default();
847 assert!(!km.up.get_keys().is_empty());
848 assert!(!km.down.get_keys().is_empty());
849 assert!(!km.open.get_keys().is_empty());
850 }
851
852 #[test]
853 fn test_dir_entry() {
854 let entry = DirEntry {
855 name: "test.txt".to_string(),
856 path: PathBuf::from("/tmp/test.txt"),
857 is_dir: false,
858 is_symlink: false,
859 size: 1024,
860 mode: "-rw-r--r--".to_string(),
861 };
862
863 assert_eq!(entry.name, "test.txt");
864 assert!(!entry.is_dir);
865 assert_eq!(entry.size, 1024);
866 }
867
868 #[test]
870 fn test_model_init_returns_cmd() {
871 let fp = FilePicker::new();
872 let cmd = Model::init(&fp);
874 assert!(cmd.is_some());
875 }
876
877 #[test]
878 fn test_model_view_matches_filepicker_view() {
879 let fp = FilePicker::new();
880 let model_view = Model::view(&fp);
882 let filepicker_view = FilePicker::view(&fp);
883 assert_eq!(model_view, filepicker_view);
884 }
885
886 #[test]
887 fn test_filepicker_satisfies_model_bounds() {
888 fn requires_model<T: Model + Send + 'static>() {}
889 requires_model::<FilePicker>();
890 }
891
892 #[test]
893 fn test_model_update_handles_navigation() {
894 use bubbletea::{KeyMsg, KeyType, Message};
895
896 let mut fp = FilePicker::new();
897 fp.files = vec![
899 DirEntry {
900 name: "file1.txt".to_string(),
901 path: PathBuf::from("/tmp/file1.txt"),
902 is_dir: false,
903 is_symlink: false,
904 size: 100,
905 mode: "-rw-r--r--".to_string(),
906 },
907 DirEntry {
908 name: "file2.txt".to_string(),
909 path: PathBuf::from("/tmp/file2.txt"),
910 is_dir: false,
911 is_symlink: false,
912 size: 200,
913 mode: "-rw-r--r--".to_string(),
914 },
915 ];
916 fp.max = 10;
917 fp.selected = 0;
918
919 let down_msg = Message::new(KeyMsg::from_type(KeyType::Down));
921 let _ = Model::update(&mut fp, down_msg);
922
923 assert_eq!(
924 fp.selected, 1,
925 "FilePicker should navigate down on Down key"
926 );
927 }
928
929 #[test]
930 fn test_model_update_handles_read_dir_msg() {
931 use bubbletea::Message;
932
933 let mut fp = FilePicker::new();
934 let id = fp.id();
935 assert!(fp.files.is_empty());
936
937 let read_msg = ReadDirMsg {
939 id,
940 entries: vec![DirEntry {
941 name: "test.txt".to_string(),
942 path: PathBuf::from("/tmp/test.txt"),
943 is_dir: false,
944 is_symlink: false,
945 size: 42,
946 mode: "-rw-r--r--".to_string(),
947 }],
948 };
949
950 let _ = Model::update(&mut fp, Message::new(read_msg));
951
952 assert_eq!(
953 fp.files.len(),
954 1,
955 "FilePicker should populate files from ReadDirMsg"
956 );
957 assert_eq!(fp.files[0].name, "test.txt");
958 }
959
960 #[test]
961 fn test_filepicker_read_dir_clamps_selection() {
962 use bubbletea::Message;
963
964 let mut fp = FilePicker::new();
965 fp.height = 5;
966 fp.selected = 10;
967 fp.min = 8;
968 fp.max = 12;
969
970 let read_msg = ReadDirMsg {
971 id: fp.id(),
972 entries: vec![
973 DirEntry {
974 name: "file1.txt".to_string(),
975 path: PathBuf::from("/tmp/file1.txt"),
976 is_dir: false,
977 is_symlink: false,
978 size: 100,
979 mode: "-rw-r--r--".to_string(),
980 },
981 DirEntry {
982 name: "file2.txt".to_string(),
983 path: PathBuf::from("/tmp/file2.txt"),
984 is_dir: false,
985 is_symlink: false,
986 size: 200,
987 mode: "-rw-r--r--".to_string(),
988 },
989 ],
990 };
991
992 let _ = Model::update(&mut fp, Message::new(read_msg));
993
994 assert!(
995 fp.selected < fp.files.len(),
996 "Selection should clamp to list"
997 );
998 assert!(fp.min <= fp.selected && fp.selected <= fp.max);
999 assert!(fp.max < fp.files.len());
1000 }
1001
1002 #[test]
1003 fn test_model_update_ignores_wrong_id() {
1004 use bubbletea::Message;
1005
1006 let mut fp = FilePicker::new();
1007 assert!(fp.files.is_empty());
1008
1009 let read_msg = ReadDirMsg {
1011 id: fp.id() + 1, entries: vec![DirEntry {
1013 name: "test.txt".to_string(),
1014 path: PathBuf::from("/tmp/test.txt"),
1015 is_dir: false,
1016 is_symlink: false,
1017 size: 42,
1018 mode: "-rw-r--r--".to_string(),
1019 }],
1020 };
1021
1022 let _ = Model::update(&mut fp, Message::new(read_msg));
1023
1024 assert!(
1025 fp.files.is_empty(),
1026 "FilePicker should ignore ReadDirMsg with wrong ID"
1027 );
1028 }
1029
1030 #[test]
1035 fn test_model_update_navigate_up_moves_cursor() {
1036 use bubbletea::{KeyMsg, KeyType, Message};
1037
1038 let mut fp = FilePicker::new();
1039 fp.files = vec![
1040 DirEntry {
1041 name: "file1.txt".to_string(),
1042 path: PathBuf::from("/tmp/file1.txt"),
1043 is_dir: false,
1044 is_symlink: false,
1045 size: 100,
1046 mode: "-rw-r--r--".to_string(),
1047 },
1048 DirEntry {
1049 name: "file2.txt".to_string(),
1050 path: PathBuf::from("/tmp/file2.txt"),
1051 is_dir: false,
1052 is_symlink: false,
1053 size: 200,
1054 mode: "-rw-r--r--".to_string(),
1055 },
1056 ];
1057 fp.max = 10;
1058 fp.selected = 1;
1059
1060 let up_msg = Message::new(KeyMsg::from_type(KeyType::Up));
1062 let _ = Model::update(&mut fp, up_msg);
1063
1064 assert_eq!(fp.selected, 0, "FilePicker should navigate up on Up key");
1065 }
1066
1067 #[test]
1068 fn test_filepicker_toggle_hidden_files() {
1069 let mut fp = FilePicker::new();
1070 assert!(!fp.show_hidden, "Hidden files should be hidden by default");
1071
1072 fp.show_hidden = true;
1073 assert!(fp.show_hidden, "Hidden files should be shown after toggle");
1074
1075 fp.show_hidden = false;
1076 assert!(!fp.show_hidden, "Hidden files should be hidden again");
1077 }
1078
1079 #[test]
1080 fn test_filepicker_filter_files() {
1081 let mut fp = FilePicker::new();
1082 fp.set_allowed_types(vec![".txt".to_string()]);
1083
1084 assert!(fp.can_select("readme.txt"));
1086 assert!(!fp.can_select("image.png"));
1087 assert!(!fp.can_select("document.pdf"));
1088 }
1089
1090 #[test]
1091 fn test_filepicker_select_respects_allowed_types() {
1092 use bubbletea::{KeyMsg, KeyType, Message};
1093
1094 let mut fp = FilePicker::new();
1095 fp.set_allowed_types(vec![".txt".to_string()]);
1096 fp.files = vec![DirEntry {
1097 name: "image.png".to_string(),
1098 path: PathBuf::from("/tmp/image.png"),
1099 is_dir: false,
1100 is_symlink: false,
1101 size: 100,
1102 mode: "-rw-r--r--".to_string(),
1103 }];
1104 fp.selected = 0;
1105
1106 let msg = Message::new(KeyMsg::from_type(KeyType::Enter));
1107 let _ = Model::update(&mut fp, msg);
1108
1109 assert!(
1110 fp.selected_path().is_none(),
1111 "Disallowed file should not be selected"
1112 );
1113 assert_eq!(
1114 fp.did_select_disabled_file(&Message::new(KeyMsg::from_type(KeyType::Enter))),
1115 Some(PathBuf::from("/tmp/image.png")),
1116 "Selecting a disallowed file should be reported as disabled"
1117 );
1118 }
1119
1120 #[test]
1121 fn test_filepicker_select_dir_when_disallowed_reports_disabled() {
1122 use bubbletea::{KeyMsg, KeyType, Message};
1123
1124 let mut fp = FilePicker::new();
1125 fp.dir_allowed = false;
1126 fp.files = vec![DirEntry {
1127 name: "subdir".to_string(),
1128 path: PathBuf::from("/tmp/subdir"),
1129 is_dir: true,
1130 is_symlink: false,
1131 size: 4096,
1132 mode: "drwxr-xr-x".to_string(),
1133 }];
1134 fp.selected = 0;
1135
1136 let msg = Message::new(KeyMsg::from_type(KeyType::Enter));
1137 let _ = Model::update(&mut fp, msg);
1138
1139 assert!(
1140 fp.selected_path().is_none(),
1141 "Disallowed dir should not be selected"
1142 );
1143 assert_eq!(
1144 fp.did_select_disabled_file(&Message::new(KeyMsg::from_type(KeyType::Enter))),
1145 Some(PathBuf::from("/tmp/subdir")),
1146 "Selecting a disallowed dir should be reported as disabled"
1147 );
1148 }
1149
1150 #[test]
1151 fn test_filepicker_view_shows_current_path() {
1152 let mut fp = FilePicker::new();
1153 fp.set_current_directory("/tmp");
1154
1155 fp.files = vec![DirEntry {
1157 name: "test.txt".to_string(),
1158 path: PathBuf::from("/tmp/test.txt"),
1159 is_dir: false,
1160 is_symlink: false,
1161 size: 100,
1162 mode: "-rw-r--r--".to_string(),
1163 }];
1164 fp.max = 10;
1165
1166 let view = fp.view();
1167 assert!(view.contains("test") || !view.is_empty());
1169 }
1170
1171 #[test]
1172 fn test_filepicker_symlink_entry() {
1173 let entry = DirEntry {
1174 name: "link".to_string(),
1175 path: PathBuf::from("/tmp/link"),
1176 is_dir: false,
1177 is_symlink: true,
1178 size: 0,
1179 mode: "lrwxrwxrwx".to_string(),
1180 };
1181
1182 assert!(entry.is_symlink, "Entry should be marked as symlink");
1183 assert!(!entry.is_dir, "Symlink should not be marked as directory");
1184 }
1185
1186 #[test]
1187 fn test_filepicker_directory_entry() {
1188 let entry = DirEntry {
1189 name: "subdir".to_string(),
1190 path: PathBuf::from("/tmp/subdir"),
1191 is_dir: true,
1192 is_symlink: false,
1193 size: 4096,
1194 mode: "drwxr-xr-x".to_string(),
1195 };
1196
1197 assert!(entry.is_dir, "Entry should be marked as directory");
1198 assert!(!entry.is_symlink);
1199 }
1200
1201 #[test]
1202 fn test_filepicker_cursor_boundary_top() {
1203 use bubbletea::{KeyMsg, KeyType, Message};
1204
1205 let mut fp = FilePicker::new();
1206 fp.files = vec![DirEntry {
1207 name: "file1.txt".to_string(),
1208 path: PathBuf::from("/tmp/file1.txt"),
1209 is_dir: false,
1210 is_symlink: false,
1211 size: 100,
1212 mode: "-rw-r--r--".to_string(),
1213 }];
1214 fp.max = 10;
1215 fp.selected = 0;
1216
1217 let up_msg = Message::new(KeyMsg::from_type(KeyType::Up));
1219 let _ = Model::update(&mut fp, up_msg);
1220
1221 assert_eq!(fp.selected, 0, "Cursor should not go below 0");
1222 }
1223
1224 #[test]
1225 fn test_filepicker_cursor_boundary_bottom() {
1226 use bubbletea::{KeyMsg, KeyType, Message};
1227
1228 let mut fp = FilePicker::new();
1229 fp.files = vec![
1230 DirEntry {
1231 name: "file1.txt".to_string(),
1232 path: PathBuf::from("/tmp/file1.txt"),
1233 is_dir: false,
1234 is_symlink: false,
1235 size: 100,
1236 mode: "-rw-r--r--".to_string(),
1237 },
1238 DirEntry {
1239 name: "file2.txt".to_string(),
1240 path: PathBuf::from("/tmp/file2.txt"),
1241 is_dir: false,
1242 is_symlink: false,
1243 size: 200,
1244 mode: "-rw-r--r--".to_string(),
1245 },
1246 ];
1247 fp.max = 10;
1248 fp.selected = 1;
1249
1250 let down_msg = Message::new(KeyMsg::from_type(KeyType::Down));
1252 let _ = Model::update(&mut fp, down_msg);
1253
1254 assert_eq!(fp.selected, 1, "Cursor should not exceed file count");
1255 }
1256
1257 #[test]
1258 fn test_filepicker_empty_navigation() {
1259 use bubbletea::{KeyMsg, KeyType, Message};
1260
1261 let mut fp = FilePicker::new();
1262 assert!(fp.files.is_empty());
1263 assert_eq!(fp.selected, 0);
1264
1265 let down_msg = Message::new(KeyMsg::from_type(KeyType::Down));
1267 let _ = Model::update(&mut fp, down_msg);
1268 assert_eq!(fp.selected, 0, "Empty filepicker cursor should stay at 0");
1269
1270 let up_msg = Message::new(KeyMsg::from_type(KeyType::Up));
1271 let _ = Model::update(&mut fp, up_msg);
1272 assert_eq!(fp.selected, 0);
1273 }
1274
1275 #[test]
1276 fn test_filepicker_j_k_navigation() {
1277 use bubbletea::{KeyMsg, Message};
1278
1279 let mut fp = FilePicker::new();
1280 fp.files = vec![
1281 DirEntry {
1282 name: "a.txt".to_string(),
1283 path: PathBuf::from("/tmp/a.txt"),
1284 is_dir: false,
1285 is_symlink: false,
1286 size: 100,
1287 mode: "-rw-r--r--".to_string(),
1288 },
1289 DirEntry {
1290 name: "b.txt".to_string(),
1291 path: PathBuf::from("/tmp/b.txt"),
1292 is_dir: false,
1293 is_symlink: false,
1294 size: 100,
1295 mode: "-rw-r--r--".to_string(),
1296 },
1297 ];
1298 fp.max = 10;
1299 fp.selected = 0;
1300
1301 let j_msg = Message::new(KeyMsg::from_char('j'));
1303 let _ = Model::update(&mut fp, j_msg);
1304 assert_eq!(fp.selected, 1, "'j' should move cursor down");
1305
1306 let k_msg = Message::new(KeyMsg::from_char('k'));
1308 let _ = Model::update(&mut fp, k_msg);
1309 assert_eq!(fp.selected, 0, "'k' should move cursor up");
1310 }
1311
1312 #[test]
1313 fn test_filepicker_page_navigation() {
1314 use bubbletea::{KeyMsg, KeyType, Message};
1315
1316 let mut fp = FilePicker::new();
1317 fp.files = (0..20)
1319 .map(|i| DirEntry {
1320 name: format!("file{}.txt", i),
1321 path: PathBuf::from(format!("/tmp/file{}.txt", i)),
1322 is_dir: false,
1323 is_symlink: false,
1324 size: 100,
1325 mode: "-rw-r--r--".to_string(),
1326 })
1327 .collect();
1328 fp.height = 5;
1329 fp.max = fp.height;
1330 fp.selected = 0;
1331
1332 let pgdown_msg = Message::new(KeyMsg::from_type(KeyType::PgDown));
1334 let _ = Model::update(&mut fp, pgdown_msg);
1335 assert!(fp.selected > 0, "PageDown should move cursor down");
1336 }
1337
1338 #[test]
1339 fn test_filepicker_set_show_permissions() {
1340 let mut fp = FilePicker::new();
1341 assert!(fp.show_permissions, "Permissions shown by default");
1342
1343 fp.show_permissions = false;
1344 assert!(!fp.show_permissions);
1345 }
1346
1347 #[test]
1348 fn test_filepicker_set_show_size() {
1349 let mut fp = FilePicker::new();
1350 assert!(fp.show_size, "Size shown by default");
1351
1352 fp.show_size = false;
1353 assert!(!fp.show_size);
1354 }
1355
1356 #[test]
1357 fn test_filepicker_dir_allowed() {
1358 let mut fp = FilePicker::new();
1359 assert!(fp.file_allowed, "Files allowed by default");
1360 assert!(!fp.dir_allowed, "Directories not allowed by default");
1361
1362 fp.dir_allowed = true;
1363 fp.file_allowed = false;
1364 assert!(fp.dir_allowed);
1365 assert!(!fp.file_allowed);
1366 }
1367
1368 #[test]
1369 fn test_filepicker_selected_file() {
1370 let mut fp = FilePicker::new();
1371 fp.files = vec![
1372 DirEntry {
1373 name: "first.txt".to_string(),
1374 path: PathBuf::from("/tmp/first.txt"),
1375 is_dir: false,
1376 is_symlink: false,
1377 size: 100,
1378 mode: "-rw-r--r--".to_string(),
1379 },
1380 DirEntry {
1381 name: "second.txt".to_string(),
1382 path: PathBuf::from("/tmp/second.txt"),
1383 is_dir: false,
1384 is_symlink: false,
1385 size: 200,
1386 mode: "-rw-r--r--".to_string(),
1387 },
1388 ];
1389 fp.max = 10;
1390 fp.selected = 0;
1391
1392 if let Some(entry) = fp.files.get(fp.selected) {
1394 assert_eq!(entry.name, "first.txt");
1395 }
1396
1397 fp.selected = 1;
1398 if let Some(entry) = fp.files.get(fp.selected) {
1399 assert_eq!(entry.name, "second.txt");
1400 }
1401 }
1402
1403 #[test]
1404 fn test_filepicker_select_key_independent_of_open() {
1405 use bubbletea::{KeyMsg, Message};
1406
1407 let mut fp = FilePicker::new();
1408 fp.key_map.select = Binding::new().keys(&["s"]);
1409 fp.key_map.open = Binding::new().keys(&["enter"]);
1410 fp.files = vec![DirEntry {
1411 name: "selected.txt".to_string(),
1412 path: PathBuf::from("/tmp/selected.txt"),
1413 is_dir: false,
1414 is_symlink: false,
1415 size: 10,
1416 mode: "-rw-r--r--".to_string(),
1417 }];
1418 fp.max = 10;
1419 fp.selected = 0;
1420
1421 let msg = Message::new(KeyMsg::from_char('s'));
1422 let _ = Model::update(&mut fp, msg);
1423
1424 assert_eq!(
1425 fp.selected_path(),
1426 Some(Path::new("/tmp/selected.txt")),
1427 "Select key should set path even when open key differs"
1428 );
1429 }
1430
1431 #[test]
1432 fn test_filepicker_current_directory_accessor() {
1433 let mut fp = FilePicker::new();
1434 let initial_dir = fp.current_directory().to_path_buf();
1435
1436 fp.set_current_directory("/home");
1437 assert_eq!(fp.current_directory(), Path::new("/home"));
1438
1439 fp.set_current_directory("/var/log");
1440 assert_eq!(fp.current_directory(), Path::new("/var/log"));
1441
1442 fp.current_directory = initial_dir;
1444 }
1445
1446 #[test]
1451 fn test_filepicker_read_dir_error_updates_state() {
1452 use bubbletea::Message;
1453
1454 let mut fp = FilePicker::new();
1455 let id = fp.id();
1456
1457 let err_msg = ReadDirErrMsg {
1459 id,
1460 error: "Permission denied".to_string(),
1461 };
1462
1463 let cmd = Model::update(&mut fp, Message::new(err_msg));
1465 assert!(cmd.is_none(), "Error handling should not return a command");
1468 }
1469
1470 #[test]
1471 fn test_filepicker_enter_directory_changes_path() {
1472 use bubbletea::{KeyMsg, KeyType, Message};
1473
1474 let mut fp = FilePicker::new();
1475 fp.set_current_directory("/tmp");
1476 fp.files = vec![
1477 DirEntry {
1478 name: "subdir".to_string(),
1479 path: PathBuf::from("/tmp/subdir"),
1480 is_dir: true,
1481 is_symlink: false,
1482 size: 4096,
1483 mode: "drwxr-xr-x".to_string(),
1484 },
1485 DirEntry {
1486 name: "file.txt".to_string(),
1487 path: PathBuf::from("/tmp/file.txt"),
1488 is_dir: false,
1489 is_symlink: false,
1490 size: 100,
1491 mode: "-rw-r--r--".to_string(),
1492 },
1493 ];
1494 fp.max = 10;
1495 fp.selected = 0;
1496
1497 let enter_msg = Message::new(KeyMsg::from_type(KeyType::Enter));
1499 let cmd = Model::update(&mut fp, enter_msg);
1500
1501 assert_eq!(
1502 fp.current_directory(),
1503 Path::new("/tmp/subdir"),
1504 "Enter on directory should change current path"
1505 );
1506 assert!(
1507 cmd.is_some(),
1508 "Entering directory should return read_dir command"
1509 );
1510 }
1511
1512 #[test]
1513 fn test_filepicker_backspace_goes_parent() {
1514 use bubbletea::{KeyMsg, KeyType, Message};
1515
1516 let mut fp = FilePicker::new();
1517 fp.set_current_directory("/tmp/subdir");
1518 fp.files = vec![DirEntry {
1519 name: "file.txt".to_string(),
1520 path: PathBuf::from("/tmp/subdir/file.txt"),
1521 is_dir: false,
1522 is_symlink: false,
1523 size: 100,
1524 mode: "-rw-r--r--".to_string(),
1525 }];
1526 fp.max = 10;
1527
1528 let back_msg = Message::new(KeyMsg::from_type(KeyType::Backspace));
1530 let cmd = Model::update(&mut fp, back_msg);
1531
1532 assert_eq!(
1533 fp.current_directory(),
1534 Path::new("/tmp"),
1535 "Backspace should navigate to parent directory"
1536 );
1537 assert!(
1538 cmd.is_some(),
1539 "Going to parent should return read_dir command"
1540 );
1541 }
1542
1543 #[test]
1544 fn test_filepicker_view_highlights_selected() {
1545 let mut fp = FilePicker::new();
1546 fp.files = vec![
1547 DirEntry {
1548 name: "first.txt".to_string(),
1549 path: PathBuf::from("/tmp/first.txt"),
1550 is_dir: false,
1551 is_symlink: false,
1552 size: 100,
1553 mode: "-rw-r--r--".to_string(),
1554 },
1555 DirEntry {
1556 name: "second.txt".to_string(),
1557 path: PathBuf::from("/tmp/second.txt"),
1558 is_dir: false,
1559 is_symlink: false,
1560 size: 200,
1561 mode: "-rw-r--r--".to_string(),
1562 },
1563 ];
1564 fp.max = 10;
1565 fp.selected = 0;
1566
1567 let view = fp.view();
1568 assert!(
1570 view.contains(&fp.cursor_char),
1571 "View should show cursor on selected item"
1572 );
1573 assert!(
1574 view.contains("first.txt"),
1575 "View should show the first file"
1576 );
1577 }
1578
1579 #[test]
1580 #[allow(clippy::useless_vec)]
1581 fn test_filepicker_view_shows_directories_first() {
1582 let mut entries = vec![
1586 DirEntry {
1587 name: "zebra.txt".to_string(),
1588 path: PathBuf::from("/tmp/zebra.txt"),
1589 is_dir: false,
1590 is_symlink: false,
1591 size: 100,
1592 mode: "-rw-r--r--".to_string(),
1593 },
1594 DirEntry {
1595 name: "apple_dir".to_string(),
1596 path: PathBuf::from("/tmp/apple_dir"),
1597 is_dir: true,
1598 is_symlink: false,
1599 size: 4096,
1600 mode: "drwxr-xr-x".to_string(),
1601 },
1602 DirEntry {
1603 name: "banana.txt".to_string(),
1604 path: PathBuf::from("/tmp/banana.txt"),
1605 is_dir: false,
1606 is_symlink: false,
1607 size: 200,
1608 mode: "-rw-r--r--".to_string(),
1609 },
1610 ];
1611
1612 entries.sort_by(|a, b| match (a.is_dir, b.is_dir) {
1614 (true, false) => std::cmp::Ordering::Less,
1615 (false, true) => std::cmp::Ordering::Greater,
1616 _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
1617 });
1618
1619 assert!(entries[0].is_dir, "Directories should come first");
1621 assert_eq!(entries[0].name, "apple_dir");
1622 assert_eq!(entries[1].name, "banana.txt");
1624 assert_eq!(entries[2].name, "zebra.txt");
1625 }
1626
1627 #[test]
1628 fn test_filepicker_root_directory_no_parent() {
1629 use bubbletea::{KeyMsg, KeyType, Message};
1630
1631 let mut fp = FilePicker::new();
1632 fp.set_current_directory("/");
1633 fp.files = vec![DirEntry {
1634 name: "etc".to_string(),
1635 path: PathBuf::from("/etc"),
1636 is_dir: true,
1637 is_symlink: false,
1638 size: 4096,
1639 mode: "drwxr-xr-x".to_string(),
1640 }];
1641 fp.max = 10;
1642
1643 let back_msg = Message::new(KeyMsg::from_type(KeyType::Backspace));
1645 let _ = Model::update(&mut fp, back_msg);
1646
1647 let current = fp.current_directory();
1651 assert!(
1652 current == Path::new("/") || current == Path::new(""),
1653 "Should stay at or near root when trying to go up from root"
1654 );
1655 }
1656
1657 #[test]
1658 fn test_filepicker_highlighted_entry() {
1659 let mut fp = FilePicker::new();
1660 fp.files = vec![
1661 DirEntry {
1662 name: "first.txt".to_string(),
1663 path: PathBuf::from("/tmp/first.txt"),
1664 is_dir: false,
1665 is_symlink: false,
1666 size: 100,
1667 mode: "-rw-r--r--".to_string(),
1668 },
1669 DirEntry {
1670 name: "second.txt".to_string(),
1671 path: PathBuf::from("/tmp/second.txt"),
1672 is_dir: false,
1673 is_symlink: false,
1674 size: 200,
1675 mode: "-rw-r--r--".to_string(),
1676 },
1677 ];
1678 fp.selected = 0;
1679
1680 let entry = fp
1681 .highlighted_entry()
1682 .expect("Should have highlighted entry");
1683 assert_eq!(entry.name, "first.txt");
1684
1685 fp.selected = 1;
1686 let entry = fp
1687 .highlighted_entry()
1688 .expect("Should have highlighted entry");
1689 assert_eq!(entry.name, "second.txt");
1690 }
1691
1692 #[test]
1693 fn test_filepicker_window_size_msg() {
1694 use bubbletea::{Message, WindowSizeMsg};
1695
1696 let mut fp = FilePicker::new();
1697 fp.auto_height = true;
1698 assert_eq!(fp.height, 0);
1699
1700 let size_msg = WindowSizeMsg {
1702 width: 80,
1703 height: 24,
1704 };
1705 let _ = Model::update(&mut fp, Message::new(size_msg));
1706
1707 assert_eq!(fp.height, 19, "Height should be terminal height minus 5");
1709 }
1710
1711 #[test]
1712 fn test_filepicker_goto_top_and_last() {
1713 use bubbletea::{KeyMsg, Message};
1714
1715 let mut fp = FilePicker::new();
1716 fp.files = (0..10)
1717 .map(|i| DirEntry {
1718 name: format!("file{}.txt", i),
1719 path: PathBuf::from(format!("/tmp/file{}.txt", i)),
1720 is_dir: false,
1721 is_symlink: false,
1722 size: 100,
1723 mode: "-rw-r--r--".to_string(),
1724 })
1725 .collect();
1726 fp.height = 5;
1727 fp.max = fp.height;
1728 fp.selected = 5;
1729
1730 let g_msg = Message::new(KeyMsg::from_char('g'));
1732 let _ = Model::update(&mut fp, g_msg);
1733 assert_eq!(fp.selected, 0, "'g' should go to first item");
1734
1735 let shift_g_msg = Message::new(KeyMsg::from_char('G'));
1737 let _ = Model::update(&mut fp, shift_g_msg);
1738 assert_eq!(fp.selected, 9, "'G' should go to last item");
1739 }
1740}