Skip to main content

rlvgl_ui/
file_browser.rs

1// SPDX-License-Identifier: MIT
2//! File browser widget for navigating storage devices and selecting files.
3//!
4//! Starts at the device level (QSPI flash, SD card) and lets users navigate
5//! directories via double-tap. Non-`.wav` files are greyed out.
6
7use alloc::boxed::Box;
8use alloc::string::String;
9use alloc::vec::Vec;
10use rlvgl_core::draw::draw_widget_bg;
11use rlvgl_core::event::Event;
12use rlvgl_core::font::{FontMetrics, WidgetFont, shape_text_ltr};
13use rlvgl_core::icon_bitmap::{ICON_FILE, ICON_FOLDER, IconBitmap};
14use rlvgl_core::renderer::Renderer;
15use rlvgl_core::style::Style;
16use rlvgl_core::widget::{Color, Rect, Widget};
17
18// ── Visual constants ───────────────────────────────────────────────────────
19
20/// Row height in display pixels (font height + padding).
21const ROW_HEIGHT: i32 = 24;
22
23/// Left margin before the icon.
24const ICON_LEFT_MARGIN: i32 = 4;
25
26/// Gap between the icon and the entry name text.
27const ICON_TEXT_GAP: i32 = 6;
28
29/// Target height for icons (matches `FONT_6X10` at 2x scale).
30const ICON_TARGET_HEIGHT: i32 = 20;
31
32/// Alpha value for greyed-out (non-interactive) entries.
33const GREYED_OUT_ALPHA: u8 = 80;
34
35// ── Data model ─────────────────────────────────────────────────────────────
36
37/// Kind of entry in the file browser.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum EntryKind {
40    /// A storage device (QSPI flash, SD card) shown at root level.
41    Device,
42    /// A directory that can be navigated into.
43    Directory,
44    /// A `.wav` file that can be selected/opened.
45    WavFile,
46    /// A non-`.wav` file — visible but greyed out and non-interactive.
47    OtherFile,
48}
49
50/// A single entry in the file browser listing.
51#[derive(Debug, Clone)]
52pub struct FileEntry {
53    /// Display name of the entry.
54    pub name: String,
55    /// Classification of the entry.
56    pub kind: EntryKind,
57}
58
59impl FileEntry {
60    /// Whether this entry responds to tap/double-tap.
61    pub fn is_interactive(&self) -> bool {
62        !matches!(self.kind, EntryKind::OtherFile)
63    }
64
65    /// Returns the appropriate icon bitmap for this entry kind.
66    fn icon(&self) -> &'static IconBitmap {
67        match self.kind {
68            EntryKind::Device | EntryKind::Directory => &ICON_FOLDER,
69            EntryKind::WavFile | EntryKind::OtherFile => &ICON_FILE,
70        }
71    }
72}
73
74// ── Storage trait ──────────────────────────────────────────────────────────
75
76/// Errors returned while listing storage devices or directories.
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum StorageBrowserError {
79    /// The requested device or directory could not be accessed.
80    Unavailable,
81}
82
83/// Abstraction for listing storage device contents.
84///
85/// Platform code implements this trait using the appropriate storage drivers
86/// (`embedded-sdmmc` for SD, `fatfs` for QSPI flash, etc.).
87pub trait StorageBrowser {
88    /// List root-level storage devices (e.g. "QSPI Flash", "SD Card").
89    fn list_devices(&mut self) -> Vec<FileEntry>;
90
91    /// List contents of a directory on the given device.
92    ///
93    /// `device_index` identifies which root device (from `list_devices`).
94    /// `path` is `"/"` for the device root or a subdirectory path like
95    /// `"/audio/drums"`.
96    fn list_directory(
97        &mut self,
98        device_index: usize,
99        path: &str,
100    ) -> Result<Vec<FileEntry>, StorageBrowserError>;
101}
102
103// ── Navigation state ───────────────────────────────────────────────────────
104
105/// Where the browser is currently looking.
106#[derive(Debug, Clone)]
107enum BrowseLevel {
108    /// Root device list.
109    DeviceList,
110    /// Inside a specific device at a directory path.
111    Directory { device_index: usize, path: String },
112}
113
114// ── Widget ─────────────────────────────────────────────────────────────────
115
116/// File browser widget.
117///
118/// Displays a list of entries (devices, directories, files) with icons.
119/// Single-tap selects, double-tap navigates or opens `.wav` files.
120/// Non-`.wav` files are dimmed and non-interactive.
121///
122/// Navigation is deferred: the widget sets a `pending_nav` flag on
123/// double-tap, and the application must call [`apply_navigation`](Self::apply_navigation)
124/// with a [`StorageBrowser`] to populate the new listing.
125type FileSelectedCallback = dyn FnMut(&str);
126
127pub struct FileBrowser {
128    bounds: Rect,
129    /// Visual style (background, border).
130    pub style: Style,
131    /// Text color for interactive entries.
132    pub text_color: Color,
133    entries: Vec<FileEntry>,
134    selected: Option<usize>,
135    scroll_offset: i32,
136    level: BrowseLevel,
137    pending_nav: Option<BrowseLevel>,
138    on_file_selected: Option<Box<FileSelectedCallback>>,
139    font: WidgetFont,
140}
141
142impl FileBrowser {
143    /// Create a new file browser rooted at the device list.
144    ///
145    /// Call [`apply_navigation`](Self::apply_navigation) with a
146    /// [`StorageBrowser`] to populate the initial device list.
147    pub fn new(bounds: Rect) -> Self {
148        Self {
149            bounds,
150            style: Style::default(),
151            text_color: Color(255, 255, 255, 255),
152            entries: Vec::new(),
153            selected: None,
154            scroll_offset: 0,
155            level: BrowseLevel::DeviceList,
156            pending_nav: Some(BrowseLevel::DeviceList),
157            on_file_selected: None,
158            font: WidgetFont::new(),
159        }
160    }
161
162    /// Assign the font used to render entry names (FONT-00 §5); resolves to
163    /// `FONT_6X10` when unset.
164    pub fn set_font(&mut self, font: &'static dyn FontMetrics) {
165        self.font.set(font);
166    }
167
168    /// Set the callback invoked when a `.wav` file is double-tapped.
169    /// The argument is the full path to the selected file.
170    pub fn on_file_selected(mut self, cb: impl FnMut(&str) + 'static) -> Self {
171        self.on_file_selected = Some(Box::new(cb));
172        self
173    }
174
175    /// Whether a navigation is pending. The application should call
176    /// [`apply_navigation`](Self::apply_navigation) when this returns `true`.
177    pub fn has_pending_navigation(&self) -> bool {
178        self.pending_nav.is_some()
179    }
180
181    /// Apply the pending navigation using the given storage browser.
182    /// Populates the entry list for the new location.
183    pub fn apply_navigation(&mut self, storage: &mut dyn StorageBrowser) {
184        let Some(nav) = self.pending_nav.take() else {
185            return;
186        };
187
188        let entries = match &nav {
189            BrowseLevel::DeviceList => storage.list_devices(),
190            BrowseLevel::Directory { device_index, path } => storage
191                .list_directory(*device_index, path)
192                .unwrap_or_default(),
193        };
194
195        self.entries = entries;
196        self.selected = None;
197        self.scroll_offset = 0;
198        self.level = nav;
199    }
200
201    /// Currently selected entry, if any.
202    pub fn selected_entry(&self) -> Option<&FileEntry> {
203        self.selected.and_then(|i| self.entries.get(i))
204    }
205
206    /// The current entries being displayed.
207    pub fn entries(&self) -> &[FileEntry] {
208        &self.entries
209    }
210
211    /// Check if a point is within this widget's bounds.
212    fn hit_test(&self, x: i32, y: i32) -> bool {
213        x >= self.bounds.x
214            && x < self.bounds.x + self.bounds.width
215            && y >= self.bounds.y
216            && y < self.bounds.y + self.bounds.height
217    }
218
219    /// Map a y coordinate to an entry index, accounting for scroll.
220    fn index_at(&self, y: i32) -> Option<usize> {
221        if y < self.bounds.y || y >= self.bounds.y + self.bounds.height {
222            return None;
223        }
224        let local_y = y - self.bounds.y + self.scroll_offset;
225        if local_y < 0 {
226            return None;
227        }
228        let idx = (local_y / ROW_HEIGHT) as usize;
229        if idx < self.entries.len() {
230            Some(idx)
231        } else {
232            None
233        }
234    }
235
236    /// Handle double-tap navigation: navigate into device/directory or
237    /// select a `.wav` file.
238    fn navigate(&mut self, idx: usize) {
239        let entry = &self.entries[idx];
240        match entry.kind {
241            EntryKind::Device => {
242                self.pending_nav = Some(BrowseLevel::Directory {
243                    device_index: idx,
244                    path: String::from("/"),
245                });
246            }
247            EntryKind::Directory => {
248                if let BrowseLevel::Directory {
249                    device_index,
250                    ref path,
251                } = self.level
252                {
253                    let new_path = if entry.name == ".." {
254                        // Navigate up
255                        if path == "/" {
256                            // Back to device list
257                            self.pending_nav = Some(BrowseLevel::DeviceList);
258                            return;
259                        }
260                        let trimmed = path.trim_end_matches('/');
261                        match trimmed.rfind('/') {
262                            Some(0) => String::from("/"),
263                            Some(pos) => String::from(&trimmed[..pos]),
264                            None => String::from("/"),
265                        }
266                    } else {
267                        let base = path.trim_end_matches('/');
268                        let mut p = String::from(base);
269                        p.push('/');
270                        p.push_str(&entry.name);
271                        p
272                    };
273                    self.pending_nav = Some(BrowseLevel::Directory {
274                        device_index,
275                        path: new_path,
276                    });
277                }
278            }
279            EntryKind::WavFile => {
280                if let (Some(cb), BrowseLevel::Directory { path, .. }) =
281                    (self.on_file_selected.as_mut(), &self.level)
282                {
283                    let base = path.trim_end_matches('/');
284                    let mut full = String::from(base);
285                    full.push('/');
286                    full.push_str(&entry.name);
287                    cb(&full);
288                }
289            }
290            EntryKind::OtherFile => {}
291        }
292    }
293}
294
295impl Widget for FileBrowser {
296    fn bounds(&self) -> Rect {
297        self.bounds
298    }
299
300    fn widget_font_mut(&mut self) -> Option<&mut WidgetFont> {
301        Some(&mut self.font)
302    }
303
304    fn draw(&self, renderer: &mut dyn Renderer) {
305        draw_widget_bg(renderer, self.bounds, &self.style);
306
307        let base_alpha = self.style.alpha;
308        let icon_w = ICON_FOLDER.scaled_width(ICON_TARGET_HEIGHT);
309        let text_x = self.bounds.x + ICON_LEFT_MARGIN + icon_w + ICON_TEXT_GAP;
310
311        for (i, entry) in self.entries.iter().enumerate() {
312            let row_y = self.bounds.y + i as i32 * ROW_HEIGHT - self.scroll_offset;
313
314            // Skip rows outside visible area
315            if row_y + ROW_HEIGHT <= self.bounds.y || row_y >= self.bounds.y + self.bounds.height {
316                continue;
317            }
318
319            let alpha = if entry.is_interactive() {
320                base_alpha
321            } else {
322                GREYED_OUT_ALPHA
323            };
324
325            // Selection highlight
326            if self.selected == Some(i) {
327                renderer.blend_rect(
328                    Rect {
329                        x: self.bounds.x,
330                        y: row_y,
331                        width: self.bounds.width,
332                        height: ROW_HEIGHT,
333                    },
334                    Color(80, 120, 200, 80),
335                );
336            }
337
338            // Icon
339            let icon_y = row_y + (ROW_HEIGHT - ICON_TARGET_HEIGHT) / 2;
340            entry.icon().draw(
341                renderer,
342                self.bounds.x + ICON_LEFT_MARGIN,
343                icon_y,
344                ICON_TARGET_HEIGHT,
345                self.text_color.with_alpha(alpha),
346            );
347
348            // Text (baseline positioned near bottom of row)
349            let shaped = shape_text_ltr(
350                self.font.resolve(),
351                &entry.name,
352                (text_x, row_y + ROW_HEIGHT - 4),
353                0,
354            );
355            renderer.draw_text_shaped(&shaped, (0, 0), self.text_color.with_alpha(alpha));
356        }
357    }
358
359    fn handle_event(&mut self, event: &Event) -> bool {
360        match event {
361            Event::PressRelease { x, y } => {
362                if !self.hit_test(*x, *y) {
363                    return false;
364                }
365                if let Some(idx) = self.index_at(*y)
366                    && self.entries[idx].is_interactive()
367                {
368                    self.selected = Some(idx);
369                    return true;
370                }
371                false
372            }
373            Event::DoubleTap { x, y } => {
374                if !self.hit_test(*x, *y) {
375                    return false;
376                }
377                if let Some(idx) = self.index_at(*y)
378                    && self.entries[idx].is_interactive()
379                {
380                    self.navigate(idx);
381                    return true;
382                }
383                false
384            }
385            _ => false,
386        }
387    }
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393
394    fn sample_entries() -> Vec<FileEntry> {
395        alloc::vec![
396            FileEntry {
397                name: String::from(".."),
398                kind: EntryKind::Directory
399            },
400            FileEntry {
401                name: String::from("drums"),
402                kind: EntryKind::Directory
403            },
404            FileEntry {
405                name: String::from("kick.wav"),
406                kind: EntryKind::WavFile
407            },
408            FileEntry {
409                name: String::from("readme.txt"),
410                kind: EntryKind::OtherFile
411            },
412            FileEntry {
413                name: String::from("snare.wav"),
414                kind: EntryKind::WavFile
415            },
416        ]
417    }
418
419    #[test]
420    fn greyed_out_entries_not_selectable() {
421        let mut fb = FileBrowser::new(Rect {
422            x: 0,
423            y: 0,
424            width: 200,
425            height: 200,
426        });
427        fb.entries = sample_entries();
428
429        // Tap on "readme.txt" (index 3, row_y = 3*24 = 72..96)
430        let consumed = fb.handle_event(&Event::PressRelease { x: 50, y: 80 });
431        assert!(!consumed);
432        assert_eq!(fb.selected, None);
433    }
434
435    #[test]
436    fn interactive_entries_selectable() {
437        let mut fb = FileBrowser::new(Rect {
438            x: 0,
439            y: 0,
440            width: 200,
441            height: 200,
442        });
443        fb.entries = sample_entries();
444
445        // Tap on "kick.wav" (index 2, row_y = 2*24 = 48..72)
446        let consumed = fb.handle_event(&Event::PressRelease { x: 50, y: 55 });
447        assert!(consumed);
448        assert_eq!(fb.selected, Some(2));
449    }
450
451    #[test]
452    fn double_tap_directory_sets_pending_nav() {
453        let mut fb = FileBrowser::new(Rect {
454            x: 0,
455            y: 0,
456            width: 200,
457            height: 200,
458        });
459        fb.entries = sample_entries();
460        fb.level = BrowseLevel::Directory {
461            device_index: 0,
462            path: String::from("/"),
463        };
464
465        // Double-tap on "drums" (index 1, row_y = 24..48)
466        let consumed = fb.handle_event(&Event::DoubleTap { x: 50, y: 30 });
467        assert!(consumed);
468        assert!(fb.has_pending_navigation());
469    }
470
471    #[test]
472    fn double_tap_other_file_ignored() {
473        let mut fb = FileBrowser::new(Rect {
474            x: 0,
475            y: 0,
476            width: 200,
477            height: 200,
478        });
479        fb.entries = sample_entries();
480        fb.pending_nav = None; // clear initial pending nav
481
482        // Double-tap on "readme.txt" (index 3)
483        let consumed = fb.handle_event(&Event::DoubleTap { x: 50, y: 80 });
484        assert!(!consumed);
485        assert!(!fb.has_pending_navigation());
486    }
487
488    #[test]
489    fn navigate_up_from_root_goes_to_device_list() {
490        let mut fb = FileBrowser::new(Rect {
491            x: 0,
492            y: 0,
493            width: 200,
494            height: 200,
495        });
496        fb.entries = sample_entries();
497        fb.level = BrowseLevel::Directory {
498            device_index: 0,
499            path: String::from("/"),
500        };
501
502        // Double-tap on ".." (index 0, row_y = 0..24)
503        fb.handle_event(&Event::DoubleTap { x: 50, y: 10 });
504        assert!(fb.has_pending_navigation());
505        assert!(matches!(fb.pending_nav, Some(BrowseLevel::DeviceList)));
506    }
507
508    #[test]
509    fn is_interactive_classification() {
510        assert!(
511            FileEntry {
512                name: String::from("sd"),
513                kind: EntryKind::Device
514            }
515            .is_interactive()
516        );
517        assert!(
518            FileEntry {
519                name: String::from("d"),
520                kind: EntryKind::Directory
521            }
522            .is_interactive()
523        );
524        assert!(
525            FileEntry {
526                name: String::from("a.wav"),
527                kind: EntryKind::WavFile
528            }
529            .is_interactive()
530        );
531        assert!(
532            !FileEntry {
533                name: String::from("a.txt"),
534                kind: EntryKind::OtherFile
535            }
536            .is_interactive()
537        );
538    }
539}