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