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