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 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 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 } 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 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}