Skip to main content

qframe/widgets/file_manager/
mod.rs

1//! A file manager: a folder shown as a tree, with the operations a person expects on it.
2
3mod details;
4mod flat;
5mod keys;
6mod kinds;
7mod mark;
8mod ops;
9mod state;
10mod trash;
11mod watch;
12
13#[cfg(test)]
14mod kinds_tests;
15#[cfg(test)]
16mod tests;
17
18use std::path::Path;
19use std::rc::Rc;
20
21use crate::icons::UserFolders;
22use crate::widget::{Length, NodeMut, View};
23
24use super::{Button, Click, ContextItem, Field, Form, FormErrors, Modal, ProgressBar, Text, TextInput, Tree, TreeNode};
25
26pub use details::FileDetails;
27pub use flat::FileView;
28pub use mark::RowMark;
29pub use ops::copy_into;
30use ops::stem;
31pub use ops::{FileChange, FileError, NameProblem, is_inside, is_within, name_of, parent_key};
32use state::ROOT;
33pub use state::{FileManagerMsg, FileManagerState, FileWork, FolderEntry, NameFor, Naming, child_key};
34
35/// Turns a manager's messages into the application's own, on the drawing side.
36type Wrap<Msg> = Rc<dyn Fn(FileManagerMsg) -> Msg>;
37
38/// What the application adds to the menu of the row `key`, which acts on `targets`.
39type Menu<Msg> = Rc<dyn Fn(&str, &[String]) -> Vec<ContextItem<Msg>>>;
40
41/// What a row's path becomes for the application.
42type OnPath<Msg> = Rc<dyn Fn(&Path) -> Msg>;
43
44/// What the application says about the look of the row `key`.
45type Marks = Rc<dyn Fn(&str) -> RowMark>;
46
47/// The name the field of the naming dialog is focused by.
48const NAME_ID: &str = "file-manager-name";
49
50/// The name the rows carry when the application gives them none, so they stay one widget, and
51/// keep the keyboard, when the view changes.
52const ROWS_ID: &str = "file-manager-rows";
53
54/// Width of the naming dialog, in cells: room for a long file name without covering the screen.
55const NAMING_WIDTH: u16 = 48;
56
57/// A folder as a tree, with every file operation on it.
58///
59/// The manager is a **file view**, not an application: it reads the folder, draws it, does the
60/// file operations and says what happened. What opening a file means is always the application's:
61/// [`on_open`](Self::on_open) says a path was asked to be opened and nothing more.
62///
63/// The application owns a [`FileManagerState`], hands it every [`FileManagerMsg`] and draws it
64/// here. Folders are read on a background thread, never while drawing; a read that takes longer
65/// than about 300 ms shows a small spinner on the folder's own row, which then stays about 500 ms,
66/// so quick reads never flash one.
67///
68/// What it does: opening and closing folders, one and several selections, the keyboard's own way
69/// through the rows, dragging entries onto a folder to move them, cut and paste, a new file or
70/// folder, renaming with the name checked as it is typed, and deleting behind a question. Each
71/// operation says what it changed or why it was refused, entry by entry when there were several.
72///
73/// The mouse works as it does in a desktop file explorer, in all three views. A click only
74/// selects; a double click or Enter opens: a file through [`on_open`](Self::on_open), a folder by
75/// stepping into it in the list and the icons and by opening or closing it in the tree, where
76/// its chevron and ← and → still do that with one click. Ctrl+click adds an entry to the
77/// selection or takes it out, and Shift+click selects the entries from the last one clicked. A
78/// drag from the free space draws a box, a tone over the cells it covers, and selects the
79/// entries inside it, adding to the selection when Ctrl was held. A drag from a selected entry
80/// carries the whole selection: released on a folder it moves there, or is copied there when
81/// Ctrl is held at the release, and released anywhere else it does nothing. The folder under the
82/// drag takes the accent tone while it can take what is dragged; a folder never takes itself or a
83/// folder inside it. In the list and the icons the row of the shown folder is the way up, so a
84/// drop on it goes into the folder above, as a desktop explorer's path takes a drop for a parent;
85/// at the root it takes nothing. A terminal that does
86/// not report Ctrl with the pointer always moves. A name already taken in the folder is never
87/// overwritten; the entry says why it stayed. A right click on a selected entry opens the menu of
88/// the selection, and on any other entry selects it and opens its menu.
89/// [`open_on(Click::Single)`](Self::open_on) opens with one click instead.
90///
91/// What it draws: the root as the top row, so the folder itself has a place for its menu; folders
92/// then files, each in name order; an entry whose name the platform does not spell as text shown
93/// lossily rather than left out; what was cut faint until it is pasted or let go.
94///
95/// See [`FileManagerState`] for a whole application, and
96/// [`FileManagerState::confined`](FileManagerState::confined) for keeping operations inside the
97/// root.
98///
99/// Keys: the tree's own (↑/↓ between rows, ←/→ and Enter to open and close a folder, Enter on a
100/// file to open it, Home and End, the menu key on the row the cursor is on), and a desktop file
101/// explorer's selection keys in all three views: Shift with the arrows, PgUp/PgDn, Home or End
102/// extends the selection from where it started, Space adds or takes out the entry under the
103/// cursor, Ctrl+A selects every entry shown and Esc leaves only the entry under the cursor
104/// selected. Then Ctrl+X, Ctrl+C and Ctrl+V. Ctrl+X cuts the selection and
105/// Ctrl+C copies it, the entry under the cursor when nothing is selected; Ctrl+V pastes what waits
106/// into the folder the list and the icons show, and in the tree into the folder under the cursor,
107/// or the folder holding the file under it. A name already taken there is refused and said, as a
108/// paste from the menu is. The keys are the manager's only while its rows have focus and no text
109/// is selected with the mouse: a text field keeps copying and pasting text, and selected text is
110/// what Ctrl+C copies, see [`NodeMut::on_clipboard`](crate::widget::NodeMut::on_clipboard). Copy
111/// and paste follow the keymap's `copy` and `paste`.
112///
113/// What it can add: each row's icon by the kind of the entry, see [`kind_icons`](Self::kind_icons),
114/// and those icons in the colours of their families, see [`kind_tones`](Self::kind_tones).
115///
116/// Style keys: the tree's (`list-item`, `tree-chevron`, `tree-drop`, `list-detail`, `spinner`),
117/// the context menu's and the dialog's. Texts: `quvyta.file-manager.*`.
118pub struct FileManager<'a, Msg> {
119    state: &'a FileManagerState,
120    wrap: Wrap<Msg>,
121    root_label: Option<String>,
122    on_open: Option<OnPath<Msg>>,
123    on_open_terminal: Option<OnPath<Msg>>,
124    menu: Option<Menu<Msg>>,
125    marks: Option<Marks>,
126    view: FileView,
127    open_on: Click,
128    disabled: bool,
129    kind_icons: bool,
130    kind_tones: bool,
131    user_folders: Option<&'a UserFolders>,
132    rows_id: Option<String>,
133    /// How kinds are drawn on this screen, worked out when it is shown.
134    kinds: kinds::KindLook<'a>,
135}
136
137impl<'a, Msg: Clone + 'static> FileManager<'a, Msg> {
138    /// A manager showing `state`; `wrap` turns the manager's messages into the application's.
139    ///
140    /// `wrap` is a function such as `Msg::Files`, or a closure that captures what it needs, such
141    /// as a screen's own conversion: `move |message| convert(screen::Msg::Files(message))`.
142    #[must_use]
143    pub fn new(state: &'a FileManagerState, wrap: impl Fn(FileManagerMsg) -> Msg + 'static) -> Self {
144        Self {
145            state,
146            wrap: Rc::new(wrap),
147            root_label: None,
148            on_open: None,
149            on_open_terminal: None,
150            menu: None,
151            marks: None,
152            view: FileView::Tree,
153            open_on: Click::Double,
154            disabled: false,
155            kind_icons: false,
156            kind_tones: false,
157            user_folders: None,
158            rows_id: None,
159            kinds: kinds::KindLook::default(),
160        }
161    }
162
163    /// What the top row says. The name of the root folder by default; an application with a name
164    /// of its own for it, such as a project's, gives that instead.
165    #[must_use]
166    pub fn root_label(mut self, label: impl Into<String>) -> Self {
167        self.root_label = Some(label.into());
168        self
169    }
170
171    /// A file was asked to be opened: a double click or Enter on its row, or a click with
172    /// [`open_on(Click::Single)`](Self::open_on).
173    ///
174    /// The manager has no viewer, tab or window of its own; one application opens the path in a
175    /// tab, another in a window, and a dialog returns it as the answer. Without this a double
176    /// click on a file only selects it.
177    #[must_use]
178    pub fn on_open(mut self, message: impl Fn(&Path) -> Msg + 'static) -> Self {
179        self.on_open = Some(Rc::new(message));
180        self
181    }
182
183    /// How many clicks open an entry: [`Click::Double`], the default, the way a desktop file
184    /// explorer opens, so a click is free to select or to start a drag; or [`Click::Single`], a
185    /// click that selects and opens at once, for a picker whose rows are only ever opened.
186    ///
187    /// A double click is two presses on the same entry within [`Click::INTERVAL`]. Enter opens
188    /// either way, and a folder's chevron in the tree opens and closes it with one click.
189    #[must_use]
190    pub fn open_on(mut self, click: Click) -> Self {
191        self.open_on = click;
192        self
193    }
194
195    /// Offers "Open a terminal here" on a folder's menu, with the folder's path.
196    ///
197    /// The wording is the framework's, so every application says it the same way; what a terminal
198    /// is stays the application's own.
199    #[must_use]
200    pub fn on_open_terminal(mut self, message: impl Fn(&Path) -> Msg + 'static) -> Self {
201        self.on_open_terminal = Some(Rc::new(message));
202        self
203    }
204
205    /// The application's own items on a row's menu, in a group of their own between the manager's
206    /// editing items and its last, destructive one.
207    ///
208    /// The row's key comes first and what an action there acts on second: the whole selection when
209    /// the row is one of several selected, the row alone otherwise, as
210    /// [`FileManagerState::targets`] works it out.
211    #[must_use]
212    pub fn menu_items(mut self, items: impl Fn(&str, &[String]) -> Vec<ContextItem<Msg>> + 'static) -> Self {
213        self.menu = Some(Rc::new(items));
214        self
215    }
216
217    /// What the application says about the look of a row, by key: a sign in a tone, a faint row,
218    /// or both. See [`RowMark`].
219    ///
220    /// The manager knows names and folders; what an entry means to the application it cannot know.
221    /// qcode marks an entry its backup leaves out with a warning sign and draws the row faint; a
222    /// version control panel marks what is ignored. Return [`RowMark::new()`] for a row with
223    /// nothing to say, which is every row by default.
224    ///
225    /// A mark cannot make a row louder than the manager's own states: a cut entry and a disabled
226    /// manager stay faint whatever the mark says, because they are about what can be done rather
227    /// than about what the entry is.
228    #[must_use]
229    pub fn row_mark(mut self, mark: impl Fn(&str) -> RowMark + 'static) -> Self {
230        self.marks = Some(Rc::new(mark));
231        self
232    }
233
234    /// The shape the folder is drawn in: the tree it is without being asked, a list of rows with
235    /// their size, date and permissions, or a grid of icons.
236    ///
237    /// The tree shows folders inside folders, opened where they stand. The other two show one
238    /// folder at a time: its own row comes first, so the folder has a place for its menu and a way
239    /// back out of it, and stepping into a folder shows that folder instead. Which folder is shown
240    /// is [`FileManagerState::folder`], and the keys, the menus and every operation are the same
241    /// in all three.
242    ///
243    /// The list reads the size, the date and the permissions of a page of entries around the
244    /// cursor, never of a whole folder; the tree and the icons read none.
245    #[must_use]
246    pub fn view(mut self, view: FileView) -> Self {
247        self.view = view;
248        self
249    }
250
251    /// Draws each row's icon by the kind of its entry: the Rust logo on a Rust file, a zipper on
252    /// an archive, a folder with a branch on `.git`, the downloads folder in the home. Off, every
253    /// row is a plain `folder` or `file`.
254    ///
255    /// A person knows what a file is from its icon before reading its name. The kind comes from
256    /// the name alone, see [`file_kind`](crate::icons::file_kind), so no file is opened to draw
257    /// it; whether a file whose name says nothing may be run is the one thing read, with the
258    /// folder. Outside a Nerd Font each icon is its family's shape, so code, pictures and archives
259    /// are still told apart.
260    ///
261    /// The icons have no colour of their own, as the plain ones have none: they are drawn in the
262    /// row's quiet colour and take the selected row's colour with the rest of it. A sign an
263    /// application gives with [`row_mark`](Self::row_mark) says something the kind cannot, so it
264    /// wins over the kind. Colours by kind are a further layer, [`kind_tones`](Self::kind_tones).
265    ///
266    /// The folders of the home are found by the names the person's language gives them, read from
267    /// `user-dirs.dirs` once the home is on screen; [`user_folders`](Self::user_folders) gives them
268    /// instead.
269    #[must_use]
270    pub fn kind_icons(mut self, on: bool) -> Self {
271        self.kind_icons = on;
272        self
273    }
274
275    /// Colours the icons of [`kind_icons`](Self::kind_icons) by their family: folders take the
276    /// accent and the files the theme's series tones, see
277    /// [`KindFamily::tone`](crate::icons::KindFamily::tone). A file whose kind is not known keeps
278    /// the row's colour.
279    ///
280    /// The colour only repeats what the shape says, so it adds nothing where tones cannot be told
281    /// apart: in sixteen colours and in ASCII it is not drawn. It does nothing without
282    /// [`kind_icons`](Self::kind_icons).
283    #[must_use]
284    pub fn kind_tones(mut self, on: bool) -> Self {
285        self.kind_tones = on;
286        self
287    }
288
289    /// The home and its folders [`kind_icons`](Self::kind_icons) recognises, in place of the
290    /// person's own, [`UserFolders::current`].
291    ///
292    /// For a manager showing another person's home, or a test that means a home of its own.
293    #[must_use]
294    pub fn user_folders(mut self, folders: &'a UserFolders) -> Self {
295        self.user_folders = Some(folders);
296        self
297    }
298
299    /// Names the rows, so [`Command::focus(name)`](crate::runtime::Command::focus) gives them
300    /// the keyboard: an application that takes the person to another folder, from a list of
301    /// places, a path bar or a back button, sends it so ↑ and ↓ move through the new folder at
302    /// once.
303    ///
304    /// The name is on the rows themselves, the tree, the list or the icons, whichever is drawn,
305    /// not on the column [`show`](Self::show) answers with, which holds the foot too and takes no
306    /// focus. The rows are the same widget in all three views, so rows that have the keyboard
307    /// keep it when the view changes, named or not.
308    #[must_use]
309    pub fn id(mut self, name: impl Into<String>) -> Self {
310        self.rows_id = Some(name.into());
311        self
312    }
313
314    /// Draws the rows faint and answers nothing: no click, key, drag or menu, while the
315    /// application has taken the folder away from the person.
316    #[must_use]
317    pub fn disabled(mut self, disabled: bool) -> Self {
318        self.disabled = disabled;
319        self
320    }
321
322    /// Adds the manager to `ui` and answers with the column that holds it, to be given a size.
323    ///
324    /// The column holds the rows and, in the list and the icons, the foot under them. It takes no
325    /// focus itself; [`id`](Self::id) names the rows inside it, which do.
326    ///
327    /// The dialog that asks for a name is added too while one is asked for; it is a layer and
328    /// takes no room of its own.
329    pub fn show<'v>(mut self, ui: &'v mut View<'_, Msg>) -> NodeMut<'v, Msg> {
330        let state = self.state;
331        self.kinds = self.kind_look(ui.env());
332        if let Some(problem) = state.error() {
333            ui.add(Text::new(crate::t!("quvyta.file-manager.unreadable")).role("secondary"));
334            return ui.add(Text::new(problem.to_owned()).role("faint")).selectable(true);
335        }
336        self.naming_dialog(ui);
337        self.work_row(ui);
338        // The list shows details, so it asks for the page around the cursor it has none of yet;
339        // the tree and the icons show names alone and ask for nothing, which is what keeps a
340        // folder of ten thousand entries from becoming ten thousand calls to the system.
341        if self.view == FileView::List {
342            let gaps = state.detail_gaps(state.folder());
343            if !gaps.is_empty() {
344                let wrap = Rc::clone(&self.wrap);
345                ui.on_idle(std::time::Duration::ZERO, move |_| wrap(FileManagerMsg::Detail(gaps.clone())));
346            }
347        }
348        // Every view is the same column with the rows first under the same name, so the rows are
349        // one widget whatever shape they take: focus on them outlives a change of view.
350        let name = self.rows_id.clone().unwrap_or_else(|| ROWS_ID.to_owned());
351        let node = ui.column(|ui| match self.view {
352            FileView::Tree => {
353                let tree = self.tree();
354                ui.add(tree).fill().id(name);
355            }
356            FileView::List | FileView::Icons => {
357                let rows = self.flat_rows();
358                if self.view == FileView::Icons {
359                    let grid = self.grid(&rows);
360                    ui.add(grid).fill().id(name);
361                } else {
362                    let table = self.table(&rows);
363                    ui.add(table).fill().id(name);
364                }
365                self.foot(ui, &rows);
366            }
367        });
368        self.claim_clipboard(node)
369    }
370
371    /// The row above the rows while a long operation runs: what it is doing, how far it has come
372    /// and a way to say stop.
373    ///
374    /// It sits above the tree rather than over it: the rows stay readable while a copy goes on, and
375    /// the row goes away by itself when the work ends. It takes no room at all while nothing runs.
376    fn work_row(&self, ui: &mut View<'_, Msg>) {
377        let Some(work) = self.state.work() else { return };
378        let label = crate::t!("quvyta.file-manager.copying", n = work.entries());
379        let stop = (self.wrap)(FileManagerMsg::Stop);
380        let note = work.note().to_owned();
381        let done = work.done();
382        ui.row(|ui| {
383            ui.add(Text::new(label).role("secondary").no_wrap());
384            ui.add(ProgressBar::new(done).percent(true)).width(Length::Fill(1));
385            if !note.is_empty() {
386                ui.add(Text::new(note).role("faint").no_wrap());
387            }
388            ui.add(Button::new(crate::t!("quvyta.file-manager.stop")).on_press(stop));
389        })
390        .fill_width();
391    }
392
393    /// The tree of the whole manager, with the root as its one top row.
394    fn tree(&self) -> Tree<Msg> {
395        let state = self.state;
396        let tree = Tree::new([self.root_node()]);
397        if self.disabled {
398            return tree;
399        }
400        let wrap = Rc::clone(&self.wrap);
401        let expand = Rc::clone(&self.wrap);
402        let choose = Rc::clone(&self.wrap);
403        let drop = Rc::clone(&self.wrap);
404        let copy = Rc::clone(&self.wrap);
405        let accepts = state.folder_keys();
406        let tree = tree
407            .selected(state.selected())
408            .on_select(move |key| wrap(FileManagerMsg::Select(key.to_owned())))
409            // The root is where the person is, not an entry to carry away, so a box or Ctrl+A that
410            // covers its row leaves it out.
411            .multi_select(state.chosen(), move |keys| {
412                choose(FileManagerMsg::Choose(keys.into_iter().filter(|key| key != ROOT).collect()))
413            })
414            .droppable(
415                move |dropped| drop(FileManagerMsg::Drop(dropped)),
416                move |key| key == ROOT || accepts.contains(key),
417            )
418            .on_copy_drop(move |dropped| copy(FileManagerMsg::DropCopy(dropped)))
419            .activate_on(self.open_on)
420            .box_select(true)
421            .on_expand(move |key, open| expand(FileManagerMsg::Expand(key.to_owned(), open)))
422            .context_menu(self.menu_for());
423        // Enter or a double click on a file opens it; on a folder they open the folder, which the
424        // tree does itself. Space and the modified clicks select instead.
425        match &self.on_open {
426            Some(open) => {
427                let (open, root) = (Rc::clone(open), state.root().to_path_buf());
428                tree.on_activate(move |key| open(&path_of(&root, key)))
429            }
430            None => tree,
431        }
432    }
433
434    /// The root folder itself, as the one row at the top.
435    ///
436    /// It is a row rather than nothing so the folder has a place of its own: its menu makes entries
437    /// and pastes at the top, reached by a right click or by selecting it and pressing the menu key
438    /// like any other row. A tree only as tall as its rows has no empty part below them to click,
439    /// so the row is the one way the mouse and the keyboard reach the folder alike.
440    fn root_node(&self) -> TreeNode {
441        let state = self.state;
442        let label = self.root_label.clone().unwrap_or_else(|| root_name(state.root()));
443        let mark = self.mark_of(ROOT);
444        let (icon, tone) = self.sign_of(&mark, ROOT, &root_name(state.root()), true, false);
445        let mut root = TreeNode::new(ROOT, label).icon(icon, tone.as_deref()).faint(self.disabled || mark.is_faint());
446        // An unread folder is not an empty one, so it never says "empty" before it is known.
447        if state.shown_children(ROOT).is_some_and(|entries| entries.is_empty()) {
448            root = root.detail(crate::t!("quvyta.file-manager.empty"));
449        }
450        root.expandable(true).expanded(state.is_open(ROOT)).loading(state.is_loading(ROOT)).children(self.nodes(ROOT))
451    }
452
453    /// The rows below the folder `key`, as far as it has been read.
454    fn nodes(&self, key: &str) -> Vec<TreeNode> {
455        let state = self.state;
456        let Some(entries) = state.shown_children(key) else { return Vec::new() };
457        entries
458            .into_iter()
459            .map(|entry| {
460                let child = child_key(key, &entry.name);
461                let mark = self.mark_of(&child);
462                let (icon, tone) = self.sign_of(&mark, &child, &entry.name, entry.folder, entry.executable);
463                // What was cut is drawn faint until it is pasted or let go, with everything in it.
464                let faint = self.disabled || state.is_cut(&child) || mark.is_faint();
465                let mut node =
466                    TreeNode::new(child.clone(), entry.name.clone()).icon(icon, tone.as_deref()).faint(faint);
467                if entry.folder {
468                    let open = state.is_open(&child);
469                    node = node.expandable(true).expanded(open).loading(state.is_loading(&child));
470                    // A folder the system refused says so on its own row. Without this it opened
471                    // to nothing, which reads as an empty folder: the person would be told a
472                    // folder they may not look into holds nothing.
473                    if state.folder_error(&child).is_some() {
474                        node = node.detail(crate::t!("quvyta.file-manager.unreadable-short"));
475                    }
476                    if open {
477                        node = node.children(self.nodes(&child));
478                    }
479                }
480                node
481            })
482            .collect()
483    }
484
485    /// What the application says about the row `key`, nothing when it says nothing.
486    fn mark_of(&self, key: &str) -> RowMark {
487        self.marks.as_ref().map(|mark| mark(key)).unwrap_or_default()
488    }
489
490    /// The icon and the colour the row `key` is drawn with: the mark's sign when it has one, and
491    /// the manager's own icon for the entry called `name` otherwise, in the row's own colour
492    /// unless kinds are coloured.
493    fn sign_of(
494        &self,
495        mark: &RowMark,
496        key: &str,
497        name: &str,
498        folder: bool,
499        executable: bool,
500    ) -> (String, Option<String>) {
501        match mark.icon() {
502            Some(icon) => (icon.to_owned(), mark.tone().map(str::to_owned)),
503            None => self.own_icon(key, name, folder, executable),
504        }
505    }
506
507    /// What every row's menu holds.
508    fn menu_for(&self) -> impl Fn(&str) -> Vec<ContextItem<Msg>> + 'static {
509        let state = self.state;
510        // The menu is built long after the view, so it takes what it needs along rather than the
511        // state itself.
512        let wrap = Rc::clone(&self.wrap);
513        let chosen = state.chosen().to_vec();
514        let pending = Pending { keys: state.pending().to_vec(), copying: state.is_copying() };
515        let trashing = state.is_trashing();
516        let folders = state.folder_keys();
517        let extra = self.menu.clone();
518        let terminal = self.on_open_terminal.clone();
519        let root = state.root().to_path_buf();
520        move |key: &str| {
521            // The tree keeps the selection when the click is on one of its rows and makes the row
522            // the selection otherwise, so the menu acts on what the click was on.
523            let targets = state::targets_of(&chosen, key);
524            let mut own = extra.as_ref().map(|items| items(key, &targets)).unwrap_or_default();
525            if let Some(message) = &terminal
526                && (key == ROOT || folders.contains(key))
527            {
528                let label = crate::t!("quvyta.file-manager.open-terminal");
529                own.push(ContextItem::new(label, message(&path_of(&root, key))));
530            }
531            let send = |message: FileManagerMsg| wrap(message);
532            if targets.len() > 1 {
533                return many_menu(key, targets.len(), &pending, trashing, own, &send);
534            }
535            if key == ROOT || folders.contains(key) {
536                return folder_menu(key, &pending, trashing, own, &send);
537            }
538            file_menu(key, &pending, trashing, own, &send)
539        }
540    }
541
542    /// The dialog that asks for a name, while one is asked for. It waits for an answer rather than
543    /// sitting beside the tree: the name is all there is to do until it is given or dropped.
544    fn naming_dialog(&self, ui: &mut View<'_, Msg>) {
545        let state = self.state;
546        let Some(naming) = state.naming() else { return };
547        let (title, confirm) = match &naming.purpose {
548            NameFor::File => (crate::t!("quvyta.file-manager.new-file-title"), crate::t!("quvyta.file-manager.create")),
549            NameFor::Folder => {
550                (crate::t!("quvyta.file-manager.new-folder-title"), crate::t!("quvyta.file-manager.create"))
551            }
552            NameFor::Rename(key) => (
553                crate::t!("quvyta.file-manager.rename-title", name = name_of(key)),
554                crate::t!("quvyta.file-manager.rename-do"),
555            ),
556        };
557        let close = (self.wrap)(FileManagerMsg::CloseNaming);
558        let submit = (self.wrap)(FileManagerMsg::Submit);
559        let dialog = Modal::new()
560            .title(title)
561            .width(NAMING_WIDTH)
562            .on_close(close.clone())
563            .action(Button::new(crate::t!("quvyta.file-manager.cancel")).on_press(close))
564            .action(Button::new(confirm).variant("primary").on_press(submit.clone()));
565        let mut errors = FormErrors::new();
566        if let Some(problem) = state.naming_problem() {
567            errors.set(NAME_ID, problem.message());
568        }
569        let value = naming.value.clone();
570        // A rename selects the name without its extension, so typing gives a new name and keeps
571        // the kind of file; a new entry starts empty and has nothing to select.
572        let selection = match &naming.purpose {
573            NameFor::Rename(key) => Some(stem(&value, state.is_folder(key))),
574            NameFor::File | NameFor::Folder => None,
575        };
576        let typed = Rc::clone(&self.wrap);
577        ui.add_with(dialog, |ui| {
578            Form::new().show(ui, |fields| {
579                let label = crate::t!("quvyta.file-manager.name-label");
580                fields.field(Field::new(label).error(errors.get(NAME_ID)), |ui| {
581                    let mut input = TextInput::new(value)
582                        .invalid(errors.has(NAME_ID))
583                        .on_change(move |value| typed(FileManagerMsg::Name(value)))
584                        .on_submit(move |_| submit.clone());
585                    if let Some(range) = selection {
586                        input = input.select_on_focus(range);
587                    }
588                    ui.add(input).id(NAME_ID).fill_width();
589                });
590            });
591        });
592    }
593}
594
595/// The path of the entry `key` under `root`.
596fn path_of(root: &Path, key: &str) -> std::path::PathBuf {
597    key.split('/').filter(|part| !part.is_empty()).fold(root.to_path_buf(), |path, part| path.join(part))
598}
599
600/// What the top row says about the folder at `root`: its own name, or the whole path when it has
601/// none, as the file system's own root has none.
602fn root_name(root: &Path) -> String {
603    root.file_name().map_or_else(|| root.display().to_string(), |name| name.to_string_lossy().into_owned())
604}
605
606/// Puts `own`, when there is any, into a menu as a group of its own.
607fn add_own<Msg>(items: &mut Vec<ContextItem<Msg>>, own: Vec<ContextItem<Msg>>) {
608    if !own.is_empty() {
609        items.push(ContextItem::gap());
610        items.extend(own);
611    }
612}
613
614/// What waits to be pasted, and whether pasting it will copy it.
615struct Pending {
616    keys: Vec<String>,
617    copying: bool,
618}
619
620impl Pending {
621    /// Whether anything waits to be pasted.
622    fn is_empty(&self) -> bool {
623        self.keys.is_empty()
624    }
625
626    /// What letting go of it is called: a move is cancelled, a copy is cancelled.
627    fn drop_label(&self) -> String {
628        let key = if self.copying { "quvyta.file-manager.drop-copy" } else { "quvyta.file-manager.drop-cut" };
629        crate::t!(key)
630    }
631}
632
633/// The items for what waits to be pasted: pasting it here, and letting it go. A folder cannot take
634/// itself or a folder that holds it, so pasting there is shown but cannot be chosen.
635fn paste_items<Msg: Clone + 'static>(
636    items: &mut Vec<ContextItem<Msg>>,
637    key: &str,
638    pending: &Pending,
639    send: &impl Fn(FileManagerMsg) -> Msg,
640) {
641    if pending.is_empty() {
642        return;
643    }
644    let paste = ContextItem::new(crate::t!("quvyta.file-manager.paste"), send(FileManagerMsg::Paste(key.to_owned())));
645    items.push(paste.disabled(pending.keys.iter().any(|waiting| is_within(key, waiting))));
646    items.push(ContextItem::new(pending.drop_label(), send(FileManagerMsg::DropCut)));
647}
648
649/// The last item of a row's menu: the trash when the manager has one, and deleting for good
650/// otherwise. Both are the destructive item, so both stand alone at the end in the danger colour.
651fn away_item<Msg: Clone + 'static>(
652    key: &str,
653    count: usize,
654    trashing: bool,
655    send: &impl Fn(FileManagerMsg) -> Msg,
656) -> ContextItem<Msg> {
657    let many = count > 1;
658    let label = match (trashing, many) {
659        (true, false) => crate::t!("quvyta.file-manager.trash"),
660        (true, true) => crate::t!("quvyta.file-manager.trash-many", n = count),
661        (false, false) => crate::t!("quvyta.file-manager.delete"),
662        (false, true) => crate::t!("quvyta.file-manager.delete-many", n = count),
663    };
664    let message = if trashing {
665        send(FileManagerMsg::Trash(key.to_owned()))
666    } else {
667        send(FileManagerMsg::Delete(key.to_owned()))
668    };
669    ContextItem::new(label, message).danger(true)
670}
671
672/// The menu of a folder, or of the root itself when `key` is [`ROOT`]: what can be made in it and,
673/// while something is cut, pasting it here.
674fn folder_menu<Msg: Clone + 'static>(
675    key: &str,
676    pending: &Pending,
677    trashing: bool,
678    own: Vec<ContextItem<Msg>>,
679    send: &impl Fn(FileManagerMsg) -> Msg,
680) -> Vec<ContextItem<Msg>> {
681    let mut items = vec![
682        ContextItem::new(crate::t!("quvyta.file-manager.new-file"), send(FileManagerMsg::NewFile(key.to_owned()))),
683        ContextItem::new(crate::t!("quvyta.file-manager.new-folder"), send(FileManagerMsg::NewFolder(key.to_owned()))),
684    ];
685    let root = key == ROOT;
686    if !root {
687        items.push(ContextItem::gap());
688        items.push(ContextItem::new(
689            crate::t!("quvyta.file-manager.rename"),
690            send(FileManagerMsg::Rename(key.to_owned())),
691        ));
692        items.push(ContextItem::new(crate::t!("quvyta.file-manager.cut"), send(FileManagerMsg::Cut(key.to_owned()))));
693        items.push(ContextItem::new(crate::t!("quvyta.file-manager.copy"), send(FileManagerMsg::Copy(key.to_owned()))));
694    }
695    paste_items(&mut items, key, pending, send);
696    add_own(&mut items, own);
697    items.push(ContextItem::gap());
698    if root {
699        items.push(ContextItem::new(crate::t!("quvyta.file-manager.refresh"), send(FileManagerMsg::Refresh)));
700    } else {
701        items.push(away_item(key, 1, trashing, send));
702    }
703    items
704}
705
706/// The menu of a file.
707fn file_menu<Msg: Clone + 'static>(
708    key: &str,
709    pending: &Pending,
710    trashing: bool,
711    own: Vec<ContextItem<Msg>>,
712    send: &impl Fn(FileManagerMsg) -> Msg,
713) -> Vec<ContextItem<Msg>> {
714    let mut items = vec![
715        ContextItem::new(crate::t!("quvyta.file-manager.rename"), send(FileManagerMsg::Rename(key.to_owned()))),
716        ContextItem::new(crate::t!("quvyta.file-manager.cut"), send(FileManagerMsg::Cut(key.to_owned()))),
717        ContextItem::new(crate::t!("quvyta.file-manager.copy"), send(FileManagerMsg::Copy(key.to_owned()))),
718    ];
719    if !pending.is_empty() {
720        items.push(ContextItem::new(pending.drop_label(), send(FileManagerMsg::DropCut)));
721    }
722    add_own(&mut items, own);
723    items.push(ContextItem::gap());
724    items.push(away_item(key, 1, trashing, send));
725    items
726}
727
728/// The menu of a row that is one of `count` selected entries: what can be done to all of them at
729/// once. A name is given to one entry at a time, so renaming is not offered.
730fn many_menu<Msg: Clone + 'static>(
731    key: &str,
732    count: usize,
733    pending: &Pending,
734    trashing: bool,
735    own: Vec<ContextItem<Msg>>,
736    send: &impl Fn(FileManagerMsg) -> Msg,
737) -> Vec<ContextItem<Msg>> {
738    let mut items = vec![
739        ContextItem::new(
740            crate::t!("quvyta.file-manager.cut-many", n = count),
741            send(FileManagerMsg::Cut(key.to_owned())),
742        ),
743        ContextItem::new(
744            crate::t!("quvyta.file-manager.copy-many", n = count),
745            send(FileManagerMsg::Copy(key.to_owned())),
746        ),
747    ];
748    if !pending.is_empty() {
749        items.push(ContextItem::new(pending.drop_label(), send(FileManagerMsg::DropCut)));
750    }
751    add_own(&mut items, own);
752    items.push(ContextItem::gap());
753    items.push(away_item(key, count, trashing, send));
754    items
755}