quvyta-framework 0.1.29

A Rust framework for building terminal applications
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
//! A file manager: a folder shown as a tree, with the operations a person expects on it.

mod details;
mod flat;
mod keys;
mod kinds;
mod mark;
mod ops;
mod state;
mod trash;
mod watch;

#[cfg(test)]
mod kinds_tests;
#[cfg(test)]
mod tests;

use std::path::Path;
use std::rc::Rc;

use crate::icons::UserFolders;
use crate::widget::{Length, NodeMut, View};

use super::{Button, Click, ContextItem, Field, Form, FormErrors, Modal, ProgressBar, Text, TextInput, Tree, TreeNode};

pub use details::FileDetails;
pub use flat::FileView;
pub use mark::RowMark;
pub use ops::copy_into;
use ops::stem;
pub use ops::{FileChange, FileError, NameProblem, is_inside, is_within, name_of, parent_key};
use state::ROOT;
pub use state::{FileManagerMsg, FileManagerState, FileWork, FolderEntry, NameFor, Naming, child_key};

/// Turns a manager's messages into the application's own, on the drawing side.
type Wrap<Msg> = Rc<dyn Fn(FileManagerMsg) -> Msg>;

/// What the application adds to the menu of the row `key`, which acts on `targets`.
type Menu<Msg> = Rc<dyn Fn(&str, &[String]) -> Vec<ContextItem<Msg>>>;

/// What a row's path becomes for the application.
type OnPath<Msg> = Rc<dyn Fn(&Path) -> Msg>;

/// What the application says about the look of the row `key`.
type Marks = Rc<dyn Fn(&str) -> RowMark>;

/// The name the field of the naming dialog is focused by.
const NAME_ID: &str = "file-manager-name";

/// The name the rows carry when the application gives them none, so they stay one widget, and
/// keep the keyboard, when the view changes.
const ROWS_ID: &str = "file-manager-rows";

/// Width of the naming dialog, in cells: room for a long file name without covering the screen.
const NAMING_WIDTH: u16 = 48;

