1mod file_entry;
2
3pub use self::file_entry::FileEntry;
4
5use crate::color::{BLACK, WHITE};
6use crate::context::Context;
7use crate::device::CURRENT_DEVICE;
8use crate::font::Fonts;
9use crate::framebuffer::{Framebuffer, UpdateMode};
10use crate::geom::{halves, CycleDir, Rectangle};
11use crate::gesture::GestureEvent;
12use crate::unit::scale_by_dpi;
13use crate::view::filler::Filler;
14use crate::view::icon::Icon;
15use crate::view::label::Label;
16use crate::view::navigation::providers::directory::DirectoryNavigationProvider;
17use crate::view::navigation::StackNavigationBar;
18use crate::view::page_label::PageLabel;
19use crate::view::top_bar::{TopBar, TopBarVariant};
20use crate::view::{Bus, EntryId, Event, Hub, Id, RenderData, RenderQueue, View, ViewId, ID_FEEDER};
21use crate::view::{SMALL_BAR_HEIGHT, THICKNESS_MEDIUM};
22use std::fs;
23use std::path::{Path, PathBuf};
24use std::time::SystemTime;
25
26const SELECT_CURRENT_FOLDER_TEXT: &str = "Select this folder";
28
29#[derive(Debug, Clone)]
30pub struct FileEntryData {
31 pub path: PathBuf,
32 pub name: String,
33 pub size: Option<u64>,
34 pub modified: Option<SystemTime>,
35 pub is_dir: bool,
36}
37
38#[derive(Debug, Copy, Clone, PartialEq, Eq)]
39pub enum SelectionMode {
40 File,
41 Directory,
42 Both,
43}
44
45struct FileChooserLayout {
46 thickness: i32,
47 small_thickness: i32,
48 big_thickness: i32,
49 small_height: i32,
50 big_height: i32,
51}
52
53impl FileChooserLayout {
54 fn new(dpi: u16) -> Self {
55 let thickness = scale_by_dpi(THICKNESS_MEDIUM, dpi) as i32;
56 let (small_thickness, big_thickness) = halves(thickness);
57 let small_height = scale_by_dpi(SMALL_BAR_HEIGHT, dpi) as i32;
58 let big_height = scale_by_dpi(SMALL_BAR_HEIGHT, dpi) as i32;
59
60 Self {
61 thickness,
62 small_thickness,
63 big_thickness,
64 small_height,
65 big_height,
66 }
67 }
68
69 fn top_bar_rect(&self, rect: &Rectangle) -> Rectangle {
70 rect![
71 rect.min.x,
72 rect.min.y,
73 rect.max.x,
74 rect.min.y + self.small_height - self.small_thickness
75 ]
76 }
77
78 fn first_separator_rect(&self, rect: &Rectangle) -> Rectangle {
79 rect![
80 rect.min.x,
81 rect.min.y + self.small_height - self.small_thickness,
82 rect.max.x,
83 rect.min.y + self.small_height + self.big_thickness
84 ]
85 }
86
87 fn nav_bar_rect(&self, rect: &Rectangle) -> Rectangle {
88 rect![
89 rect.min.x,
90 rect.min.y + self.small_height + self.big_thickness,
91 rect.max.x,
92 rect.min.y + self.small_height + self.big_thickness + self.small_height
93 - self.thickness
94 ]
95 }
96
97 fn second_separator_rect(&self, rect: &Rectangle) -> Rectangle {
98 rect![
99 rect.min.x,
100 rect.min.y + 2 * self.small_height + self.big_thickness - self.thickness,
101 rect.max.x,
102 rect.min.y + 2 * self.small_height + self.big_thickness
103 ]
104 }
105}
106
107pub struct FileChooser {
108 id: Id,
109 rect: Rectangle,
110 children: Vec<Box<dyn View>>,
111 current_path: PathBuf,
112 entries: Vec<FileEntryData>,
113 current_page: usize,
114 pages_count: usize,
115 mode: SelectionMode,
116 nav_bar_index: usize,
117 entries_start_index: usize,
118 error_message: Option<String>,
119
120 selected_path: Option<PathBuf>,
123
124 bottom_bar_rect: Rectangle,
125}
126
127impl FileChooser {
128 fn create_separator(rect: Rectangle) -> Box<dyn View> {
129 Box::new(Filler::new(rect, BLACK))
130 }
131
132 fn get_title_for_mode(mode: SelectionMode) -> &'static str {
133 match mode {
134 SelectionMode::File => "Select File",
135 SelectionMode::Directory => "Select Folder",
136 SelectionMode::Both => "Select File or Folder",
137 }
138 }
139
140 #[inline]
141 fn build_children(
142 rect: Rectangle,
143 initial_path: &Path,
144 mode: SelectionMode,
145 layout: &FileChooserLayout,
146 context: &mut Context,
147 ) -> (Vec<Box<dyn View>>, usize) {
148 let mut children = Vec::new();
149
150 let background = Filler::new(rect, WHITE);
151 children.push(Box::new(background) as Box<dyn View>);
152
153 let title = Self::get_title_for_mode(mode);
154 let top_bar = TopBar::new(
155 layout.top_bar_rect(&rect),
156 TopBarVariant::Cancel(Event::Close(ViewId::FileChooser)),
157 title.to_string(),
158 context,
159 );
160 children.push(Box::new(top_bar) as Box<dyn View>);
161
162 children.push(Self::create_separator(layout.first_separator_rect(&rect)));
163
164 let nav_bar_index = children.len();
165 let provider = DirectoryNavigationProvider::filesystem(PathBuf::from("/"));
166 let nav_bar = StackNavigationBar::new(
167 layout.nav_bar_rect(&rect),
168 rect.max.y - layout.small_height - layout.thickness,
169 3,
170 provider,
171 initial_path.to_path_buf(),
172 );
173
174 children.push(Box::new(nav_bar) as Box<dyn View>);
175 children.push(Self::create_separator(layout.second_separator_rect(&rect)));
176
177 (children, nav_bar_index)
178 }
179
180 pub fn new(
181 rect: Rectangle,
182 initial_path: PathBuf,
183 mode: SelectionMode,
184 _hub: &Hub,
185 rq: &mut RenderQueue,
186 context: &mut Context,
187 ) -> FileChooser {
188 let id = ID_FEEDER.next();
189 let dpi = CURRENT_DEVICE.dpi;
190 let layout = FileChooserLayout::new(dpi);
191
192 let (children, nav_bar_index) =
193 Self::build_children(rect, &initial_path, mode, &layout, context);
194 let entries_start_index = children.len();
195
196 rq.add(RenderData::new(id, rect, UpdateMode::Gui));
197
198 let mut file_chooser = FileChooser {
199 id,
200 rect,
201 children,
202 current_path: initial_path.clone(),
203 entries: Vec::new(),
204 current_page: 0,
205 pages_count: 1,
206 mode,
207 nav_bar_index,
208 entries_start_index,
209 error_message: None,
210 selected_path: None,
211 bottom_bar_rect: Rectangle::default(),
212 };
213
214 file_chooser.navigate_to(initial_path, rq, context);
215
216 file_chooser
217 }
218
219 fn list_directory(&self, path: &Path) -> Result<Vec<FileEntryData>, String> {
225 let mut entries = Vec::new();
226
227 if !path.exists() {
228 return Err("Path does not exist".to_string());
229 }
230
231 if !path.is_dir() {
232 return Err("Path is not a directory".to_string());
233 }
234
235 if self.mode == SelectionMode::Directory {
236 return Ok(entries);
237 }
238
239 match fs::read_dir(path) {
240 Ok(read_dir) => {
241 for entry in read_dir.flatten() {
242 if let Ok(metadata) = entry.metadata() {
243 if metadata.is_dir() {
244 continue;
245 }
246
247 let path = entry.path();
248
249 let name = path
250 .file_name()
251 .unwrap_or_default()
252 .to_string_lossy()
253 .into_owned();
254
255 let size = Some(metadata.len());
256 let modified = metadata.modified().ok();
257
258 entries.push(FileEntryData {
259 path,
260 name,
261 size,
262 modified,
263 is_dir: metadata.is_dir(),
264 });
265 }
266 }
267 }
268 Err(err) => {
269 return Err(format!("Failed to read directory: {}", err));
270 }
271 }
272
273 entries.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
274
275 Ok(entries)
276 }
277
278 fn navigate_to(&mut self, path: PathBuf, rq: &mut RenderQueue, context: &mut Context) {
279 self.current_path = path;
280 match self.list_directory(&self.current_path) {
281 Ok(entries) => {
282 self.entries = entries;
283 self.error_message = None;
284 }
285 Err(err) => {
286 self.entries = Vec::new();
287 self.error_message = Some(err);
288 }
289 }
290
291 if self.error_message.is_none() {
292 if let Some(select_current_entry) = self.create_select_current_entry() {
293 self.entries.insert(0, select_current_entry);
294 }
295 }
296
297 self.current_page = 0;
298
299 let nav_bar = self.children[self.nav_bar_index]
300 .as_mut()
301 .downcast_mut::<StackNavigationBar<DirectoryNavigationProvider>>()
302 .unwrap();
303 nav_bar.set_selected(self.current_path.clone(), rq, context);
304
305 self.update_nav_bar_separator();
306 self.update_entries_list(context);
307
308 rq.add(RenderData::new(self.id, self.rect, UpdateMode::Gui));
309 }
310
311 #[inline]
312 fn update_nav_bar_separator(&mut self) {
313 let nav_bar_bottom = self.children[self.nav_bar_index].rect().max.y;
314 let thickness = scale_by_dpi(THICKNESS_MEDIUM, CURRENT_DEVICE.dpi) as i32;
315 let separator_rect = rect![
316 self.rect.min.x,
317 nav_bar_bottom,
318 self.rect.max.x,
319 nav_bar_bottom + thickness
320 ];
321
322 if let Some(separator) = self.children[self.nav_bar_index + 1]
323 .as_mut()
324 .downcast_mut::<Filler>()
325 {
326 *separator.rect_mut() = separator_rect;
327 }
328 }
329
330 fn calculate_entry_rect(
331 &self,
332 y_pos: i32,
333 index: usize,
334 max_lines: usize,
335 big_height: i32,
336 big_thickness: i32,
337 small_thickness: i32,
338 ) -> Rectangle {
339 let y_min = y_pos + if index > 0 { big_thickness } else { 0 };
340 let y_max = y_pos + big_height
341 - if index < max_lines - 1 {
342 small_thickness
343 } else {
344 0
345 };
346
347 rect![self.rect.min.x, y_min, self.rect.max.x, y_max]
348 }
349
350 fn add_error_label(&mut self, nav_bar_bottom: i32, thickness: i32, big_height: i32) {
351 if let Some(error_msg) = &self.error_message {
352 let label = Label::new(
353 rect![
354 self.rect.min.x,
355 nav_bar_bottom + thickness,
356 self.rect.max.x,
357 nav_bar_bottom + thickness + big_height * 2
358 ],
359 format!("Error: {}", error_msg),
360 crate::view::Align::Center,
361 );
362 self.children.push(Box::new(label) as Box<dyn View>);
363 }
364 }
365
366 fn add_empty_label(&mut self, nav_bar_bottom: i32, thickness: i32, big_height: i32) {
367 let label = Label::new(
368 rect![
369 self.rect.min.x,
370 nav_bar_bottom + thickness,
371 self.rect.max.x,
372 nav_bar_bottom + thickness + big_height
373 ],
374 "Empty directory".to_string(),
375 crate::view::Align::Center,
376 );
377 self.children.push(Box::new(label) as Box<dyn View>);
378 }
379
380 #[allow(clippy::too_many_arguments)]
381 fn add_file_entries(
403 &mut self,
404 start_idx: usize,
405 end_idx: usize,
406 nav_bar_bottom: i32,
407 thickness: i32,
408 big_height: i32,
409 big_thickness: i32,
410 small_thickness: i32,
411 max_lines: usize,
412 context: &mut Context,
413 ) {
414 let mut y_pos = nav_bar_bottom + thickness;
415
416 for (i, entry_data) in self.entries[start_idx..end_idx].iter().enumerate() {
417 let entry_rect = self.calculate_entry_rect(
418 y_pos,
419 i,
420 max_lines,
421 big_height,
422 big_thickness,
423 small_thickness,
424 );
425
426 let file_entry = FileEntry::new(entry_rect, entry_data.clone(), context);
427 self.children.push(Box::new(file_entry) as Box<dyn View>);
428
429 let y_max = entry_rect.max.y;
430 let separator_rect = rect![self.rect.min.x, y_max, self.rect.max.x, y_max + thickness];
431 self.children.push(Self::create_separator(separator_rect));
432
433 y_pos += big_height;
434 }
435 }
436
437 fn update_entries_list(&mut self, context: &mut Context) {
438 self.children.drain(self.entries_start_index..);
439
440 let layout = FileChooserLayout::new(CURRENT_DEVICE.dpi);
441 let nav_bar_bottom = self.children[self.nav_bar_index].rect().max.y;
442 let available_height =
443 self.rect.max.y - nav_bar_bottom - layout.thickness - layout.small_height;
444 let max_lines = (available_height / layout.big_height).max(1) as usize;
445
446 self.pages_count = (self.entries.len() as f32 / max_lines as f32).ceil() as usize;
447 if self.pages_count == 0 {
448 self.pages_count = 1;
449 }
450
451 let start_idx = self.current_page * max_lines;
452 let end_idx = (start_idx + max_lines).min(self.entries.len());
453
454 if self.error_message.is_some() {
455 self.add_error_label(nav_bar_bottom, layout.thickness, layout.big_height);
456 } else if self.entries.is_empty() {
457 self.add_empty_label(nav_bar_bottom, layout.thickness, layout.big_height);
458 } else {
459 self.add_file_entries(
460 start_idx,
461 end_idx,
462 nav_bar_bottom,
463 layout.thickness,
464 layout.big_height,
465 layout.big_thickness,
466 layout.small_thickness,
467 max_lines,
468 context,
469 );
470 }
471
472 let separator_rect = rect![
473 self.rect.min.x,
474 self.rect.max.y - layout.small_height - layout.thickness,
475 self.rect.max.x,
476 self.rect.max.y - layout.small_height
477 ];
478 self.children.push(Self::create_separator(separator_rect));
479
480 self.create_bottom_bar();
481 }
482
483 fn create_bottom_bar(&mut self) {
484 let dpi = CURRENT_DEVICE.dpi;
485 let small_height = scale_by_dpi(SMALL_BAR_HEIGHT, dpi) as i32;
486 let thickness = scale_by_dpi(THICKNESS_MEDIUM, dpi) as i32;
487 let (_, big_thickness) = halves(thickness);
488
489 let bottom_bar_rect = rect![
490 self.rect.min.x,
491 self.rect.max.y - small_height + big_thickness,
492 self.rect.max.x,
493 self.rect.max.y
494 ];
495
496 self.bottom_bar_rect = bottom_bar_rect;
497
498 let side = bottom_bar_rect.height() as i32;
499 let is_prev_disabled = self.pages_count < 2 || self.current_page == 0;
500 let is_next_disabled = self.pages_count < 2 || self.current_page == self.pages_count - 1;
501
502 let prev_rect = rect![bottom_bar_rect.min, bottom_bar_rect.min + side];
503 if is_prev_disabled {
504 let prev_filler = Filler::new(prev_rect, WHITE);
505 self.children.push(Box::new(prev_filler) as Box<dyn View>);
506 } else {
507 let prev_icon = Icon::new("arrow-left", prev_rect, Event::Page(CycleDir::Previous));
508 self.children.push(Box::new(prev_icon) as Box<dyn View>);
509 }
510
511 let page_label = PageLabel::new(
512 rect![
513 bottom_bar_rect.min.x + side,
514 bottom_bar_rect.min.y,
515 bottom_bar_rect.max.x - side,
516 bottom_bar_rect.max.y
517 ],
518 self.current_page,
519 self.pages_count,
520 false,
521 );
522 self.children.push(Box::new(page_label) as Box<dyn View>);
523
524 let next_rect = rect![bottom_bar_rect.max - side, bottom_bar_rect.max];
525 if is_next_disabled {
526 let next_filler = Filler::new(next_rect, WHITE);
527 self.children.push(Box::new(next_filler) as Box<dyn View>);
528 } else {
529 let next_icon = Icon::new("arrow-right", next_rect, Event::Page(CycleDir::Next));
530 self.children.push(Box::new(next_icon) as Box<dyn View>);
531 }
532 }
533
534 #[inline]
537 fn create_select_current_entry(&self) -> Option<FileEntryData> {
538 match self.mode {
539 SelectionMode::File => None,
540 SelectionMode::Directory | SelectionMode::Both => Some(FileEntryData {
541 path: self.current_path.clone(),
542 name: SELECT_CURRENT_FOLDER_TEXT.to_string(),
543 size: None,
544 modified: None,
545 is_dir: true,
546 }),
547 }
548 }
549
550 fn select_item(&mut self, path: PathBuf, bus: &mut Bus) {
553 let is_dir = path.is_dir();
554
555 let can_select = match self.mode {
556 SelectionMode::File => !is_dir,
557 SelectionMode::Directory => is_dir,
558 SelectionMode::Both => true,
559 };
560
561 if can_select {
562 self.selected_path = Some(path);
563 bus.push_back(Event::FileChooserClosed(self.selected_path.clone()));
564 bus.push_back(Event::Close(self.view_id().unwrap()));
565 }
566 }
567
568 fn go_to_page(&mut self, dir: CycleDir, context: &mut Context) {
569 match dir {
570 CycleDir::Next => {
571 if self.current_page < self.pages_count - 1 {
572 self.current_page += 1;
573 }
574 }
575 CycleDir::Previous => {
576 if self.current_page > 0 {
577 self.current_page -= 1;
578 }
579 }
580 }
581 self.update_entries_list(context);
582 }
583}
584
585impl View for FileChooser {
586 #[cfg_attr(feature = "otel", tracing::instrument(skip(self, _hub, bus, rq, context), fields(event = ?evt), ret(level=tracing::Level::TRACE)))]
587 fn handle_event(
588 &mut self,
589 evt: &Event,
590 _hub: &Hub,
591 bus: &mut Bus,
592 rq: &mut RenderQueue,
593 context: &mut Context,
594 ) -> bool {
595 match evt {
596 Event::ToggleSelectDirectory(path) => {
597 self.navigate_to(path.clone(), rq, context);
598 true
599 }
600 Event::Select(EntryId::FileEntry(path)) => {
601 self.select_item(path.clone(), bus);
602 true
603 }
604 Event::Page(dir) => {
605 self.go_to_page(*dir, context);
606
607 rq.add(RenderData::new(self.id, self.rect, UpdateMode::Gui));
608
609 true
610 }
611 Event::NavigationBarResized(_) => {
612 self.update_nav_bar_separator();
613 self.update_entries_list(context);
614
615 rq.add(RenderData::new(self.id, self.rect, UpdateMode::Gui));
616
617 true
618 }
619 Event::Gesture(GestureEvent::Tap(center)) if self.bottom_bar_rect.includes(*center) => {
620 true
621 }
622 _ => false,
623 }
624 }
625
626 #[cfg_attr(feature = "otel", tracing::instrument(skip(self, _fb, _fonts), fields(rect = ?_rect)))]
627 fn render(&self, _fb: &mut dyn Framebuffer, _rect: Rectangle, _fonts: &mut Fonts) {}
628
629 fn rect(&self) -> &Rectangle {
630 &self.rect
631 }
632
633 fn rect_mut(&mut self) -> &mut Rectangle {
634 &mut self.rect
635 }
636
637 fn children(&self) -> &Vec<Box<dyn View>> {
638 &self.children
639 }
640
641 fn children_mut(&mut self) -> &mut Vec<Box<dyn View>> {
642 &mut self.children
643 }
644
645 fn id(&self) -> Id {
646 self.id
647 }
648
649 fn view_id(&self) -> Option<ViewId> {
650 Some(ViewId::FileChooser)
651 }
652}
653
654#[cfg(test)]
655impl FileChooser {
656 pub fn bottom_bar_rect(&self) -> Rectangle {
657 self.bottom_bar_rect
658 }
659}
660
661#[cfg(test)]
662mod tests {
663 use super::*;
664 use crate::context::test_helpers::create_test_context;
665 use crate::geom::Point;
666 use std::collections::VecDeque;
667 use std::sync::mpsc::channel;
668
669 fn create_test_file_chooser(rq: &mut RenderQueue, context: &mut Context) -> FileChooser {
670 let rect = rect![0, 0, 600, 800];
671 let path = PathBuf::from("/tmp");
672 let (hub, _receiver) = channel();
673 FileChooser::new(rect, path, SelectionMode::File, &hub, rq, context)
674 }
675
676 fn create_test_file_chooser_with_path(
677 rq: &mut RenderQueue,
678 context: &mut Context,
679 path: PathBuf,
680 mode: SelectionMode,
681 ) -> FileChooser {
682 let rect = rect![0, 0, 600, 800];
683 let (hub, _receiver) = channel();
684 FileChooser::new(rect, path, mode, &hub, rq, context)
685 }
686
687 #[test]
688 fn test_bottom_bar_rect_stored_correctly() {
689 let mut rq = RenderQueue::new();
690 let mut context = create_test_context();
691 let file_chooser = create_test_file_chooser(&mut rq, &mut context);
692
693 let bottom_bar = file_chooser.bottom_bar_rect();
694
695 assert!(
696 bottom_bar.max.y > 0,
697 "bottom_bar_rect should be properly initialized"
698 );
699 assert_eq!(
700 bottom_bar.min.x, 0,
701 "bottom_bar_rect should start at left edge"
702 );
703 assert_eq!(
704 bottom_bar.max.x, 600,
705 "bottom_bar_rect should span full width"
706 );
707 assert!(
708 bottom_bar.min.y < bottom_bar.max.y,
709 "bottom_bar_rect should have positive height"
710 );
711 }
712
713 #[test]
714 fn test_tap_in_bottom_bar_is_consumed() {
715 let mut rq = RenderQueue::new();
716 let mut context = create_test_context();
717 let mut file_chooser = create_test_file_chooser(&mut rq, &mut context);
718
719 let (hub, _receiver) = channel();
720 let mut bus = VecDeque::new();
721
722 let bottom_bar = file_chooser.bottom_bar_rect();
723 let center = Point {
724 x: (bottom_bar.min.x + bottom_bar.max.x) / 2,
725 y: (bottom_bar.min.y + bottom_bar.max.y) / 2,
726 };
727
728 let tap_event = Event::Gesture(GestureEvent::Tap(center));
729 let consumed = file_chooser.handle_event(&tap_event, &hub, &mut bus, &mut rq, &mut context);
730
731 assert!(consumed, "Tap event in bottom bar should be consumed");
732 assert!(
733 bus.is_empty(),
734 "Consumed event should not be forwarded to bus"
735 );
736 }
737
738 #[test]
739 fn test_tap_outside_bottom_bar_not_consumed() {
740 let mut rq = RenderQueue::new();
741 let mut context = create_test_context();
742 let mut file_chooser = create_test_file_chooser(&mut rq, &mut context);
743
744 let (hub, _receiver) = channel();
745 let mut bus = VecDeque::new();
746
747 let bottom_bar = file_chooser.bottom_bar_rect();
748 let entry_point = Point {
749 x: 300,
750 y: bottom_bar.min.y - 50,
751 };
752
753 let tap_event = Event::Gesture(GestureEvent::Tap(entry_point));
754 let consumed = file_chooser.handle_event(&tap_event, &hub, &mut bus, &mut rq, &mut context);
755
756 assert!(
757 !consumed,
758 "Tap event outside bottom bar should not be consumed"
759 );
760 }
761
762 #[test]
763 fn test_page_event_still_handled() {
764 let mut rq = RenderQueue::new();
765 let mut context = create_test_context();
766 let mut file_chooser = create_test_file_chooser(&mut rq, &mut context);
767
768 let (hub, _receiver) = channel();
769 let mut bus = VecDeque::new();
770
771 let page_event = Event::Page(CycleDir::Next);
772 let consumed =
773 file_chooser.handle_event(&page_event, &hub, &mut bus, &mut rq, &mut context);
774
775 assert!(consumed, "Page event should still be handled correctly");
776 }
777
778 #[test]
779 fn test_tap_on_bottom_bar_edge_is_consumed() {
780 let mut rq = RenderQueue::new();
781 let mut context = create_test_context();
782 let mut file_chooser = create_test_file_chooser(&mut rq, &mut context);
783
784 let (hub, _receiver) = channel();
785 let mut bus = VecDeque::new();
786
787 let bottom_bar = file_chooser.bottom_bar_rect();
788 let edge_point = Point {
789 x: bottom_bar.min.x + 1,
790 y: bottom_bar.min.y + 1,
791 };
792
793 let tap_event = Event::Gesture(GestureEvent::Tap(edge_point));
794 let consumed = file_chooser.handle_event(&tap_event, &hub, &mut bus, &mut rq, &mut context);
795
796 assert!(consumed, "Tap event on bottom bar edge should be consumed");
797 }
798
799 #[test]
802 fn test_list_directory_returns_only_files() {
803 let temp_dir = tempfile::tempdir().expect("Failed to create temp directory");
804 let temp_path = temp_dir.path();
805
806 fs::write(temp_path.join("alpha.txt"), "content").unwrap();
807 fs::write(temp_path.join("beta.txt"), "content").unwrap();
808 fs::write(temp_path.join("gamma.txt"), "content").unwrap();
809
810 fs::create_dir(temp_path.join("subdir1")).unwrap();
811 fs::create_dir(temp_path.join("subdir2")).unwrap();
812
813 let mut rq = RenderQueue::new();
814 let mut context = create_test_context();
815 let file_chooser = create_test_file_chooser_with_path(
816 &mut rq,
817 &mut context,
818 temp_path.to_path_buf(),
819 SelectionMode::File,
820 );
821
822 let entries = file_chooser.list_directory(temp_path).unwrap();
823
824 assert_eq!(
825 entries.len(),
826 3,
827 "Should only return files, not directories"
828 );
829 for entry in &entries {
830 assert!(
831 !entry.is_dir,
832 "Entry {} should not be a directory",
833 entry.name
834 );
835 }
836
837 assert_eq!(entries[0].name, "alpha.txt");
838 assert_eq!(entries[1].name, "beta.txt");
839 assert_eq!(entries[2].name, "gamma.txt");
840 }
841
842 #[test]
843 fn test_list_directory_returns_empty_for_empty_directory() {
844 let temp_dir = tempfile::tempdir().expect("Failed to create temp directory");
845 let temp_path = temp_dir.path();
846
847 let mut rq = RenderQueue::new();
848 let mut context = create_test_context();
849 let file_chooser = create_test_file_chooser_with_path(
850 &mut rq,
851 &mut context,
852 temp_path.to_path_buf(),
853 SelectionMode::File,
854 );
855
856 let entries = file_chooser.list_directory(temp_path).unwrap();
857
858 assert!(
859 entries.is_empty(),
860 "Empty directory should return empty list"
861 );
862 }
863
864 #[test]
865 fn test_list_directory_returns_error_for_nonexistent_path() {
866 let mut rq = RenderQueue::new();
867 let mut context = create_test_context();
868 let file_chooser = create_test_file_chooser(&mut rq, &mut context);
869
870 let result =
871 file_chooser.list_directory(Path::new("/nonexistent/path/that/does/not/exist"));
872
873 assert!(result.is_err(), "Should return error for nonexistent path");
874 assert!(
875 result.unwrap_err().contains("does not exist"),
876 "Error should mention path does not exist"
877 );
878 }
879
880 #[test]
881 fn test_list_directory_returns_error_for_file_path() {
882 let temp_dir = tempfile::tempdir().expect("Failed to create temp directory");
883 let temp_path = temp_dir.path();
884 let file_path = temp_path.join("test_file.txt");
885 fs::write(&file_path, "content").unwrap();
886
887 let mut rq = RenderQueue::new();
888 let mut context = create_test_context();
889 let file_chooser = create_test_file_chooser(&mut rq, &mut context);
890
891 let result = file_chooser.list_directory(&file_path);
892
893 assert!(result.is_err(), "Should return error when path is a file");
894 assert!(
895 result.unwrap_err().contains("not a directory"),
896 "Error should mention path is not a directory"
897 );
898 }
899
900 #[test]
901 fn test_list_directory_sorts_alphabetically_case_insensitive() {
902 let temp_dir = tempfile::tempdir().expect("Failed to create temp directory");
903 let temp_path = temp_dir.path();
904
905 fs::write(temp_path.join("Zebra.txt"), "content").unwrap();
906 fs::write(temp_path.join("alpha.txt"), "content").unwrap();
907 fs::write(temp_path.join("BETA.txt"), "content").unwrap();
908
909 let mut rq = RenderQueue::new();
910 let mut context = create_test_context();
911 let file_chooser = create_test_file_chooser_with_path(
912 &mut rq,
913 &mut context,
914 temp_path.to_path_buf(),
915 SelectionMode::File,
916 );
917
918 let entries = file_chooser.list_directory(temp_path).unwrap();
919
920 assert_eq!(entries.len(), 3);
921 assert_eq!(entries[0].name, "alpha.txt");
922 assert_eq!(entries[1].name, "BETA.txt");
923 assert_eq!(entries[2].name, "Zebra.txt");
924 }
925
926 #[test]
927 fn test_list_directory_returns_no_files_in_directory_mode() {
928 let temp_dir = tempfile::tempdir().expect("Failed to create temp directory");
929 let temp_path = temp_dir.path();
930
931 fs::write(temp_path.join("file1.txt"), "content").unwrap();
932 fs::write(temp_path.join("file2.txt"), "content").unwrap();
933 fs::create_dir(temp_path.join("subdir")).unwrap();
934
935 let mut rq = RenderQueue::new();
936 let mut context = create_test_context();
937 let file_chooser = create_test_file_chooser_with_path(
938 &mut rq,
939 &mut context,
940 temp_path.to_path_buf(),
941 SelectionMode::Directory,
942 );
943
944 let entries = file_chooser.list_directory(temp_path).unwrap();
945
946 assert_eq!(
947 entries.len(),
948 0,
949 "Directory mode should return no files - only navigation bar shows directories"
950 );
951 }
952
953 #[test]
956 fn test_select_current_folder_entry_in_directory_mode() {
957 let temp_dir = tempfile::tempdir().expect("Failed to create temp directory");
958 let temp_path = temp_dir.path();
959
960 let mut rq = RenderQueue::new();
961 let mut context = create_test_context();
962 let file_chooser = create_test_file_chooser_with_path(
963 &mut rq,
964 &mut context,
965 temp_path.to_path_buf(),
966 SelectionMode::Directory,
967 );
968
969 assert!(
970 !file_chooser.entries.is_empty(),
971 "Should have at least one entry (Select Current Folder)"
972 );
973 assert_eq!(
974 file_chooser.entries[0].name, SELECT_CURRENT_FOLDER_TEXT,
975 "Select Current Folder entry should be at index 0"
976 );
977 assert!(
978 file_chooser.entries[0].is_dir,
979 "Select Current Folder entry should be marked as directory"
980 );
981 }
982
983 #[test]
984 fn test_select_current_folder_entry_in_both_mode() {
985 let temp_dir = tempfile::tempdir().expect("Failed to create temp directory");
986 let temp_path = temp_dir.path();
987
988 let mut rq = RenderQueue::new();
989 let mut context = create_test_context();
990 let file_chooser = create_test_file_chooser_with_path(
991 &mut rq,
992 &mut context,
993 temp_path.to_path_buf(),
994 SelectionMode::Both,
995 );
996
997 assert!(
998 !file_chooser.entries.is_empty(),
999 "Should have at least one entry (Select Current Folder)"
1000 );
1001 assert_eq!(
1002 file_chooser.entries[0].name, SELECT_CURRENT_FOLDER_TEXT,
1003 "Select Current Folder entry should be at index 0"
1004 );
1005 }
1006
1007 #[test]
1008 fn test_no_select_current_folder_entry_in_file_mode() {
1009 let temp_dir = tempfile::tempdir().expect("Failed to create temp directory");
1010 let temp_path = temp_dir.path();
1011
1012 fs::write(temp_path.join("test.txt"), "content").unwrap();
1013
1014 let mut rq = RenderQueue::new();
1015 let mut context = create_test_context();
1016 let file_chooser = create_test_file_chooser_with_path(
1017 &mut rq,
1018 &mut context,
1019 temp_path.to_path_buf(),
1020 SelectionMode::File,
1021 );
1022
1023 for entry in &file_chooser.entries {
1024 assert_ne!(
1025 entry.name, SELECT_CURRENT_FOLDER_TEXT,
1026 "File mode should not contain Select Current Folder text"
1027 );
1028 }
1029 }
1030
1031 #[test]
1032 fn test_select_current_folder_entry_path_is_current_directory() {
1033 let temp_dir = tempfile::tempdir().expect("Failed to create temp directory");
1034 let temp_path = temp_dir.path();
1035
1036 let mut rq = RenderQueue::new();
1037 let mut context = create_test_context();
1038 let file_chooser = create_test_file_chooser_with_path(
1039 &mut rq,
1040 &mut context,
1041 temp_path.to_path_buf(),
1042 SelectionMode::Directory,
1043 );
1044
1045 assert_eq!(
1046 file_chooser.entries[0].path, temp_path,
1047 "Select Current Folder entry should point to current directory"
1048 );
1049 }
1050
1051 #[test]
1054 fn test_select_current_folder_selects_directory() {
1055 let temp_dir = tempfile::tempdir().expect("Failed to create temp directory");
1056 let temp_path = temp_dir.path();
1057
1058 let mut rq = RenderQueue::new();
1059 let mut context = create_test_context();
1060 let mut file_chooser = create_test_file_chooser_with_path(
1061 &mut rq,
1062 &mut context,
1063 temp_path.to_path_buf(),
1064 SelectionMode::Directory,
1065 );
1066
1067 let (hub, _receiver) = channel();
1068 let mut bus = VecDeque::new();
1069
1070 let select_event = Event::Select(EntryId::FileEntry(temp_path.to_path_buf()));
1071 let consumed =
1072 file_chooser.handle_event(&select_event, &hub, &mut bus, &mut rq, &mut context);
1073
1074 assert!(
1075 consumed,
1076 "Select event for current folder should be consumed"
1077 );
1078
1079 let mut found_close_event = false;
1080 let mut found_file_chooser_closed = false;
1081
1082 for event in &bus {
1083 match event {
1084 Event::FileChooserClosed(Some(path)) => {
1085 found_file_chooser_closed = true;
1086 assert_eq!(
1087 path, &temp_path,
1088 "FileChooserClosed should contain the selected directory path"
1089 );
1090 }
1091 Event::Close(ViewId::FileChooser) => {
1092 found_close_event = true;
1093 }
1094 _ => {}
1095 }
1096 }
1097
1098 assert!(
1099 found_file_chooser_closed,
1100 "FileChooserClosed event should be in bus"
1101 );
1102 assert!(
1103 found_close_event,
1104 "Close FileChooser event should be in bus"
1105 );
1106 }
1107
1108 #[test]
1109 fn test_select_current_folder_in_both_mode() {
1110 let temp_dir = tempfile::tempdir().expect("Failed to create temp directory");
1111 let temp_path = temp_dir.path();
1112
1113 let mut rq = RenderQueue::new();
1114 let mut context = create_test_context();
1115 let mut file_chooser = create_test_file_chooser_with_path(
1116 &mut rq,
1117 &mut context,
1118 temp_path.to_path_buf(),
1119 SelectionMode::Both,
1120 );
1121
1122 let (hub, _receiver) = channel();
1123 let mut bus = VecDeque::new();
1124
1125 let select_event = Event::Select(EntryId::FileEntry(temp_path.to_path_buf()));
1126 let consumed =
1127 file_chooser.handle_event(&select_event, &hub, &mut bus, &mut rq, &mut context);
1128
1129 assert!(
1130 consumed,
1131 "Select event for current folder should be consumed in Both mode"
1132 );
1133
1134 let found_close = bus
1135 .iter()
1136 .any(|e| matches!(e, Event::Close(ViewId::FileChooser)));
1137 assert!(
1138 found_close,
1139 "Close event should be sent when selecting current folder in Both mode"
1140 );
1141 }
1142
1143 #[test]
1146 fn test_file_mode_rejects_directory_selection() {
1147 let temp_dir = tempfile::tempdir().expect("Failed to create temp directory");
1148 let temp_path = temp_dir.path();
1149
1150 let mut rq = RenderQueue::new();
1151 let mut context = create_test_context();
1152 let mut file_chooser = create_test_file_chooser_with_path(
1153 &mut rq,
1154 &mut context,
1155 temp_path.to_path_buf(),
1156 SelectionMode::File,
1157 );
1158
1159 let (hub, _receiver) = channel();
1160 let mut bus = VecDeque::new();
1161
1162 let subdir_path = temp_path.join("subdir");
1163 fs::create_dir(&subdir_path).unwrap();
1164 let select_event = Event::Select(EntryId::FileEntry(subdir_path));
1165
1166 let consumed =
1167 file_chooser.handle_event(&select_event, &hub, &mut bus, &mut rq, &mut context);
1168
1169 assert!(consumed, "Select event should be consumed");
1170 assert!(
1171 bus.is_empty(),
1172 "No events should be sent when rejecting directory in File mode"
1173 );
1174 }
1175
1176 #[test]
1177 fn test_directory_mode_accepts_directory_selection() {
1178 let temp_dir = tempfile::tempdir().expect("Failed to create temp directory");
1179 let temp_path = temp_dir.path();
1180
1181 let mut rq = RenderQueue::new();
1182 let mut context = create_test_context();
1183 let mut file_chooser = create_test_file_chooser_with_path(
1184 &mut rq,
1185 &mut context,
1186 temp_path.to_path_buf(),
1187 SelectionMode::Directory,
1188 );
1189
1190 let (hub, _receiver) = channel();
1191 let mut bus = VecDeque::new();
1192
1193 let select_event = Event::Select(EntryId::FileEntry(temp_path.to_path_buf()));
1194 let consumed =
1195 file_chooser.handle_event(&select_event, &hub, &mut bus, &mut rq, &mut context);
1196
1197 assert!(consumed, "Select event should be consumed");
1198
1199 let found_close = bus
1200 .iter()
1201 .any(|e| matches!(e, Event::Close(ViewId::FileChooser)));
1202 assert!(
1203 found_close,
1204 "Close event should be sent when selecting directory in Directory mode"
1205 );
1206 }
1207
1208 #[test]
1209 fn test_directory_mode_rejects_file_selection() {
1210 let temp_dir = tempfile::tempdir().expect("Failed to create temp directory");
1211 let temp_path = temp_dir.path();
1212
1213 let file_path = temp_path.join("test.txt");
1214 fs::write(&file_path, "content").unwrap();
1215
1216 let mut rq = RenderQueue::new();
1217 let mut context = create_test_context();
1218 let mut file_chooser = create_test_file_chooser_with_path(
1219 &mut rq,
1220 &mut context,
1221 temp_path.to_path_buf(),
1222 SelectionMode::Directory,
1223 );
1224
1225 let (hub, _receiver) = channel();
1226 let mut bus = VecDeque::new();
1227
1228 let select_event = Event::Select(EntryId::FileEntry(file_path));
1229
1230 let consumed =
1231 file_chooser.handle_event(&select_event, &hub, &mut bus, &mut rq, &mut context);
1232
1233 assert!(consumed, "Select event should be consumed");
1234 assert!(
1235 bus.is_empty(),
1236 "No events should be sent when rejecting file in Directory mode"
1237 );
1238 }
1239
1240 #[test]
1241 fn test_both_mode_accepts_file_selection() {
1242 let temp_dir = tempfile::tempdir().expect("Failed to create temp directory");
1243 let temp_path = temp_dir.path();
1244
1245 let file_path = temp_path.join("test.txt");
1246 fs::write(&file_path, "content").unwrap();
1247
1248 let mut rq = RenderQueue::new();
1249 let mut context = create_test_context();
1250 let mut file_chooser = create_test_file_chooser_with_path(
1251 &mut rq,
1252 &mut context,
1253 temp_path.to_path_buf(),
1254 SelectionMode::Both,
1255 );
1256
1257 let (hub, _receiver) = channel();
1258 let mut bus = VecDeque::new();
1259
1260 let select_event = Event::Select(EntryId::FileEntry(file_path.clone()));
1261 let consumed =
1262 file_chooser.handle_event(&select_event, &hub, &mut bus, &mut rq, &mut context);
1263
1264 assert!(consumed, "Select event should be consumed");
1265
1266 let found_file_chooser_closed = bus
1267 .iter()
1268 .any(|e| matches!(e, Event::FileChooserClosed(Some(path)) if path == &file_path));
1269 assert!(
1270 found_file_chooser_closed,
1271 "FileChooserClosed should be sent with file path in Both mode"
1272 );
1273 }
1274
1275 #[test]
1276 fn test_both_mode_accepts_directory_selection() {
1277 let temp_dir = tempfile::tempdir().expect("Failed to create temp directory");
1278 let temp_path = temp_dir.path();
1279
1280 let mut rq = RenderQueue::new();
1281 let mut context = create_test_context();
1282 let mut file_chooser = create_test_file_chooser_with_path(
1283 &mut rq,
1284 &mut context,
1285 temp_path.to_path_buf(),
1286 SelectionMode::Both,
1287 );
1288
1289 let (hub, _receiver) = channel();
1290 let mut bus = VecDeque::new();
1291
1292 let select_event = Event::Select(EntryId::FileEntry(temp_path.to_path_buf()));
1293 let consumed =
1294 file_chooser.handle_event(&select_event, &hub, &mut bus, &mut rq, &mut context);
1295
1296 assert!(consumed, "Select event should be consumed");
1297
1298 let found_file_chooser_closed = bus
1299 .iter()
1300 .any(|e| matches!(e, Event::FileChooserClosed(Some(path)) if path == temp_path));
1301 assert!(
1302 found_file_chooser_closed,
1303 "FileChooserClosed should be sent with directory path in Both mode"
1304 );
1305 }
1306
1307 #[test]
1310 fn test_navigation_bar_is_initialized() {
1311 let mut rq = RenderQueue::new();
1312 let mut context = create_test_context();
1313 let file_chooser = create_test_file_chooser(&mut rq, &mut context);
1314
1315 let nav_bar_child = file_chooser.children.get(file_chooser.nav_bar_index);
1316 assert!(
1317 nav_bar_child.is_some(),
1318 "Navigation bar should be present at nav_bar_index"
1319 );
1320
1321 let can_downcast = nav_bar_child
1322 .unwrap()
1323 .as_any()
1324 .downcast_ref::<StackNavigationBar<DirectoryNavigationProvider>>()
1325 .is_some();
1326 assert!(
1327 can_downcast,
1328 "Child at nav_bar_index should be StackNavigationBar"
1329 );
1330 }
1331
1332 #[test]
1333 fn test_navigate_to_updates_navigation_bar() {
1334 let temp_dir = tempfile::tempdir().expect("Failed to create temp directory");
1335 let temp_path = temp_dir.path();
1336
1337 let mut rq = RenderQueue::new();
1338 let mut context = create_test_context();
1339 let mut file_chooser = create_test_file_chooser_with_path(
1340 &mut rq,
1341 &mut context,
1342 temp_path.to_path_buf(),
1343 SelectionMode::File,
1344 );
1345
1346 let initial_path = file_chooser.current_path.clone();
1347
1348 let subdir_path = temp_path.join("subdir");
1349 fs::create_dir(&subdir_path).unwrap();
1350
1351 file_chooser.navigate_to(subdir_path.clone(), &mut rq, &mut context);
1352
1353 assert_eq!(
1354 file_chooser.current_path, subdir_path,
1355 "Current path should be updated after navigation"
1356 );
1357 assert_ne!(
1358 file_chooser.current_path, initial_path,
1359 "Current path should have changed"
1360 );
1361 }
1362
1363 #[test]
1364 fn test_toggle_select_directory_event_navigates() {
1365 let temp_dir = tempfile::tempdir().expect("Failed to create temp directory");
1366 let temp_path = temp_dir.path();
1367
1368 let subdir_path = temp_path.join("navtarget");
1369 fs::create_dir(&subdir_path).unwrap();
1370
1371 let mut rq = RenderQueue::new();
1372 let mut context = create_test_context();
1373 let mut file_chooser = create_test_file_chooser_with_path(
1374 &mut rq,
1375 &mut context,
1376 temp_path.to_path_buf(),
1377 SelectionMode::File,
1378 );
1379
1380 let (hub, _receiver) = channel();
1381 let mut bus = VecDeque::new();
1382
1383 let initial_path = file_chooser.current_path.clone();
1384
1385 let toggle_event = Event::ToggleSelectDirectory(subdir_path.clone());
1386 let consumed =
1387 file_chooser.handle_event(&toggle_event, &hub, &mut bus, &mut rq, &mut context);
1388
1389 assert!(consumed, "ToggleSelectDirectory event should be consumed");
1390 assert_eq!(
1391 file_chooser.current_path, subdir_path,
1392 "Current path should be updated to subdirectory"
1393 );
1394 assert_ne!(
1395 file_chooser.current_path, initial_path,
1396 "Current path should have changed"
1397 );
1398 }
1399
1400 #[test]
1401 fn test_navigation_bar_resized_event_consumed() {
1402 let temp_dir = tempfile::tempdir().expect("Failed to create temp directory");
1403 let temp_path = temp_dir.path();
1404
1405 fs::write(temp_path.join("test.txt"), "content").unwrap();
1406
1407 let mut rq = RenderQueue::new();
1408 let mut context = create_test_context();
1409 let mut file_chooser = create_test_file_chooser_with_path(
1410 &mut rq,
1411 &mut context,
1412 temp_path.to_path_buf(),
1413 SelectionMode::File,
1414 );
1415
1416 let (hub, _receiver) = channel();
1417 let mut bus = VecDeque::new();
1418
1419 let resized_event = Event::NavigationBarResized(50);
1420 let consumed =
1421 file_chooser.handle_event(&resized_event, &hub, &mut bus, &mut rq, &mut context);
1422
1423 assert!(
1424 consumed,
1425 "NavigationBarResized event should be consumed by FileChooser"
1426 );
1427 }
1428}