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}