/// A folder as a tree, with every file operation on it.
///
/// The manager is a **file view**, not an application: it reads the folder, draws it, does the
/// file operations and says what happened. What opening a file means is always the application's:
/// [`on_open`](Self::on_open) says a path was asked to be opened and nothing more.
///
/// The application owns a [`FileManagerState`], hands it every [`FileManagerMsg`] and draws it
/// here. Folders are read on a background thread, never while drawing; a read that takes longer
/// than about 300 ms shows a small spinner on the folder's own row, which then stays about 500 ms,
/// so quick reads never flash one.
///
/// What it does: opening and closing folders, one and several selections, the keyboard's own way
/// through the rows, dragging entries onto a folder to move them, cut and paste, a new file or
/// folder, renaming with the name checked as it is typed, and deleting behind a question. Each
/// operation says what it changed or why it was refused, entry by entry when there were several.
///
/// The mouse works as it does in a desktop file explorer, in all three views. A click only
/// selects; a double click or Enter opens: a file through [`on_open`](Self::on_open), a folder by
/// stepping into it in the list and the icons and by opening or closing it in the tree, where
/// its chevron and ← and → still do that with one click. Ctrl+click adds an entry to the
/// selection or takes it out, and Shift+click selects the entries from the last one clicked. A
/// drag from the free space draws a box, a tone over the cells it covers, and selects the
/// entries inside it, adding to the selection when Ctrl was held. A drag from a selected entry
/// carries the whole selection: released on a folder it moves there, or is copied there when
/// Ctrl is held at the release, and released anywhere else it does nothing. The folder under the
/// drag takes the accent tone while it can take what is dragged; a folder never takes itself or a
/// folder inside it. In the list and the icons the row of the shown folder is the way up, so a
/// drop on it goes into the folder above, as a desktop explorer's path takes a drop for a parent;
/// at the root it takes nothing. A terminal that does
/// not report Ctrl with the pointer always moves. A name already taken in the folder is never
/// overwritten; the entry says why it stayed. A right click on a selected entry opens the menu of
/// the selection, and on any other entry selects it and opens its menu.
/// [`open_on(Click::Single)`](Self::open_on) opens with one click instead.
///
/// What it draws: the root as the top row, so the folder itself has a place for its menu; folders
/// then files, each in name order; an entry whose name the platform does not spell as text shown
/// lossily rather than left out; what was cut faint until it is pasted or let go.
///
/// See [`FileManagerState`] for a whole application, and
/// [`FileManagerState::confined`](FileManagerState::confined) for keeping operations inside the
/// root.
///
/// Keys: the tree's own (↑/↓ between rows, ←/→ and Enter to open and close a folder, Enter on a
/// file to open it, Home and End, the menu key on the row the cursor is on), and a desktop file
/// explorer's selection keys in all three views: Shift with the arrows, PgUp/PgDn, Home or End
/// extends the selection from where it started, Space adds or takes out the entry under the
/// cursor, Ctrl+A selects every entry shown and Esc leaves only the entry under the cursor
/// selected. Then Ctrl+X, Ctrl+C and Ctrl+V. Ctrl+X cuts the selection and
/// Ctrl+C copies it, the entry under the cursor when nothing is selected; Ctrl+V pastes what waits
/// into the folder the list and the icons show, and in the tree into the folder under the cursor,
/// or the folder holding the file under it. A name already taken there is refused and said, as a
/// paste from the menu is. The keys are the manager's only while its rows have focus and no text
/// is selected with the mouse: a text field keeps copying and pasting text, and selected text is
/// what Ctrl+C copies, see [`NodeMut::on_clipboard`](crate::widget::NodeMut::on_clipboard). Copy
/// and paste follow the keymap's `copy` and `paste`.
///
/// What it can add: each row's icon by the kind of the entry, see [`kind_icons`](Self::kind_icons),
/// and those icons in the colours of their families, see [`kind_tones`](Self::kind_tones).
///
/// Style keys: the tree's (`list-item`, `tree-chevron`, `tree-drop`, `list-detail`, `spinner`),
/// the context menu's and the dialog's. Texts: `quvyta.file-manager.*`.
pub struct FileManager<'a, Msg> {
    state: &'a FileManagerState,
    wrap: Wrap<Msg>,
    root_label: Option<String>,
    on_open: Option<OnPath<Msg>>,
    on_open_terminal: Option<OnPath<Msg>>,
    menu: Option<Menu<Msg>>,
    marks: Option<Marks>,
    view: FileView,
    open_on: Click,
    disabled: bool,
    kind_icons: bool,
    kind_tones: bool,
    user_folders: Option<&'a UserFolders>,
    rows_id: Option<String>,
    /// How kinds are drawn on this screen, worked out when it is shown.
    kinds: kinds::KindLook<'a>,
}

