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