1use std::cell::{Cell, RefCell};
19use std::collections::{HashMap, HashSet};
20use std::path::{Path, PathBuf};
21use std::rc::Rc;
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::model::{Change, SortKey, StdDirSource, TreeModel, VisibleRow};
30use crate::fs::ops::FileOps;
31use crate::fs::watcher::{RecursiveMode, RecommendedWatcher, Watcher, spawn as spawn_watcher};
32use gtk4_layer_shell::{KeyboardMode, Layer, LayerShell};
33use relm4::gtk::{gdk, gio, glib, pango, prelude::*};
34use relm4::prelude::*;
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38enum ClipboardOp {
39 Copy,
40 Cut,
41}
42
43#[derive(Debug, Default)]
47struct TypeAhead {
48 prefix: String,
50 last: Option<std::time::Instant>,
53}
54
55const TYPEAHEAD_TIMEOUT: Duration = Duration::from_millis(700);
57const TYPEAHEAD_MAX: usize = 32;
59
60impl TypeAhead {
61 fn push(&mut self, ch: char, now: std::time::Instant) -> Option<String> {
64 if !ch.is_alphanumeric() && ch != '_' && ch != '-' && ch != '.' && ch != ' ' {
65 return None;
66 }
67 let expired = self
68 .last
69 .is_some_and(|last| now.duration_since(last) > TYPEAHEAD_TIMEOUT);
70 if expired {
71 self.prefix.clear();
72 }
73 if self.prefix.len() >= TYPEAHEAD_MAX {
74 self.prefix.clear();
75 }
76 self.prefix.push(ch.to_ascii_lowercase());
77 self.last = Some(now);
78 Some(self.prefix.clone())
79 }
80
81 fn clear(&mut self) {
83 self.prefix.clear();
84 self.last = None;
85 }
86}
87
88fn typeahead_target(rows: &[VisibleRow], prefix: &str, cursor: Option<usize>) -> Option<usize> {
91 if prefix.is_empty() || rows.is_empty() {
92 return None;
93 }
94 let len = rows.len();
95 let start = cursor.map_or(0, |c| (c + 1) % len);
96 for offset in 0..len {
97 let index = (start + offset) % len;
98 if rows[index].name.to_ascii_lowercase().starts_with(prefix) {
99 return Some(index);
100 }
101 }
102 None
103}
104
105#[derive(Debug, Clone)]
106struct Clipboard {
107 op: ClipboardOp,
108 paths: Vec<PathBuf>,
109}
110
111#[derive(Debug, Clone, PartialEq, Eq)]
114enum DropPlan {
115 Ignore,
117 Copy(Vec<PathBuf>),
119 Move(Vec<PathBuf>),
121 Reject(String),
123}
124
125fn resolve_drop(target: &Path, sources: Vec<PathBuf>, copy: bool) -> DropPlan {
128 let sources: Vec<PathBuf> = sources
129 .into_iter()
130 .filter(|src| src != target && src.parent() != Some(target))
131 .collect();
132 if sources.is_empty() {
133 return DropPlan::Ignore;
134 }
135 if sources.iter().any(|src| target.starts_with(src)) {
136 return DropPlan::Reject("Cannot move a folder into itself".to_string());
137 }
138 if copy { DropPlan::Copy(sources) } else { DropPlan::Move(sources) }
139}
140
141fn index_range(a: usize, b: usize) -> std::ops::RangeInclusive<usize> {
144 if a <= b { a..=b } else { b..=a }
145}
146
147fn drag_paths(selected: &[PathBuf], pressed: &Path) -> Vec<PathBuf> {
152 if selected.len() > 1 && selected.iter().any(|p| p == pressed) {
153 selected.to_vec()
154 } else {
155 vec![pressed.to_path_buf()]
156 }
157}
158
159fn format_uri_list(paths: &[PathBuf]) -> String {
162 let mut out = String::new();
163 for path in paths {
164 if let Ok(uri) = glib::filename_to_uri(path, None) {
165 out.push_str(&uri);
166 out.push_str("\r\n");
167 }
168 }
169 out
170}
171
172fn provider_for_paths(paths: &[PathBuf]) -> gdk::ContentProvider {
177 let files: Vec<gio::File> = paths.iter().map(gio::File::for_path).collect();
178 let list = gdk::FileList::from_array(&files);
179 let native = gdk::ContentProvider::for_value(&list.to_value());
180 let uris = format_uri_list(paths);
181 let bytes = glib::Bytes::from(uris.as_bytes());
182 let text = gdk::ContentProvider::for_bytes(URI_LIST_MIME, &bytes);
183 gdk::ContentProvider::new_union(&[native, text])
184}
185
186fn past_drag_threshold(row: >k::Widget, start: (f64, f64), now: (f64, f64)) -> bool {
193 let threshold = row
194 .settings()
195 .gtk_dnd_drag_threshold()
196 .max(1) as f64;
197 (now.0 - start.0).abs() > threshold || (now.1 - start.1).abs() > threshold
198}
199
200fn drag_badge(count: usize) -> gtk::Widget {
203 let label = gtk::Label::new(Some(&if count <= 1 {
204 "1 item".to_owned()
205 } else {
206 format!("{count} items")
207 }));
208 label.add_css_class("tree-drag-badge");
209 label.upcast()
210}
211
212#[derive(Debug, Clone)]
216struct DragOrigin {
217 x: f64,
219 y: f64,
220}
221
222fn begin_row_drag(
235 widget: >k::Widget,
236 paths: &[PathBuf],
237 start: (f64, f64),
238 actions: gdk::DragAction,
239 on_finished: impl Fn(gdk::Drag, bool) + 'static,
240 on_cancelled: impl Fn(gdk::Drag, gdk::DragCancelReason) + 'static,
241) -> bool {
242 let Some(native) = widget.native() else {
243 return false;
244 };
245 let Some(surface) = native.surface() else {
246 return false;
247 };
248 let Some(device) = widget
249 .display()
250 .default_seat()
251 .and_then(|seat| seat.pointer())
252 else {
253 return false;
254 };
255
256 let (dx, dy) = match (
259 surface.device_position(&device),
260 widget.compute_point(
261 &native,
262 &relm4::gtk::graphene::Point::new(start.0 as f32, start.1 as f32),
263 ),
264 ) {
265 (Some((px, py, _)), Some(p)) => ((px - p.x() as f64).round(), (py - p.y() as f64).round()),
266 _ => (0.0, 0.0),
267 };
268
269 let content = provider_for_paths(paths);
270 let Some(drag) = gdk::Drag::begin(&surface, &device, &content, actions, dx, dy) else {
271 return false;
272 };
273
274 let icon = gtk::DragIcon::for_drag(&drag);
275 icon.set_child(Some(&drag_badge(paths.len())));
276
277 {
278 let on_finished = Rc::new(on_finished);
279 let drag = drag.clone();
280 drag.connect_dnd_finished(move |drag| {
281 let delete = drag.selected_action() == gdk::DragAction::MOVE;
282 on_finished(drag.clone(), delete);
283 });
284 }
285 {
286 let on_cancelled = Rc::new(on_cancelled);
287 let drag = drag.clone();
288 drag.connect_cancel(move |drag, reason| on_cancelled(drag.clone(), reason));
289 }
290 true
291}
292
293pub struct TreeInit {
295 pub config: TreeConfig,
297 pub parent: gtk::Window,
299 pub menu: ContextMenu,
301 pub side: PanelSide,
304 pub panel: PanelConfig,
306}
307
308#[derive(Debug, Clone)]
310pub enum TreeMsg {
311 OpenRoot(PathBuf),
312 SelectPath(PathBuf),
316 Focus,
319 SetFilter(String),
320 SetPanel(PanelConfig),
323
324 MoveUp,
325 MoveDown,
326 ExtendSelection(isize),
330 MoveExtendingSelection(isize),
335 CollapseCursor,
336 ExpandCursor,
337 ActivateCursor,
338 Rename,
339
340 Activate(PathBuf),
341 Toggle(PathBuf),
342 Select(PathBuf),
343 ToggleSelect(PathBuf),
346 RangeSelect(PathBuf),
349 RowPress { path: PathBuf, is_dir: bool, ctrl: bool, shift: bool },
354 RowRelease { path: PathBuf },
359 DragStarted,
362 DroppedAsMove(Vec<PathBuf>),
366 Deselect,
369 Menu(PathBuf),
370 MenuAt { x: f64, y: f64 },
373 RenameAt(PathBuf),
374 Trash(PathBuf),
377 DeleteSelected,
379 ConfirmTrash(Vec<PathBuf>),
380 PermanentDelete(PathBuf),
382 PermanentDeleteSelected,
384 ConfirmPermanentDelete(Vec<PathBuf>),
386 RunCommand { command: String, path: PathBuf },
389 OpenSplit(PathBuf),
391 OpenInOppositePanel(PathBuf),
393 OpenWith(PathBuf),
395 OpenWithDefault(PathBuf),
397 CreateLink(PathBuf),
399 Properties(PathBuf),
401 PropertiesSelected(Vec<PathBuf>),
404 CopyPaths(Vec<PathBuf>),
407 CopyRelativePaths(Vec<PathBuf>),
410 RunCommandEach { command: String, paths: Vec<PathBuf> },
413 RenameTo { path: PathBuf, name: String },
416 ToggleThumbnail(PathBuf),
419 ToggleThumbnailPlay(PathBuf),
421 RunShortcut { target: ShortcutTarget },
423 Duplicate(PathBuf),
424 RequestDuplicateCursor,
426 SelectAllRows,
428 TypeAhead(char),
431 CopyPath(PathBuf),
432 CopyRelativePath(PathBuf),
433 AddBookmark(PathBuf),
435
436 RenameCommit,
437 RenameCancel,
438
439 Copy,
440 Cut,
441 Paste,
442 PastePaths(Vec<PathBuf>),
445 NewFile,
446 NewFolder,
447
448 ToggleHidden,
450 SetSortKey(SortKey),
452 ToggleSortDirection,
454
455 FsChange(Change),
456 FlushChanges,
457 RequestOpenFolder,
458
459 DropInto { target: PathBuf, sources: Vec<PathBuf>, copy: bool },
464 DropIntoRoot { sources: Vec<PathBuf>, copy: bool },
467 DropIntoConfirmed { target: PathBuf, sources: Vec<PathBuf> },
469 Shutdown,
471}
472
473#[derive(Debug)]
475pub enum TreeOutput {
476 Status(String),
478 RootChanged(PathBuf),
480 OpenFolderRequested,
482 OpenSplit(PathBuf),
484 OpenOpposite(PathBuf),
486 PaneAction(BuiltinAction),
490 AddBookmark(PathBuf),
492}
493
494pub struct Tree {
496 tree: Option<TreeModel>,
497 ops: FileOps,
498 config: TreeConfig,
499 parent: gtk::Window,
500 menu: ContextMenu,
501 side: PanelSide,
502
503 thumbnails: HashSet<PathBuf>,
507 thumb_cache: HashMap<PathBuf, gdk::Texture>,
510 media: HashMap<PathBuf, gtk::MediaFile>,
513 audio: HashMap<PathBuf, Rc<AudioPlayer>>,
516 playing: HashSet<PathBuf>,
518 gif_anims: Rc<RefCell<HashMap<PathBuf, GifAnim>>>,
520 gif_widgets: Rc<RefCell<HashMap<PathBuf, gtk::Picture>>>,
523 gif_tick: Rc<RefCell<Option<glib::SourceId>>>,
525 audio_widgets: Rc<RefCell<HashMap<PathBuf, AudioWidgets>>>,
529 audio_tick: Rc<RefCell<Option<glib::SourceId>>>,
531 content_width: i32,
533
534 rows: Vec<VisibleRow>,
535 cursor: Option<usize>,
536 selected: Vec<PathBuf>,
537 anchor: Option<PathBuf>,
541 last_press: Option<(PathBuf, std::time::Instant)>,
545
546 renaming: Option<PathBuf>,
547 rename_entry: Option<gtk::Entry>,
548 clipboard: Option<Clipboard>,
549
550 pending: Vec<Change>,
551 flush_source: Option<glib::SourceId>,
552 _watcher: Option<RecommendedWatcher>,
553 watched: HashSet<PathBuf>,
556
557 popover: Option<gtk::Popover>,
560 menu_target: Option<PathBuf>,
564 menu_point: Option<(f64, f64)>,
567
568 renaming_state: Rc<Cell<bool>>,
570
571 selection_dirty: bool,
576 typeahead: TypeAhead,
579 scroll_to: Option<usize>,
582
583 drag_payload: Rc<RefCell<Vec<PathBuf>>>,
588 drag_active: Rc<Cell<bool>>,
592 pending_click: Option<PathBuf>,
596 drag_landed_internal: Rc<Cell<bool>>,
600 drag_origin: Rc<RefCell<Option<DragOrigin>>>,
604 drag_button_down: Rc<Cell<bool>>,
609}
610
611#[relm4::component(pub)]
612impl Component for Tree {
613 type Init = TreeInit;
614 type Input = TreeMsg;
615 type Output = TreeOutput;
616 type CommandOutput = ();
617
618 view! {
619 scrolled = gtk::ScrolledWindow {
620 set_vexpand: true,
621 set_hexpand: true,
622 set_min_content_width: 160,
623 add_css_class: "tree-scroll",
624
625 #[name = "list"]
626 gtk::ListBox {
627 set_selection_mode: gtk::SelectionMode::None,
628 set_activate_on_single_click: false,
629 }
630 }
631 }
632
633 fn init(
634 init: Self::Init,
635 root: Self::Root,
636 sender: ComponentSender<Self>,
637 ) -> ComponentParts<Self> {
638 let renaming_state = Rc::new(Cell::new(false));
639 let keys = compile_shortcuts(&init.menu);
640 let widgets = view_output!();
641
642 let controller = gtk::EventControllerKey::new();
643 controller.set_propagation_phase(gtk::PropagationPhase::Capture);
644 let capture_sender = sender.clone();
645 let capture_state = renaming_state.clone();
646 let capture_keys = keys.clone();
647 controller.connect_key_pressed(move |_ctrl, key, _keycode, state| {
648 handle_key(
649 key,
650 state,
651 capture_state.get(),
652 &capture_keys,
653 &capture_sender,
654 )
655 });
656 widgets.scrolled.add_controller(controller);
657
658 let root_drop = gtk::DropTarget::new(gdk::FileList::static_type(), gdk::DragAction::MOVE | gdk::DragAction::COPY);
664 let root_drop_sender = sender.clone();
665 root_drop.connect_drop(move |target, value, _x, _y| {
666 let Ok(list) = value.get::<gdk::FileList>() else {
667 return false;
668 };
669 let sources: Vec<PathBuf> = list.files().iter().filter_map(gio::File::path).collect();
670 if sources.is_empty() {
671 return false;
672 }
673 let copy = target.current_event_state().contains(gdk::ModifierType::CONTROL_MASK);
674 root_drop_sender.input(TreeMsg::DropIntoRoot { sources, copy });
675 true
676 });
677 widgets.list.add_controller(root_drop);
678
679 let blank_scrolled = widgets.scrolled.clone();
685 let blank_click = gtk::GestureClick::new();
686 blank_click.set_button(0);
687 blank_click.set_propagation_phase(gtk::PropagationPhase::Capture);
688 let blank_sender = sender.clone();
689 blank_click.connect_pressed(move |gesture, _n_press, x, y| {
690 if gesture.current_button() == 3
693 && gesture.current_event_state().contains(gdk::ModifierType::SUPER_MASK)
694 {
695 gesture.set_state(gtk::EventSequenceState::Denied);
696 return;
697 }
698 let on_row = blank_scrolled
699 .pick(x, y, gtk::PickFlags::DEFAULT)
700 .is_some_and(|widget| in_list_row(&widget));
701 if on_row {
702 return;
703 }
704 match gesture.current_button() {
705 1 => blank_sender.input(TreeMsg::Deselect),
706 3 => blank_sender.input(TreeMsg::MenuAt { x, y }),
707 _ => {}
708 }
709 });
710 widgets.scrolled.add_controller(blank_click);
711
712 let model = Tree {
713 tree: None,
714 ops: FileOps::new(),
715 config: init.config,
716 parent: init.parent,
717 menu: init.menu,
718 side: init.side,
719 thumbnails: HashSet::new(),
720 thumb_cache: HashMap::new(),
721 media: HashMap::new(),
722 audio: HashMap::new(),
723 playing: HashSet::new(),
724 gif_anims: Rc::new(RefCell::new(HashMap::new())),
725 gif_widgets: Rc::new(RefCell::new(HashMap::new())),
726 gif_tick: Rc::new(RefCell::new(None)),
727 audio_widgets: Rc::new(RefCell::new(HashMap::new())),
728 audio_tick: Rc::new(RefCell::new(None)),
729 content_width: thumbnail_content_width(init.panel),
730 rows: Vec::new(),
731 cursor: None,
732 selected: Vec::new(),
733 anchor: None,
734 last_press: None,
735 renaming: None,
736 rename_entry: None,
737 clipboard: None,
738 pending: Vec::new(),
739 flush_source: None,
740 _watcher: None,
741 watched: HashSet::new(),
742 popover: None,
743 menu_target: None,
744 menu_point: None,
745 renaming_state,
746 selection_dirty: false,
747 typeahead: TypeAhead::default(),
748 scroll_to: None,
749 drag_payload: Rc::new(RefCell::new(Vec::new())),
750 drag_active: Rc::new(Cell::new(false)),
751 pending_click: None,
752 drag_landed_internal: Rc::new(Cell::new(false)),
753 drag_origin: Rc::new(RefCell::new(None)),
754 drag_button_down: Rc::new(Cell::new(false)),
755 };
756
757 {
767 let legacy = gtk::EventControllerLegacy::new();
768 legacy.set_propagation_phase(gtk::PropagationPhase::Capture);
769 let origin = model.drag_origin.clone();
770 let payload = model.drag_payload.clone();
771 let active = model.drag_active.clone();
772 let landed = model.drag_landed_internal.clone();
773 let down = model.drag_button_down.clone();
774 let drag_root = widgets.scrolled.clone();
775 let s = sender.clone();
776 legacy.connect_event(move |_controller, event| {
777 match event.event_type() {
778 gdk::EventType::ButtonPress => {
779 let button = event
780 .downcast_ref::<gdk::ButtonEvent>()
781 .map(|b| b.button())
782 .unwrap_or(0);
783 if button == 1
784 && let Some((x, y)) = event.position()
785 {
786 down.set(true);
787 *origin.borrow_mut() = Some(DragOrigin { x, y });
788 }
789 }
790 gdk::EventType::ButtonRelease => {
791 let button = event
792 .downcast_ref::<gdk::ButtonEvent>()
793 .map(|b| b.button())
794 .unwrap_or(0);
795 if button == 1 {
796 down.set(false);
797 if !active.get() {
798 *origin.borrow_mut() = None;
799 }
800 }
801 }
802 gdk::EventType::MotionNotify => {
803 if active.get() || !down.get() {
804 return glib::Propagation::Proceed;
805 }
806 let Some((x, y)) = event.position() else {
807 return glib::Propagation::Proceed;
808 };
809 let start = {
810 let guard = origin.borrow();
811 match guard.as_ref() {
812 Some(o) => (o.x, o.y),
813 None => return glib::Propagation::Proceed,
814 }
815 };
816 if !past_drag_threshold(drag_root.upcast_ref(), start, (x, y)) {
817 return glib::Propagation::Proceed;
818 }
819 let paths = payload.borrow().clone();
820 if paths.is_empty() {
821 return glib::Propagation::Proceed;
822 }
823 *origin.borrow_mut() = None;
825 let finished = {
826 let active = active.clone();
827 let landed = landed.clone();
828 let s = s.clone();
829 let payload = payload.clone();
830 move |_drag: gdk::Drag, delete: bool| {
831 active.set(false);
832 if delete && !landed.replace(false) {
835 let paths = payload.borrow().clone();
836 if !paths.is_empty() {
837 s.input(TreeMsg::DroppedAsMove(paths));
838 }
839 }
840 }
841 };
842 let cancelled = {
843 let active = active.clone();
844 let landed = landed.clone();
845 move |_drag: gdk::Drag, _reason: gdk::DragCancelReason| {
846 active.set(false);
847 landed.set(false);
848 }
849 };
850 if begin_row_drag(
851 drag_root.upcast_ref(),
852 &paths,
853 start,
854 gdk::DragAction::MOVE | gdk::DragAction::COPY,
855 finished,
856 cancelled,
857 ) {
858 active.set(true);
859 s.input(TreeMsg::DragStarted);
860 }
861 }
862 _ => {}
863 }
864 glib::Propagation::Proceed
865 });
866 widgets.scrolled.add_controller(legacy);
867 }
868
869 ComponentParts { model, widgets }
870 }
871
872 fn update_with_view(
873 &mut self,
874 widgets: &mut Self::Widgets,
875 message: Self::Input,
876 sender: ComponentSender<Self>,
877 root: &Self::Root,
878 ) {
879 let _ = root;
880 if let TreeMsg::Focus = message {
883 let index = self.cursor.unwrap_or(0) as i32;
887 if let Some(row) = widgets
888 .list
889 .row_at_index(index)
890 .or_else(|| widgets.list.row_at_index(0))
891 {
892 row.grab_focus();
893 } else if !widgets.list.grab_focus() {
894 widgets.scrolled.grab_focus();
895 }
896 return;
897 }
898 self.handle_message(message, sender.clone());
899 if let Some(index) = self.scroll_to.take() {
903 scroll_row_into_view(widgets, index);
904 }
905 self.reconcile_watches();
909 if self.selection_dirty {
910 self.selection_dirty = false;
913 if update_selection_classes(self, widgets) {
914 return;
915 }
916 }
917 <Self as Component>::update_view(self, widgets, sender.clone());
918 rebuild(self, widgets, sender);
919 }
920}
921
922impl Tree {
923 fn handle_message(&mut self, msg: TreeMsg, sender: ComponentSender<Self>) {
924 match msg {
925 TreeMsg::OpenRoot(path) => self.open_root(&path, &sender),
926 TreeMsg::SelectPath(path) => self.reveal_path(path),
927 TreeMsg::Focus => {}
929 TreeMsg::SetFilter(filter) => {
930 if let Some(tree) = self.tree.as_mut() {
931 tree.set_filter(&filter);
932 self.refresh_rows();
933 }
934 }
935 TreeMsg::SetPanel(panel) => self.content_width = thumbnail_content_width(panel),
936 TreeMsg::Shutdown => self.shutdown(),
937
938 TreeMsg::MoveUp => self.cursor_delta(-1),
939 TreeMsg::MoveDown => self.cursor_delta(1),
940 TreeMsg::ExtendSelection(delta) => self.cursor_extend(delta, false),
941 TreeMsg::MoveExtendingSelection(delta) => self.cursor_extend(delta, true),
942 TreeMsg::CollapseCursor => {
943 if let Some(path) = self.cursor_path()
944 && self.tree.as_ref().is_some_and(|t| t.is_expanded(&path))
945 {
946 self.collapse_dir(&path);
947 }
948 }
949 TreeMsg::ExpandCursor => {
950 if let Some(path) = self.cursor_path() {
951 self.expand_dir(&path, &sender);
952 }
953 }
954 TreeMsg::ActivateCursor => {
955 if let Some(path) = self.cursor_path() {
956 self.activate(&path, &sender);
957 }
958 }
959 TreeMsg::Rename => {
960 if let Some(path) = self.cursor_path() {
961 self.renaming = Some(path);
962 self.renaming_state.set(true);
963 }
964 }
965
966 TreeMsg::Activate(path) => self.activate(&path, &sender),
967 TreeMsg::Toggle(path) => self.toggle_dir(&path, &sender),
968 TreeMsg::Select(path) => {
969 self.select(&path);
970 self.anchor = Some(path);
971 self.selection_dirty = true;
972 }
973 TreeMsg::ToggleSelect(path) => {
974 if let Some(i) = self.rows.iter().position(|r| r.path == path) {
975 self.cursor = Some(i);
976 }
977 if let Some(i) = self.selected.iter().position(|p| p == &path) {
978 self.selected.remove(i);
979 } else {
980 self.selected.push(path.clone());
981 }
982 self.anchor = Some(path);
983 self.selection_dirty = true;
984 }
985 TreeMsg::RangeSelect(path) => {
986 self.range_select(&path);
987 self.selection_dirty = true;
988 }
989 TreeMsg::RowPress { path, is_dir, ctrl, shift } => {
990 if ctrl {
995 sender.input(TreeMsg::ToggleSelect(path));
996 return;
997 }
998 if shift {
999 sender.input(TreeMsg::RangeSelect(path));
1000 return;
1001 }
1002 *self.drag_payload.borrow_mut() = drag_paths(&self.selected, &path);
1007
1008 if self.selected.len() > 1 && self.selected.iter().any(|p| p == &path) {
1012 self.pending_click = Some(path.clone());
1013 if is_dir {
1014 self.toggle_dir(&path, &sender);
1015 }
1016 return;
1017 }
1018 self.pending_click = None;
1019 self.press_default(&path, is_dir, &sender);
1020 }
1021 TreeMsg::RowRelease { path } => {
1022 *self.drag_origin.borrow_mut() = None;
1024 self.drag_button_down.set(false);
1025 if self.drag_active.get() {
1028 return;
1029 }
1030 if self.pending_click.take().as_deref() == Some(path.as_path()) {
1031 let is_dir = self
1032 .rows
1033 .iter()
1034 .find(|r| r.path == path)
1035 .map(|r| r.is_dir)
1036 .unwrap_or(false);
1037 self.press_default(&path, is_dir, &sender);
1038 }
1039 }
1040 TreeMsg::DragStarted => {
1041 self.drag_active.set(true);
1043 self.pending_click = None;
1044 *self.drag_origin.borrow_mut() = None;
1045 self.drag_button_down.set(false);
1046 }
1047 TreeMsg::DroppedAsMove(paths) => {
1048 match self.ops.trash(&paths) {
1052 Ok(()) => {
1053 for path in &paths {
1054 self.apply_change(Change::Removed { path: path.clone() });
1055 }
1056 let n = paths.len();
1057 self.status(format!("Moved {n} item(s) out of the panel"), &sender);
1058 }
1059 Err(err) => self.status(format!("Could not finish move: {err}"), &sender),
1060 }
1061 }
1062 TreeMsg::Deselect => {
1063 self.selected.clear();
1064 self.cursor = None;
1065 self.anchor = None;
1066 self.typeahead.clear();
1067 self.selection_dirty = true;
1068 }
1069 TreeMsg::Menu(path) => self.open_menu(&path),
1070 TreeMsg::MenuAt { x, y } => {
1071 if let Some(root) = self.tree.as_ref().map(|t| t.root().to_path_buf()) {
1072 self.open_menu_at(&root, (x, y));
1073 }
1074 }
1075 TreeMsg::RenameAt(path) => {
1076 self.select(&path);
1077 self.renaming = Some(path);
1078 self.renaming_state.set(true);
1079 }
1080 TreeMsg::Trash(path) => self.confirm_trash(vec![path], sender.clone()),
1081 TreeMsg::DeleteSelected => {
1082 let paths = self.selected_for_clipboard();
1083 if !paths.is_empty() {
1084 self.confirm_trash(paths, sender.clone());
1085 }
1086 }
1087 TreeMsg::PermanentDelete(path) => {
1088 self.confirm_permanent_delete(vec![path], sender.clone());
1089 }
1090 TreeMsg::PermanentDeleteSelected => {
1091 let paths = self.selected_for_clipboard();
1092 if !paths.is_empty() {
1093 self.confirm_permanent_delete(paths, sender.clone());
1094 }
1095 }
1096 TreeMsg::ConfirmPermanentDelete(paths) => {
1097 if let Some(renaming) = self.renaming.as_ref()
1098 && paths.contains(renaming)
1099 {
1100 self.renaming = None;
1101 self.renaming_state.set(false);
1102 }
1103 match self.ops.delete_permanently(&paths) {
1104 Ok(()) => {
1105 for path in paths {
1106 self.apply_change(Change::Removed { path });
1107 }
1108 self.selected.clear();
1109 self.cursor_snap();
1110 }
1111 Err(err) => self.status(err.to_string(), &sender),
1112 }
1113 }
1114 TreeMsg::ConfirmTrash(paths) => {
1115 if let Some(renaming) = self.renaming.as_ref()
1116 && paths.contains(renaming)
1117 {
1118 self.renaming = None;
1119 self.renaming_state.set(false);
1120 }
1121 match self.ops.trash(&paths) {
1122 Ok(()) => {
1123 for path in paths {
1124 self.apply_change(Change::Removed { path });
1125 }
1126 self.selected.clear();
1127 self.cursor_snap();
1128 }
1129 Err(err) => self.status(err.to_string(), &sender),
1132 }
1133 }
1134 TreeMsg::Duplicate(path) => match self.ops.duplicate(&path) {
1135 Ok(new_path) => {
1136 self.apply_change(Change::Created { path: new_path.clone() });
1137 self.select(&new_path);
1138 }
1139 Err(err) => self.status(format!("Could not duplicate: {err}"), &sender),
1140 },
1141 TreeMsg::RequestDuplicateCursor => {
1142 if let Some(path) = self.cursor_path() {
1143 sender.input(TreeMsg::Duplicate(path));
1144 }
1145 }
1146 TreeMsg::CopyPath(path) => {
1147 set_clipboard_text(&path.display().to_string());
1148 self.status("Copied path to clipboard".to_string(), &sender);
1149 }
1150 TreeMsg::CopyRelativePath(path) => {
1151 let relative = self
1152 .tree
1153 .as_ref()
1154 .and_then(|t| path.strip_prefix(t.root()).ok())
1155 .map(|p| p.display().to_string())
1156 .unwrap_or_else(|| path.display().to_string());
1157 set_clipboard_text(&relative);
1158 self.status("Copied relative path to clipboard".to_string(), &sender);
1159 }
1160 TreeMsg::AddBookmark(path) => {
1161 let _ = sender.output(TreeOutput::AddBookmark(path));
1162 }
1163 TreeMsg::RunCommand { command, path } => {
1164 let cmd = action_command(&command, &path);
1165 match spawn_command(&cmd) {
1166 Ok(()) => self.status(format!("Ran {command} on {}", path.display()), &sender),
1167 Err(err) => self.status(format!("Could not run {command}: {err}"), &sender),
1168 }
1169 }
1170 TreeMsg::OpenSplit(path) => {
1171 let _ = sender.output(TreeOutput::OpenSplit(path));
1172 }
1173 TreeMsg::OpenInOppositePanel(path) => {
1174 let _ = sender.output(TreeOutput::OpenOpposite(path));
1175 }
1176 TreeMsg::OpenWith(path) => open_with_dialog(&self.parent.clone(), &path, &sender),
1177 TreeMsg::OpenWithDefault(path) => match open_in_app(&path) {
1178 Ok(app) => {
1179 self.status(format!("Opened {} with {app}", path.display()), &sender);
1180 }
1181 Err(err) => self.status(err, &sender),
1182 },
1183 TreeMsg::CreateLink(path) => match self.ops.create_link(&path) {
1184 Ok(new_path) => {
1185 self.apply_change(Change::Created { path: new_path.clone() });
1186 }
1187 Err(err) => self.status(format!("Could not create link: {err}"), &sender),
1188 },
1189 TreeMsg::Properties(path) => {
1190 crate::ui::props::show_properties_dialog(&self.parent.clone(), &path, &sender);
1191 }
1192 TreeMsg::PropertiesSelected(paths) => {
1193 crate::ui::props::show_multi_properties_dialog(&self.parent.clone(), &paths);
1194 }
1195 TreeMsg::CopyPaths(paths) => {
1196 let text = paths
1197 .iter()
1198 .map(|p| p.display().to_string())
1199 .collect::<Vec<_>>()
1200 .join("\n");
1201 set_clipboard_text(&text);
1202 self.status(format!("Copied {} paths to clipboard", paths.len()), &sender);
1203 }
1204 TreeMsg::CopyRelativePaths(paths) => {
1205 let root = self.tree.as_ref().map(|t| t.root().to_path_buf());
1206 let text = paths
1207 .iter()
1208 .map(|p| {
1209 root.as_deref()
1210 .and_then(|root| p.strip_prefix(root).ok())
1211 .map(|rel| rel.display().to_string())
1212 .unwrap_or_else(|| p.display().to_string())
1213 })
1214 .collect::<Vec<_>>()
1215 .join("\n");
1216 set_clipboard_text(&text);
1217 self.status(
1218 format!("Copied {} relative paths to clipboard", paths.len()),
1219 &sender,
1220 );
1221 }
1222 TreeMsg::RunCommandEach { command, paths } => {
1223 let mut failures = 0usize;
1224 for path in &paths {
1225 let cmd = action_command(&command, path);
1226 if spawn_command(&cmd).is_err() {
1227 failures += 1;
1228 }
1229 }
1230 let total = paths.len();
1231 if failures == 0 {
1232 self.status(format!("Ran {command} on {total} item(s)"), &sender);
1233 } else {
1234 self.status(
1235 format!("Ran {command} on {total} item(s); {failures} failed"),
1236 &sender,
1237 );
1238 }
1239 }
1240 TreeMsg::RenameTo { path, name } => match self.ops.rename(&path, &name) {
1241 Ok(new_path) => {
1242 self.apply_change(Change::Renamed {
1243 from: path,
1244 to: new_path.clone(),
1245 });
1246 self.select(&new_path);
1247 }
1248 Err(err) => self.status(format!("Could not rename: {err}"), &sender),
1249 },
1250 TreeMsg::ToggleThumbnail(path) => {
1251 if self.thumbnails.contains(&path) {
1252 self.clear_thumbnails_under(&path);
1254 } else {
1255 self.thumbnails.insert(path.clone());
1256 if path.is_dir() {
1258 self.expand_dir(&path, &sender);
1259 }
1260 }
1261 }
1262 TreeMsg::ToggleThumbnailPlay(path) => self.toggle_thumbnail_play(&path),
1263 TreeMsg::RunShortcut { target } => self.run_shortcut(target, &sender),
1264
1265 TreeMsg::SelectAllRows => {
1266 if !self.rows.is_empty() {
1267 self.selected = self.rows.iter().map(|r| r.path.clone()).collect();
1268 self.cursor = Some(0);
1269 self.anchor = self.rows.first().map(|r| r.path.clone());
1270 self.selection_dirty = true;
1271 }
1272 }
1273 TreeMsg::TypeAhead(ch) => {
1274 let now = std::time::Instant::now();
1275 if let Some(prefix) = self.typeahead.push(ch, now)
1276 && let Some(index) = typeahead_target(&self.rows, &prefix, self.cursor)
1277 {
1278 let path = self.rows[index].path.clone();
1279 self.cursor = Some(index);
1280 self.set_single_selection(path);
1281 self.selection_dirty = true;
1282 }
1283 }
1284
1285 TreeMsg::RenameCommit => {
1286 let text = self
1287 .rename_entry
1288 .as_ref()
1289 .map(|e| e.text().to_string())
1290 .unwrap_or_default();
1291 let Some(old) = self.renaming.take() else {
1292 return;
1293 };
1294 self.renaming_state.set(false);
1295 self.rename_entry = None;
1296 let trimmed = text.trim().to_string();
1297 let unchanged = old
1298 .file_name()
1299 .map(|n| n.to_string_lossy() == trimmed.as_str())
1300 .unwrap_or(false);
1301 if trimmed.is_empty() || unchanged {
1302 return;
1303 }
1304 match self.ops.rename(&old, &trimmed) {
1305 Ok(new_path) => {
1306 self.apply_change(Change::Renamed {
1307 from: old,
1308 to: new_path.clone(),
1309 });
1310 self.select(&new_path);
1311 }
1312 Err(err) => self.status(format!("Could not rename: {err}"), &sender),
1313 }
1314 }
1315 TreeMsg::RenameCancel => {
1316 self.renaming = None;
1317 self.renaming_state.set(false);
1318 self.rename_entry = None;
1319 }
1320
1321 TreeMsg::Copy => {
1322 let paths = self.selected_for_clipboard();
1323 if !paths.is_empty() {
1324 self.clipboard = Some(Clipboard { op: ClipboardOp::Copy, paths: paths.clone() });
1325 self.status(format!("Copying {} item(s)", paths.len()), &sender);
1326 }
1327 }
1328 TreeMsg::Cut => {
1329 let paths = self.selected_for_clipboard();
1330 if !paths.is_empty() {
1331 self.clipboard = Some(Clipboard { op: ClipboardOp::Cut, paths: paths.clone() });
1332 self.status(format!("Cutting {} item(s)", paths.len()), &sender);
1333 }
1334 }
1335 TreeMsg::Paste => self.paste(&sender),
1336 TreeMsg::PastePaths(paths) => self.paste_paths(paths, &sender),
1337
1338 TreeMsg::NewFile => self.create_entry(false, &sender),
1339 TreeMsg::NewFolder => self.create_entry(true, &sender),
1340
1341 TreeMsg::ToggleHidden => self.toggle_hidden(&sender),
1342 TreeMsg::SetSortKey(key) => self.set_sort_key(key, &sender),
1343 TreeMsg::ToggleSortDirection => self.toggle_sort_direction(&sender),
1344
1345 TreeMsg::FsChange(change) => {
1346 self.pending.push(change);
1348 if self.flush_source.is_none() {
1349 let s = sender.clone();
1350 let id = glib::timeout_add_local_once(
1351 Duration::from_millis(100),
1352 move || {
1353 s.input(TreeMsg::FlushChanges);
1354 }
1355 );
1356 self.flush_source = Some(id);
1357 }
1358 }
1359 TreeMsg::FlushChanges => {
1360 self.flush_source = None;
1361 let changes = std::mem::take(&mut self.pending);
1362 for change in changes {
1363 self.apply_change(change);
1364 }
1365 self.cursor_snap();
1366 }
1367 TreeMsg::RequestOpenFolder => {
1368 self.status("Pick a folder to browse...".to_string(), &sender);
1369 let _ = sender.output(TreeOutput::OpenFolderRequested);
1370 }
1371
1372 TreeMsg::DropInto { target, sources, copy } => {
1373 self.request_drop_into(target, sources, copy, &sender);
1374 }
1375 TreeMsg::DropIntoRoot { sources, copy } => {
1376 if let Some(root) = self.tree.as_ref().map(|t| t.root().to_path_buf()) {
1377 self.request_drop_into(root, sources, copy, &sender);
1378 }
1379 }
1380 TreeMsg::DropIntoConfirmed { target, sources } => {
1381 self.drop_into(&target, &sources, false, &sender);
1382 }
1383 }
1384 }
1385}
1386
1387fn accel_mods(m: gdk::ModifierType) -> gdk::ModifierType {
1394 m & (gdk::ModifierType::CONTROL_MASK
1395 | gdk::ModifierType::SHIFT_MASK
1396 | gdk::ModifierType::ALT_MASK
1397 | gdk::ModifierType::SUPER_MASK
1398 | gdk::ModifierType::HYPER_MASK
1399 | gdk::ModifierType::META_MASK)
1400}
1401
1402#[derive(Clone)]
1404struct KeyBinding {
1405 key: gdk::Key,
1406 mods: gdk::ModifierType,
1407 target: ShortcutTarget,
1408}
1409
1410fn compile_shortcuts(menu: &ContextMenu) -> Rc<Vec<KeyBinding>> {
1414 let mut out = Vec::new();
1415 for (accel, target) in menu.shortcuts() {
1416 if let Some((key, mods)) = parse_accelerator(&accel) {
1417 out.push(KeyBinding { key, mods, target });
1418 }
1419 }
1420 Rc::new(out)
1421}
1422
1423fn parse_accelerator(accel: &str) -> Option<(gdk::Key, gdk::ModifierType)> {
1429 gtk::accelerator_parse(normalize_accelerator(accel))
1430}
1431
1432fn normalize_accelerator(accel: &str) -> String {
1435 if !accel.contains('+') {
1436 return accel.to_owned();
1437 }
1438 let mut mods = String::new();
1439 let mut key = String::new();
1440 for token in accel.split('+') {
1441 let token = token.trim();
1442 if token.is_empty() {
1443 continue;
1444 }
1445 match token.to_ascii_lowercase().as_str() {
1446 "ctrl" | "control" | "primary" => mods.push_str("<Control>"),
1447 "shift" => mods.push_str("<Shift>"),
1448 "alt" => mods.push_str("<Alt>"),
1449 "super" | "win" | "mod4" => mods.push_str("<Super>"),
1450 "meta" => mods.push_str("<Meta>"),
1451 "hyper" => mods.push_str("<Hyper>"),
1452 _ => key = token.to_owned(),
1453 }
1454 }
1455 format!("{mods}{key}")
1456}
1457
1458fn handle_key(
1459 key: gdk::Key,
1460 state: gdk::ModifierType,
1461 renaming: bool,
1462 keys: &[KeyBinding],
1463 sender: &ComponentSender<Tree>,
1464) -> glib::Propagation {
1465 use gdk::Key;
1466
1467 if renaming {
1469 return glib::Propagation::Proceed;
1470 }
1471
1472 let ctrl = state.contains(gdk::ModifierType::CONTROL_MASK);
1473 let shift = state.contains(gdk::ModifierType::SHIFT_MASK);
1474
1475 let structural = match key {
1478 Key::Up if ctrl && shift => Some(TreeMsg::MoveExtendingSelection(-1)),
1479 Key::Down if ctrl && shift => Some(TreeMsg::MoveExtendingSelection(1)),
1480 Key::Up if shift => Some(TreeMsg::ExtendSelection(-1)),
1481 Key::Down if shift => Some(TreeMsg::ExtendSelection(1)),
1482 Key::Up => Some(TreeMsg::MoveUp),
1483 Key::Down => Some(TreeMsg::MoveDown),
1484 Key::Left => Some(TreeMsg::CollapseCursor),
1485 Key::Right => Some(TreeMsg::ExpandCursor),
1486 Key::Return | Key::KP_Enter | Key::space => Some(TreeMsg::ActivateCursor),
1487 Key::Escape => Some(TreeMsg::RenameCancel),
1488 Key::o | Key::O if ctrl && !shift => Some(TreeMsg::RequestOpenFolder),
1489 Key::a | Key::A if ctrl && !shift => Some(TreeMsg::SelectAllRows),
1490 _ => None,
1491 };
1492
1493 let msg = match structural {
1494 Some(msg) => msg,
1495 None => {
1496 let mut hit = None;
1500 for binding in keys.iter() {
1501 if key.to_lower() == binding.key.to_lower()
1502 && accel_mods(state) == accel_mods(binding.mods)
1503 {
1504 hit = Some(TreeMsg::RunShortcut {
1505 target: binding.target.clone(),
1506 });
1507 break;
1508 }
1509 }
1510 match hit {
1511 Some(msg) => msg,
1512 None if !ctrl
1515 && !state.contains(gdk::ModifierType::ALT_MASK)
1516 && !state.contains(gdk::ModifierType::SUPER_MASK) =>
1517 {
1518 if let Some(ch) = key.to_unicode()
1519 && ch.is_ascii_graphic()
1520 {
1521 TreeMsg::TypeAhead(ch)
1522 } else {
1523 return glib::Propagation::Proceed;
1524 }
1525 }
1526 None => return glib::Propagation::Proceed,
1527 }
1528 }
1529 };
1530
1531 sender.input(msg);
1532 glib::Propagation::Stop
1533}
1534
1535fn update_selection_classes(
1544 tree: &mut Tree,
1545 widgets: &mut <Tree as Component>::Widgets,
1546) -> bool {
1547 let mut existing = 0;
1549 let mut child = widgets.list.first_child();
1550 while let Some(current) = child {
1551 if current.is::<gtk::ListBoxRow>() {
1552 existing += 1;
1553 }
1554 child = current.next_sibling();
1555 }
1556 if existing != tree.rows.len() {
1557 return false;
1558 }
1559 for (index, row) in tree.rows.iter().enumerate() {
1560 let Some(list_row) = widgets.list.row_at_index(index as i32) else {
1561 return false;
1562 };
1563 let selected = tree.cursor == Some(index) || tree.selected.contains(&row.path);
1564 if selected {
1565 list_row.add_css_class("tree-row-selected");
1566 } else {
1567 list_row.remove_css_class("tree-row-selected");
1568 }
1569 }
1570 true
1571}
1572
1573fn scroll_row_into_view(widgets: &<Tree as Component>::Widgets, index: usize) {
1578 let Some(row) = widgets.list.row_at_index(index as i32) else {
1579 return;
1580 };
1581 let adj = widgets.scrolled.vadjustment();
1584 let page = adj.page_size();
1585 if page <= 0.0 {
1586 return;
1587 }
1588 let Some(bounds) = row.compute_bounds(&widgets.list) else {
1591 return;
1592 };
1593 let y = bounds.y() as f64;
1594 let height = bounds.height() as f64;
1595 let top = adj.value();
1596 if y < top {
1597 adj.set_value(y);
1598 } else if y + height > top + page {
1599 adj.set_value((y + height - page).max(0.0));
1600 }
1601}
1602
1603fn rebuild(tree: &mut Tree, widgets: &mut <Tree as Component>::Widgets, sender: ComponentSender<Tree>) {
1604 let list = &widgets.list;
1605
1606 if let Some(previous) = tree.popover.take() {
1615 previous.unparent();
1616 }
1617
1618 widgets.scrolled.set_child(None::<>k::Widget>);
1623 list.remove_all();
1624
1625 if tree.tree.is_none() {
1626 widgets.scrolled.set_child(Some(list));
1627 return;
1628 }
1629
1630 let rows = std::mem::take(&mut tree.rows);
1633 let mut menu_row: Option<gtk::ListBoxRow> = None;
1634 tree.rename_entry = None;
1635 tree.gif_widgets.borrow_mut().clear();
1638 tree.audio_widgets.borrow_mut().clear();
1641
1642 for (index, row) in rows.iter().enumerate() {
1643 let list_row = gtk::ListBoxRow::new();
1644 list_row.set_focusable(true);
1645 list_row.set_can_focus(true);
1646 list_row.add_css_class("tree-row");
1647
1648 let row_indent = row.depth as i32 * 16 + 4;
1649 let container = gtk::Box::new(gtk::Orientation::Vertical, 0);
1651 container.set_hexpand(true);
1652 list_row.set_child(Some(&container));
1653
1654 let hbox = gtk::Box::new(gtk::Orientation::Horizontal, 6);
1655 hbox.set_margin_start(row_indent);
1656 hbox.set_hexpand(true);
1657 container.append(&hbox);
1658
1659 let icon = gtk::Image::from_icon_name(icon_name(row));
1660 icon.set_pixel_size(tree.config.icon_size as i32);
1661 icon.set_valign(gtk::Align::Center);
1662 hbox.append(&icon);
1663
1664 if tree.renaming.as_ref() == Some(&row.path) {
1665 let entry = gtk::Entry::new();
1669 entry.set_text(&row.name);
1670 entry.set_hexpand(true);
1671 entry.add_css_class("tree-rename-entry");
1672 {
1673 let s = sender.clone();
1674 entry.connect_activate(move |_| s.input(TreeMsg::RenameCommit));
1675 }
1676 let esc_controller = gtk::EventControllerKey::new();
1677 let s = sender.clone();
1678 esc_controller.connect_key_pressed(move |_, key, _code, _state| {
1679 if key == gdk::Key::Escape {
1680 s.input(TreeMsg::RenameCancel);
1681 glib::Propagation::Stop
1682 } else {
1683 glib::Propagation::Proceed
1684 }
1685 });
1686 entry.add_controller(esc_controller);
1687 hbox.append(&entry);
1688 tree.rename_entry = Some(entry.clone());
1689 entry.grab_focus();
1690 } else {
1691 let label = gtk::Label::new(Some(&row.name));
1692 label.set_xalign(0.0);
1693 label.set_hexpand(true);
1694 label.set_ellipsize(pango::EllipsizeMode::Middle);
1695 label.set_tooltip_text(Some(&row.path.to_string_lossy()));
1696 hbox.append(&label);
1697 }
1698
1699 if tree.cursor == Some(index) || tree.selected.contains(&row.path) {
1700 list_row.add_css_class("tree-row-selected");
1701 }
1702
1703 if row.matches {
1704 list_row.add_css_class("tree-row-match");
1705 }
1706
1707 if tree.menu_target.as_ref() == Some(&row.path) {
1708 menu_row = Some(list_row.clone());
1709 }
1710
1711 if tree.wants_thumbnail(row) {
1714 tree.append_thumbnail(&row.path, row_indent, &container, &sender);
1715 }
1716
1717 let path = row.path.clone();
1718 let release_path = row.path.clone();
1719 let is_dir = row.is_dir;
1720 let click = gtk::GestureClick::new();
1721 click.set_button(0);
1725 let s = sender.clone();
1726 let focus_row = list_row.clone();
1727 click.connect_pressed(move |gesture, _n_press, x, y| {
1728 if focus_row
1733 .pick(x, y, gtk::PickFlags::DEFAULT)
1734 .is_some_and(|widget| in_inline_media(&widget))
1735 {
1736 return;
1737 }
1738 focus_row.grab_focus();
1743 let button = gesture.current_button();
1744 let state = gesture.current_event_state();
1745 if button == 3 {
1746 if state.contains(gdk::ModifierType::SUPER_MASK) {
1750 gesture.set_state(gtk::EventSequenceState::Denied);
1751 return;
1752 }
1753 s.input(TreeMsg::Menu(path.clone()));
1754 return;
1755 }
1756 if button != 1 {
1757 return;
1758 }
1759 let ctrl = state.contains(gdk::ModifierType::CONTROL_MASK);
1760 let shift = state.contains(gdk::ModifierType::SHIFT_MASK);
1761 s.input(TreeMsg::RowPress { path: path.clone(), is_dir, ctrl, shift });
1764 });
1765 {
1766 let s = sender.clone();
1770 click.connect_released(move |gesture, _n_press, _x, _y| {
1771 if gesture.current_button() == 1 {
1772 s.input(TreeMsg::RowRelease { path: release_path.clone() });
1773 }
1774 });
1775 }
1776 list_row.add_controller(click);
1777
1778 if row.is_dir {
1788 let drop = gtk::DropTarget::new(gdk::FileList::static_type(), gdk::DragAction::MOVE | gdk::DragAction::COPY);
1789 let s = sender.clone();
1790 let target_dir = row.path.clone();
1791 let active = tree.drag_active.clone();
1792 let landed = tree.drag_landed_internal.clone();
1793 drop.connect_drop(move |target, value, _x, _y| {
1794 let Ok(list) = value.get::<gdk::FileList>() else {
1795 return false;
1796 };
1797 let sources: Vec<PathBuf> = list.files().iter().filter_map(gio::File::path).collect();
1798 if sources.is_empty() {
1799 return false;
1800 }
1801 let copy = target.current_event_state().contains(gdk::ModifierType::CONTROL_MASK);
1802 if active.get() {
1805 landed.set(true);
1806 }
1807 s.input(TreeMsg::DropInto { target: target_dir.clone(), sources, copy });
1808 true
1809 });
1810 list_row.add_controller(drop);
1811 }
1812
1813 list.append(&list_row);
1814 }
1815
1816 tree.rows = rows;
1817 widgets.scrolled.set_child(Some(list));
1818
1819 if let Some(row) = menu_row {
1823 let target = tree.menu_target.take().expect("menu_row set implies a target");
1824 let selection = tree.selected.clone();
1825 tree.popover = Some(build_menu(&row, &target, &tree.menu, &selection, tree.side, &sender));
1826 tree.menu_point = None;
1827 } else if let (Some(target), Some(at)) = (tree.menu_target.take(), tree.menu_point.take()) {
1828 let selection = tree.selected.clone();
1829 tree.popover = Some(build_menu_at(&widgets.scrolled, at, &target, &tree.menu, &selection, tree.side, &sender));
1830 }
1831 tree.menu_target = None;
1832 tree.menu_point = None;
1833}
1834
1835fn action_builtin(action: &ContextAction) -> Option<BuiltinAction> {
1837 match action {
1838 ContextAction::Builtin(b) => Some(*b),
1839 ContextAction::Entry(e) => Some(e.action),
1840 ContextAction::Command(_) | ContextAction::Submenu(_) => None,
1841 }
1842}
1843
1844fn menu_label(action: &ContextAction, path: &Path, side: PanelSide) -> String {
1849 let dynamic = match action_builtin(action) {
1850 Some(BuiltinAction::OpenWithDefault) => default_app_label(path),
1851 Some(BuiltinAction::InOppositePanel) => Some(format!("In {} panel", side.opposite().name())),
1852 Some(BuiltinAction::ViewThumbnail) if media_kind(path) == Some(MediaKind::Audio) => {
1854 Some("Show Player".to_owned())
1855 }
1856 _ => None,
1857 };
1858 dynamic.unwrap_or_else(|| action.label())
1859}
1860
1861pub(crate) fn accel_display(accel: &str) -> Option<String> {
1864 let (key, mods) = parse_accelerator(accel)?;
1865 Some(gtk::accelerator_get_label(key, mods).to_string())
1866}
1867
1868fn build_menu_box(
1873 path: &Path,
1874 menu: &ContextMenu,
1875 selection: &[PathBuf],
1876 side: PanelSide,
1877 sender: &ComponentSender<Tree>,
1878) -> gtk::Box {
1879 let menu_box = gtk::Box::new(gtk::Orientation::Vertical, 0);
1880 menu_box.add_css_class("tree-menu");
1881 let path = path.to_path_buf();
1882 let actions = menu.actions_for_selection(&path, selection.len());
1883 append_menu_items(&menu_box, &actions, &path, selection, side, sender);
1884 menu_box
1885}
1886
1887fn append_menu_items(
1890 menu_box: >k::Box,
1891 actions: &[ContextAction],
1892 path: &Path,
1893 selection: &[PathBuf],
1894 side: PanelSide,
1895 sender: &ComponentSender<Tree>,
1896) {
1897 let multi = selection.len() > 1;
1898 for action in actions {
1899 if action.is_hidden() {
1902 continue;
1903 }
1904
1905 if action_builtin(action) == Some(BuiltinAction::Separator) {
1907 let separator = gtk::Separator::new(gtk::Orientation::Horizontal);
1908 separator.add_css_class("tree-menu-separator");
1909 menu_box.append(&separator);
1910 continue;
1911 }
1912
1913 if let Some(builtin) = action_builtin(action) {
1917 if builtin.is_directory_only() && !path.is_dir() {
1918 continue;
1919 }
1920 if multi && builtin.is_single_row_only() {
1921 continue;
1922 }
1923 if matches!(builtin, BuiltinAction::OpenWithDefault) && path.is_dir() {
1924 continue;
1925 }
1926 if builtin == BuiltinAction::ViewThumbnail
1928 && !path.is_dir()
1929 && !is_thumbnailable(path)
1930 {
1931 continue;
1932 }
1933 }
1934
1935 let row_box = gtk::Box::new(gtk::Orientation::Horizontal, 12);
1936 row_box.add_css_class("tree-menu-item");
1937
1938 if let ContextAction::Submenu(sub) = action {
1940 let button = submenu_row_button(&sub.label);
1941 let popover = build_submenu_popover(&sub.items, path, selection, side, sender);
1942 let child = popover.clone();
1943 let anchor = button.clone();
1944 button.connect_clicked(move |_| {
1945 child.set_parent(&anchor);
1948 child.popup();
1949 });
1950 let child = popover.clone();
1951 popover.connect_closed(move |_| child.unparent());
1952 row_box.append(&button);
1953 row_box.append(&submenu_arrow());
1954 menu_box.append(&row_box);
1955 continue;
1956 }
1957
1958 let label = menu_label(action, path, side);
1959 let msg = match action {
1960 ContextAction::Builtin(b) => action_message(*b, path, selection),
1961 ContextAction::Entry(e) => action_message(e.action, path, selection),
1962 ContextAction::Command(cmd) => {
1963 if multi {
1964 TreeMsg::RunCommandEach {
1965 command: cmd.command.clone(),
1966 paths: selection.to_vec(),
1967 }
1968 } else {
1969 TreeMsg::RunCommand {
1970 command: cmd.command.clone(),
1971 path: path.to_path_buf(),
1972 }
1973 }
1974 }
1975 ContextAction::Submenu(_) => unreachable!("handled above"),
1976 };
1977
1978 let button = gtk::Button::new();
1981 button.set_halign(gtk::Align::Fill);
1982 button.set_hexpand(true);
1983 let label_widget = gtk::Label::new(Some(&label));
1984 label_widget.set_xalign(0.0);
1985 label_widget.set_hexpand(true);
1986 button.set_child(Some(&label_widget));
1987 let s = sender.clone();
1988 button.connect_clicked(move |_| s.input(msg.clone()));
1989 row_box.append(&button);
1990
1991 if let Some(shortcut) = action.shortcut()
1992 && let Some(display) = accel_display(&shortcut)
1993 {
1994 let hint = gtk::Label::new(Some(&display));
1995 hint.add_css_class("tree-menu-shortcut");
1996 hint.set_halign(gtk::Align::End);
1997 hint.set_valign(gtk::Align::Center);
1998 row_box.append(&hint);
1999 }
2000
2001 menu_box.append(&row_box);
2002 }
2003}
2004
2005fn submenu_row_button(label: &str) -> gtk::Button {
2007 let button = gtk::Button::new();
2008 button.set_halign(gtk::Align::Fill);
2009 button.set_hexpand(true);
2010 let text = gtk::Label::new(Some(label));
2011 text.set_xalign(0.0);
2012 text.set_hexpand(true);
2013 button.set_child(Some(&text));
2014 button
2015}
2016
2017fn submenu_arrow() -> gtk::Label {
2019 let arrow = gtk::Label::new(Some("\u{25b8}"));
2020 arrow.add_css_class("tree-menu-submenu-arrow");
2021 arrow.set_halign(gtk::Align::End);
2022 arrow.set_valign(gtk::Align::Center);
2023 arrow
2024}
2025
2026fn build_submenu_popover(
2029 items: &[ContextAction],
2030 path: &Path,
2031 selection: &[PathBuf],
2032 side: PanelSide,
2033 sender: &ComponentSender<Tree>,
2034) -> gtk::Popover {
2035 let menu_box = gtk::Box::new(gtk::Orientation::Vertical, 0);
2036 menu_box.add_css_class("tree-menu");
2037 append_menu_items(&menu_box, items, path, selection, side, sender);
2038
2039 let popover = gtk::Popover::new();
2040 popover.add_css_class("tree-menu-popover");
2041 popover.set_has_arrow(false);
2042 popover.set_position(gtk::PositionType::Right);
2043 popover.set_child(Some(&menu_box));
2044 popover
2045}
2046
2047fn show_menu(
2050 anchor: &impl IsA<gtk::Widget>,
2051 menu_box: >k::Box,
2052 point: Option<(f64, f64)>,
2053) -> gtk::Popover {
2054 let popover = gtk::Popover::new();
2055 popover.add_css_class("tree-menu-popover");
2056 popover.set_child(Some(menu_box));
2057 popover.set_autohide(true);
2058 popover.set_parent(anchor);
2059 if let Some((x, y)) = point {
2060 popover.set_pointing_to(Some(&gdk::Rectangle::new(x as i32, y as i32, 1, 1)));
2061 }
2062 popover.popup();
2063 popover
2064}
2065
2066fn build_menu(
2068 row: >k::ListBoxRow,
2069 path: &Path,
2070 menu: &ContextMenu,
2071 selection: &[PathBuf],
2072 side: PanelSide,
2073 sender: &ComponentSender<Tree>,
2074) -> gtk::Popover {
2075 let menu_box = build_menu_box(path, menu, selection, side, sender);
2076 show_menu(row, &menu_box, None)
2077}
2078
2079fn build_menu_at(
2082 anchor: &impl IsA<gtk::Widget>,
2083 at: (f64, f64),
2084 path: &Path,
2085 menu: &ContextMenu,
2086 selection: &[PathBuf],
2087 side: PanelSide,
2088 sender: &ComponentSender<Tree>,
2089) -> gtk::Popover {
2090 let menu_box = build_menu_box(path, menu, selection, side, sender);
2091 show_menu(anchor, &menu_box, Some(at))
2092}
2093
2094fn action_message(action: BuiltinAction, path: &Path, selection: &[PathBuf]) -> TreeMsg {
2100 if selection.len() > 1 {
2101 match action {
2102 BuiltinAction::Trash => return TreeMsg::DeleteSelected,
2103 BuiltinAction::DeletePermanently => return TreeMsg::PermanentDeleteSelected,
2104 BuiltinAction::CopyPath => return TreeMsg::CopyPaths(selection.to_vec()),
2105 BuiltinAction::CopyRelativePath => return TreeMsg::CopyRelativePaths(selection.to_vec()),
2106 BuiltinAction::Properties => return TreeMsg::PropertiesSelected(selection.to_vec()),
2107 _ => {}
2108 }
2109 }
2110 builtin_message(action, path)
2111}
2112
2113fn builtin_message(action: BuiltinAction, path: &Path) -> TreeMsg {
2115 match action {
2116 BuiltinAction::Open => TreeMsg::Activate(path.to_path_buf()),
2117 BuiltinAction::OpenSplit => TreeMsg::OpenSplit(path.to_path_buf()),
2118 BuiltinAction::InNewPanel => TreeMsg::OpenSplit(path.to_path_buf()),
2119 BuiltinAction::InOppositePanel => TreeMsg::OpenInOppositePanel(path.to_path_buf()),
2120 BuiltinAction::OpenWith => TreeMsg::OpenWith(path.to_path_buf()),
2121 BuiltinAction::OpenWithDefault => TreeMsg::OpenWithDefault(path.to_path_buf()),
2122 BuiltinAction::ViewThumbnail => TreeMsg::ToggleThumbnail(path.to_path_buf()),
2123 BuiltinAction::NewFile => TreeMsg::NewFile,
2124 BuiltinAction::NewFolder => TreeMsg::NewFolder,
2125 BuiltinAction::Duplicate => TreeMsg::Duplicate(path.to_path_buf()),
2126 BuiltinAction::CreateLink => TreeMsg::CreateLink(path.to_path_buf()),
2127 BuiltinAction::Cut => TreeMsg::Cut,
2128 BuiltinAction::Copy => TreeMsg::Copy,
2129 BuiltinAction::Paste => TreeMsg::Paste,
2130 BuiltinAction::Rename => TreeMsg::RenameAt(path.to_path_buf()),
2131 BuiltinAction::CopyPath => TreeMsg::CopyPath(path.to_path_buf()),
2132 BuiltinAction::CopyRelativePath => TreeMsg::CopyRelativePath(path.to_path_buf()),
2133 BuiltinAction::AddBookmark => TreeMsg::AddBookmark(path.to_path_buf()),
2134 BuiltinAction::Properties => TreeMsg::Properties(path.to_path_buf()),
2135 BuiltinAction::Trash => TreeMsg::Trash(path.to_path_buf()),
2136 BuiltinAction::DeletePermanently => TreeMsg::PermanentDelete(path.to_path_buf()),
2137 BuiltinAction::ToggleHidden => TreeMsg::ToggleHidden,
2138 BuiltinAction::SortByName => TreeMsg::SetSortKey(SortKey::Name),
2139 BuiltinAction::SortBySize => TreeMsg::SetSortKey(SortKey::Size),
2140 BuiltinAction::SortByModified => TreeMsg::SetSortKey(SortKey::Modified),
2141 BuiltinAction::SortByType => TreeMsg::SetSortKey(SortKey::Type),
2142 BuiltinAction::ToggleSortAscending => TreeMsg::ToggleSortDirection,
2143 BuiltinAction::OpenFolder
2145 | BuiltinAction::Filter
2146 | BuiltinAction::SplitView
2147 | BuiltinAction::Up
2148 | BuiltinAction::Back
2149 | BuiltinAction::Forward
2150 | BuiltinAction::Collapse
2151 | BuiltinAction::ClosePane
2152 | BuiltinAction::ToggleBookmarks => unreachable!("pane action in a row context menu"),
2153 BuiltinAction::Separator => unreachable!("separators are rendered, not dispatched"),
2154 }
2155}
2156
2157const ARCHIVE_EXTENSIONS: [&str; 8] = ["zip", "tar", "gz", "xz", "bz2", "zst", "7z", "rar"];
2159
2160fn icon_name(row: &VisibleRow) -> &'static str {
2161 if row.is_dir {
2162 return if row.expanded { "folder-open-symbolic" } else { "folder-symbolic" };
2163 }
2164 if has_extension(&row.path, &["pdf"]) {
2168 "application-pdf-symbolic"
2169 } else if has_extension(&row.path, &["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp", "avif"])
2170 {
2171 "image-x-generic-symbolic"
2172 } else if has_extension(&row.path, &["mp3", "flac", "wav", "ogg", "m4a", "opus"]) {
2173 "audio-x-generic-symbolic"
2174 } else if has_extension(&row.path, &["mp4", "mkv", "webm", "mov", "avi"]) {
2175 "video-x-generic-symbolic"
2176 } else if has_extension(&row.path, &ARCHIVE_EXTENSIONS) {
2177 "package-x-generic-symbolic"
2178 } else {
2179 "text-x-generic-symbolic"
2180 }
2181}
2182
2183#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2189enum MediaKind {
2190 Image,
2192 Gif,
2194 Video,
2196 Audio,
2198}
2199
2200const IMAGE_EXTENSIONS: [&str; 10] =
2204 ["jpg", "jpeg", "png", "webp", "bmp", "tif", "tiff", "avif", "heic", "heif"];
2205
2206const VIDEO_EXTENSIONS: [&str; 15] = [
2209 "mp4", "m4v", "mkv", "webm", "mov", "avi", "wmv", "flv", "ogv", "ogg", "ts", "mpg",
2210 "mpeg", "3gp", "m2ts",
2211];
2212
2213const AUDIO_EXTENSIONS: [&str; 12] = [
2217 "mp3", "m4a", "m4b", "aac", "flac", "wav", "opus", "oga", "wma", "aif", "aiff", "alac",
2218];
2219
2220fn has_extension(path: &Path, extensions: &[&str]) -> bool {
2221 path.extension()
2222 .and_then(|ext| ext.to_str())
2223 .is_some_and(|ext| extensions.iter().any(|candidate| ext.eq_ignore_ascii_case(candidate)))
2224}
2225
2226fn media_kind(path: &Path) -> Option<MediaKind> {
2228 if has_extension(path, &["gif"]) {
2229 Some(MediaKind::Gif)
2230 } else if has_extension(path, &IMAGE_EXTENSIONS) {
2231 Some(MediaKind::Image)
2232 } else if has_extension(path, &VIDEO_EXTENSIONS) {
2233 Some(MediaKind::Video)
2234 } else if cfg!(feature = "audio") && has_extension(path, &AUDIO_EXTENSIONS) {
2235 Some(MediaKind::Audio)
2236 } else {
2237 None
2238 }
2239}
2240
2241fn is_thumbnailable(path: &Path) -> bool {
2243 media_kind(path).is_some()
2244}
2245
2246struct GifAnim {
2249 iter: gdk::gdk_pixbuf::PixbufAnimationIter,
2250}
2251
2252struct AudioWidgets {
2256 player: Rc<AudioPlayer>,
2257 play: gtk::Button,
2258 seek: gtk::Scale,
2259 time: gtk::Label,
2260 seeking: Rc<Cell<bool>>,
2263}
2264
2265fn needs_selection(action: BuiltinAction) -> bool {
2269 matches!(
2270 action,
2271 BuiltinAction::Rename
2272 | BuiltinAction::Duplicate
2273 | BuiltinAction::CreateLink
2274 | BuiltinAction::Cut
2275 | BuiltinAction::Copy
2276 | BuiltinAction::OpenWith
2277 | BuiltinAction::Trash
2278 | BuiltinAction::DeletePermanently
2279 )
2280}
2281
2282fn in_list_row(widget: >k::Widget) -> bool {
2285 let mut current = Some(widget.clone());
2286 while let Some(widget) = current {
2287 if widget.downcast_ref::<gtk::ListBoxRow>().is_some() {
2288 return true;
2289 }
2290 current = widget.parent();
2291 }
2292 false
2293}
2294
2295fn in_inline_media(widget: >k::Widget) -> bool {
2298 let mut current = Some(widget.clone());
2299 while let Some(widget) = current {
2300 if widget.downcast_ref::<gtk::Video>().is_some() || widget.has_css_class("tree-audio") {
2301 return true;
2302 }
2303 current = widget.parent();
2304 }
2305 false
2306}
2307
2308fn thumbnail_content_width(panel: PanelConfig) -> i32 {
2311 (panel.width as i32 - 2 * panel.margin as i32 - 24).max(64)
2312}
2313
2314fn thumbnail_size(texture: &gdk::Texture, available: i32) -> (i32, i32) {
2317 let natural_w = texture.width().max(1);
2318 let natural_h = texture.height().max(1);
2319 let available = available.max(32);
2320 if natural_w <= available {
2321 return (natural_w, natural_h);
2322 }
2323 let height = (natural_h as i64 * available as i64 / natural_w as i64).max(1) as i32;
2324 (available, height)
2325}
2326
2327fn thumbnail_picture(
2330 texture: &gdk::Texture,
2331 margin_start: i32,
2332 width: i32,
2333 height: i32,
2334) -> gtk::Picture {
2335 let picture = gtk::Picture::for_paintable(texture);
2336 picture.add_css_class("tree-thumbnail");
2337 picture.set_can_shrink(true);
2338 picture.set_content_fit(gtk::ContentFit::ScaleDown);
2339 picture.set_halign(gtk::Align::Start);
2340 picture.set_size_request(width, height);
2341 picture.set_margin_start(margin_start);
2342 picture.set_margin_bottom(6);
2343 picture
2344}
2345
2346fn format_time(micros: i64) -> String {
2349 if micros <= 0 {
2350 return "-:--".to_owned();
2351 }
2352 let total = micros / 1_000_000;
2353 let (h, m, s) = (total / 3600, (total % 3600) / 60, total % 60);
2354 if h > 0 {
2355 format!("{h}:{m:02}:{s:02}")
2356 } else {
2357 format!("{m}:{s:02}")
2358 }
2359}
2360
2361fn build_audio_player(player: &Rc<AudioPlayer>, margin_start: i32) -> (gtk::Box, AudioWidgets) {
2365 let controls = gtk::Box::new(gtk::Orientation::Horizontal, 6);
2366 controls.add_css_class("tree-audio");
2367 controls.set_margin_start(margin_start);
2368 controls.set_margin_bottom(6);
2369 controls.set_hexpand(true);
2370
2371 let play = gtk::Button::from_icon_name("media-playback-start-symbolic");
2372 play.add_css_class("flat");
2373 play.set_tooltip_text(Some("Play"));
2374 play.set_valign(gtk::Align::Center);
2375 {
2376 let player = player.clone();
2377 play.connect_clicked(move |button| {
2378 let playing = player.is_playing();
2379 player.set_playing(!playing);
2380 button.set_icon_name(if playing {
2381 "media-playback-start-symbolic"
2382 } else {
2383 "media-playback-pause-symbolic"
2384 });
2385 });
2386 }
2387 controls.append(&play);
2388
2389 let seek = gtk::Scale::with_range(gtk::Orientation::Horizontal, 0.0, 1.0, 1.0);
2392 seek.set_draw_value(false);
2393 seek.set_hexpand(true);
2394 seek.set_valign(gtk::Align::Center);
2395 seek.set_size_request(60, -1);
2396 let seeking = Rc::new(Cell::new(false));
2397 {
2398 let player = player.clone();
2399 let seeking = seeking.clone();
2400 seek.connect_change_value(move |_, _, value| {
2401 seeking.set(true);
2403 let flag = seeking.clone();
2404 glib::timeout_add_local_once(Duration::from_millis(200), move || flag.set(false));
2405 player.seek(value.round() as i64);
2406 glib::Propagation::Proceed
2407 });
2408 }
2409 controls.append(&seek);
2410
2411 let time = gtk::Label::new(Some("0:00 / -:--"));
2412 time.add_css_class("tree-audio-time");
2413 time.set_valign(gtk::Align::Center);
2414 time.set_width_chars(13);
2415 time.set_xalign(1.0);
2416 controls.append(&time);
2417
2418 let volume = gtk::Scale::with_range(gtk::Orientation::Horizontal, 0.0, 1.0, 0.05);
2419 volume.set_draw_value(false);
2420 volume.set_value(player.volume());
2421 volume.set_valign(gtk::Align::Center);
2422 volume.set_size_request(56, -1);
2423 volume.set_tooltip_text(Some("Volume"));
2424 {
2425 let player = player.clone();
2426 volume.connect_value_changed(move |scale| player.set_volume(scale.value()));
2427 }
2428 controls.append(&volume);
2429
2430 let widgets = AudioWidgets {
2431 player: player.clone(),
2432 play,
2433 seek,
2434 time,
2435 seeking,
2436 };
2437 (controls, widgets)
2438}
2439
2440impl Tree {
2441 fn open_root(&mut self, path: &Path, sender: &ComponentSender<Self>) {
2442 if let Some(id) = self.flush_source.take() {
2443 id.remove();
2444 }
2445 self.pending.clear();
2446
2447 let mut tree = TreeModel::new(
2448 path.to_path_buf(),
2449 self.config.sort_options(),
2450 self.config.show_hidden,
2451 );
2452 if let Err(err) = tree.expand(path, &StdDirSource) {
2453 self.status(format!("Could not open {}: {err}", path.display()), sender);
2454 return;
2455 }
2456 self.tree = Some(tree);
2457 self.refresh_rows();
2458 self.cursor = if self.rows.is_empty() { None } else { Some(0) };
2459 self.selected = Vec::new();
2460 self.renaming = None;
2461 self.renaming_state.set(false);
2462 self.rename_entry = None;
2463 self.menu_target = None;
2464 self.thumbnails.clear();
2465 self.thumb_cache.clear();
2466 self.playing.clear();
2467 for media in self.media.values() {
2468 media.set_playing(false);
2469 }
2470 self.media.clear();
2471 self.audio.clear();
2472 self.gif_anims.borrow_mut().clear();
2473 self.gif_widgets.borrow_mut().clear();
2474 self.audio_widgets.borrow_mut().clear();
2475 if let Some(previous) = self.popover.take() {
2476 previous.unparent();
2477 }
2478
2479 let root = path.to_path_buf();
2480 let watcher_sender = sender.clone();
2481 self._watcher = spawn_watcher(path, move |change| {
2482 watcher_sender.input(TreeMsg::FsChange(change));
2483 })
2484 .ok();
2485 self.watched.clear();
2486 if self._watcher.is_some() {
2487 self.watched.insert(root.clone());
2488 }
2489
2490 let _ = sender.output(TreeOutput::RootChanged(root));
2491 }
2492
2493 fn refresh_rows(&mut self) {
2494 if let Some(tree) = self.tree.as_ref() {
2495 tree.visible_rows_into(&mut self.rows);
2496 } else {
2497 self.rows.clear();
2498 }
2499 }
2500
2501 fn reconcile_watches(&mut self) {
2507 let Some(watcher) = self._watcher.as_mut() else {
2508 return;
2509 };
2510 let Some(model) = self.tree.as_ref() else {
2511 return;
2512 };
2513 let desired: HashSet<PathBuf> = model.expanded_loaded_dirs().into_iter().collect();
2514
2515 let stale: Vec<PathBuf> = self.watched.difference(&desired).cloned().collect();
2516 for path in stale {
2517 let _ = watcher.unwatch(&path);
2518 self.watched.remove(&path);
2519 }
2520
2521 let fresh: Vec<PathBuf> = desired.difference(&self.watched).cloned().collect();
2522 for path in fresh {
2523 if watcher.watch(&path, RecursiveMode::NonRecursive).is_ok() {
2524 self.watched.insert(path);
2525 }
2526 }
2527 }
2528
2529 fn cursor_path(&self) -> Option<PathBuf> {
2530 self.cursor.and_then(|i| self.rows.get(i)).map(|r| r.path.clone())
2531 }
2532
2533 fn cursor_delta(&mut self, delta: isize) {
2534 if self.rows.is_empty() {
2535 return;
2536 }
2537 let next = (self.cursor.unwrap_or(0) as isize + delta).clamp(0, self.rows.len() as isize - 1) as usize;
2538 let path = self.rows[next].path.clone();
2539 self.cursor = Some(next);
2540 self.set_single_selection(path);
2541 self.selection_dirty = true;
2542 }
2543
2544 fn cursor_extend(&mut self, delta: isize, _keep_anchor: bool) {
2551 if self.rows.is_empty() {
2552 return;
2553 }
2554 let anchor = self
2555 .anchor
2556 .clone()
2557 .or_else(|| self.cursor.map(|i| self.rows[i].path.clone()))
2558 .unwrap_or_else(|| self.rows[0].path.clone());
2559 let next = (self.cursor.unwrap_or(0) as isize + delta)
2560 .clamp(0, self.rows.len() as isize - 1) as usize;
2561 self.cursor = Some(next);
2562 self.anchor = Some(anchor.clone());
2563 let (Some(a), Some(b)) = (self.rows.iter().position(|r| r.path == anchor), Some(next)) else {
2564 return;
2565 };
2566 self.selected = self.rows[index_range(a, b)].iter().map(|r| r.path.clone()).collect();
2567 self.scroll_to = Some(next);
2568 self.selection_dirty = true;
2569 }
2570
2571 fn cursor_snap(&mut self) {
2572 let deselected = self.cursor.is_none();
2573 let prev = self.cursor_path();
2574 self.refresh_rows();
2575 if self.rows.is_empty() {
2576 self.cursor = None;
2577 self.selected.clear();
2578 return;
2579 }
2580 if deselected {
2581 self.cursor = None;
2583 return;
2584 }
2585 let index = prev
2586 .and_then(|p| self.rows.iter().position(|r| r.path == p))
2587 .unwrap_or(0);
2588 self.cursor = Some(index);
2589 }
2590
2591 fn select(&mut self, path: &Path) {
2592 if let Some(i) = self.rows.iter().position(|r| r.path == path) {
2593 self.cursor = Some(i);
2594 }
2595 self.selected = vec![path.to_path_buf()];
2596 }
2597
2598 fn press_default(&mut self, path: &Path, is_dir: bool, sender: &ComponentSender<Self>) {
2603 let now = std::time::Instant::now();
2606 let is_double = self.last_press.as_ref().is_some_and(|(last, at)| {
2607 last == path && now.duration_since(*at) < Duration::from_millis(400)
2608 });
2609 self.last_press = Some((path.to_path_buf(), now));
2610
2611 self.select(path);
2612 self.anchor = Some(path.to_path_buf());
2613 if is_dir {
2614 self.toggle_dir(path, sender);
2618 } else if is_double {
2619 self.activate(path, sender);
2621 }
2622 }
2623
2624 fn reveal_path(&mut self, path: PathBuf) {
2629 if let Some(i) = self.rows.iter().position(|r| r.path == path) {
2630 self.cursor = Some(i);
2631 self.selected = vec![path.clone()];
2632 self.anchor = Some(path);
2633 self.selection_dirty = true;
2634 self.scroll_to = Some(i);
2635 }
2636 }
2637
2638 fn range_select(&mut self, path: &Path) {
2642 let Some(anchor) = self.anchor.clone() else {
2643 self.select(path);
2644 self.anchor = Some(path.to_path_buf());
2645 return;
2646 };
2647 let (Some(a), Some(b)) = (
2648 self.rows.iter().position(|r| r.path == anchor),
2649 self.rows.iter().position(|r| r.path == path),
2650 ) else {
2651 self.select(path);
2652 return;
2653 };
2654 self.selected = self.rows[index_range(a, b)].iter().map(|r| r.path.clone()).collect();
2655 self.cursor = Some(b);
2656 }
2657
2658 fn set_single_selection(&mut self, path: PathBuf) {
2659 self.selected = vec![path.clone()];
2660 if let Some(i) = self.rows.iter().position(|r| r.path == path) {
2661 self.cursor = Some(i);
2662 }
2663 }
2664
2665 fn selected_for_clipboard(&self) -> Vec<PathBuf> {
2666 if self.selected.is_empty() {
2667 if let Some(path) = self.cursor_path() {
2668 return vec![path];
2669 }
2670 return Vec::new();
2671 }
2672 self.selected.clone()
2673 }
2674
2675 fn collapse_dir(&mut self, path: &Path) {
2678 if let Some(tree) = self.tree.as_mut() {
2679 tree.collapse(path);
2680 }
2681 self.clear_thumbnails_under(path);
2682 self.cursor_snap();
2683 }
2684
2685 fn toggle_dir(&mut self, path: &Path, sender: &ComponentSender<Self>) {
2687 let expanded = self.tree.as_ref().is_some_and(|t| t.is_expanded(path));
2688 if expanded {
2689 self.collapse_dir(path);
2690 } else {
2691 self.expand_dir(path, sender);
2692 }
2693 }
2694
2695 fn shutdown(&mut self) {
2702 if let Some(id) = self.gif_tick.borrow_mut().take() {
2703 id.remove();
2704 }
2705 if let Some(id) = self.audio_tick.borrow_mut().take() {
2706 id.remove();
2707 }
2708 self.gif_anims.borrow_mut().clear();
2709 self.gif_widgets.borrow_mut().clear();
2710 self.audio_widgets.borrow_mut().clear();
2711 self.playing.clear();
2712 self.thumbnails.clear();
2713 for media in self.media.values() {
2714 media.set_playing(false);
2715 }
2716 self.media.clear();
2717 self.audio.clear();
2719 }
2720
2721 fn clear_thumbnails_under(&mut self, prefix: &Path) {
2723 self.thumbnails.retain(|path| !path.starts_with(prefix));
2724 self.thumb_cache.retain(|path, _| !path.starts_with(prefix));
2725 self.playing.retain(|path| !path.starts_with(prefix));
2726 self.media.retain(|path, media| {
2727 if path.starts_with(prefix) {
2728 media.set_playing(false);
2729 false
2730 } else {
2731 true
2732 }
2733 });
2734 self.gif_anims.borrow_mut().retain(|path, _| !path.starts_with(prefix));
2735 self.gif_widgets.borrow_mut().retain(|path, _| !path.starts_with(prefix));
2736 self.audio_widgets.borrow_mut().retain(|path, _| !path.starts_with(prefix));
2737 self.audio.retain(|path, _| !path.starts_with(prefix));
2739 }
2740
2741 fn wants_thumbnail(&self, row: &VisibleRow) -> bool {
2744 if row.is_dir || !is_thumbnailable(&row.path) {
2745 return false;
2746 }
2747 self.thumbnails.contains(&row.path)
2748 || row
2749 .path
2750 .ancestors()
2751 .skip(1)
2752 .any(|ancestor| self.thumbnails.contains(ancestor))
2753 }
2754
2755 fn static_texture(&mut self, path: &Path) -> Option<gdk::Texture> {
2758 if let Some(texture) = self.thumb_cache.get(path) {
2759 return Some(texture.clone());
2760 }
2761 let texture = match media_kind(path)? {
2762 MediaKind::Image => gdk::Texture::from_filename(path).ok()?,
2763 MediaKind::Gif => {
2764 let animation = gdk::gdk_pixbuf::PixbufAnimation::from_file(path).ok()?;
2765 let pixbuf = animation.static_image()?;
2766 gdk::Texture::for_pixbuf(&pixbuf)
2767 }
2768 MediaKind::Video | MediaKind::Audio => return None,
2770 };
2771 self.thumb_cache.insert(path.to_path_buf(), texture.clone());
2772 Some(texture)
2773 }
2774
2775 fn media_file(&mut self, path: &Path) -> Option<gtk::MediaFile> {
2780 if let Some(media) = self.media.get(path) {
2781 return Some(media.clone());
2782 }
2783 let media = gtk::MediaFile::for_filename(path);
2784 media.set_loop(true);
2785 media.set_muted(false);
2790 media.set_volume(1.0);
2791 self.media.insert(path.to_path_buf(), media.clone());
2792 Some(media)
2793 }
2794
2795 fn audio_player(&mut self, path: &Path) -> Option<Rc<AudioPlayer>> {
2798 if let Some(player) = self.audio.get(path) {
2799 return Some(player.clone());
2800 }
2801 let player = AudioPlayer::new(path)?;
2802 self.audio.insert(path.to_path_buf(), player.clone());
2803 Some(player)
2804 }
2805
2806 fn toggle_thumbnail_play(&mut self, path: &Path) {
2809 if media_kind(path) != Some(MediaKind::Gif) {
2810 return;
2811 }
2812 if self.playing.remove(path) {
2813 self.gif_anims.borrow_mut().remove(path);
2814 } else {
2815 let Ok(animation) = gdk::gdk_pixbuf::PixbufAnimation::from_file(path) else {
2816 return;
2817 };
2818 let iter = animation.iter(Some(SystemTime::now()));
2819 self.gif_anims.borrow_mut().insert(path.to_path_buf(), GifAnim { iter });
2820 self.playing.insert(path.to_path_buf());
2821 self.ensure_gif_ticker();
2822 }
2823 }
2824
2825 fn ensure_gif_ticker(&mut self) {
2829 if self.gif_tick.borrow().is_some() {
2830 return;
2831 }
2832 let anims = self.gif_anims.clone();
2833 let widgets = self.gif_widgets.clone();
2834 let tick = self.gif_tick.clone();
2835 let id = glib::timeout_add_local(Duration::from_millis(33), move || {
2836 let mut anims = anims.borrow_mut();
2837 if anims.is_empty() {
2838 *tick.borrow_mut() = None;
2839 return glib::ControlFlow::Break;
2840 }
2841 let now = SystemTime::now();
2842 let widgets = widgets.borrow();
2843 for (path, anim) in anims.iter_mut() {
2844 let _ = anim.iter.advance(now);
2845 if let Some(picture) = widgets.get(path) {
2846 let pixbuf = anim.iter.pixbuf();
2847 picture.set_paintable(Some(&gdk::Texture::for_pixbuf(&pixbuf)));
2848 }
2849 }
2850 glib::ControlFlow::Continue
2851 });
2852 *self.gif_tick.borrow_mut() = Some(id);
2853 }
2854
2855 fn ensure_audio_ticker(&mut self) {
2858 if self.audio_tick.borrow().is_some() {
2859 return;
2860 }
2861 let players = self.audio_widgets.clone();
2862 let tick = self.audio_tick.clone();
2863 let id = glib::timeout_add_local(Duration::from_millis(200), move || {
2864 let players = players.borrow();
2865 if players.is_empty() {
2866 *tick.borrow_mut() = None;
2867 return glib::ControlFlow::Break;
2868 }
2869 for widgets in players.values() {
2870 let position = widgets.player.position();
2871 let duration = widgets.player.duration();
2872 if duration > 0
2873 && (widgets.seek.adjustment().upper() - duration as f64).abs() > 0.5
2874 {
2875 widgets.seek.adjustment().set_upper(duration as f64);
2876 }
2877 if !widgets.seeking.get() {
2878 widgets.seek.set_value(position as f64);
2879 }
2880 widgets.time.set_text(&format!(
2881 "{} / {}",
2882 format_time(position),
2883 format_time(duration)
2884 ));
2885 widgets.play.set_icon_name(if widgets.player.is_playing() {
2886 "media-playback-pause-symbolic"
2887 } else {
2888 "media-playback-start-symbolic"
2889 });
2890 }
2891 glib::ControlFlow::Continue
2892 });
2893 *self.audio_tick.borrow_mut() = Some(id);
2894 }
2895
2896 fn append_thumbnail(
2899 &mut self,
2900 path: &Path,
2901 row_indent: i32,
2902 container: >k::Box,
2903 sender: &ComponentSender<Self>,
2904 ) {
2905 let Some(kind) = media_kind(path) else {
2906 return;
2907 };
2908 let label_start = row_indent + self.config.icon_size as i32 + 6;
2909 let available = self.content_width - label_start - 6;
2910
2911 match kind {
2912 MediaKind::Image => {
2913 if let Some(texture) = self.static_texture(path) {
2914 let (width, height) = thumbnail_size(&texture, available);
2915 container.append(&thumbnail_picture(&texture, label_start, width, height));
2916 }
2917 }
2918 MediaKind::Gif => {
2919 let playing = self.playing.contains(path);
2921 let texture = if playing {
2922 self.gif_anims
2923 .borrow()
2924 .get(path)
2925 .map(|anim| gdk::Texture::for_pixbuf(&anim.iter.pixbuf()))
2926 } else {
2927 self.static_texture(path)
2928 };
2929 if let Some(texture) = texture {
2930 let (width, height) = thumbnail_size(&texture, available);
2931 let picture = thumbnail_picture(&texture, label_start, width, height);
2932 picture.set_tooltip_text(Some("Click to play"));
2933 if playing {
2934 self.gif_widgets
2935 .borrow_mut()
2936 .insert(path.to_path_buf(), picture.clone());
2937 }
2938 let msg = TreeMsg::ToggleThumbnailPlay(path.to_path_buf());
2939 let s = sender.clone();
2940 let click = gtk::GestureClick::new();
2941 click.set_button(1);
2942 click.connect_pressed(move |_, _, _, _| s.input(msg.clone()));
2943 picture.add_controller(click);
2944 container.append(&picture);
2945 }
2946 }
2947 MediaKind::Video => {
2948 if let Some(media) = self.media_file(path) {
2949 let video = gtk::Video::new();
2955 video.set_autoplay(false);
2956 video.set_media_stream(Some(&media));
2957 video.set_loop(true);
2958 video.add_css_class("tree-thumbnail");
2959 video.set_halign(gtk::Align::Start);
2960 let width = available.max(64);
2961 video.set_size_request(width, (width * 9 / 16).max(36));
2962 video.set_margin_start(label_start);
2963 video.set_margin_bottom(6);
2964 container.append(&video);
2965 }
2966 }
2967 MediaKind::Audio => {
2968 if let Some(player) = self.audio_player(path) {
2969 let (controls, widgets) = build_audio_player(&player, label_start);
2973 self.audio_widgets.borrow_mut().insert(path.to_path_buf(), widgets);
2974 container.append(&controls);
2975 self.ensure_audio_ticker();
2976 }
2977 }
2978 }
2979 }
2980
2981 fn expand_dir(&mut self, path: &Path, sender: &ComponentSender<Self>) {
2982 let Some(tree) = self.tree.as_mut() else {
2983 return;
2984 };
2985 if let Err(err) = tree.expand(path, &StdDirSource) {
2986 self.status(format!("Could not open {}: {err}", path.display()), sender);
2987 }
2988 self.refresh_rows();
2989 }
2990
2991 fn activate(&mut self, path: &Path, sender: &ComponentSender<Self>) {
2992 let Some(index) = self.rows.iter().position(|r| r.path == path) else {
2993 return;
2994 };
2995 if self.rows[index].is_dir {
2996 self.open_root(path, sender);
3000 } else if let Err(err) = open_in_app(path) {
3001 self.status(err, sender);
3002 }
3003 }
3004
3005 fn confirm_trash(&mut self, paths: Vec<PathBuf>, sender: ComponentSender<Self>) {
3006 let message = match paths.as_slice() {
3007 [single] => {
3008 let name = single
3009 .file_name()
3010 .map(|n| n.to_string_lossy().into_owned())
3011 .unwrap_or_else(|| single.display().to_string());
3012 format!("Move \u{201c}{name}\u{201d} to trash?")
3013 }
3014 many => format!("Move {} items to trash?", many.len()),
3015 };
3016 let dialog = gtk::AlertDialog::builder()
3017 .modal(true)
3018 .message(message)
3019 .detail("The panel cannot restore trashed items.")
3020 .buttons(["Cancel", "Trash"])
3021 .cancel_button(0)
3022 .default_button(1)
3023 .build();
3024 dialog.choose(Some(&self.parent), None::<&gio::Cancellable>, move |res| {
3025 if matches!(res, Ok(1)) {
3026 sender.input(TreeMsg::ConfirmTrash(paths));
3027 }
3028 });
3029 }
3030
3031 fn confirm_permanent_delete(&mut self, paths: Vec<PathBuf>, sender: ComponentSender<Self>) {
3032 let message = match paths.as_slice() {
3033 [single] => {
3034 let name = single
3035 .file_name()
3036 .map(|n| n.to_string_lossy().into_owned())
3037 .unwrap_or_else(|| single.display().to_string());
3038 format!("Permanently delete \u{201c}{name}\u{201d}?")
3039 }
3040 many => format!("Permanently delete {} items?", many.len()),
3041 };
3042 let dialog = gtk::AlertDialog::builder()
3043 .modal(true)
3044 .message(message)
3045 .detail("This cannot be undone. Files will not go to the trash.")
3046 .buttons(["Cancel", "Delete Permanently"])
3047 .cancel_button(0)
3048 .default_button(1)
3049 .build();
3050 dialog.choose(Some(&self.parent), None::<&gio::Cancellable>, move |res| {
3051 if matches!(res, Ok(1)) {
3052 sender.input(TreeMsg::ConfirmPermanentDelete(paths));
3053 }
3054 });
3055 }
3056
3057 fn open_menu(&mut self, path: &Path) {
3058 let in_selection = self.selected.len() > 1 && self.selected.iter().any(|p| p == path);
3062 if !in_selection {
3063 self.select(path);
3064 } else if let Some(i) = self.rows.iter().position(|r| r.path == path) {
3065 self.cursor = Some(i);
3066 }
3067 if let Some(previous) = self.popover.take() {
3068 previous.unparent();
3069 }
3070 self.menu_target = Some(path.to_path_buf());
3071 self.menu_point = None;
3072 }
3073
3074 fn open_menu_at(&mut self, path: &Path, at: (f64, f64)) {
3078 if let Some(previous) = self.popover.take() {
3079 previous.unparent();
3080 }
3081 self.menu_target = Some(path.to_path_buf());
3082 self.menu_point = Some(at);
3083 }
3084
3085 fn paste(&mut self, sender: &ComponentSender<Self>) {
3086 let Some(clip) = self.clipboard.clone() else {
3087 self.paste_from_system_clipboard(sender);
3090 return;
3091 };
3092 let Some(dir) = self.target_dir() else {
3093 return;
3094 };
3095 let mut count = 0usize;
3096 let mut failed: Option<String> = None;
3097 for src in clip.paths {
3098 if src == dir {
3099 continue;
3100 }
3101 let result = match clip.op {
3102 ClipboardOp::Copy => self.ops.copy(&src, &dir),
3103 ClipboardOp::Cut => self.ops.move_(&src, &dir),
3104 };
3105 match result {
3106 Ok(dest) => {
3107 count += 1;
3108 self.apply_change(Change::Created { path: dest });
3109 if clip.op == ClipboardOp::Cut {
3110 self.apply_change(Change::Removed { path: src });
3111 }
3112 }
3113 Err(err) => {
3114 failed = Some(err.to_string());
3115 break;
3116 }
3117 }
3118 }
3119 if let Some(failed) = failed {
3120 self.status(failed, sender);
3121 return;
3122 }
3123 let verb = match clip.op {
3124 ClipboardOp::Copy => "Copied",
3125 ClipboardOp::Cut => "Moved",
3126 };
3127 self.status(format!("{verb} {count} item(s) into {}", dir.display()), sender);
3128 if clip.op == ClipboardOp::Cut {
3129 self.clipboard = None;
3130 }
3131 self.cursor_snap();
3132 }
3133
3134 fn paste_from_system_clipboard(&mut self, sender: &ComponentSender<Self>) {
3138 if self.target_dir().is_none() {
3139 return;
3140 }
3141 let s = sender.clone();
3142 read_clipboard_files(move |paths| s.input(TreeMsg::PastePaths(paths)));
3143 }
3144
3145 fn paste_paths(&mut self, paths: Vec<PathBuf>, sender: &ComponentSender<Self>) {
3147 let Some(dir) = self.target_dir() else {
3148 return;
3149 };
3150 if paths.is_empty() {
3151 self.status("Clipboard has no files to paste".to_string(), sender);
3152 return;
3153 }
3154 let mut count = 0usize;
3155 let mut failed: Option<String> = None;
3156 for src in paths {
3157 if src == dir {
3158 continue;
3159 }
3160 match self.ops.copy(&src, &dir) {
3161 Ok(dest) => {
3162 count += 1;
3163 self.apply_change(Change::Created { path: dest });
3164 }
3165 Err(err) => {
3166 failed = Some(err.to_string());
3167 break;
3168 }
3169 }
3170 }
3171 if let Some(failed) = failed {
3172 self.status(failed, sender);
3173 return;
3174 }
3175 self.status(format!("Pasted {count} item(s) into {}", dir.display()), sender);
3176 self.cursor_snap();
3177 }
3178
3179 fn request_drop_into(
3184 &mut self,
3185 target: PathBuf,
3186 sources: Vec<PathBuf>,
3187 copy: bool,
3188 sender: &ComponentSender<Self>,
3189 ) {
3190 match resolve_drop(&target, sources, copy) {
3191 DropPlan::Ignore => {}
3192 DropPlan::Reject(message) => self.status(message, sender),
3193 DropPlan::Copy(sources) => self.drop_into(&target, &sources, true, sender),
3194 DropPlan::Move(sources) => {
3195 if self.config.confirm_drop_move {
3196 self.confirm_move(target, sources, sender);
3197 } else {
3198 self.drop_into(&target, &sources, false, sender);
3199 }
3200 }
3201 }
3202 }
3203
3204 fn confirm_move(
3208 &mut self,
3209 target: PathBuf,
3210 sources: Vec<PathBuf>,
3211 sender: &ComponentSender<Self>,
3212 ) {
3213 let target_name = target
3214 .file_name()
3215 .map(|n| n.to_string_lossy().into_owned())
3216 .unwrap_or_else(|| target.display().to_string());
3217 let message = match sources.as_slice() {
3218 [single] => {
3219 let name = single
3220 .file_name()
3221 .map(|n| n.to_string_lossy().into_owned())
3222 .unwrap_or_else(|| single.display().to_string());
3223 format!("Move \u{201c}{name}\u{201d} into \u{201c}{target_name}\u{201d}?")
3224 }
3225 many => format!("Move {} items into \u{201c}{target_name}\u{201d}?", many.len()),
3226 };
3227 let dialog = gtk::AlertDialog::builder()
3228 .modal(true)
3229 .message(message)
3230 .detail("Hold Ctrl while dropping to copy instead of move.")
3231 .buttons(["Cancel", "Move"])
3232 .cancel_button(0)
3233 .default_button(1)
3234 .build();
3235 let s = sender.clone();
3236 dialog.choose(Some(&self.parent), None::<&gio::Cancellable>, move |res| {
3237 if matches!(res, Ok(1)) {
3238 s.input(TreeMsg::DropIntoConfirmed { target, sources });
3239 }
3240 });
3241 }
3242
3243 fn drop_into(
3247 &mut self,
3248 target: &Path,
3249 sources: &[PathBuf],
3250 copy: bool,
3251 sender: &ComponentSender<Self>,
3252 ) {
3253 let mut count = 0usize;
3254 let mut failed: Option<String> = None;
3255 for src in sources {
3256 if src == target || src.parent() == Some(target) {
3257 continue; }
3259 if !copy && target.starts_with(src) {
3260 failed = Some("Cannot move a folder into itself".to_string());
3261 break;
3262 }
3263 let result = if copy { self.ops.copy(src, target) } else { self.ops.move_(src, target) };
3264 match result {
3265 Ok(dest) => {
3266 count += 1;
3267 self.apply_change(Change::Created { path: dest });
3268 if !copy {
3269 self.apply_change(Change::Removed { path: src.clone() });
3270 }
3271 }
3272 Err(err) => {
3273 failed = Some(err.to_string());
3274 break;
3275 }
3276 }
3277 }
3278 if let Some(failed) = failed {
3279 self.status(failed, sender);
3280 return;
3281 }
3282 if count > 0 {
3283 let verb = if copy { "Copied" } else { "Moved" };
3284 self.status(format!("{verb} {count} item(s) into {}", target.display()), sender);
3285 }
3286 self.cursor_snap();
3287 }
3288
3289 fn create_entry(&mut self, is_dir: bool, sender: &ComponentSender<Self>) {
3290 let Some(dir) = self.target_dir() else {
3291 return;
3292 };
3293 let name = fresh_name(&dir, if is_dir { "new folder" } else { "new file" });
3294 let result = if is_dir {
3295 self.ops.create_dir(&dir, &name)
3296 } else {
3297 self.ops.create_file(&dir, &name)
3298 };
3299 match result {
3300 Ok(new_path) => {
3301 self.apply_change(Change::Created { path: new_path.clone() });
3302 self.renaming = Some(new_path.clone());
3303 self.renaming_state.set(true);
3304 self.select(&new_path);
3305 }
3306 Err(err) => self.status(format!("Could not create: {err}"), sender),
3307 }
3308 }
3309
3310 fn target_dir(&self) -> Option<PathBuf> {
3313 if let Some(path) = self.cursor_path() {
3314 let is_dir = self.rows.iter().find(|r| r.path == path).map(|r| r.is_dir).unwrap_or(false);
3315 if is_dir {
3316 return Some(path);
3317 }
3318 return path.parent().map(Path::to_path_buf);
3319 }
3320 self.tree.as_ref().map(|t| t.root().to_path_buf())
3321 }
3322
3323 fn apply_change(&mut self, change: Change) {
3324 if let Some(tree) = self.tree.as_mut() {
3325 tree.apply(&change, &StdDirSource);
3326 self.refresh_rows();
3327 }
3328 }
3329
3330 fn toggle_hidden(&mut self, sender: &ComponentSender<Self>) {
3333 let show = !self.config.show_hidden;
3334 self.config.show_hidden = show;
3335 if let Some(tree) = self.tree.as_mut() {
3336 tree.set_show_hidden(show);
3337 tree.reload_all(&StdDirSource);
3338 self.refresh_rows();
3339 }
3340 let label = if show { "Showing hidden files." } else { "Hiding hidden files." };
3341 self.status(label.to_string(), sender);
3342 }
3343
3344 fn set_sort_key(&mut self, key: SortKey, sender: &ComponentSender<Self>) {
3346 let mut opts = self.config.sort_options();
3347 opts.key = key;
3348 self.config.sort_key = key.as_str().to_owned();
3349 if let Some(tree) = self.tree.as_mut() {
3350 tree.set_sort(opts);
3351 tree.reload_all(&StdDirSource);
3352 self.refresh_rows();
3353 }
3354 self.status(format!("Sorting by {}.", key.as_str()), sender);
3355 }
3356
3357 fn toggle_sort_direction(&mut self, sender: &ComponentSender<Self>) {
3359 let mut opts = self.config.sort_options();
3360 opts.ascending = !opts.ascending;
3361 self.config.sort_ascending = opts.ascending;
3362 if let Some(tree) = self.tree.as_mut() {
3363 tree.set_sort(opts);
3364 tree.reload_all(&StdDirSource);
3365 self.refresh_rows();
3366 }
3367 let dir = if opts.ascending { "ascending" } else { "descending" };
3368 self.status(format!("Sort order: {dir}."), sender);
3369 }
3370
3371 fn status(&mut self, message: String, sender: &ComponentSender<Self>) {
3372 let _ = sender.output(TreeOutput::Status(message));
3373 }
3374
3375 fn run_shortcut(&mut self, target: ShortcutTarget, sender: &ComponentSender<Self>) {
3380 if let ShortcutTarget::Builtin(builtin) = &target
3383 && builtin.is_pane_action()
3384 {
3385 let _ = sender.output(TreeOutput::PaneAction(*builtin));
3386 return;
3387 }
3388 let has_selection = self.cursor.is_some() || !self.selected.is_empty();
3392 let path = match self.cursor_path() {
3393 Some(path) => path,
3394 None => match self.tree.as_ref().map(|t| t.root().to_path_buf()) {
3395 Some(root) => root,
3396 None => return,
3397 },
3398 };
3399
3400 if let ShortcutTarget::Command(cmd) = &target {
3402 if !has_selection {
3403 return;
3404 }
3405 let command = action_command(&cmd.command, &path);
3406 match spawn_command(&command) {
3407 Ok(()) => {
3408 self.status(format!("Ran {} on {}", cmd.command, path.display()), sender);
3409 }
3410 Err(err) => {
3411 self.status(format!("Could not run {}: {err}", cmd.command), sender);
3412 }
3413 }
3414 return;
3415 }
3416 let ShortcutTarget::Builtin(builtin) = target else {
3417 return;
3418 };
3419 if builtin == BuiltinAction::Separator
3420 || (builtin.is_directory_only() && !path.is_dir())
3421 {
3422 return;
3423 }
3424 if matches!(builtin, BuiltinAction::OpenWithDefault) && path.is_dir() {
3425 return;
3426 }
3427 if builtin == BuiltinAction::ViewThumbnail && !path.is_dir() && !is_thumbnailable(&path) {
3428 return;
3429 }
3430 if !has_selection && needs_selection(builtin) {
3432 return;
3433 }
3434 if has_selection && !self.selected.iter().any(|p| p == &path) {
3438 self.select(&path);
3439 }
3440 let selection = self.selected.clone();
3443 sender.input(action_message(builtin, &path, &selection));
3444 }
3445}
3446
3447fn fresh_name(dir: &Path, stem: &str) -> String {
3452 for n in 0.. {
3453 let name = if n == 0 { stem.to_string() } else { format!("{stem} {n}") };
3454 if !dir.join(&name).exists() {
3455 return name;
3456 }
3457 }
3458 unreachable!("the loop above always finds a free name")
3459}
3460
3461fn open_in_app(path: &Path) -> Result<String, String> {
3470 let content_type = content_type_for(path)
3471 .ok_or_else(|| format!("Could not determine the type of {}", path.display()))?;
3472 let app = gio::AppInfo::default_for_type(&content_type, false)
3473 .ok_or_else(|| format!("No application is registered to open {}", path.display()))?;
3474 let file = gio::File::for_path(path);
3475 app.launch(&[file], None::<&gio::AppLaunchContext>)
3476 .map_err(|err| format!("Could not open {}: {err}", path.display()))?;
3477 Ok(app.display_name().to_string())
3478}
3479
3480fn content_type_for(path: &Path) -> Option<glib::GString> {
3487 let file = gio::File::for_path(path);
3488 let info = file
3489 .query_info(
3490 gio::FILE_ATTRIBUTE_STANDARD_CONTENT_TYPE,
3491 gio::FileQueryInfoFlags::NONE,
3492 None::<&gio::Cancellable>,
3493 )
3494 .ok()?;
3495 info.content_type()
3496}
3497
3498fn default_app_label(path: &Path) -> Option<String> {
3502 let content_type = content_type_for(path)?;
3503 gio::AppInfo::default_for_type(&content_type, false).map(|app| {
3504 format!("Open With {}", app.display_name())
3505 })
3506}
3507
3508#[allow(deprecated)] fn open_with_dialog(parent: >k::Window, path: &Path, sender: &ComponentSender<Tree>) {
3512 let Some(content_type) = content_type_for(path) else {
3513 let _ = sender.output(TreeOutput::Status(format!(
3514 "Could not determine the type of {}",
3515 path.display()
3516 )));
3517 return;
3518 };
3519 let dialog = gtk::AppChooserDialog::for_content_type(
3520 Some(parent),
3521 gtk::DialogFlags::MODAL,
3522 &content_type,
3523 );
3524 dialog.set_title(Some(&format!("Open {} with...", path.display())));
3525
3526 if gtk4_layer_shell::is_supported() && !dialog.is_layer_window() {
3532 dialog.init_layer_shell();
3533 dialog.set_layer(Layer::Overlay);
3534 dialog.set_keyboard_mode(KeyboardMode::Exclusive);
3535 dialog.set_default_size(540, 620);
3536 }
3538
3539 let dialog = dialog.clone();
3540 let path = path.to_path_buf();
3541 let s = sender.clone();
3542 dialog.connect_response(move |dialog, response| {
3543 if response == gtk::ResponseType::Ok
3544 && let Some(app) = dialog.app_info()
3545 {
3546 let file = gio::File::for_path(&path);
3547 match app.launch(&[file], None::<&gio::AppLaunchContext>) {
3548 Ok(()) => {
3549 let _ = s.output(TreeOutput::Status(format!(
3550 "Opened {} with {}",
3551 path.display(),
3552 app.display_name()
3553 )));
3554 }
3555 Err(err) => {
3556 let _ = s.output(TreeOutput::Status(format!(
3557 "Could not open {} with {}: {err}",
3558 path.display(),
3559 app.display_name()
3560 )));
3561 }
3562 }
3563 }
3564 dialog.close();
3565 });
3566 dialog.present();
3567}
3568
3569fn set_clipboard_text(text: &str) {
3571 if let Some(display) = gdk::Display::default() {
3572 display.clipboard().set_text(text);
3573 }
3574}
3575
3576const URI_LIST_MIME: &str = "text/uri-list";
3579
3580const GNOME_CLIP_MIME: &str = "x-special/gnome-copied-files";
3584
3585fn read_clipboard_files(done: impl FnOnce(Vec<PathBuf>) + 'static) {
3590 let Some(display) = gdk::Display::default() else {
3591 done(Vec::new());
3592 return;
3593 };
3594 let clipboard = display.clipboard();
3595 let formats = clipboard.formats();
3596 let has_uri = formats.contain_mime_type(URI_LIST_MIME);
3597 let has_gnome = formats.contain_mime_type(GNOME_CLIP_MIME);
3598 if !has_uri && !has_gnome {
3599 done(Vec::new());
3600 return;
3601 }
3602 clipboard.read_text_async(gio::Cancellable::NONE, move |result| {
3603 let text = result.ok().flatten().unwrap_or_default();
3604 done(parse_clipboard_uris(&text, has_gnome && !has_uri));
3605 });
3606}
3607
3608fn parse_clipboard_uris(text: &str, verb_line: bool) -> Vec<PathBuf> {
3614 text.lines()
3615 .map(str::trim)
3616 .filter(|line| !line.is_empty() && !line.starts_with('#'))
3617 .filter_map(|line| {
3618 if verb_line && (line.eq_ignore_ascii_case("copy") || line.eq_ignore_ascii_case("cut")) {
3619 return None;
3620 }
3621 let (path, _host) = glib::filename_from_uri(line).ok()?;
3622 path.is_absolute().then_some(path)
3623 })
3624 .collect()
3625}
3626
3627fn spawn_command(cmd: &[String]) -> Result<(), std::io::Error> {
3628 if cmd.is_empty() {
3629 return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "empty command"));
3630 }
3631 let mut process = std::process::Command::new(&cmd[0]);
3632 for arg in &cmd[1..] {
3633 process.arg(arg);
3634 }
3635 process.spawn().map(|_| ())
3636}
3637
3638#[cfg(test)]
3639mod tests {
3640 use super::*;
3641
3642 #[test]
3643 fn media_detection_is_extension_based_and_case_insensitive() {
3644 for name in ["a.JPG", "b.jpeg", "c.png", "d.webp", "e.HEIC", "f.tiff"] {
3645 assert_eq!(media_kind(Path::new(name)), Some(MediaKind::Image), "{name}");
3646 }
3647 for name in ["loop.GIF", "x.gif"] {
3648 assert_eq!(media_kind(Path::new(name)), Some(MediaKind::Gif), "{name}");
3649 }
3650 for name in ["clip.mp4", "b.MKV", "c.webm", "d.mov", "e.OGV"] {
3651 assert_eq!(media_kind(Path::new(name)), Some(MediaKind::Video), "{name}");
3652 }
3653 for name in ["song.MP3", "b.flac", "c.wav", "d.m4a", "e.opus", "f.oga"] {
3654 if cfg!(feature = "audio") {
3655 assert_eq!(media_kind(Path::new(name)), Some(MediaKind::Audio), "{name}");
3656 assert!(is_thumbnailable(Path::new(name)), "{name}");
3657 } else {
3658 assert_eq!(media_kind(Path::new(name)), None, "{name}");
3660 }
3661 }
3662 for name in ["a.svg", "b.txt", "c.rs", "noext", "d.png.bak"] {
3663 assert_eq!(media_kind(Path::new(name)), None, "{name}");
3664 assert!(!is_thumbnailable(Path::new(name)), "{name}");
3665 }
3666 }
3667
3668 #[test]
3669 fn thumbnail_width_tracks_the_panel_and_stays_positive() {
3670 let panel = PanelConfig { width: 300, margin: 0, ..PanelConfig::default() };
3671 assert_eq!(thumbnail_content_width(panel), 276);
3672
3673 let panel = PanelConfig { width: 500, margin: 10, ..PanelConfig::default() };
3674 assert_eq!(thumbnail_content_width(panel), 456);
3675
3676 let tiny = PanelConfig { width: 40, margin: 0, ..PanelConfig::default() };
3677 assert_eq!(thumbnail_content_width(tiny), 64);
3678 }
3679
3680 #[test]
3681 fn accelerator_normalization_supports_both_syntaxes() {
3682 assert_eq!(normalize_accelerator("Ctrl+x"), "<Control>x");
3684 assert_eq!(normalize_accelerator("Ctrl+Shift+m"), "<Control><Shift>m");
3685 assert_eq!(normalize_accelerator("Shift+Delete"), "<Shift>Delete");
3686 assert_eq!(normalize_accelerator("ctrl+alt+t"), "<Control><Alt>t");
3687 assert_eq!(normalize_accelerator("<Control>x"), "<Control>x");
3689 assert_eq!(normalize_accelerator("F2"), "F2");
3690 assert_eq!(normalize_accelerator("Delete"), "Delete");
3691 }
3692
3693 fn row(path: &str) -> VisibleRow {
3694 VisibleRow {
3695 path: PathBuf::from(path),
3696 name: path.rsplit('/').next().unwrap().to_string(),
3697 depth: 0,
3698 is_dir: false,
3699 is_symlink: false,
3700 expanded: false,
3701 has_children: false,
3702 matches: false,
3703 }
3704 }
3705
3706 #[test]
3707 fn parse_clipboard_uris_handles_both_formats() {
3708 let uri_list = "#comment\r\nfile:///tmp/a.txt\r\n\r\nfile:///tmp/b%20c.txt\r\n";
3710 assert_eq!(
3711 parse_clipboard_uris(uri_list, false),
3712 vec![PathBuf::from("/tmp/a.txt"), PathBuf::from("/tmp/b c.txt")]
3713 );
3714 let gnome = "cut\nfile:///home/u/x\n";
3716 assert_eq!(parse_clipboard_uris(gnome, true), vec![PathBuf::from("/home/u/x")]);
3717 assert_eq!(parse_clipboard_uris("copy\n", false), Vec::<PathBuf>::new());
3719 assert_eq!(
3721 parse_clipboard_uris("https://example.com/x\nrelative/path\nfile:///ok\n", false),
3722 vec![PathBuf::from("/ok")]
3723 );
3724 assert!(parse_clipboard_uris("", false).is_empty());
3725 }
3726
3727 #[test]
3728 fn icon_name_matches_extensions_case_insensitively() {
3729 assert_eq!(icon_name(&row("/a/Photo.JPG")), "image-x-generic-symbolic");
3730 assert_eq!(icon_name(&row("/a/song.Flac")), "audio-x-generic-symbolic");
3731 assert_eq!(icon_name(&row("/a/clip.mkv")), "video-x-generic-symbolic");
3732 assert_eq!(icon_name(&row("/a/archive.tar")), "package-x-generic-symbolic");
3733 assert_eq!(icon_name(&row("/a/doc.pdf")), "application-pdf-symbolic");
3734 assert_eq!(icon_name(&row("/a/notes.txt")), "text-x-generic-symbolic");
3735 let mut dir = row("/a/pictures");
3737 dir.is_dir = true;
3738 assert_eq!(icon_name(&dir), "folder-symbolic");
3739 dir.expanded = true;
3740 assert_eq!(icon_name(&dir), "folder-open-symbolic");
3741 assert_eq!(icon_name(&row("/a/.bashrc")), "text-x-generic-symbolic");
3743 }
3744
3745 #[test]
3746 fn resolve_drop_filters_noops_and_detects_cycles() {
3747 let target = Path::new("/a/b");
3748 assert_eq!(resolve_drop(target, vec![PathBuf::from("/a/b")], false), DropPlan::Ignore);
3750 assert_eq!(resolve_drop(target, vec![PathBuf::from("/a/b/c")], false), DropPlan::Ignore);
3751 assert!(matches!(
3753 resolve_drop(target, vec![PathBuf::from("/a")], false),
3754 DropPlan::Reject(_)
3755 ));
3756 assert!(matches!(
3758 resolve_drop(target, vec![PathBuf::from("/x/y")], false),
3759 DropPlan::Move(_)
3760 ));
3761 assert!(matches!(
3762 resolve_drop(target, vec![PathBuf::from("/x/y")], true),
3763 DropPlan::Copy(_)
3764 ));
3765 }
3766
3767 #[test]
3768 fn drag_paths_uses_the_selection_only_when_the_row_is_in_it() {
3769 let sel = vec![PathBuf::from("/r/a"), PathBuf::from("/r/b")];
3770 assert_eq!(drag_paths(&sel, Path::new("/r/b")), sel);
3772 assert_eq!(
3774 drag_paths(&sel, Path::new("/r/c")),
3775 vec![PathBuf::from("/r/c")]
3776 );
3777 assert_eq!(
3779 drag_paths(&[PathBuf::from("/r/a")], Path::new("/r/a")),
3780 vec![PathBuf::from("/r/a")]
3781 );
3782 assert_eq!(
3784 drag_paths(&[], Path::new("/r/z")),
3785 vec![PathBuf::from("/r/z")]
3786 );
3787 }
3788
3789 #[test]
3790 fn index_range_is_inclusive_and_order_independent() {
3791 assert_eq!(index_range(2, 2).collect::<Vec<_>>(), vec![2]);
3792 assert_eq!(index_range(1, 4).collect::<Vec<_>>(), vec![1, 2, 3, 4]);
3793 assert_eq!(index_range(4, 1).collect::<Vec<_>>(), vec![1, 2, 3, 4]);
3795 }
3796
3797 #[test]
3798 fn format_uri_list_serializes_file_uris() {
3799 let text = format_uri_list(&[PathBuf::from("/tmp/a.txt"), PathBuf::from("/tmp/b c.txt")]);
3800 assert_eq!(text, "file:///tmp/a.txt\r\nfile:///tmp/b%20c.txt\r\n");
3801 assert!(format_uri_list(&[]).is_empty());
3802 assert_eq!(
3804 parse_clipboard_uris(&text, false),
3805 vec![PathBuf::from("/tmp/a.txt"), PathBuf::from("/tmp/b c.txt")]
3806 );
3807 }
3808
3809 #[test]
3810 fn typeahead_finds_the_next_matching_row_and_wraps() {
3811 let rows = vec![row("/r/apple"), row("/r/banana"), row("/r/apricot"), row("/r/cherry")];
3812 assert_eq!(typeahead_target(&rows, "ap", None), Some(0));
3815 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);
3818 }
3819
3820 #[test]
3821 fn typeahead_buffer_resets_after_a_pause_and_caps_length() {
3822 let mut ta = TypeAhead::default();
3823 let t0 = std::time::Instant::now();
3824 assert_eq!(ta.push('a', t0).as_deref(), Some("a"));
3825 assert_eq!(ta.push('b', t0 + Duration::from_millis(100)).as_deref(), Some("ab"));
3826 assert_eq!(ta.push('c', t0 + Duration::from_millis(2000)).as_deref(), Some("c"));
3828 assert_eq!(ta.push('\u{1}', t0), None);
3830 }
3831}