impl<'a, Msg: Clone + 'static> FileManager<'a, Msg> {
    /// A manager showing `state`; `wrap` turns the manager's messages into the application's.
    ///
    /// `wrap` is a function such as `Msg::Files`, or a closure that captures what it needs, such
    /// as a screen's own conversion: `move |message| convert(screen::Msg::Files(message))`.
    #[must_use]
    pub fn new(state: &'a FileManagerState, wrap: impl Fn(FileManagerMsg) -> Msg + 'static) -> Self {
        Self {
            state,
            wrap: Rc::new(wrap),
            root_label: None,
            on_open: None,
            on_open_terminal: None,
            menu: None,
            marks: None,
            view: FileView::Tree,
            open_on: Click::Double,
            disabled: false,
            kind_icons: false,
            kind_tones: false,
            user_folders: None,
            rows_id: None,
            kinds: kinds::KindLook::default(),
        }
    }

    /// What the top row says. The name of the root folder by default; an application with a name
    /// of its own for it, such as a project's, gives that instead.
    #[must_use]
    pub fn root_label(mut self, label: impl Into<String>) -> Self {
        self.root_label = Some(label.into());
        self
    }

    /// A file was asked to be opened: a double click or Enter on its row, or a click with
    /// [`open_on(Click::Single)`](Self::open_on).
    ///
    /// The manager has no viewer, tab or window of its own; one application opens the path in a
    /// tab, another in a window, and a dialog returns it as the answer. Without this a double
    /// click on a file only selects it.
    #[must_use]
    pub fn on_open(mut self, message: impl Fn(&Path) -> Msg + 'static) -> Self {
        self.on_open = Some(Rc::new(message));
        self
    }

    /// How many clicks open an entry: [`Click::Double`], the default, the way a desktop file
    /// explorer opens, so a click is free to select or to start a drag; or [`Click::Single`], a
    /// click that selects and opens at once, for a picker whose rows are only ever opened.
    ///
    /// A double click is two presses on the same entry within [`Click::INTERVAL`]. Enter opens
    /// either way, and a folder's chevron in the tree opens and closes it with one click.
    #[must_use]
    pub fn open_on(mut self, click: Click) -> Self {
        self.open_on = click;
        self
    }

    /// Offers "Open a terminal here" on a folder's menu, with the folder's path.
    ///
    /// The wording is the framework's, so every application says it the same way; what a terminal
    /// is stays the application's own.
    #[must_use]
    pub fn on_open_terminal(mut self, message: impl Fn(&Path) -> Msg + 'static) -> Self {
        self.on_open_terminal = Some(Rc::new(message));
        self
    }

    /// The application's own items on a row's menu, in a group of their own between the manager's
    /// editing items and its last, destructive one.
    ///
    /// The row's key comes first and what an action there acts on second: the whole selection when
    /// the row is one of several selected, the row alone otherwise, as
    /// [`FileManagerState::targets`] works it out.
    #[must_use]
    pub fn menu_items(mut self, items: impl Fn(&str, &[String]) -> Vec<ContextItem<Msg>> + 'static) -> Self {
        self.menu = Some(Rc::new(items));
        self
    }

    /// What the application says about the look of a row, by key: a sign in a tone, a faint row,
    /// or both. See [`RowMark`].
    ///
    /// The manager knows names and folders; what an entry means to the application it cannot know.
    /// qcode marks an entry its backup leaves out with a warning sign and draws the row faint; a
    /// version control panel marks what is ignored. Return [`RowMark::new()`] for a row with
    /// nothing to say, which is every row by default.
    ///
    /// A mark cannot make a row louder than the manager's own states: a cut entry and a disabled
    /// manager stay faint whatever the mark says, because they are about what can be done rather
    /// than about what the entry is.
    #[must_use]
    pub fn row_mark(mut self, mark: impl Fn(&str) -> RowMark + 'static) -> Self {
        self.marks = Some(Rc::new(mark));
        self
    }

    /// The shape the folder is drawn in: the tree it is without being asked, a list of rows with
    /// their size, date and permissions, or a grid of icons.
    ///
    /// The tree shows folders inside folders, opened where they stand. The other two show one
    /// folder at a time: its own row comes first, so the folder has a place for its menu and a way
    /// back out of it, and stepping into a folder shows that folder instead. Which folder is shown
    /// is [`FileManagerState::folder`], and the keys, the menus and every operation are the same
    /// in all three.
    ///
    /// The list reads the size, the date and the permissions of a page of entries around the
    /// cursor, never of a whole folder; the tree and the icons read none.
    #[must_use]
    pub fn view(mut self, view: FileView) -> Self {
        self.view = view;
        self
    }

    /// Draws each row's icon by the kind of its entry: the Rust logo on a Rust file, a zipper on
    /// an archive, a folder with a branch on `.git`, the downloads folder in the home. Off, every
    /// row is a plain `folder` or `file`.
    ///
    /// A person knows what a file is from its icon before reading its name. The kind comes from
    /// the name alone, see [`file_kind`](crate::icons::file_kind), so no file is opened to draw
    /// it; whether a file whose name says nothing may be run is the one thing read, with the
    /// folder. Outside a Nerd Font each icon is its family's shape, so code, pictures and archives
    /// are still told apart.
    ///
    /// The icons have no colour of their own, as the plain ones have none: they are drawn in the
    /// row's quiet colour and take the selected row's colour with the rest of it. A sign an
    /// application gives with [`row_mark`](Self::row_mark) says something the kind cannot, so it
    /// wins over the kind. Colours by kind are a further layer, [`kind_tones`](Self::kind_tones).
    ///
    /// The folders of the home are found by the names the person's language gives them, read from
    /// `user-dirs.dirs` once the home is on screen; [`user_folders`](Self::user_folders) gives them
    /// instead.
    #[must_use]
    pub fn kind_icons(mut self, on: bool) -> Self {
        self.kind_icons = on;
        self
    }

    /// Colours the icons of [`kind_icons`](Self::kind_icons) by their family: folders take the
    /// accent and the files the theme's series tones, see
    /// [`KindFamily::tone`](crate::icons::KindFamily::tone). A file whose kind is not known keeps
    /// the row's colour.
    ///
    /// The colour only repeats what the shape says, so it adds nothing where tones cannot be told
    /// apart: in sixteen colours and in ASCII it is not drawn. It does nothing without
    /// [`kind_icons`](Self::kind_icons).
    #[must_use]
    pub fn kind_tones(mut self, on: bool) -> Self {
        self.kind_tones = on;
        self
    }

    /// The home and its folders [`kind_icons`](Self::kind_icons) recognises, in place of the
    /// person's own, [`UserFolders::current`].
    ///
    /// For a manager showing another person's home, or a test that means a home of its own.
    #[must_use]
    pub fn user_folders(mut self, folders: &'a UserFolders) -> Self {
        self.user_folders = Some(folders);
        self
    }

    /// Names the rows, so [`Command::focus(name)`](crate::runtime::Command::focus) gives them
    /// the keyboard: an application that takes the person to another folder, from a list of
    /// places, a path bar or a back button, sends it so ↑ and ↓ move through the new folder at
    /// once.
    ///
    /// The name is on the rows themselves, the tree, the list or the icons, whichever is drawn,
    /// not on the column [`show`](Self::show) answers with, which holds the foot too and takes no
    /// focus. The rows are the same widget in all three views, so rows that have the keyboard
    /// keep it when the view changes, named or not.
    #[must_use]
    pub fn id(mut self, name: impl Into<String>) -> Self {
        self.rows_id = Some(name.into());
        self
    }

    /// Draws the rows faint and answers nothing: no click, key, drag or menu, while the
    /// application has taken the folder away from the person.
    #[must_use]
    pub fn disabled(mut self, disabled: bool) -> Self {
        self.disabled = disabled;
        self
    }

    /// Adds the manager to `ui` and answers with the column that holds it, to be given a size.
    ///
    /// The column holds the rows and, in the list and the icons, the foot under them. It takes no
    /// focus itself; [`id`](Self::id) names the rows inside it, which do.
    ///
    /// The dialog that asks for a name is added too while one is asked for; it is a layer and
    /// takes no room of its own.
    pub fn show<'v>(mut self, ui: &'v mut View<'_, Msg>) -> NodeMut<'v, Msg> {
        let state = self.state;
        self.kinds = self.kind_look(ui.env());
        if let Some(problem) = state.error() {
            ui.add(Text::new(crate::t!("quvyta.file-manager.unreadable")).role("secondary"));
            return ui.add(Text::new(problem.to_owned()).role("faint")).selectable(true);
        }
        self.naming_dialog(ui);
        self.work_row(ui);
        // The list shows details, so it asks for the page around the cursor it has none of yet;
        // the tree and the icons show names alone and ask for nothing, which is what keeps a
        // folder of ten thousand entries from becoming ten thousand calls to the system.
        if self.view == FileView::List {
            let gaps = state.detail_gaps(state.folder());
            if !gaps.is_empty() {
                let wrap = Rc::clone(&self.wrap);
                ui.on_idle(std::time::Duration::ZERO, move |_| wrap(FileManagerMsg::Detail(gaps.clone())));
            }
        }
        // Every view is the same column with the rows first under the same name, so the rows are
        // one widget whatever shape they take: focus on them outlives a change of view.
        let name = self.rows_id.clone().unwrap_or_else(|| ROWS_ID.to_owned());
        let node = ui.column(|ui| match self.view {
            FileView::Tree => {
                let tree = self.tree();
                ui.add(tree).fill().id(name);
            }
            FileView::List | FileView::Icons => {
                let rows = self.flat_rows();
                if self.view == FileView::Icons {
                    let grid = self.grid(&rows);
                    ui.add(grid).fill().id(name);
                } else {
                    let table = self.table(&rows);
                    ui.add(table).fill().id(name);
                }
                self.foot(ui, &rows);
            }
        });
        self.claim_clipboard(node)
    }

    /// The row above the rows while a long operation runs: what it is doing, how far it has come
    /// and a way to say stop.
    ///
    /// It sits above the tree rather than over it: the rows stay readable while a copy goes on, and
    /// the row goes away by itself when the work ends. It takes no room at all while nothing runs.
    fn work_row(&self, ui: &mut View<'_, Msg>) {
        let Some(work) = self.state.work() else { return };
        let label = crate::t!("quvyta.file-manager.copying", n = work.entries());
        let stop = (self.wrap)(FileManagerMsg::Stop);
        let note = work.note().to_owned();
        let done = work.done();
        ui.row(|ui| {
            ui.add(Text::new(label).role("secondary").no_wrap());
            ui.add(ProgressBar::new(done).percent(true)).width(Length::Fill(1));
            if !note.is_empty() {
                ui.add(Text::new(note).role("faint").no_wrap());
            }
            ui.add(Button::new(crate::t!("quvyta.file-manager.stop")).on_press(stop));
        })
        .fill_width();
    }

    /// The tree of the whole manager, with the root as its one top row.
    fn tree(&self) -> Tree<Msg> {
        let state = self.state;
        let tree = Tree::new([self.root_node()]);
        if self.disabled {
            return tree;
        }
        let wrap = Rc::clone(&self.wrap);
        let expand = Rc::clone(&self.wrap);
        let choose = Rc::clone(&self.wrap);
        let drop = Rc::clone(&self.wrap);
        let copy = Rc::clone(&self.wrap);
        let accepts = state.folder_keys();
        let tree = tree
            .selected(state.selected())
            .on_select(move |key| wrap(FileManagerMsg::Select(key.to_owned())))
            // The root is where the person is, not an entry to carry away, so a box or Ctrl+A that
            // covers its row leaves it out.
            .multi_select(state.chosen(), move |keys| {
                choose(FileManagerMsg::Choose(keys.into_iter().filter(|key| key != ROOT).collect()))
            })
            .droppable(
                move |dropped| drop(FileManagerMsg::Drop(dropped)),
                move |key| key == ROOT || accepts.contains(key),
            )
            .on_copy_drop(move |dropped| copy(FileManagerMsg::DropCopy(dropped)))
            .activate_on(self.open_on)
            .box_select(true)
            .on_expand(move |key, open| expand(FileManagerMsg::Expand(key.to_owned(), open)))
            .context_menu(self.menu_for());
        // Enter or a double click on a file opens it; on a folder they open the folder, which the
        // tree does itself. Space and the modified clicks select instead.
        match &self.on_open {
            Some(open) => {
                let (open, root) = (Rc::clone(open), state.root().to_path_buf());
                tree.on_activate(move |key| open(&path_of(&root, key)))
            }
            None => tree,
        }
    }

    /// The root folder itself, as the one row at the top.
    ///
    /// It is a row rather than nothing so the folder has a place of its own: its menu makes entries
    /// and pastes at the top, reached by a right click or by selecting it and pressing the menu key
    /// like any other row. A tree only as tall as its rows has no empty part below them to click,
    /// so the row is the one way the mouse and the keyboard reach the folder alike.
    fn root_node(&self) -> TreeNode {
        let state = self.state;
        let label = self.root_label.clone().unwrap_or_else(|| root_name(state.root()));
        let mark = self.mark_of(ROOT);
        let (icon, tone) = self.sign_of(&mark, ROOT, &root_name(state.root()), true, false);
        let mut root = TreeNode::new(ROOT, label).icon(icon, tone.as_deref()).faint(self.disabled || mark.is_faint());
        // An unread folder is not an empty one, so it never says "empty" before it is known.
        if state.shown_children(ROOT).is_some_and(|entries| entries.is_empty()) {
            root = root.detail(crate::t!("quvyta.file-manager.empty"));
        }
        root.expandable(true).expanded(state.is_open(ROOT)).loading(state.is_loading(ROOT)).children(self.nodes(ROOT))
    }

    /// The rows below the folder `key`, as far as it has been read.
    fn nodes(&self, key: &str) -> Vec<TreeNode> {
        let state = self.state;
        let Some(entries) = state.shown_children(key) else { return Vec::new() };
        entries
            .into_iter()
            .map(|entry| {
                let child = child_key(key, &entry.name);
                let mark = self.mark_of(&child);
                let (icon, tone) = self.sign_of(&mark, &child, &entry.name, entry.folder, entry.executable);
                // What was cut is drawn faint until it is pasted or let go, with everything in it.
                let faint = self.disabled || state.is_cut(&child) || mark.is_faint();
                let mut node =
                    TreeNode::new(child.clone(), entry.name.clone()).icon(icon, tone.as_deref()).faint(faint);
                if entry.folder {
                    let open = state.is_open(&child);
                    node = node.expandable(true).expanded(open).loading(state.is_loading(&child));
                    // A folder the system refused says so on its own row. Without this it opened
                    // to nothing, which reads as an empty folder: the person would be told a
                    // folder they may not look into holds nothing.
                    if state.folder_error(&child).is_some() {
                        node = node.detail(crate::t!("quvyta.file-manager.unreadable-short"));
                    }
                    if open {
                        node = node.children(self.nodes(&child));
                    }
                }
                node
            })
            .collect()
    }

    /// What the application says about the row `key`, nothing when it says nothing.
    fn mark_of(&self, key: &str) -> RowMark {
        self.marks.as_ref().map(|mark| mark(key)).unwrap_or_default()
    }

    /// The icon and the colour the row `key` is drawn with: the mark's sign when it has one, and
    /// the manager's own icon for the entry called `name` otherwise, in the row's own colour
    /// unless kinds are coloured.
    fn sign_of(
        &self,
        mark: &RowMark,
        key: &str,
        name: &str,
        folder: bool,
        executable: bool,
    ) -> (String, Option<String>) {
        match mark.icon() {
            Some(icon) => (icon.to_owned(), mark.tone().map(str::to_owned)),
            None => self.own_icon(key, name, folder, executable),
        }
    }

    /// What every row's menu holds.
    fn menu_for(&self) -> impl Fn(&str) -> Vec<ContextItem<Msg>> + 'static {
        let state = self.state;
        // The menu is built long after the view, so it takes what it needs along rather than the
        // state itself.
        let wrap = Rc::clone(&self.wrap);
        let chosen = state.chosen().to_vec();
        let pending = Pending { keys: state.pending().to_vec(), copying: state.is_copying() };
        let trashing = state.is_trashing();
        let folders = state.folder_keys();
        let extra = self.menu.clone();
        let terminal = self.on_open_terminal.clone();
        let root = state.root().to_path_buf();
        move |key: &str| {
            // The tree keeps the selection when the click is on one of its rows and makes the row
            // the selection otherwise, so the menu acts on what the click was on.
            let targets = state::targets_of(&chosen, key);
            let mut own = extra.as_ref().map(|items| items(key, &targets)).unwrap_or_default();
            if let Some(message) = &terminal
                && (key == ROOT || folders.contains(key))
            {
                let label = crate::t!("quvyta.file-manager.open-terminal");
                own.push(ContextItem::new(label, message(&path_of(&root, key))));
            }
            let send = |message: FileManagerMsg| wrap(message);
            if targets.len() > 1 {
                return many_menu(key, targets.len(), &pending, trashing, own, &send);
            }
            if key == ROOT || folders.contains(key) {
                return folder_menu(key, &pending, trashing, own, &send);
            }
            file_menu(key, &pending, trashing, own, &send)
        }
    }

    /// The dialog that asks for a name, while one is asked for. It waits for an answer rather than
    /// sitting beside the tree: the name is all there is to do until it is given or dropped.
    fn naming_dialog(&self, ui: &mut View<'_, Msg>) {
        let state = self.state;
        let Some(naming) = state.naming() else { return };
        let (title, confirm) = match &naming.purpose {
            NameFor::File => (crate::t!("quvyta.file-manager.new-file-title"), crate::t!("quvyta.file-manager.create")),
            NameFor::Folder => {
                (crate::t!("quvyta.file-manager.new-folder-title"), crate::t!("quvyta.file-manager.create"))
            }
            NameFor::Rename(key) => (
                crate::t!("quvyta.file-manager.rename-title", name = name_of(key)),
                crate::t!("quvyta.file-manager.rename-do"),
            ),
        };
        let close = (self.wrap)(FileManagerMsg::CloseNaming);
        let submit = (self.wrap)(FileManagerMsg::Submit);
        let dialog = Modal::new()
            .title(title)
            .width(NAMING_WIDTH)
            .on_close(close.clone())
            .action(Button::new(crate::t!("quvyta.file-manager.cancel")).on_press(close))
            .action(Button::new(confirm).variant("primary").on_press(submit.clone()));
        let mut errors = FormErrors::new();
        if let Some(problem) = state.naming_problem() {
            errors.set(NAME_ID, problem.message());
        }
        let value = naming.value.clone();
        // A rename selects the name without its extension, so typing gives a new name and keeps
        // the kind of file; a new entry starts empty and has nothing to select.
        let selection = match &naming.purpose {
            NameFor::Rename(key) => Some(stem(&value, state.is_folder(key))),
            NameFor::File | NameFor::Folder => None,
        };
        let typed = Rc::clone(&self.wrap);
        ui.add_with(dialog, |ui| {
            Form::new().show(ui, |fields| {
                let label = crate::t!("quvyta.file-manager.name-label");
                fields.field(Field::new(label).error(errors.get(NAME_ID)), |ui| {
                    let mut input = TextInput::new(value)
                        .invalid(errors.has(NAME_ID))
                        .on_change(move |value| typed(FileManagerMsg::Name(value)))
                        .on_submit(move |_| submit.clone());
                    if let Some(range) = selection {
                        input = input.select_on_focus(range);
                    }
                    ui.add(input).id(NAME_ID).fill_width();
                });
            });
        });
    }
}

