1use 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
18const ROW_HEIGHT: i32 = 24;
22
23const ICON_LEFT_MARGIN: i32 = 4;
25
26const ICON_TEXT_GAP: i32 = 6;
28
29const ICON_TARGET_HEIGHT: i32 = 20;
31
32const GREYED_OUT_ALPHA: u8 = 80;
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum EntryKind {
40 Device,
42 Directory,
44 WavFile,
46 OtherFile,
48}
49
50#[derive(Debug, Clone)]
52pub struct FileEntry {
53 pub name: String,
55 pub kind: EntryKind,
57}
58
59impl FileEntry {
60 pub fn is_interactive(&self) -> bool {
62 !matches!(self.kind, EntryKind::OtherFile)
63 }
64
65 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum StorageBrowserError {
79 Unavailable,
81}
82
83pub trait StorageBrowser {
88 fn list_devices(&mut self) -> Vec<FileEntry>;
90
91 fn list_directory(
97 &mut self,
98 device_index: usize,
99 path: &str,
100 ) -> Result<Vec<FileEntry>, StorageBrowserError>;
101}
102
103#[derive(Debug, Clone)]
107enum BrowseLevel {
108 DeviceList,
110 Directory { device_index: usize, path: String },
112}
113
114type FileSelectedCallback = dyn FnMut(&str);
126
127pub struct FileBrowser {
128 bounds: Rect,
129 pub style: Style,
131 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 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 pub fn set_font(&mut self, font: &'static dyn FontMetrics) {
165 self.font.set(font);
166 }
167
168 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 pub fn has_pending_navigation(&self) -> bool {
178 self.pending_nav.is_some()
179 }
180
181 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 pub fn selected_entry(&self) -> Option<&FileEntry> {
203 self.selected.and_then(|i| self.entries.get(i))
204 }
205
206 pub fn entries(&self) -> &[FileEntry] {
208 &self.entries
209 }
210
211 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 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 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 if path == "/" {
256 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 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 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 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 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 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 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 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; 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 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}