1use std::cell::{Cell, RefCell};
19use std::collections::{HashMap, HashSet};
20use std::path::{Path, PathBuf};
21use std::rc::{Rc, Weak};
22use std::time::{Duration, SystemTime};
23
24use crate::audio::AudioPlayer;
25use crate::config::{
26 BuiltinAction, ContextAction, ContextMenu, PanelConfig, PanelSide, ShortcutTarget,
27 TreeConfig, action_command,
28};
29use crate::fs::meta::format_size;
30use crate::fs::model::{Change, SortKey, StdDirSource, TreeModel, VisibleRow};
31use crate::fs::ops::FileOps;
32use crate::fs::watcher::{RecursiveMode, RecommendedWatcher, Watcher, spawn as spawn_watcher};
33use crate::preview::{
34 self, ArchiveData, DocumentData, DocumentLines, ParseStatus, PreviewKind, TableData,
35};
36use gtk4_layer_shell::{KeyboardMode, Layer, LayerShell};
37use relm4::gtk::{gdk, gio, glib, pango, prelude::*};
38use relm4::prelude::*;
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42enum ClipboardOp {
43 Copy,
44 Cut,
45}
46
47#[derive(Debug, Default)]
51struct TypeAhead {
52 prefix: String,
54 last: Option<std::time::Instant>,
57}
58
59const TYPEAHEAD_TIMEOUT: Duration = Duration::from_millis(700);
61const TYPEAHEAD_MAX: usize = 32;
63
64impl TypeAhead {
65 fn push(&mut self, ch: char, now: std::time::Instant) -> Option<String> {
68 if !ch.is_alphanumeric() && ch != '_' && ch != '-' && ch != '.' && ch != ' ' {
69 return None;
70 }
71 let expired = self
72 .last
73 .is_some_and(|last| now.duration_since(last) > TYPEAHEAD_TIMEOUT);
74 if expired {
75 self.prefix.clear();
76 }
77 if self.prefix.len() >= TYPEAHEAD_MAX {
78 self.prefix.clear();
79 }
80 self.prefix.push(ch.to_ascii_lowercase());
81 self.last = Some(now);
82 Some(self.prefix.clone())
83 }
84
85 fn clear(&mut self) {
87 self.prefix.clear();
88 self.last = None;
89 }
90}
91
92fn typeahead_target(rows: &[VisibleRow], prefix: &str, cursor: Option<usize>) -> Option<usize> {
95 if prefix.is_empty() || rows.is_empty() {
96 return None;
97 }
98 let len = rows.len();
99 let start = cursor.map_or(0, |c| (c + 1) % len);
100 for offset in 0..len {
101 let index = (start + offset) % len;
102 if rows[index].name.to_ascii_lowercase().starts_with(prefix) {
103 return Some(index);
104 }
105 }
106 None
107}
108
109#[derive(Debug, Clone)]
110struct Clipboard {
111 op: ClipboardOp,
112 paths: Vec<PathBuf>,
113}
114
115#[derive(Debug, Clone, PartialEq, Eq)]
118enum DropPlan {
119 Ignore,
121 Copy(Vec<PathBuf>),
123 Move(Vec<PathBuf>),
125 Reject(String),
127}
128
129fn resolve_drop(target: &Path, sources: Vec<PathBuf>, copy: bool) -> DropPlan {
132 let sources: Vec<PathBuf> = sources
133 .into_iter()
134 .filter(|src| src != target && src.parent() != Some(target))
135 .collect();
136 if sources.is_empty() {
137 return DropPlan::Ignore;
138 }
139 if sources.iter().any(|src| target.starts_with(src)) {
140 return DropPlan::Reject("Cannot move a folder into itself".to_string());
141 }
142 if copy { DropPlan::Copy(sources) } else { DropPlan::Move(sources) }
143}
144
145fn index_range(a: usize, b: usize) -> std::ops::RangeInclusive<usize> {
148 if a <= b { a..=b } else { b..=a }
149}
150
151fn drag_paths(selected: &[PathBuf], pressed: &Path) -> Vec<PathBuf> {
156 if selected.len() > 1 && selected.iter().any(|p| p == pressed) {
157 selected.to_vec()
158 } else {
159 vec![pressed.to_path_buf()]
160 }
161}
162
163fn format_uri_list(paths: &[PathBuf]) -> String {
166 let mut out = String::new();
167 for path in paths {
168 if let Ok(uri) = glib::filename_to_uri(path, None) {
169 out.push_str(&uri);
170 out.push_str("\r\n");
171 }
172 }
173 out
174}
175
176fn provider_for_paths(paths: &[PathBuf]) -> gdk::ContentProvider {
181 let files: Vec<gio::File> = paths.iter().map(gio::File::for_path).collect();
182 let list = gdk::FileList::from_array(&files);
183 let native = gdk::ContentProvider::for_value(&list.to_value());
184 let uris = format_uri_list(paths);
185 let bytes = glib::Bytes::from(uris.as_bytes());
186 let text = gdk::ContentProvider::for_bytes(URI_LIST_MIME, &bytes);
187 gdk::ContentProvider::new_union(&[native, text])
188}
189
190pub(crate) fn past_drag_threshold(row: >k::Widget, start: (f64, f64), now: (f64, f64)) -> bool {
197 let threshold = row
198 .settings()
199 .gtk_dnd_drag_threshold()
200 .max(1) as f64;
201 (now.0 - start.0).abs() > threshold || (now.1 - start.1).abs() > threshold
202}
203
204fn drag_badge(count: usize) -> gtk::Widget {
207 let label = gtk::Label::new(Some(&if count <= 1 {
208 "1 item".to_owned()
209 } else {
210 format!("{count} items")
211 }));
212 label.add_css_class("tree-drag-badge");
213 label.upcast()
214}
215
216#[derive(Debug, Clone)]
220struct DragOrigin {
221 x: f64,
223 y: f64,
224}
225
226pub(crate) fn begin_row_drag(
239 widget: >k::Widget,
240 paths: &[PathBuf],
241 start: (f64, f64),
242 actions: gdk::DragAction,
243 on_finished: impl Fn(gdk::Drag, bool) + 'static,
244 on_cancelled: impl Fn(gdk::Drag, gdk::DragCancelReason) + 'static,
245) -> bool {
246 let Some(native) = widget.native() else {
247 return false;
248 };
249 let Some(surface) = native.surface() else {
250 return false;
251 };
252 let Some(device) = widget
253 .display()
254 .default_seat()
255 .and_then(|seat| seat.pointer())
256 else {
257 return false;
258 };
259
260 let (dx, dy) = match (
263 surface.device_position(&device),
264 widget.compute_point(
265 &native,
266 &relm4::gtk::graphene::Point::new(start.0 as f32, start.1 as f32),
267 ),
268 ) {
269 (Some((px, py, _)), Some(p)) => ((px - p.x() as f64).round(), (py - p.y() as f64).round()),
270 _ => (0.0, 0.0),
271 };
272
273 let content = provider_for_paths(paths);
274 let Some(drag) = gdk::Drag::begin(&surface, &device, &content, actions, dx, dy) else {
275 return false;
276 };
277
278 let icon = gtk::DragIcon::for_drag(&drag);
279 icon.set_child(Some(&drag_badge(paths.len())));
280
281 {
282 let on_finished = Rc::new(on_finished);
283 let drag = drag.clone();
284 drag.connect_dnd_finished(move |drag| {
285 let delete = drag.selected_action() == gdk::DragAction::MOVE;
286 on_finished(drag.clone(), delete);
287 });
288 }
289 {
290 let on_cancelled = Rc::new(on_cancelled);
291 let drag = drag.clone();
292 drag.connect_cancel(move |drag, reason| on_cancelled(drag.clone(), reason));
293 }
294 true
295}
296
297pub struct TreeInit {
299 pub config: TreeConfig,
301 pub parent: gtk::Window,
303 pub menu: ContextMenu,
305 pub side: PanelSide,
308 pub panel: PanelConfig,
310}
311
312#[derive(Debug, Clone)]
314pub enum TreeMsg {
315 OpenRoot(PathBuf),
316 SelectPath(PathBuf),
320 Focus,
323 SetFilter(String),
324 SetPanel(PanelConfig),
327
328 MoveUp,
329 MoveDown,
330 ExtendSelection(isize),
334 MoveExtendingSelection(isize),
339 CollapseCursor,
340 ExpandCursor,
341 ActivateCursor,
342 Rename,
343
344 Activate(PathBuf),
345 Toggle(PathBuf),
346 Select(PathBuf),
347 ToggleSelect(PathBuf),
350 RangeSelect(PathBuf),
353 RowPress { path: PathBuf, is_dir: bool, ctrl: bool, shift: bool },
358 RowRelease { path: PathBuf },
363 DragStarted,
366 DroppedAsMove(Vec<PathBuf>),
370 Deselect,
373 Menu(PathBuf),
374 MenuAt { x: f64, y: f64 },
377 RenameAt(PathBuf),
378 Trash(PathBuf),
381 DeleteSelected,
383 ConfirmTrash(Vec<PathBuf>),
384 PermanentDelete(PathBuf),
386 PermanentDeleteSelected,
388 ConfirmPermanentDelete(Vec<PathBuf>),
390 RunCommand { command: String, path: PathBuf },
393 OpenSplit(PathBuf),
395 OpenInOppositePanel(PathBuf),
397 OpenWith(PathBuf),
399 OpenWithDefault(PathBuf),
401 CreateLink(PathBuf),
403 Properties(PathBuf),
405 PropertiesSelected(Vec<PathBuf>),
408 CopyPaths(Vec<PathBuf>),
411 CopyRelativePaths(Vec<PathBuf>),
414 RunCommandEach { command: String, paths: Vec<PathBuf> },
417 RenameTo { path: PathBuf, name: String },
420 ToggleThumbnail(PathBuf),
423 ToggleThumbnailPlay(PathBuf),
425 RunShortcut { target: ShortcutTarget },
427 Duplicate(PathBuf),
428 RequestDuplicateCursor,
430 SelectAllRows,
432 TypeAhead(char),
435 CopyPath(PathBuf),
436 CopyRelativePath(PathBuf),
437 AddBookmark(PathBuf),
439
440 RenameCommit,
441 RenameCancel,
442
443 Copy,
444 Cut,
445 Paste,
446 PastePaths(Vec<PathBuf>),
449 NewFile,
450 NewFolder,
451
452 ToggleHidden,
454 SetSortKey(SortKey),
456 ToggleSortDirection,
458
459 FsChange(Change),
460 FlushChanges,
461 RequestOpenFolder,
462
463 DropInto { target: PathBuf, sources: Vec<PathBuf>, copy: bool },
468 DropIntoRoot { sources: Vec<PathBuf>, copy: bool },
471 DropIntoConfirmed { target: PathBuf, sources: Vec<PathBuf> },
473 Shutdown,
475 Noop,
478}
479
480#[derive(Debug)]
482pub enum TreeOutput {
483 Status(String),
485 RootChanged(PathBuf),
487 OpenFolderRequested,
489 OpenSplit(PathBuf),
491 OpenOpposite(PathBuf),
493 PaneAction(BuiltinAction),
497 AddBookmark(PathBuf),
499}
500
501pub struct Tree {
503 tree: Option<TreeModel>,
504 ops: FileOps,
505 config: TreeConfig,
506 parent: gtk::Window,
507 menu: ContextMenu,
508 side: PanelSide,
509
510 thumbnails: HashSet<PathBuf>,
514 preview_kinds: HashMap<PathBuf, Option<PreviewKind>>,
518 documents: HashMap<PathBuf, Option<DocumentData>>,
522 thumb_cache: HashMap<PathBuf, gdk::Texture>,
525 media: HashMap<PathBuf, gtk::MediaFile>,
528 video_widgets: HashMap<PathBuf, gtk::Video>,
533 audio: HashMap<PathBuf, Rc<AudioPlayer>>,
536 playing: HashSet<PathBuf>,
538 gif_anims: Rc<RefCell<HashMap<PathBuf, GifAnim>>>,
540 gif_widgets: Rc<RefCell<HashMap<PathBuf, gtk::Picture>>>,
543 gif_tick: Rc<RefCell<Option<glib::SourceId>>>,
545 audio_widgets: Rc<RefCell<HashMap<PathBuf, AudioWidgets>>>,
549 audio_tick: Rc<RefCell<Option<glib::SourceId>>>,
551 content_width: i32,
553
554 rows: Vec<VisibleRow>,
555 cursor: Option<usize>,
556 selected: Vec<PathBuf>,
557 anchor: Option<PathBuf>,
561 last_press: Option<(PathBuf, std::time::Instant)>,
565
566 renaming: Option<PathBuf>,
567 rename_entry: Option<gtk::Entry>,
568 clipboard: Option<Clipboard>,
569
570 pending: Vec<Change>,
571 flush_source: Option<glib::SourceId>,
572 _watcher: Option<RecommendedWatcher>,
573 watched: HashSet<PathBuf>,
576
577 popover: Option<gtk::Popover>,
580 menu_target: Option<PathBuf>,
584 menu_point: Option<(f64, f64)>,
587
588 renaming_state: Rc<Cell<bool>>,
590
591 selection_dirty: bool,
596 typeahead: TypeAhead,
599 scroll_to: Option<usize>,
602 focus_pending: bool,
606
607 drag_payload: Rc<RefCell<Vec<PathBuf>>>,
612 drag_active: Rc<Cell<bool>>,
616 pending_click: Option<PathBuf>,
620 drag_landed_internal: Rc<Cell<bool>>,
624 drag_origin: Rc<RefCell<Option<DragOrigin>>>,
628 drag_button_down: Rc<Cell<bool>>,
633}
634
635#[relm4::component(pub)]
636impl Component for Tree {
637 type Init = TreeInit;
638 type Input = TreeMsg;
639 type Output = TreeOutput;
640 type CommandOutput = ();
641
642 view! {
643 scrolled = gtk::ScrolledWindow {
644 set_vexpand: true,
645 set_hexpand: true,
646 set_min_content_width: 160,
647 add_css_class: "tree-scroll",
648
649 #[name = "list"]
650 gtk::ListBox {
651 set_selection_mode: gtk::SelectionMode::None,
652 set_activate_on_single_click: false,
653 }
654 }
655 }
656
657 fn init(
658 init: Self::Init,
659 root: Self::Root,
660 sender: ComponentSender<Self>,
661 ) -> ComponentParts<Self> {
662 let renaming_state = Rc::new(Cell::new(false));
663 let keys = compile_shortcuts(&init.menu);
664 let widgets = view_output!();
665
666 let controller = gtk::EventControllerKey::new();
667 controller.set_propagation_phase(gtk::PropagationPhase::Capture);
668 let capture_sender = sender.clone();
669 let capture_state = renaming_state.clone();
670 let capture_keys = keys.clone();
671 controller.connect_key_pressed(move |_ctrl, key, _keycode, state| {
672 handle_key(
673 key,
674 state,
675 capture_state.get(),
676 &capture_keys,
677 &capture_sender,
678 )
679 });
680 widgets.scrolled.add_controller(controller);
681
682 let root_drop = gtk::DropTarget::new(gdk::FileList::static_type(), gdk::DragAction::MOVE | gdk::DragAction::COPY);
688 let root_drop_sender = sender.clone();
689 root_drop.connect_drop(move |target, value, _x, _y| {
690 let Ok(list) = value.get::<gdk::FileList>() else {
691 return false;
692 };
693 let sources: Vec<PathBuf> = list.files().iter().filter_map(gio::File::path).collect();
694 if sources.is_empty() {
695 return false;
696 }
697 let copy = target.current_event_state().contains(gdk::ModifierType::CONTROL_MASK);
698 root_drop_sender.input(TreeMsg::DropIntoRoot { sources, copy });
699 true
700 });
701 widgets.list.add_controller(root_drop);
702
703 let blank_scrolled = widgets.scrolled.clone();
709 let blank_click = gtk::GestureClick::new();
710 blank_click.set_button(0);
711 blank_click.set_propagation_phase(gtk::PropagationPhase::Capture);
712 let blank_sender = sender.clone();
713 blank_click.connect_pressed(move |gesture, _n_press, x, y| {
714 if gesture.current_button() == 3
717 && gesture.current_event_state().contains(gdk::ModifierType::SUPER_MASK)
718 {
719 gesture.set_state(gtk::EventSequenceState::Denied);
720 return;
721 }
722 let on_row = blank_scrolled
723 .pick(x, y, gtk::PickFlags::DEFAULT)
724 .is_some_and(|widget| in_list_row(&widget));
725 if on_row {
726 return;
727 }
728 match gesture.current_button() {
729 1 => blank_sender.input(TreeMsg::Deselect),
730 3 => blank_sender.input(TreeMsg::MenuAt { x, y }),
731 _ => {}
732 }
733 });
734 widgets.scrolled.add_controller(blank_click);
735
736 let model = Tree {
737 tree: None,
738 ops: FileOps::new(),
739 config: init.config,
740 parent: init.parent,
741 menu: init.menu,
742 side: init.side,
743 thumbnails: HashSet::new(),
744 preview_kinds: HashMap::new(),
745 documents: HashMap::new(),
746 thumb_cache: HashMap::new(),
747 media: HashMap::new(),
748 video_widgets: HashMap::new(),
749 audio: HashMap::new(),
750 playing: HashSet::new(),
751 gif_anims: Rc::new(RefCell::new(HashMap::new())),
752 gif_widgets: Rc::new(RefCell::new(HashMap::new())),
753 gif_tick: Rc::new(RefCell::new(None)),
754 audio_widgets: Rc::new(RefCell::new(HashMap::new())),
755 audio_tick: Rc::new(RefCell::new(None)),
756 content_width: thumbnail_content_width(init.panel),
757 rows: Vec::new(),
758 cursor: None,
759 selected: Vec::new(),
760 anchor: None,
761 last_press: None,
762 renaming: None,
763 rename_entry: None,
764 clipboard: None,
765 pending: Vec::new(),
766 flush_source: None,
767 _watcher: None,
768 watched: HashSet::new(),
769 popover: None,
770 menu_target: None,
771 menu_point: None,
772 renaming_state,
773 selection_dirty: false,
774 typeahead: TypeAhead::default(),
775 scroll_to: None,
776 focus_pending: false,
777 drag_payload: Rc::new(RefCell::new(Vec::new())),
778 drag_active: Rc::new(Cell::new(false)),
779 pending_click: None,
780 drag_landed_internal: Rc::new(Cell::new(false)),
781 drag_origin: Rc::new(RefCell::new(None)),
782 drag_button_down: Rc::new(Cell::new(false)),
783 };
784
785 {
795 let legacy = gtk::EventControllerLegacy::new();
796 legacy.set_propagation_phase(gtk::PropagationPhase::Capture);
797 let origin = model.drag_origin.clone();
798 let payload = model.drag_payload.clone();
799 let active = model.drag_active.clone();
800 let landed = model.drag_landed_internal.clone();
801 let down = model.drag_button_down.clone();
802 let drag_root = widgets.scrolled.clone();
803 let s = sender.clone();
804 legacy.connect_event(move |_controller, event| {
805 match event.event_type() {
806 gdk::EventType::ButtonPress => {
807 let button = event
808 .downcast_ref::<gdk::ButtonEvent>()
809 .map(|b| b.button())
810 .unwrap_or(0);
811 if button == 1
812 && let Some((x, y)) = event.position()
813 {
814 down.set(true);
815 *origin.borrow_mut() = Some(DragOrigin { x, y });
816 }
817 }
818 gdk::EventType::ButtonRelease => {
819 let button = event
820 .downcast_ref::<gdk::ButtonEvent>()
821 .map(|b| b.button())
822 .unwrap_or(0);
823 if button == 1 {
824 down.set(false);
825 if !active.get() {
826 *origin.borrow_mut() = None;
827 }
828 }
829 }
830 gdk::EventType::MotionNotify => {
831 if active.get() || !down.get() {
832 return glib::Propagation::Proceed;
833 }
834 let Some((x, y)) = event.position() else {
835 return glib::Propagation::Proceed;
836 };
837 let start = {
838 let guard = origin.borrow();
839 match guard.as_ref() {
840 Some(o) => (o.x, o.y),
841 None => return glib::Propagation::Proceed,
842 }
843 };
844 if !past_drag_threshold(drag_root.upcast_ref(), start, (x, y)) {
845 return glib::Propagation::Proceed;
846 }
847 let paths = payload.borrow().clone();
848 if paths.is_empty() {
849 return glib::Propagation::Proceed;
850 }
851 *origin.borrow_mut() = None;
853 let finished = {
854 let active = active.clone();
855 let landed = landed.clone();
856 let s = s.clone();
857 let payload = payload.clone();
858 move |_drag: gdk::Drag, delete: bool| {
859 active.set(false);
860 if delete && !landed.replace(false) {
863 let paths = payload.borrow().clone();
864 if !paths.is_empty() {
865 s.input(TreeMsg::DroppedAsMove(paths));
866 }
867 }
868 }
869 };
870 let cancelled = {
871 let active = active.clone();
872 let landed = landed.clone();
873 move |_drag: gdk::Drag, _reason: gdk::DragCancelReason| {
874 active.set(false);
875 landed.set(false);
876 }
877 };
878 if begin_row_drag(
879 drag_root.upcast_ref(),
880 &paths,
881 start,
882 gdk::DragAction::MOVE | gdk::DragAction::COPY,
883 finished,
884 cancelled,
885 ) {
886 active.set(true);
887 s.input(TreeMsg::DragStarted);
888 }
889 }
890 _ => {}
891 }
892 glib::Propagation::Proceed
893 });
894 widgets.scrolled.add_controller(legacy);
895 }
896
897 ComponentParts { model, widgets }
898 }
899
900 fn update_with_view(
901 &mut self,
902 widgets: &mut Self::Widgets,
903 message: Self::Input,
904 sender: ComponentSender<Self>,
905 root: &Self::Root,
906 ) {
907 let _ = root;
908 if let TreeMsg::Focus = message {
911 if widgets.list.row_at_index(0).is_none() {
915 self.focus_pending = true;
918 return;
919 }
920 let index = self.cursor.unwrap_or(0) as i32;
921 if let Some(row) = widgets
922 .list
923 .row_at_index(index)
924 .or_else(|| widgets.list.row_at_index(0))
925 {
926 row.grab_focus();
927 } else if !widgets.list.grab_focus() {
928 widgets.scrolled.grab_focus();
929 }
930 return;
931 }
932 self.handle_message(message, sender.clone());
933 if let Some(index) = self.scroll_to.take() {
937 scroll_row_into_view(widgets, index);
938 }
939 self.reconcile_watches();
943 if self.selection_dirty {
944 self.selection_dirty = false;
947 if update_selection_classes(self, widgets) {
948 return;
949 }
950 }
951 <Self as Component>::update_view(self, widgets, sender.clone());
952 rebuild(self, widgets, sender);
953 if self.focus_pending && widgets.list.row_at_index(0).is_some() {
956 self.focus_pending = false;
957 let index = self.cursor.unwrap_or(0) as i32;
958 if let Some(row) = widgets
959 .list
960 .row_at_index(index)
961 .or_else(|| widgets.list.row_at_index(0))
962 {
963 row.grab_focus();
964 }
965 }
966 }
967}
968
969impl Tree {
970 fn handle_message(&mut self, msg: TreeMsg, sender: ComponentSender<Self>) {
971 match msg {
972 TreeMsg::OpenRoot(path) => self.open_root(&path, &sender),
973 TreeMsg::SelectPath(path) => self.reveal_path(path),
974 TreeMsg::Focus => {}
976 TreeMsg::SetFilter(filter) => {
977 if let Some(tree) = self.tree.as_mut() {
978 tree.set_filter(&filter);
979 self.refresh_rows();
980 }
981 }
982 TreeMsg::SetPanel(panel) => self.content_width = thumbnail_content_width(panel),
983 TreeMsg::Shutdown => self.shutdown(),
984 TreeMsg::Noop => {}
985
986 TreeMsg::MoveUp => self.cursor_delta(-1),
987 TreeMsg::MoveDown => self.cursor_delta(1),
988 TreeMsg::ExtendSelection(delta) => self.cursor_extend(delta, false),
989 TreeMsg::MoveExtendingSelection(delta) => self.cursor_extend(delta, true),
990 TreeMsg::CollapseCursor => {
991 if let Some(path) = self.cursor_path()
992 && self.tree.as_ref().is_some_and(|t| t.is_expanded(&path))
993 {
994 self.collapse_dir(&path);
995 }
996 }
997 TreeMsg::ExpandCursor => {
998 if let Some(path) = self.cursor_path() {
999 self.expand_dir(&path, &sender);
1000 }
1001 }
1002 TreeMsg::ActivateCursor => {
1003 if let Some(path) = self.cursor_path() {
1004 self.activate(&path, &sender);
1005 }
1006 }
1007 TreeMsg::Rename => {
1008 if let Some(path) = self.cursor_path() {
1009 self.renaming = Some(path);
1010 self.renaming_state.set(true);
1011 }
1012 }
1013
1014 TreeMsg::Activate(path) => self.activate(&path, &sender),
1015 TreeMsg::Toggle(path) => self.toggle_dir(&path, &sender),
1016 TreeMsg::Select(path) => {
1017 self.select(&path);
1018 self.anchor = Some(path);
1019 self.selection_dirty = true;
1020 }
1021 TreeMsg::ToggleSelect(path) => {
1022 if let Some(i) = self.rows.iter().position(|r| r.path == path) {
1023 self.cursor = Some(i);
1024 }
1025 if let Some(i) = self.selected.iter().position(|p| p == &path) {
1026 self.selected.remove(i);
1027 } else {
1028 self.selected.push(path.clone());
1029 }
1030 self.anchor = Some(path);
1031 self.selection_dirty = true;
1032 }
1033 TreeMsg::RangeSelect(path) => {
1034 self.range_select(&path);
1035 self.selection_dirty = true;
1036 }
1037 TreeMsg::RowPress { path, is_dir, ctrl, shift } => {
1038 if ctrl {
1043 sender.input(TreeMsg::ToggleSelect(path));
1044 return;
1045 }
1046 if shift {
1047 sender.input(TreeMsg::RangeSelect(path));
1048 return;
1049 }
1050 *self.drag_payload.borrow_mut() = drag_paths(&self.selected, &path);
1055
1056 if self.selected.len() > 1 && self.selected.iter().any(|p| p == &path) {
1060 self.pending_click = Some(path.clone());
1061 if is_dir {
1062 self.toggle_dir(&path, &sender);
1063 }
1064 return;
1065 }
1066 self.pending_click = None;
1067 self.press_default(&path, is_dir, &sender);
1068 }
1069 TreeMsg::RowRelease { path } => {
1070 *self.drag_origin.borrow_mut() = None;
1072 self.drag_button_down.set(false);
1073 if self.drag_active.get() {
1076 return;
1077 }
1078 if self.pending_click.take().as_deref() == Some(path.as_path()) {
1079 let is_dir = self
1080 .rows
1081 .iter()
1082 .find(|r| r.path == path)
1083 .map(|r| r.is_dir)
1084 .unwrap_or(false);
1085 self.press_default(&path, is_dir, &sender);
1086 }
1087 }
1088 TreeMsg::DragStarted => {
1089 self.drag_active.set(true);
1091 self.pending_click = None;
1092 *self.drag_origin.borrow_mut() = None;
1093 self.drag_button_down.set(false);
1094 }
1095 TreeMsg::DroppedAsMove(paths) => {
1096 match self.ops.trash(&paths) {
1100 Ok(()) => {
1101 for path in &paths {
1102 self.apply_change(Change::Removed { path: path.clone() });
1103 }
1104 let n = paths.len();
1105 self.status(format!("Moved {n} item(s) out of the panel"), &sender);
1106 }
1107 Err(err) => self.status(format!("Could not finish move: {err}"), &sender),
1108 }
1109 }
1110 TreeMsg::Deselect => {
1111 self.selected.clear();
1112 self.cursor = None;
1113 self.anchor = None;
1114 self.typeahead.clear();
1115 self.selection_dirty = true;
1116 }
1117 TreeMsg::Menu(path) => self.open_menu(&path),
1118 TreeMsg::MenuAt { x, y } => {
1119 if let Some(root) = self.tree.as_ref().map(|t| t.root().to_path_buf()) {
1120 self.open_menu_at(&root, (x, y));
1121 }
1122 }
1123 TreeMsg::RenameAt(path) => {
1124 self.select(&path);
1125 self.renaming = Some(path);
1126 self.renaming_state.set(true);
1127 }
1128 TreeMsg::Trash(path) => self.confirm_trash(vec![path], sender.clone()),
1129 TreeMsg::DeleteSelected => {
1130 let paths = self.selected_for_clipboard();
1131 if !paths.is_empty() {
1132 self.confirm_trash(paths, sender.clone());
1133 }
1134 }
1135 TreeMsg::PermanentDelete(path) => {
1136 self.confirm_permanent_delete(vec![path], sender.clone());
1137 }
1138 TreeMsg::PermanentDeleteSelected => {
1139 let paths = self.selected_for_clipboard();
1140 if !paths.is_empty() {
1141 self.confirm_permanent_delete(paths, sender.clone());
1142 }
1143 }
1144 TreeMsg::ConfirmPermanentDelete(paths) => {
1145 if let Some(renaming) = self.renaming.as_ref()
1146 && paths.contains(renaming)
1147 {
1148 self.renaming = None;
1149 self.renaming_state.set(false);
1150 }
1151 match self.ops.delete_permanently(&paths) {
1152 Ok(()) => {
1153 for path in paths {
1154 self.apply_change(Change::Removed { path });
1155 }
1156 self.selected.clear();
1157 self.cursor_snap();
1158 }
1159 Err(err) => self.status(err.to_string(), &sender),
1160 }
1161 }
1162 TreeMsg::ConfirmTrash(paths) => {
1163 if let Some(renaming) = self.renaming.as_ref()
1164 && paths.contains(renaming)
1165 {
1166 self.renaming = None;
1167 self.renaming_state.set(false);
1168 }
1169 match self.ops.trash(&paths) {
1170 Ok(()) => {
1171 for path in paths {
1172 self.apply_change(Change::Removed { path });
1173 }
1174 self.selected.clear();
1175 self.cursor_snap();
1176 }
1177 Err(err) => self.status(err.to_string(), &sender),
1180 }
1181 }
1182 TreeMsg::Duplicate(path) => match self.ops.duplicate(&path) {
1183 Ok(new_path) => {
1184 self.apply_change(Change::Created { path: new_path.clone() });
1185 self.select(&new_path);
1186 }
1187 Err(err) => self.status(format!("Could not duplicate: {err}"), &sender),
1188 },
1189 TreeMsg::RequestDuplicateCursor => {
1190 if let Some(path) = self.cursor_path() {
1191 sender.input(TreeMsg::Duplicate(path));
1192 }
1193 }
1194 TreeMsg::CopyPath(path) => {
1195 set_clipboard_text(&path.display().to_string());
1196 self.status("Copied path to clipboard".to_string(), &sender);
1197 }
1198 TreeMsg::CopyRelativePath(path) => {
1199 let relative = self
1200 .tree
1201 .as_ref()
1202 .and_then(|t| path.strip_prefix(t.root()).ok())
1203 .map(|p| p.display().to_string())
1204 .unwrap_or_else(|| path.display().to_string());
1205 set_clipboard_text(&relative);
1206 self.status("Copied relative path to clipboard".to_string(), &sender);
1207 }
1208 TreeMsg::AddBookmark(path) => {
1209 let _ = sender.output(TreeOutput::AddBookmark(path));
1210 }
1211 TreeMsg::RunCommand { command, path } => {
1212 let cmd = action_command(&command, &path);
1213 match spawn_command(&cmd) {
1214 Ok(()) => self.status(format!("Ran {command} on {}", path.display()), &sender),
1215 Err(err) => self.status(format!("Could not run {command}: {err}"), &sender),
1216 }
1217 }
1218 TreeMsg::OpenSplit(path) => {
1219 let _ = sender.output(TreeOutput::OpenSplit(path));
1220 }
1221 TreeMsg::OpenInOppositePanel(path) => {
1222 let _ = sender.output(TreeOutput::OpenOpposite(path));
1223 }
1224 TreeMsg::OpenWith(path) => open_with_dialog(&self.parent.clone(), &path, &sender),
1225 TreeMsg::OpenWithDefault(path) => match open_in_app(&path) {
1226 Ok(app) => {
1227 self.status(format!("Opened {} with {app}", path.display()), &sender);
1228 }
1229 Err(err) => self.status(err, &sender),
1230 },
1231 TreeMsg::CreateLink(path) => match self.ops.create_link(&path) {
1232 Ok(new_path) => {
1233 self.apply_change(Change::Created { path: new_path.clone() });
1234 }
1235 Err(err) => self.status(format!("Could not create link: {err}"), &sender),
1236 },
1237 TreeMsg::Properties(path) => {
1238 crate::ui::props::show_properties_dialog(&self.parent.clone(), &path, &sender);
1239 }
1240 TreeMsg::PropertiesSelected(paths) => {
1241 crate::ui::props::show_multi_properties_dialog(&self.parent.clone(), &paths);
1242 }
1243 TreeMsg::CopyPaths(paths) => {
1244 let text = paths
1245 .iter()
1246 .map(|p| p.display().to_string())
1247 .collect::<Vec<_>>()
1248 .join("\n");
1249 set_clipboard_text(&text);
1250 self.status(format!("Copied {} paths to clipboard", paths.len()), &sender);
1251 }
1252 TreeMsg::CopyRelativePaths(paths) => {
1253 let root = self.tree.as_ref().map(|t| t.root().to_path_buf());
1254 let text = paths
1255 .iter()
1256 .map(|p| {
1257 root.as_deref()
1258 .and_then(|root| p.strip_prefix(root).ok())
1259 .map(|rel| rel.display().to_string())
1260 .unwrap_or_else(|| p.display().to_string())
1261 })
1262 .collect::<Vec<_>>()
1263 .join("\n");
1264 set_clipboard_text(&text);
1265 self.status(
1266 format!("Copied {} relative paths to clipboard", paths.len()),
1267 &sender,
1268 );
1269 }
1270 TreeMsg::RunCommandEach { command, paths } => {
1271 let mut failures = 0usize;
1272 for path in &paths {
1273 let cmd = action_command(&command, path);
1274 if spawn_command(&cmd).is_err() {
1275 failures += 1;
1276 }
1277 }
1278 let total = paths.len();
1279 if failures == 0 {
1280 self.status(format!("Ran {command} on {total} item(s)"), &sender);
1281 } else {
1282 self.status(
1283 format!("Ran {command} on {total} item(s); {failures} failed"),
1284 &sender,
1285 );
1286 }
1287 }
1288 TreeMsg::RenameTo { path, name } => match self.ops.rename(&path, &name) {
1289 Ok(new_path) => {
1290 self.apply_change(Change::Renamed {
1291 from: path,
1292 to: new_path.clone(),
1293 });
1294 self.select(&new_path);
1295 }
1296 Err(err) => self.status(format!("Could not rename: {err}"), &sender),
1297 },
1298 TreeMsg::ToggleThumbnail(path) => {
1299 if self.thumbnails.contains(&path) {
1300 self.clear_thumbnails_under(&path);
1302 } else {
1303 self.thumbnails.insert(path.clone());
1304 if path.is_dir() {
1306 self.expand_dir(&path, &sender);
1307 }
1308 }
1309 }
1310 TreeMsg::ToggleThumbnailPlay(path) => self.toggle_thumbnail_play(&path),
1311 TreeMsg::RunShortcut { target } => self.run_shortcut(target, &sender),
1312
1313 TreeMsg::SelectAllRows => {
1314 if !self.rows.is_empty() {
1315 self.selected = self.rows.iter().map(|r| r.path.clone()).collect();
1316 self.cursor = Some(0);
1317 self.anchor = self.rows.first().map(|r| r.path.clone());
1318 self.selection_dirty = true;
1319 }
1320 }
1321 TreeMsg::TypeAhead(ch) => {
1322 let now = std::time::Instant::now();
1323 if let Some(prefix) = self.typeahead.push(ch, now)
1324 && let Some(index) = typeahead_target(&self.rows, &prefix, self.cursor)
1325 {
1326 let path = self.rows[index].path.clone();
1327 self.cursor = Some(index);
1328 self.set_single_selection(path);
1329 self.selection_dirty = true;
1330 }
1331 }
1332
1333 TreeMsg::RenameCommit => {
1334 let text = self
1335 .rename_entry
1336 .as_ref()
1337 .map(|e| e.text().to_string())
1338 .unwrap_or_default();
1339 let Some(old) = self.renaming.take() else {
1340 return;
1341 };
1342 self.renaming_state.set(false);
1343 self.rename_entry = None;
1344 let trimmed = text.trim().to_string();
1345 let unchanged = old
1346 .file_name()
1347 .map(|n| n.to_string_lossy() == trimmed.as_str())
1348 .unwrap_or(false);
1349 if trimmed.is_empty() || unchanged {
1350 return;
1351 }
1352 match self.ops.rename(&old, &trimmed) {
1353 Ok(new_path) => {
1354 self.apply_change(Change::Renamed {
1355 from: old,
1356 to: new_path.clone(),
1357 });
1358 self.select(&new_path);
1359 }
1360 Err(err) => self.status(format!("Could not rename: {err}"), &sender),
1361 }
1362 }
1363 TreeMsg::RenameCancel => {
1364 self.renaming = None;
1365 self.renaming_state.set(false);
1366 self.rename_entry = None;
1367 }
1368
1369 TreeMsg::Copy => {
1370 let paths = self.selected_for_clipboard();
1371 if !paths.is_empty() {
1372 self.clipboard = Some(Clipboard { op: ClipboardOp::Copy, paths: paths.clone() });
1373 self.status(format!("Copying {} item(s)", paths.len()), &sender);
1374 }
1375 }
1376 TreeMsg::Cut => {
1377 let paths = self.selected_for_clipboard();
1378 if !paths.is_empty() {
1379 self.clipboard = Some(Clipboard { op: ClipboardOp::Cut, paths: paths.clone() });
1380 self.status(format!("Cutting {} item(s)", paths.len()), &sender);
1381 }
1382 }
1383 TreeMsg::Paste => self.paste(&sender),
1384 TreeMsg::PastePaths(paths) => self.paste_paths(paths, &sender),
1385
1386 TreeMsg::NewFile => self.create_entry(false, &sender),
1387 TreeMsg::NewFolder => self.create_entry(true, &sender),
1388
1389 TreeMsg::ToggleHidden => self.toggle_hidden(&sender),
1390 TreeMsg::SetSortKey(key) => self.set_sort_key(key, &sender),
1391 TreeMsg::ToggleSortDirection => self.toggle_sort_direction(&sender),
1392
1393 TreeMsg::FsChange(change) => {
1394 self.pending.push(change);
1396 if self.flush_source.is_none() {
1397 let s = sender.clone();
1398 let id = glib::timeout_add_local_once(
1399 Duration::from_millis(100),
1400 move || {
1401 s.input(TreeMsg::FlushChanges);
1402 }
1403 );
1404 self.flush_source = Some(id);
1405 }
1406 }
1407 TreeMsg::FlushChanges => {
1408 self.flush_source = None;
1409 let changes = std::mem::take(&mut self.pending);
1410 for change in changes {
1411 self.apply_change(change);
1412 }
1413 self.cursor_snap();
1414 }
1415 TreeMsg::RequestOpenFolder => {
1416 self.status("Pick a folder to browse...".to_string(), &sender);
1417 let _ = sender.output(TreeOutput::OpenFolderRequested);
1418 }
1419
1420 TreeMsg::DropInto { target, sources, copy } => {
1421 self.request_drop_into(target, sources, copy, &sender);
1422 }
1423 TreeMsg::DropIntoRoot { sources, copy } => {
1424 if let Some(root) = self.tree.as_ref().map(|t| t.root().to_path_buf()) {
1425 self.request_drop_into(root, sources, copy, &sender);
1426 }
1427 }
1428 TreeMsg::DropIntoConfirmed { target, sources } => {
1429 self.drop_into(&target, &sources, false, &sender);
1430 }
1431 }
1432 }
1433}
1434
1435fn accel_mods(m: gdk::ModifierType) -> gdk::ModifierType {
1442 m & (gdk::ModifierType::CONTROL_MASK
1443 | gdk::ModifierType::SHIFT_MASK
1444 | gdk::ModifierType::ALT_MASK
1445 | gdk::ModifierType::SUPER_MASK
1446 | gdk::ModifierType::HYPER_MASK
1447 | gdk::ModifierType::META_MASK)
1448}
1449
1450#[derive(Clone)]
1452struct KeyBinding {
1453 key: gdk::Key,
1454 mods: gdk::ModifierType,
1455 target: ShortcutTarget,
1456}
1457
1458fn compile_shortcuts(menu: &ContextMenu) -> Rc<Vec<KeyBinding>> {
1462 let mut out = Vec::new();
1463 for (accel, target) in menu.shortcuts() {
1464 if let Some((key, mods)) = parse_accelerator(&accel) {
1465 out.push(KeyBinding { key, mods, target });
1466 }
1467 }
1468 Rc::new(out)
1469}
1470
1471fn parse_accelerator(accel: &str) -> Option<(gdk::Key, gdk::ModifierType)> {
1477 gtk::accelerator_parse(normalize_accelerator(accel))
1478}
1479
1480fn normalize_accelerator(accel: &str) -> String {
1483 if !accel.contains('+') {
1484 return accel.to_owned();
1485 }
1486 let mut mods = String::new();
1487 let mut key = String::new();
1488 for token in accel.split('+') {
1489 let token = token.trim();
1490 if token.is_empty() {
1491 continue;
1492 }
1493 match token.to_ascii_lowercase().as_str() {
1494 "ctrl" | "control" | "primary" => mods.push_str("<Control>"),
1495 "shift" => mods.push_str("<Shift>"),
1496 "alt" => mods.push_str("<Alt>"),
1497 "super" | "win" | "mod4" => mods.push_str("<Super>"),
1498 "meta" => mods.push_str("<Meta>"),
1499 "hyper" => mods.push_str("<Hyper>"),
1500 _ => key = token.to_owned(),
1501 }
1502 }
1503 format!("{mods}{key}")
1504}
1505
1506fn handle_key(
1507 key: gdk::Key,
1508 state: gdk::ModifierType,
1509 renaming: bool,
1510 keys: &[KeyBinding],
1511 sender: &ComponentSender<Tree>,
1512) -> glib::Propagation {
1513 use gdk::Key;
1514
1515 if renaming {
1517 return glib::Propagation::Proceed;
1518 }
1519
1520 let ctrl = state.contains(gdk::ModifierType::CONTROL_MASK);
1521 let shift = state.contains(gdk::ModifierType::SHIFT_MASK);
1522
1523 let structural = match key {
1526 Key::Up if ctrl && shift => Some(TreeMsg::MoveExtendingSelection(-1)),
1527 Key::Down if ctrl && shift => Some(TreeMsg::MoveExtendingSelection(1)),
1528 Key::Up if shift => Some(TreeMsg::ExtendSelection(-1)),
1529 Key::Down if shift => Some(TreeMsg::ExtendSelection(1)),
1530 Key::Up => Some(TreeMsg::MoveUp),
1531 Key::Down => Some(TreeMsg::MoveDown),
1532 Key::Left => Some(TreeMsg::CollapseCursor),
1533 Key::Right => Some(TreeMsg::ExpandCursor),
1534 Key::Return | Key::KP_Enter | Key::space => Some(TreeMsg::ActivateCursor),
1535 Key::Escape => Some(TreeMsg::RenameCancel),
1536 Key::o | Key::O if ctrl && !shift => Some(TreeMsg::RequestOpenFolder),
1537 Key::a | Key::A if ctrl && !shift => Some(TreeMsg::SelectAllRows),
1538 _ => None,
1539 };
1540
1541 let msg = match structural {
1542 Some(msg) => msg,
1543 None => {
1544 let mut hit = None;
1548 for binding in keys.iter() {
1549 if key.to_lower() == binding.key.to_lower()
1550 && accel_mods(state) == accel_mods(binding.mods)
1551 {
1552 hit = Some(TreeMsg::RunShortcut {
1553 target: binding.target.clone(),
1554 });
1555 break;
1556 }
1557 }
1558 match hit {
1559 Some(msg) => msg,
1560 None if !ctrl
1563 && !state.contains(gdk::ModifierType::ALT_MASK)
1564 && !state.contains(gdk::ModifierType::SUPER_MASK) =>
1565 {
1566 if let Some(ch) = key.to_unicode()
1567 && ch.is_ascii_graphic()
1568 {
1569 TreeMsg::TypeAhead(ch)
1570 } else {
1571 return glib::Propagation::Proceed;
1572 }
1573 }
1574 None => return glib::Propagation::Proceed,
1575 }
1576 }
1577 };
1578
1579 sender.input(msg);
1580 glib::Propagation::Stop
1581}
1582
1583fn update_selection_classes(
1592 tree: &mut Tree,
1593 widgets: &mut <Tree as Component>::Widgets,
1594) -> bool {
1595 let mut existing = 0;
1597 let mut child = widgets.list.first_child();
1598 while let Some(current) = child {
1599 if current.is::<gtk::ListBoxRow>() {
1600 existing += 1;
1601 }
1602 child = current.next_sibling();
1603 }
1604 if existing != tree.rows.len() {
1605 return false;
1606 }
1607 for (index, row) in tree.rows.iter().enumerate() {
1608 let Some(list_row) = widgets.list.row_at_index(index as i32) else {
1609 return false;
1610 };
1611 let selected = tree.cursor == Some(index) || tree.selected.contains(&row.path);
1612 if selected {
1613 list_row.add_css_class("tree-row-selected");
1614 } else {
1615 list_row.remove_css_class("tree-row-selected");
1616 }
1617 }
1618 true
1619}
1620
1621fn scroll_row_into_view(widgets: &<Tree as Component>::Widgets, index: usize) {
1626 let Some(row) = widgets.list.row_at_index(index as i32) else {
1627 return;
1628 };
1629 let adj = widgets.scrolled.vadjustment();
1632 let page = adj.page_size();
1633 if page <= 0.0 {
1634 return;
1635 }
1636 let Some(bounds) = row.compute_bounds(&widgets.list) else {
1639 return;
1640 };
1641 let y = bounds.y() as f64;
1642 let height = bounds.height() as f64;
1643 let top = adj.value();
1644 if y < top {
1645 adj.set_value(y);
1646 } else if y + height > top + page {
1647 adj.set_value((y + height - page).max(0.0));
1648 }
1649}
1650
1651fn rebuild(tree: &mut Tree, widgets: &mut <Tree as Component>::Widgets, sender: ComponentSender<Tree>) {
1652 let list = &widgets.list;
1653
1654 if let Some(previous) = tree.popover.take() {
1663 previous.unparent();
1664 }
1665
1666 widgets.scrolled.set_child(None::<>k::Widget>);
1671 list.remove_all();
1672
1673 if tree.tree.is_none() {
1674 widgets.scrolled.set_child(Some(list));
1675 return;
1676 }
1677
1678 let rows = std::mem::take(&mut tree.rows);
1681 let mut menu_row: Option<gtk::ListBoxRow> = None;
1682 tree.rename_entry = None;
1683 tree.gif_widgets.borrow_mut().clear();
1686 tree.audio_widgets.borrow_mut().clear();
1689 let visible: HashSet<PathBuf> = rows.iter().map(|row| row.path.clone()).collect();
1691 tree.retain_visible_media(&visible);
1692
1693 for (index, row) in rows.iter().enumerate() {
1694 let list_row = gtk::ListBoxRow::new();
1695 list_row.set_focusable(true);
1696 list_row.set_can_focus(true);
1697 list_row.add_css_class("tree-row");
1698
1699 let row_indent = row.depth as i32 * 16 + 4;
1700 let container = gtk::Box::new(gtk::Orientation::Vertical, 0);
1702 container.set_hexpand(true);
1703 list_row.set_child(Some(&container));
1704
1705 let hbox = gtk::Box::new(gtk::Orientation::Horizontal, 6);
1706 hbox.set_margin_start(row_indent);
1707 hbox.set_hexpand(true);
1708 container.append(&hbox);
1709
1710 let icon = gtk::Image::from_icon_name(icon_name(row));
1711 icon.set_pixel_size(tree.config.icon_size as i32);
1712 icon.set_valign(gtk::Align::Center);
1713 hbox.append(&icon);
1714
1715 if tree.renaming.as_ref() == Some(&row.path) {
1716 let entry = gtk::Entry::new();
1720 entry.set_text(&row.name);
1721 entry.set_hexpand(true);
1722 entry.add_css_class("tree-rename-entry");
1723 {
1724 let s = sender.clone();
1725 entry.connect_activate(move |_| s.input(TreeMsg::RenameCommit));
1726 }
1727 let esc_controller = gtk::EventControllerKey::new();
1728 let s = sender.clone();
1729 esc_controller.connect_key_pressed(move |_, key, _code, _state| {
1730 if key == gdk::Key::Escape {
1731 s.input(TreeMsg::RenameCancel);
1732 glib::Propagation::Stop
1733 } else {
1734 glib::Propagation::Proceed
1735 }
1736 });
1737 entry.add_controller(esc_controller);
1738 hbox.append(&entry);
1739 tree.rename_entry = Some(entry.clone());
1740 entry.grab_focus();
1741 } else {
1742 let label = gtk::Label::new(Some(&row.name));
1743 label.set_xalign(0.0);
1744 label.set_hexpand(true);
1745 label.set_ellipsize(pango::EllipsizeMode::Middle);
1746 label.set_tooltip_text(Some(&row.path.to_string_lossy()));
1747 hbox.append(&label);
1748 }
1749
1750 if tree.cursor == Some(index) || tree.selected.contains(&row.path) {
1751 list_row.add_css_class("tree-row-selected");
1752 }
1753
1754 if row.matches {
1755 list_row.add_css_class("tree-row-match");
1756 }
1757
1758 if tree.menu_target.as_ref() == Some(&row.path) {
1759 menu_row = Some(list_row.clone());
1760 }
1761
1762 if tree.wants_preview(row) {
1765 tree.append_preview(&row.path, row_indent, &container, &sender);
1766 }
1767
1768 let path = row.path.clone();
1769 let release_path = row.path.clone();
1770 let is_dir = row.is_dir;
1771 let click = gtk::GestureClick::new();
1772 click.set_button(0);
1776 let s = sender.clone();
1777 let focus_row = list_row.clone();
1778 click.connect_pressed(move |gesture, _n_press, x, y| {
1779 if focus_row
1784 .pick(x, y, gtk::PickFlags::DEFAULT)
1785 .is_some_and(|widget| in_inline_media(&widget))
1786 {
1787 return;
1788 }
1789 focus_row.grab_focus();
1794 let button = gesture.current_button();
1795 let state = gesture.current_event_state();
1796 if button == 3 {
1797 if state.contains(gdk::ModifierType::SUPER_MASK) {
1801 gesture.set_state(gtk::EventSequenceState::Denied);
1802 return;
1803 }
1804 s.input(TreeMsg::Menu(path.clone()));
1805 return;
1806 }
1807 if button != 1 {
1808 return;
1809 }
1810 let ctrl = state.contains(gdk::ModifierType::CONTROL_MASK);
1811 let shift = state.contains(gdk::ModifierType::SHIFT_MASK);
1812 s.input(TreeMsg::RowPress { path: path.clone(), is_dir, ctrl, shift });
1815 });
1816 {
1817 let s = sender.clone();
1821 click.connect_released(move |gesture, _n_press, _x, _y| {
1822 if gesture.current_button() == 1 {
1823 s.input(TreeMsg::RowRelease { path: release_path.clone() });
1824 }
1825 });
1826 }
1827 list_row.add_controller(click);
1828
1829 if row.is_dir {
1839 let drop = gtk::DropTarget::new(gdk::FileList::static_type(), gdk::DragAction::MOVE | gdk::DragAction::COPY);
1840 let s = sender.clone();
1841 let target_dir = row.path.clone();
1842 let active = tree.drag_active.clone();
1843 let landed = tree.drag_landed_internal.clone();
1844 drop.connect_drop(move |target, value, _x, _y| {
1845 let Ok(list) = value.get::<gdk::FileList>() else {
1846 return false;
1847 };
1848 let sources: Vec<PathBuf> = list.files().iter().filter_map(gio::File::path).collect();
1849 if sources.is_empty() {
1850 return false;
1851 }
1852 let copy = target.current_event_state().contains(gdk::ModifierType::CONTROL_MASK);
1853 if active.get() {
1856 landed.set(true);
1857 }
1858 s.input(TreeMsg::DropInto { target: target_dir.clone(), sources, copy });
1859 true
1860 });
1861 list_row.add_controller(drop);
1862 }
1863
1864 list.append(&list_row);
1865 }
1866
1867 tree.rows = rows;
1868 widgets.scrolled.set_child(Some(list));
1869
1870 if let Some(row) = menu_row {
1874 let target = tree.menu_target.take().expect("menu_row set implies a target");
1875 let selection = tree.selected.clone();
1876 tree.popover = Some(build_menu(&row, &target, &tree.menu, &selection, tree.side, &sender));
1877 tree.menu_point = None;
1878 } else if let (Some(target), Some(at)) = (tree.menu_target.take(), tree.menu_point.take()) {
1879 let selection = tree.selected.clone();
1880 tree.popover = Some(build_menu_at(&widgets.scrolled, at, &target, &tree.menu, &selection, tree.side, &sender));
1881 }
1882 tree.menu_target = None;
1883 tree.menu_point = None;
1884}
1885
1886fn action_builtin(action: &ContextAction) -> Option<BuiltinAction> {
1888 match action {
1889 ContextAction::Builtin(b) => Some(*b),
1890 ContextAction::Entry(e) => Some(e.action),
1891 ContextAction::Command(_) | ContextAction::Submenu(_) => None,
1892 }
1893}
1894
1895pub(crate) fn menu_label(action: &ContextAction, path: &Path, side: PanelSide) -> String {
1900 let dynamic = match action_builtin(action) {
1901 Some(BuiltinAction::OpenWithDefault) => default_app_label(path),
1902 Some(BuiltinAction::InOppositePanel) => Some(format!("In {} panel", side.opposite().name())),
1903 Some(BuiltinAction::ViewThumbnail) => preview::detect(path)
1906 .and_then(PreviewKind::menu_label)
1907 .map(str::to_owned),
1908 _ => None,
1909 };
1910 dynamic.unwrap_or_else(|| action.label())
1911}
1912
1913pub(crate) fn accel_display(accel: &str) -> Option<String> {
1916 let (key, mods) = parse_accelerator(accel)?;
1917 Some(gtk::accelerator_get_label(key, mods).to_string())
1918}
1919
1920fn build_menu_box(
1925 path: &Path,
1926 menu: &ContextMenu,
1927 selection: &[PathBuf],
1928 side: PanelSide,
1929 sender: &ComponentSender<Tree>,
1930) -> gtk::Box {
1931 let menu_box = gtk::Box::new(gtk::Orientation::Vertical, 0);
1932 menu_box.add_css_class("tree-menu");
1933 let path = path.to_path_buf();
1934 let actions = menu.actions_for_selection(&path, selection.len());
1935 append_menu_items(&menu_box, &actions, &path, selection, side, sender);
1936 menu_box
1937}
1938
1939fn append_menu_items(
1942 menu_box: >k::Box,
1943 actions: &[ContextAction],
1944 path: &Path,
1945 selection: &[PathBuf],
1946 side: PanelSide,
1947 sender: &ComponentSender<Tree>,
1948) {
1949 let multi = selection.len() > 1;
1950 for action in actions {
1951 if action.is_hidden() {
1954 continue;
1955 }
1956
1957 if action_builtin(action) == Some(BuiltinAction::Separator) {
1959 let separator = gtk::Separator::new(gtk::Orientation::Horizontal);
1960 separator.add_css_class("tree-menu-separator");
1961 menu_box.append(&separator);
1962 continue;
1963 }
1964
1965 if let Some(builtin) = action_builtin(action) {
1969 if builtin.is_directory_only() && !path.is_dir() {
1970 continue;
1971 }
1972 if multi && builtin.is_single_row_only() {
1973 continue;
1974 }
1975 if matches!(builtin, BuiltinAction::OpenWithDefault) && path.is_dir() {
1976 continue;
1977 }
1978 if builtin == BuiltinAction::ViewThumbnail
1981 && !path.is_dir()
1982 && preview::detect(path).is_none()
1983 {
1984 continue;
1985 }
1986 }
1987
1988 let row_box = gtk::Box::new(gtk::Orientation::Horizontal, 12);
1989 row_box.add_css_class("tree-menu-item");
1990
1991 if let ContextAction::Submenu(sub) = action {
1993 let button = submenu_row_button(&sub.label);
1994 let popover = build_submenu_popover(&sub.items, path, selection, side, sender);
1995 let child = popover.clone();
1996 let anchor = button.clone();
1997 button.connect_clicked(move |_| {
1998 child.set_parent(&anchor);
2001 child.popup();
2002 });
2003 let child = popover.clone();
2004 popover.connect_closed(move |_| child.unparent());
2005 row_box.append(&button);
2006 row_box.append(&submenu_arrow());
2007 menu_box.append(&row_box);
2008 continue;
2009 }
2010
2011 let label = menu_label(action, path, side);
2012 let msg = match action {
2013 ContextAction::Builtin(b) => action_message(*b, path, selection),
2014 ContextAction::Entry(e) => action_message(e.action, path, selection),
2015 ContextAction::Command(cmd) => {
2016 if multi {
2017 TreeMsg::RunCommandEach {
2018 command: cmd.command.clone(),
2019 paths: selection.to_vec(),
2020 }
2021 } else {
2022 TreeMsg::RunCommand {
2023 command: cmd.command.clone(),
2024 path: path.to_path_buf(),
2025 }
2026 }
2027 }
2028 ContextAction::Submenu(_) => unreachable!("handled above"),
2029 };
2030
2031 let button = gtk::Button::new();
2034 button.set_halign(gtk::Align::Fill);
2035 button.set_hexpand(true);
2036 let label_widget = gtk::Label::new(Some(&label));
2037 label_widget.set_xalign(0.0);
2038 label_widget.set_hexpand(true);
2039 button.set_child(Some(&label_widget));
2040 let s = sender.clone();
2041 button.connect_clicked(move |_| s.input(msg.clone()));
2042 row_box.append(&button);
2043
2044 if let Some(shortcut) = action.shortcut()
2045 && let Some(display) = accel_display(&shortcut)
2046 {
2047 let hint = gtk::Label::new(Some(&display));
2048 hint.add_css_class("tree-menu-shortcut");
2049 hint.set_halign(gtk::Align::End);
2050 hint.set_valign(gtk::Align::Center);
2051 row_box.append(&hint);
2052 }
2053
2054 menu_box.append(&row_box);
2055 }
2056}
2057
2058fn submenu_row_button(label: &str) -> gtk::Button {
2060 let button = gtk::Button::new();
2061 button.set_halign(gtk::Align::Fill);
2062 button.set_hexpand(true);
2063 let text = gtk::Label::new(Some(label));
2064 text.set_xalign(0.0);
2065 text.set_hexpand(true);
2066 button.set_child(Some(&text));
2067 button
2068}
2069
2070fn submenu_arrow() -> gtk::Label {
2072 let arrow = gtk::Label::new(Some("\u{25b8}"));
2073 arrow.add_css_class("tree-menu-submenu-arrow");
2074 arrow.set_halign(gtk::Align::End);
2075 arrow.set_valign(gtk::Align::Center);
2076 arrow
2077}
2078
2079fn build_submenu_popover(
2082 items: &[ContextAction],
2083 path: &Path,
2084 selection: &[PathBuf],
2085 side: PanelSide,
2086 sender: &ComponentSender<Tree>,
2087) -> gtk::Popover {
2088 let menu_box = gtk::Box::new(gtk::Orientation::Vertical, 0);
2089 menu_box.add_css_class("tree-menu");
2090 append_menu_items(&menu_box, items, path, selection, side, sender);
2091
2092 let popover = gtk::Popover::new();
2093 popover.add_css_class("tree-menu-popover");
2094 popover.set_has_arrow(false);
2095 popover.set_position(gtk::PositionType::Right);
2096 popover.set_child(Some(&menu_box));
2097 popover
2098}
2099
2100fn show_menu(
2103 anchor: &impl IsA<gtk::Widget>,
2104 menu_box: >k::Box,
2105 point: Option<(f64, f64)>,
2106) -> gtk::Popover {
2107 let popover = gtk::Popover::new();
2108 popover.add_css_class("tree-menu-popover");
2109 popover.set_child(Some(menu_box));
2110 popover.set_autohide(true);
2111 popover.set_parent(anchor);
2112 if let Some((x, y)) = point {
2113 popover.set_pointing_to(Some(&gdk::Rectangle::new(x as i32, y as i32, 1, 1)));
2114 }
2115 popover.popup();
2116 popover
2117}
2118
2119fn build_menu(
2121 row: >k::ListBoxRow,
2122 path: &Path,
2123 menu: &ContextMenu,
2124 selection: &[PathBuf],
2125 side: PanelSide,
2126 sender: &ComponentSender<Tree>,
2127) -> gtk::Popover {
2128 let menu_box = build_menu_box(path, menu, selection, side, sender);
2129 show_menu(row, &menu_box, None)
2130}
2131
2132fn build_menu_at(
2135 anchor: &impl IsA<gtk::Widget>,
2136 at: (f64, f64),
2137 path: &Path,
2138 menu: &ContextMenu,
2139 selection: &[PathBuf],
2140 side: PanelSide,
2141 sender: &ComponentSender<Tree>,
2142) -> gtk::Popover {
2143 let menu_box = build_menu_box(path, menu, selection, side, sender);
2144 show_menu(anchor, &menu_box, Some(at))
2145}
2146
2147fn action_message(action: BuiltinAction, path: &Path, selection: &[PathBuf]) -> TreeMsg {
2153 if selection.len() > 1 {
2154 match action {
2155 BuiltinAction::Trash => return TreeMsg::DeleteSelected,
2156 BuiltinAction::DeletePermanently => return TreeMsg::PermanentDeleteSelected,
2157 BuiltinAction::CopyPath => return TreeMsg::CopyPaths(selection.to_vec()),
2158 BuiltinAction::CopyRelativePath => return TreeMsg::CopyRelativePaths(selection.to_vec()),
2159 BuiltinAction::Properties => return TreeMsg::PropertiesSelected(selection.to_vec()),
2160 _ => {}
2161 }
2162 }
2163 builtin_message(action, path)
2164}
2165
2166fn builtin_message(action: BuiltinAction, path: &Path) -> TreeMsg {
2168 match action {
2169 BuiltinAction::Open => TreeMsg::Activate(path.to_path_buf()),
2170 BuiltinAction::OpenSplit => TreeMsg::OpenSplit(path.to_path_buf()),
2171 BuiltinAction::InNewPanel => TreeMsg::OpenSplit(path.to_path_buf()),
2172 BuiltinAction::InOppositePanel => TreeMsg::OpenInOppositePanel(path.to_path_buf()),
2173 BuiltinAction::OpenWith => TreeMsg::OpenWith(path.to_path_buf()),
2174 BuiltinAction::OpenWithDefault => TreeMsg::OpenWithDefault(path.to_path_buf()),
2175 BuiltinAction::ViewThumbnail => TreeMsg::ToggleThumbnail(path.to_path_buf()),
2176 BuiltinAction::NewFile => TreeMsg::NewFile,
2177 BuiltinAction::NewFolder => TreeMsg::NewFolder,
2178 BuiltinAction::Duplicate => TreeMsg::Duplicate(path.to_path_buf()),
2179 BuiltinAction::CreateLink => TreeMsg::CreateLink(path.to_path_buf()),
2180 BuiltinAction::Cut => TreeMsg::Cut,
2181 BuiltinAction::Copy => TreeMsg::Copy,
2182 BuiltinAction::Paste => TreeMsg::Paste,
2183 BuiltinAction::Rename => TreeMsg::RenameAt(path.to_path_buf()),
2184 BuiltinAction::CopyPath => TreeMsg::CopyPath(path.to_path_buf()),
2185 BuiltinAction::CopyRelativePath => TreeMsg::CopyRelativePath(path.to_path_buf()),
2186 BuiltinAction::AddBookmark => TreeMsg::AddBookmark(path.to_path_buf()),
2187 BuiltinAction::Properties => TreeMsg::Properties(path.to_path_buf()),
2188 BuiltinAction::Trash => TreeMsg::Trash(path.to_path_buf()),
2189 BuiltinAction::DeletePermanently => TreeMsg::PermanentDelete(path.to_path_buf()),
2190 BuiltinAction::ToggleHidden => TreeMsg::ToggleHidden,
2191 BuiltinAction::SortByName => TreeMsg::SetSortKey(SortKey::Name),
2192 BuiltinAction::SortBySize => TreeMsg::SetSortKey(SortKey::Size),
2193 BuiltinAction::SortByModified => TreeMsg::SetSortKey(SortKey::Modified),
2194 BuiltinAction::SortByType => TreeMsg::SetSortKey(SortKey::Type),
2195 BuiltinAction::ToggleSortAscending => TreeMsg::ToggleSortDirection,
2196 BuiltinAction::OpenFolder
2198 | BuiltinAction::Filter
2199 | BuiltinAction::SplitView
2200 | BuiltinAction::Up
2201 | BuiltinAction::Back
2202 | BuiltinAction::Forward
2203 | BuiltinAction::Collapse
2204 | BuiltinAction::ClosePane
2205 | BuiltinAction::ToggleBookmarks
2206 | BuiltinAction::NewBookmark
2207 | BuiltinAction::NewBookmarkFolder => unreachable!("pane action in a row context menu"),
2208 BuiltinAction::EditBookmark | BuiltinAction::DeleteBookmark => TreeMsg::Noop,
2210 BuiltinAction::Separator => unreachable!("separators are rendered, not dispatched"),
2211 }
2212}
2213
2214pub(crate) fn is_path_safe(action: BuiltinAction) -> bool {
2219 matches!(
2220 action,
2221 BuiltinAction::Open
2222 | BuiltinAction::OpenSplit
2223 | BuiltinAction::InNewPanel
2224 | BuiltinAction::InOppositePanel
2225 | BuiltinAction::OpenWith
2226 | BuiltinAction::OpenWithDefault
2227 | BuiltinAction::Duplicate
2228 | BuiltinAction::CreateLink
2229 | BuiltinAction::CopyPath
2230 | BuiltinAction::CopyRelativePath
2231 | BuiltinAction::Properties
2232 | BuiltinAction::Trash
2233 | BuiltinAction::DeletePermanently
2234 | BuiltinAction::AddBookmark
2235 )
2236}
2237
2238pub(crate) fn path_action_message(action: BuiltinAction, path: &Path) -> Option<TreeMsg> {
2243 if !is_path_safe(action) || action == BuiltinAction::Open {
2244 return None;
2245 }
2246 Some(builtin_message(action, path))
2247}
2248
2249const ARCHIVE_EXTENSIONS: [&str; 8] = ["zip", "tar", "gz", "xz", "bz2", "zst", "7z", "rar"];
2251
2252fn has_extension(path: &Path, extensions: &[&str]) -> bool {
2253 path.extension()
2254 .and_then(|ext| ext.to_str())
2255 .is_some_and(|ext| extensions.iter().any(|candidate| ext.eq_ignore_ascii_case(candidate)))
2256}
2257
2258fn icon_name(row: &VisibleRow) -> &'static str {
2259 if row.is_dir {
2260 return if row.expanded { "folder-open-symbolic" } else { "folder-symbolic" };
2261 }
2262 if has_extension(&row.path, &["pdf"]) {
2266 "application-pdf-symbolic"
2267 } else if has_extension(&row.path, &["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp", "avif"])
2268 {
2269 "image-x-generic-symbolic"
2270 } else if has_extension(&row.path, &["mp3", "flac", "wav", "ogg", "m4a", "opus"]) {
2271 "audio-x-generic-symbolic"
2272 } else if has_extension(&row.path, &["mp4", "mkv", "webm", "mov", "avi"]) {
2273 "video-x-generic-symbolic"
2274 } else if has_extension(&row.path, &ARCHIVE_EXTENSIONS) {
2275 "package-x-generic-symbolic"
2276 } else {
2277 "text-x-generic-symbolic"
2278 }
2279}
2280
2281struct GifAnim {
2288 iter: gdk::gdk_pixbuf::PixbufAnimationIter,
2289}
2290
2291struct AudioWidgets {
2295 player: Weak<AudioPlayer>,
2298 play: gtk::Button,
2299 seek: gtk::Scale,
2300 time: gtk::Label,
2301 seeking: Rc<Cell<bool>>,
2304}
2305
2306fn needs_selection(action: BuiltinAction) -> bool {
2310 matches!(
2311 action,
2312 BuiltinAction::Rename
2313 | BuiltinAction::Duplicate
2314 | BuiltinAction::CreateLink
2315 | BuiltinAction::Cut
2316 | BuiltinAction::Copy
2317 | BuiltinAction::OpenWith
2318 | BuiltinAction::Trash
2319 | BuiltinAction::DeletePermanently
2320 )
2321}
2322
2323fn in_list_row(widget: >k::Widget) -> bool {
2326 let mut current = Some(widget.clone());
2327 while let Some(widget) = current {
2328 if widget.downcast_ref::<gtk::ListBoxRow>().is_some() {
2329 return true;
2330 }
2331 current = widget.parent();
2332 }
2333 false
2334}
2335
2336fn in_inline_media(widget: >k::Widget) -> bool {
2339 let mut current = Some(widget.clone());
2340 while let Some(widget) = current {
2341 if widget.downcast_ref::<gtk::Video>().is_some() || widget.has_css_class("tree-audio") {
2342 return true;
2343 }
2344 current = widget.parent();
2345 }
2346 false
2347}
2348
2349fn thumbnail_content_width(panel: PanelConfig) -> i32 {
2352 (panel.width as i32 - 2 * panel.margin as i32 - 24).max(64)
2353}
2354
2355fn thumbnail_size(texture: &gdk::Texture, available: i32) -> (i32, i32) {
2358 let natural_w = texture.width().max(1);
2359 let natural_h = texture.height().max(1);
2360 let available = available.max(32);
2361 if natural_w <= available {
2362 return (natural_w, natural_h);
2363 }
2364 let height = (natural_h as i64 * available as i64 / natural_w as i64).max(1) as i32;
2365 (available, height)
2366}
2367
2368fn thumbnail_picture(
2371 texture: &gdk::Texture,
2372 margin_start: i32,
2373 width: i32,
2374 height: i32,
2375) -> gtk::Picture {
2376 let picture = gtk::Picture::for_paintable(texture);
2377 picture.add_css_class("tree-thumbnail");
2378 picture.set_can_shrink(true);
2379 picture.set_content_fit(gtk::ContentFit::ScaleDown);
2380 picture.set_halign(gtk::Align::Start);
2381 picture.set_size_request(width, height);
2382 picture.set_margin_start(margin_start);
2383 picture.set_margin_bottom(6);
2384 picture
2385}
2386
2387fn format_time(micros: i64) -> String {
2390 if micros <= 0 {
2391 return "-:--".to_owned();
2392 }
2393 let total = micros / 1_000_000;
2394 let (h, m, s) = (total / 3600, (total % 3600) / 60, total % 60);
2395 if h > 0 {
2396 format!("{h}:{m:02}:{s:02}")
2397 } else {
2398 format!("{m}:{s:02}")
2399 }
2400}
2401
2402fn build_audio_player(player: &Rc<AudioPlayer>, margin_start: i32) -> (gtk::Box, AudioWidgets) {
2406 let controls = gtk::Box::new(gtk::Orientation::Horizontal, 6);
2407 controls.add_css_class("tree-audio");
2408 controls.set_margin_start(margin_start);
2409 controls.set_margin_bottom(6);
2410 controls.set_hexpand(true);
2411
2412 let play = gtk::Button::from_icon_name("media-playback-start-symbolic");
2413 play.add_css_class("flat");
2414 play.set_tooltip_text(Some("Play"));
2415 play.set_valign(gtk::Align::Center);
2416 {
2417 let player = Rc::downgrade(player);
2422 play.connect_clicked(move |button| {
2423 let Some(player) = player.upgrade() else {
2424 return;
2425 };
2426 let playing = player.is_playing();
2427 player.set_playing(!playing);
2428 button.set_icon_name(if playing {
2429 "media-playback-start-symbolic"
2430 } else {
2431 "media-playback-pause-symbolic"
2432 });
2433 });
2434 }
2435 controls.append(&play);
2436
2437 let seek = gtk::Scale::with_range(gtk::Orientation::Horizontal, 0.0, 1.0, 1.0);
2440 seek.set_draw_value(false);
2441 seek.set_hexpand(true);
2442 seek.set_valign(gtk::Align::Center);
2443 seek.set_size_request(60, -1);
2444 let seeking = Rc::new(Cell::new(false));
2445 {
2446 let player = Rc::downgrade(player);
2447 let seeking = seeking.clone();
2448 seek.connect_change_value(move |_, _, value| {
2449 seeking.set(true);
2451 let flag = seeking.clone();
2452 glib::timeout_add_local_once(Duration::from_millis(200), move || flag.set(false));
2453 if let Some(player) = player.upgrade() {
2454 player.seek(value.round() as i64);
2455 }
2456 glib::Propagation::Proceed
2457 });
2458 }
2459 controls.append(&seek);
2460
2461 let time = gtk::Label::new(Some("0:00 / -:--"));
2462 time.add_css_class("tree-audio-time");
2463 time.set_valign(gtk::Align::Center);
2464 time.set_width_chars(13);
2465 time.set_xalign(1.0);
2466 controls.append(&time);
2467
2468 let volume = gtk::Scale::with_range(gtk::Orientation::Horizontal, 0.0, 1.0, 0.05);
2469 volume.set_draw_value(false);
2470 volume.set_value(player.volume());
2471 volume.set_valign(gtk::Align::Center);
2472 volume.set_size_request(56, -1);
2473 volume.set_tooltip_text(Some("Volume"));
2474 {
2475 let player = Rc::downgrade(player);
2476 volume.connect_value_changed(move |scale| {
2477 if let Some(player) = player.upgrade() {
2478 player.set_volume(scale.value());
2479 }
2480 });
2481 }
2482 controls.append(&volume);
2483
2484 let widgets = AudioWidgets {
2485 player: Rc::downgrade(player),
2486 play,
2487 seek,
2488 time,
2489 seeking,
2490 };
2491 (controls, widgets)
2492}
2493
2494const PREVIEW_CELL_CHARS: i32 = 12;
2496
2497fn append_text_lines(container: >k::Box, margin_start: i32, lines: &DocumentLines) {
2499 if lines.lines.is_empty() {
2500 return;
2501 }
2502 let mut text = lines.lines.join("\n");
2503 if lines.more {
2504 text.push_str("\n…");
2505 }
2506 let label = gtk::Label::new(Some(&text));
2507 label.add_css_class("tree-preview-text");
2508 label.set_xalign(0.0);
2509 label.set_hexpand(true);
2510 label.set_wrap(true);
2511 label.set_wrap_mode(pango::WrapMode::Char);
2512 label.set_margin_start(margin_start);
2513 label.set_margin_bottom(6);
2514 container.append(&label);
2515}
2516
2517fn append_status(container: >k::Box, margin_start: i32, status: &ParseStatus) {
2519 if status.detail.is_empty() {
2520 return;
2521 }
2522 let label = gtk::Label::new(Some(&status.detail));
2523 label.add_css_class("tree-preview-status");
2524 label.add_css_class(if status.ok { "ok" } else { "error" });
2525 label.set_xalign(0.0);
2526 label.set_hexpand(true);
2527 label.set_wrap(true);
2528 label.set_wrap_mode(pango::WrapMode::WordChar);
2529 label.set_margin_start(margin_start);
2530 label.set_margin_bottom(6);
2531 container.append(&label);
2532}
2533
2534fn append_table(container: >k::Box, margin_start: i32, table: &TableData) {
2537 if table.rows.is_empty() {
2538 return;
2539 }
2540 let grid = gtk::Grid::new();
2541 grid.add_css_class("tree-preview-table");
2542 grid.set_margin_start(margin_start);
2543 grid.set_margin_bottom(6);
2544 grid.set_column_spacing(10);
2545 grid.set_row_spacing(1);
2546 for (r, row) in table.rows.iter().enumerate() {
2547 for (c, cell) in row.iter().enumerate() {
2548 let label = gtk::Label::new(Some(cell));
2549 label.set_xalign(0.0);
2550 label.set_ellipsize(pango::EllipsizeMode::End);
2551 label.set_max_width_chars(PREVIEW_CELL_CHARS);
2552 if r == 0 {
2553 label.add_css_class("tree-preview-header");
2554 }
2555 grid.attach(&label, c as i32, r as i32, 1, 1);
2556 }
2557 }
2558 container.append(&grid);
2559 if table.more {
2560 let more = gtk::Label::new(Some("…"));
2561 more.add_css_class("tree-preview-status");
2562 more.set_xalign(0.0);
2563 more.set_margin_start(margin_start);
2564 container.append(&more);
2565 }
2566}
2567
2568fn append_archive(container: >k::Box, margin_start: i32, archive: &ArchiveData) {
2571 let list = gtk::Box::new(gtk::Orientation::Vertical, 1);
2572 list.add_css_class("tree-preview-archive");
2573 list.set_margin_start(margin_start);
2574 list.set_margin_bottom(6);
2575 match archive {
2576 ArchiveData::Unsupported(reason) => {
2577 let label = gtk::Label::new(Some(reason));
2578 label.add_css_class("tree-preview-status");
2579 label.set_xalign(0.0);
2580 label.set_wrap(true);
2581 list.append(&label);
2582 }
2583 ArchiveData::Listed(listing) => {
2584 for entry in &listing.entries {
2585 let row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
2586 let name = gtk::Label::new(Some(&entry.name));
2587 name.add_css_class("tree-preview-archive-name");
2588 name.set_xalign(0.0);
2589 name.set_hexpand(true);
2590 name.set_ellipsize(pango::EllipsizeMode::Middle);
2591 row.append(&name);
2592 if !entry.is_dir {
2593 let size = gtk::Label::new(Some(&format_size(entry.size)));
2594 size.add_css_class("tree-preview-archive-size");
2595 size.set_xalign(1.0);
2596 row.append(&size);
2597 }
2598 list.append(&row);
2599 }
2600 if listing.more {
2601 let more = gtk::Label::new(Some("…"));
2602 more.add_css_class("tree-preview-status");
2603 more.set_xalign(0.0);
2604 list.append(&more);
2605 }
2606 }
2607 }
2608 container.append(&list);
2609}
2610
2611impl Tree {
2612 fn open_root(&mut self, path: &Path, sender: &ComponentSender<Self>) {
2613 if let Some(id) = self.flush_source.take() {
2614 id.remove();
2615 }
2616 self.pending.clear();
2617
2618 let mut tree = TreeModel::new(
2619 path.to_path_buf(),
2620 self.config.sort_options(),
2621 self.config.show_hidden,
2622 );
2623 if let Err(err) = tree.expand(path, &StdDirSource) {
2624 self.status(format!("Could not open {}: {err}", path.display()), sender);
2625 return;
2626 }
2627 self.tree = Some(tree);
2628 self.refresh_rows();
2629 self.cursor = if self.rows.is_empty() { None } else { Some(0) };
2630 self.selected = Vec::new();
2631 self.renaming = None;
2632 self.renaming_state.set(false);
2633 self.rename_entry = None;
2634 self.menu_target = None;
2635 self.thumbnails.clear();
2636 self.preview_kinds.clear();
2637 self.documents.clear();
2638 self.thumb_cache.clear();
2639 self.playing.clear();
2640 self.stop_all_videos();
2641 self.media.clear();
2642 self.audio.clear();
2643 self.gif_anims.borrow_mut().clear();
2644 self.gif_widgets.borrow_mut().clear();
2645 self.audio_widgets.borrow_mut().clear();
2646 if let Some(previous) = self.popover.take() {
2647 previous.unparent();
2648 }
2649
2650 let root = path.to_path_buf();
2651 let watcher_sender = sender.clone();
2652 self._watcher = spawn_watcher(path, move |change| {
2653 watcher_sender.input(TreeMsg::FsChange(change));
2654 })
2655 .ok();
2656 self.watched.clear();
2657 if self._watcher.is_some() {
2658 self.watched.insert(root.clone());
2659 }
2660
2661 let _ = sender.output(TreeOutput::RootChanged(root));
2662 }
2663
2664 fn refresh_rows(&mut self) {
2665 if let Some(tree) = self.tree.as_ref() {
2666 tree.visible_rows_into(&mut self.rows);
2667 } else {
2668 self.rows.clear();
2669 }
2670 }
2671
2672 fn reconcile_watches(&mut self) {
2678 let Some(watcher) = self._watcher.as_mut() else {
2679 return;
2680 };
2681 let Some(model) = self.tree.as_ref() else {
2682 return;
2683 };
2684 let desired: HashSet<PathBuf> = model.expanded_loaded_dirs().into_iter().collect();
2685
2686 let stale: Vec<PathBuf> = self.watched.difference(&desired).cloned().collect();
2687 for path in stale {
2688 let _ = watcher.unwatch(&path);
2689 self.watched.remove(&path);
2690 }
2691
2692 let fresh: Vec<PathBuf> = desired.difference(&self.watched).cloned().collect();
2693 for path in fresh {
2694 if watcher.watch(&path, RecursiveMode::NonRecursive).is_ok() {
2695 self.watched.insert(path);
2696 }
2697 }
2698 }
2699
2700 fn cursor_path(&self) -> Option<PathBuf> {
2701 self.cursor.and_then(|i| self.rows.get(i)).map(|r| r.path.clone())
2702 }
2703
2704 fn cursor_delta(&mut self, delta: isize) {
2705 if self.rows.is_empty() {
2706 return;
2707 }
2708 let next = (self.cursor.unwrap_or(0) as isize + delta).clamp(0, self.rows.len() as isize - 1) as usize;
2709 let path = self.rows[next].path.clone();
2710 self.cursor = Some(next);
2711 self.set_single_selection(path);
2712 self.selection_dirty = true;
2713 }
2714
2715 fn cursor_extend(&mut self, delta: isize, _keep_anchor: bool) {
2722 if self.rows.is_empty() {
2723 return;
2724 }
2725 let anchor = self
2726 .anchor
2727 .clone()
2728 .or_else(|| self.cursor.map(|i| self.rows[i].path.clone()))
2729 .unwrap_or_else(|| self.rows[0].path.clone());
2730 let next = (self.cursor.unwrap_or(0) as isize + delta)
2731 .clamp(0, self.rows.len() as isize - 1) as usize;
2732 self.cursor = Some(next);
2733 self.anchor = Some(anchor.clone());
2734 let (Some(a), Some(b)) = (self.rows.iter().position(|r| r.path == anchor), Some(next)) else {
2735 return;
2736 };
2737 self.selected = self.rows[index_range(a, b)].iter().map(|r| r.path.clone()).collect();
2738 self.scroll_to = Some(next);
2739 self.selection_dirty = true;
2740 }
2741
2742 fn cursor_snap(&mut self) {
2743 let deselected = self.cursor.is_none();
2744 let prev = self.cursor_path();
2745 self.refresh_rows();
2746 if self.rows.is_empty() {
2747 self.cursor = None;
2748 self.selected.clear();
2749 return;
2750 }
2751 if deselected {
2752 self.cursor = None;
2754 return;
2755 }
2756 let index = prev
2757 .and_then(|p| self.rows.iter().position(|r| r.path == p))
2758 .unwrap_or(0);
2759 self.cursor = Some(index);
2760 }
2761
2762 fn select(&mut self, path: &Path) {
2763 if let Some(i) = self.rows.iter().position(|r| r.path == path) {
2764 self.cursor = Some(i);
2765 }
2766 self.selected = vec![path.to_path_buf()];
2767 }
2768
2769 fn press_default(&mut self, path: &Path, is_dir: bool, sender: &ComponentSender<Self>) {
2774 let now = std::time::Instant::now();
2777 let is_double = self.last_press.as_ref().is_some_and(|(last, at)| {
2778 last == path && now.duration_since(*at) < Duration::from_millis(400)
2779 });
2780 self.last_press = Some((path.to_path_buf(), now));
2781
2782 self.select(path);
2783 self.anchor = Some(path.to_path_buf());
2784 if is_dir {
2785 self.toggle_dir(path, sender);
2789 } else if is_double {
2790 self.activate(path, sender);
2792 }
2793 }
2794
2795 fn reveal_path(&mut self, path: PathBuf) {
2800 if let Some(i) = self.rows.iter().position(|r| r.path == path) {
2801 self.cursor = Some(i);
2802 self.selected = vec![path.clone()];
2803 self.anchor = Some(path);
2804 self.selection_dirty = true;
2805 self.scroll_to = Some(i);
2806 }
2807 }
2808
2809 fn range_select(&mut self, path: &Path) {
2813 let Some(anchor) = self.anchor.clone() else {
2814 self.select(path);
2815 self.anchor = Some(path.to_path_buf());
2816 return;
2817 };
2818 let (Some(a), Some(b)) = (
2819 self.rows.iter().position(|r| r.path == anchor),
2820 self.rows.iter().position(|r| r.path == path),
2821 ) else {
2822 self.select(path);
2823 return;
2824 };
2825 self.selected = self.rows[index_range(a, b)].iter().map(|r| r.path.clone()).collect();
2826 self.cursor = Some(b);
2827 }
2828
2829 fn set_single_selection(&mut self, path: PathBuf) {
2830 self.selected = vec![path.clone()];
2831 if let Some(i) = self.rows.iter().position(|r| r.path == path) {
2832 self.cursor = Some(i);
2833 }
2834 }
2835
2836 fn selected_for_clipboard(&self) -> Vec<PathBuf> {
2837 if self.selected.is_empty() {
2838 if let Some(path) = self.cursor_path() {
2839 return vec![path];
2840 }
2841 return Vec::new();
2842 }
2843 self.selected.clone()
2844 }
2845
2846 fn collapse_dir(&mut self, path: &Path) {
2849 if let Some(tree) = self.tree.as_mut() {
2850 tree.collapse(path);
2851 }
2852 self.clear_thumbnails_under(path);
2853 self.cursor_snap();
2854 }
2855
2856 fn toggle_dir(&mut self, path: &Path, sender: &ComponentSender<Self>) {
2858 let expanded = self.tree.as_ref().is_some_and(|t| t.is_expanded(path));
2859 if expanded {
2860 self.collapse_dir(path);
2861 } else {
2862 self.expand_dir(path, sender);
2863 }
2864 }
2865
2866 fn shutdown(&mut self) {
2873 if let Some(id) = self.gif_tick.borrow_mut().take() {
2874 id.remove();
2875 }
2876 if let Some(id) = self.audio_tick.borrow_mut().take() {
2877 id.remove();
2878 }
2879 self.gif_anims.borrow_mut().clear();
2880 self.gif_widgets.borrow_mut().clear();
2881 self.audio_widgets.borrow_mut().clear();
2882 self.playing.clear();
2883 self.thumbnails.clear();
2884 self.stop_all_videos();
2885 self.media.clear();
2886 self.audio.clear();
2888 }
2889
2890 fn clear_thumbnails_under(&mut self, prefix: &Path) {
2895 self.thumbnails.retain(|path| !path.starts_with(prefix));
2896 self.preview_kinds.retain(|path, _| !path.starts_with(prefix));
2897 self.documents.retain(|path, _| !path.starts_with(prefix));
2898 self.thumb_cache.retain(|path, _| !path.starts_with(prefix));
2899 self.playing.retain(|path| !path.starts_with(prefix));
2900 self.stop_videos_under(prefix);
2901 self.media.retain(|path, _| !path.starts_with(prefix));
2902 self.gif_anims.borrow_mut().retain(|path, _| !path.starts_with(prefix));
2903 self.gif_widgets.borrow_mut().retain(|path, _| !path.starts_with(prefix));
2904 self.audio_widgets.borrow_mut().retain(|path, _| !path.starts_with(prefix));
2905 self.audio.retain(|path, _| !path.starts_with(prefix));
2907 }
2908
2909 fn stop_videos_under(&mut self, prefix: &Path) {
2913 self.video_widgets.retain(|path, video| {
2914 if path.starts_with(prefix) {
2915 video.set_media_stream(None::<>k::MediaStream>);
2916 false
2917 } else {
2918 true
2919 }
2920 });
2921 }
2922
2923 fn stop_all_videos(&mut self) {
2925 for video in self.video_widgets.values() {
2926 video.set_media_stream(None::<>k::MediaStream>);
2927 }
2928 self.video_widgets.clear();
2929 }
2930
2931 fn retain_visible_media(&mut self, visible: &HashSet<PathBuf>) {
2937 self.video_widgets.retain(|path, video| {
2938 if visible.contains(path) {
2939 true
2940 } else {
2941 video.set_media_stream(None::<>k::MediaStream>);
2942 false
2943 }
2944 });
2945 self.media.retain(|path, _| visible.contains(path));
2946 self.playing.retain(|path| visible.contains(path));
2947 self.gif_anims.borrow_mut().retain(|path, _| visible.contains(path));
2948 self.audio.retain(|path, _| visible.contains(path));
2950 }
2951
2952 fn wants_preview(&self, row: &VisibleRow) -> bool {
2956 if row.is_dir {
2957 return false;
2958 }
2959 self.thumbnails.contains(&row.path)
2960 || row
2961 .path
2962 .ancestors()
2963 .skip(1)
2964 .any(|ancestor| self.thumbnails.contains(ancestor))
2965 }
2966
2967 fn preview_kind(&mut self, path: &Path) -> Option<PreviewKind> {
2970 if let Some(kind) = self.preview_kinds.get(path) {
2971 return *kind;
2972 }
2973 let kind = preview::detect(path);
2974 self.preview_kinds.insert(path.to_path_buf(), kind);
2975 kind
2976 }
2977
2978 fn static_texture(&mut self, path: &Path, kind: PreviewKind) -> Option<gdk::Texture> {
2981 if let Some(texture) = self.thumb_cache.get(path) {
2982 return Some(texture.clone());
2983 }
2984 let texture = match kind {
2985 PreviewKind::Image => gdk::Texture::from_filename(path).ok()?,
2986 PreviewKind::Gif => {
2987 let animation = gdk::gdk_pixbuf::PixbufAnimation::from_file(path).ok()?;
2988 let pixbuf = animation.static_image()?;
2989 gdk::Texture::for_pixbuf(&pixbuf)
2990 }
2991 _ => return None,
2993 };
2994 self.thumb_cache.insert(path.to_path_buf(), texture.clone());
2995 Some(texture)
2996 }
2997
2998 fn media_file(&mut self, path: &Path) -> Option<gtk::MediaFile> {
3003 if let Some(media) = self.media.get(path) {
3004 return Some(media.clone());
3005 }
3006 let media = gtk::MediaFile::for_filename(path);
3007 media.set_loop(true);
3008 media.set_muted(false);
3013 media.set_volume(1.0);
3014 self.media.insert(path.to_path_buf(), media.clone());
3015 Some(media)
3016 }
3017
3018 fn audio_player(&mut self, path: &Path) -> Option<Rc<AudioPlayer>> {
3021 if let Some(player) = self.audio.get(path) {
3022 return Some(player.clone());
3023 }
3024 let player = AudioPlayer::new(path)?;
3025 self.audio.insert(path.to_path_buf(), player.clone());
3026 Some(player)
3027 }
3028
3029 fn toggle_thumbnail_play(&mut self, path: &Path) {
3032 if self.preview_kind(path) != Some(PreviewKind::Gif) {
3033 return;
3034 }
3035 if self.playing.remove(path) {
3036 self.gif_anims.borrow_mut().remove(path);
3037 } else {
3038 let Ok(animation) = gdk::gdk_pixbuf::PixbufAnimation::from_file(path) else {
3039 return;
3040 };
3041 let iter = animation.iter(Some(SystemTime::now()));
3042 self.gif_anims.borrow_mut().insert(path.to_path_buf(), GifAnim { iter });
3043 self.playing.insert(path.to_path_buf());
3044 self.ensure_gif_ticker();
3045 }
3046 }
3047
3048 fn ensure_gif_ticker(&mut self) {
3052 if self.gif_tick.borrow().is_some() {
3053 return;
3054 }
3055 let anims = self.gif_anims.clone();
3056 let widgets = self.gif_widgets.clone();
3057 let tick = self.gif_tick.clone();
3058 let id = glib::timeout_add_local(Duration::from_millis(33), move || {
3059 let mut anims = anims.borrow_mut();
3060 if anims.is_empty() {
3061 *tick.borrow_mut() = None;
3062 return glib::ControlFlow::Break;
3063 }
3064 let now = SystemTime::now();
3065 let widgets = widgets.borrow();
3066 for (path, anim) in anims.iter_mut() {
3067 let _ = anim.iter.advance(now);
3068 if let Some(picture) = widgets.get(path) {
3069 let pixbuf = anim.iter.pixbuf();
3070 picture.set_paintable(Some(&gdk::Texture::for_pixbuf(&pixbuf)));
3071 }
3072 }
3073 glib::ControlFlow::Continue
3074 });
3075 *self.gif_tick.borrow_mut() = Some(id);
3076 }
3077
3078 fn ensure_audio_ticker(&mut self) {
3081 if self.audio_tick.borrow().is_some() {
3082 return;
3083 }
3084 let players = self.audio_widgets.clone();
3085 let tick = self.audio_tick.clone();
3086 let id = glib::timeout_add_local(Duration::from_millis(200), move || {
3087 let players = players.borrow();
3088 if players.is_empty() {
3089 *tick.borrow_mut() = None;
3090 return glib::ControlFlow::Break;
3091 }
3092 for widgets in players.values() {
3093 let Some(player) = widgets.player.upgrade() else {
3096 continue;
3097 };
3098 let position = player.position();
3099 let duration = player.duration();
3100 if duration > 0
3101 && (widgets.seek.adjustment().upper() - duration as f64).abs() > 0.5
3102 {
3103 widgets.seek.adjustment().set_upper(duration as f64);
3104 }
3105 if !widgets.seeking.get() {
3106 widgets.seek.set_value(position as f64);
3107 }
3108 widgets.time.set_text(&format!(
3109 "{} / {}",
3110 format_time(position),
3111 format_time(duration)
3112 ));
3113 widgets.play.set_icon_name(if player.is_playing() {
3114 "media-playback-pause-symbolic"
3115 } else {
3116 "media-playback-start-symbolic"
3117 });
3118 }
3119 glib::ControlFlow::Continue
3120 });
3121 *self.audio_tick.borrow_mut() = Some(id);
3122 }
3123
3124 fn append_preview(
3128 &mut self,
3129 path: &Path,
3130 row_indent: i32,
3131 container: >k::Box,
3132 sender: &ComponentSender<Self>,
3133 ) {
3134 let Some(kind) = self.preview_kind(path) else {
3135 return;
3136 };
3137 let label_start = row_indent + self.config.icon_size as i32 + 6;
3138 if kind.is_media() {
3139 let available = self.content_width - label_start - 6;
3140 self.append_media(path, kind, label_start, available, container, sender);
3141 } else {
3142 self.append_document(path, kind, label_start, container);
3143 }
3144 }
3145
3146 fn append_media(
3148 &mut self,
3149 path: &Path,
3150 kind: PreviewKind,
3151 label_start: i32,
3152 available: i32,
3153 container: >k::Box,
3154 sender: &ComponentSender<Self>,
3155 ) {
3156 match kind {
3157 PreviewKind::Image => {
3158 if let Some(texture) = self.static_texture(path, kind) {
3159 let (width, height) = thumbnail_size(&texture, available);
3160 container.append(&thumbnail_picture(&texture, label_start, width, height));
3161 }
3162 }
3163 PreviewKind::Gif => {
3164 let playing = self.playing.contains(path);
3166 let texture = if playing {
3167 self.gif_anims
3168 .borrow()
3169 .get(path)
3170 .map(|anim| gdk::Texture::for_pixbuf(&anim.iter.pixbuf()))
3171 } else {
3172 self.static_texture(path, kind)
3173 };
3174 if let Some(texture) = texture {
3175 let (width, height) = thumbnail_size(&texture, available);
3176 let picture = thumbnail_picture(&texture, label_start, width, height);
3177 picture.set_tooltip_text(Some("Click to play"));
3178 if playing {
3179 self.gif_widgets
3180 .borrow_mut()
3181 .insert(path.to_path_buf(), picture.clone());
3182 }
3183 let msg = TreeMsg::ToggleThumbnailPlay(path.to_path_buf());
3184 let s = sender.clone();
3185 let click = gtk::GestureClick::new();
3186 click.set_button(1);
3187 click.connect_pressed(move |_, _, _, _| s.input(msg.clone()));
3188 picture.add_controller(click);
3189 container.append(&picture);
3190 }
3191 }
3192 PreviewKind::Video => {
3193 if let Some(media) = self.media_file(path) {
3194 let video = gtk::Video::new();
3200 video.set_autoplay(false);
3201 video.set_media_stream(Some(&media));
3202 video.set_loop(true);
3203 video.add_css_class("tree-thumbnail");
3204 video.set_halign(gtk::Align::Start);
3205 let width = available.max(64);
3206 video.set_size_request(width, (width * 9 / 16).max(36));
3207 video.set_margin_start(label_start);
3208 video.set_margin_bottom(6);
3209 self.video_widgets.insert(path.to_path_buf(), video.clone());
3210 container.append(&video);
3211 }
3212 }
3213 PreviewKind::Audio => {
3214 if let Some(player) = self.audio_player(path) {
3215 let (controls, widgets) = build_audio_player(&player, label_start);
3219 self.audio_widgets.borrow_mut().insert(path.to_path_buf(), widgets);
3220 container.append(&controls);
3221 self.ensure_audio_ticker();
3222 }
3223 }
3224 PreviewKind::Text
3226 | PreviewKind::Csv
3227 | PreviewKind::Json
3228 | PreviewKind::Toml
3229 | PreviewKind::Yaml
3230 | PreviewKind::Archive => {}
3231 }
3232 }
3233
3234 fn append_document(
3237 &mut self,
3238 path: &Path,
3239 kind: PreviewKind,
3240 label_start: i32,
3241 container: >k::Box,
3242 ) {
3243 if !self.documents.contains_key(path) {
3244 let data = preview::load_document(path, kind);
3245 self.documents.insert(path.to_path_buf(), data);
3246 }
3247 let Some(Some(data)) = self.documents.get(path) else {
3248 return;
3249 };
3250 match data {
3251 DocumentData::Lines(lines) => append_text_lines(container, label_start, lines),
3252 DocumentData::Structured { lines, status } => {
3253 append_text_lines(container, label_start, lines);
3254 append_status(container, label_start, status);
3255 }
3256 DocumentData::Table(table) => append_table(container, label_start, table),
3257 DocumentData::Archive(archive) => append_archive(container, label_start, archive),
3258 }
3259 }
3260
3261 fn expand_dir(&mut self, path: &Path, sender: &ComponentSender<Self>) {
3262 let Some(tree) = self.tree.as_mut() else {
3263 return;
3264 };
3265 if let Err(err) = tree.expand(path, &StdDirSource) {
3266 self.status(format!("Could not open {}: {err}", path.display()), sender);
3267 }
3268 self.refresh_rows();
3269 }
3270
3271 fn activate(&mut self, path: &Path, sender: &ComponentSender<Self>) {
3272 let Some(index) = self.rows.iter().position(|r| r.path == path) else {
3273 return;
3274 };
3275 if self.rows[index].is_dir {
3276 self.open_root(path, sender);
3280 } else if let Err(err) = open_in_app(path) {
3281 self.status(err, sender);
3282 }
3283 }
3284
3285 fn confirm_trash(&mut self, paths: Vec<PathBuf>, sender: ComponentSender<Self>) {
3286 let message = match paths.as_slice() {
3287 [single] => {
3288 let name = single
3289 .file_name()
3290 .map(|n| n.to_string_lossy().into_owned())
3291 .unwrap_or_else(|| single.display().to_string());
3292 format!("Move \u{201c}{name}\u{201d} to trash?")
3293 }
3294 many => format!("Move {} items to trash?", many.len()),
3295 };
3296 let dialog = gtk::AlertDialog::builder()
3297 .modal(true)
3298 .message(message)
3299 .detail("The panel cannot restore trashed items.")
3300 .buttons(["Cancel", "Trash"])
3301 .cancel_button(0)
3302 .default_button(1)
3303 .build();
3304 dialog.choose(Some(&self.parent), None::<&gio::Cancellable>, move |res| {
3305 if matches!(res, Ok(1)) {
3306 sender.input(TreeMsg::ConfirmTrash(paths));
3307 }
3308 });
3309 }
3310
3311 fn confirm_permanent_delete(&mut self, paths: Vec<PathBuf>, sender: ComponentSender<Self>) {
3312 let message = match paths.as_slice() {
3313 [single] => {
3314 let name = single
3315 .file_name()
3316 .map(|n| n.to_string_lossy().into_owned())
3317 .unwrap_or_else(|| single.display().to_string());
3318 format!("Permanently delete \u{201c}{name}\u{201d}?")
3319 }
3320 many => format!("Permanently delete {} items?", many.len()),
3321 };
3322 let dialog = gtk::AlertDialog::builder()
3323 .modal(true)
3324 .message(message)
3325 .detail("This cannot be undone. Files will not go to the trash.")
3326 .buttons(["Cancel", "Delete Permanently"])
3327 .cancel_button(0)
3328 .default_button(1)
3329 .build();
3330 dialog.choose(Some(&self.parent), None::<&gio::Cancellable>, move |res| {
3331 if matches!(res, Ok(1)) {
3332 sender.input(TreeMsg::ConfirmPermanentDelete(paths));
3333 }
3334 });
3335 }
3336
3337 fn open_menu(&mut self, path: &Path) {
3338 let in_selection = self.selected.len() > 1 && self.selected.iter().any(|p| p == path);
3342 if !in_selection {
3343 self.select(path);
3344 } else if let Some(i) = self.rows.iter().position(|r| r.path == path) {
3345 self.cursor = Some(i);
3346 }
3347 if let Some(previous) = self.popover.take() {
3348 previous.unparent();
3349 }
3350 self.menu_target = Some(path.to_path_buf());
3351 self.menu_point = None;
3352 }
3353
3354 fn open_menu_at(&mut self, path: &Path, at: (f64, f64)) {
3358 if let Some(previous) = self.popover.take() {
3359 previous.unparent();
3360 }
3361 self.menu_target = Some(path.to_path_buf());
3362 self.menu_point = Some(at);
3363 }
3364
3365 fn paste(&mut self, sender: &ComponentSender<Self>) {
3366 let Some(clip) = self.clipboard.clone() else {
3367 self.paste_from_system_clipboard(sender);
3370 return;
3371 };
3372 let Some(dir) = self.target_dir() else {
3373 return;
3374 };
3375 let mut count = 0usize;
3376 let mut failed: Option<String> = None;
3377 for src in clip.paths {
3378 if src == dir {
3379 continue;
3380 }
3381 let result = match clip.op {
3382 ClipboardOp::Copy => self.ops.copy(&src, &dir),
3383 ClipboardOp::Cut => self.ops.move_(&src, &dir),
3384 };
3385 match result {
3386 Ok(dest) => {
3387 count += 1;
3388 self.apply_change(Change::Created { path: dest });
3389 if clip.op == ClipboardOp::Cut {
3390 self.apply_change(Change::Removed { path: src });
3391 }
3392 }
3393 Err(err) => {
3394 failed = Some(err.to_string());
3395 break;
3396 }
3397 }
3398 }
3399 if let Some(failed) = failed {
3400 self.status(failed, sender);
3401 return;
3402 }
3403 let verb = match clip.op {
3404 ClipboardOp::Copy => "Copied",
3405 ClipboardOp::Cut => "Moved",
3406 };
3407 self.status(format!("{verb} {count} item(s) into {}", dir.display()), sender);
3408 if clip.op == ClipboardOp::Cut {
3409 self.clipboard = None;
3410 }
3411 self.cursor_snap();
3412 }
3413
3414 fn paste_from_system_clipboard(&mut self, sender: &ComponentSender<Self>) {
3418 if self.target_dir().is_none() {
3419 return;
3420 }
3421 let s = sender.clone();
3422 read_clipboard_files(move |paths| s.input(TreeMsg::PastePaths(paths)));
3423 }
3424
3425 fn paste_paths(&mut self, paths: Vec<PathBuf>, sender: &ComponentSender<Self>) {
3427 let Some(dir) = self.target_dir() else {
3428 return;
3429 };
3430 if paths.is_empty() {
3431 self.status("Clipboard has no files to paste".to_string(), sender);
3432 return;
3433 }
3434 let mut count = 0usize;
3435 let mut failed: Option<String> = None;
3436 for src in paths {
3437 if src == dir {
3438 continue;
3439 }
3440 match self.ops.copy(&src, &dir) {
3441 Ok(dest) => {
3442 count += 1;
3443 self.apply_change(Change::Created { path: dest });
3444 }
3445 Err(err) => {
3446 failed = Some(err.to_string());
3447 break;
3448 }
3449 }
3450 }
3451 if let Some(failed) = failed {
3452 self.status(failed, sender);
3453 return;
3454 }
3455 self.status(format!("Pasted {count} item(s) into {}", dir.display()), sender);
3456 self.cursor_snap();
3457 }
3458
3459 fn request_drop_into(
3464 &mut self,
3465 target: PathBuf,
3466 sources: Vec<PathBuf>,
3467 copy: bool,
3468 sender: &ComponentSender<Self>,
3469 ) {
3470 match resolve_drop(&target, sources, copy) {
3471 DropPlan::Ignore => {}
3472 DropPlan::Reject(message) => self.status(message, sender),
3473 DropPlan::Copy(sources) => self.drop_into(&target, &sources, true, sender),
3474 DropPlan::Move(sources) => {
3475 if self.config.confirm_drop_move {
3476 self.confirm_move(target, sources, sender);
3477 } else {
3478 self.drop_into(&target, &sources, false, sender);
3479 }
3480 }
3481 }
3482 }
3483
3484 fn confirm_move(
3488 &mut self,
3489 target: PathBuf,
3490 sources: Vec<PathBuf>,
3491 sender: &ComponentSender<Self>,
3492 ) {
3493 let target_name = target
3494 .file_name()
3495 .map(|n| n.to_string_lossy().into_owned())
3496 .unwrap_or_else(|| target.display().to_string());
3497 let message = match sources.as_slice() {
3498 [single] => {
3499 let name = single
3500 .file_name()
3501 .map(|n| n.to_string_lossy().into_owned())
3502 .unwrap_or_else(|| single.display().to_string());
3503 format!("Move \u{201c}{name}\u{201d} into \u{201c}{target_name}\u{201d}?")
3504 }
3505 many => format!("Move {} items into \u{201c}{target_name}\u{201d}?", many.len()),
3506 };
3507 let dialog = gtk::AlertDialog::builder()
3508 .modal(true)
3509 .message(message)
3510 .detail("Hold Ctrl while dropping to copy instead of move.")
3511 .buttons(["Cancel", "Move"])
3512 .cancel_button(0)
3513 .default_button(1)
3514 .build();
3515 let s = sender.clone();
3516 dialog.choose(Some(&self.parent), None::<&gio::Cancellable>, move |res| {
3517 if matches!(res, Ok(1)) {
3518 s.input(TreeMsg::DropIntoConfirmed { target, sources });
3519 }
3520 });
3521 }
3522
3523 fn drop_into(
3527 &mut self,
3528 target: &Path,
3529 sources: &[PathBuf],
3530 copy: bool,
3531 sender: &ComponentSender<Self>,
3532 ) {
3533 let mut count = 0usize;
3534 let mut failed: Option<String> = None;
3535 for src in sources {
3536 if src == target || src.parent() == Some(target) {
3537 continue; }
3539 if !copy && target.starts_with(src) {
3540 failed = Some("Cannot move a folder into itself".to_string());
3541 break;
3542 }
3543 let result = if copy { self.ops.copy(src, target) } else { self.ops.move_(src, target) };
3544 match result {
3545 Ok(dest) => {
3546 count += 1;
3547 self.apply_change(Change::Created { path: dest });
3548 if !copy {
3549 self.apply_change(Change::Removed { path: src.clone() });
3550 }
3551 }
3552 Err(err) => {
3553 failed = Some(err.to_string());
3554 break;
3555 }
3556 }
3557 }
3558 if let Some(failed) = failed {
3559 self.status(failed, sender);
3560 return;
3561 }
3562 if count > 0 {
3563 let verb = if copy { "Copied" } else { "Moved" };
3564 self.status(format!("{verb} {count} item(s) into {}", target.display()), sender);
3565 }
3566 self.cursor_snap();
3567 }
3568
3569 fn create_entry(&mut self, is_dir: bool, sender: &ComponentSender<Self>) {
3570 let Some(dir) = self.target_dir() else {
3571 return;
3572 };
3573 let name = fresh_name(&dir, if is_dir { "new folder" } else { "new file" });
3574 let result = if is_dir {
3575 self.ops.create_dir(&dir, &name)
3576 } else {
3577 self.ops.create_file(&dir, &name)
3578 };
3579 match result {
3580 Ok(new_path) => {
3581 self.apply_change(Change::Created { path: new_path.clone() });
3582 self.renaming = Some(new_path.clone());
3583 self.renaming_state.set(true);
3584 self.select(&new_path);
3585 }
3586 Err(err) => self.status(format!("Could not create: {err}"), sender),
3587 }
3588 }
3589
3590 fn target_dir(&self) -> Option<PathBuf> {
3593 if let Some(path) = self.cursor_path() {
3594 let is_dir = self.rows.iter().find(|r| r.path == path).map(|r| r.is_dir).unwrap_or(false);
3595 if is_dir {
3596 return Some(path);
3597 }
3598 return path.parent().map(Path::to_path_buf);
3599 }
3600 self.tree.as_ref().map(|t| t.root().to_path_buf())
3601 }
3602
3603 fn apply_change(&mut self, change: Change) {
3604 if let Some(tree) = self.tree.as_mut() {
3605 tree.apply(&change, &StdDirSource);
3606 self.refresh_rows();
3607 }
3608 }
3609
3610 fn toggle_hidden(&mut self, sender: &ComponentSender<Self>) {
3613 let show = !self.config.show_hidden;
3614 self.config.show_hidden = show;
3615 if let Some(tree) = self.tree.as_mut() {
3616 tree.set_show_hidden(show);
3617 tree.reload_all(&StdDirSource);
3618 self.refresh_rows();
3619 }
3620 let label = if show { "Showing hidden files." } else { "Hiding hidden files." };
3621 self.status(label.to_string(), sender);
3622 }
3623
3624 fn set_sort_key(&mut self, key: SortKey, sender: &ComponentSender<Self>) {
3626 let mut opts = self.config.sort_options();
3627 opts.key = key;
3628 self.config.sort_key = key.as_str().to_owned();
3629 if let Some(tree) = self.tree.as_mut() {
3630 tree.set_sort(opts);
3631 tree.reload_all(&StdDirSource);
3632 self.refresh_rows();
3633 }
3634 self.status(format!("Sorting by {}.", key.as_str()), sender);
3635 }
3636
3637 fn toggle_sort_direction(&mut self, sender: &ComponentSender<Self>) {
3639 let mut opts = self.config.sort_options();
3640 opts.ascending = !opts.ascending;
3641 self.config.sort_ascending = opts.ascending;
3642 if let Some(tree) = self.tree.as_mut() {
3643 tree.set_sort(opts);
3644 tree.reload_all(&StdDirSource);
3645 self.refresh_rows();
3646 }
3647 let dir = if opts.ascending { "ascending" } else { "descending" };
3648 self.status(format!("Sort order: {dir}."), sender);
3649 }
3650
3651 fn status(&mut self, message: String, sender: &ComponentSender<Self>) {
3652 let _ = sender.output(TreeOutput::Status(message));
3653 }
3654
3655 fn run_shortcut(&mut self, target: ShortcutTarget, sender: &ComponentSender<Self>) {
3660 if let ShortcutTarget::Builtin(builtin) = &target
3663 && builtin.is_pane_action()
3664 {
3665 let _ = sender.output(TreeOutput::PaneAction(*builtin));
3666 return;
3667 }
3668 let has_selection = self.cursor.is_some() || !self.selected.is_empty();
3672 let path = match self.cursor_path() {
3673 Some(path) => path,
3674 None => match self.tree.as_ref().map(|t| t.root().to_path_buf()) {
3675 Some(root) => root,
3676 None => return,
3677 },
3678 };
3679
3680 if let ShortcutTarget::Command(cmd) = &target {
3682 if !has_selection {
3683 return;
3684 }
3685 let command = action_command(&cmd.command, &path);
3686 match spawn_command(&command) {
3687 Ok(()) => {
3688 self.status(format!("Ran {} on {}", cmd.command, path.display()), sender);
3689 }
3690 Err(err) => {
3691 self.status(format!("Could not run {}: {err}", cmd.command), sender);
3692 }
3693 }
3694 return;
3695 }
3696 let ShortcutTarget::Builtin(builtin) = target else {
3697 return;
3698 };
3699 if builtin == BuiltinAction::Separator
3700 || (builtin.is_directory_only() && !path.is_dir())
3701 {
3702 return;
3703 }
3704 if matches!(builtin, BuiltinAction::OpenWithDefault) && path.is_dir() {
3705 return;
3706 }
3707 if builtin == BuiltinAction::ViewThumbnail
3708 && !path.is_dir()
3709 && preview::detect(&path).is_none()
3710 {
3711 return;
3712 }
3713 if !has_selection && needs_selection(builtin) {
3715 return;
3716 }
3717 if has_selection && !self.selected.iter().any(|p| p == &path) {
3721 self.select(&path);
3722 }
3723 let selection = self.selected.clone();
3726 sender.input(action_message(builtin, &path, &selection));
3727 }
3728}
3729
3730fn fresh_name(dir: &Path, stem: &str) -> String {
3735 for n in 0.. {
3736 let name = if n == 0 { stem.to_string() } else { format!("{stem} {n}") };
3737 if !dir.join(&name).exists() {
3738 return name;
3739 }
3740 }
3741 unreachable!("the loop above always finds a free name")
3742}
3743
3744fn open_in_app(path: &Path) -> Result<String, String> {
3753 let content_type = content_type_for(path)
3754 .ok_or_else(|| format!("Could not determine the type of {}", path.display()))?;
3755 let app = gio::AppInfo::default_for_type(&content_type, false)
3756 .ok_or_else(|| format!("No application is registered to open {}", path.display()))?;
3757 let file = gio::File::for_path(path);
3758 app.launch(&[file], None::<&gio::AppLaunchContext>)
3759 .map_err(|err| format!("Could not open {}: {err}", path.display()))?;
3760 Ok(app.display_name().to_string())
3761}
3762
3763fn content_type_for(path: &Path) -> Option<glib::GString> {
3770 let file = gio::File::for_path(path);
3771 let info = file
3772 .query_info(
3773 gio::FILE_ATTRIBUTE_STANDARD_CONTENT_TYPE,
3774 gio::FileQueryInfoFlags::NONE,
3775 None::<&gio::Cancellable>,
3776 )
3777 .ok()?;
3778 info.content_type()
3779}
3780
3781fn default_app_label(path: &Path) -> Option<String> {
3785 let content_type = content_type_for(path)?;
3786 gio::AppInfo::default_for_type(&content_type, false).map(|app| {
3787 format!("Open With {}", app.display_name())
3788 })
3789}
3790
3791#[allow(deprecated)] fn open_with_dialog(parent: >k::Window, path: &Path, sender: &ComponentSender<Tree>) {
3795 let Some(content_type) = content_type_for(path) else {
3796 let _ = sender.output(TreeOutput::Status(format!(
3797 "Could not determine the type of {}",
3798 path.display()
3799 )));
3800 return;
3801 };
3802 let dialog = gtk::AppChooserDialog::for_content_type(
3803 Some(parent),
3804 gtk::DialogFlags::MODAL,
3805 &content_type,
3806 );
3807 dialog.set_title(Some(&format!("Open {} with...", path.display())));
3808
3809 if gtk4_layer_shell::is_supported() && !dialog.is_layer_window() {
3815 dialog.init_layer_shell();
3816 dialog.set_layer(Layer::Overlay);
3817 dialog.set_keyboard_mode(KeyboardMode::Exclusive);
3818 dialog.set_default_size(540, 620);
3819 }
3821
3822 let dialog = dialog.clone();
3823 let path = path.to_path_buf();
3824 let s = sender.clone();
3825 dialog.connect_response(move |dialog, response| {
3826 if response == gtk::ResponseType::Ok
3827 && let Some(app) = dialog.app_info()
3828 {
3829 let file = gio::File::for_path(&path);
3830 match app.launch(&[file], None::<&gio::AppLaunchContext>) {
3831 Ok(()) => {
3832 let _ = s.output(TreeOutput::Status(format!(
3833 "Opened {} with {}",
3834 path.display(),
3835 app.display_name()
3836 )));
3837 }
3838 Err(err) => {
3839 let _ = s.output(TreeOutput::Status(format!(
3840 "Could not open {} with {}: {err}",
3841 path.display(),
3842 app.display_name()
3843 )));
3844 }
3845 }
3846 }
3847 dialog.close();
3848 });
3849 dialog.present();
3850}
3851
3852fn set_clipboard_text(text: &str) {
3854 if let Some(display) = gdk::Display::default() {
3855 display.clipboard().set_text(text);
3856 }
3857}
3858
3859const URI_LIST_MIME: &str = "text/uri-list";
3862
3863const GNOME_CLIP_MIME: &str = "x-special/gnome-copied-files";
3867
3868fn read_clipboard_files(done: impl FnOnce(Vec<PathBuf>) + 'static) {
3873 let Some(display) = gdk::Display::default() else {
3874 done(Vec::new());
3875 return;
3876 };
3877 let clipboard = display.clipboard();
3878 let formats = clipboard.formats();
3879 let has_uri = formats.contain_mime_type(URI_LIST_MIME);
3880 let has_gnome = formats.contain_mime_type(GNOME_CLIP_MIME);
3881 if !has_uri && !has_gnome {
3882 done(Vec::new());
3883 return;
3884 }
3885 clipboard.read_text_async(gio::Cancellable::NONE, move |result| {
3886 let text = result.ok().flatten().unwrap_or_default();
3887 done(parse_clipboard_uris(&text, has_gnome && !has_uri));
3888 });
3889}
3890
3891fn parse_clipboard_uris(text: &str, verb_line: bool) -> Vec<PathBuf> {
3897 text.lines()
3898 .map(str::trim)
3899 .filter(|line| !line.is_empty() && !line.starts_with('#'))
3900 .filter_map(|line| {
3901 if verb_line && (line.eq_ignore_ascii_case("copy") || line.eq_ignore_ascii_case("cut")) {
3902 return None;
3903 }
3904 let (path, _host) = glib::filename_from_uri(line).ok()?;
3905 path.is_absolute().then_some(path)
3906 })
3907 .collect()
3908}
3909
3910fn spawn_command(cmd: &[String]) -> Result<(), std::io::Error> {
3911 if cmd.is_empty() {
3912 return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "empty command"));
3913 }
3914 let mut process = std::process::Command::new(&cmd[0]);
3915 for arg in &cmd[1..] {
3916 process.arg(arg);
3917 }
3918 process.spawn().map(|_| ())
3919}
3920
3921#[cfg(test)]
3922mod tests {
3923 use super::*;
3924
3925 #[test]
3926 fn preview_menu_label_reflects_the_file_kind() {
3927 use tempfile::tempdir;
3928 let dir = tempdir().unwrap();
3929 let write = |name: &str, bytes: &[u8]| {
3930 let path = dir.path().join(name);
3931 std::fs::write(&path, bytes).unwrap();
3932 path
3933 };
3934 let label = |path: &Path| {
3935 menu_label(
3936 &ContextAction::Builtin(BuiltinAction::ViewThumbnail),
3937 path,
3938 PanelSide::Left,
3939 )
3940 };
3941 let png = write("a.png", &[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]);
3943 assert_eq!(label(&png), "View Thumbnail");
3944 let text = write("a.txt", b"hello\n");
3945 assert_eq!(label(&text), "Show Preview");
3946 let json = write("a.json", br#"{"a":1}"#);
3947 assert_eq!(label(&json), "Show Preview");
3948 let zip = write("a.zip", b"PK\x03\x04rest");
3950 assert_eq!(label(&zip), "Show Contents");
3951 assert_eq!(label(dir.path()), "View Thumbnail");
3953 }
3954
3955 #[test]
3956 fn thumbnail_width_tracks_the_panel_and_stays_positive() {
3957 let panel = PanelConfig { width: 300, margin: 0, ..PanelConfig::default() };
3958 assert_eq!(thumbnail_content_width(panel), 276);
3959
3960 let panel = PanelConfig { width: 500, margin: 10, ..PanelConfig::default() };
3961 assert_eq!(thumbnail_content_width(panel), 456);
3962
3963 let tiny = PanelConfig { width: 40, margin: 0, ..PanelConfig::default() };
3964 assert_eq!(thumbnail_content_width(tiny), 64);
3965 }
3966
3967 #[test]
3968 fn accelerator_normalization_supports_both_syntaxes() {
3969 assert_eq!(normalize_accelerator("Ctrl+x"), "<Control>x");
3971 assert_eq!(normalize_accelerator("Ctrl+Shift+m"), "<Control><Shift>m");
3972 assert_eq!(normalize_accelerator("Shift+Delete"), "<Shift>Delete");
3973 assert_eq!(normalize_accelerator("ctrl+alt+t"), "<Control><Alt>t");
3974 assert_eq!(normalize_accelerator("<Control>x"), "<Control>x");
3976 assert_eq!(normalize_accelerator("F2"), "F2");
3977 assert_eq!(normalize_accelerator("Delete"), "Delete");
3978 }
3979
3980 fn row(path: &str) -> VisibleRow {
3981 VisibleRow {
3982 path: PathBuf::from(path),
3983 name: path.rsplit('/').next().unwrap().to_string(),
3984 depth: 0,
3985 is_dir: false,
3986 is_symlink: false,
3987 expanded: false,
3988 has_children: false,
3989 matches: false,
3990 }
3991 }
3992
3993 #[test]
3994 fn parse_clipboard_uris_handles_both_formats() {
3995 let uri_list = "#comment\r\nfile:///tmp/a.txt\r\n\r\nfile:///tmp/b%20c.txt\r\n";
3997 assert_eq!(
3998 parse_clipboard_uris(uri_list, false),
3999 vec![PathBuf::from("/tmp/a.txt"), PathBuf::from("/tmp/b c.txt")]
4000 );
4001 let gnome = "cut\nfile:///home/u/x\n";
4003 assert_eq!(parse_clipboard_uris(gnome, true), vec![PathBuf::from("/home/u/x")]);
4004 assert_eq!(parse_clipboard_uris("copy\n", false), Vec::<PathBuf>::new());
4006 assert_eq!(
4008 parse_clipboard_uris("https://example.com/x\nrelative/path\nfile:///ok\n", false),
4009 vec![PathBuf::from("/ok")]
4010 );
4011 assert!(parse_clipboard_uris("", false).is_empty());
4012 }
4013
4014 #[test]
4015 fn icon_name_matches_extensions_case_insensitively() {
4016 assert_eq!(icon_name(&row("/a/Photo.JPG")), "image-x-generic-symbolic");
4017 assert_eq!(icon_name(&row("/a/song.Flac")), "audio-x-generic-symbolic");
4018 assert_eq!(icon_name(&row("/a/clip.mkv")), "video-x-generic-symbolic");
4019 assert_eq!(icon_name(&row("/a/archive.tar")), "package-x-generic-symbolic");
4020 assert_eq!(icon_name(&row("/a/doc.pdf")), "application-pdf-symbolic");
4021 assert_eq!(icon_name(&row("/a/notes.txt")), "text-x-generic-symbolic");
4022 let mut dir = row("/a/pictures");
4024 dir.is_dir = true;
4025 assert_eq!(icon_name(&dir), "folder-symbolic");
4026 dir.expanded = true;
4027 assert_eq!(icon_name(&dir), "folder-open-symbolic");
4028 assert_eq!(icon_name(&row("/a/.bashrc")), "text-x-generic-symbolic");
4030 }
4031
4032 #[test]
4033 fn resolve_drop_filters_noops_and_detects_cycles() {
4034 let target = Path::new("/a/b");
4035 assert_eq!(resolve_drop(target, vec![PathBuf::from("/a/b")], false), DropPlan::Ignore);
4037 assert_eq!(resolve_drop(target, vec![PathBuf::from("/a/b/c")], false), DropPlan::Ignore);
4038 assert!(matches!(
4040 resolve_drop(target, vec![PathBuf::from("/a")], false),
4041 DropPlan::Reject(_)
4042 ));
4043 assert!(matches!(
4045 resolve_drop(target, vec![PathBuf::from("/x/y")], false),
4046 DropPlan::Move(_)
4047 ));
4048 assert!(matches!(
4049 resolve_drop(target, vec![PathBuf::from("/x/y")], true),
4050 DropPlan::Copy(_)
4051 ));
4052 }
4053
4054 #[test]
4055 fn drag_paths_uses_the_selection_only_when_the_row_is_in_it() {
4056 let sel = vec![PathBuf::from("/r/a"), PathBuf::from("/r/b")];
4057 assert_eq!(drag_paths(&sel, Path::new("/r/b")), sel);
4059 assert_eq!(
4061 drag_paths(&sel, Path::new("/r/c")),
4062 vec![PathBuf::from("/r/c")]
4063 );
4064 assert_eq!(
4066 drag_paths(&[PathBuf::from("/r/a")], Path::new("/r/a")),
4067 vec![PathBuf::from("/r/a")]
4068 );
4069 assert_eq!(
4071 drag_paths(&[], Path::new("/r/z")),
4072 vec![PathBuf::from("/r/z")]
4073 );
4074 }
4075
4076 #[test]
4077 fn index_range_is_inclusive_and_order_independent() {
4078 assert_eq!(index_range(2, 2).collect::<Vec<_>>(), vec![2]);
4079 assert_eq!(index_range(1, 4).collect::<Vec<_>>(), vec![1, 2, 3, 4]);
4080 assert_eq!(index_range(4, 1).collect::<Vec<_>>(), vec![1, 2, 3, 4]);
4082 }
4083
4084 #[test]
4085 fn format_uri_list_serializes_file_uris() {
4086 let text = format_uri_list(&[PathBuf::from("/tmp/a.txt"), PathBuf::from("/tmp/b c.txt")]);
4087 assert_eq!(text, "file:///tmp/a.txt\r\nfile:///tmp/b%20c.txt\r\n");
4088 assert!(format_uri_list(&[]).is_empty());
4089 assert_eq!(
4091 parse_clipboard_uris(&text, false),
4092 vec![PathBuf::from("/tmp/a.txt"), PathBuf::from("/tmp/b c.txt")]
4093 );
4094 }
4095
4096 #[test]
4097 fn typeahead_finds_the_next_matching_row_and_wraps() {
4098 let rows = vec![row("/r/apple"), row("/r/banana"), row("/r/apricot"), row("/r/cherry")];
4099 assert_eq!(typeahead_target(&rows, "ap", None), Some(0));
4102 assert_eq!(typeahead_target(&rows, "ap", Some(0)), Some(2)); assert_eq!(typeahead_target(&rows, "b", Some(2)), Some(1)); assert_eq!(typeahead_target(&rows, "zzz", None), None);
4105 }
4106
4107 #[test]
4108 fn typeahead_buffer_resets_after_a_pause_and_caps_length() {
4109 let mut ta = TypeAhead::default();
4110 let t0 = std::time::Instant::now();
4111 assert_eq!(ta.push('a', t0).as_deref(), Some("a"));
4112 assert_eq!(ta.push('b', t0 + Duration::from_millis(100)).as_deref(), Some("ab"));
4113 assert_eq!(ta.push('c', t0 + Duration::from_millis(2000)).as_deref(), Some("c"));
4115 assert_eq!(ta.push('\u{1}', t0), None);
4117 }
4118}