/// The path of the entry `key` under `root`.
fn path_of(root: &Path, key: &str) -> std::path::PathBuf {
    key.split('/').filter(|part| !part.is_empty()).fold(root.to_path_buf(), |path, part| path.join(part))
}

/// What the top row says about the folder at `root`: its own name, or the whole path when it has
/// none, as the file system's own root has none.
fn root_name(root: &Path) -> String {
    root.file_name().map_or_else(|| root.display().to_string(), |name| name.to_string_lossy().into_owned())
}

/// Puts `own`, when there is any, into a menu as a group of its own.
fn add_own<Msg>(items: &mut Vec<ContextItem<Msg>>, own: Vec<ContextItem<Msg>>) {
    if !own.is_empty() {
        items.push(ContextItem::gap());
        items.extend(own);
    }
}

/// What waits to be pasted, and whether pasting it will copy it.
struct Pending {
    keys: Vec<String>,
    copying: bool,
}

impl Pending {
    /// Whether anything waits to be pasted.
    fn is_empty(&self) -> bool {
        self.keys.is_empty()
    }

    /// What letting go of it is called: a move is cancelled, a copy is cancelled.
    fn drop_label(&self) -> String {
        let key = if self.copying { "quvyta.file-manager.drop-copy" } else { "quvyta.file-manager.drop-cut" };
        crate::t!(key)
    }
}

