Skip to main content

qframe/widgets/
file_picker.rs

1//! Browsing the file system to choose a file or a folder.
2
3use std::path::{Component, Path, PathBuf};
4use std::rc::Rc;
5
6use crate::event::{Event, MouseButton, MouseKind};
7use crate::geometry::{Rect, Size};
8use crate::style::CellStyle;
9use crate::text;
10use crate::theme::State;
11use crate::widget::{Align, EventCx, Length, MeasureCx, NodeMut, PaintCx, View, Widget};
12
13use super::cells;
14use super::delayed::DelayedIndicator;
15use super::file_browser::{FileBrowser, FilePickerMsg, FolderState, ListingError, PickMode};
16use super::{Button, List, ListItem, SpinnerStyle, Switch, Text, TextInput};
17
18/// Browses folders and chooses a file or a folder.
19///
20/// The picker is built from framework widgets: a clickable path, a name filter, a list of the
21/// folder's entries (folders first, a parent row on top), and a footer with a hidden-files
22/// switch and the choose button. The application owns a [`FileBrowser`] and passes it every
23/// [`FilePickerMsg`]; folders are read on a background thread, and a folder that cannot be opened
24/// shows a readable message at once.
25///
26/// While a folder is read the picker keeps showing the folder it was on, unchanged and usable,
27/// and switches to the new one in a single frame when it has been read. A read that takes longer
28/// than about 300 ms shows a small spinner right after the path, which then stays at least
29/// about 500 ms, so quick reads never flash a loading state and slow ones never blink one.
30///
31/// ```
32/// use qframe::prelude::*;
33/// use qframe::widgets::{FileBrowser, FilePicker, FilePickerMsg, PickMode};
34///
35/// struct Open {
36///     browser: FileBrowser,
37///     chosen: Option<std::path::PathBuf>,
38/// }
39///
40/// #[derive(Clone)]
41/// enum Msg {
42///     Picker(FilePickerMsg),
43/// }
44///
45/// impl App for Open {
46///     type Msg = Msg;
47///     fn update(&mut self, msg: Msg) -> Command<Msg> {
48///         match msg {
49///             Msg::Picker(FilePickerMsg::Chosen(path)) => self.chosen = Some(path),
50///             Msg::Picker(message) => return self.browser.update(message, Msg::Picker),
51///         }
52///         Command::none()
53///     }
54///     fn view(&self, ui: &mut View<'_, Msg>) {
55///         FilePicker::new(&self.browser, Msg::Picker).show(ui).fill();
56///     }
57/// }
58/// ```
59///
60/// Keys: the list's keys (↑/↓, Enter opens a folder or chooses a file) and Tab between the
61/// filter, the list, the switch and the button. Style keys: `path-segment` (`fg`, `bg`) with
62/// `hover` and `selected` for the current folder; `path-separator`; `spinner` and
63/// `spinner-label` for the reading indicator; the styles of `List`, `TextInput`, `Switch` and
64/// `Button`. Icons: `folder`, `file`, `arrow-up`,
65/// `path-separator`, `error`. Framework strings under `quvyta.file-picker`.
66pub struct FilePicker<'a, Msg> {
67    browser: &'a FileBrowser,
68    wrap: Wrap<Msg>,
69}
70
71/// Turns picker messages into the application's; shared by every widget of one picker.
72type Wrap<Msg> = Rc<dyn Fn(FilePickerMsg) -> Msg>;
73
74impl<'a, Msg: Clone + Send + 'static> FilePicker<'a, Msg> {
75    /// A picker showing `browser`; `wrap` turns picker messages into application messages.
76    ///
77    /// `wrap` is a function such as `Msg::Picker`, or a closure that captures what it needs,
78    /// e.g. a screen's own conversion: `move |message| wrap(screen::Msg::Picker(message))`. It
79    /// runs while events are handled on the drawing thread, so it need not be `Send`, and the
80    /// picker shares it among its widgets, so it need not be `Clone`.
81    #[must_use]
82    pub fn new(browser: &'a FileBrowser, wrap: impl Fn(FilePickerMsg) -> Msg + 'static) -> Self {
83        Self { browser, wrap: Rc::new(wrap) }
84    }
85
86    /// Adds the picker to `ui` as a column.
87    pub fn show<'v>(self, ui: &'v mut View<'_, Msg>) -> NodeMut<'v, Msg> {
88        let browser = self.browser;
89        let wrap = self.wrap;
90        ui.column(|ui| {
91            let failed = matches!(browser.state, FolderState::Failed(_));
92            let busy = browser.loading.is_some();
93            ui.add(PathBar { folder: browser.folder.clone(), busy, failed, wrap: Rc::clone(&wrap) }).fill_width();
94            let filter = Rc::clone(&wrap);
95            ui.add(
96                TextInput::new(&browser.filter)
97                    .placeholder(crate::t!("quvyta.file-picker.filter"))
98                    .on_change(move |text| filter(FilePickerMsg::Filter(text))),
99            )
100            .fill_width();
101            match &browser.state {
102                // Before the first answer there is nothing to keep: the space stays empty and the
103                // path line shows the reading indicator if the read is slow.
104                FolderState::Unread => {
105                    ui.add(Text::new("")).height(Length::Fill(1));
106                }
107                FolderState::Failed(error) => Self::failure(ui, error, &browser.folder),
108                FolderState::Ready(_) => Self::entries(ui, browser, &wrap),
109            }
110            Self::footer(ui, browser, &wrap);
111        })
112        .gap(1)
113    }
114
115    fn failure(ui: &mut View<'_, Msg>, error: &ListingError, folder: &Path) {
116        let (key, detail) = match error {
117            ListingError::PermissionDenied => ("permission-denied", None),
118            ListingError::NotFound => ("not-found", None),
119            ListingError::NotAFolder => ("not-a-folder", None),
120            ListingError::Other(message) => ("unreadable", Some(message.clone())),
121        };
122        ui.column(|ui| {
123            let glyph = ui.env().icons().glyph("error").into_owned();
124            ui.add(
125                Text::rich([
126                    super::Span::new(format!("{glyph}  ")).color("danger"),
127                    super::Span::new(crate::t!(&format!("quvyta.file-picker.{key}"))).role("body"),
128                ])
129                .no_wrap(),
130            );
131            ui.add(Text::new(folder.display().to_string()).role("faint"));
132            if let Some(detail) = detail {
133                ui.add(Text::new(detail).role("faint"));
134            }
135        })
136        .height(Length::Fill(1))
137        .fill_width();
138    }
139
140    fn entries(ui: &mut View<'_, Msg>, browser: &FileBrowser, wrap: &Wrap<Msg>) {
141        let parent = browser.folder.parent().map(Path::to_path_buf);
142        let visible = browser.visible();
143        let mut items = Vec::new();
144        // What each row does when opened, and which name it selects.
145        let mut rows: Vec<(Option<String>, Option<FilePickerMsg>)> = Vec::new();
146        if let Some(parent) = &parent {
147            items.push(ListItem::new(crate::t!("quvyta.file-picker.parent")).icon("arrow-up", Some("muted")));
148            rows.push((None, Some(FilePickerMsg::Open(parent.clone()))));
149        }
150        for entry in &visible {
151            let path = browser.folder.join(entry.name());
152            let (item, action) = if entry.is_folder() {
153                (ListItem::new(entry.name()).icon("folder", Some("accent")), Some(FilePickerMsg::Open(path)))
154            } else {
155                let item = ListItem::new(entry.name())
156                    .icon("file", Some("muted"))
157                    .detail(entry.size().map(format_size).unwrap_or_default())
158                    .faint(browser.mode == PickMode::Folders);
159                let action = (browser.mode == PickMode::Files).then_some(FilePickerMsg::Chosen(path));
160                (item, action)
161            };
162            items.push(item);
163            rows.push((Some(entry.name().to_owned()), action));
164        }
165        let selected = match &browser.selected {
166            Some(name) => rows.iter().position(|(row, _)| row.as_ref() == Some(name)),
167            None => None,
168        };
169        let empty = if browser.filter.is_empty() { "empty" } else { "no-match" };
170        let nothing = visible.is_empty();
171        let names: Vec<Option<String>> = rows.iter().map(|(name, _)| name.clone()).collect();
172        let actions: Vec<FilePickerMsg> = rows
173            .into_iter()
174            // Rows without an action (a file in a folder picker) only select.
175            .map(|(name, action)| action.unwrap_or(FilePickerMsg::Select(name)))
176            .collect();
177        let (select, activate) = (Rc::clone(wrap), Rc::clone(wrap));
178        let list = List::new(items)
179            .selected(selected)
180            .on_select(move |index| select(FilePickerMsg::Select(names.get(index).cloned().flatten())))
181            .on_activate(move |index| activate(actions.get(index).cloned().unwrap_or(FilePickerMsg::Refresh)));
182        if nothing {
183            // An empty folder still offers the way back up above its empty text.
184            ui.add(list).fill_width();
185            ui.add(Text::new(crate::t!(&format!("quvyta.file-picker.{empty}"))).role("faint"))
186                .height(Length::Fill(1))
187                .padding(crate::geometry::Padding { top: 0, right: 0, bottom: 0, left: 2 });
188            return;
189        }
190        ui.add(list).width(Length::Fill(1)).height(Length::Fill(1));
191    }
192
193    fn footer(ui: &mut View<'_, Msg>, browser: &FileBrowser, wrap: &Wrap<Msg>) {
194        // The selected entry, when it is one the mode can choose: a file, or a folder the person
195        // pointed at. The entry a folder selects by itself when it opens is only where the keys
196        // start; taking it for the choice would pick `myapp/src` for someone who opened `myapp`.
197        let wants_folder = browser.mode == PickMode::Folders;
198        let selected = browser.selected.as_ref().filter(|_| !wants_folder || browser.picked).filter(|name| {
199            browser.visible().iter().any(|entry| entry.name() == name.as_str() && entry.is_folder() == wants_folder)
200        });
201        let chosen = match browser.mode {
202            PickMode::Files => selected.map(|name| browser.folder.join(name)),
203            // Without a chosen folder inside, the folder shown is the choice.
204            PickMode::Folders => {
205                Some(selected.map_or_else(|| browser.folder.clone(), |name| browser.folder.join(name)))
206            }
207        };
208        let ready = matches!(browser.state, FolderState::Ready(_));
209        ui.row(|ui| {
210            let toggle = Rc::clone(wrap);
211            ui.add(
212                Switch::new(browser.show_hidden)
213                    .label(crate::t!("quvyta.file-picker.hidden"))
214                    .on_toggle(move |on| toggle(FilePickerMsg::ShowHidden(on))),
215            );
216            if !browser.extensions.is_empty() {
217                ui.add(Text::new(browser.extensions.join(", ")).role("faint").no_wrap());
218            }
219            ui.spacer();
220            let label = match browser.mode {
221                PickMode::Files => crate::t!("quvyta.file-picker.choose-file"),
222                PickMode::Folders => crate::t!("quvyta.file-picker.choose-folder"),
223            };
224            let mut button = Button::new(label).variant("primary").disabled(!ready || chosen.is_none());
225            if let Some(path) = chosen {
226                button = button.on_press(wrap(FilePickerMsg::Chosen(path)));
227            }
228            ui.add(button);
229        })
230        .gap(2)
231        .align(Align::Center)
232        .fill_width();
233    }
234}
235
236/// A size in bytes as a short human text: `812 B`, `12.4 KiB`, `3.1 GiB`.
237fn format_size(bytes: u64) -> String {
238    const UNITS: [&str; 5] = ["KiB", "MiB", "GiB", "TiB", "PiB"];
239    if bytes < 1024 {
240        return format!("{bytes} B");
241    }
242    let mut value = bytes as f64 / 1024.0;
243    let mut unit = 0;
244    while value >= 1024.0 && unit + 1 < UNITS.len() {
245        value /= 1024.0;
246        unit += 1;
247    }
248    format!("{} {}", crate::i18n::number(value, 1), UNITS[unit])
249}
250
251/// Cells kept after the path for the reading spinner: a gap and the spinner, so the segments
252/// never move when it shows.
253const INDICATOR_CELLS: u16 = 2;
254
255/// The current folder as clickable segments separated by a faint glyph, with the delayed reading
256/// indicator after them.
257struct PathBar<Msg> {
258    folder: PathBuf,
259    /// A folder is being read.
260    busy: bool,
261    /// The folder shown could not be read; its error replaces any indicator at once.
262    failed: bool,
263    wrap: Wrap<Msg>,
264}
265
266impl<Msg> PathBar<Msg> {
267    /// Segment labels with the folder each one opens.
268    fn segments(&self) -> Vec<(String, PathBuf)> {
269        let mut out = Vec::new();
270        let mut path = PathBuf::new();
271        for component in self.folder.components() {
272            path.push(component.as_os_str());
273            let label = match component {
274                Component::RootDir => std::path::MAIN_SEPARATOR.to_string(),
275                other => other.as_os_str().to_string_lossy().into_owned(),
276            };
277            if matches!(component, Component::Prefix(_)) {
278                continue;
279            }
280            out.push((label, path.clone()));
281        }
282        out
283    }
284
285    /// Where the segments go: the line without the cells kept for the indicator.
286    fn segments_area(area: Rect) -> Rect {
287        Rect::new(area.x, area.y, area.width.saturating_sub(INDICATOR_CELLS), area.height)
288    }
289
290    /// Draws the reading spinner right after the segments, which end at `end`, when the delay
291    /// rule says so, with its label where the line has room.
292    fn paint_indicator(&self, cx: &mut PaintCx<'_>, area: Rect, end: i32) {
293        let now = cx.now();
294        let indicator = cx.memory::<DelayedIndicator>();
295        if self.failed && !self.busy {
296            indicator.cancel();
297        }
298        let shown = indicator.update(self.busy, now);
299        let next = indicator.next_change(self.busy, now);
300        if let Some(delay) = next {
301            cx.request_frame_in(delay);
302        }
303        if !shown || area.width <= INDICATOR_CELLS {
304            return;
305        }
306        // Glyph, a space and the label, like a `Spinner`, right after the last segment: the eye is
307        // already there. The label only shows where it fits whole; the glyph always has its cells.
308        let x = end + 1;
309        let style = cx.style("spinner", None, &[]).text();
310        let cell = cx.animation(SpinnerStyle::default().animation(), style, Some(std::time::Duration::ZERO));
311        cx.text(x, area.y, &cell.glyph, cell.style, 1);
312        let label = crate::t!("quvyta.file-picker.loading");
313        let width = text::width(&label);
314        let label_x = x + 2;
315        if label_x + i32::from(width) <= area.right() {
316            let style = cx.style("spinner-label", None, &[]).text();
317            cx.text(label_x, area.y, &label, style, width);
318        }
319    }
320
321    /// Screen spans of the segments that fit, from the right; the first span may be the
322    /// ellipsis of hidden leading segments (index `None`).
323    fn layout(&self, separator_width: u16, area: Rect) -> Vec<(Option<usize>, Rect)> {
324        let segments = self.segments();
325        let widths: Vec<u16> = segments.iter().map(|(label, _)| text::width(label).saturating_add(2)).collect();
326        let step = separator_width + 2;
327        let mut first = 0;
328        let total = |from: usize| -> u16 {
329            let ellipsis = if from > 0 { 3 + step } else { 0 };
330            widths[from..].iter().sum::<u16>()
331                + step * u16::try_from(widths.len() - from).unwrap_or(0).saturating_sub(1)
332                + ellipsis
333        };
334        while first + 1 < segments.len() && total(first) > area.width {
335            first += 1;
336        }
337        let mut out = Vec::new();
338        let mut x = area.x;
339        if first > 0 {
340            out.push((None, Rect::new(x, area.y, 3, 1)));
341            x += 3 + i32::from(step);
342        }
343        for (index, width) in widths.iter().enumerate().skip(first) {
344            out.push((Some(index), Rect::new(x, area.y, *width, 1)));
345            x += i32::from(*width + step);
346        }
347        out
348    }
349}
350
351impl<Msg: 'static> Widget<Msg> for PathBar<Msg> {
352    fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
353        let width = cells::sum(self.segments().iter().map(|(label, _)| text::width(label).saturating_add(5)));
354        Size::new(width.saturating_add(INDICATOR_CELLS), 1).min(available)
355    }
356
357    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
358        cx.register_hit(area);
359        let separator = cx.env().icons().glyph("path-separator").into_owned();
360        let separator_width = text::width(&separator);
361        let segments = self.segments();
362        let pointer = cx.pointer();
363        let last = segments.len().saturating_sub(1);
364        let separator_style = cx.style("path-separator", None, &[]).text();
365        let spans = self.layout(separator_width, Self::segments_area(area));
366        for (position, (index, rect)) in spans.iter().enumerate() {
367            let mut states = Vec::new();
368            if pointer.is_some_and(|(x, y)| rect.contains(x, y)) && *index != Some(last) && index.is_some() {
369                states.push(State::Hover);
370            }
371            if *index == Some(last) {
372                states.push(State::Selected);
373            }
374            let style = cx.style("path-segment", None, &states).text();
375            if let Some(bg) = style.bg {
376                cx.fill(*rect, bg);
377            }
378            let label = index.map_or_else(|| text::ELLIPSIS.to_owned(), |i| segments[i].0.clone());
379            cx.text(rect.x + 1, rect.y, &label, CellStyle { bg: None, ..style }, rect.width.saturating_sub(2));
380            if position + 1 < spans.len() {
381                cx.text(rect.right() + 1, rect.y, &separator, separator_style, separator_width);
382            }
383        }
384        let end = spans.last().map_or(area.x, |(_, rect)| rect.right());
385        self.paint_indicator(cx, area, end);
386    }
387
388    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
389        let Event::Mouse(mouse) = event else { return false };
390        if mouse.kind != MouseKind::Down(MouseButton::Left) {
391            return false;
392        }
393        let separator = text::width(&cx.env().icons().glyph("path-separator"));
394        let segments = self.segments();
395        let hit = self
396            .layout(separator, Self::segments_area(cx.area()))
397            .into_iter()
398            .find(|(_, rect)| rect.contains(mouse.x, mouse.y));
399        match hit {
400            Some((Some(index), _)) if index + 1 < segments.len() => {
401                cx.emit((self.wrap)(FilePickerMsg::Open(segments[index].1.clone())));
402                true
403            }
404            _ => false,
405        }
406    }
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412    use crate::icons::GlyphMode;
413    use crate::runtime::{App, Command, Harness};
414    use crate::widgets::read_folder;
415
416    struct Demo {
417        browser: FileBrowser,
418        chosen: Option<PathBuf>,
419    }
420
421    #[derive(Debug, Clone)]
422    enum Msg {
423        Picker(FilePickerMsg),
424    }
425
426    impl App for Demo {
427        type Msg = Msg;
428        fn update(&mut self, msg: Msg) -> Command<Msg> {
429            match msg {
430                Msg::Picker(FilePickerMsg::Chosen(path)) => self.chosen = Some(path),
431                Msg::Picker(message) => return self.browser.update(message, Msg::Picker),
432            }
433            Command::none()
434        }
435        fn view(&self, ui: &mut View<'_, Msg>) {
436            FilePicker::new(&self.browser, Msg::Picker).show(ui).fill();
437        }
438    }
439
440    fn scratch(name: &str) -> PathBuf {
441        let dir = std::env::temp_dir().join(format!("quvyta-a8-picker-{name}-{}", std::process::id()));
442        let _ = std::fs::remove_dir_all(&dir);
443        std::fs::create_dir_all(dir.join("deploy/scripts")).expect("scratch folders");
444        std::fs::write(dir.join("compose.yaml"), "services:\n").expect("scratch file");
445        std::fs::write(dir.join("deploy/release.sh"), vec![b'#'; 2048]).expect("scratch file");
446        dir
447    }
448
449    fn harness(dir: &Path, mode: PickMode) -> Harness<Demo> {
450        let browser = FileBrowser::new(dir, mode);
451        let mut h = Harness::new(Demo { browser, chosen: None }, 60, 14);
452        h.set_glyph_mode(GlyphMode::Unicode);
453        let folder = dir.to_path_buf();
454        h.send(Msg::Picker(FilePickerMsg::Open(folder)));
455        h
456    }
457
458    #[test]
459    fn browses_into_folders_and_chooses_a_file() {
460        let dir = scratch("choose");
461        let mut h = harness(&dir, PickMode::Files);
462        let screen = h.screen();
463        assert!(screen.contains("■ deploy") && screen.contains("compose.yaml"), "{screen}");
464        assert!(screen.contains("Parent folder"), "{screen}");
465        h.click_text("deploy");
466        let screen = h.screen();
467        assert!(screen.contains("release.sh") && screen.contains("2.0 KiB"), "{screen}");
468        assert!(screen.contains("scripts"), "{screen}");
469        h.click_text("release.sh");
470        assert_eq!(h.app().chosen.as_deref(), Some(dir.join("deploy/release.sh").as_path()));
471        let name = dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
472        h.click_text(&name);
473        assert_eq!(h.app().browser.folder(), dir.as_path());
474        std::fs::remove_dir_all(dir).ok();
475    }
476
477    #[test]
478    fn folder_mode_chooses_the_folder_entered_rather_than_its_first_child() {
479        // A folder that opens puts the list's cursor on its first entry so the keys have
480        // somewhere to start. Taking that for a choice picked `deploy/scripts` for someone who
481        // opened `deploy` and pressed Choose folder.
482        let dir = scratch("entered");
483        let mut h = harness(&dir, PickMode::Folders);
484        h.click_text("deploy");
485        assert!(h.screen().contains("scripts"), "{}", h.screen());
486        h.click_text("Choose folder");
487        assert_eq!(h.app().chosen.as_deref(), Some(dir.join("deploy").as_path()));
488        std::fs::remove_dir_all(dir).ok();
489    }
490
491    #[test]
492    fn folder_mode_chooses_the_folder_shown_until_one_inside_is_pointed_at() {
493        let dir = scratch("folders");
494        let mut h = harness(&dir, PickMode::Folders);
495        h.click_text("Choose folder");
496        assert_eq!(h.app().chosen.as_deref(), Some(dir.as_path()), "nothing was pointed at");
497        // The keys move the cursor off the entry the folder chose by itself and back onto it:
498        // now the person pointed at it.
499        let (x, y) = h.find("compose.yaml").expect("the listing");
500        h.click(x, y).press("up").press("down").press("up");
501        h.click_text("Choose folder");
502        assert_eq!(h.app().chosen.as_deref(), Some(dir.join("deploy").as_path()));
503        h.send(Msg::Picker(FilePickerMsg::Filter("zzz".into())));
504        assert!(h.screen().contains("Nothing matches"), "{}", h.screen());
505        std::fs::remove_dir_all(dir).ok();
506    }
507
508    #[test]
509    fn unreadable_folders_show_a_state() {
510        let missing = std::env::temp_dir().join("quvyta-a8-picker-missing");
511        let h = harness(&missing, PickMode::Files);
512        let screen = h.screen();
513        assert!(screen.contains("✕  This folder does not exist"), "{screen}");
514        assert!(!screen.contains('['));
515    }
516
517    /// A picker inside a screen of the application: the screen's messages carry the tab it sits
518    /// in, so `wrap` is a closure over that tab rather than a function.
519    struct Tabbed {
520        tab: usize,
521        browser: FileBrowser,
522        chosen: Vec<(usize, PathBuf)>,
523    }
524
525    #[derive(Debug, Clone)]
526    enum TabMsg {
527        Picker(usize, FilePickerMsg),
528    }
529
530    impl App for Tabbed {
531        type Msg = TabMsg;
532        fn update(&mut self, msg: TabMsg) -> Command<TabMsg> {
533            let TabMsg::Picker(tab, message) = msg;
534            if let FilePickerMsg::Chosen(path) = message {
535                self.chosen.push((tab, path));
536                return Command::none();
537            }
538            self.browser.update(message, move |message| TabMsg::Picker(tab, message))
539        }
540        fn view(&self, ui: &mut View<'_, TabMsg>) {
541            let tab = self.tab;
542            FilePicker::new(&self.browser, move |message| TabMsg::Picker(tab, message)).show(ui).fill();
543        }
544    }
545
546    #[test]
547    fn a_closure_capturing_state_wraps_every_picker_message() {
548        let dir = scratch("closure");
549        let browser = FileBrowser::new(&dir, PickMode::Files);
550        let mut h = Harness::new(Tabbed { tab: 3, browser, chosen: Vec::new() }, 60, 14);
551        h.set_glyph_mode(GlyphMode::Unicode);
552        // `update` hands the closure to `open`, whose read delivers `Loaded` through it.
553        h.send(TabMsg::Picker(3, FilePickerMsg::Open(dir.clone())));
554        assert!(h.screen().contains("compose.yaml"), "{}", h.screen());
555        h.click_text("deploy").click_text("release.sh");
556        assert_eq!(h.app().chosen, [(3, dir.join("deploy/release.sh"))]);
557        std::fs::remove_dir_all(dir).ok();
558    }
559
560    impl From<FilePickerMsg> for Msg {
561        fn from(message: FilePickerMsg) -> Self {
562            Msg::Picker(message)
563        }
564    }
565
566    /// Callers that pass a plain function keep compiling: an item, a pointer held in a value, and
567    /// a generic function made concrete by the message type.
568    #[test]
569    fn plain_functions_still_wrap() {
570        fn wrap(message: FilePickerMsg) -> Msg {
571            Msg::Picker(message)
572        }
573        fn picked<M: From<FilePickerMsg>>(message: FilePickerMsg) -> M {
574            M::from(message)
575        }
576        let dir = scratch("plain");
577        let pointer: fn(FilePickerMsg) -> Msg = wrap;
578        let mut browser = FileBrowser::new(&dir, PickMode::Files);
579        let command: Command<Msg> = browser.open(dir.clone(), pointer);
580        assert_eq!(command.actions.len(), 1);
581        let command: Command<Msg> = browser.update(FilePickerMsg::Refresh, picked::<Msg>);
582        assert_eq!(command.actions.len(), 1);
583        let _ = browser.update(FilePickerMsg::Loaded(dir.clone(), read_folder(&dir)), wrap);
584        let mut h = Harness::new(Demo { browser, chosen: None }, 60, 14);
585        h.set_glyph_mode(GlyphMode::Unicode);
586        assert!(h.screen().contains("compose.yaml"), "{}", h.screen());
587        // `Demo` shows its picker with the variant `Msg::Picker`; a pointer is accepted as well.
588        let _ = FilePicker::new(&h.app().browser, pointer);
589        h.click_text("compose.yaml");
590        assert_eq!(h.app().chosen.as_deref(), Some(dir.join("compose.yaml").as_path()));
591        std::fs::remove_dir_all(dir).ok();
592    }
593
594    #[test]
595    fn sizes_read_naturally() {
596        assert_eq!(format_size(812), "812 B");
597        assert_eq!(format_size(12_700), "12.4 KiB");
598        assert_eq!(format_size(3 * 1024 * 1024 * 1024 + 1024 * 1024 * 100), "3.1 GiB");
599    }
600
601    /// A picker whose folder reads wait until the test delivers them, like a real disk.
602    struct Held {
603        browser: FileBrowser,
604    }
605
606    impl App for Held {
607        type Msg = Msg;
608        fn update(&mut self, msg: Msg) -> Command<Msg> {
609            let Msg::Picker(message) = msg;
610            // The read command is dropped: the test sends `Loaded` itself when it wants.
611            let _ = self.browser.update(message, Msg::Picker);
612            Command::none()
613        }
614        fn view(&self, ui: &mut View<'_, Msg>) {
615            FilePicker::new(&self.browser, Msg::Picker).show(ui).fill();
616        }
617    }
618
619    /// A picker on `dir`, already showing it, with the list focused.
620    fn held(dir: &Path) -> Harness<Held> {
621        let mut browser = FileBrowser::new(dir, PickMode::Files);
622        let _ = browser.update(FilePickerMsg::Loaded(dir.to_path_buf(), read_folder(dir)), |m| m);
623        let mut h = Harness::new(Held { browser }, 60, 14);
624        h.set_glyph_mode(GlyphMode::Unicode);
625        h.press("tab").press("tab");
626        h
627    }
628
629    fn ms(value: u64) -> std::time::Duration {
630        std::time::Duration::from_millis(value)
631    }
632
633    /// Whether the path line shows the reading spinner.
634    fn spinning(h: &Harness<Held>) -> bool {
635        let screen = h.screen();
636        screen.lines().next().is_some_and(|line| line.contains(|c| "◜◠◝◞◡◟".contains(c)))
637    }
638
639    fn deliver(h: &mut Harness<Held>, folder: &Path) {
640        h.send(Msg::Picker(FilePickerMsg::Loaded(folder.to_path_buf(), read_folder(folder))));
641    }
642
643    /// Opening a folder must not flash: the path and the listing stay as they were, keyboard focus
644    /// included, until the folder has been read. Every frame before the answer is the frame before
645    /// the click.
646    #[test]
647    fn opening_a_folder_keeps_the_listing_until_it_is_read() {
648        let dir = scratch("flash");
649        let mut h = held(&dir);
650        let before = h.screen();
651        h.press("enter");
652        assert_eq!(h.app().browser.loading(), Some(dir.join("deploy").as_path()));
653        for step in [0, 40, 120, 139] {
654            h.advance(ms(step));
655            assert_eq!(h.screen(), before, "at +{step} ms");
656        }
657        deliver(&mut h, &dir.join("deploy"));
658        let screen = h.screen();
659        assert!(screen.contains("›  deploy") && screen.contains("release.sh"), "{screen}");
660        assert!(!spinning(&h), "{screen}");
661        // The list kept its focus through the read, so the keys still move in it.
662        h.press("down");
663        assert!(h.screen().contains("▌  ▪ release.sh"), "{}", h.screen());
664        std::fs::remove_dir_all(dir).ok();
665    }
666
667    #[test]
668    fn a_slow_read_shows_the_indicator_after_the_delay_for_at_least_its_minimum() {
669        let dir = scratch("slow");
670        let mut h = held(&dir);
671        h.press("enter").advance(ms(299));
672        assert!(!spinning(&h), "{}", h.screen());
673        h.advance(ms(1));
674        let first = h.screen();
675        assert!(spinning(&h), "{first}");
676        // Still the old folder underneath, untouched.
677        assert!(first.contains("▌  ■ deploy") && first.contains("compose.yaml"), "{first}");
678        // The read ends at 350 ms: the new folder shows, the indicator stays until 800 ms.
679        h.advance(ms(50));
680        deliver(&mut h, &dir.join("deploy"));
681        assert!(h.screen().contains("release.sh") && spinning(&h), "{}", h.screen());
682        h.advance(ms(449));
683        assert!(spinning(&h), "{}", h.screen());
684        h.advance(ms(1));
685        assert!(!spinning(&h), "{}", h.screen());
686        std::fs::remove_dir_all(dir).ok();
687    }
688
689    #[test]
690    fn a_folder_that_cannot_be_read_shows_its_error_at_once() {
691        let dir = scratch("error");
692        let mut h = held(&dir);
693        let missing = dir.join("gone");
694        h.send(Msg::Picker(FilePickerMsg::Open(missing.clone()))).advance(ms(320));
695        assert!(spinning(&h), "{}", h.screen());
696        deliver(&mut h, &missing);
697        let screen = h.screen();
698        assert!(screen.contains("This folder does not exist"), "{screen}");
699        assert!(!spinning(&h), "{screen}");
700        std::fs::remove_dir_all(dir).ok();
701    }
702
703    #[test]
704    fn a_first_read_leaves_the_space_empty_until_it_is_slow() {
705        let dir = scratch("first");
706        // Wide enough for the indicator's label after the path.
707        let mut h = Harness::new(Held { browser: FileBrowser::new(&dir, PickMode::Files) }, 90, 12);
708        h.set_glyph_mode(GlyphMode::Unicode);
709        h.send(Msg::Picker(FilePickerMsg::Open(dir.clone())));
710        assert!(!spinning(&h) && !h.screen().contains("deploy"), "{}", h.screen());
711        h.advance(ms(300));
712        assert!(spinning(&h), "{}", h.screen());
713        assert!(
714            h.screen().lines().next().is_some_and(|line| line.trim_end().ends_with(" Reading folder…")),
715            "{}",
716            h.screen()
717        );
718        deliver(&mut h, &dir);
719        assert!(h.screen().contains("compose.yaml"), "{}", h.screen());
720        std::fs::remove_dir_all(dir).ok();
721    }
722}