cadmus_core/view/file_chooser/
mod.rs

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
26/// The text displayed for the "Select Current Folder" entry
27const 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    /// The path that was selected by the user.
121    /// This is used to determine how the file chooser should be closed.
122    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    /// Lists files in the given directory.
220    ///
221    /// In Directory mode, returns an empty list since directories are navigated
222    /// via the navigation bar and only the "Select Current Folder" special entry
223    /// is shown in the content area.
224    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    /// Adds file entry views to the FileChooser's children for the current page.
382    ///
383    /// Each file entry is represented using the `FileEntry` component, which displays
384    /// the file or directory's name, icon, and metadata (such as size and modification date).
385    /// Between each entry, a separator is added using the `Filler` component to visually
386    /// separate the entries.
387    ///
388    /// Components used to build each file entry:
389    /// - [`FileEntry`]: Displays the file or directory entry, including icon, name, and metadata.
390    /// - [`Filler`]: Used as a separator between file entries for visual clarity.
391    ///
392    /// # Arguments
393    /// * `start_idx` - The starting index of the entries to display.
394    /// * `end_idx` - The ending index (exclusive) of the entries to display.
395    /// * `nav_bar_bottom` - The y-coordinate below the breadcrumb bar.
396    /// * `thickness` - The thickness of the separator lines.
397    /// * `big_height` - The height of each file entry row.
398    /// * `big_thickness` - The thickness of the separator between entries.
399    /// * `small_thickness` - The thickness of the separator at the end of the list.
400    /// * `max_lines` - The maximum number of entries to display per page.
401    /// * `context`
402    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    /// Creates a special "Select Current Folder" entry when in Directory or Both mode.
535    /// This entry allows the user to select the current directory rather than navigating into it.
536    #[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    /// Selects the given item if it matches the selection mode.
551    /// Sends FileChooserClosed event with the selected path to the bus.
552    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    // Tests for list_directory returning only files
800
801    #[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    // Tests for "Select Current Folder" entry
954
955    #[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    // Tests for selecting the current folder
1052
1053    #[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    // Tests for mode-specific selection behavior
1144
1145    #[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    // Tests for navigation bar integration
1308
1309    #[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}