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
434 RenameCommit,
435 RenameCancel,
436
437 Copy,
438 Cut,
439 Paste,
440 PastePaths(Vec<PathBuf>),
443 NewFile,
444 NewFolder,
445
446 ToggleHidden,
448 SetSortKey(SortKey),
450 ToggleSortDirection,
452
453 FsChange(Change),
454 FlushChanges,
455 RequestOpenFolder,
456
457 DropInto { target: PathBuf, sources: Vec<PathBuf>, copy: bool },
462 DropIntoRoot { sources: Vec<PathBuf>, copy: bool },
465 DropIntoConfirmed { target: PathBuf, sources: Vec<PathBuf> },
467 Shutdown,
469}
470
471#[derive(Debug)]
473pub enum TreeOutput {
474 Status(String),
476 RootChanged(PathBuf),
478 OpenFolderRequested,
480 OpenSplit(PathBuf),
482 OpenOpposite(PathBuf),
484 PaneAction(BuiltinAction),
488}
489
490pub struct Tree {
492 tree: Option<TreeModel>,
493 ops: FileOps,
494 config: TreeConfig,
495 parent: gtk::Window,
496 menu: ContextMenu,
497 side: PanelSide,
498
499 thumbnails: HashSet<PathBuf>,
503 thumb_cache: HashMap<PathBuf, gdk::Texture>,
506 media: HashMap<PathBuf, gtk::MediaFile>,
509 audio: HashMap<PathBuf, Rc<AudioPlayer>>,
512 playing: HashSet<PathBuf>,
514 gif_anims: Rc<RefCell<HashMap<PathBuf, GifAnim>>>,
516 gif_widgets: Rc<RefCell<HashMap<PathBuf, gtk::Picture>>>,
519 gif_tick: Rc<RefCell<Option<glib::SourceId>>>,
521 audio_widgets: Rc<RefCell<HashMap<PathBuf, AudioWidgets>>>,
525 audio_tick: Rc<RefCell<Option<glib::SourceId>>>,
527 content_width: i32,
529
530 rows: Vec<VisibleRow>,
531 cursor: Option<usize>,
532 selected: Vec<PathBuf>,
533 anchor: Option<PathBuf>,
537 last_press: Option<(PathBuf, std::time::Instant)>,
541
542 renaming: Option<PathBuf>,
543 rename_entry: Option<gtk::Entry>,
544 clipboard: Option<Clipboard>,
545
546 pending: Vec<Change>,
547 flush_source: Option<glib::SourceId>,
548 _watcher: Option<RecommendedWatcher>,
549 watched: HashSet<PathBuf>,
552
553 popover: Option<gtk::Popover>,
556 menu_target: Option<PathBuf>,
560 menu_point: Option<(f64, f64)>,
563
564 renaming_state: Rc<Cell<bool>>,
566
567 selection_dirty: bool,
572 typeahead: TypeAhead,
575 scroll_to: Option<usize>,
578
579 drag_payload: Rc<RefCell<Vec<PathBuf>>>,
584 drag_active: Rc<Cell<bool>>,
588 pending_click: Option<PathBuf>,
592 drag_landed_internal: Rc<Cell<bool>>,
596 drag_origin: Rc<RefCell<Option<DragOrigin>>>,
600 drag_button_down: Rc<Cell<bool>>,
605}
606
607#[relm4::component(pub)]
608impl Component for Tree {
609 type Init = TreeInit;
610 type Input = TreeMsg;
611 type Output = TreeOutput;
612 type CommandOutput = ();
613
614 view! {
615 scrolled = gtk::ScrolledWindow {
616 set_vexpand: true,
617 set_hexpand: true,
618 set_min_content_width: 160,
619 add_css_class: "tree-scroll",
620
621 #[name = "list"]
622 gtk::ListBox {
623 set_selection_mode: gtk::SelectionMode::None,
624 set_activate_on_single_click: false,
625 }
626 }
627 }
628
629 fn init(
630 init: Self::Init,
631 root: Self::Root,
632 sender: ComponentSender<Self>,
633 ) -> ComponentParts<Self> {
634 let renaming_state = Rc::new(Cell::new(false));
635 let keys = compile_shortcuts(&init.menu);
636 let widgets = view_output!();
637
638 let controller = gtk::EventControllerKey::new();
639 controller.set_propagation_phase(gtk::PropagationPhase::Capture);
640 let capture_sender = sender.clone();
641 let capture_state = renaming_state.clone();
642 let capture_keys = keys.clone();
643 controller.connect_key_pressed(move |_ctrl, key, _keycode, state| {
644 handle_key(
645 key,
646 state,
647 capture_state.get(),
648 &capture_keys,
649 &capture_sender,
650 )
651 });
652 widgets.scrolled.add_controller(controller);
653
654 let root_drop = gtk::DropTarget::new(gdk::FileList::static_type(), gdk::DragAction::MOVE | gdk::DragAction::COPY);
660 let root_drop_sender = sender.clone();
661 root_drop.connect_drop(move |target, value, _x, _y| {
662 let Ok(list) = value.get::<gdk::FileList>() else {
663 return false;
664 };
665 let sources: Vec<PathBuf> = list.files().iter().filter_map(gio::File::path).collect();
666 if sources.is_empty() {
667 return false;
668 }
669 let copy = target.current_event_state().contains(gdk::ModifierType::CONTROL_MASK);
670 root_drop_sender.input(TreeMsg::DropIntoRoot { sources, copy });
671 true
672 });
673 widgets.list.add_controller(root_drop);
674
675 let blank_scrolled = widgets.scrolled.clone();
681 let blank_click = gtk::GestureClick::new();
682 blank_click.set_button(0);
683 blank_click.set_propagation_phase(gtk::PropagationPhase::Capture);
684 let blank_sender = sender.clone();
685 blank_click.connect_pressed(move |gesture, _n_press, x, y| {
686 if gesture.current_button() == 3
689 && gesture.current_event_state().contains(gdk::ModifierType::SUPER_MASK)
690 {
691 gesture.set_state(gtk::EventSequenceState::Denied);
692 return;
693 }
694 let on_row = blank_scrolled
695 .pick(x, y, gtk::PickFlags::DEFAULT)
696 .is_some_and(|widget| in_list_row(&widget));
697 if on_row {
698 return;
699 }
700 match gesture.current_button() {
701 1 => blank_sender.input(TreeMsg::Deselect),
702 3 => blank_sender.input(TreeMsg::MenuAt { x, y }),
703 _ => {}
704 }
705 });
706 widgets.scrolled.add_controller(blank_click);
707
708 let model = Tree {
709 tree: None,
710 ops: FileOps::new(),
711 config: init.config,
712 parent: init.parent,
713 menu: init.menu,
714 side: init.side,
715 thumbnails: HashSet::new(),
716 thumb_cache: HashMap::new(),
717 media: HashMap::new(),
718 audio: HashMap::new(),
719 playing: HashSet::new(),
720 gif_anims: Rc::new(RefCell::new(HashMap::new())),
721 gif_widgets: Rc::new(RefCell::new(HashMap::new())),
722 gif_tick: Rc::new(RefCell::new(None)),
723 audio_widgets: Rc::new(RefCell::new(HashMap::new())),
724 audio_tick: Rc::new(RefCell::new(None)),
725 content_width: thumbnail_content_width(init.panel),
726 rows: Vec::new(),
727 cursor: None,
728 selected: Vec::new(),
729 anchor: None,
730 last_press: None,
731 renaming: None,
732 rename_entry: None,
733 clipboard: None,
734 pending: Vec::new(),
735 flush_source: None,
736 _watcher: None,
737 watched: HashSet::new(),
738 popover: None,
739 menu_target: None,
740 menu_point: None,
741 renaming_state,
742 selection_dirty: false,
743 typeahead: TypeAhead::default(),
744 scroll_to: None,
745 drag_payload: Rc::new(RefCell::new(Vec::new())),
746 drag_active: Rc::new(Cell::new(false)),
747 pending_click: None,
748 drag_landed_internal: Rc::new(Cell::new(false)),
749 drag_origin: Rc::new(RefCell::new(None)),
750 drag_button_down: Rc::new(Cell::new(false)),
751 };
752
753 {
763 let legacy = gtk::EventControllerLegacy::new();
764 legacy.set_propagation_phase(gtk::PropagationPhase::Capture);
765 let origin = model.drag_origin.clone();
766 let payload = model.drag_payload.clone();
767 let active = model.drag_active.clone();
768 let landed = model.drag_landed_internal.clone();
769 let down = model.drag_button_down.clone();
770 let drag_root = widgets.scrolled.clone();
771 let s = sender.clone();
772 legacy.connect_event(move |_controller, event| {
773 match event.event_type() {
774 gdk::EventType::ButtonPress => {
775 let button = event
776 .downcast_ref::<gdk::ButtonEvent>()
777 .map(|b| b.button())
778 .unwrap_or(0);
779 if button == 1
780 && let Some((x, y)) = event.position()
781 {
782 down.set(true);
783 *origin.borrow_mut() = Some(DragOrigin { x, y });
784 }
785 }
786 gdk::EventType::ButtonRelease => {
787 let button = event
788 .downcast_ref::<gdk::ButtonEvent>()
789 .map(|b| b.button())
790 .unwrap_or(0);
791 if button == 1 {
792 down.set(false);
793 if !active.get() {
794 *origin.borrow_mut() = None;
795 }
796 }
797 }
798 gdk::EventType::MotionNotify => {
799 if active.get() || !down.get() {
800 return glib::Propagation::Proceed;
801 }
802 let Some((x, y)) = event.position() else {
803 return glib::Propagation::Proceed;
804 };
805 let start = {
806 let guard = origin.borrow();
807 match guard.as_ref() {
808 Some(o) => (o.x, o.y),
809 None => return glib::Propagation::Proceed,
810 }
811 };
812 if !past_drag_threshold(drag_root.upcast_ref(), start, (x, y)) {
813 return glib::Propagation::Proceed;
814 }
815 let paths = payload.borrow().clone();
816 if paths.is_empty() {
817 return glib::Propagation::Proceed;
818 }
819 *origin.borrow_mut() = None;
821 let finished = {
822 let active = active.clone();
823 let landed = landed.clone();
824 let s = s.clone();
825 let payload = payload.clone();
826 move |_drag: gdk::Drag, delete: bool| {
827 active.set(false);
828 if delete && !landed.replace(false) {
831 let paths = payload.borrow().clone();
832 if !paths.is_empty() {
833 s.input(TreeMsg::DroppedAsMove(paths));
834 }
835 }
836 }
837 };
838 let cancelled = {
839 let active = active.clone();
840 let landed = landed.clone();
841 move |_drag: gdk::Drag, _reason: gdk::DragCancelReason| {
842 active.set(false);
843 landed.set(false);
844 }
845 };
846 if begin_row_drag(
847 drag_root.upcast_ref(),
848 &paths,
849 start,
850 gdk::DragAction::MOVE | gdk::DragAction::COPY,
851 finished,
852 cancelled,
853 ) {
854 active.set(true);
855 s.input(TreeMsg::DragStarted);
856 }
857 }
858 _ => {}
859 }
860 glib::Propagation::Proceed
861 });
862 widgets.scrolled.add_controller(legacy);
863 }
864
865 ComponentParts { model, widgets }
866 }
867
868 fn update_with_view(
869 &mut self,
870 widgets: &mut Self::Widgets,
871 message: Self::Input,
872 sender: ComponentSender<Self>,
873 root: &Self::Root,
874 ) {
875 let _ = root;
876 if let TreeMsg::Focus = message {
879 let index = self.cursor.unwrap_or(0) as i32;
883 if let Some(row) = widgets
884 .list
885 .row_at_index(index)
886 .or_else(|| widgets.list.row_at_index(0))
887 {
888 row.grab_focus();
889 } else if !widgets.list.grab_focus() {
890 widgets.scrolled.grab_focus();
891 }
892 return;
893 }
894 self.handle_message(message, sender.clone());
895 if let Some(index) = self.scroll_to.take() {
899 scroll_row_into_view(widgets, index);
900 }
901 self.reconcile_watches();
905 if self.selection_dirty {
906 self.selection_dirty = false;
909 if update_selection_classes(self, widgets) {
910 return;
911 }
912 }
913 <Self as Component>::update_view(self, widgets, sender.clone());
914 rebuild(self, widgets, sender);
915 }
916}
917
918impl Tree {
919 fn handle_message(&mut self, msg: TreeMsg, sender: ComponentSender<Self>) {
920 match msg {
921 TreeMsg::OpenRoot(path) => self.open_root(&path, &sender),
922 TreeMsg::SelectPath(path) => self.reveal_path(path),
923 TreeMsg::Focus => {}
925 TreeMsg::SetFilter(filter) => {
926 if let Some(tree) = self.tree.as_mut() {
927 tree.set_filter(&filter);
928 self.refresh_rows();
929 }
930 }
931 TreeMsg::SetPanel(panel) => self.content_width = thumbnail_content_width(panel),
932 TreeMsg::Shutdown => self.shutdown(),
933
934 TreeMsg::MoveUp => self.cursor_delta(-1),
935 TreeMsg::MoveDown => self.cursor_delta(1),
936 TreeMsg::ExtendSelection(delta) => self.cursor_extend(delta, false),
937 TreeMsg::MoveExtendingSelection(delta) => self.cursor_extend(delta, true),
938 TreeMsg::CollapseCursor => {
939 if let Some(path) = self.cursor_path()
940 && self.tree.as_ref().is_some_and(|t| t.is_expanded(&path))
941 {
942 self.collapse_dir(&path);
943 }
944 }
945 TreeMsg::ExpandCursor => {
946 if let Some(path) = self.cursor_path() {
947 self.expand_dir(&path, &sender);
948 }
949 }
950 TreeMsg::ActivateCursor => {
951 if let Some(path) = self.cursor_path() {
952 self.activate(&path, &sender);
953 }
954 }
955 TreeMsg::Rename => {
956 if let Some(path) = self.cursor_path() {
957 self.renaming = Some(path);
958 self.renaming_state.set(true);
959 }
960 }
961
962 TreeMsg::Activate(path) => self.activate(&path, &sender),
963 TreeMsg::Toggle(path) => self.toggle_dir(&path, &sender),
964 TreeMsg::Select(path) => {
965 self.select(&path);
966 self.anchor = Some(path);
967 self.selection_dirty = true;
968 }
969 TreeMsg::ToggleSelect(path) => {
970 if let Some(i) = self.rows.iter().position(|r| r.path == path) {
971 self.cursor = Some(i);
972 }
973 if let Some(i) = self.selected.iter().position(|p| p == &path) {
974 self.selected.remove(i);
975 } else {
976 self.selected.push(path.clone());
977 }
978 self.anchor = Some(path);
979 self.selection_dirty = true;
980 }
981 TreeMsg::RangeSelect(path) => {
982 self.range_select(&path);
983 self.selection_dirty = true;
984 }
985 TreeMsg::RowPress { path, is_dir, ctrl, shift } => {
986 if ctrl {
991 sender.input(TreeMsg::ToggleSelect(path));
992 return;
993 }
994 if shift {
995 sender.input(TreeMsg::RangeSelect(path));
996 return;
997 }
998 *self.drag_payload.borrow_mut() = drag_paths(&self.selected, &path);
1003
1004 if self.selected.len() > 1 && self.selected.iter().any(|p| p == &path) {
1008 self.pending_click = Some(path.clone());
1009 if is_dir {
1010 self.toggle_dir(&path, &sender);
1011 }
1012 return;
1013 }
1014 self.pending_click = None;
1015 self.press_default(&path, is_dir, &sender);
1016 }
1017 TreeMsg::RowRelease { path } => {
1018 *self.drag_origin.borrow_mut() = None;
1020 self.drag_button_down.set(false);
1021 if self.drag_active.get() {
1024 return;
1025 }
1026 if self.pending_click.take().as_deref() == Some(path.as_path()) {
1027 let is_dir = self
1028 .rows
1029 .iter()
1030 .find(|r| r.path == path)
1031 .map(|r| r.is_dir)
1032 .unwrap_or(false);
1033 self.press_default(&path, is_dir, &sender);
1034 }
1035 }
1036 TreeMsg::DragStarted => {
1037 self.drag_active.set(true);
1039 self.pending_click = None;
1040 *self.drag_origin.borrow_mut() = None;
1041 self.drag_button_down.set(false);
1042 }
1043 TreeMsg::DroppedAsMove(paths) => {
1044 match self.ops.trash(&paths) {
1048 Ok(()) => {
1049 for path in &paths {
1050 self.apply_change(Change::Removed { path: path.clone() });
1051 }
1052 let n = paths.len();
1053 self.status(format!("Moved {n} item(s) out of the panel"), &sender);
1054 }
1055 Err(err) => self.status(format!("Could not finish move: {err}"), &sender),
1056 }
1057 }
1058 TreeMsg::Deselect => {
1059 self.selected.clear();
1060 self.cursor = None;
1061 self.anchor = None;
1062 self.typeahead.clear();
1063 self.selection_dirty = true;
1064 }
1065 TreeMsg::Menu(path) => self.open_menu(&path),
1066 TreeMsg::MenuAt { x, y } => {
1067 if let Some(root) = self.tree.as_ref().map(|t| t.root().to_path_buf()) {
1068 self.open_menu_at(&root, (x, y));
1069 }
1070 }
1071 TreeMsg::RenameAt(path) => {
1072 self.select(&path);
1073 self.renaming = Some(path);
1074 self.renaming_state.set(true);
1075 }
1076 TreeMsg::Trash(path) => self.confirm_trash(vec![path], sender.clone()),
1077 TreeMsg::DeleteSelected => {
1078 let paths = self.selected_for_clipboard();
1079 if !paths.is_empty() {
1080 self.confirm_trash(paths, sender.clone());
1081 }
1082 }
1083 TreeMsg::PermanentDelete(path) => {
1084 self.confirm_permanent_delete(vec![path], sender.clone());
1085 }
1086 TreeMsg::PermanentDeleteSelected => {
1087 let paths = self.selected_for_clipboard();
1088 if !paths.is_empty() {
1089 self.confirm_permanent_delete(paths, sender.clone());
1090 }
1091 }
1092 TreeMsg::ConfirmPermanentDelete(paths) => {
1093 if let Some(renaming) = self.renaming.as_ref()
1094 && paths.contains(renaming)
1095 {
1096 self.renaming = None;
1097 self.renaming_state.set(false);
1098 }
1099 match self.ops.delete_permanently(&paths) {
1100 Ok(()) => {
1101 for path in paths {
1102 self.apply_change(Change::Removed { path });
1103 }
1104 self.selected.clear();
1105 self.cursor_snap();
1106 }
1107 Err(err) => self.status(err.to_string(), &sender),
1108 }
1109 }
1110 TreeMsg::ConfirmTrash(paths) => {
1111 if let Some(renaming) = self.renaming.as_ref()
1112 && paths.contains(renaming)
1113 {
1114 self.renaming = None;
1115 self.renaming_state.set(false);
1116 }
1117 match self.ops.trash(&paths) {
1118 Ok(()) => {
1119 for path in paths {
1120 self.apply_change(Change::Removed { path });
1121 }
1122 self.selected.clear();
1123 self.cursor_snap();
1124 }
1125 Err(err) => self.status(err.to_string(), &sender),
1128 }
1129 }
1130 TreeMsg::Duplicate(path) => match self.ops.duplicate(&path) {
1131 Ok(new_path) => {
1132 self.apply_change(Change::Created { path: new_path.clone() });
1133 self.select(&new_path);
1134 }
1135 Err(err) => self.status(format!("Could not duplicate: {err}"), &sender),
1136 },
1137 TreeMsg::RequestDuplicateCursor => {
1138 if let Some(path) = self.cursor_path() {
1139 sender.input(TreeMsg::Duplicate(path));
1140 }
1141 }
1142 TreeMsg::CopyPath(path) => {
1143 set_clipboard_text(&path.display().to_string());
1144 self.status("Copied path to clipboard".to_string(), &sender);
1145 }
1146 TreeMsg::CopyRelativePath(path) => {
1147 let relative = self
1148 .tree
1149 .as_ref()
1150 .and_then(|t| path.strip_prefix(t.root()).ok())
1151 .map(|p| p.display().to_string())
1152 .unwrap_or_else(|| path.display().to_string());
1153 set_clipboard_text(&relative);
1154 self.status("Copied relative path to clipboard".to_string(), &sender);
1155 }
1156 TreeMsg::RunCommand { command, path } => {
1157 let cmd = action_command(&command, &path);
1158 match spawn_command(&cmd) {
1159 Ok(()) => self.status(format!("Ran {command} on {}", path.display()), &sender),
1160 Err(err) => self.status(format!("Could not run {command}: {err}"), &sender),
1161 }
1162 }
1163 TreeMsg::OpenSplit(path) => {
1164 let _ = sender.output(TreeOutput::OpenSplit(path));
1165 }
1166 TreeMsg::OpenInOppositePanel(path) => {
1167 let _ = sender.output(TreeOutput::OpenOpposite(path));
1168 }
1169 TreeMsg::OpenWith(path) => open_with_dialog(&self.parent.clone(), &path, &sender),
1170 TreeMsg::OpenWithDefault(path) => match open_in_app(&path) {
1171 Ok(app) => {
1172 self.status(format!("Opened {} with {app}", path.display()), &sender);
1173 }
1174 Err(err) => self.status(err, &sender),
1175 },
1176 TreeMsg::CreateLink(path) => match self.ops.create_link(&path) {
1177 Ok(new_path) => {
1178 self.apply_change(Change::Created { path: new_path.clone() });
1179 }
1180 Err(err) => self.status(format!("Could not create link: {err}"), &sender),
1181 },
1182 TreeMsg::Properties(path) => {
1183 crate::ui::props::show_properties_dialog(&self.parent.clone(), &path, &sender);
1184 }
1185 TreeMsg::PropertiesSelected(paths) => {
1186 crate::ui::props::show_multi_properties_dialog(&self.parent.clone(), &paths);
1187 }
1188 TreeMsg::CopyPaths(paths) => {
1189 let text = paths
1190 .iter()
1191 .map(|p| p.display().to_string())
1192 .collect::<Vec<_>>()
1193 .join("\n");
1194 set_clipboard_text(&text);
1195 self.status(format!("Copied {} paths to clipboard", paths.len()), &sender);
1196 }
1197 TreeMsg::CopyRelativePaths(paths) => {
1198 let root = self.tree.as_ref().map(|t| t.root().to_path_buf());
1199 let text = paths
1200 .iter()
1201 .map(|p| {
1202 root.as_deref()
1203 .and_then(|root| p.strip_prefix(root).ok())
1204 .map(|rel| rel.display().to_string())
1205 .unwrap_or_else(|| p.display().to_string())
1206 })
1207 .collect::<Vec<_>>()
1208 .join("\n");
1209 set_clipboard_text(&text);
1210 self.status(
1211 format!("Copied {} relative paths to clipboard", paths.len()),
1212 &sender,
1213 );
1214 }
1215 TreeMsg::RunCommandEach { command, paths } => {
1216 let mut failures = 0usize;
1217 for path in &paths {
1218 let cmd = action_command(&command, path);
1219 if spawn_command(&cmd).is_err() {
1220 failures += 1;
1221 }
1222 }
1223 let total = paths.len();
1224 if failures == 0 {
1225 self.status(format!("Ran {command} on {total} item(s)"), &sender);
1226 } else {
1227 self.status(
1228 format!("Ran {command} on {total} item(s); {failures} failed"),
1229 &sender,
1230 );
1231 }
1232 }
1233 TreeMsg::RenameTo { path, name } => match self.ops.rename(&path, &name) {
1234 Ok(new_path) => {
1235 self.apply_change(Change::Renamed {
1236 from: path,
1237 to: new_path.clone(),
1238 });
1239 self.select(&new_path);
1240 }
1241 Err(err) => self.status(format!("Could not rename: {err}"), &sender),
1242 },
1243 TreeMsg::ToggleThumbnail(path) => {
1244 if self.thumbnails.contains(&path) {
1245 self.clear_thumbnails_under(&path);
1247 } else {
1248 self.thumbnails.insert(path.clone());
1249 if path.is_dir() {
1251 self.expand_dir(&path, &sender);
1252 }
1253 }
1254 }
1255 TreeMsg::ToggleThumbnailPlay(path) => self.toggle_thumbnail_play(&path),
1256 TreeMsg::RunShortcut { target } => self.run_shortcut(target, &sender),
1257
1258 TreeMsg::SelectAllRows => {
1259 if !self.rows.is_empty() {
1260 self.selected = self.rows.iter().map(|r| r.path.clone()).collect();
1261 self.cursor = Some(0);
1262 self.anchor = self.rows.first().map(|r| r.path.clone());
1263 self.selection_dirty = true;
1264 }
1265 }
1266 TreeMsg::TypeAhead(ch) => {
1267 let now = std::time::Instant::now();
1268 if let Some(prefix) = self.typeahead.push(ch, now)
1269 && let Some(index) = typeahead_target(&self.rows, &prefix, self.cursor)
1270 {
1271 let path = self.rows[index].path.clone();
1272 self.cursor = Some(index);
1273 self.set_single_selection(path);
1274 self.selection_dirty = true;
1275 }
1276 }
1277
1278 TreeMsg::RenameCommit => {
1279 let text = self
1280 .rename_entry
1281 .as_ref()
1282 .map(|e| e.text().to_string())
1283 .unwrap_or_default();
1284 let Some(old) = self.renaming.take() else {
1285 return;
1286 };
1287 self.renaming_state.set(false);
1288 self.rename_entry = None;
1289 let trimmed = text.trim().to_string();
1290 let unchanged = old
1291 .file_name()
1292 .map(|n| n.to_string_lossy() == trimmed.as_str())
1293 .unwrap_or(false);
1294 if trimmed.is_empty() || unchanged {
1295 return;
1296 }
1297 match self.ops.rename(&old, &trimmed) {
1298 Ok(new_path) => {
1299 self.apply_change(Change::Renamed {
1300 from: old,
1301 to: new_path.clone(),
1302 });
1303 self.select(&new_path);
1304 }
1305 Err(err) => self.status(format!("Could not rename: {err}"), &sender),
1306 }
1307 }
1308 TreeMsg::RenameCancel => {
1309 self.renaming = None;
1310 self.renaming_state.set(false);
1311 self.rename_entry = None;
1312 }
1313
1314 TreeMsg::Copy => {
1315 let paths = self.selected_for_clipboard();
1316 if !paths.is_empty() {
1317 self.clipboard = Some(Clipboard { op: ClipboardOp::Copy, paths: paths.clone() });
1318 self.status(format!("Copying {} item(s)", paths.len()), &sender);
1319 }
1320 }
1321 TreeMsg::Cut => {
1322 let paths = self.selected_for_clipboard();
1323 if !paths.is_empty() {
1324 self.clipboard = Some(Clipboard { op: ClipboardOp::Cut, paths: paths.clone() });
1325 self.status(format!("Cutting {} item(s)", paths.len()), &sender);
1326 }
1327 }
1328 TreeMsg::Paste => self.paste(&sender),
1329 TreeMsg::PastePaths(paths) => self.paste_paths(paths, &sender),
1330
1331 TreeMsg::NewFile => self.create_entry(false, &sender),
1332 TreeMsg::NewFolder => self.create_entry(true, &sender),
1333
1334 TreeMsg::ToggleHidden => self.toggle_hidden(&sender),
1335 TreeMsg::SetSortKey(key) => self.set_sort_key(key, &sender),
1336 TreeMsg::ToggleSortDirection => self.toggle_sort_direction(&sender),
1337
1338 TreeMsg::FsChange(change) => {
1339 self.pending.push(change);
1341 if self.flush_source.is_none() {
1342 let s = sender.clone();
1343 let id = glib::timeout_add_local_once(
1344 Duration::from_millis(100),
1345 move || {
1346 s.input(TreeMsg::FlushChanges);
1347 }
1348 );
1349 self.flush_source = Some(id);
1350 }
1351 }
1352 TreeMsg::FlushChanges => {
1353 self.flush_source = None;
1354 let changes = std::mem::take(&mut self.pending);
1355 for change in changes {
1356 self.apply_change(change);
1357 }
1358 self.cursor_snap();
1359 }
1360 TreeMsg::RequestOpenFolder => {
1361 self.status("Pick a folder to browse...".to_string(), &sender);
1362 let _ = sender.output(TreeOutput::OpenFolderRequested);
1363 }
1364
1365 TreeMsg::DropInto { target, sources, copy } => {
1366 self.request_drop_into(target, sources, copy, &sender);
1367 }
1368 TreeMsg::DropIntoRoot { sources, copy } => {
1369 if let Some(root) = self.tree.as_ref().map(|t| t.root().to_path_buf()) {
1370 self.request_drop_into(root, sources, copy, &sender);
1371 }
1372 }
1373 TreeMsg::DropIntoConfirmed { target, sources } => {
1374 self.drop_into(&target, &sources, false, &sender);
1375 }
1376 }
1377 }
1378}
1379
1380fn accel_mods(m: gdk::ModifierType) -> gdk::ModifierType {
1387 m & (gdk::ModifierType::CONTROL_MASK
1388 | gdk::ModifierType::SHIFT_MASK
1389 | gdk::ModifierType::ALT_MASK
1390 | gdk::ModifierType::SUPER_MASK
1391 | gdk::ModifierType::HYPER_MASK
1392 | gdk::ModifierType::META_MASK)
1393}
1394
1395#[derive(Clone)]
1397struct KeyBinding {
1398 key: gdk::Key,
1399 mods: gdk::ModifierType,
1400 target: ShortcutTarget,
1401}
1402
1403fn compile_shortcuts(menu: &ContextMenu) -> Rc<Vec<KeyBinding>> {
1407 let mut out = Vec::new();
1408 for (accel, target) in menu.shortcuts() {
1409 if let Some((key, mods)) = parse_accelerator(&accel) {
1410 out.push(KeyBinding { key, mods, target });
1411 }
1412 }
1413 Rc::new(out)
1414}
1415
1416fn parse_accelerator(accel: &str) -> Option<(gdk::Key, gdk::ModifierType)> {
1422 gtk::accelerator_parse(normalize_accelerator(accel))
1423}
1424
1425fn normalize_accelerator(accel: &str) -> String {
1428 if !accel.contains('+') {
1429 return accel.to_owned();
1430 }
1431 let mut mods = String::new();
1432 let mut key = String::new();
1433 for token in accel.split('+') {
1434 let token = token.trim();
1435 if token.is_empty() {
1436 continue;
1437 }
1438 match token.to_ascii_lowercase().as_str() {
1439 "ctrl" | "control" | "primary" => mods.push_str("<Control>"),
1440 "shift" => mods.push_str("<Shift>"),
1441 "alt" => mods.push_str("<Alt>"),
1442 "super" | "win" | "mod4" => mods.push_str("<Super>"),
1443 "meta" => mods.push_str("<Meta>"),
1444 "hyper" => mods.push_str("<Hyper>"),
1445 _ => key = token.to_owned(),
1446 }
1447 }
1448 format!("{mods}{key}")
1449}
1450
1451fn handle_key(
1452 key: gdk::Key,
1453 state: gdk::ModifierType,
1454 renaming: bool,
1455 keys: &[KeyBinding],
1456 sender: &ComponentSender<Tree>,
1457) -> glib::Propagation {
1458 use gdk::Key;
1459
1460 if renaming {
1462 return glib::Propagation::Proceed;
1463 }
1464
1465 let ctrl = state.contains(gdk::ModifierType::CONTROL_MASK);
1466 let shift = state.contains(gdk::ModifierType::SHIFT_MASK);
1467
1468 let structural = match key {
1471 Key::Up if ctrl && shift => Some(TreeMsg::MoveExtendingSelection(-1)),
1472 Key::Down if ctrl && shift => Some(TreeMsg::MoveExtendingSelection(1)),
1473 Key::Up if shift => Some(TreeMsg::ExtendSelection(-1)),
1474 Key::Down if shift => Some(TreeMsg::ExtendSelection(1)),
1475 Key::Up => Some(TreeMsg::MoveUp),
1476 Key::Down => Some(TreeMsg::MoveDown),
1477 Key::Left => Some(TreeMsg::CollapseCursor),
1478 Key::Right => Some(TreeMsg::ExpandCursor),
1479 Key::Return | Key::KP_Enter | Key::space => Some(TreeMsg::ActivateCursor),
1480 Key::Escape => Some(TreeMsg::RenameCancel),
1481 Key::o | Key::O if ctrl && !shift => Some(TreeMsg::RequestOpenFolder),
1482 Key::a | Key::A if ctrl && !shift => Some(TreeMsg::SelectAllRows),
1483 _ => None,
1484 };
1485
1486 let msg = match structural {
1487 Some(msg) => msg,
1488 None => {
1489 let mut hit = None;
1493 for binding in keys.iter() {
1494 if key.to_lower() == binding.key.to_lower()
1495 && accel_mods(state) == accel_mods(binding.mods)
1496 {
1497 hit = Some(TreeMsg::RunShortcut {
1498 target: binding.target.clone(),
1499 });
1500 break;
1501 }
1502 }
1503 match hit {
1504 Some(msg) => msg,
1505 None if !ctrl
1508 && !state.contains(gdk::ModifierType::ALT_MASK)
1509 && !state.contains(gdk::ModifierType::SUPER_MASK) =>
1510 {
1511 if let Some(ch) = key.to_unicode()
1512 && ch.is_ascii_graphic()
1513 {
1514 TreeMsg::TypeAhead(ch)
1515 } else {
1516 return glib::Propagation::Proceed;
1517 }
1518 }
1519 None => return glib::Propagation::Proceed,
1520 }
1521 }
1522 };
1523
1524 sender.input(msg);
1525 glib::Propagation::Stop
1526}
1527
1528fn update_selection_classes(
1537 tree: &mut Tree,
1538 widgets: &mut <Tree as Component>::Widgets,
1539) -> bool {
1540 let mut existing = 0;
1542 let mut child = widgets.list.first_child();
1543 while let Some(current) = child {
1544 if current.is::<gtk::ListBoxRow>() {
1545 existing += 1;
1546 }
1547 child = current.next_sibling();
1548 }
1549 if existing != tree.rows.len() {
1550 return false;
1551 }
1552 for (index, row) in tree.rows.iter().enumerate() {
1553 let Some(list_row) = widgets.list.row_at_index(index as i32) else {
1554 return false;
1555 };
1556 let selected = tree.cursor == Some(index) || tree.selected.contains(&row.path);
1557 if selected {
1558 list_row.add_css_class("tree-row-selected");
1559 } else {
1560 list_row.remove_css_class("tree-row-selected");
1561 }
1562 }
1563 true
1564}
1565
1566fn scroll_row_into_view(widgets: &<Tree as Component>::Widgets, index: usize) {
1571 let Some(row) = widgets.list.row_at_index(index as i32) else {
1572 return;
1573 };
1574 let adj = widgets.scrolled.vadjustment();
1577 let page = adj.page_size();
1578 if page <= 0.0 {
1579 return;
1580 }
1581 let Some(bounds) = row.compute_bounds(&widgets.list) else {
1584 return;
1585 };
1586 let y = bounds.y() as f64;
1587 let height = bounds.height() as f64;
1588 let top = adj.value();
1589 if y < top {
1590 adj.set_value(y);
1591 } else if y + height > top + page {
1592 adj.set_value((y + height - page).max(0.0));
1593 }
1594}
1595
1596fn rebuild(tree: &mut Tree, widgets: &mut <Tree as Component>::Widgets, sender: ComponentSender<Tree>) {
1597 let list = &widgets.list;
1598
1599 if let Some(previous) = tree.popover.take() {
1608 previous.unparent();
1609 }
1610
1611 widgets.scrolled.set_child(None::<>k::Widget>);
1616 list.remove_all();
1617
1618 if tree.tree.is_none() {
1619 widgets.scrolled.set_child(Some(list));
1620 return;
1621 }
1622
1623 let rows = std::mem::take(&mut tree.rows);
1626 let mut menu_row: Option<gtk::ListBoxRow> = None;
1627 tree.rename_entry = None;
1628 tree.gif_widgets.borrow_mut().clear();
1631 tree.audio_widgets.borrow_mut().clear();
1634
1635 for (index, row) in rows.iter().enumerate() {
1636 let list_row = gtk::ListBoxRow::new();
1637 list_row.set_focusable(true);
1638 list_row.set_can_focus(true);
1639 list_row.add_css_class("tree-row");
1640
1641 let row_indent = row.depth as i32 * 16 + 4;
1642 let container = gtk::Box::new(gtk::Orientation::Vertical, 0);
1644 container.set_hexpand(true);
1645 list_row.set_child(Some(&container));
1646
1647 let hbox = gtk::Box::new(gtk::Orientation::Horizontal, 6);
1648 hbox.set_margin_start(row_indent);
1649 hbox.set_hexpand(true);
1650 container.append(&hbox);
1651
1652 let icon = gtk::Image::from_icon_name(icon_name(row));
1653 icon.set_pixel_size(tree.config.icon_size as i32);
1654 icon.set_valign(gtk::Align::Center);
1655 hbox.append(&icon);
1656
1657 if tree.renaming.as_ref() == Some(&row.path) {
1658 let entry = gtk::Entry::new();
1662 entry.set_text(&row.name);
1663 entry.set_hexpand(true);
1664 entry.add_css_class("tree-rename-entry");
1665 {
1666 let s = sender.clone();
1667 entry.connect_activate(move |_| s.input(TreeMsg::RenameCommit));
1668 }
1669 let esc_controller = gtk::EventControllerKey::new();
1670 let s = sender.clone();
1671 esc_controller.connect_key_pressed(move |_, key, _code, _state| {
1672 if key == gdk::Key::Escape {
1673 s.input(TreeMsg::RenameCancel);
1674 glib::Propagation::Stop
1675 } else {
1676 glib::Propagation::Proceed
1677 }
1678 });
1679 entry.add_controller(esc_controller);
1680 hbox.append(&entry);
1681 tree.rename_entry = Some(entry.clone());
1682 entry.grab_focus();
1683 } else {
1684 let label = gtk::Label::new(Some(&row.name));
1685 label.set_xalign(0.0);
1686 label.set_hexpand(true);
1687 label.set_ellipsize(pango::EllipsizeMode::Middle);
1688 label.set_tooltip_text(Some(&row.path.to_string_lossy()));
1689 hbox.append(&label);
1690 }
1691
1692 if tree.cursor == Some(index) || tree.selected.contains(&row.path) {
1693 list_row.add_css_class("tree-row-selected");
1694 }
1695
1696 if row.matches {
1697 list_row.add_css_class("tree-row-match");
1698 }
1699
1700 if tree.menu_target.as_ref() == Some(&row.path) {
1701 menu_row = Some(list_row.clone());
1702 }
1703
1704 if tree.wants_thumbnail(row) {
1707 tree.append_thumbnail(&row.path, row_indent, &container, &sender);
1708 }
1709
1710 let path = row.path.clone();
1711 let release_path = row.path.clone();
1712 let is_dir = row.is_dir;
1713 let click = gtk::GestureClick::new();
1714 click.set_button(0);
1718 let s = sender.clone();
1719 let focus_row = list_row.clone();
1720 click.connect_pressed(move |gesture, _n_press, x, y| {
1721 if focus_row
1726 .pick(x, y, gtk::PickFlags::DEFAULT)
1727 .is_some_and(|widget| in_inline_media(&widget))
1728 {
1729 return;
1730 }
1731 focus_row.grab_focus();
1736 let button = gesture.current_button();
1737 let state = gesture.current_event_state();
1738 if button == 3 {
1739 if state.contains(gdk::ModifierType::SUPER_MASK) {
1743 gesture.set_state(gtk::EventSequenceState::Denied);
1744 return;
1745 }
1746 s.input(TreeMsg::Menu(path.clone()));
1747 return;
1748 }
1749 if button != 1 {
1750 return;
1751 }
1752 let ctrl = state.contains(gdk::ModifierType::CONTROL_MASK);
1753 let shift = state.contains(gdk::ModifierType::SHIFT_MASK);
1754 s.input(TreeMsg::RowPress { path: path.clone(), is_dir, ctrl, shift });
1757 });
1758 {
1759 let s = sender.clone();
1763 click.connect_released(move |gesture, _n_press, _x, _y| {
1764 if gesture.current_button() == 1 {
1765 s.input(TreeMsg::RowRelease { path: release_path.clone() });
1766 }
1767 });
1768 }
1769 list_row.add_controller(click);
1770
1771 if row.is_dir {
1781 let drop = gtk::DropTarget::new(gdk::FileList::static_type(), gdk::DragAction::MOVE | gdk::DragAction::COPY);
1782 let s = sender.clone();
1783 let target_dir = row.path.clone();
1784 let active = tree.drag_active.clone();
1785 let landed = tree.drag_landed_internal.clone();
1786 drop.connect_drop(move |target, value, _x, _y| {
1787 let Ok(list) = value.get::<gdk::FileList>() else {
1788 return false;
1789 };
1790 let sources: Vec<PathBuf> = list.files().iter().filter_map(gio::File::path).collect();
1791 if sources.is_empty() {
1792 return false;
1793 }
1794 let copy = target.current_event_state().contains(gdk::ModifierType::CONTROL_MASK);
1795 if active.get() {
1798 landed.set(true);
1799 }
1800 s.input(TreeMsg::DropInto { target: target_dir.clone(), sources, copy });
1801 true
1802 });
1803 list_row.add_controller(drop);
1804 }
1805
1806 list.append(&list_row);
1807 }
1808
1809 tree.rows = rows;
1810 widgets.scrolled.set_child(Some(list));
1811
1812 if let Some(row) = menu_row {
1816 let target = tree.menu_target.take().expect("menu_row set implies a target");
1817 let selection = tree.selected.clone();
1818 tree.popover = Some(build_menu(&row, &target, &tree.menu, &selection, tree.side, &sender));
1819 tree.menu_point = None;
1820 } else if let (Some(target), Some(at)) = (tree.menu_target.take(), tree.menu_point.take()) {
1821 let selection = tree.selected.clone();
1822 tree.popover = Some(build_menu_at(&widgets.scrolled, at, &target, &tree.menu, &selection, tree.side, &sender));
1823 }
1824 tree.menu_target = None;
1825 tree.menu_point = None;
1826}
1827
1828fn action_builtin(action: &ContextAction) -> Option<BuiltinAction> {
1830 match action {
1831 ContextAction::Builtin(b) => Some(*b),
1832 ContextAction::Entry(e) => Some(e.action),
1833 ContextAction::Command(_) | ContextAction::Submenu(_) => None,
1834 }
1835}
1836
1837fn menu_label(action: &ContextAction, path: &Path, side: PanelSide) -> String {
1842 let dynamic = match action_builtin(action) {
1843 Some(BuiltinAction::OpenWithDefault) => default_app_label(path),
1844 Some(BuiltinAction::InOppositePanel) => Some(format!("In {} panel", side.opposite().name())),
1845 Some(BuiltinAction::ViewThumbnail) if media_kind(path) == Some(MediaKind::Audio) => {
1847 Some("Show Player".to_owned())
1848 }
1849 _ => None,
1850 };
1851 dynamic.unwrap_or_else(|| action.label())
1852}
1853
1854pub(crate) fn accel_display(accel: &str) -> Option<String> {
1857 let (key, mods) = parse_accelerator(accel)?;
1858 Some(gtk::accelerator_get_label(key, mods).to_string())
1859}
1860
1861fn build_menu_box(
1866 path: &Path,
1867 menu: &ContextMenu,
1868 selection: &[PathBuf],
1869 side: PanelSide,
1870 sender: &ComponentSender<Tree>,
1871) -> gtk::Box {
1872 let menu_box = gtk::Box::new(gtk::Orientation::Vertical, 0);
1873 menu_box.add_css_class("tree-menu");
1874 let path = path.to_path_buf();
1875 let actions = menu.actions_for_selection(&path, selection.len());
1876 append_menu_items(&menu_box, &actions, &path, selection, side, sender);
1877 menu_box
1878}
1879
1880fn append_menu_items(
1883 menu_box: >k::Box,
1884 actions: &[ContextAction],
1885 path: &Path,
1886 selection: &[PathBuf],
1887 side: PanelSide,
1888 sender: &ComponentSender<Tree>,
1889) {
1890 let multi = selection.len() > 1;
1891 for action in actions {
1892 if action.is_hidden() {
1895 continue;
1896 }
1897
1898 if action_builtin(action) == Some(BuiltinAction::Separator) {
1900 let separator = gtk::Separator::new(gtk::Orientation::Horizontal);
1901 separator.add_css_class("tree-menu-separator");
1902 menu_box.append(&separator);
1903 continue;
1904 }
1905
1906 if let Some(builtin) = action_builtin(action) {
1910 if builtin.is_directory_only() && !path.is_dir() {
1911 continue;
1912 }
1913 if multi && builtin.is_single_row_only() {
1914 continue;
1915 }
1916 if matches!(builtin, BuiltinAction::OpenWithDefault) && path.is_dir() {
1917 continue;
1918 }
1919 if builtin == BuiltinAction::ViewThumbnail
1921 && !path.is_dir()
1922 && !is_thumbnailable(path)
1923 {
1924 continue;
1925 }
1926 }
1927
1928 let row_box = gtk::Box::new(gtk::Orientation::Horizontal, 12);
1929 row_box.add_css_class("tree-menu-item");
1930
1931 if let ContextAction::Submenu(sub) = action {
1933 let button = submenu_row_button(&sub.label);
1934 let popover = build_submenu_popover(&sub.items, path, selection, side, sender);
1935 let child = popover.clone();
1936 let anchor = button.clone();
1937 button.connect_clicked(move |_| {
1938 child.set_parent(&anchor);
1941 child.popup();
1942 });
1943 let child = popover.clone();
1944 popover.connect_closed(move |_| child.unparent());
1945 row_box.append(&button);
1946 row_box.append(&submenu_arrow());
1947 menu_box.append(&row_box);
1948 continue;
1949 }
1950
1951 let label = menu_label(action, path, side);
1952 let msg = match action {
1953 ContextAction::Builtin(b) => action_message(*b, path, selection),
1954 ContextAction::Entry(e) => action_message(e.action, path, selection),
1955 ContextAction::Command(cmd) => {
1956 if multi {
1957 TreeMsg::RunCommandEach {
1958 command: cmd.command.clone(),
1959 paths: selection.to_vec(),
1960 }
1961 } else {
1962 TreeMsg::RunCommand {
1963 command: cmd.command.clone(),
1964 path: path.to_path_buf(),
1965 }
1966 }
1967 }
1968 ContextAction::Submenu(_) => unreachable!("handled above"),
1969 };
1970
1971 let button = gtk::Button::new();
1974 button.set_halign(gtk::Align::Fill);
1975 button.set_hexpand(true);
1976 let label_widget = gtk::Label::new(Some(&label));
1977 label_widget.set_xalign(0.0);
1978 label_widget.set_hexpand(true);
1979 button.set_child(Some(&label_widget));
1980 let s = sender.clone();
1981 button.connect_clicked(move |_| s.input(msg.clone()));
1982 row_box.append(&button);
1983
1984 if let Some(shortcut) = action.shortcut()
1985 && let Some(display) = accel_display(&shortcut)
1986 {
1987 let hint = gtk::Label::new(Some(&display));
1988 hint.add_css_class("tree-menu-shortcut");
1989 hint.set_halign(gtk::Align::End);
1990 hint.set_valign(gtk::Align::Center);
1991 row_box.append(&hint);
1992 }
1993
1994 menu_box.append(&row_box);
1995 }
1996}
1997
1998fn submenu_row_button(label: &str) -> gtk::Button {
2000 let button = gtk::Button::new();
2001 button.set_halign(gtk::Align::Fill);
2002 button.set_hexpand(true);
2003 let text = gtk::Label::new(Some(label));
2004 text.set_xalign(0.0);
2005 text.set_hexpand(true);
2006 button.set_child(Some(&text));
2007 button
2008}
2009
2010fn submenu_arrow() -> gtk::Label {
2012 let arrow = gtk::Label::new(Some("\u{25b8}"));
2013 arrow.add_css_class("tree-menu-submenu-arrow");
2014 arrow.set_halign(gtk::Align::End);
2015 arrow.set_valign(gtk::Align::Center);
2016 arrow
2017}
2018
2019fn build_submenu_popover(
2022 items: &[ContextAction],
2023 path: &Path,
2024 selection: &[PathBuf],
2025 side: PanelSide,
2026 sender: &ComponentSender<Tree>,
2027) -> gtk::Popover {
2028 let menu_box = gtk::Box::new(gtk::Orientation::Vertical, 0);
2029 menu_box.add_css_class("tree-menu");
2030 append_menu_items(&menu_box, items, path, selection, side, sender);
2031
2032 let popover = gtk::Popover::new();
2033 popover.add_css_class("tree-menu-popover");
2034 popover.set_has_arrow(false);
2035 popover.set_position(gtk::PositionType::Right);
2036 popover.set_child(Some(&menu_box));
2037 popover
2038}
2039
2040fn show_menu(
2043 anchor: &impl IsA<gtk::Widget>,
2044 menu_box: >k::Box,
2045 point: Option<(f64, f64)>,
2046) -> gtk::Popover {
2047 let popover = gtk::Popover::new();
2048 popover.add_css_class("tree-menu-popover");
2049 popover.set_child(Some(menu_box));
2050 popover.set_autohide(true);
2051 popover.set_parent(anchor);
2052 if let Some((x, y)) = point {
2053 popover.set_pointing_to(Some(&gdk::Rectangle::new(x as i32, y as i32, 1, 1)));
2054 }
2055 popover.popup();
2056 popover
2057}
2058
2059fn build_menu(
2061 row: >k::ListBoxRow,
2062 path: &Path,
2063 menu: &ContextMenu,
2064 selection: &[PathBuf],
2065 side: PanelSide,
2066 sender: &ComponentSender<Tree>,
2067) -> gtk::Popover {
2068 let menu_box = build_menu_box(path, menu, selection, side, sender);
2069 show_menu(row, &menu_box, None)
2070}
2071
2072fn build_menu_at(
2075 anchor: &impl IsA<gtk::Widget>,
2076 at: (f64, f64),
2077 path: &Path,
2078 menu: &ContextMenu,
2079 selection: &[PathBuf],
2080 side: PanelSide,
2081 sender: &ComponentSender<Tree>,
2082) -> gtk::Popover {
2083 let menu_box = build_menu_box(path, menu, selection, side, sender);
2084 show_menu(anchor, &menu_box, Some(at))
2085}
2086
2087fn action_message(action: BuiltinAction, path: &Path, selection: &[PathBuf]) -> TreeMsg {
2093 if selection.len() > 1 {
2094 match action {
2095 BuiltinAction::Trash => return TreeMsg::DeleteSelected,
2096 BuiltinAction::DeletePermanently => return TreeMsg::PermanentDeleteSelected,
2097 BuiltinAction::CopyPath => return TreeMsg::CopyPaths(selection.to_vec()),
2098 BuiltinAction::CopyRelativePath => return TreeMsg::CopyRelativePaths(selection.to_vec()),
2099 BuiltinAction::Properties => return TreeMsg::PropertiesSelected(selection.to_vec()),
2100 _ => {}
2101 }
2102 }
2103 builtin_message(action, path)
2104}
2105
2106fn builtin_message(action: BuiltinAction, path: &Path) -> TreeMsg {
2108 match action {
2109 BuiltinAction::Open => TreeMsg::Activate(path.to_path_buf()),
2110 BuiltinAction::OpenSplit => TreeMsg::OpenSplit(path.to_path_buf()),
2111 BuiltinAction::InNewPanel => TreeMsg::OpenSplit(path.to_path_buf()),
2112 BuiltinAction::InOppositePanel => TreeMsg::OpenInOppositePanel(path.to_path_buf()),
2113 BuiltinAction::OpenWith => TreeMsg::OpenWith(path.to_path_buf()),
2114 BuiltinAction::OpenWithDefault => TreeMsg::OpenWithDefault(path.to_path_buf()),
2115 BuiltinAction::ViewThumbnail => TreeMsg::ToggleThumbnail(path.to_path_buf()),
2116 BuiltinAction::NewFile => TreeMsg::NewFile,
2117 BuiltinAction::NewFolder => TreeMsg::NewFolder,
2118 BuiltinAction::Duplicate => TreeMsg::Duplicate(path.to_path_buf()),
2119 BuiltinAction::CreateLink => TreeMsg::CreateLink(path.to_path_buf()),
2120 BuiltinAction::Cut => TreeMsg::Cut,
2121 BuiltinAction::Copy => TreeMsg::Copy,
2122 BuiltinAction::Paste => TreeMsg::Paste,
2123 BuiltinAction::Rename => TreeMsg::RenameAt(path.to_path_buf()),
2124 BuiltinAction::CopyPath => TreeMsg::CopyPath(path.to_path_buf()),
2125 BuiltinAction::CopyRelativePath => TreeMsg::CopyRelativePath(path.to_path_buf()),
2126 BuiltinAction::Properties => TreeMsg::Properties(path.to_path_buf()),
2127 BuiltinAction::Trash => TreeMsg::Trash(path.to_path_buf()),
2128 BuiltinAction::DeletePermanently => TreeMsg::PermanentDelete(path.to_path_buf()),
2129 BuiltinAction::ToggleHidden => TreeMsg::ToggleHidden,
2130 BuiltinAction::SortByName => TreeMsg::SetSortKey(SortKey::Name),
2131 BuiltinAction::SortBySize => TreeMsg::SetSortKey(SortKey::Size),
2132 BuiltinAction::SortByModified => TreeMsg::SetSortKey(SortKey::Modified),
2133 BuiltinAction::SortByType => TreeMsg::SetSortKey(SortKey::Type),
2134 BuiltinAction::ToggleSortAscending => TreeMsg::ToggleSortDirection,
2135 BuiltinAction::OpenFolder
2137 | BuiltinAction::Filter
2138 | BuiltinAction::SplitView
2139 | BuiltinAction::Up
2140 | BuiltinAction::Back
2141 | BuiltinAction::Forward
2142 | BuiltinAction::Collapse
2143 | BuiltinAction::ClosePane => unreachable!("pane action in a row context menu"),
2144 BuiltinAction::Separator => unreachable!("separators are rendered, not dispatched"),
2145 }
2146}
2147
2148const ARCHIVE_EXTENSIONS: [&str; 8] = ["zip", "tar", "gz", "xz", "bz2", "zst", "7z", "rar"];
2150
2151fn icon_name(row: &VisibleRow) -> &'static str {
2152 if row.is_dir {
2153 return if row.expanded { "folder-open-symbolic" } else { "folder-symbolic" };
2154 }
2155 if has_extension(&row.path, &["pdf"]) {
2159 "application-pdf-symbolic"
2160 } else if has_extension(&row.path, &["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp", "avif"])
2161 {
2162 "image-x-generic-symbolic"
2163 } else if has_extension(&row.path, &["mp3", "flac", "wav", "ogg", "m4a", "opus"]) {
2164 "audio-x-generic-symbolic"
2165 } else if has_extension(&row.path, &["mp4", "mkv", "webm", "mov", "avi"]) {
2166 "video-x-generic-symbolic"
2167 } else if has_extension(&row.path, &ARCHIVE_EXTENSIONS) {
2168 "package-x-generic-symbolic"
2169 } else {
2170 "text-x-generic-symbolic"
2171 }
2172}
2173
2174#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2180enum MediaKind {
2181 Image,
2183 Gif,
2185 Video,
2187 Audio,
2189}
2190
2191const IMAGE_EXTENSIONS: [&str; 10] =
2195 ["jpg", "jpeg", "png", "webp", "bmp", "tif", "tiff", "avif", "heic", "heif"];
2196
2197const VIDEO_EXTENSIONS: [&str; 15] = [
2200 "mp4", "m4v", "mkv", "webm", "mov", "avi", "wmv", "flv", "ogv", "ogg", "ts", "mpg",
2201 "mpeg", "3gp", "m2ts",
2202];
2203
2204const AUDIO_EXTENSIONS: [&str; 12] = [
2208 "mp3", "m4a", "m4b", "aac", "flac", "wav", "opus", "oga", "wma", "aif", "aiff", "alac",
2209];
2210
2211fn has_extension(path: &Path, extensions: &[&str]) -> bool {
2212 path.extension()
2213 .and_then(|ext| ext.to_str())
2214 .is_some_and(|ext| extensions.iter().any(|candidate| ext.eq_ignore_ascii_case(candidate)))
2215}
2216
2217fn media_kind(path: &Path) -> Option<MediaKind> {
2219 if has_extension(path, &["gif"]) {
2220 Some(MediaKind::Gif)
2221 } else if has_extension(path, &IMAGE_EXTENSIONS) {
2222 Some(MediaKind::Image)
2223 } else if has_extension(path, &VIDEO_EXTENSIONS) {
2224 Some(MediaKind::Video)
2225 } else if cfg!(feature = "audio") && has_extension(path, &AUDIO_EXTENSIONS) {
2226 Some(MediaKind::Audio)
2227 } else {
2228 None
2229 }
2230}
2231
2232fn is_thumbnailable(path: &Path) -> bool {
2234 media_kind(path).is_some()
2235}
2236
2237struct GifAnim {
2240 iter: gdk::gdk_pixbuf::PixbufAnimationIter,
2241}
2242
2243struct AudioWidgets {
2247 player: Rc<AudioPlayer>,
2248 play: gtk::Button,
2249 seek: gtk::Scale,
2250 time: gtk::Label,
2251 seeking: Rc<Cell<bool>>,
2254}
2255
2256fn needs_selection(action: BuiltinAction) -> bool {
2260 matches!(
2261 action,
2262 BuiltinAction::Rename
2263 | BuiltinAction::Duplicate
2264 | BuiltinAction::CreateLink
2265 | BuiltinAction::Cut
2266 | BuiltinAction::Copy
2267 | BuiltinAction::OpenWith
2268 | BuiltinAction::Trash
2269 | BuiltinAction::DeletePermanently
2270 )
2271}
2272
2273fn in_list_row(widget: >k::Widget) -> bool {
2276 let mut current = Some(widget.clone());
2277 while let Some(widget) = current {
2278 if widget.downcast_ref::<gtk::ListBoxRow>().is_some() {
2279 return true;
2280 }
2281 current = widget.parent();
2282 }
2283 false
2284}
2285
2286fn in_inline_media(widget: >k::Widget) -> bool {
2289 let mut current = Some(widget.clone());
2290 while let Some(widget) = current {
2291 if widget.downcast_ref::<gtk::Video>().is_some() || widget.has_css_class("tree-audio") {
2292 return true;
2293 }
2294 current = widget.parent();
2295 }
2296 false
2297}
2298
2299fn thumbnail_content_width(panel: PanelConfig) -> i32 {
2302 (panel.width as i32 - 2 * panel.margin as i32 - 24).max(64)
2303}
2304
2305fn thumbnail_size(texture: &gdk::Texture, available: i32) -> (i32, i32) {
2308 let natural_w = texture.width().max(1);
2309 let natural_h = texture.height().max(1);
2310 let available = available.max(32);
2311 if natural_w <= available {
2312 return (natural_w, natural_h);
2313 }
2314 let height = (natural_h as i64 * available as i64 / natural_w as i64).max(1) as i32;
2315 (available, height)
2316}
2317
2318fn thumbnail_picture(
2321 texture: &gdk::Texture,
2322 margin_start: i32,
2323 width: i32,
2324 height: i32,
2325) -> gtk::Picture {
2326 let picture = gtk::Picture::for_paintable(texture);
2327 picture.add_css_class("tree-thumbnail");
2328 picture.set_can_shrink(true);
2329 picture.set_content_fit(gtk::ContentFit::ScaleDown);
2330 picture.set_halign(gtk::Align::Start);
2331 picture.set_size_request(width, height);
2332 picture.set_margin_start(margin_start);
2333 picture.set_margin_bottom(6);
2334 picture
2335}
2336
2337fn format_time(micros: i64) -> String {
2340 if micros <= 0 {
2341 return "-:--".to_owned();
2342 }
2343 let total = micros / 1_000_000;
2344 let (h, m, s) = (total / 3600, (total % 3600) / 60, total % 60);
2345 if h > 0 {
2346 format!("{h}:{m:02}:{s:02}")
2347 } else {
2348 format!("{m}:{s:02}")
2349 }
2350}
2351
2352fn build_audio_player(player: &Rc<AudioPlayer>, margin_start: i32) -> (gtk::Box, AudioWidgets) {
2356 let controls = gtk::Box::new(gtk::Orientation::Horizontal, 6);
2357 controls.add_css_class("tree-audio");
2358 controls.set_margin_start(margin_start);
2359 controls.set_margin_bottom(6);
2360 controls.set_hexpand(true);
2361
2362 let play = gtk::Button::from_icon_name("media-playback-start-symbolic");
2363 play.add_css_class("flat");
2364 play.set_tooltip_text(Some("Play"));
2365 play.set_valign(gtk::Align::Center);
2366 {
2367 let player = player.clone();
2368 play.connect_clicked(move |button| {
2369 let playing = player.is_playing();
2370 player.set_playing(!playing);
2371 button.set_icon_name(if playing {
2372 "media-playback-start-symbolic"
2373 } else {
2374 "media-playback-pause-symbolic"
2375 });
2376 });
2377 }
2378 controls.append(&play);
2379
2380 let seek = gtk::Scale::with_range(gtk::Orientation::Horizontal, 0.0, 1.0, 1.0);
2383 seek.set_draw_value(false);
2384 seek.set_hexpand(true);
2385 seek.set_valign(gtk::Align::Center);
2386 seek.set_size_request(60, -1);
2387 let seeking = Rc::new(Cell::new(false));
2388 {
2389 let player = player.clone();
2390 let seeking = seeking.clone();
2391 seek.connect_change_value(move |_, _, value| {
2392 seeking.set(true);
2394 let flag = seeking.clone();
2395 glib::timeout_add_local_once(Duration::from_millis(200), move || flag.set(false));
2396 player.seek(value.round() as i64);
2397 glib::Propagation::Proceed
2398 });
2399 }
2400 controls.append(&seek);
2401
2402 let time = gtk::Label::new(Some("0:00 / -:--"));
2403 time.add_css_class("tree-audio-time");
2404 time.set_valign(gtk::Align::Center);
2405 time.set_width_chars(13);
2406 time.set_xalign(1.0);
2407 controls.append(&time);
2408
2409 let volume = gtk::Scale::with_range(gtk::Orientation::Horizontal, 0.0, 1.0, 0.05);
2410 volume.set_draw_value(false);
2411 volume.set_value(player.volume());
2412 volume.set_valign(gtk::Align::Center);
2413 volume.set_size_request(56, -1);
2414 volume.set_tooltip_text(Some("Volume"));
2415 {
2416 let player = player.clone();
2417 volume.connect_value_changed(move |scale| player.set_volume(scale.value()));
2418 }
2419 controls.append(&volume);
2420
2421 let widgets = AudioWidgets {
2422 player: player.clone(),
2423 play,
2424 seek,
2425 time,
2426 seeking,
2427 };
2428 (controls, widgets)
2429}
2430
2431impl Tree {
2432 fn open_root(&mut self, path: &Path, sender: &ComponentSender<Self>) {
2433 if let Some(id) = self.flush_source.take() {
2434 id.remove();
2435 }
2436 self.pending.clear();
2437
2438 let mut tree = TreeModel::new(
2439 path.to_path_buf(),
2440 self.config.sort_options(),
2441 self.config.show_hidden,
2442 );
2443 if let Err(err) = tree.expand(path, &StdDirSource) {
2444 self.status(format!("Could not open {}: {err}", path.display()), sender);
2445 return;
2446 }
2447 self.tree = Some(tree);
2448 self.refresh_rows();
2449 self.cursor = if self.rows.is_empty() { None } else { Some(0) };
2450 self.selected = Vec::new();
2451 self.renaming = None;
2452 self.renaming_state.set(false);
2453 self.rename_entry = None;
2454 self.menu_target = None;
2455 self.thumbnails.clear();
2456 self.thumb_cache.clear();
2457 self.playing.clear();
2458 for media in self.media.values() {
2459 media.set_playing(false);
2460 }
2461 self.media.clear();
2462 self.audio.clear();
2463 self.gif_anims.borrow_mut().clear();
2464 self.gif_widgets.borrow_mut().clear();
2465 self.audio_widgets.borrow_mut().clear();
2466 if let Some(previous) = self.popover.take() {
2467 previous.unparent();
2468 }
2469
2470 let root = path.to_path_buf();
2471 let watcher_sender = sender.clone();
2472 self._watcher = spawn_watcher(path, move |change| {
2473 watcher_sender.input(TreeMsg::FsChange(change));
2474 })
2475 .ok();
2476 self.watched.clear();
2477 if self._watcher.is_some() {
2478 self.watched.insert(root.clone());
2479 }
2480
2481 let _ = sender.output(TreeOutput::RootChanged(root));
2482 }
2483
2484 fn refresh_rows(&mut self) {
2485 if let Some(tree) = self.tree.as_ref() {
2486 tree.visible_rows_into(&mut self.rows);
2487 } else {
2488 self.rows.clear();
2489 }
2490 }
2491
2492 fn reconcile_watches(&mut self) {
2498 let Some(watcher) = self._watcher.as_mut() else {
2499 return;
2500 };
2501 let Some(model) = self.tree.as_ref() else {
2502 return;
2503 };
2504 let desired: HashSet<PathBuf> = model.expanded_loaded_dirs().into_iter().collect();
2505
2506 let stale: Vec<PathBuf> = self.watched.difference(&desired).cloned().collect();
2507 for path in stale {
2508 let _ = watcher.unwatch(&path);
2509 self.watched.remove(&path);
2510 }
2511
2512 let fresh: Vec<PathBuf> = desired.difference(&self.watched).cloned().collect();
2513 for path in fresh {
2514 if watcher.watch(&path, RecursiveMode::NonRecursive).is_ok() {
2515 self.watched.insert(path);
2516 }
2517 }
2518 }
2519
2520 fn cursor_path(&self) -> Option<PathBuf> {
2521 self.cursor.and_then(|i| self.rows.get(i)).map(|r| r.path.clone())
2522 }
2523
2524 fn cursor_delta(&mut self, delta: isize) {
2525 if self.rows.is_empty() {
2526 return;
2527 }
2528 let next = (self.cursor.unwrap_or(0) as isize + delta).clamp(0, self.rows.len() as isize - 1) as usize;
2529 let path = self.rows[next].path.clone();
2530 self.cursor = Some(next);
2531 self.set_single_selection(path);
2532 self.selection_dirty = true;
2533 }
2534
2535 fn cursor_extend(&mut self, delta: isize, _keep_anchor: bool) {
2542 if self.rows.is_empty() {
2543 return;
2544 }
2545 let anchor = self
2546 .anchor
2547 .clone()
2548 .or_else(|| self.cursor.map(|i| self.rows[i].path.clone()))
2549 .unwrap_or_else(|| self.rows[0].path.clone());
2550 let next = (self.cursor.unwrap_or(0) as isize + delta)
2551 .clamp(0, self.rows.len() as isize - 1) as usize;
2552 self.cursor = Some(next);
2553 self.anchor = Some(anchor.clone());
2554 let (Some(a), Some(b)) = (self.rows.iter().position(|r| r.path == anchor), Some(next)) else {
2555 return;
2556 };
2557 self.selected = self.rows[index_range(a, b)].iter().map(|r| r.path.clone()).collect();
2558 self.scroll_to = Some(next);
2559 self.selection_dirty = true;
2560 }
2561
2562 fn cursor_snap(&mut self) {
2563 let deselected = self.cursor.is_none();
2564 let prev = self.cursor_path();
2565 self.refresh_rows();
2566 if self.rows.is_empty() {
2567 self.cursor = None;
2568 self.selected.clear();
2569 return;
2570 }
2571 if deselected {
2572 self.cursor = None;
2574 return;
2575 }
2576 let index = prev
2577 .and_then(|p| self.rows.iter().position(|r| r.path == p))
2578 .unwrap_or(0);
2579 self.cursor = Some(index);
2580 }
2581
2582 fn select(&mut self, path: &Path) {
2583 if let Some(i) = self.rows.iter().position(|r| r.path == path) {
2584 self.cursor = Some(i);
2585 }
2586 self.selected = vec![path.to_path_buf()];
2587 }
2588
2589 fn press_default(&mut self, path: &Path, is_dir: bool, sender: &ComponentSender<Self>) {
2594 let now = std::time::Instant::now();
2597 let is_double = self.last_press.as_ref().is_some_and(|(last, at)| {
2598 last == path && now.duration_since(*at) < Duration::from_millis(400)
2599 });
2600 self.last_press = Some((path.to_path_buf(), now));
2601
2602 self.select(path);
2603 self.anchor = Some(path.to_path_buf());
2604 if is_dir {
2605 self.toggle_dir(path, sender);
2609 } else if is_double {
2610 self.activate(path, sender);
2612 }
2613 }
2614
2615 fn reveal_path(&mut self, path: PathBuf) {
2620 if let Some(i) = self.rows.iter().position(|r| r.path == path) {
2621 self.cursor = Some(i);
2622 self.selected = vec![path.clone()];
2623 self.anchor = Some(path);
2624 self.selection_dirty = true;
2625 self.scroll_to = Some(i);
2626 }
2627 }
2628
2629 fn range_select(&mut self, path: &Path) {
2633 let Some(anchor) = self.anchor.clone() else {
2634 self.select(path);
2635 self.anchor = Some(path.to_path_buf());
2636 return;
2637 };
2638 let (Some(a), Some(b)) = (
2639 self.rows.iter().position(|r| r.path == anchor),
2640 self.rows.iter().position(|r| r.path == path),
2641 ) else {
2642 self.select(path);
2643 return;
2644 };
2645 self.selected = self.rows[index_range(a, b)].iter().map(|r| r.path.clone()).collect();
2646 self.cursor = Some(b);
2647 }
2648
2649 fn set_single_selection(&mut self, path: PathBuf) {
2650 self.selected = vec![path.clone()];
2651 if let Some(i) = self.rows.iter().position(|r| r.path == path) {
2652 self.cursor = Some(i);
2653 }
2654 }
2655
2656 fn selected_for_clipboard(&self) -> Vec<PathBuf> {
2657 if self.selected.is_empty() {
2658 if let Some(path) = self.cursor_path() {
2659 return vec![path];
2660 }
2661 return Vec::new();
2662 }
2663 self.selected.clone()
2664 }
2665
2666 fn collapse_dir(&mut self, path: &Path) {
2669 if let Some(tree) = self.tree.as_mut() {
2670 tree.collapse(path);
2671 }
2672 self.clear_thumbnails_under(path);
2673 self.cursor_snap();
2674 }
2675
2676 fn toggle_dir(&mut self, path: &Path, sender: &ComponentSender<Self>) {
2678 let expanded = self.tree.as_ref().is_some_and(|t| t.is_expanded(path));
2679 if expanded {
2680 self.collapse_dir(path);
2681 } else {
2682 self.expand_dir(path, sender);
2683 }
2684 }
2685
2686 fn shutdown(&mut self) {
2693 if let Some(id) = self.gif_tick.borrow_mut().take() {
2694 id.remove();
2695 }
2696 if let Some(id) = self.audio_tick.borrow_mut().take() {
2697 id.remove();
2698 }
2699 self.gif_anims.borrow_mut().clear();
2700 self.gif_widgets.borrow_mut().clear();
2701 self.audio_widgets.borrow_mut().clear();
2702 self.playing.clear();
2703 self.thumbnails.clear();
2704 for media in self.media.values() {
2705 media.set_playing(false);
2706 }
2707 self.media.clear();
2708 self.audio.clear();
2710 }
2711
2712 fn clear_thumbnails_under(&mut self, prefix: &Path) {
2714 self.thumbnails.retain(|path| !path.starts_with(prefix));
2715 self.thumb_cache.retain(|path, _| !path.starts_with(prefix));
2716 self.playing.retain(|path| !path.starts_with(prefix));
2717 self.media.retain(|path, media| {
2718 if path.starts_with(prefix) {
2719 media.set_playing(false);
2720 false
2721 } else {
2722 true
2723 }
2724 });
2725 self.gif_anims.borrow_mut().retain(|path, _| !path.starts_with(prefix));
2726 self.gif_widgets.borrow_mut().retain(|path, _| !path.starts_with(prefix));
2727 self.audio_widgets.borrow_mut().retain(|path, _| !path.starts_with(prefix));
2728 self.audio.retain(|path, _| !path.starts_with(prefix));
2730 }
2731
2732 fn wants_thumbnail(&self, row: &VisibleRow) -> bool {
2735 if row.is_dir || !is_thumbnailable(&row.path) {
2736 return false;
2737 }
2738 self.thumbnails.contains(&row.path)
2739 || row
2740 .path
2741 .ancestors()
2742 .skip(1)
2743 .any(|ancestor| self.thumbnails.contains(ancestor))
2744 }
2745
2746 fn static_texture(&mut self, path: &Path) -> Option<gdk::Texture> {
2749 if let Some(texture) = self.thumb_cache.get(path) {
2750 return Some(texture.clone());
2751 }
2752 let texture = match media_kind(path)? {
2753 MediaKind::Image => gdk::Texture::from_filename(path).ok()?,
2754 MediaKind::Gif => {
2755 let animation = gdk::gdk_pixbuf::PixbufAnimation::from_file(path).ok()?;
2756 let pixbuf = animation.static_image()?;
2757 gdk::Texture::for_pixbuf(&pixbuf)
2758 }
2759 MediaKind::Video | MediaKind::Audio => return None,
2761 };
2762 self.thumb_cache.insert(path.to_path_buf(), texture.clone());
2763 Some(texture)
2764 }
2765
2766 fn media_file(&mut self, path: &Path) -> Option<gtk::MediaFile> {
2771 if let Some(media) = self.media.get(path) {
2772 return Some(media.clone());
2773 }
2774 let media = gtk::MediaFile::for_filename(path);
2775 media.set_loop(true);
2776 media.set_muted(false);
2781 media.set_volume(1.0);
2782 self.media.insert(path.to_path_buf(), media.clone());
2783 Some(media)
2784 }
2785
2786 fn audio_player(&mut self, path: &Path) -> Option<Rc<AudioPlayer>> {
2789 if let Some(player) = self.audio.get(path) {
2790 return Some(player.clone());
2791 }
2792 let player = AudioPlayer::new(path)?;
2793 self.audio.insert(path.to_path_buf(), player.clone());
2794 Some(player)
2795 }
2796
2797 fn toggle_thumbnail_play(&mut self, path: &Path) {
2800 if media_kind(path) != Some(MediaKind::Gif) {
2801 return;
2802 }
2803 if self.playing.remove(path) {
2804 self.gif_anims.borrow_mut().remove(path);
2805 } else {
2806 let Ok(animation) = gdk::gdk_pixbuf::PixbufAnimation::from_file(path) else {
2807 return;
2808 };
2809 let iter = animation.iter(Some(SystemTime::now()));
2810 self.gif_anims.borrow_mut().insert(path.to_path_buf(), GifAnim { iter });
2811 self.playing.insert(path.to_path_buf());
2812 self.ensure_gif_ticker();
2813 }
2814 }
2815
2816 fn ensure_gif_ticker(&mut self) {
2820 if self.gif_tick.borrow().is_some() {
2821 return;
2822 }
2823 let anims = self.gif_anims.clone();
2824 let widgets = self.gif_widgets.clone();
2825 let tick = self.gif_tick.clone();
2826 let id = glib::timeout_add_local(Duration::from_millis(33), move || {
2827 let mut anims = anims.borrow_mut();
2828 if anims.is_empty() {
2829 *tick.borrow_mut() = None;
2830 return glib::ControlFlow::Break;
2831 }
2832 let now = SystemTime::now();
2833 let widgets = widgets.borrow();
2834 for (path, anim) in anims.iter_mut() {
2835 let _ = anim.iter.advance(now);
2836 if let Some(picture) = widgets.get(path) {
2837 let pixbuf = anim.iter.pixbuf();
2838 picture.set_paintable(Some(&gdk::Texture::for_pixbuf(&pixbuf)));
2839 }
2840 }
2841 glib::ControlFlow::Continue
2842 });
2843 *self.gif_tick.borrow_mut() = Some(id);
2844 }
2845
2846 fn ensure_audio_ticker(&mut self) {
2849 if self.audio_tick.borrow().is_some() {
2850 return;
2851 }
2852 let players = self.audio_widgets.clone();
2853 let tick = self.audio_tick.clone();
2854 let id = glib::timeout_add_local(Duration::from_millis(200), move || {
2855 let players = players.borrow();
2856 if players.is_empty() {
2857 *tick.borrow_mut() = None;
2858 return glib::ControlFlow::Break;
2859 }
2860 for widgets in players.values() {
2861 let position = widgets.player.position();
2862 let duration = widgets.player.duration();
2863 if duration > 0
2864 && (widgets.seek.adjustment().upper() - duration as f64).abs() > 0.5
2865 {
2866 widgets.seek.adjustment().set_upper(duration as f64);
2867 }
2868 if !widgets.seeking.get() {
2869 widgets.seek.set_value(position as f64);
2870 }
2871 widgets.time.set_text(&format!(
2872 "{} / {}",
2873 format_time(position),
2874 format_time(duration)
2875 ));
2876 widgets.play.set_icon_name(if widgets.player.is_playing() {
2877 "media-playback-pause-symbolic"
2878 } else {
2879 "media-playback-start-symbolic"
2880 });
2881 }
2882 glib::ControlFlow::Continue
2883 });
2884 *self.audio_tick.borrow_mut() = Some(id);
2885 }
2886
2887 fn append_thumbnail(
2890 &mut self,
2891 path: &Path,
2892 row_indent: i32,
2893 container: >k::Box,
2894 sender: &ComponentSender<Self>,
2895 ) {
2896 let Some(kind) = media_kind(path) else {
2897 return;
2898 };
2899 let label_start = row_indent + self.config.icon_size as i32 + 6;
2900 let available = self.content_width - label_start - 6;
2901
2902 match kind {
2903 MediaKind::Image => {
2904 if let Some(texture) = self.static_texture(path) {
2905 let (width, height) = thumbnail_size(&texture, available);
2906 container.append(&thumbnail_picture(&texture, label_start, width, height));
2907 }
2908 }
2909 MediaKind::Gif => {
2910 let playing = self.playing.contains(path);
2912 let texture = if playing {
2913 self.gif_anims
2914 .borrow()
2915 .get(path)
2916 .map(|anim| gdk::Texture::for_pixbuf(&anim.iter.pixbuf()))
2917 } else {
2918 self.static_texture(path)
2919 };
2920 if let Some(texture) = texture {
2921 let (width, height) = thumbnail_size(&texture, available);
2922 let picture = thumbnail_picture(&texture, label_start, width, height);
2923 picture.set_tooltip_text(Some("Click to play"));
2924 if playing {
2925 self.gif_widgets
2926 .borrow_mut()
2927 .insert(path.to_path_buf(), picture.clone());
2928 }
2929 let msg = TreeMsg::ToggleThumbnailPlay(path.to_path_buf());
2930 let s = sender.clone();
2931 let click = gtk::GestureClick::new();
2932 click.set_button(1);
2933 click.connect_pressed(move |_, _, _, _| s.input(msg.clone()));
2934 picture.add_controller(click);
2935 container.append(&picture);
2936 }
2937 }
2938 MediaKind::Video => {
2939 if let Some(media) = self.media_file(path) {
2940 let video = gtk::Video::new();
2946 video.set_autoplay(false);
2947 video.set_media_stream(Some(&media));
2948 video.set_loop(true);
2949 video.add_css_class("tree-thumbnail");
2950 video.set_halign(gtk::Align::Start);
2951 let width = available.max(64);
2952 video.set_size_request(width, (width * 9 / 16).max(36));
2953 video.set_margin_start(label_start);
2954 video.set_margin_bottom(6);
2955 container.append(&video);
2956 }
2957 }
2958 MediaKind::Audio => {
2959 if let Some(player) = self.audio_player(path) {
2960 let (controls, widgets) = build_audio_player(&player, label_start);
2964 self.audio_widgets.borrow_mut().insert(path.to_path_buf(), widgets);
2965 container.append(&controls);
2966 self.ensure_audio_ticker();
2967 }
2968 }
2969 }
2970 }
2971
2972 fn expand_dir(&mut self, path: &Path, sender: &ComponentSender<Self>) {
2973 let Some(tree) = self.tree.as_mut() else {
2974 return;
2975 };
2976 if let Err(err) = tree.expand(path, &StdDirSource) {
2977 self.status(format!("Could not open {}: {err}", path.display()), sender);
2978 }
2979 self.refresh_rows();
2980 }
2981
2982 fn activate(&mut self, path: &Path, sender: &ComponentSender<Self>) {
2983 let Some(index) = self.rows.iter().position(|r| r.path == path) else {
2984 return;
2985 };
2986 if self.rows[index].is_dir {
2987 self.open_root(path, sender);
2991 } else if let Err(err) = open_in_app(path) {
2992 self.status(err, sender);
2993 }
2994 }
2995
2996 fn confirm_trash(&mut self, paths: Vec<PathBuf>, sender: ComponentSender<Self>) {
2997 let message = match paths.as_slice() {
2998 [single] => {
2999 let name = single
3000 .file_name()
3001 .map(|n| n.to_string_lossy().into_owned())
3002 .unwrap_or_else(|| single.display().to_string());
3003 format!("Move \u{201c}{name}\u{201d} to trash?")
3004 }
3005 many => format!("Move {} items to trash?", many.len()),
3006 };
3007 let dialog = gtk::AlertDialog::builder()
3008 .modal(true)
3009 .message(message)
3010 .detail("The panel cannot restore trashed items.")
3011 .buttons(["Cancel", "Trash"])
3012 .cancel_button(0)
3013 .default_button(1)
3014 .build();
3015 dialog.choose(Some(&self.parent), None::<&gio::Cancellable>, move |res| {
3016 if matches!(res, Ok(1)) {
3017 sender.input(TreeMsg::ConfirmTrash(paths));
3018 }
3019 });
3020 }
3021
3022 fn confirm_permanent_delete(&mut self, paths: Vec<PathBuf>, sender: ComponentSender<Self>) {
3023 let message = match paths.as_slice() {
3024 [single] => {
3025 let name = single
3026 .file_name()
3027 .map(|n| n.to_string_lossy().into_owned())
3028 .unwrap_or_else(|| single.display().to_string());
3029 format!("Permanently delete \u{201c}{name}\u{201d}?")
3030 }
3031 many => format!("Permanently delete {} items?", many.len()),
3032 };
3033 let dialog = gtk::AlertDialog::builder()
3034 .modal(true)
3035 .message(message)
3036 .detail("This cannot be undone. Files will not go to the trash.")
3037 .buttons(["Cancel", "Delete Permanently"])
3038 .cancel_button(0)
3039 .default_button(1)
3040 .build();
3041 dialog.choose(Some(&self.parent), None::<&gio::Cancellable>, move |res| {
3042 if matches!(res, Ok(1)) {
3043 sender.input(TreeMsg::ConfirmPermanentDelete(paths));
3044 }
3045 });
3046 }
3047
3048 fn open_menu(&mut self, path: &Path) {
3049 let in_selection = self.selected.len() > 1 && self.selected.iter().any(|p| p == path);
3053 if !in_selection {
3054 self.select(path);
3055 } else if let Some(i) = self.rows.iter().position(|r| r.path == path) {
3056 self.cursor = Some(i);
3057 }
3058 if let Some(previous) = self.popover.take() {
3059 previous.unparent();
3060 }
3061 self.menu_target = Some(path.to_path_buf());
3062 self.menu_point = None;
3063 }
3064
3065 fn open_menu_at(&mut self, path: &Path, at: (f64, f64)) {
3069 if let Some(previous) = self.popover.take() {
3070 previous.unparent();
3071 }
3072 self.menu_target = Some(path.to_path_buf());
3073 self.menu_point = Some(at);
3074 }
3075
3076 fn paste(&mut self, sender: &ComponentSender<Self>) {
3077 let Some(clip) = self.clipboard.clone() else {
3078 self.paste_from_system_clipboard(sender);
3081 return;
3082 };
3083 let Some(dir) = self.target_dir() else {
3084 return;
3085 };
3086 let mut count = 0usize;
3087 let mut failed: Option<String> = None;
3088 for src in clip.paths {
3089 if src == dir {
3090 continue;
3091 }
3092 let result = match clip.op {
3093 ClipboardOp::Copy => self.ops.copy(&src, &dir),
3094 ClipboardOp::Cut => self.ops.move_(&src, &dir),
3095 };
3096 match result {
3097 Ok(dest) => {
3098 count += 1;
3099 self.apply_change(Change::Created { path: dest });
3100 if clip.op == ClipboardOp::Cut {
3101 self.apply_change(Change::Removed { path: src });
3102 }
3103 }
3104 Err(err) => {
3105 failed = Some(err.to_string());
3106 break;
3107 }
3108 }
3109 }
3110 if let Some(failed) = failed {
3111 self.status(failed, sender);
3112 return;
3113 }
3114 let verb = match clip.op {
3115 ClipboardOp::Copy => "Copied",
3116 ClipboardOp::Cut => "Moved",
3117 };
3118 self.status(format!("{verb} {count} item(s) into {}", dir.display()), sender);
3119 if clip.op == ClipboardOp::Cut {
3120 self.clipboard = None;
3121 }
3122 self.cursor_snap();
3123 }
3124
3125 fn paste_from_system_clipboard(&mut self, sender: &ComponentSender<Self>) {
3129 if self.target_dir().is_none() {
3130 return;
3131 }
3132 let s = sender.clone();
3133 read_clipboard_files(move |paths| s.input(TreeMsg::PastePaths(paths)));
3134 }
3135
3136 fn paste_paths(&mut self, paths: Vec<PathBuf>, sender: &ComponentSender<Self>) {
3138 let Some(dir) = self.target_dir() else {
3139 return;
3140 };
3141 if paths.is_empty() {
3142 self.status("Clipboard has no files to paste".to_string(), sender);
3143 return;
3144 }
3145 let mut count = 0usize;
3146 let mut failed: Option<String> = None;
3147 for src in paths {
3148 if src == dir {
3149 continue;
3150 }
3151 match self.ops.copy(&src, &dir) {
3152 Ok(dest) => {
3153 count += 1;
3154 self.apply_change(Change::Created { path: dest });
3155 }
3156 Err(err) => {
3157 failed = Some(err.to_string());
3158 break;
3159 }
3160 }
3161 }
3162 if let Some(failed) = failed {
3163 self.status(failed, sender);
3164 return;
3165 }
3166 self.status(format!("Pasted {count} item(s) into {}", dir.display()), sender);
3167 self.cursor_snap();
3168 }
3169
3170 fn request_drop_into(
3175 &mut self,
3176 target: PathBuf,
3177 sources: Vec<PathBuf>,
3178 copy: bool,
3179 sender: &ComponentSender<Self>,
3180 ) {
3181 match resolve_drop(&target, sources, copy) {
3182 DropPlan::Ignore => {}
3183 DropPlan::Reject(message) => self.status(message, sender),
3184 DropPlan::Copy(sources) => self.drop_into(&target, &sources, true, sender),
3185 DropPlan::Move(sources) => {
3186 if self.config.confirm_drop_move {
3187 self.confirm_move(target, sources, sender);
3188 } else {
3189 self.drop_into(&target, &sources, false, sender);
3190 }
3191 }
3192 }
3193 }
3194
3195 fn confirm_move(
3199 &mut self,
3200 target: PathBuf,
3201 sources: Vec<PathBuf>,
3202 sender: &ComponentSender<Self>,
3203 ) {
3204 let target_name = target
3205 .file_name()
3206 .map(|n| n.to_string_lossy().into_owned())
3207 .unwrap_or_else(|| target.display().to_string());
3208 let message = match sources.as_slice() {
3209 [single] => {
3210 let name = single
3211 .file_name()
3212 .map(|n| n.to_string_lossy().into_owned())
3213 .unwrap_or_else(|| single.display().to_string());
3214 format!("Move \u{201c}{name}\u{201d} into \u{201c}{target_name}\u{201d}?")
3215 }
3216 many => format!("Move {} items into \u{201c}{target_name}\u{201d}?", many.len()),
3217 };
3218 let dialog = gtk::AlertDialog::builder()
3219 .modal(true)
3220 .message(message)
3221 .detail("Hold Ctrl while dropping to copy instead of move.")
3222 .buttons(["Cancel", "Move"])
3223 .cancel_button(0)
3224 .default_button(1)
3225 .build();
3226 let s = sender.clone();
3227 dialog.choose(Some(&self.parent), None::<&gio::Cancellable>, move |res| {
3228 if matches!(res, Ok(1)) {
3229 s.input(TreeMsg::DropIntoConfirmed { target, sources });
3230 }
3231 });
3232 }
3233
3234 fn drop_into(
3238 &mut self,
3239 target: &Path,
3240 sources: &[PathBuf],
3241 copy: bool,
3242 sender: &ComponentSender<Self>,
3243 ) {
3244 let mut count = 0usize;
3245 let mut failed: Option<String> = None;
3246 for src in sources {
3247 if src == target || src.parent() == Some(target) {
3248 continue; }
3250 if !copy && target.starts_with(src) {
3251 failed = Some("Cannot move a folder into itself".to_string());
3252 break;
3253 }
3254 let result = if copy { self.ops.copy(src, target) } else { self.ops.move_(src, target) };
3255 match result {
3256 Ok(dest) => {
3257 count += 1;
3258 self.apply_change(Change::Created { path: dest });
3259 if !copy {
3260 self.apply_change(Change::Removed { path: src.clone() });
3261 }
3262 }
3263 Err(err) => {
3264 failed = Some(err.to_string());
3265 break;
3266 }
3267 }
3268 }
3269 if let Some(failed) = failed {
3270 self.status(failed, sender);
3271 return;
3272 }
3273 if count > 0 {
3274 let verb = if copy { "Copied" } else { "Moved" };
3275 self.status(format!("{verb} {count} item(s) into {}", target.display()), sender);
3276 }
3277 self.cursor_snap();
3278 }
3279
3280 fn create_entry(&mut self, is_dir: bool, sender: &ComponentSender<Self>) {
3281 let Some(dir) = self.target_dir() else {
3282 return;
3283 };
3284 let name = fresh_name(&dir, if is_dir { "new folder" } else { "new file" });
3285 let result = if is_dir {
3286 self.ops.create_dir(&dir, &name)
3287 } else {
3288 self.ops.create_file(&dir, &name)
3289 };
3290 match result {
3291 Ok(new_path) => {
3292 self.apply_change(Change::Created { path: new_path.clone() });
3293 self.renaming = Some(new_path.clone());
3294 self.renaming_state.set(true);
3295 self.select(&new_path);
3296 }
3297 Err(err) => self.status(format!("Could not create: {err}"), sender),
3298 }
3299 }
3300
3301 fn target_dir(&self) -> Option<PathBuf> {
3304 if let Some(path) = self.cursor_path() {
3305 let is_dir = self.rows.iter().find(|r| r.path == path).map(|r| r.is_dir).unwrap_or(false);
3306 if is_dir {
3307 return Some(path);
3308 }
3309 return path.parent().map(Path::to_path_buf);
3310 }
3311 self.tree.as_ref().map(|t| t.root().to_path_buf())
3312 }
3313
3314 fn apply_change(&mut self, change: Change) {
3315 if let Some(tree) = self.tree.as_mut() {
3316 tree.apply(&change, &StdDirSource);
3317 self.refresh_rows();
3318 }
3319 }
3320
3321 fn toggle_hidden(&mut self, sender: &ComponentSender<Self>) {
3324 let show = !self.config.show_hidden;
3325 self.config.show_hidden = show;
3326 if let Some(tree) = self.tree.as_mut() {
3327 tree.set_show_hidden(show);
3328 tree.reload_all(&StdDirSource);
3329 self.refresh_rows();
3330 }
3331 let label = if show { "Showing hidden files." } else { "Hiding hidden files." };
3332 self.status(label.to_string(), sender);
3333 }
3334
3335 fn set_sort_key(&mut self, key: SortKey, sender: &ComponentSender<Self>) {
3337 let mut opts = self.config.sort_options();
3338 opts.key = key;
3339 self.config.sort_key = key.as_str().to_owned();
3340 if let Some(tree) = self.tree.as_mut() {
3341 tree.set_sort(opts);
3342 tree.reload_all(&StdDirSource);
3343 self.refresh_rows();
3344 }
3345 self.status(format!("Sorting by {}.", key.as_str()), sender);
3346 }
3347
3348 fn toggle_sort_direction(&mut self, sender: &ComponentSender<Self>) {
3350 let mut opts = self.config.sort_options();
3351 opts.ascending = !opts.ascending;
3352 self.config.sort_ascending = opts.ascending;
3353 if let Some(tree) = self.tree.as_mut() {
3354 tree.set_sort(opts);
3355 tree.reload_all(&StdDirSource);
3356 self.refresh_rows();
3357 }
3358 let dir = if opts.ascending { "ascending" } else { "descending" };
3359 self.status(format!("Sort order: {dir}."), sender);
3360 }
3361
3362 fn status(&mut self, message: String, sender: &ComponentSender<Self>) {
3363 let _ = sender.output(TreeOutput::Status(message));
3364 }
3365
3366 fn run_shortcut(&mut self, target: ShortcutTarget, sender: &ComponentSender<Self>) {
3371 if let ShortcutTarget::Builtin(builtin) = &target
3374 && builtin.is_pane_action()
3375 {
3376 let _ = sender.output(TreeOutput::PaneAction(*builtin));
3377 return;
3378 }
3379 let has_selection = self.cursor.is_some() || !self.selected.is_empty();
3383 let path = match self.cursor_path() {
3384 Some(path) => path,
3385 None => match self.tree.as_ref().map(|t| t.root().to_path_buf()) {
3386 Some(root) => root,
3387 None => return,
3388 },
3389 };
3390
3391 if let ShortcutTarget::Command(cmd) = &target {
3393 if !has_selection {
3394 return;
3395 }
3396 let command = action_command(&cmd.command, &path);
3397 match spawn_command(&command) {
3398 Ok(()) => {
3399 self.status(format!("Ran {} on {}", cmd.command, path.display()), sender);
3400 }
3401 Err(err) => {
3402 self.status(format!("Could not run {}: {err}", cmd.command), sender);
3403 }
3404 }
3405 return;
3406 }
3407 let ShortcutTarget::Builtin(builtin) = target else {
3408 return;
3409 };
3410 if builtin == BuiltinAction::Separator
3411 || (builtin.is_directory_only() && !path.is_dir())
3412 {
3413 return;
3414 }
3415 if matches!(builtin, BuiltinAction::OpenWithDefault) && path.is_dir() {
3416 return;
3417 }
3418 if builtin == BuiltinAction::ViewThumbnail && !path.is_dir() && !is_thumbnailable(&path) {
3419 return;
3420 }
3421 if !has_selection && needs_selection(builtin) {
3423 return;
3424 }
3425 if has_selection && !self.selected.iter().any(|p| p == &path) {
3429 self.select(&path);
3430 }
3431 let selection = self.selected.clone();
3434 sender.input(action_message(builtin, &path, &selection));
3435 }
3436}
3437
3438fn fresh_name(dir: &Path, stem: &str) -> String {
3443 for n in 0.. {
3444 let name = if n == 0 { stem.to_string() } else { format!("{stem} {n}") };
3445 if !dir.join(&name).exists() {
3446 return name;
3447 }
3448 }
3449 unreachable!("the loop above always finds a free name")
3450}
3451
3452fn open_in_app(path: &Path) -> Result<String, String> {
3461 let content_type = content_type_for(path)
3462 .ok_or_else(|| format!("Could not determine the type of {}", path.display()))?;
3463 let app = gio::AppInfo::default_for_type(&content_type, false)
3464 .ok_or_else(|| format!("No application is registered to open {}", path.display()))?;
3465 let file = gio::File::for_path(path);
3466 app.launch(&[file], None::<&gio::AppLaunchContext>)
3467 .map_err(|err| format!("Could not open {}: {err}", path.display()))?;
3468 Ok(app.display_name().to_string())
3469}
3470
3471fn content_type_for(path: &Path) -> Option<glib::GString> {
3478 let file = gio::File::for_path(path);
3479 let info = file
3480 .query_info(
3481 gio::FILE_ATTRIBUTE_STANDARD_CONTENT_TYPE,
3482 gio::FileQueryInfoFlags::NONE,
3483 None::<&gio::Cancellable>,
3484 )
3485 .ok()?;
3486 info.content_type()
3487}
3488
3489fn default_app_label(path: &Path) -> Option<String> {
3493 let content_type = content_type_for(path)?;
3494 gio::AppInfo::default_for_type(&content_type, false).map(|app| {
3495 format!("Open With {}", app.display_name())
3496 })
3497}
3498
3499#[allow(deprecated)] fn open_with_dialog(parent: >k::Window, path: &Path, sender: &ComponentSender<Tree>) {
3503 let Some(content_type) = content_type_for(path) else {
3504 let _ = sender.output(TreeOutput::Status(format!(
3505 "Could not determine the type of {}",
3506 path.display()
3507 )));
3508 return;
3509 };
3510 let dialog = gtk::AppChooserDialog::for_content_type(
3511 Some(parent),
3512 gtk::DialogFlags::MODAL,
3513 &content_type,
3514 );
3515 dialog.set_title(Some(&format!("Open {} with...", path.display())));
3516
3517 if gtk4_layer_shell::is_supported() && !dialog.is_layer_window() {
3523 dialog.init_layer_shell();
3524 dialog.set_layer(Layer::Overlay);
3525 dialog.set_keyboard_mode(KeyboardMode::Exclusive);
3526 dialog.set_default_size(540, 620);
3527 }
3529
3530 let dialog = dialog.clone();
3531 let path = path.to_path_buf();
3532 let s = sender.clone();
3533 dialog.connect_response(move |dialog, response| {
3534 if response == gtk::ResponseType::Ok
3535 && let Some(app) = dialog.app_info()
3536 {
3537 let file = gio::File::for_path(&path);
3538 match app.launch(&[file], None::<&gio::AppLaunchContext>) {
3539 Ok(()) => {
3540 let _ = s.output(TreeOutput::Status(format!(
3541 "Opened {} with {}",
3542 path.display(),
3543 app.display_name()
3544 )));
3545 }
3546 Err(err) => {
3547 let _ = s.output(TreeOutput::Status(format!(
3548 "Could not open {} with {}: {err}",
3549 path.display(),
3550 app.display_name()
3551 )));
3552 }
3553 }
3554 }
3555 dialog.close();
3556 });
3557 dialog.present();
3558}
3559
3560fn set_clipboard_text(text: &str) {
3562 if let Some(display) = gdk::Display::default() {
3563 display.clipboard().set_text(text);
3564 }
3565}
3566
3567const URI_LIST_MIME: &str = "text/uri-list";
3570
3571const GNOME_CLIP_MIME: &str = "x-special/gnome-copied-files";
3575
3576fn read_clipboard_files(done: impl FnOnce(Vec<PathBuf>) + 'static) {
3581 let Some(display) = gdk::Display::default() else {
3582 done(Vec::new());
3583 return;
3584 };
3585 let clipboard = display.clipboard();
3586 let formats = clipboard.formats();
3587 let has_uri = formats.contain_mime_type(URI_LIST_MIME);
3588 let has_gnome = formats.contain_mime_type(GNOME_CLIP_MIME);
3589 if !has_uri && !has_gnome {
3590 done(Vec::new());
3591 return;
3592 }
3593 clipboard.read_text_async(gio::Cancellable::NONE, move |result| {
3594 let text = result.ok().flatten().unwrap_or_default();
3595 done(parse_clipboard_uris(&text, has_gnome && !has_uri));
3596 });
3597}
3598
3599fn parse_clipboard_uris(text: &str, verb_line: bool) -> Vec<PathBuf> {
3605 text.lines()
3606 .map(str::trim)
3607 .filter(|line| !line.is_empty() && !line.starts_with('#'))
3608 .filter_map(|line| {
3609 if verb_line && (line.eq_ignore_ascii_case("copy") || line.eq_ignore_ascii_case("cut")) {
3610 return None;
3611 }
3612 let (path, _host) = glib::filename_from_uri(line).ok()?;
3613 path.is_absolute().then_some(path)
3614 })
3615 .collect()
3616}
3617
3618fn spawn_command(cmd: &[String]) -> Result<(), std::io::Error> {
3619 if cmd.is_empty() {
3620 return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "empty command"));
3621 }
3622 let mut process = std::process::Command::new(&cmd[0]);
3623 for arg in &cmd[1..] {
3624 process.arg(arg);
3625 }
3626 process.spawn().map(|_| ())
3627}
3628
3629#[cfg(test)]
3630mod tests {
3631 use super::*;
3632
3633 #[test]
3634 fn media_detection_is_extension_based_and_case_insensitive() {
3635 for name in ["a.JPG", "b.jpeg", "c.png", "d.webp", "e.HEIC", "f.tiff"] {
3636 assert_eq!(media_kind(Path::new(name)), Some(MediaKind::Image), "{name}");
3637 }
3638 for name in ["loop.GIF", "x.gif"] {
3639 assert_eq!(media_kind(Path::new(name)), Some(MediaKind::Gif), "{name}");
3640 }
3641 for name in ["clip.mp4", "b.MKV", "c.webm", "d.mov", "e.OGV"] {
3642 assert_eq!(media_kind(Path::new(name)), Some(MediaKind::Video), "{name}");
3643 }
3644 for name in ["song.MP3", "b.flac", "c.wav", "d.m4a", "e.opus", "f.oga"] {
3645 if cfg!(feature = "audio") {
3646 assert_eq!(media_kind(Path::new(name)), Some(MediaKind::Audio), "{name}");
3647 assert!(is_thumbnailable(Path::new(name)), "{name}");
3648 } else {
3649 assert_eq!(media_kind(Path::new(name)), None, "{name}");
3651 }
3652 }
3653 for name in ["a.svg", "b.txt", "c.rs", "noext", "d.png.bak"] {
3654 assert_eq!(media_kind(Path::new(name)), None, "{name}");
3655 assert!(!is_thumbnailable(Path::new(name)), "{name}");
3656 }
3657 }
3658
3659 #[test]
3660 fn thumbnail_width_tracks_the_panel_and_stays_positive() {
3661 let panel = PanelConfig { width: 300, margin: 0, ..PanelConfig::default() };
3662 assert_eq!(thumbnail_content_width(panel), 276);
3663
3664 let panel = PanelConfig { width: 500, margin: 10, ..PanelConfig::default() };
3665 assert_eq!(thumbnail_content_width(panel), 456);
3666
3667 let tiny = PanelConfig { width: 40, margin: 0, ..PanelConfig::default() };
3668 assert_eq!(thumbnail_content_width(tiny), 64);
3669 }
3670
3671 #[test]
3672 fn accelerator_normalization_supports_both_syntaxes() {
3673 assert_eq!(normalize_accelerator("Ctrl+x"), "<Control>x");
3675 assert_eq!(normalize_accelerator("Ctrl+Shift+m"), "<Control><Shift>m");
3676 assert_eq!(normalize_accelerator("Shift+Delete"), "<Shift>Delete");
3677 assert_eq!(normalize_accelerator("ctrl+alt+t"), "<Control><Alt>t");
3678 assert_eq!(normalize_accelerator("<Control>x"), "<Control>x");
3680 assert_eq!(normalize_accelerator("F2"), "F2");
3681 assert_eq!(normalize_accelerator("Delete"), "Delete");
3682 }
3683
3684 fn row(path: &str) -> VisibleRow {
3685 VisibleRow {
3686 path: PathBuf::from(path),
3687 name: path.rsplit('/').next().unwrap().to_string(),
3688 depth: 0,
3689 is_dir: false,
3690 is_symlink: false,
3691 expanded: false,
3692 has_children: false,
3693 matches: false,
3694 }
3695 }
3696
3697 #[test]
3698 fn parse_clipboard_uris_handles_both_formats() {
3699 let uri_list = "#comment\r\nfile:///tmp/a.txt\r\n\r\nfile:///tmp/b%20c.txt\r\n";
3701 assert_eq!(
3702 parse_clipboard_uris(uri_list, false),
3703 vec![PathBuf::from("/tmp/a.txt"), PathBuf::from("/tmp/b c.txt")]
3704 );
3705 let gnome = "cut\nfile:///home/u/x\n";
3707 assert_eq!(parse_clipboard_uris(gnome, true), vec![PathBuf::from("/home/u/x")]);
3708 assert_eq!(parse_clipboard_uris("copy\n", false), Vec::<PathBuf>::new());
3710 assert_eq!(
3712 parse_clipboard_uris("https://example.com/x\nrelative/path\nfile:///ok\n", false),
3713 vec![PathBuf::from("/ok")]
3714 );
3715 assert!(parse_clipboard_uris("", false).is_empty());
3716 }
3717
3718 #[test]
3719 fn icon_name_matches_extensions_case_insensitively() {
3720 assert_eq!(icon_name(&row("/a/Photo.JPG")), "image-x-generic-symbolic");
3721 assert_eq!(icon_name(&row("/a/song.Flac")), "audio-x-generic-symbolic");
3722 assert_eq!(icon_name(&row("/a/clip.mkv")), "video-x-generic-symbolic");
3723 assert_eq!(icon_name(&row("/a/archive.tar")), "package-x-generic-symbolic");
3724 assert_eq!(icon_name(&row("/a/doc.pdf")), "application-pdf-symbolic");
3725 assert_eq!(icon_name(&row("/a/notes.txt")), "text-x-generic-symbolic");
3726 let mut dir = row("/a/pictures");
3728 dir.is_dir = true;
3729 assert_eq!(icon_name(&dir), "folder-symbolic");
3730 dir.expanded = true;
3731 assert_eq!(icon_name(&dir), "folder-open-symbolic");
3732 assert_eq!(icon_name(&row("/a/.bashrc")), "text-x-generic-symbolic");
3734 }
3735
3736 #[test]
3737 fn resolve_drop_filters_noops_and_detects_cycles() {
3738 let target = Path::new("/a/b");
3739 assert_eq!(resolve_drop(target, vec![PathBuf::from("/a/b")], false), DropPlan::Ignore);
3741 assert_eq!(resolve_drop(target, vec![PathBuf::from("/a/b/c")], false), DropPlan::Ignore);
3742 assert!(matches!(
3744 resolve_drop(target, vec![PathBuf::from("/a")], false),
3745 DropPlan::Reject(_)
3746 ));
3747 assert!(matches!(
3749 resolve_drop(target, vec![PathBuf::from("/x/y")], false),
3750 DropPlan::Move(_)
3751 ));
3752 assert!(matches!(
3753 resolve_drop(target, vec![PathBuf::from("/x/y")], true),
3754 DropPlan::Copy(_)
3755 ));
3756 }
3757
3758 #[test]
3759 fn drag_paths_uses_the_selection_only_when_the_row_is_in_it() {
3760 let sel = vec![PathBuf::from("/r/a"), PathBuf::from("/r/b")];
3761 assert_eq!(drag_paths(&sel, Path::new("/r/b")), sel);
3763 assert_eq!(
3765 drag_paths(&sel, Path::new("/r/c")),
3766 vec![PathBuf::from("/r/c")]
3767 );
3768 assert_eq!(
3770 drag_paths(&[PathBuf::from("/r/a")], Path::new("/r/a")),
3771 vec![PathBuf::from("/r/a")]
3772 );
3773 assert_eq!(
3775 drag_paths(&[], Path::new("/r/z")),
3776 vec![PathBuf::from("/r/z")]
3777 );
3778 }
3779
3780 #[test]
3781 fn index_range_is_inclusive_and_order_independent() {
3782 assert_eq!(index_range(2, 2).collect::<Vec<_>>(), vec![2]);
3783 assert_eq!(index_range(1, 4).collect::<Vec<_>>(), vec![1, 2, 3, 4]);
3784 assert_eq!(index_range(4, 1).collect::<Vec<_>>(), vec![1, 2, 3, 4]);
3786 }
3787
3788 #[test]
3789 fn format_uri_list_serializes_file_uris() {
3790 let text = format_uri_list(&[PathBuf::from("/tmp/a.txt"), PathBuf::from("/tmp/b c.txt")]);
3791 assert_eq!(text, "file:///tmp/a.txt\r\nfile:///tmp/b%20c.txt\r\n");
3792 assert!(format_uri_list(&[]).is_empty());
3793 assert_eq!(
3795 parse_clipboard_uris(&text, false),
3796 vec![PathBuf::from("/tmp/a.txt"), PathBuf::from("/tmp/b c.txt")]
3797 );
3798 }
3799
3800 #[test]
3801 fn typeahead_finds_the_next_matching_row_and_wraps() {
3802 let rows = vec![row("/r/apple"), row("/r/banana"), row("/r/apricot"), row("/r/cherry")];
3803 assert_eq!(typeahead_target(&rows, "ap", None), Some(0));
3806 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);
3809 }
3810
3811 #[test]
3812 fn typeahead_buffer_resets_after_a_pause_and_caps_length() {
3813 let mut ta = TypeAhead::default();
3814 let t0 = std::time::Instant::now();
3815 assert_eq!(ta.push('a', t0).as_deref(), Some("a"));
3816 assert_eq!(ta.push('b', t0 + Duration::from_millis(100)).as_deref(), Some("ab"));
3817 assert_eq!(ta.push('c', t0 + Duration::from_millis(2000)).as_deref(), Some("c"));
3819 assert_eq!(ta.push('\u{1}', t0), None);
3821 }
3822}