/// The items for what waits to be pasted: pasting it here, and letting it go. A folder cannot take
/// itself or a folder that holds it, so pasting there is shown but cannot be chosen.
fn paste_items<Msg: Clone + 'static>(
    items: &mut Vec<ContextItem<Msg>>,
    key: &str,
    pending: &Pending,
    send: &impl Fn(FileManagerMsg) -> Msg,
) {
    if pending.is_empty() {
        return;
    }
    let paste = ContextItem::new(crate::t!("quvyta.file-manager.paste"), send(FileManagerMsg::Paste(key.to_owned())));
    items.push(paste.disabled(pending.keys.iter().any(|waiting| is_within(key, waiting))));
    items.push(ContextItem::new(pending.drop_label(), send(FileManagerMsg::DropCut)));
}

/// The last item of a row's menu: the trash when the manager has one, and deleting for good
/// otherwise. Both are the destructive item, so both stand alone at the end in the danger colour.
fn away_item<Msg: Clone + 'static>(
    key: &str,
    count: usize,
    trashing: bool,
    send: &impl Fn(FileManagerMsg) -> Msg,
) -> ContextItem<Msg> {
    let many = count > 1;
    let label = match (trashing, many) {
        (true, false) => crate::t!("quvyta.file-manager.trash"),
        (true, true) => crate::t!("quvyta.file-manager.trash-many", n = count),
        (false, false) => crate::t!("quvyta.file-manager.delete"),
        (false, true) => crate::t!("quvyta.file-manager.delete-many", n = count),
    };
    let message = if trashing {
        send(FileManagerMsg::Trash(key.to_owned()))
    } else {
        send(FileManagerMsg::Delete(key.to_owned()))
    };
    ContextItem::new(label, message).danger(true)
}

