Skip to main content

ravenrs_widgets/lists/
file_browser.rs

1//! Folders and files, from whatever data the app has.
2
3use std::cell::RefCell;
4use std::collections::BTreeSet;
5use std::fmt;
6use std::rc::Rc;
7
8use ravenrs_core::{Border, Color, CursorIcon, Insets, Key, Modifiers, Role};
9use ravenrs_layout::{Align, Axis, Justify, LayoutStyle};
10use ravenrs_reactive::{Binding, State};
11use ravenrs_style::{Semantic, TextStyle, Weight};
12
13use super::selection;
14use super::virtual_list::{ScrollCommand, VirtualList};
15use crate::context::{BuildCx, NodeId, Paint, QuadPaint, Semantics};
16use crate::files::size_text;
17use crate::grid::SelectionMode;
18use crate::view::View;
19use crate::{Breadcrumb, Divider, IconName, util};
20
21/// What runs when an entry is opened.
22type OpenHandler = Rc<dyn Fn(&FileEntry)>;
23
24const ROW_HEIGHT: f32 = 32.0;
25const SIZE_WIDTH: f32 = 96.0;
26const MODIFIED_WIDTH: f32 = 148.0;
27
28/// A folder or file of a [`FileBrowser`]. Nothing here touches the file system: it says what the
29/// app knows, wherever that came from.
30#[derive(Clone, Debug, PartialEq)]
31#[non_exhaustive]
32#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
33pub struct FileEntry {
34    /// What the selection keeps. The name by default.
35    pub id: String,
36    /// What it is called.
37    pub name: String,
38    /// It opens into more entries.
39    pub is_folder: bool,
40    /// How many bytes it has, if that is known.
41    pub size: Option<u64>,
42    /// When it changed, as it should read.
43    pub modified: Option<String>,
44    /// What sorts the dates, as a number that grows with time.
45    pub modified_order: i64,
46    /// An icon of its own, instead of the one for a folder or a file.
47    pub icon: Option<IconName>,
48}
49
50#[cfg_attr(feature = "live", ravenrs_live::expose)]
51impl FileEntry {
52    fn new(name: String, is_folder: bool, size: Option<u64>) -> Self {
53        Self { id: name.clone(), name, is_folder, size, modified: None, modified_order: 0, icon: None }
54    }
55
56    /// A folder called `name`.
57    pub fn folder(name: impl Into<String>) -> Self {
58        Self::new(name.into(), true, None)
59    }
60
61    /// A file called `name`, of `size` bytes.
62    pub fn file(name: impl Into<String>, size: u64) -> Self {
63        Self::new(name.into(), false, Some(size))
64    }
65
66    /// What identifies it in the selection, when names can repeat.
67    pub fn id(mut self, id: impl Into<String>) -> Self {
68        self.id = id.into();
69        self
70    }
71
72    /// When it changed: how it reads, and a number that grows with time to sort it by.
73    pub fn modified(mut self, text: impl Into<String>, order: i64) -> Self {
74        self.modified = Some(text.into());
75        self.modified_order = order;
76        self
77    }
78
79    /// An icon of its own.
80    pub fn icon(mut self, icon: IconName) -> Self {
81        self.icon = Some(icon);
82        self
83    }
84}
85
86/// What a [`FileBrowser`] sorts by.
87#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
88#[non_exhaustive]
89#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
90pub enum FileSortColumn {
91    /// By name, ignoring case.
92    #[default]
93    Name,
94    /// By size.
95    Size,
96    /// By date.
97    Modified,
98}
99
100/// The order of a [`FileBrowser`]. Folders always come first.
101#[derive(Clone, Copy, Debug, PartialEq, Eq)]
102#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
103pub struct FileSort {
104    /// What it sorts by.
105    pub column: FileSortColumn,
106    /// From the least to the most.
107    pub ascending: bool,
108}
109
110impl Default for FileSort {
111    fn default() -> Self {
112        Self { column: FileSortColumn::Name, ascending: true }
113    }
114}
115
116/// The positions of `entries` in this order, folders first.
117fn sorted(entries: &[FileEntry], sort: FileSort) -> Vec<usize> {
118    let names: Vec<String> = entries.iter().map(|entry| entry.name.to_lowercase()).collect();
119    let mut order: Vec<usize> = (0..entries.len()).collect();
120    order.sort_by(|&a, &b| {
121        let (x, y) = (&entries[a], &entries[b]);
122        let by = match sort.column {
123            FileSortColumn::Name => names[a].cmp(&names[b]),
124            FileSortColumn::Size => x.size.cmp(&y.size),
125            FileSortColumn::Modified => x.modified_order.cmp(&y.modified_order),
126        };
127        let by = if sort.ascending { by } else { by.reverse() };
128        y.is_folder.cmp(&x.is_folder).then(by).then_with(|| names[a].cmp(&names[b]))
129    });
130    order
131}
132
133/// An explorer of folders and files: a breadcrumb that goes back, a header that sorts, and a
134/// list of any length with icon, name, size and date. The entries are data from the app, which
135/// answers [`on_navigate`](Self::on_navigate) and [`on_open`](Self::on_open) with another
136/// folder; it never touches the file system, so it serves a backup on a server as well as a disk.
137/// A click selects alone, Ctrl or Cmd adds, and Shift takes a range. The arrows, Home and End
138/// move, Space adds, Enter opens, and Backspace goes up.
139///
140/// ```
141/// use ravenrs_widgets::{FileBrowser, FileEntry, Ui};
142///
143/// let browser = FileBrowser::new(
144///     ["Backups", "2026"],
145///     vec![FileEntry::folder("October"), FileEntry::file("notes.txt", 2048)],
146/// )
147/// .on_open(|entry| println!("{}", entry.name))
148/// .on_navigate(|depth| println!("{depth}"));
149/// let mut ui = Ui::new(browser, ravenrs_style::Theme::raven_dark());
150/// ui.update();
151/// ```
152#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
153pub struct FileBrowser {
154    path: Vec<String>,
155    entries: Rc<[FileEntry]>,
156    selection: Option<Binding<BTreeSet<String>>>,
157    sort: Option<Binding<FileSort>>,
158    mode: SelectionMode,
159    on_navigate: Option<Rc<dyn Fn(usize)>>,
160    on_open: Option<OpenHandler>,
161    height: Option<f32>,
162    label: Option<String>,
163}
164
165impl fmt::Debug for FileBrowser {
166    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
167        f.debug_struct("FileBrowser").field("path", &self.path).finish_non_exhaustive()
168    }
169}
170
171#[cfg_attr(feature = "live", ravenrs_live::expose)]
172impl FileBrowser {
173    /// The folder at `path`, from the root, with `entries` inside it. Keep the entries in an
174    /// `Rc` between builds when they are many: they are sorted again only when they are not the
175    /// same ones.
176    pub fn new(
177        path: impl IntoIterator<Item = impl Into<String>>,
178        entries: impl Into<Rc<[FileEntry]>>,
179    ) -> Self {
180        Self {
181            path: path.into_iter().map(Into::into).collect(),
182            entries: entries.into(),
183            selection: None,
184            sort: None,
185            mode: SelectionMode::Multiple,
186            on_navigate: None,
187            on_open: None,
188            height: None,
189            label: None,
190        }
191    }
192
193    /// The ids of the selected entries, to read and to change from outside.
194    pub fn selection(mut self, selection: Binding<BTreeSet<String>>) -> Self {
195        self.selection = Some(selection);
196        self
197    }
198
199    /// The order, to keep or to set from outside.
200    pub fn sort(mut self, sort: Binding<FileSort>) -> Self {
201        self.sort = Some(sort);
202        self
203    }
204
205    /// How entries can be selected ([`SelectionMode::Multiple`] by default).
206    pub fn mode(mut self, mode: SelectionMode) -> Self {
207        self.mode = mode;
208        self
209    }
210
211    /// Called with the depth of the place to go back to, 0 being the root.
212    pub fn on_navigate(mut self, handler: impl Fn(usize) + 'static) -> Self {
213        self.on_navigate = Some(Rc::new(handler));
214        self
215    }
216
217    /// Called with an entry on a double click or on Enter.
218    pub fn on_open(mut self, handler: impl Fn(&FileEntry) + 'static) -> Self {
219        self.on_open = Some(Rc::new(handler));
220        self
221    }
222
223    /// A fixed height. It takes the height its parent gives it by default.
224    pub fn height(mut self, height: f32) -> Self {
225        self.height = Some(height);
226        self
227    }
228
229    /// What screen readers call it, instead of the name in [`Labels`](crate::Labels).
230    pub fn label(mut self, label: impl Into<String>) -> Self {
231        self.label = Some(label.into());
232        self
233    }
234}
235
236/// A column title that sorts.
237struct SortHeader {
238    text: String,
239    column: FileSortColumn,
240    sort: Binding<FileSort>,
241    width: Option<f32>,
242    end: bool,
243}
244
245impl View for SortHeader {
246    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
247        let theme = *cx.theme();
248        let now = self.sort.get();
249        let on = now.column == self.column;
250        let lit = cx.is_hovered() || cx.is_focus_visible();
251        let color = theme.color(if on || lit { Semantic::Ink } else { Semantic::InkMuted });
252        let mut parts = vec![util::label(cx, &self.text, TextStyle::Caption, Some(Weight::SEMIBOLD), color)];
253        if on {
254            let icon = if now.ascending { IconName::ArrowUp } else { IconName::ArrowDown };
255            parts.push(util::icon(cx, icon, 12.0, color));
256        }
257        let node = cx.container(
258            LayoutStyle {
259                width: self.width,
260                grow: if self.width.is_none() { 1.0 } else { 0.0 },
261                min_width: Some(0.0),
262                spacing: theme.spacing.s1,
263                align: Align::Center,
264                justify: if self.end { Justify::End } else { Justify::Start },
265                ..LayoutStyle::stack(Axis::Horizontal)
266            },
267            &parts,
268            Paint::None,
269        );
270        let (sort, column) = (self.sort.clone(), self.column);
271        cx.on_click(
272            node,
273            Rc::new(move || {
274                sort.update(|s| {
275                    s.ascending = if s.column == column { !s.ascending } else { true };
276                    s.column = column;
277                });
278            }),
279        );
280        cx.semantics(node, Semantics::new(Role::ColumnHeader).name(self.text.clone()).selected(on));
281        cx.focusable(node, theme.radii.sm);
282        cx.cursor(node, CursorIcon::Pointer);
283        node
284    }
285}
286
287/// A line of the list.
288struct FileRow {
289    entry_index: usize,
290    position: usize,
291    entries: Rc<[FileEntry]>,
292    selected: bool,
293    active: bool,
294    keyboard: bool,
295    dates: bool,
296    pick: Rc<dyn Fn(usize)>,
297    open: Option<OpenHandler>,
298}
299
300impl View for FileRow {
301    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
302        let theme = *cx.theme();
303        let labels = cx.labels();
304        let Some(entry) = self.entries.get(self.entry_index) else { return cx.empty() };
305        let (ink, muted) = (theme.color(Semantic::Ink), theme.color(Semantic::InkMuted));
306        let icon = entry.icon.unwrap_or(if entry.is_folder { IconName::Folder } else { IconName::File });
307        let tint = if entry.is_folder { theme.color(Semantic::Accent) } else { muted };
308        let mut parts = vec![util::icon(cx, icon, 16.0, tint)];
309        let attrs = util::attrs(&theme, TextStyle::Callout, None);
310        parts.push(cx.text_truncated(
311            &entry.name,
312            &attrs,
313            ink,
314            LayoutStyle { grow: 1.0, min_width: Some(0.0), ..LayoutStyle::leaf() },
315        ));
316        let size = entry.size.map(size_text).unwrap_or_default();
317        let column = |width: f32, end: bool| LayoutStyle {
318            width: Some(width),
319            align: Align::Center,
320            justify: if end { Justify::End } else { Justify::Start },
321            ..LayoutStyle::stack(Axis::Horizontal)
322        };
323        let number = util::label(cx, &size, TextStyle::Caption, None, muted);
324        parts.push(cx.container(column(SIZE_WIDTH, true), &[number], Paint::None));
325        if self.dates {
326            let date = entry.modified.as_deref().unwrap_or("");
327            let date = util::label(cx, date, TextStyle::Caption, None, muted);
328            parts.push(cx.container(column(MODIFIED_WIDTH, true), &[date], Paint::None));
329        }
330        let accent = theme.color(Semantic::Accent);
331        let fill = if self.selected {
332            accent.with_alpha(0.16)
333        } else if cx.is_hovered() {
334            ink.with_alpha(0.05)
335        } else {
336            Color::TRANSPARENT
337        };
338        let border = (self.active && self.keyboard).then_some(Border { width: 1.5, color: accent });
339        let node = cx.container(
340            LayoutStyle {
341                grow: 1.0,
342                spacing: theme.spacing.s2,
343                padding: Insets::symmetric(0.0, theme.spacing.s3),
344                align: Align::Center,
345                ..LayoutStyle::stack(Axis::Horizontal)
346            },
347            &parts,
348            Paint::Quad(QuadPaint { corner_radius: theme.radii.sm, border, ..QuadPaint::new(fill) }),
349        );
350        let (pick, position) = (self.pick.clone(), self.position);
351        cx.on_click(node, Rc::new(move || pick(position)));
352        if let Some(open) = self.open.clone() {
353            let (entries, index) = (self.entries.clone(), self.entry_index);
354            cx.on_double_click(
355                node,
356                Rc::new(move || {
357                    if let Some(entry) = entries.get(index) {
358                        open(entry);
359                    }
360                }),
361            );
362        }
363        cx.cursor(node, CursorIcon::Pointer);
364        let what = if entry.is_folder { labels.folder_word.clone() } else { size };
365        cx.semantics(
366            node,
367            Semantics::new(Role::ListBoxOption)
368                .name(entry.name.clone())
369                .description(what)
370                .selected(self.selected)
371                .active(self.active),
372        );
373        node
374    }
375}
376
377impl View for FileBrowser {
378    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
379        let theme = *cx.theme();
380        let labels = cx.labels();
381        let local_selection: State<BTreeSet<String>> = cx.use_state(BTreeSet::new);
382        let selection = self.selection.clone().unwrap_or_else(|| local_selection.binding());
383        let local_sort: State<FileSort> = cx.use_state(FileSort::default);
384        let sort = self.sort.clone().unwrap_or_else(|| local_sort.binding());
385        let commands: State<Option<ScrollCommand>> = cx.use_state(|| None);
386        let cursor: State<usize> = cx.use_state(|| 0);
387        let anchor: State<usize> = cx.use_state(|| 0);
388        type Sorted = (Rc<[FileEntry]>, FileSort, Rc<Vec<usize>>);
389        let cache: Rc<RefCell<Option<Sorted>>> = cx.use_state(|| Rc::new(RefCell::new(None))).peek().clone();
390        let order = sort.get();
391        let sorted_now = {
392            let mut cached = cache.borrow_mut();
393            match cached.as_ref() {
394                Some((entries, by, found)) if Rc::ptr_eq(entries, &self.entries) && *by == order => {
395                    found.clone()
396                }
397                _ => {
398                    let found = Rc::new(sorted(&self.entries, order));
399                    *cached = Some((self.entries.clone(), order, found.clone()));
400                    found
401                }
402            }
403        };
404
405        let count = sorted_now.len();
406        let active = cursor.get().min(count.saturating_sub(1));
407        let selected = Rc::new(selection.get());
408        let keyboard = cx.is_focus_visible();
409        let mode = self.mode;
410        let dates = self.entries.iter().any(|entry| entry.modified.is_some());
411        let id_of = {
412            let (entries, order) = (self.entries.clone(), sorted_now.clone());
413            move |position: usize| {
414                order.get(position).and_then(|i| entries.get(*i)).map_or_else(String::new, |e| e.id.clone())
415            }
416        };
417        let modifiers = cx.modifiers();
418        let apply: Rc<dyn Fn(usize, Modifiers)> = {
419            let (selection, cursor, anchor, id_of) =
420                (selection.clone(), cursor.clone(), anchor.clone(), id_of.clone());
421            Rc::new(move |position, held| {
422                let mut next = anchor.get();
423                selection.update(|set| {
424                    next = selection::pick(set, mode, next, position, held, &id_of);
425                });
426                anchor.set(next);
427                cursor.set(position);
428            })
429        };
430        let pick: Rc<dyn Fn(usize)> = {
431            let apply = apply.clone();
432            let modifiers = modifiers.clone();
433            Rc::new(move |position| apply(position, modifiers.get()))
434        };
435
436        let mut crumbs = Breadcrumb::new(self.path.iter().cloned()).root_icon(IconName::House);
437        if let Some(go) = self.on_navigate.clone() {
438            crumbs = crumbs.on_navigate(move |depth| go(depth));
439        }
440        let crumbs = cx.child(&crumbs);
441        let crumbs = cx.container(
442            LayoutStyle {
443                padding: Insets::symmetric(theme.spacing.s2, theme.spacing.s3),
444                align: Align::Start,
445                ..LayoutStyle::stack(Axis::Vertical)
446            },
447            &[crumbs],
448            Paint::None,
449        );
450        let first_line = cx.child(&Divider::horizontal());
451        let mut titles = vec![cx.child(&SortHeader {
452            text: labels.file_name.clone(),
453            column: FileSortColumn::Name,
454            sort: sort.clone(),
455            width: None,
456            end: false,
457        })];
458        titles.push(cx.child(&SortHeader {
459            text: labels.file_size.clone(),
460            column: FileSortColumn::Size,
461            sort: sort.clone(),
462            width: Some(SIZE_WIDTH),
463            end: true,
464        }));
465        if dates {
466            titles.push(cx.child(&SortHeader {
467                text: labels.file_modified.clone(),
468                column: FileSortColumn::Modified,
469                sort: sort.clone(),
470                width: Some(MODIFIED_WIDTH),
471                end: true,
472            }));
473        }
474        let header = cx.container(
475            LayoutStyle {
476                spacing: theme.spacing.s2,
477                // The icon column of the rows, and their padding, line up the titles.
478                padding: Insets::symmetric(theme.spacing.s1, theme.spacing.s3),
479                align: Align::Center,
480                ..LayoutStyle::stack(Axis::Horizontal)
481            },
482            &titles,
483            Paint::Quad(QuadPaint::new(theme.color(Semantic::Surface))),
484        );
485        let second_line = cx.child(&Divider::horizontal());
486
487        let body = if count == 0 {
488            let words = util::label(
489                cx,
490                &labels.folder_empty,
491                TextStyle::Callout,
492                None,
493                theme.color(Semantic::InkMuted),
494            );
495            cx.container(
496                LayoutStyle {
497                    grow: 1.0,
498                    min_height: Some(0.0),
499                    padding: Insets::uniform(theme.spacing.s6),
500                    align: Align::Center,
501                    justify: Justify::Center,
502                    ..LayoutStyle::stack(Axis::Vertical)
503                },
504                &[words],
505                Paint::None,
506            )
507        } else {
508            let (entries, order_now, open, shown, picker) = (
509                self.entries.clone(),
510                sorted_now.clone(),
511                self.on_open.clone(),
512                selected.clone(),
513                pick.clone(),
514            );
515            let id = id_of.clone();
516            let mut list = VirtualList::new(count, move |position| FileRow {
517                entry_index: order_now.get(position).copied().unwrap_or(0),
518                position,
519                entries: entries.clone(),
520                selected: shown.contains(&id(position)),
521                active: position == active,
522                keyboard,
523                dates,
524                pick: picker.clone(),
525                open: open.clone(),
526            })
527            .row_height(ROW_HEIGHT)
528            .bare()
529            .commands(commands.clone());
530            let (keyed_entries, keyed_order) = (self.entries.clone(), sorted_now.clone());
531            list = list.key(move |position| {
532                keyed_order
533                    .get(position)
534                    .and_then(|i| keyed_entries.get(*i))
535                    .map_or(position as u64, |entry| util::hash_text(&entry.id))
536            });
537            cx.keyed_child(1, &list)
538        };
539        let root = cx.container(
540            LayoutStyle {
541                height: self.height,
542                grow: if self.height.is_some() { 0.0 } else { 1.0 },
543                min_height: Some(0.0),
544                min_width: Some(0.0),
545                overflow_hidden: true,
546                align: Align::Stretch,
547                align_self: Some(Align::Stretch),
548                ..LayoutStyle::stack(Axis::Vertical)
549            },
550            &[crumbs, first_line, header, second_line, body],
551            Paint::None,
552        );
553        let name = self.label.clone().unwrap_or_else(|| labels.file_browser_name.clone());
554        cx.semantics(root, Semantics::new(Role::ListBox).name(name).description(count.to_string()));
555        cx.focusable(root, theme.radii.sm);
556        let (entries, order_now, open, up, depth) = (
557            self.entries.clone(),
558            sorted_now,
559            self.on_open.clone(),
560            self.on_navigate.clone(),
561            self.path.len(),
562        );
563        let select_all = selection;
564        cx.on_key(
565            root,
566            Rc::new(move |key, held| {
567                let to = match key {
568                    Key::ArrowUp => active.saturating_sub(1),
569                    Key::ArrowDown => (active + 1).min(count.saturating_sub(1)),
570                    Key::Home => 0,
571                    Key::End => count.saturating_sub(1),
572                    Key::Enter => {
573                        if let (Some(open), Some(entry)) =
574                            (&open, order_now.get(active).and_then(|i| entries.get(*i)))
575                        {
576                            open(entry);
577                        }
578                        return true;
579                    }
580                    Key::Space => {
581                        if count > 0 {
582                            apply(active, Modifiers { ctrl: true, ..Modifiers::default() });
583                        }
584                        return true;
585                    }
586                    Key::Backspace => {
587                        if let (Some(up), true) = (&up, depth >= 2) {
588                            up(depth - 2);
589                        }
590                        return true;
591                    }
592                    Key::Character(c) if selection::adds(held) && c.eq_ignore_ascii_case("a") => {
593                        if mode != SelectionMode::Multiple {
594                            return false;
595                        }
596                        select_all.set((0..count).map(&id_of).collect());
597                        return true;
598                    }
599                    _ => return false,
600                };
601                if count == 0 {
602                    return false;
603                }
604                if selection::adds(held) {
605                    cursor.set(to);
606                } else {
607                    apply(to, held);
608                }
609                commands.set(Some(ScrollCommand::Reveal(to)));
610                true
611            }),
612        );
613        root
614    }
615}
616
617#[cfg(test)]
618mod tests {
619    use super::*;
620
621    #[test]
622    fn folders_stay_first_in_either_direction() {
623        let entries = [
624            FileEntry::file("b.txt", 10),
625            FileEntry::folder("Z"),
626            FileEntry::file("A.txt", 30),
627            FileEntry::folder("c"),
628        ];
629        let by = |column, ascending| sorted(&entries, FileSort { column, ascending });
630        assert_eq!(by(FileSortColumn::Name, true), vec![3, 1, 2, 0]);
631        assert_eq!(by(FileSortColumn::Name, false), vec![1, 3, 0, 2]);
632        assert_eq!(by(FileSortColumn::Size, false), vec![3, 1, 2, 0]);
633    }
634}