Skip to main content

qframe/widgets/file_manager/
flat.rs

1//! The flat views of a file manager: one folder at a time, drawn as a list of rows with their
2//! details or as a grid of icons.
3//!
4//! A tree shows folders inside folders; a flat view shows one folder, and stepping into a folder
5//! or out of it is how it is moved through. Which folder that is belongs to the state, because
6//! reading a folder is the state's work, while which shape it is drawn in belongs to the view.
7//!
8//! Both shapes are built from the same rows: the folder itself first, so it has a place of its own
9//! for its menu and a way out of it, and then its entries. Only the rows on screen are painted,
10//! whatever the folder holds, which is what [`Table`] and [`CardGrid`] do by themselves.
11
12use std::rc::Rc;
13use std::sync::Arc;
14use std::time::Duration;
15
16use crate::geometry::{Rect, Size};
17use crate::style::CellStyle;
18use crate::text;
19use crate::widget::{Align, Length, MeasureCx, PaintCx, View, Widget};
20
21use super::super::delayed::DelayedIndicator;
22use super::super::{
23    CardGrid, Column, ColumnWidth, ContextItem, RowDrop, Span, SpinnerStyle, Table, TableCell, TableRow, Text, TreeDrop,
24};
25use super::state::ROOT;
26use super::{FileManager, FileManagerMsg, child_key, root_name};
27
28/// Cells the date column takes: `2026-09-20 14:32` and no more.
29const DATE_WIDTH: u16 = 16;
30
31/// Cells the permissions column takes: the ten letters unix writes them with.
32const PERMISSIONS_WIDTH: u16 = 11;
33
34/// The fewest cells the name column shrinks to before the columns scroll sideways.
35const NAME_WIDTH: u16 = 12;
36
37/// The shape a file manager draws its folder in.
38///
39/// The tree is what a manager is without being asked anything; the other two show one folder at a
40/// time and are turned on with [`FileManager::view`].
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
42#[non_exhaustive]
43pub enum FileView {
44    /// Folders inside folders, opened and closed where they stand. The default.
45    #[default]
46    Tree,
47    /// One folder as rows: the name, the size, when it changed last and its permissions.
48    List,
49    /// One folder as cards, each an icon and a name.
50    Icons,
51}
52
53/// What one card of the icon view shows: its name, its icon, the icon's tone and whether the card
54/// is drawn faint.
55type Card = (String, String, Option<String>, bool);
56
57/// One row of a flat view: an entry of the shown folder, or the folder itself at the top.
58pub(super) struct FlatRow {
59    /// The key it acts on.
60    pub(super) key: String,
61    /// What it says.
62    pub(super) name: String,
63    /// Whether it can be stepped into.
64    pub(super) folder: bool,
65    /// Whether it is a file that may be run.
66    pub(super) executable: bool,
67    /// Whether it is the row of the shown folder itself, which steps out of it.
68    pub(super) itself: bool,
69}
70
71impl<'a, Msg: Clone + 'static> FileManager<'a, Msg> {
72    /// The rows a flat view shows: the folder itself, then the entries it has been read to hold.
73    pub(super) fn flat_rows(&self) -> Vec<FlatRow> {
74        let state = self.state;
75        let shown = state.folder();
76        let label = if shown == ROOT {
77            self.root_label.clone().unwrap_or_else(|| root_name(state.root()))
78        } else {
79            super::name_of(shown).to_owned()
80        };
81        let mut rows =
82            vec![FlatRow { key: shown.to_owned(), name: label, folder: true, executable: false, itself: true }];
83        if let Some(entries) = state.shown_children(shown) {
84            rows.extend(entries.into_iter().map(|entry| FlatRow {
85                key: child_key(shown, &entry.name),
86                name: entry.name.clone(),
87                folder: entry.folder,
88                executable: entry.executable,
89                itself: false,
90            }));
91        }
92        rows
93    }
94
95    /// The icon and the colour a flat row is drawn with, and whether it is drawn faint.
96    fn flat_look(&self, row: &FlatRow) -> (String, Option<String>, bool) {
97        let mark = self.mark_of(&row.key);
98        // The kind goes by the entry's own name, not by a label the application gave the root.
99        let name = if row.key == ROOT { root_name(self.state.root()) } else { super::name_of(&row.key).to_owned() };
100        let (icon, tone) = self.sign_of(&mark, &row.key, &name, row.folder, row.executable);
101        // The row of the folder itself is never faint for being cut: it is where the person is.
102        let cut = !row.itself && self.state.is_cut(&row.key);
103        (icon, tone, self.disabled || cut || mark.is_faint())
104    }
105
106    /// Which of a flat view's rows the cursor is on and which are selected, as the widgets count
107    /// them.
108    fn flat_selection(&self, rows: &[FlatRow]) -> (Option<usize>, Vec<usize>) {
109        let state = self.state;
110        let selected = state.selected().and_then(|cursor| rows.iter().position(|row| row.key == cursor));
111        let chosen = rows
112            .iter()
113            .enumerate()
114            .filter(|(_, row)| !row.itself && state.chosen().contains(&row.key))
115            .map(|(index, _)| index)
116            .collect();
117        (selected, chosen)
118    }
119
120    /// The message a flat row's activation sends: the folder row steps out of the folder, another
121    /// folder is stepped into, and a file is the application's to open.
122    fn flat_activate(&self, row: &FlatRow) -> Msg {
123        if row.itself {
124            return (self.wrap)(FileManagerMsg::Leave);
125        }
126        if row.folder {
127            return (self.wrap)(FileManagerMsg::Enter(row.key.clone()));
128        }
129        match &self.on_open {
130            Some(open) => open(&self.state.path(&row.key)),
131            // Without a way to open files, a click on one only moves the cursor to it.
132            None => (self.wrap)(FileManagerMsg::Select(row.key.clone())),
133        }
134    }
135
136    /// The keys, the activations and the checks a flat view's widgets are wired with.
137    fn flat_wiring(&self, rows: &[FlatRow]) -> Wiring<Msg> {
138        Wiring {
139            keys: rows.iter().map(|row| row.key.clone()).collect(),
140            activations: rows.iter().map(|row| self.flat_activate(row)).collect(),
141            itself: rows.iter().map(|row| row.itself).collect(),
142            folders: rows.iter().map(|row| row.folder && !row.itself).collect(),
143            up: (self.state.folder() != ROOT).then(|| super::parent_key(self.state.folder()).to_owned()),
144        }
145    }
146
147    /// The list view: a row per entry with its size, when it changed last and its permissions.
148    pub(super) fn table(&self, rows: &[FlatRow]) -> Table<Msg> {
149        let state = self.state;
150        let columns = vec![
151            Column::new(crate::t!("quvyta.file-manager.column-name")).min(NAME_WIDTH),
152            Column::new(crate::t!("quvyta.file-manager.column-size")).width(ColumnWidth::Fit).align(Align::End),
153            Column::new(crate::t!("quvyta.file-manager.column-modified")).width(ColumnWidth::Fixed(DATE_WIDTH)),
154            Column::new(crate::t!("quvyta.file-manager.column-permissions"))
155                .width(ColumnWidth::Fixed(PERMISSIONS_WIDTH)),
156        ];
157        let table_rows: Vec<TableRow> = rows
158            .iter()
159            .map(|row| {
160                let (icon, tone, faint) = self.flat_look(row);
161                let name = TableCell::new(row.name.clone()).icon(icon, tone.as_deref());
162                // The folder's own row says nothing about itself: its size is the folder's own,
163                // which says nothing about what is in it, and the row is a way out rather than an
164                // entry of the list.
165                let details = if row.itself { None } else { state.details(&row.key).flatten() };
166                let (size, modified, permissions) = match details {
167                    Some(details) => {
168                        (details.size_text(row.folder), details.modified_text(), details.permissions_text(row.folder))
169                    }
170                    None => (String::new(), String::new(), String::new()),
171                };
172                TableRow::new([name, TableCell::new(size), TableCell::new(modified), TableCell::new(permissions)])
173                    .faint(faint)
174            })
175            .collect();
176        let table = Table::new(columns, table_rows);
177        if self.disabled {
178            return table;
179        }
180        let (selected, chosen) = self.flat_selection(rows);
181        let wiring = self.flat_wiring(rows);
182        let [select, activate, choose, drop, copy, accepts] = std::array::from_fn(|_| wiring.clone());
183        let [wrap, choosing, dropping, copying] = std::array::from_fn(|_| Rc::clone(&self.wrap));
184        table
185            .selected(selected)
186            .activate_on(self.open_on)
187            .multi_select(&chosen, move |indexes| choosing(FileManagerMsg::Choose(choose.keys_of(&indexes))))
188            .box_select(true)
189            .droppable(
190                move |dropped| dropping(FileManagerMsg::Drop(drop.drop(&dropped))),
191                move |index| accepts.takes_drop(index),
192            )
193            .on_copy_drop(move |dropped| copying(FileManagerMsg::DropCopy(copy.drop(&dropped))))
194            .on_select(move |index| select.select(index, &wrap))
195            .on_activate(move |index| activate.activate(index))
196            .context_menu(self.flat_menu(rows))
197    }
198
199    /// The icon view: a card per entry, each an icon and a name.
200    pub(super) fn grid(&self, rows: &[FlatRow]) -> CardGrid<Msg> {
201        let cards: Arc<[Card]> = rows
202            .iter()
203            .map(|row| {
204                let (icon, tone, faint) = self.flat_look(row);
205                (row.name.clone(), icon, tone, faint)
206            })
207            .collect();
208        let built = Arc::clone(&cards);
209        let grid = CardGrid::new(rows.len()).card_width(18, 26).card_height(1).disabled(self.disabled).card(
210            move |ui, index| {
211                let Some((name, icon, tone, faint)) = built.get(index) else { return };
212                let glyph = ui.env().icons().glyph(icon).into_owned();
213                let mut mark = Span::new(format!("{glyph} "));
214                if let Some(tone) = tone {
215                    mark = mark.color(tone.as_str());
216                }
217                let mut label = Span::new(name.clone());
218                if *faint {
219                    (mark, label) = (mark.role("faint"), label.role("faint"));
220                }
221                ui.add(Text::rich([mark, label]).no_wrap());
222            },
223        );
224        if self.disabled {
225            return grid;
226        }
227        let (selected, chosen) = self.flat_selection(rows);
228        let wiring = self.flat_wiring(rows);
229        let [select, activate, choose, drop, copy, accepts] = std::array::from_fn(|_| wiring.clone());
230        let [wrap, choosing, dropping, copying] = std::array::from_fn(|_| Rc::clone(&self.wrap));
231        grid.selected(selected)
232            .activate_on(self.open_on)
233            .multi_select(&chosen, move |indexes| choosing(FileManagerMsg::Choose(choose.keys_of(&indexes))))
234            .box_select(true)
235            .droppable(
236                move |dropped| dropping(FileManagerMsg::Drop(drop.drop(&dropped))),
237                move |index| accepts.takes_drop(index),
238            )
239            .on_copy_drop(move |dropped| copying(FileManagerMsg::DropCopy(copy.drop(&dropped))))
240            .on_select(move |index| select.select(index, &wrap))
241            .on_activate(move |index| activate.activate(index))
242            .context_menu(self.flat_menu(rows))
243    }
244
245    /// The menu of a flat row, which is the menu of the entry it stands for.
246    fn flat_menu(&self, rows: &[FlatRow]) -> impl Fn(usize) -> Vec<ContextItem<Msg>> + 'static {
247        let keys: Vec<String> = rows.iter().map(|row| row.key.clone()).collect();
248        let items = self.menu_for();
249        // The way out of the folder is on the folder's own row, where the way in was.
250        let leave = (self.state.folder() != ROOT).then(|| (self.wrap)(FileManagerMsg::Leave));
251        move |index| {
252            let mut own = keys.get(index).map(|key| items(key)).unwrap_or_default();
253            if index == 0
254                && let Some(leave) = &leave
255            {
256                own.insert(0, ContextItem::new(crate::t!("quvyta.file-manager.up"), leave.clone()));
257                own.insert(1, ContextItem::gap());
258            }
259            own
260        }
261    }
262
263    /// The foot of a flat view: the spinner of a slow read, and what the folder holds otherwise.
264    ///
265    /// It keeps its row whether it shows the spinner or the count, so the rows above it never jump
266    /// when a read takes long enough to be worth saying something about.
267    pub(super) fn foot(&self, ui: &mut View<'_, Msg>, rows: &[FlatRow]) {
268        let state = self.state;
269        let shown = state.folder();
270        let known = state.shown_children(shown);
271        let label = match &known {
272            None => String::new(),
273            // A folder that could not be read is not an empty one, and the foot is the only place
274            // a flat view has to say which of the two it is showing.
275            Some(_) if state.folder_error(shown).is_some() => {
276                crate::t!("quvyta.file-manager.unreadable-short")
277            }
278            Some(_) if rows.len() <= 1 => crate::t!("quvyta.file-manager.empty"),
279            Some(_) => crate::t!("quvyta.file-manager.entries", n = rows.len() - 1),
280        };
281        ui.add(Reading { busy: state.is_loading(shown), label }).fill_width().height(Length::Cells(1));
282    }
283}
284
285/// What a flat view's widgets need to turn a row number back into the entry it stands for.
286struct Wiring<Msg> {
287    keys: Vec<String>,
288    activations: Vec<Msg>,
289    itself: Vec<bool>,
290    /// Whether each row is a folder other than the shown one, which is what takes a drop.
291    folders: Vec<bool>,
292    /// The folder above the shown one, which the shown folder's own row stands for as a drop
293    /// target; `None` at the root, which has nothing above it.
294    up: Option<String>,
295}
296
297impl<Msg: Clone> Clone for Wiring<Msg> {
298    fn clone(&self) -> Self {
299        Self {
300            keys: self.keys.clone(),
301            activations: self.activations.clone(),
302            itself: self.itself.clone(),
303            folders: self.folders.clone(),
304            up: self.up.clone(),
305        }
306    }
307}
308
309impl<Msg: Clone> Wiring<Msg> {
310    /// Moving the cursor to row `index`.
311    fn select(&self, index: usize, wrap: &Rc<dyn Fn(FileManagerMsg) -> Msg>) -> Msg {
312        let key = self.keys.get(index).cloned().unwrap_or_default();
313        wrap(FileManagerMsg::Select(key))
314    }
315
316    /// What row `index` does when it is opened.
317    fn activate(&self, index: usize) -> Msg {
318        self.activations
319            .get(index)
320            .cloned()
321            .unwrap_or_else(|| self.activations.first().cloned().expect("a flat view always has the folder's own row"))
322    }
323
324    /// The keys of the rows `indexes`, the selection a widget reports. The folder's own row is not
325    /// an entry, so it is never part of the selection, even when a box covers it.
326    fn keys_of(&self, indexes: &[usize]) -> Vec<String> {
327        indexes
328            .iter()
329            .filter(|index| self.itself.get(**index) == Some(&false))
330            .filter_map(|index| self.keys.get(*index).cloned())
331            .collect()
332    }
333
334    /// Whether row `index` takes a drop: a folder of the shown one, or the shown folder's own
335    /// row, which is the way up and takes a drop for the folder above, as a desktop explorer's
336    /// path takes one for a parent. At the root that row has nothing above it and takes nothing.
337    fn takes_drop(&self, index: usize) -> bool {
338        if self.itself.get(index) == Some(&true) {
339            return self.up.is_some();
340        }
341        self.folders.get(index).copied().unwrap_or(false)
342    }
343
344    /// The entries a widget's drop moves, and the folder they go into: the folder above for a
345    /// drop on the shown folder's own row.
346    fn drop(&self, dropped: &RowDrop) -> TreeDrop {
347        let into = if self.itself.get(dropped.into) == Some(&true) {
348            self.up.clone()
349        } else {
350            self.keys.get(dropped.into).cloned()
351        };
352        TreeDrop { keys: self.keys_of(&dropped.rows), into }
353    }
354}
355
356/// The one row under a flat view's rows: the spinner of a read that takes long enough to notice,
357/// and what the folder holds the rest of the time.
358struct Reading {
359    busy: bool,
360    label: String,
361}
362
363/// The delayed indicator of the read, kept in the row's memory.
364#[derive(Debug, Default)]
365struct Mark(DelayedIndicator);
366
367impl<Msg: 'static> Widget<Msg> for Reading {
368    fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
369        Size::new(available.width, 1)
370    }
371
372    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
373        if area.is_empty() {
374            return;
375        }
376        let now = cx.now();
377        let mut mark = cx.memory::<Mark>().0;
378        let shown = mark.update(self.busy, now);
379        if let Some(change) = mark.next_change(self.busy, now) {
380            cx.request_frame_in(change);
381        }
382        cx.memory::<Mark>().0 = mark;
383        let faint = cx.style("list-header", None, &[]).text();
384        if !shown {
385            cx.text(area.x, area.y, &self.label, faint, area.width);
386            return;
387        }
388        let style = cx.style("spinner", None, &[]).text();
389        let cell = cx.animation(SpinnerStyle::Dots.animation(), style, Some(Duration::ZERO));
390        let glyph = text::truncate(&cell.glyph, 1).into_owned();
391        cx.text(area.x, area.y, &glyph, cell.style, 1);
392        let word = crate::t!("quvyta.file-manager.reading");
393        cx.text(area.x + 2, area.y, &word, CellStyle { bg: None, ..faint }, area.width.saturating_sub(2));
394    }
395}