/// The menu of a folder, or of the root itself when `key` is [`ROOT`]: what can be made in it and,
/// while something is cut, pasting it here.
fn folder_menu<Msg: Clone + 'static>(
    key: &str,
    pending: &Pending,
    trashing: bool,
    own: Vec<ContextItem<Msg>>,
    send: &impl Fn(FileManagerMsg) -> Msg,
) -> Vec<ContextItem<Msg>> {
    let mut items = vec![
        ContextItem::new(crate::t!("quvyta.file-manager.new-file"), send(FileManagerMsg::NewFile(key.to_owned()))),
        ContextItem::new(crate::t!("quvyta.file-manager.new-folder"), send(FileManagerMsg::NewFolder(key.to_owned()))),
    ];
    let root = key == ROOT;
    if !root {
        items.push(ContextItem::gap());
        items.push(ContextItem::new(
            crate::t!("quvyta.file-manager.rename"),
            send(FileManagerMsg::Rename(key.to_owned())),
        ));
        items.push(ContextItem::new(crate::t!("quvyta.file-manager.cut"), send(FileManagerMsg::Cut(key.to_owned()))));
        items.push(ContextItem::new(crate::t!("quvyta.file-manager.copy"), send(FileManagerMsg::Copy(key.to_owned()))));
    }
    paste_items(&mut items, key, pending, send);
    add_own(&mut items, own);
    items.push(ContextItem::gap());
    if root {
        items.push(ContextItem::new(crate::t!("quvyta.file-manager.refresh"), send(FileManagerMsg::Refresh)));
    } else {
        items.push(away_item(key, 1, trashing, send));
    }
    items
}

