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.
195        let wants_folder = browser.mode == PickMode::Folders;
196        let selected = browser.selected.as_ref().filter(|name| {
197            browser.visible().iter().any(|entry| entry.name() == name.as_str() && entry.is_folder() == wants_folder)
198        });
199        let chosen = match browser.mode {
200            PickMode::Files => selected.map(|name| browser.folder.join(name)),
201            // Without a chosen folder inside, the folder shown is the choice.
202            PickMode::Folders => {
203                Some(selected.map_or_else(|| browser.folder.clone(), |name| browser.folder.join(name)))
204            }
205        };
206        let ready = matches!(browser.state, FolderState::Ready(_));
207        ui.row(|ui| {
208            let toggle = Rc::clone(wrap);
209            ui.add(
210                Switch::new(browser.show_hidden)
211                    .label(crate::t!("quvyta.file-picker.hidden"))
212                    .on_toggle(move |on| toggle(FilePickerMsg::ShowHidden(on))),
213            );
214            if !browser.extensions.is_empty() {
215                ui.add(Text::new(browser.extensions.join(", ")).role("faint").no_wrap());
216            }
217            ui.spacer();
218            let label = match browser.mode {
219                PickMode::Files => crate::t!("quvyta.file-picker.choose-file"),
220                PickMode::Folders => crate::t!("quvyta.file-picker.choose-folder"),
221            };
222            let mut button = Button::new(label).variant("primary").disabled(!ready || chosen.is_none());
223            if let Some(path) = chosen {
224                button = button.on_press(wrap(FilePickerMsg::Chosen(path)));
225            }
226            ui.add(button);
227        })
228        .gap(2)
229        .align(Align::Center)
230        .fill_width();
231    }
232}
233
234/// A size in bytes as a short human text: `812 B`, `12.4 KiB`, `3.1 GiB`.
235fn format_size(bytes: u64) -> String {
236    const UNITS: [&str; 5] = ["KiB", "MiB", "GiB", "TiB", "PiB"];
237    if bytes < 1024 {
238        return format!("{bytes} B");
239    }
240    let mut value = bytes as f64 / 1024.0;
241    let mut unit = 0;
242    while value >= 1024.0 && unit + 1 < UNITS.len() {
243        value /= 1024.0;
244        unit += 1;
245    }
246    format!("{} {}", crate::i18n::number(value, 1), UNITS[unit])
247}
248
249/// Cells kept after the path for the reading spinner: a gap and the spinner, so the segments
250/// never move when it shows.
251const INDICATOR_CELLS: u16 = 2;
252
253/// The current folder as clickable segments separated by a faint glyph, with the delayed reading
254/// indicator after them.
255struct PathBar<Msg> {
256    folder: PathBuf,
257    /// A folder is being read.
258    busy: bool,
259    /// The folder shown could not be read; its error replaces any indicator at once.
260    failed: bool,
261    wrap: Wrap<Msg>,
262}
263
264impl<Msg> PathBar<Msg> {
265    /// Segment labels with the folder each one opens.
266    fn segments(&self) -> Vec<(String, PathBuf)> {
267        let mut out = Vec::new();
268        let mut path = PathBuf::new();
269        for component in self.folder.components() {
270            path.push(component.as_os_str());
271            let label = match component {
272                Component::RootDir => std::path::MAIN_SEPARATOR.to_string(),
273                other => other.as_os_str().to_string_lossy().into_owned(),
274            };
275            if matches!(component, Component::Prefix(_)) {
276                continue;
277            }
278            out.push((label, path.clone()));
279        }
280        out
281    }
282
283    /// Where the segments go: the line without the cells kept for the indicator.
284    fn segments_area(area: Rect) -> Rect {
285        Rect::new(area.x, area.y, area.width.saturating_sub(INDICATOR_CELLS), area.height)
286    }
287
288    /// Draws the reading spinner right after the segments, which end at `end`, when the delay
289    /// rule says so, with its label where the line has room.
290    fn paint_indicator(&self, cx: &mut PaintCx<'_>, area: Rect, end: i32) {
291        let now = cx.now();
292        let indicator = cx.memory::<DelayedIndicator>();
293        if self.failed && !self.busy {
294            indicator.cancel();
295        }
296        let shown = indicator.update(self.busy, now);
297        let next = indicator.next_change(self.busy, now);
298        if let Some(delay) = next {
299            cx.request_frame_in(delay);
300        }
301        if !shown || area.width <= INDICATOR_CELLS {
302            return;
303        }
304        // Glyph, a space and the label, like a `Spinner`, right after the last segment: the eye is
305        // already there. The label only shows where it fits whole; the glyph always has its cells.
306        let x = end + 1;
307        let style = cx.style("spinner", None, &[]).text();
308        let cell = cx.animation(SpinnerStyle::default().animation(), style, Some(std::time::Duration::ZERO));
309        cx.text(x, area.y, &cell.glyph, cell.style, 1);
310        let label = crate::t!("quvyta.file-picker.loading");
311        let width = text::width(&label);
312        let label_x = x + 2;
313        if label_x + i32::from(width) <= area.right() {
314            let style = cx.style("spinner-label", None, &[]).text();
315            cx.text(label_x, area.y, &label, style, width);
316        }
317    }
318
319    /// Screen spans of the segments that fit, from the right; the first span may be the
320    /// ellipsis of hidden leading segments (index `None`).
321    fn layout(&self, separator_width: u16, area: Rect) -> Vec<(Option<usize>, Rect)> {
322        let segments = self.segments();
323        let widths: Vec<u16> = segments.iter().map(|(label, _)| text::width(label).saturating_add(2)).collect();
324        let step = separator_width + 2;
325        let mut first = 0;
326        let total = |from: usize| -> u16 {
327            let ellipsis = if from > 0 { 3 + step } else { 0 };
328            widths[from..].iter().sum::<u16>()
329                + step * u16::try_from(widths.len() - from).unwrap_or(0).saturating_sub(1)
330                + ellipsis
331        };
332        while first + 1 < segments.len() && total(first) > area.width {
333            first += 1;
334        }
335        let mut out = Vec::new();
336        let mut x = area.x;
337        if first > 0 {
338            out.push((None, Rect::new(x, area.y, 3, 1)));
339            x += 3 + i32::from(step);
340        }
341        for (index, width) in widths.iter().enumerate().skip(first) {
342            out.push((Some(index), Rect::new(x, area.y, *width, 1)));
343            x += i32::from(*width + step);
344        }
345        out
346    }
347}
348
349impl<Msg: 'static> Widget<Msg> for PathBar<Msg> {
350    fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
351        let width = cells::sum(self.segments().iter().map(|(label, _)| text::width(label).saturating_add(5)));
352        Size::new(width.saturating_add(INDICATOR_CELLS), 1).min(available)
353    }
354
355    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
356        cx.register_hit(area);
357        let separator = cx.env().icons().glyph("path-separator").into_owned();
358        let separator_width = text::width(&separator);
359        let segments = self.segments();
360        let pointer = cx.pointer();
361        let last = segments.len().saturating_sub(1);
362        let separator_style = cx.style("path-separator", None, &[]).text();
363        let spans = self.layout(separator_width, Self::segments_area(area));
364        for (position, (index, rect)) in spans.iter().enumerate() {
365            let mut states = Vec::new();
366            if pointer.is_some_and(|(x, y)| rect.contains(x, y)) && *index != Some(last) && index.is_some() {
367                states.push(State::Hover);
368            }
369            if *index == Some(last) {
370                states.push(State::Selected);
371            }
372            let style = cx.style("path-segment", None, &states).text();
373            if let Some(bg) = style.bg {
374                cx.fill(*rect, bg);
375            }
376            let label = index.map_or_else(|| text::ELLIPSIS.to_owned(), |i| segments[i].0.clone());
377            cx.text(rect.x + 1, rect.y, &label, CellStyle { bg: None, ..style }, rect.width.saturating_sub(2));
378            if position + 1 < spans.len() {
379                cx.text(rect.right() + 1, rect.y, &separator, separator_style, separator_width);
380            }
381        }
382        let end = spans.last().map_or(area.x, |(_, rect)| rect.right());
383        self.paint_indicator(cx, area, end);
384    }
385
386    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
387        let Event::Mouse(mouse) = event else { return false };
388        if mouse.kind != MouseKind::Down(MouseButton::Left) {
389            return false;
390        }
391        let separator = text::width(&cx.env().icons().glyph("path-separator"));
392        let segments = self.segments();
393        let hit = self
394            .layout(separator, Self::segments_area(cx.area()))
395            .into_iter()
396            .find(|(_, rect)| rect.contains(mouse.x, mouse.y));
397        match hit {
398            Some((Some(index), _)) if index + 1 < segments.len() => {
399                cx.emit((self.wrap)(FilePickerMsg::Open(segments[index].1.clone())));
400                true
401            }
402            _ => false,
403        }
404    }
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410    use crate::icons::GlyphMode;
411    use crate::runtime::{App, Command, Harness};
412    use crate::widgets::read_folder;
413
414    struct Demo {
415        browser: FileBrowser,
416        chosen: Option<PathBuf>,
417    }
418
419    #[derive(Debug, Clone)]
420    enum Msg {
421        Picker(FilePickerMsg),
422    }
423
424    impl App for Demo {
425        type Msg = Msg;
426        fn update(&mut self, msg: Msg) -> Command<Msg> {
427            match msg {
428                Msg::Picker(FilePickerMsg::Chosen(path)) => self.chosen = Some(path),
429                Msg::Picker(message) => return self.browser.update(message, Msg::Picker),
430            }
431            Command::none()
432        }
433        fn view(&self, ui: &mut View<'_, Msg>) {
434            FilePicker::new(&self.browser, Msg::Picker).show(ui).fill();
435        }
436    }
437
438    fn scratch(name: &str) -> PathBuf {
439        let dir = std::env::temp_dir().join(format!("quvyta-a8-picker-{name}-{}", std::process::id()));
440        let _ = std::fs::remove_dir_all(&dir);
441        std::fs::create_dir_all(dir.join("deploy/scripts")).expect("scratch folders");
442        std::fs::write(dir.join("compose.yaml"), "services:\n").expect("scratch file");
443        std::fs::write(dir.join("deploy/release.sh"), vec![b'#'; 2048]).expect("scratch file");
444        dir
445    }
446
447    fn harness(dir: &Path, mode: PickMode) -> Harness<Demo> {
448        let browser = FileBrowser::new(dir, mode);
449        let mut h = Harness::new(Demo { browser, chosen: None }, 60, 14);
450        h.set_glyph_mode(GlyphMode::Unicode);
451        let folder = dir.to_path_buf();
452        h.send(Msg::Picker(FilePickerMsg::Open(folder)));
453        h
454    }
455
456    #[test]
457    fn browses_into_folders_and_chooses_a_file() {
458        let dir = scratch("choose");
459        let mut h = harness(&dir, PickMode::Files);
460        let screen = h.screen();
461        assert!(screen.contains("■ deploy") && screen.contains("compose.yaml"), "{screen}");
462        assert!(screen.contains("Parent folder"), "{screen}");
463        h.click_text("deploy");
464        let screen = h.screen();
465        assert!(screen.contains("release.sh") && screen.contains("2.0 KiB"), "{screen}");
466        assert!(screen.contains("scripts"), "{screen}");
467        h.click_text("release.sh");
468        assert_eq!(h.app().chosen.as_deref(), Some(dir.join("deploy/release.sh").as_path()));
469        let name = dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
470        h.click_text(&name);
471        assert_eq!(h.app().browser.folder(), dir.as_path());
472        std::fs::remove_dir_all(dir).ok();
473    }
474
475    #[test]
476    fn folder_mode_chooses_the_selected_folder_and_filters() {
477        let dir = scratch("folders");
478        let mut h = harness(&dir, PickMode::Folders);
479        h.click_text("Choose folder");
480        assert_eq!(h.app().chosen.as_deref(), Some(dir.join("deploy").as_path()));
481        h.send(Msg::Picker(FilePickerMsg::Filter("zzz".into())));
482        assert!(h.screen().contains("Nothing matches"), "{}", h.screen());
483        std::fs::remove_dir_all(dir).ok();
484    }
485
486    #[test]
487    fn unreadable_folders_show_a_state() {
488        let missing = std::env::temp_dir().join("quvyta-a8-picker-missing");
489        let h = harness(&missing, PickMode::Files);
490        let screen = h.screen();
491        assert!(screen.contains("✕  This folder does not exist"), "{screen}");
492        assert!(!screen.contains('['));
493    }
494
495    /// A picker inside a screen of the application: the screen's messages carry the tab it sits
496    /// in, so `wrap` is a closure over that tab rather than a function.
497    struct Tabbed {
498        tab: usize,
499        browser: FileBrowser,
500        chosen: Vec<(usize, PathBuf)>,
501    }
502
503    #[derive(Debug, Clone)]
504    enum TabMsg {
505        Picker(usize, FilePickerMsg),
506    }
507
508    impl App for Tabbed {
509        type Msg = TabMsg;
510        fn update(&mut self, msg: TabMsg) -> Command<TabMsg> {
511            let TabMsg::Picker(tab, message) = msg;
512            if let FilePickerMsg::Chosen(path) = message {
513                self.chosen.push((tab, path));
514                return Command::none();
515            }
516            self.browser.update(message, move |message| TabMsg::Picker(tab, message))
517        }
518        fn view(&self, ui: &mut View<'_, TabMsg>) {
519            let tab = self.tab;
520            FilePicker::new(&self.browser, move |message| TabMsg::Picker(tab, message)).show(ui).fill();
521        }
522    }
523
524    #[test]
525    fn a_closure_capturing_state_wraps_every_picker_message() {
526        let dir = scratch("closure");
527        let browser = FileBrowser::new(&dir, PickMode::Files);
528        let mut h = Harness::new(Tabbed { tab: 3, browser, chosen: Vec::new() }, 60, 14);
529        h.set_glyph_mode(GlyphMode::Unicode);
530        // `update` hands the closure to `open`, whose read delivers `Loaded` through it.
531        h.send(TabMsg::Picker(3, FilePickerMsg::Open(dir.clone())));
532        assert!(h.screen().contains("compose.yaml"), "{}", h.screen());
533        h.click_text("deploy").click_text("release.sh");
534        assert_eq!(h.app().chosen, [(3, dir.join("deploy/release.sh"))]);
535        std::fs::remove_dir_all(dir).ok();
536    }
537
538    impl From<FilePickerMsg> for Msg {
539        fn from(message: FilePickerMsg) -> Self {
540            Msg::Picker(message)
541        }
542    }
543
544    /// Callers that pass a plain function keep compiling: an item, a pointer held in a value, and
545    /// a generic function made concrete by the message type.
546    #[test]
547    fn plain_functions_still_wrap() {
548        fn wrap(message: FilePickerMsg) -> Msg {
549            Msg::Picker(message)
550        }
551        fn picked<M: From<FilePickerMsg>>(message: FilePickerMsg) -> M {
552            M::from(message)
553        }
554        let dir = scratch("plain");
555        let pointer: fn(FilePickerMsg) -> Msg = wrap;
556        let mut browser = FileBrowser::new(&dir, PickMode::Files);
557        let command: Command<Msg> = browser.open(dir.clone(), pointer);
558        assert_eq!(command.actions.len(), 1);
559        let command: Command<Msg> = browser.update(FilePickerMsg::Refresh, picked::<Msg>);
560        assert_eq!(command.actions.len(), 1);
561        let _ = browser.update(FilePickerMsg::Loaded(dir.clone(), read_folder(&dir)), wrap);
562        let mut h = Harness::new(Demo { browser, chosen: None }, 60, 14);
563        h.set_glyph_mode(GlyphMode::Unicode);
564        assert!(h.screen().contains("compose.yaml"), "{}", h.screen());
565        // `Demo` shows its picker with the variant `Msg::Picker`; a pointer is accepted as well.
566        let _ = FilePicker::new(&h.app().browser, pointer);
567        h.click_text("compose.yaml");
568        assert_eq!(h.app().chosen.as_deref(), Some(dir.join("compose.yaml").as_path()));
569        std::fs::remove_dir_all(dir).ok();
570    }
571
572    #[test]
573    fn sizes_read_naturally() {
574        assert_eq!(format_size(812), "812 B");
575        assert_eq!(format_size(12_700), "12.4 KiB");
576        assert_eq!(format_size(3 * 1024 * 1024 * 1024 + 1024 * 1024 * 100), "3.1 GiB");
577    }
578
579    /// A picker whose folder reads wait until the test delivers them, like a real disk.
580    struct Held {
581        browser: FileBrowser,
582    }
583
584    impl App for Held {
585        type Msg = Msg;
586        fn update(&mut self, msg: Msg) -> Command<Msg> {
587            let Msg::Picker(message) = msg;
588            // The read command is dropped: the test sends `Loaded` itself when it wants.
589            let _ = self.browser.update(message, Msg::Picker);
590            Command::none()
591        }
592        fn view(&self, ui: &mut View<'_, Msg>) {
593            FilePicker::new(&self.browser, Msg::Picker).show(ui).fill();
594        }
595    }
596
597    /// A picker on `dir`, already showing it, with the list focused.
598    fn held(dir: &Path) -> Harness<Held> {
599        let mut browser = FileBrowser::new(dir, PickMode::Files);
600        let _ = browser.update(FilePickerMsg::Loaded(dir.to_path_buf(), read_folder(dir)), |m| m);
601        let mut h = Harness::new(Held { browser }, 60, 14);
602        h.set_glyph_mode(GlyphMode::Unicode);
603        h.press("tab").press("tab");
604        h
605    }
606
607    fn ms(value: u64) -> std::time::Duration {
608        std::time::Duration::from_millis(value)
609    }
610
611    /// Whether the path line shows the reading spinner.
612    fn spinning(h: &Harness<Held>) -> bool {
613        let screen = h.screen();
614        screen.lines().next().is_some_and(|line| line.contains(|c| "◜◠◝◞◡◟".contains(c)))
615    }
616
617    fn deliver(h: &mut Harness<Held>, folder: &Path) {
618        h.send(Msg::Picker(FilePickerMsg::Loaded(folder.to_path_buf(), read_folder(folder))));
619    }
620
621    /// Opening a folder must not flash: the path and the listing stay as they were, keyboard focus
622    /// included, until the folder has been read. Every frame before the answer is the frame before
623    /// the click.
624    #[test]
625    fn opening_a_folder_keeps_the_listing_until_it_is_read() {
626        let dir = scratch("flash");
627        let mut h = held(&dir);
628        let before = h.screen();
629        h.press("enter");
630        assert_eq!(h.app().browser.loading(), Some(dir.join("deploy").as_path()));
631        for step in [0, 40, 120, 139] {
632            h.advance(ms(step));
633            assert_eq!(h.screen(), before, "at +{step} ms");
634        }
635        deliver(&mut h, &dir.join("deploy"));
636        let screen = h.screen();
637        assert!(screen.contains("›  deploy") && screen.contains("release.sh"), "{screen}");
638        assert!(!spinning(&h), "{screen}");
639        // The list kept its focus through the read, so the keys still move in it.
640        h.press("down");
641        assert!(h.screen().contains("▌  ▪ release.sh"), "{}", h.screen());
642        std::fs::remove_dir_all(dir).ok();
643    }
644
645    #[test]
646    fn a_slow_read_shows_the_indicator_after_the_delay_for_at_least_its_minimum() {
647        let dir = scratch("slow");
648        let mut h = held(&dir);
649        h.press("enter").advance(ms(299));
650        assert!(!spinning(&h), "{}", h.screen());
651        h.advance(ms(1));
652        let first = h.screen();
653        assert!(spinning(&h), "{first}");
654        // Still the old folder underneath, untouched.
655        assert!(first.contains("▌  ■ deploy") && first.contains("compose.yaml"), "{first}");
656        // The read ends at 350 ms: the new folder shows, the indicator stays until 800 ms.
657        h.advance(ms(50));
658        deliver(&mut h, &dir.join("deploy"));
659        assert!(h.screen().contains("release.sh") && spinning(&h), "{}", h.screen());
660        h.advance(ms(449));
661        assert!(spinning(&h), "{}", h.screen());
662        h.advance(ms(1));
663        assert!(!spinning(&h), "{}", h.screen());
664        std::fs::remove_dir_all(dir).ok();
665    }
666
667    #[test]
668    fn a_folder_that_cannot_be_read_shows_its_error_at_once() {
669        let dir = scratch("error");
670        let mut h = held(&dir);
671        let missing = dir.join("gone");
672        h.send(Msg::Picker(FilePickerMsg::Open(missing.clone()))).advance(ms(320));
673        assert!(spinning(&h), "{}", h.screen());
674        deliver(&mut h, &missing);
675        let screen = h.screen();
676        assert!(screen.contains("This folder does not exist"), "{screen}");
677        assert!(!spinning(&h), "{screen}");
678        std::fs::remove_dir_all(dir).ok();
679    }
680
681    #[test]
682    fn a_first_read_leaves_the_space_empty_until_it_is_slow() {
683        let dir = scratch("first");
684        // Wide enough for the indicator's label after the path.
685        let mut h = Harness::new(Held { browser: FileBrowser::new(&dir, PickMode::Files) }, 90, 12);
686        h.set_glyph_mode(GlyphMode::Unicode);
687        h.send(Msg::Picker(FilePickerMsg::Open(dir.clone())));
688        assert!(!spinning(&h) && !h.screen().contains("deploy"), "{}", h.screen());
689        h.advance(ms(300));
690        assert!(spinning(&h), "{}", h.screen());
691        assert!(
692            h.screen().lines().next().is_some_and(|line| line.trim_end().ends_with(" Reading folder…")),
693            "{}",
694            h.screen()
695        );
696        deliver(&mut h, &dir);
697        assert!(h.screen().contains("compose.yaml"), "{}", h.screen());
698        std::fs::remove_dir_all(dir).ok();
699    }
700}