cadmus_core/view/file_chooser/
mod.rs

1mod breadcrumb;
2mod file_entry;
3
4use self::breadcrumb::Breadcrumb;
5pub use self::file_entry::FileEntry;
6
7use crate::color::{BLACK, WHITE};
8use crate::context::Context;
9use crate::device::CURRENT_DEVICE;
10use crate::font::Fonts;
11use crate::framebuffer::{Framebuffer, UpdateMode};
12use crate::geom::{halves, CycleDir, Rectangle};
13use crate::gesture::GestureEvent;
14use crate::unit::scale_by_dpi;
15use crate::view::filler::Filler;
16use crate::view::icon::Icon;
17use crate::view::label::Label;
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#[derive(Debug, Clone)]
27pub struct FileEntryData {
28    pub path: PathBuf,
29    pub name: String,
30    pub size: Option<u64>,
31    pub modified: Option<SystemTime>,
32    pub is_dir: bool,
33}
34
35#[derive(Debug, Copy, Clone, PartialEq, Eq)]
36pub enum SelectionMode {
37    File,
38    Directory,
39    Both,
40}
41
42struct FileChooserLayout {
43    thickness: i32,
44    small_thickness: i32,
45    big_thickness: i32,
46    small_height: i32,
47    big_height: i32,
48}
49
50impl FileChooserLayout {
51    fn new(dpi: u16) -> Self {
52        let thickness = scale_by_dpi(THICKNESS_MEDIUM, dpi) as i32;
53        let (small_thickness, big_thickness) = halves(thickness);
54        let small_height = scale_by_dpi(SMALL_BAR_HEIGHT, dpi) as i32;
55        let big_height = scale_by_dpi(SMALL_BAR_HEIGHT, dpi) as i32;
56
57        Self {
58            thickness,
59            small_thickness,
60            big_thickness,
61            small_height,
62            big_height,
63        }
64    }
65
66    fn top_bar_rect(&self, rect: &Rectangle) -> Rectangle {
67        rect![
68            rect.min.x,
69            rect.min.y,
70            rect.max.x,
71            rect.min.y + self.small_height - self.small_thickness
72        ]
73    }
74
75    fn first_separator_rect(&self, rect: &Rectangle) -> Rectangle {
76        rect![
77            rect.min.x,
78            rect.min.y + self.small_height - self.small_thickness,
79            rect.max.x,
80            rect.min.y + self.small_height + self.big_thickness
81        ]
82    }
83
84    fn breadcrumb_rect(&self, rect: &Rectangle) -> Rectangle {
85        rect![
86            rect.min.x,
87            rect.min.y + self.small_height + self.big_thickness,
88            rect.max.x,
89            rect.min.y + self.small_height + self.big_thickness + self.small_height
90                - self.thickness
91        ]
92    }
93
94    fn second_separator_rect(&self, rect: &Rectangle) -> Rectangle {
95        rect![
96            rect.min.x,
97            rect.min.y + 2 * self.small_height + self.big_thickness - self.thickness,
98            rect.max.x,
99            rect.min.y + 2 * self.small_height + self.big_thickness
100        ]
101    }
102}
103
104pub struct FileChooser {
105    id: Id,
106    rect: Rectangle,
107    children: Vec<Box<dyn View>>,
108    current_path: PathBuf,
109    entries: Vec<FileEntryData>,
110    current_page: usize,
111    pages_count: usize,
112    mode: SelectionMode,
113    breadcrumb_index: usize,
114    entries_start_index: usize,
115    error_message: Option<String>,
116
117    /// The path that was selected by the user.
118    /// This is used to determine how the file chooser should be closed.
119    selected_path: Option<PathBuf>,
120
121    bottom_bar_rect: Rectangle,
122}
123
124impl FileChooser {
125    fn create_separator(rect: Rectangle) -> Box<dyn View> {
126        Box::new(Filler::new(rect, BLACK))
127    }
128
129    fn get_title_for_mode(mode: SelectionMode) -> &'static str {
130        match mode {
131            SelectionMode::File => "Select File",
132            SelectionMode::Directory => "Select Folder",
133            SelectionMode::Both => "Select File or Folder",
134        }
135    }
136
137    #[inline]
138    fn build_children(
139        rect: Rectangle,
140        initial_path: &Path,
141        mode: SelectionMode,
142        layout: &FileChooserLayout,
143        context: &mut Context,
144    ) -> (Vec<Box<dyn View>>, usize) {
145        let mut children = Vec::new();
146
147        let background = Filler::new(rect, WHITE);
148        children.push(Box::new(background) as Box<dyn View>);
149
150        let title = Self::get_title_for_mode(mode);
151        let top_bar = TopBar::new(
152            layout.top_bar_rect(&rect),
153            TopBarVariant::Cancel(Event::Close(ViewId::FileChooser)),
154            title.to_string(),
155            context,
156        );
157        children.push(Box::new(top_bar) as Box<dyn View>);
158
159        children.push(Self::create_separator(layout.first_separator_rect(&rect)));
160
161        let breadcrumb_index = children.len();
162        let breadcrumb = Breadcrumb::new(layout.breadcrumb_rect(&rect), initial_path);
163        children.push(Box::new(breadcrumb) as Box<dyn View>);
164
165        children.push(Self::create_separator(layout.second_separator_rect(&rect)));
166
167        (children, breadcrumb_index)
168    }
169
170    pub fn new(
171        rect: Rectangle,
172        initial_path: PathBuf,
173        mode: SelectionMode,
174        _hub: &Hub,
175        rq: &mut RenderQueue,
176        context: &mut Context,
177    ) -> FileChooser {
178        let id = ID_FEEDER.next();
179        let dpi = CURRENT_DEVICE.dpi;
180        let layout = FileChooserLayout::new(dpi);
181
182        let (children, breadcrumb_index) =
183            Self::build_children(rect, &initial_path, mode, &layout, context);
184        let entries_start_index = children.len();
185
186        rq.add(RenderData::new(id, rect, UpdateMode::Gui));
187
188        let mut file_chooser = FileChooser {
189            id,
190            rect,
191            children,
192            current_path: initial_path,
193            entries: Vec::new(),
194            current_page: 0,
195            pages_count: 1,
196            mode,
197            breadcrumb_index,
198            entries_start_index,
199            error_message: None,
200            selected_path: None,
201            bottom_bar_rect: Rectangle::default(),
202        };
203
204        file_chooser.navigate_to(file_chooser.current_path.clone(), rq, context);
205
206        file_chooser
207    }
208
209    fn list_directory(&self, path: &Path) -> Result<Vec<FileEntryData>, String> {
210        let mut entries = Vec::new();
211
212        if !path.exists() {
213            return Err("Path does not exist".to_string());
214        }
215
216        if !path.is_dir() {
217            return Err("Path is not a directory".to_string());
218        }
219
220        match fs::read_dir(path) {
221            Ok(read_dir) => {
222                for entry in read_dir.flatten() {
223                    if let Ok(metadata) = entry.metadata() {
224                        let path = entry.path();
225
226                        if self.mode == SelectionMode::Directory && !metadata.is_dir() {
227                            continue;
228                        }
229
230                        let name = path
231                            .file_name()
232                            .unwrap_or_default()
233                            .to_string_lossy()
234                            .into_owned();
235
236                        let size = if metadata.is_file() {
237                            Some(metadata.len())
238                        } else {
239                            None
240                        };
241
242                        let modified = metadata.modified().ok();
243
244                        entries.push(FileEntryData {
245                            path,
246                            name,
247                            size,
248                            modified,
249                            is_dir: metadata.is_dir(),
250                        });
251                    }
252                }
253            }
254            Err(err) => {
255                return Err(format!("Failed to read directory: {}", err));
256            }
257        }
258
259        entries.sort_by(|a, b| match (a.is_dir, b.is_dir) {
260            (true, false) => std::cmp::Ordering::Less,
261            (false, true) => std::cmp::Ordering::Greater,
262            _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
263        });
264
265        Ok(entries)
266    }
267
268    fn navigate_to(&mut self, path: PathBuf, rq: &mut RenderQueue, context: &mut Context) {
269        self.current_path = path;
270        match self.list_directory(&self.current_path) {
271            Ok(entries) => {
272                self.entries = entries;
273                self.error_message = None;
274            }
275            Err(err) => {
276                self.entries = Vec::new();
277                self.error_message = Some(err);
278            }
279        }
280        self.current_page = 0;
281
282        self.update_breadcrumb(context);
283        self.update_entries_list(rq, context);
284    }
285
286    fn update_breadcrumb(&mut self, context: &mut Context) {
287        let breadcrumb = self.children[self.breadcrumb_index]
288            .as_mut()
289            .downcast_mut::<Breadcrumb>()
290            .unwrap();
291        breadcrumb.set_path(&self.current_path, &mut context.fonts);
292    }
293
294    fn calculate_entry_rect(
295        &self,
296        y_pos: i32,
297        index: usize,
298        max_lines: usize,
299        big_height: i32,
300        big_thickness: i32,
301        small_thickness: i32,
302    ) -> Rectangle {
303        let y_min = y_pos + if index > 0 { big_thickness } else { 0 };
304        let y_max = y_pos + big_height
305            - if index < max_lines - 1 {
306                small_thickness
307            } else {
308                0
309            };
310
311        rect![self.rect.min.x, y_min, self.rect.max.x, y_max]
312    }
313
314    fn add_error_label(&mut self, breadcrumb_bottom: i32, thickness: i32, big_height: i32) {
315        if let Some(error_msg) = &self.error_message {
316            let label = Label::new(
317                rect![
318                    self.rect.min.x,
319                    breadcrumb_bottom + thickness,
320                    self.rect.max.x,
321                    breadcrumb_bottom + thickness + big_height * 2
322                ],
323                format!("Error: {}", error_msg),
324                crate::view::Align::Center,
325            );
326            self.children.push(Box::new(label) as Box<dyn View>);
327        }
328    }
329
330    fn add_empty_label(&mut self, breadcrumb_bottom: i32, thickness: i32, big_height: i32) {
331        let label = Label::new(
332            rect![
333                self.rect.min.x,
334                breadcrumb_bottom + thickness,
335                self.rect.max.x,
336                breadcrumb_bottom + thickness + big_height
337            ],
338            "Empty directory".to_string(),
339            crate::view::Align::Center,
340        );
341        self.children.push(Box::new(label) as Box<dyn View>);
342    }
343
344    #[allow(clippy::too_many_arguments)]
345    /// Adds file entry views to the FileChooser's children for the current page.
346    ///
347    /// Each file entry is represented using the `FileEntry` component, which displays
348    /// the file or directory's name, icon, and metadata (such as size and modification date).
349    /// Between each entry, a separator is added using the `Filler` component to visually
350    /// separate the entries.
351    ///
352    /// Components used to build each file entry:
353    /// - [`FileEntry`]: Displays the file or directory entry, including icon, name, and metadata.
354    /// - [`Filler`]: Used as a separator between file entries for visual clarity.
355    ///
356    /// # Arguments
357    /// * `start_idx` - The starting index of the entries to display.
358    /// * `end_idx` - The ending index (exclusive) of the entries to display.
359    /// * `breadcrumb_bottom` - The y-coordinate below the breadcrumb bar.
360    /// * `thickness` - The thickness of the separator lines.
361    /// * `big_height` - The height of each file entry row.
362    /// * `big_thickness` - The thickness of the separator between entries.
363    /// * `small_thickness` - The thickness of the separator at the end of the list.
364    /// * `max_lines` - The maximum number of entries to display per page.
365    /// * `context`
366    fn add_file_entries(
367        &mut self,
368        start_idx: usize,
369        end_idx: usize,
370        breadcrumb_bottom: i32,
371        thickness: i32,
372        big_height: i32,
373        big_thickness: i32,
374        small_thickness: i32,
375        max_lines: usize,
376        context: &mut Context,
377    ) {
378        let mut y_pos = breadcrumb_bottom + thickness;
379
380        for (i, entry_data) in self.entries[start_idx..end_idx].iter().enumerate() {
381            let entry_rect = self.calculate_entry_rect(
382                y_pos,
383                i,
384                max_lines,
385                big_height,
386                big_thickness,
387                small_thickness,
388            );
389
390            let file_entry = FileEntry::new(entry_rect, entry_data.clone(), context);
391            self.children.push(Box::new(file_entry) as Box<dyn View>);
392
393            let y_max = entry_rect.max.y;
394            let separator_rect = rect![self.rect.min.x, y_max, self.rect.max.x, y_max + thickness];
395            self.children.push(Self::create_separator(separator_rect));
396
397            y_pos += big_height;
398        }
399    }
400
401    fn update_entries_list(&mut self, rq: &mut RenderQueue, context: &mut Context) {
402        self.children.drain(self.entries_start_index..);
403
404        let layout = FileChooserLayout::new(CURRENT_DEVICE.dpi);
405        let breadcrumb_bottom = self.children[self.breadcrumb_index].rect().max.y;
406        let available_height =
407            self.rect.max.y - breadcrumb_bottom - layout.thickness - layout.small_height;
408        let max_lines = (available_height / layout.big_height).max(1) as usize;
409
410        self.pages_count = (self.entries.len() as f32 / max_lines as f32).ceil() as usize;
411        if self.pages_count == 0 {
412            self.pages_count = 1;
413        }
414
415        let start_idx = self.current_page * max_lines;
416        let end_idx = (start_idx + max_lines).min(self.entries.len());
417
418        if self.error_message.is_some() {
419            self.add_error_label(breadcrumb_bottom, layout.thickness, layout.big_height);
420        } else if self.entries.is_empty() {
421            if self.mode == SelectionMode::Directory {
422                // don't show "Empty directory" when selecting directories
423            } else {
424                self.add_empty_label(breadcrumb_bottom, layout.thickness, layout.big_height);
425            }
426        } else {
427            self.add_file_entries(
428                start_idx,
429                end_idx,
430                breadcrumb_bottom,
431                layout.thickness,
432                layout.big_height,
433                layout.big_thickness,
434                layout.small_thickness,
435                max_lines,
436                context,
437            );
438        }
439
440        let separator_rect = rect![
441            self.rect.min.x,
442            self.rect.max.y - layout.small_height - layout.thickness,
443            self.rect.max.x,
444            self.rect.max.y - layout.small_height
445        ];
446        self.children.push(Self::create_separator(separator_rect));
447
448        self.create_bottom_bar();
449
450        rq.add(RenderData::new(self.id, self.rect, UpdateMode::Partial));
451    }
452
453    fn create_bottom_bar(&mut self) {
454        let dpi = CURRENT_DEVICE.dpi;
455        let small_height = scale_by_dpi(SMALL_BAR_HEIGHT, dpi) as i32;
456        let thickness = scale_by_dpi(THICKNESS_MEDIUM, dpi) as i32;
457        let (_, big_thickness) = halves(thickness);
458
459        let bottom_bar_rect = rect![
460            self.rect.min.x,
461            self.rect.max.y - small_height + big_thickness,
462            self.rect.max.x,
463            self.rect.max.y
464        ];
465
466        self.bottom_bar_rect = bottom_bar_rect;
467
468        let side = bottom_bar_rect.height() as i32;
469        let is_prev_disabled = self.pages_count < 2 || self.current_page == 0;
470        let is_next_disabled = self.pages_count < 2 || self.current_page == self.pages_count - 1;
471
472        let prev_rect = rect![bottom_bar_rect.min, bottom_bar_rect.min + side];
473        if is_prev_disabled {
474            let prev_filler = Filler::new(prev_rect, WHITE);
475            self.children.push(Box::new(prev_filler) as Box<dyn View>);
476        } else {
477            let prev_icon = Icon::new("arrow-left", prev_rect, Event::Page(CycleDir::Previous));
478            self.children.push(Box::new(prev_icon) as Box<dyn View>);
479        }
480
481        let page_label = PageLabel::new(
482            rect![
483                bottom_bar_rect.min.x + side,
484                bottom_bar_rect.min.y,
485                bottom_bar_rect.max.x - side,
486                bottom_bar_rect.max.y
487            ],
488            self.current_page,
489            self.pages_count,
490            false,
491        );
492        self.children.push(Box::new(page_label) as Box<dyn View>);
493
494        let next_rect = rect![bottom_bar_rect.max - side, bottom_bar_rect.max];
495        if is_next_disabled {
496            let next_filler = Filler::new(next_rect, WHITE);
497            self.children.push(Box::new(next_filler) as Box<dyn View>);
498        } else {
499            let next_icon = Icon::new("arrow-right", next_rect, Event::Page(CycleDir::Next));
500            self.children.push(Box::new(next_icon) as Box<dyn View>);
501        }
502    }
503
504    /// Selects the given item if it matches the selection mode.
505    /// Sends FileChooserClosed event with the selected path to the bus.
506    fn select_item(&mut self, path: PathBuf, bus: &mut Bus) {
507        let is_dir = path.is_dir();
508
509        let can_select = match self.mode {
510            SelectionMode::File => !is_dir,
511            SelectionMode::Directory => is_dir,
512            SelectionMode::Both => true,
513        };
514
515        if can_select {
516            self.selected_path = Some(path);
517            bus.push_back(Event::FileChooserClosed(self.selected_path.clone()));
518            bus.push_back(Event::Close(self.view_id().unwrap()));
519        }
520    }
521
522    fn go_to_page(&mut self, dir: CycleDir, rq: &mut RenderQueue, context: &mut Context) {
523        match dir {
524            CycleDir::Next => {
525                if self.current_page < self.pages_count - 1 {
526                    self.current_page += 1;
527                }
528            }
529            CycleDir::Previous => {
530                if self.current_page > 0 {
531                    self.current_page -= 1;
532                }
533            }
534        }
535        self.update_entries_list(rq, context);
536    }
537}
538
539impl View for FileChooser {
540    #[cfg_attr(feature = "otel", tracing::instrument(skip(self, _hub, bus, rq, context), fields(event = ?evt), ret(level=tracing::Level::TRACE)))]
541    fn handle_event(
542        &mut self,
543        evt: &Event,
544        _hub: &Hub,
545        bus: &mut Bus,
546        rq: &mut RenderQueue,
547        context: &mut Context,
548    ) -> bool {
549        match evt {
550            Event::SelectDirectory(path) => {
551                self.navigate_to(path.clone(), rq, context);
552                true
553            }
554            Event::Select(EntryId::FileEntry(path)) => {
555                self.select_item(path.clone(), bus);
556                true
557            }
558            Event::Hold(EntryId::FileEntry(path)) => {
559                self.select_item(path.clone(), bus);
560                true
561            }
562            Event::Page(dir) => {
563                self.go_to_page(*dir, rq, context);
564                true
565            }
566            Event::Gesture(GestureEvent::Tap(center)) if self.bottom_bar_rect.includes(*center) => {
567                true
568            }
569            _ => false,
570        }
571    }
572
573    #[cfg_attr(feature = "otel", tracing::instrument(skip(self, _fb, _fonts), fields(rect = ?_rect)))]
574    fn render(&self, _fb: &mut dyn Framebuffer, _rect: Rectangle, _fonts: &mut Fonts) {}
575
576    fn rect(&self) -> &Rectangle {
577        &self.rect
578    }
579
580    fn rect_mut(&mut self) -> &mut Rectangle {
581        &mut self.rect
582    }
583
584    fn children(&self) -> &Vec<Box<dyn View>> {
585        &self.children
586    }
587
588    fn children_mut(&mut self) -> &mut Vec<Box<dyn View>> {
589        &mut self.children
590    }
591
592    fn id(&self) -> Id {
593        self.id
594    }
595
596    fn view_id(&self) -> Option<ViewId> {
597        Some(ViewId::FileChooser)
598    }
599}
600
601#[cfg(test)]
602impl FileChooser {
603    pub fn bottom_bar_rect(&self) -> Rectangle {
604        self.bottom_bar_rect
605    }
606}
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611    use crate::context::test_helpers::create_test_context;
612    use crate::geom::Point;
613    use std::collections::VecDeque;
614    use std::sync::mpsc::channel;
615
616    fn create_test_file_chooser(rq: &mut RenderQueue, context: &mut Context) -> FileChooser {
617        let rect = rect![0, 0, 600, 800];
618        let path = PathBuf::from("/tmp");
619        let (hub, _receiver) = channel();
620        FileChooser::new(rect, path, SelectionMode::File, &hub, rq, context)
621    }
622
623    #[test]
624    fn test_bottom_bar_rect_stored_correctly() {
625        let mut rq = RenderQueue::new();
626        let mut context = create_test_context();
627        let file_chooser = create_test_file_chooser(&mut rq, &mut context);
628
629        let bottom_bar = file_chooser.bottom_bar_rect();
630
631        assert!(
632            bottom_bar.max.y > 0,
633            "bottom_bar_rect should be properly initialized"
634        );
635        assert_eq!(
636            bottom_bar.min.x, 0,
637            "bottom_bar_rect should start at left edge"
638        );
639        assert_eq!(
640            bottom_bar.max.x, 600,
641            "bottom_bar_rect should span full width"
642        );
643        assert!(
644            bottom_bar.min.y < bottom_bar.max.y,
645            "bottom_bar_rect should have positive height"
646        );
647    }
648
649    #[test]
650    fn test_tap_in_bottom_bar_is_consumed() {
651        let mut rq = RenderQueue::new();
652        let mut context = create_test_context();
653        let mut file_chooser = create_test_file_chooser(&mut rq, &mut context);
654
655        let (hub, _receiver) = channel();
656        let mut bus = VecDeque::new();
657
658        let bottom_bar = file_chooser.bottom_bar_rect();
659        let center = Point {
660            x: (bottom_bar.min.x + bottom_bar.max.x) / 2,
661            y: (bottom_bar.min.y + bottom_bar.max.y) / 2,
662        };
663
664        let tap_event = Event::Gesture(GestureEvent::Tap(center));
665        let consumed = file_chooser.handle_event(&tap_event, &hub, &mut bus, &mut rq, &mut context);
666
667        assert!(consumed, "Tap event in bottom bar should be consumed");
668        assert!(
669            bus.is_empty(),
670            "Consumed event should not be forwarded to bus"
671        );
672    }
673
674    #[test]
675    fn test_tap_outside_bottom_bar_not_consumed() {
676        let mut rq = RenderQueue::new();
677        let mut context = create_test_context();
678        let mut file_chooser = create_test_file_chooser(&mut rq, &mut context);
679
680        let (hub, _receiver) = channel();
681        let mut bus = VecDeque::new();
682
683        let bottom_bar = file_chooser.bottom_bar_rect();
684        let entry_point = Point {
685            x: 300,
686            y: bottom_bar.min.y - 50,
687        };
688
689        let tap_event = Event::Gesture(GestureEvent::Tap(entry_point));
690        let consumed = file_chooser.handle_event(&tap_event, &hub, &mut bus, &mut rq, &mut context);
691
692        assert!(
693            !consumed,
694            "Tap event outside bottom bar should not be consumed"
695        );
696    }
697
698    #[test]
699    fn test_page_event_still_handled() {
700        let mut rq = RenderQueue::new();
701        let mut context = create_test_context();
702        let mut file_chooser = create_test_file_chooser(&mut rq, &mut context);
703
704        let (hub, _receiver) = channel();
705        let mut bus = VecDeque::new();
706
707        let page_event = Event::Page(CycleDir::Next);
708        let consumed =
709            file_chooser.handle_event(&page_event, &hub, &mut bus, &mut rq, &mut context);
710
711        assert!(consumed, "Page event should still be handled correctly");
712    }
713
714    #[test]
715    fn test_tap_on_bottom_bar_edge_is_consumed() {
716        let mut rq = RenderQueue::new();
717        let mut context = create_test_context();
718        let mut file_chooser = create_test_file_chooser(&mut rq, &mut context);
719
720        let (hub, _receiver) = channel();
721        let mut bus = VecDeque::new();
722
723        let bottom_bar = file_chooser.bottom_bar_rect();
724        let edge_point = Point {
725            x: bottom_bar.min.x + 1,
726            y: bottom_bar.min.y + 1,
727        };
728
729        let tap_event = Event::Gesture(GestureEvent::Tap(edge_point));
730        let consumed = file_chooser.handle_event(&tap_event, &hub, &mut bus, &mut rq, &mut context);
731
732        assert!(consumed, "Tap event on bottom bar edge should be consumed");
733    }
734}