/// The menu of a file.
fn file_menu<Msg: Clone + 'static>(
    key: &str,
    pending: &Pending,
    trashing: bool,
    own: Vec<ContextItem<Msg>>,
    send: &impl Fn(FileManagerMsg) -> Msg,
) -> Vec<ContextItem<Msg>> {
    let mut items = vec![
        ContextItem::new(crate::t!("quvyta.file-manager.rename"), send(FileManagerMsg::Rename(key.to_owned()))),
        ContextItem::new(crate::t!("quvyta.file-manager.cut"), send(FileManagerMsg::Cut(key.to_owned()))),
        ContextItem::new(crate::t!("quvyta.file-manager.copy"), send(FileManagerMsg::Copy(key.to_owned()))),
    ];
    if !pending.is_empty() {
        items.push(ContextItem::new(pending.drop_label(), send(FileManagerMsg::DropCut)));
    }
    add_own(&mut items, own);
    items.push(ContextItem::gap());
    items.push(away_item(key, 1, trashing, send));
    items
}

/// The menu of a row that is one of `count` selected entries: what can be done to all of them at
/// once. A name is given to one entry at a time, so renaming is not offered.
fn many_menu<Msg: Clone + 'static>(
    key: &str,
    count: usize,
    pending: &Pending,
    trashing: bool,
    own: Vec<ContextItem<Msg>>,
    send: &impl Fn(FileManagerMsg) -> Msg,
) -> Vec<ContextItem<Msg>> {
    let mut items = vec![
        ContextItem::new(
            crate::t!("quvyta.file-manager.cut-many", n = count),
            send(FileManagerMsg::Cut(key.to_owned())),
        ),
        ContextItem::new(
            crate::t!("quvyta.file-manager.copy-many", n = count),
            send(FileManagerMsg::Copy(key.to_owned())),
        ),
    ];
    if !pending.is_empty() {
        items.push(ContextItem::new(pending.drop_label(), send(FileManagerMsg::DropCut)));
    }
    add_own(&mut items, own);
    items.push(ContextItem::gap());
    items.push(away_item(key, count, trashing, send));
    items
}