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) => {
1171 open_in_app(&path);
1172 self.status(format!("Opened {} with its default app", path.display()), &sender);
1173 }
1174 TreeMsg::CreateLink(path) => match self.ops.create_link(&path) {
1175 Ok(new_path) => {
1176 self.apply_change(Change::Created { path: new_path.clone() });
1177 }
1178 Err(err) => self.status(format!("Could not create link: {err}"), &sender),
1179 },
1180 TreeMsg::Properties(path) => {
1181 crate::ui::props::show_properties_dialog(&self.parent.clone(), &path, &sender);
1182 }
1183 TreeMsg::PropertiesSelected(paths) => {
1184 crate::ui::props::show_multi_properties_dialog(&self.parent.clone(), &paths);
1185 }
1186 TreeMsg::CopyPaths(paths) => {
1187 let text = paths
1188 .iter()
1189 .map(|p| p.display().to_string())
1190 .collect::<Vec<_>>()
1191 .join("\n");
1192 set_clipboard_text(&text);
1193 self.status(format!("Copied {} paths to clipboard", paths.len()), &sender);
1194 }
1195 TreeMsg::CopyRelativePaths(paths) => {
1196 let root = self.tree.as_ref().map(|t| t.root().to_path_buf());
1197 let text = paths
1198 .iter()
1199 .map(|p| {
1200 root.as_deref()
1201 .and_then(|root| p.strip_prefix(root).ok())
1202 .map(|rel| rel.display().to_string())
1203 .unwrap_or_else(|| p.display().to_string())
1204 })
1205 .collect::<Vec<_>>()
1206 .join("\n");
1207 set_clipboard_text(&text);
1208 self.status(
1209 format!("Copied {} relative paths to clipboard", paths.len()),
1210 &sender,
1211 );
1212 }
1213 TreeMsg::RunCommandEach { command, paths } => {
1214 let mut failures = 0usize;
1215 for path in &paths {
1216 let cmd = action_command(&command, path);
1217 if spawn_command(&cmd).is_err() {
1218 failures += 1;
1219 }
1220 }
1221 let total = paths.len();
1222 if failures == 0 {
1223 self.status(format!("Ran {command} on {total} item(s)"), &sender);
1224 } else {
1225 self.status(
1226 format!("Ran {command} on {total} item(s); {failures} failed"),
1227 &sender,
1228 );
1229 }
1230 }
1231 TreeMsg::RenameTo { path, name } => match self.ops.rename(&path, &name) {
1232 Ok(new_path) => {
1233 self.apply_change(Change::Renamed {
1234 from: path,
1235 to: new_path.clone(),
1236 });
1237 self.select(&new_path);
1238 }
1239 Err(err) => self.status(format!("Could not rename: {err}"), &sender),
1240 },
1241 TreeMsg::ToggleThumbnail(path) => {
1242 if self.thumbnails.contains(&path) {
1243 self.clear_thumbnails_under(&path);
1245 } else {
1246 self.thumbnails.insert(path.clone());
1247 if path.is_dir() {
1249 self.expand_dir(&path, &sender);
1250 }
1251 }
1252 }
1253 TreeMsg::ToggleThumbnailPlay(path) => self.toggle_thumbnail_play(&path),
1254 TreeMsg::RunShortcut { target } => self.run_shortcut(target, &sender),
1255
1256 TreeMsg::SelectAllRows => {
1257 if !self.rows.is_empty() {
1258 self.selected = self.rows.iter().map(|r| r.path.clone()).collect();
1259 self.cursor = Some(0);
1260 self.anchor = self.rows.first().map(|r| r.path.clone());
1261 self.selection_dirty = true;
1262 }
1263 }
1264 TreeMsg::TypeAhead(ch) => {
1265 let now = std::time::Instant::now();
1266 if let Some(prefix) = self.typeahead.push(ch, now)
1267 && let Some(index) = typeahead_target(&self.rows, &prefix, self.cursor)
1268 {
1269 let path = self.rows[index].path.clone();
1270 self.cursor = Some(index);
1271 self.set_single_selection(path);
1272 self.selection_dirty = true;
1273 }
1274 }
1275
1276 TreeMsg::RenameCommit => {
1277 let text = self
1278 .rename_entry
1279 .as_ref()
1280 .map(|e| e.text().to_string())
1281 .unwrap_or_default();
1282 let Some(old) = self.renaming.take() else {
1283 return;
1284 };
1285 self.renaming_state.set(false);
1286 self.rename_entry = None;
1287 let trimmed = text.trim().to_string();
1288 let unchanged = old
1289 .file_name()
1290 .map(|n| n.to_string_lossy() == trimmed.as_str())
1291 .unwrap_or(false);
1292 if trimmed.is_empty() || unchanged {
1293 return;
1294 }
1295 match self.ops.rename(&old, &trimmed) {
1296 Ok(new_path) => {
1297 self.apply_change(Change::Renamed {
1298 from: old,
1299 to: new_path.clone(),
1300 });
1301 self.select(&new_path);
1302 }
1303 Err(err) => self.status(format!("Could not rename: {err}"), &sender),
1304 }
1305 }
1306 TreeMsg::RenameCancel => {
1307 self.renaming = None;
1308 self.renaming_state.set(false);
1309 self.rename_entry = None;
1310 }
1311
1312 TreeMsg::Copy => {
1313 let paths = self.selected_for_clipboard();
1314 if !paths.is_empty() {
1315 self.clipboard = Some(Clipboard { op: ClipboardOp::Copy, paths: paths.clone() });
1316 self.status(format!("Copying {} item(s)", paths.len()), &sender);
1317 }
1318 }
1319 TreeMsg::Cut => {
1320 let paths = self.selected_for_clipboard();
1321 if !paths.is_empty() {
1322 self.clipboard = Some(Clipboard { op: ClipboardOp::Cut, paths: paths.clone() });
1323 self.status(format!("Cutting {} item(s)", paths.len()), &sender);
1324 }
1325 }
1326 TreeMsg::Paste => self.paste(&sender),
1327 TreeMsg::PastePaths(paths) => self.paste_paths(paths, &sender),
1328
1329 TreeMsg::NewFile => self.create_entry(false, &sender),
1330 TreeMsg::NewFolder => self.create_entry(true, &sender),
1331
1332 TreeMsg::ToggleHidden => self.toggle_hidden(&sender),
1333 TreeMsg::SetSortKey(key) => self.set_sort_key(key, &sender),
1334 TreeMsg::ToggleSortDirection => self.toggle_sort_direction(&sender),
1335
1336 TreeMsg::FsChange(change) => {
1337 self.pending.push(change);
1339 if self.flush_source.is_none() {
1340 let s = sender.clone();
1341 let id = glib::timeout_add_local_once(
1342 Duration::from_millis(100),
1343 move || {
1344 s.input(TreeMsg::FlushChanges);
1345 }
1346 );
1347 self.flush_source = Some(id);
1348 }
1349 }
1350 TreeMsg::FlushChanges => {
1351 self.flush_source = None;
1352 let changes = std::mem::take(&mut self.pending);
1353 for change in changes {
1354 self.apply_change(change);
1355 }
1356 self.cursor_snap();
1357 }
1358 TreeMsg::RequestOpenFolder => {
1359 self.status("Pick a folder to browse...".to_string(), &sender);
1360 let _ = sender.output(TreeOutput::OpenFolderRequested);
1361 }
1362
1363 TreeMsg::DropInto { target, sources, copy } => {
1364 self.request_drop_into(target, sources, copy, &sender);
1365 }
1366 TreeMsg::DropIntoRoot { sources, copy } => {
1367 if let Some(root) = self.tree.as_ref().map(|t| t.root().to_path_buf()) {
1368 self.request_drop_into(root, sources, copy, &sender);
1369 }
1370 }
1371 TreeMsg::DropIntoConfirmed { target, sources } => {
1372 self.drop_into(&target, &sources, false, &sender);
1373 }
1374 }
1375 }
1376}
1377
1378fn accel_mods(m: gdk::ModifierType) -> gdk::ModifierType {
1385 m & (gdk::ModifierType::CONTROL_MASK
1386 | gdk::ModifierType::SHIFT_MASK
1387 | gdk::ModifierType::ALT_MASK
1388 | gdk::ModifierType::SUPER_MASK
1389 | gdk::ModifierType::HYPER_MASK
1390 | gdk::ModifierType::META_MASK)
1391}
1392
1393#[derive(Clone)]
1395struct KeyBinding {
1396 key: gdk::Key,
1397 mods: gdk::ModifierType,
1398 target: ShortcutTarget,
1399}
1400
1401fn compile_shortcuts(menu: &ContextMenu) -> Rc<Vec<KeyBinding>> {
1405 let mut out = Vec::new();
1406 for (accel, target) in menu.shortcuts() {
1407 if let Some((key, mods)) = parse_accelerator(&accel) {
1408 out.push(KeyBinding { key, mods, target });
1409 }
1410 }
1411 Rc::new(out)
1412}
1413
1414fn parse_accelerator(accel: &str) -> Option<(gdk::Key, gdk::ModifierType)> {
1420 gtk::accelerator_parse(normalize_accelerator(accel))
1421}
1422
1423fn normalize_accelerator(accel: &str) -> String {
1426 if !accel.contains('+') {
1427 return accel.to_owned();
1428 }
1429 let mut mods = String::new();
1430 let mut key = String::new();
1431 for token in accel.split('+') {
1432 let token = token.trim();
1433 if token.is_empty() {
1434 continue;
1435 }
1436 match token.to_ascii_lowercase().as_str() {
1437 "ctrl" | "control" | "primary" => mods.push_str("<Control>"),
1438 "shift" => mods.push_str("<Shift>"),
1439 "alt" => mods.push_str("<Alt>"),
1440 "super" | "win" | "mod4" => mods.push_str("<Super>"),
1441 "meta" => mods.push_str("<Meta>"),
1442 "hyper" => mods.push_str("<Hyper>"),
1443 _ => key = token.to_owned(),
1444 }
1445 }
1446 format!("{mods}{key}")
1447}
1448
1449fn handle_key(
1450 key: gdk::Key,
1451 state: gdk::ModifierType,
1452 renaming: bool,
1453 keys: &[KeyBinding],
1454 sender: &ComponentSender<Tree>,
1455) -> glib::Propagation {
1456 use gdk::Key;
1457
1458 if renaming {
1460 return glib::Propagation::Proceed;
1461 }
1462
1463 let ctrl = state.contains(gdk::ModifierType::CONTROL_MASK);
1464 let shift = state.contains(gdk::ModifierType::SHIFT_MASK);
1465
1466 let structural = match key {
1469 Key::Up if ctrl && shift => Some(TreeMsg::MoveExtendingSelection(-1)),
1470 Key::Down if ctrl && shift => Some(TreeMsg::MoveExtendingSelection(1)),
1471 Key::Up if shift => Some(TreeMsg::ExtendSelection(-1)),
1472 Key::Down if shift => Some(TreeMsg::ExtendSelection(1)),
1473 Key::Up => Some(TreeMsg::MoveUp),
1474 Key::Down => Some(TreeMsg::MoveDown),
1475 Key::Left => Some(TreeMsg::CollapseCursor),
1476 Key::Right => Some(TreeMsg::ExpandCursor),
1477 Key::Return | Key::KP_Enter | Key::space => Some(TreeMsg::ActivateCursor),
1478 Key::Escape => Some(TreeMsg::RenameCancel),
1479 Key::o | Key::O if ctrl && !shift => Some(TreeMsg::RequestOpenFolder),
1480 Key::a | Key::A if ctrl && !shift => Some(TreeMsg::SelectAllRows),
1481 _ => None,
1482 };
1483
1484 let msg = match structural {
1485 Some(msg) => msg,
1486 None => {
1487 let mut hit = None;
1491 for binding in keys.iter() {
1492 if key.to_lower() == binding.key.to_lower()
1493 && accel_mods(state) == accel_mods(binding.mods)
1494 {
1495 hit = Some(TreeMsg::RunShortcut {
1496 target: binding.target.clone(),
1497 });
1498 break;
1499 }
1500 }
1501 match hit {
1502 Some(msg) => msg,
1503 None if !ctrl
1506 && !state.contains(gdk::ModifierType::ALT_MASK)
1507 && !state.contains(gdk::ModifierType::SUPER_MASK) =>
1508 {
1509 if let Some(ch) = key.to_unicode()
1510 && ch.is_ascii_graphic()
1511 {
1512 TreeMsg::TypeAhead(ch)
1513 } else {
1514 return glib::Propagation::Proceed;
1515 }
1516 }
1517 None => return glib::Propagation::Proceed,
1518 }
1519 }
1520 };
1521
1522 sender.input(msg);
1523 glib::Propagation::Stop
1524}
1525
1526fn update_selection_classes(
1535 tree: &mut Tree,
1536 widgets: &mut <Tree as Component>::Widgets,
1537) -> bool {
1538 let mut existing = 0;
1540 let mut child = widgets.list.first_child();
1541 while let Some(current) = child {
1542 if current.is::<gtk::ListBoxRow>() {
1543 existing += 1;
1544 }
1545 child = current.next_sibling();
1546 }
1547 if existing != tree.rows.len() {
1548 return false;
1549 }
1550 for (index, row) in tree.rows.iter().enumerate() {
1551 let Some(list_row) = widgets.list.row_at_index(index as i32) else {
1552 return false;
1553 };
1554 let selected = tree.cursor == Some(index) || tree.selected.contains(&row.path);
1555 if selected {
1556 list_row.add_css_class("tree-row-selected");
1557 } else {
1558 list_row.remove_css_class("tree-row-selected");
1559 }
1560 }
1561 true
1562}
1563
1564fn scroll_row_into_view(widgets: &<Tree as Component>::Widgets, index: usize) {
1569 let Some(row) = widgets.list.row_at_index(index as i32) else {
1570 return;
1571 };
1572 let adj = widgets.scrolled.vadjustment();
1575 let page = adj.page_size();
1576 if page <= 0.0 {
1577 return;
1578 }
1579 let Some(bounds) = row.compute_bounds(&widgets.list) else {
1582 return;
1583 };
1584 let y = bounds.y() as f64;
1585 let height = bounds.height() as f64;
1586 let top = adj.value();
1587 if y < top {
1588 adj.set_value(y);
1589 } else if y + height > top + page {
1590 adj.set_value((y + height - page).max(0.0));
1591 }
1592}
1593
1594fn rebuild(tree: &mut Tree, widgets: &mut <Tree as Component>::Widgets, sender: ComponentSender<Tree>) {
1595 let list = &widgets.list;
1596
1597 if let Some(previous) = tree.popover.take() {
1606 previous.unparent();
1607 }
1608
1609 widgets.scrolled.set_child(None::<>k::Widget>);
1614 list.remove_all();
1615
1616 if tree.tree.is_none() {
1617 widgets.scrolled.set_child(Some(list));
1618 return;
1619 }
1620
1621 let rows = std::mem::take(&mut tree.rows);
1624 let mut menu_row: Option<gtk::ListBoxRow> = None;
1625 tree.rename_entry = None;
1626 tree.gif_widgets.borrow_mut().clear();
1629 tree.audio_widgets.borrow_mut().clear();
1632
1633 for (index, row) in rows.iter().enumerate() {
1634 let list_row = gtk::ListBoxRow::new();
1635 list_row.set_focusable(true);
1636 list_row.set_can_focus(true);
1637 list_row.add_css_class("tree-row");
1638
1639 let row_indent = row.depth as i32 * 16 + 4;
1640 let container = gtk::Box::new(gtk::Orientation::Vertical, 0);
1642 container.set_hexpand(true);
1643 list_row.set_child(Some(&container));
1644
1645 let hbox = gtk::Box::new(gtk::Orientation::Horizontal, 6);
1646 hbox.set_margin_start(row_indent);
1647 hbox.set_hexpand(true);
1648 container.append(&hbox);
1649
1650 let icon = gtk::Image::from_icon_name(icon_name(row));
1651 icon.set_pixel_size(tree.config.icon_size as i32);
1652 icon.set_valign(gtk::Align::Center);
1653 hbox.append(&icon);
1654
1655 if tree.renaming.as_ref() == Some(&row.path) {
1656 let entry = gtk::Entry::new();
1660 entry.set_text(&row.name);
1661 entry.set_hexpand(true);
1662 entry.add_css_class("tree-rename-entry");
1663 {
1664 let s = sender.clone();
1665 entry.connect_activate(move |_| s.input(TreeMsg::RenameCommit));
1666 }
1667 let esc_controller = gtk::EventControllerKey::new();
1668 let s = sender.clone();
1669 esc_controller.connect_key_pressed(move |_, key, _code, _state| {
1670 if key == gdk::Key::Escape {
1671 s.input(TreeMsg::RenameCancel);
1672 glib::Propagation::Stop
1673 } else {
1674 glib::Propagation::Proceed
1675 }
1676 });
1677 entry.add_controller(esc_controller);
1678 hbox.append(&entry);
1679 tree.rename_entry = Some(entry.clone());
1680 entry.grab_focus();
1681 } else {
1682 let label = gtk::Label::new(Some(&row.name));
1683 label.set_xalign(0.0);
1684 label.set_hexpand(true);
1685 label.set_ellipsize(pango::EllipsizeMode::Middle);
1686 label.set_tooltip_text(Some(&row.path.to_string_lossy()));
1687 hbox.append(&label);
1688 }
1689
1690 if tree.cursor == Some(index) || tree.selected.contains(&row.path) {
1691 list_row.add_css_class("tree-row-selected");
1692 }
1693
1694 if row.matches {
1695 list_row.add_css_class("tree-row-match");
1696 }
1697
1698 if tree.menu_target.as_ref() == Some(&row.path) {
1699 menu_row = Some(list_row.clone());
1700 }
1701
1702 if tree.wants_thumbnail(row) {
1705 tree.append_thumbnail(&row.path, row_indent, &container, &sender);
1706 }
1707
1708 let path = row.path.clone();
1709 let release_path = row.path.clone();
1710 let is_dir = row.is_dir;
1711 let click = gtk::GestureClick::new();
1712 click.set_button(0);
1716 let s = sender.clone();
1717 let focus_row = list_row.clone();
1718 click.connect_pressed(move |gesture, _n_press, x, y| {
1719 if focus_row
1724 .pick(x, y, gtk::PickFlags::DEFAULT)
1725 .is_some_and(|widget| in_inline_media(&widget))
1726 {
1727 return;
1728 }
1729 focus_row.grab_focus();
1734 let button = gesture.current_button();
1735 let state = gesture.current_event_state();
1736 if button == 3 {
1737 if state.contains(gdk::ModifierType::SUPER_MASK) {
1741 gesture.set_state(gtk::EventSequenceState::Denied);
1742 return;
1743 }
1744 s.input(TreeMsg::Menu(path.clone()));
1745 return;
1746 }
1747 if button != 1 {
1748 return;
1749 }
1750 let ctrl = state.contains(gdk::ModifierType::CONTROL_MASK);
1751 let shift = state.contains(gdk::ModifierType::SHIFT_MASK);
1752 s.input(TreeMsg::RowPress { path: path.clone(), is_dir, ctrl, shift });
1755 });
1756 {
1757 let s = sender.clone();
1761 click.connect_released(move |gesture, _n_press, _x, _y| {
1762 if gesture.current_button() == 1 {
1763 s.input(TreeMsg::RowRelease { path: release_path.clone() });
1764 }
1765 });
1766 }
1767 list_row.add_controller(click);
1768
1769 if row.is_dir {
1779 let drop = gtk::DropTarget::new(gdk::FileList::static_type(), gdk::DragAction::MOVE | gdk::DragAction::COPY);
1780 let s = sender.clone();
1781 let target_dir = row.path.clone();
1782 let active = tree.drag_active.clone();
1783 let landed = tree.drag_landed_internal.clone();
1784 drop.connect_drop(move |target, value, _x, _y| {
1785 let Ok(list) = value.get::<gdk::FileList>() else {
1786 return false;
1787 };
1788 let sources: Vec<PathBuf> = list.files().iter().filter_map(gio::File::path).collect();
1789 if sources.is_empty() {
1790 return false;
1791 }
1792 let copy = target.current_event_state().contains(gdk::ModifierType::CONTROL_MASK);
1793 if active.get() {
1796 landed.set(true);
1797 }
1798 s.input(TreeMsg::DropInto { target: target_dir.clone(), sources, copy });
1799 true
1800 });
1801 list_row.add_controller(drop);
1802 }
1803
1804 list.append(&list_row);
1805 }
1806
1807 tree.rows = rows;
1808 widgets.scrolled.set_child(Some(list));
1809
1810 if let Some(row) = menu_row {
1814 let target = tree.menu_target.take().expect("menu_row set implies a target");
1815 let selection = tree.selected.clone();
1816 tree.popover = Some(build_menu(&row, &target, &tree.menu, &selection, tree.side, &sender));
1817 tree.menu_point = None;
1818 } else if let (Some(target), Some(at)) = (tree.menu_target.take(), tree.menu_point.take()) {
1819 let selection = tree.selected.clone();
1820 tree.popover = Some(build_menu_at(&widgets.scrolled, at, &target, &tree.menu, &selection, tree.side, &sender));
1821 }
1822 tree.menu_target = None;
1823 tree.menu_point = None;
1824}
1825
1826fn action_builtin(action: &ContextAction) -> Option<BuiltinAction> {
1828 match action {
1829 ContextAction::Builtin(b) => Some(*b),
1830 ContextAction::Entry(e) => Some(e.action),
1831 ContextAction::Command(_) | ContextAction::Submenu(_) => None,
1832 }
1833}
1834
1835fn menu_label(action: &ContextAction, path: &Path, side: PanelSide) -> String {
1840 let dynamic = match action_builtin(action) {
1841 Some(BuiltinAction::OpenWithDefault) => default_app_label(path),
1842 Some(BuiltinAction::InOppositePanel) => Some(format!("In {} panel", side.opposite().name())),
1843 Some(BuiltinAction::ViewThumbnail) if media_kind(path) == Some(MediaKind::Audio) => {
1845 Some("Show Player".to_owned())
1846 }
1847 _ => None,
1848 };
1849 dynamic.unwrap_or_else(|| action.label())
1850}
1851
1852pub(crate) fn accel_display(accel: &str) -> Option<String> {
1855 let (key, mods) = parse_accelerator(accel)?;
1856 Some(gtk::accelerator_get_label(key, mods).to_string())
1857}
1858
1859fn build_menu_box(
1864 path: &Path,
1865 menu: &ContextMenu,
1866 selection: &[PathBuf],
1867 side: PanelSide,
1868 sender: &ComponentSender<Tree>,
1869) -> gtk::Box {
1870 let menu_box = gtk::Box::new(gtk::Orientation::Vertical, 0);
1871 menu_box.add_css_class("tree-menu");
1872 let path = path.to_path_buf();
1873 let actions = menu.actions_for_selection(&path, selection.len());
1874 append_menu_items(&menu_box, &actions, &path, selection, side, sender);
1875 menu_box
1876}
1877
1878fn append_menu_items(
1881 menu_box: >k::Box,
1882 actions: &[ContextAction],
1883 path: &Path,
1884 selection: &[PathBuf],
1885 side: PanelSide,
1886 sender: &ComponentSender<Tree>,
1887) {
1888 let multi = selection.len() > 1;
1889 for action in actions {
1890 if action.is_hidden() {
1893 continue;
1894 }
1895
1896 if action_builtin(action) == Some(BuiltinAction::Separator) {
1898 let separator = gtk::Separator::new(gtk::Orientation::Horizontal);
1899 separator.add_css_class("tree-menu-separator");
1900 menu_box.append(&separator);
1901 continue;
1902 }
1903
1904 if let Some(builtin) = action_builtin(action) {
1908 if builtin.is_directory_only() && !path.is_dir() {
1909 continue;
1910 }
1911 if multi && builtin.is_single_row_only() {
1912 continue;
1913 }
1914 if matches!(builtin, BuiltinAction::OpenWithDefault) && path.is_dir() {
1915 continue;
1916 }
1917 if builtin == BuiltinAction::ViewThumbnail
1919 && !path.is_dir()
1920 && !is_thumbnailable(path)
1921 {
1922 continue;
1923 }
1924 }
1925
1926 let row_box = gtk::Box::new(gtk::Orientation::Horizontal, 12);
1927 row_box.add_css_class("tree-menu-item");
1928
1929 if let ContextAction::Submenu(sub) = action {
1931 let button = submenu_row_button(&sub.label);
1932 let popover = build_submenu_popover(&sub.items, path, selection, side, sender);
1933 let child = popover.clone();
1934 let anchor = button.clone();
1935 button.connect_clicked(move |_| {
1936 child.set_parent(&anchor);
1939 child.popup();
1940 });
1941 let child = popover.clone();
1942 popover.connect_closed(move |_| child.unparent());
1943 row_box.append(&button);
1944 row_box.append(&submenu_arrow());
1945 menu_box.append(&row_box);
1946 continue;
1947 }
1948
1949 let label = menu_label(action, path, side);
1950 let msg = match action {
1951 ContextAction::Builtin(b) => action_message(*b, path, selection),
1952 ContextAction::Entry(e) => action_message(e.action, path, selection),
1953 ContextAction::Command(cmd) => {
1954 if multi {
1955 TreeMsg::RunCommandEach {
1956 command: cmd.command.clone(),
1957 paths: selection.to_vec(),
1958 }
1959 } else {
1960 TreeMsg::RunCommand {
1961 command: cmd.command.clone(),
1962 path: path.to_path_buf(),
1963 }
1964 }
1965 }
1966 ContextAction::Submenu(_) => unreachable!("handled above"),
1967 };
1968
1969 let button = gtk::Button::new();
1972 button.set_halign(gtk::Align::Fill);
1973 button.set_hexpand(true);
1974 let label_widget = gtk::Label::new(Some(&label));
1975 label_widget.set_xalign(0.0);
1976 label_widget.set_hexpand(true);
1977 button.set_child(Some(&label_widget));
1978 let s = sender.clone();
1979 button.connect_clicked(move |_| s.input(msg.clone()));
1980 row_box.append(&button);
1981
1982 if let Some(shortcut) = action.shortcut()
1983 && let Some(display) = accel_display(&shortcut)
1984 {
1985 let hint = gtk::Label::new(Some(&display));
1986 hint.add_css_class("tree-menu-shortcut");
1987 hint.set_halign(gtk::Align::End);
1988 hint.set_valign(gtk::Align::Center);
1989 row_box.append(&hint);
1990 }
1991
1992 menu_box.append(&row_box);
1993 }
1994}
1995
1996fn submenu_row_button(label: &str) -> gtk::Button {
1998 let button = gtk::Button::new();
1999 button.set_halign(gtk::Align::Fill);
2000 button.set_hexpand(true);
2001 let text = gtk::Label::new(Some(label));
2002 text.set_xalign(0.0);
2003 text.set_hexpand(true);
2004 button.set_child(Some(&text));
2005 button
2006}
2007
2008fn submenu_arrow() -> gtk::Label {
2010 let arrow = gtk::Label::new(Some("\u{25b8}"));
2011 arrow.add_css_class("tree-menu-submenu-arrow");
2012 arrow.set_halign(gtk::Align::End);
2013 arrow.set_valign(gtk::Align::Center);
2014 arrow
2015}
2016
2017fn build_submenu_popover(
2020 items: &[ContextAction],
2021 path: &Path,
2022 selection: &[PathBuf],
2023 side: PanelSide,
2024 sender: &ComponentSender<Tree>,
2025) -> gtk::Popover {
2026 let menu_box = gtk::Box::new(gtk::Orientation::Vertical, 0);
2027 menu_box.add_css_class("tree-menu");
2028 append_menu_items(&menu_box, items, path, selection, side, sender);
2029
2030 let popover = gtk::Popover::new();
2031 popover.add_css_class("tree-menu-popover");
2032 popover.set_has_arrow(false);
2033 popover.set_position(gtk::PositionType::Right);
2034 popover.set_child(Some(&menu_box));
2035 popover
2036}
2037
2038fn show_menu(
2041 anchor: &impl IsA<gtk::Widget>,
2042 menu_box: >k::Box,
2043 point: Option<(f64, f64)>,
2044) -> gtk::Popover {
2045 let popover = gtk::Popover::new();
2046 popover.add_css_class("tree-menu-popover");
2047 popover.set_child(Some(menu_box));
2048 popover.set_autohide(true);
2049 popover.set_parent(anchor);
2050 if let Some((x, y)) = point {
2051 popover.set_pointing_to(Some(&gdk::Rectangle::new(x as i32, y as i32, 1, 1)));
2052 }
2053 popover.popup();
2054 popover
2055}
2056
2057fn build_menu(
2059 row: >k::ListBoxRow,
2060 path: &Path,
2061 menu: &ContextMenu,
2062 selection: &[PathBuf],
2063 side: PanelSide,
2064 sender: &ComponentSender<Tree>,
2065) -> gtk::Popover {
2066 let menu_box = build_menu_box(path, menu, selection, side, sender);
2067 show_menu(row, &menu_box, None)
2068}
2069
2070fn build_menu_at(
2073 anchor: &impl IsA<gtk::Widget>,
2074 at: (f64, f64),
2075 path: &Path,
2076 menu: &ContextMenu,
2077 selection: &[PathBuf],
2078 side: PanelSide,
2079 sender: &ComponentSender<Tree>,
2080) -> gtk::Popover {
2081 let menu_box = build_menu_box(path, menu, selection, side, sender);
2082 show_menu(anchor, &menu_box, Some(at))
2083}
2084
2085fn action_message(action: BuiltinAction, path: &Path, selection: &[PathBuf]) -> TreeMsg {
2091 if selection.len() > 1 {
2092 match action {
2093 BuiltinAction::Trash => return TreeMsg::DeleteSelected,
2094 BuiltinAction::DeletePermanently => return TreeMsg::PermanentDeleteSelected,
2095 BuiltinAction::CopyPath => return TreeMsg::CopyPaths(selection.to_vec()),
2096 BuiltinAction::CopyRelativePath => return TreeMsg::CopyRelativePaths(selection.to_vec()),
2097 BuiltinAction::Properties => return TreeMsg::PropertiesSelected(selection.to_vec()),
2098 _ => {}
2099 }
2100 }
2101 builtin_message(action, path)
2102}
2103
2104fn builtin_message(action: BuiltinAction, path: &Path) -> TreeMsg {
2106 match action {
2107 BuiltinAction::Open => TreeMsg::Activate(path.to_path_buf()),
2108 BuiltinAction::OpenSplit => TreeMsg::OpenSplit(path.to_path_buf()),
2109 BuiltinAction::InNewPanel => TreeMsg::OpenSplit(path.to_path_buf()),
2110 BuiltinAction::InOppositePanel => TreeMsg::OpenInOppositePanel(path.to_path_buf()),
2111 BuiltinAction::OpenWith => TreeMsg::OpenWith(path.to_path_buf()),
2112 BuiltinAction::OpenWithDefault => TreeMsg::OpenWithDefault(path.to_path_buf()),
2113 BuiltinAction::ViewThumbnail => TreeMsg::ToggleThumbnail(path.to_path_buf()),
2114 BuiltinAction::NewFile => TreeMsg::NewFile,
2115 BuiltinAction::NewFolder => TreeMsg::NewFolder,
2116 BuiltinAction::Duplicate => TreeMsg::Duplicate(path.to_path_buf()),
2117 BuiltinAction::CreateLink => TreeMsg::CreateLink(path.to_path_buf()),
2118 BuiltinAction::Cut => TreeMsg::Cut,
2119 BuiltinAction::Copy => TreeMsg::Copy,
2120 BuiltinAction::Paste => TreeMsg::Paste,
2121 BuiltinAction::Rename => TreeMsg::RenameAt(path.to_path_buf()),
2122 BuiltinAction::CopyPath => TreeMsg::CopyPath(path.to_path_buf()),
2123 BuiltinAction::CopyRelativePath => TreeMsg::CopyRelativePath(path.to_path_buf()),
2124 BuiltinAction::Properties => TreeMsg::Properties(path.to_path_buf()),
2125 BuiltinAction::Trash => TreeMsg::Trash(path.to_path_buf()),
2126 BuiltinAction::DeletePermanently => TreeMsg::PermanentDelete(path.to_path_buf()),
2127 BuiltinAction::ToggleHidden => TreeMsg::ToggleHidden,
2128 BuiltinAction::SortByName => TreeMsg::SetSortKey(SortKey::Name),
2129 BuiltinAction::SortBySize => TreeMsg::SetSortKey(SortKey::Size),
2130 BuiltinAction::SortByModified => TreeMsg::SetSortKey(SortKey::Modified),
2131 BuiltinAction::SortByType => TreeMsg::SetSortKey(SortKey::Type),
2132 BuiltinAction::ToggleSortAscending => TreeMsg::ToggleSortDirection,
2133 BuiltinAction::OpenFolder
2135 | BuiltinAction::Filter
2136 | BuiltinAction::SplitView
2137 | BuiltinAction::Up
2138 | BuiltinAction::Back
2139 | BuiltinAction::Forward
2140 | BuiltinAction::Collapse
2141 | BuiltinAction::ClosePane => unreachable!("pane action in a row context menu"),
2142 BuiltinAction::Separator => unreachable!("separators are rendered, not dispatched"),
2143 }
2144}
2145
2146const ARCHIVE_EXTENSIONS: [&str; 8] = ["zip", "tar", "gz", "xz", "bz2", "zst", "7z", "rar"];
2148
2149fn icon_name(row: &VisibleRow) -> &'static str {
2150 if row.is_dir {
2151 return if row.expanded { "folder-open-symbolic" } else { "folder-symbolic" };
2152 }
2153 if has_extension(&row.path, &["pdf"]) {
2157 "application-pdf-symbolic"
2158 } else if has_extension(&row.path, &["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp", "avif"])
2159 {
2160 "image-x-generic-symbolic"
2161 } else if has_extension(&row.path, &["mp3", "flac", "wav", "ogg", "m4a", "opus"]) {
2162 "audio-x-generic-symbolic"
2163 } else if has_extension(&row.path, &["mp4", "mkv", "webm", "mov", "avi"]) {
2164 "video-x-generic-symbolic"
2165 } else if has_extension(&row.path, &ARCHIVE_EXTENSIONS) {
2166 "package-x-generic-symbolic"
2167 } else {
2168 "text-x-generic-symbolic"
2169 }
2170}
2171
2172#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2178enum MediaKind {
2179 Image,
2181 Gif,
2183 Video,
2185 Audio,
2187}
2188
2189const IMAGE_EXTENSIONS: [&str; 10] =
2193 ["jpg", "jpeg", "png", "webp", "bmp", "tif", "tiff", "avif", "heic", "heif"];
2194
2195const VIDEO_EXTENSIONS: [&str; 15] = [
2198 "mp4", "m4v", "mkv", "webm", "mov", "avi", "wmv", "flv", "ogv", "ogg", "ts", "mpg",
2199 "mpeg", "3gp", "m2ts",
2200];
2201
2202const AUDIO_EXTENSIONS: [&str; 12] = [
2206 "mp3", "m4a", "m4b", "aac", "flac", "wav", "opus", "oga", "wma", "aif", "aiff", "alac",
2207];
2208
2209fn has_extension(path: &Path, extensions: &[&str]) -> bool {
2210 path.extension()
2211 .and_then(|ext| ext.to_str())
2212 .is_some_and(|ext| extensions.iter().any(|candidate| ext.eq_ignore_ascii_case(candidate)))
2213}
2214
2215fn media_kind(path: &Path) -> Option<MediaKind> {
2217 if has_extension(path, &["gif"]) {
2218 Some(MediaKind::Gif)
2219 } else if has_extension(path, &IMAGE_EXTENSIONS) {
2220 Some(MediaKind::Image)
2221 } else if has_extension(path, &VIDEO_EXTENSIONS) {
2222 Some(MediaKind::Video)
2223 } else if cfg!(feature = "audio") && has_extension(path, &AUDIO_EXTENSIONS) {
2224 Some(MediaKind::Audio)
2225 } else {
2226 None
2227 }
2228}
2229
2230fn is_thumbnailable(path: &Path) -> bool {
2232 media_kind(path).is_some()
2233}
2234
2235struct GifAnim {
2238 iter: gdk::gdk_pixbuf::PixbufAnimationIter,
2239}
2240
2241struct AudioWidgets {
2245 player: Rc<AudioPlayer>,
2246 play: gtk::Button,
2247 seek: gtk::Scale,
2248 time: gtk::Label,
2249 seeking: Rc<Cell<bool>>,
2252}
2253
2254fn needs_selection(action: BuiltinAction) -> bool {
2258 matches!(
2259 action,
2260 BuiltinAction::Rename
2261 | BuiltinAction::Duplicate
2262 | BuiltinAction::CreateLink
2263 | BuiltinAction::Cut
2264 | BuiltinAction::Copy
2265 | BuiltinAction::OpenWith
2266 | BuiltinAction::Trash
2267 | BuiltinAction::DeletePermanently
2268 )
2269}
2270
2271fn in_list_row(widget: >k::Widget) -> bool {
2274 let mut current = Some(widget.clone());
2275 while let Some(widget) = current {
2276 if widget.downcast_ref::<gtk::ListBoxRow>().is_some() {
2277 return true;
2278 }
2279 current = widget.parent();
2280 }
2281 false
2282}
2283
2284fn in_inline_media(widget: >k::Widget) -> bool {
2287 let mut current = Some(widget.clone());
2288 while let Some(widget) = current {
2289 if widget.downcast_ref::<gtk::Video>().is_some() || widget.has_css_class("tree-audio") {
2290 return true;
2291 }
2292 current = widget.parent();
2293 }
2294 false
2295}
2296
2297fn thumbnail_content_width(panel: PanelConfig) -> i32 {
2300 (panel.width as i32 - 2 * panel.margin as i32 - 24).max(64)
2301}
2302
2303fn thumbnail_size(texture: &gdk::Texture, available: i32) -> (i32, i32) {
2306 let natural_w = texture.width().max(1);
2307 let natural_h = texture.height().max(1);
2308 let available = available.max(32);
2309 if natural_w <= available {
2310 return (natural_w, natural_h);
2311 }
2312 let height = (natural_h as i64 * available as i64 / natural_w as i64).max(1) as i32;
2313 (available, height)
2314}
2315
2316fn thumbnail_picture(
2319 texture: &gdk::Texture,
2320 margin_start: i32,
2321 width: i32,
2322 height: i32,
2323) -> gtk::Picture {
2324 let picture = gtk::Picture::for_paintable(texture);
2325 picture.add_css_class("tree-thumbnail");
2326 picture.set_can_shrink(true);
2327 picture.set_content_fit(gtk::ContentFit::ScaleDown);
2328 picture.set_halign(gtk::Align::Start);
2329 picture.set_size_request(width, height);
2330 picture.set_margin_start(margin_start);
2331 picture.set_margin_bottom(6);
2332 picture
2333}
2334
2335fn format_time(micros: i64) -> String {
2338 if micros <= 0 {
2339 return "-:--".to_owned();
2340 }
2341 let total = micros / 1_000_000;
2342 let (h, m, s) = (total / 3600, (total % 3600) / 60, total % 60);
2343 if h > 0 {
2344 format!("{h}:{m:02}:{s:02}")
2345 } else {
2346 format!("{m}:{s:02}")
2347 }
2348}
2349
2350fn build_audio_player(player: &Rc<AudioPlayer>, margin_start: i32) -> (gtk::Box, AudioWidgets) {
2354 let controls = gtk::Box::new(gtk::Orientation::Horizontal, 6);
2355 controls.add_css_class("tree-audio");
2356 controls.set_margin_start(margin_start);
2357 controls.set_margin_bottom(6);
2358 controls.set_hexpand(true);
2359
2360 let play = gtk::Button::from_icon_name("media-playback-start-symbolic");
2361 play.add_css_class("flat");
2362 play.set_tooltip_text(Some("Play"));
2363 play.set_valign(gtk::Align::Center);
2364 {
2365 let player = player.clone();
2366 play.connect_clicked(move |button| {
2367 let playing = player.is_playing();
2368 player.set_playing(!playing);
2369 button.set_icon_name(if playing {
2370 "media-playback-start-symbolic"
2371 } else {
2372 "media-playback-pause-symbolic"
2373 });
2374 });
2375 }
2376 controls.append(&play);
2377
2378 let seek = gtk::Scale::with_range(gtk::Orientation::Horizontal, 0.0, 1.0, 1.0);
2381 seek.set_draw_value(false);
2382 seek.set_hexpand(true);
2383 seek.set_valign(gtk::Align::Center);
2384 seek.set_size_request(60, -1);
2385 let seeking = Rc::new(Cell::new(false));
2386 {
2387 let player = player.clone();
2388 let seeking = seeking.clone();
2389 seek.connect_change_value(move |_, _, value| {
2390 seeking.set(true);
2392 let flag = seeking.clone();
2393 glib::timeout_add_local_once(Duration::from_millis(200), move || flag.set(false));
2394 player.seek(value.round() as i64);
2395 glib::Propagation::Proceed
2396 });
2397 }
2398 controls.append(&seek);
2399
2400 let time = gtk::Label::new(Some("0:00 / -:--"));
2401 time.add_css_class("tree-audio-time");
2402 time.set_valign(gtk::Align::Center);
2403 time.set_width_chars(13);
2404 time.set_xalign(1.0);
2405 controls.append(&time);
2406
2407 let volume = gtk::Scale::with_range(gtk::Orientation::Horizontal, 0.0, 1.0, 0.05);
2408 volume.set_draw_value(false);
2409 volume.set_value(player.volume());
2410 volume.set_valign(gtk::Align::Center);
2411 volume.set_size_request(56, -1);
2412 volume.set_tooltip_text(Some("Volume"));
2413 {
2414 let player = player.clone();
2415 volume.connect_value_changed(move |scale| player.set_volume(scale.value()));
2416 }
2417 controls.append(&volume);
2418
2419 let widgets = AudioWidgets {
2420 player: player.clone(),
2421 play,
2422 seek,
2423 time,
2424 seeking,
2425 };
2426 (controls, widgets)
2427}
2428
2429impl Tree {
2430 fn open_root(&mut self, path: &Path, sender: &ComponentSender<Self>) {
2431 if let Some(id) = self.flush_source.take() {
2432 id.remove();
2433 }
2434 self.pending.clear();
2435
2436 let mut tree = TreeModel::new(
2437 path.to_path_buf(),
2438 self.config.sort_options(),
2439 self.config.show_hidden,
2440 );
2441 if let Err(err) = tree.expand(path, &StdDirSource) {
2442 self.status(format!("Could not open {}: {err}", path.display()), sender);
2443 return;
2444 }
2445 self.tree = Some(tree);
2446 self.refresh_rows();
2447 self.cursor = if self.rows.is_empty() { None } else { Some(0) };
2448 self.selected = Vec::new();
2449 self.renaming = None;
2450 self.renaming_state.set(false);
2451 self.rename_entry = None;
2452 self.menu_target = None;
2453 self.thumbnails.clear();
2454 self.thumb_cache.clear();
2455 self.playing.clear();
2456 for media in self.media.values() {
2457 media.set_playing(false);
2458 }
2459 self.media.clear();
2460 self.audio.clear();
2461 self.gif_anims.borrow_mut().clear();
2462 self.gif_widgets.borrow_mut().clear();
2463 self.audio_widgets.borrow_mut().clear();
2464 if let Some(previous) = self.popover.take() {
2465 previous.unparent();
2466 }
2467
2468 let root = path.to_path_buf();
2469 let watcher_sender = sender.clone();
2470 self._watcher = spawn_watcher(path, move |change| {
2471 watcher_sender.input(TreeMsg::FsChange(change));
2472 })
2473 .ok();
2474 self.watched.clear();
2475 if self._watcher.is_some() {
2476 self.watched.insert(root.clone());
2477 }
2478
2479 let _ = sender.output(TreeOutput::RootChanged(root));
2480 }
2481
2482 fn refresh_rows(&mut self) {
2483 if let Some(tree) = self.tree.as_ref() {
2484 tree.visible_rows_into(&mut self.rows);
2485 } else {
2486 self.rows.clear();
2487 }
2488 }
2489
2490 fn reconcile_watches(&mut self) {
2496 let Some(watcher) = self._watcher.as_mut() else {
2497 return;
2498 };
2499 let Some(model) = self.tree.as_ref() else {
2500 return;
2501 };
2502 let desired: HashSet<PathBuf> = model.expanded_loaded_dirs().into_iter().collect();
2503
2504 let stale: Vec<PathBuf> = self.watched.difference(&desired).cloned().collect();
2505 for path in stale {
2506 let _ = watcher.unwatch(&path);
2507 self.watched.remove(&path);
2508 }
2509
2510 let fresh: Vec<PathBuf> = desired.difference(&self.watched).cloned().collect();
2511 for path in fresh {
2512 if watcher.watch(&path, RecursiveMode::NonRecursive).is_ok() {
2513 self.watched.insert(path);
2514 }
2515 }
2516 }
2517
2518 fn cursor_path(&self) -> Option<PathBuf> {
2519 self.cursor.and_then(|i| self.rows.get(i)).map(|r| r.path.clone())
2520 }
2521
2522 fn cursor_delta(&mut self, delta: isize) {
2523 if self.rows.is_empty() {
2524 return;
2525 }
2526 let next = (self.cursor.unwrap_or(0) as isize + delta).clamp(0, self.rows.len() as isize - 1) as usize;
2527 let path = self.rows[next].path.clone();
2528 self.cursor = Some(next);
2529 self.set_single_selection(path);
2530 self.selection_dirty = true;
2531 }
2532
2533 fn cursor_extend(&mut self, delta: isize, _keep_anchor: bool) {
2540 if self.rows.is_empty() {
2541 return;
2542 }
2543 let anchor = self
2544 .anchor
2545 .clone()
2546 .or_else(|| self.cursor.map(|i| self.rows[i].path.clone()))
2547 .unwrap_or_else(|| self.rows[0].path.clone());
2548 let next = (self.cursor.unwrap_or(0) as isize + delta)
2549 .clamp(0, self.rows.len() as isize - 1) as usize;
2550 self.cursor = Some(next);
2551 self.anchor = Some(anchor.clone());
2552 let (Some(a), Some(b)) = (self.rows.iter().position(|r| r.path == anchor), Some(next)) else {
2553 return;
2554 };
2555 self.selected = self.rows[index_range(a, b)].iter().map(|r| r.path.clone()).collect();
2556 self.scroll_to = Some(next);
2557 self.selection_dirty = true;
2558 }
2559
2560 fn cursor_snap(&mut self) {
2561 let deselected = self.cursor.is_none();
2562 let prev = self.cursor_path();
2563 self.refresh_rows();
2564 if self.rows.is_empty() {
2565 self.cursor = None;
2566 self.selected.clear();
2567 return;
2568 }
2569 if deselected {
2570 self.cursor = None;
2572 return;
2573 }
2574 let index = prev
2575 .and_then(|p| self.rows.iter().position(|r| r.path == p))
2576 .unwrap_or(0);
2577 self.cursor = Some(index);
2578 }
2579
2580 fn select(&mut self, path: &Path) {
2581 if let Some(i) = self.rows.iter().position(|r| r.path == path) {
2582 self.cursor = Some(i);
2583 }
2584 self.selected = vec![path.to_path_buf()];
2585 }
2586
2587 fn press_default(&mut self, path: &Path, is_dir: bool, sender: &ComponentSender<Self>) {
2592 let now = std::time::Instant::now();
2595 let is_double = self.last_press.as_ref().is_some_and(|(last, at)| {
2596 last == path && now.duration_since(*at) < Duration::from_millis(400)
2597 });
2598 self.last_press = Some((path.to_path_buf(), now));
2599
2600 self.select(path);
2601 self.anchor = Some(path.to_path_buf());
2602 if is_dir {
2603 self.toggle_dir(path, sender);
2607 } else if is_double {
2608 self.activate(path, sender);
2610 }
2611 }
2612
2613 fn reveal_path(&mut self, path: PathBuf) {
2618 if let Some(i) = self.rows.iter().position(|r| r.path == path) {
2619 self.cursor = Some(i);
2620 self.selected = vec![path.clone()];
2621 self.anchor = Some(path);
2622 self.selection_dirty = true;
2623 self.scroll_to = Some(i);
2624 }
2625 }
2626
2627 fn range_select(&mut self, path: &Path) {
2631 let Some(anchor) = self.anchor.clone() else {
2632 self.select(path);
2633 self.anchor = Some(path.to_path_buf());
2634 return;
2635 };
2636 let (Some(a), Some(b)) = (
2637 self.rows.iter().position(|r| r.path == anchor),
2638 self.rows.iter().position(|r| r.path == path),
2639 ) else {
2640 self.select(path);
2641 return;
2642 };
2643 self.selected = self.rows[index_range(a, b)].iter().map(|r| r.path.clone()).collect();
2644 self.cursor = Some(b);
2645 }
2646
2647 fn set_single_selection(&mut self, path: PathBuf) {
2648 self.selected = vec![path.clone()];
2649 if let Some(i) = self.rows.iter().position(|r| r.path == path) {
2650 self.cursor = Some(i);
2651 }
2652 }
2653
2654 fn selected_for_clipboard(&self) -> Vec<PathBuf> {
2655 if self.selected.is_empty() {
2656 if let Some(path) = self.cursor_path() {
2657 return vec![path];
2658 }
2659 return Vec::new();
2660 }
2661 self.selected.clone()
2662 }
2663
2664 fn collapse_dir(&mut self, path: &Path) {
2667 if let Some(tree) = self.tree.as_mut() {
2668 tree.collapse(path);
2669 }
2670 self.clear_thumbnails_under(path);
2671 self.cursor_snap();
2672 }
2673
2674 fn toggle_dir(&mut self, path: &Path, sender: &ComponentSender<Self>) {
2676 let expanded = self.tree.as_ref().is_some_and(|t| t.is_expanded(path));
2677 if expanded {
2678 self.collapse_dir(path);
2679 } else {
2680 self.expand_dir(path, sender);
2681 }
2682 }
2683
2684 fn shutdown(&mut self) {
2691 if let Some(id) = self.gif_tick.borrow_mut().take() {
2692 id.remove();
2693 }
2694 if let Some(id) = self.audio_tick.borrow_mut().take() {
2695 id.remove();
2696 }
2697 self.gif_anims.borrow_mut().clear();
2698 self.gif_widgets.borrow_mut().clear();
2699 self.audio_widgets.borrow_mut().clear();
2700 self.playing.clear();
2701 self.thumbnails.clear();
2702 for media in self.media.values() {
2703 media.set_playing(false);
2704 }
2705 self.media.clear();
2706 self.audio.clear();
2708 }
2709
2710 fn clear_thumbnails_under(&mut self, prefix: &Path) {
2712 self.thumbnails.retain(|path| !path.starts_with(prefix));
2713 self.thumb_cache.retain(|path, _| !path.starts_with(prefix));
2714 self.playing.retain(|path| !path.starts_with(prefix));
2715 self.media.retain(|path, media| {
2716 if path.starts_with(prefix) {
2717 media.set_playing(false);
2718 false
2719 } else {
2720 true
2721 }
2722 });
2723 self.gif_anims.borrow_mut().retain(|path, _| !path.starts_with(prefix));
2724 self.gif_widgets.borrow_mut().retain(|path, _| !path.starts_with(prefix));
2725 self.audio_widgets.borrow_mut().retain(|path, _| !path.starts_with(prefix));
2726 self.audio.retain(|path, _| !path.starts_with(prefix));
2728 }
2729
2730 fn wants_thumbnail(&self, row: &VisibleRow) -> bool {
2733 if row.is_dir || !is_thumbnailable(&row.path) {
2734 return false;
2735 }
2736 self.thumbnails.contains(&row.path)
2737 || row
2738 .path
2739 .ancestors()
2740 .skip(1)
2741 .any(|ancestor| self.thumbnails.contains(ancestor))
2742 }
2743
2744 fn static_texture(&mut self, path: &Path) -> Option<gdk::Texture> {
2747 if let Some(texture) = self.thumb_cache.get(path) {
2748 return Some(texture.clone());
2749 }
2750 let texture = match media_kind(path)? {
2751 MediaKind::Image => gdk::Texture::from_filename(path).ok()?,
2752 MediaKind::Gif => {
2753 let animation = gdk::gdk_pixbuf::PixbufAnimation::from_file(path).ok()?;
2754 let pixbuf = animation.static_image()?;
2755 gdk::Texture::for_pixbuf(&pixbuf)
2756 }
2757 MediaKind::Video | MediaKind::Audio => return None,
2759 };
2760 self.thumb_cache.insert(path.to_path_buf(), texture.clone());
2761 Some(texture)
2762 }
2763
2764 fn media_file(&mut self, path: &Path) -> Option<gtk::MediaFile> {
2769 if let Some(media) = self.media.get(path) {
2770 return Some(media.clone());
2771 }
2772 let media = gtk::MediaFile::for_filename(path);
2773 media.set_loop(true);
2774 media.set_muted(false);
2779 media.set_volume(1.0);
2780 self.media.insert(path.to_path_buf(), media.clone());
2781 Some(media)
2782 }
2783
2784 fn audio_player(&mut self, path: &Path) -> Option<Rc<AudioPlayer>> {
2787 if let Some(player) = self.audio.get(path) {
2788 return Some(player.clone());
2789 }
2790 let player = AudioPlayer::new(path)?;
2791 self.audio.insert(path.to_path_buf(), player.clone());
2792 Some(player)
2793 }
2794
2795 fn toggle_thumbnail_play(&mut self, path: &Path) {
2798 if media_kind(path) != Some(MediaKind::Gif) {
2799 return;
2800 }
2801 if self.playing.remove(path) {
2802 self.gif_anims.borrow_mut().remove(path);
2803 } else {
2804 let Ok(animation) = gdk::gdk_pixbuf::PixbufAnimation::from_file(path) else {
2805 return;
2806 };
2807 let iter = animation.iter(Some(SystemTime::now()));
2808 self.gif_anims.borrow_mut().insert(path.to_path_buf(), GifAnim { iter });
2809 self.playing.insert(path.to_path_buf());
2810 self.ensure_gif_ticker();
2811 }
2812 }
2813
2814 fn ensure_gif_ticker(&mut self) {
2818 if self.gif_tick.borrow().is_some() {
2819 return;
2820 }
2821 let anims = self.gif_anims.clone();
2822 let widgets = self.gif_widgets.clone();
2823 let tick = self.gif_tick.clone();
2824 let id = glib::timeout_add_local(Duration::from_millis(33), move || {
2825 let mut anims = anims.borrow_mut();
2826 if anims.is_empty() {
2827 *tick.borrow_mut() = None;
2828 return glib::ControlFlow::Break;
2829 }
2830 let now = SystemTime::now();
2831 let widgets = widgets.borrow();
2832 for (path, anim) in anims.iter_mut() {
2833 let _ = anim.iter.advance(now);
2834 if let Some(picture) = widgets.get(path) {
2835 let pixbuf = anim.iter.pixbuf();
2836 picture.set_paintable(Some(&gdk::Texture::for_pixbuf(&pixbuf)));
2837 }
2838 }
2839 glib::ControlFlow::Continue
2840 });
2841 *self.gif_tick.borrow_mut() = Some(id);
2842 }
2843
2844 fn ensure_audio_ticker(&mut self) {
2847 if self.audio_tick.borrow().is_some() {
2848 return;
2849 }
2850 let players = self.audio_widgets.clone();
2851 let tick = self.audio_tick.clone();
2852 let id = glib::timeout_add_local(Duration::from_millis(200), move || {
2853 let players = players.borrow();
2854 if players.is_empty() {
2855 *tick.borrow_mut() = None;
2856 return glib::ControlFlow::Break;
2857 }
2858 for widgets in players.values() {
2859 let position = widgets.player.position();
2860 let duration = widgets.player.duration();
2861 if duration > 0
2862 && (widgets.seek.adjustment().upper() - duration as f64).abs() > 0.5
2863 {
2864 widgets.seek.adjustment().set_upper(duration as f64);
2865 }
2866 if !widgets.seeking.get() {
2867 widgets.seek.set_value(position as f64);
2868 }
2869 widgets.time.set_text(&format!(
2870 "{} / {}",
2871 format_time(position),
2872 format_time(duration)
2873 ));
2874 widgets.play.set_icon_name(if widgets.player.is_playing() {
2875 "media-playback-pause-symbolic"
2876 } else {
2877 "media-playback-start-symbolic"
2878 });
2879 }
2880 glib::ControlFlow::Continue
2881 });
2882 *self.audio_tick.borrow_mut() = Some(id);
2883 }
2884
2885 fn append_thumbnail(
2888 &mut self,
2889 path: &Path,
2890 row_indent: i32,
2891 container: >k::Box,
2892 sender: &ComponentSender<Self>,
2893 ) {
2894 let Some(kind) = media_kind(path) else {
2895 return;
2896 };
2897 let label_start = row_indent + self.config.icon_size as i32 + 6;
2898 let available = self.content_width - label_start - 6;
2899
2900 match kind {
2901 MediaKind::Image => {
2902 if let Some(texture) = self.static_texture(path) {
2903 let (width, height) = thumbnail_size(&texture, available);
2904 container.append(&thumbnail_picture(&texture, label_start, width, height));
2905 }
2906 }
2907 MediaKind::Gif => {
2908 let playing = self.playing.contains(path);
2910 let texture = if playing {
2911 self.gif_anims
2912 .borrow()
2913 .get(path)
2914 .map(|anim| gdk::Texture::for_pixbuf(&anim.iter.pixbuf()))
2915 } else {
2916 self.static_texture(path)
2917 };
2918 if let Some(texture) = texture {
2919 let (width, height) = thumbnail_size(&texture, available);
2920 let picture = thumbnail_picture(&texture, label_start, width, height);
2921 picture.set_tooltip_text(Some("Click to play"));
2922 if playing {
2923 self.gif_widgets
2924 .borrow_mut()
2925 .insert(path.to_path_buf(), picture.clone());
2926 }
2927 let msg = TreeMsg::ToggleThumbnailPlay(path.to_path_buf());
2928 let s = sender.clone();
2929 let click = gtk::GestureClick::new();
2930 click.set_button(1);
2931 click.connect_pressed(move |_, _, _, _| s.input(msg.clone()));
2932 picture.add_controller(click);
2933 container.append(&picture);
2934 }
2935 }
2936 MediaKind::Video => {
2937 if let Some(media) = self.media_file(path) {
2938 let video = gtk::Video::new();
2944 video.set_autoplay(false);
2945 video.set_media_stream(Some(&media));
2946 video.set_loop(true);
2947 video.add_css_class("tree-thumbnail");
2948 video.set_halign(gtk::Align::Start);
2949 let width = available.max(64);
2950 video.set_size_request(width, (width * 9 / 16).max(36));
2951 video.set_margin_start(label_start);
2952 video.set_margin_bottom(6);
2953 container.append(&video);
2954 }
2955 }
2956 MediaKind::Audio => {
2957 if let Some(player) = self.audio_player(path) {
2958 let (controls, widgets) = build_audio_player(&player, label_start);
2962 self.audio_widgets.borrow_mut().insert(path.to_path_buf(), widgets);
2963 container.append(&controls);
2964 self.ensure_audio_ticker();
2965 }
2966 }
2967 }
2968 }
2969
2970 fn expand_dir(&mut self, path: &Path, sender: &ComponentSender<Self>) {
2971 let Some(tree) = self.tree.as_mut() else {
2972 return;
2973 };
2974 if let Err(err) = tree.expand(path, &StdDirSource) {
2975 self.status(format!("Could not open {}: {err}", path.display()), sender);
2976 }
2977 self.refresh_rows();
2978 }
2979
2980 fn activate(&mut self, path: &Path, sender: &ComponentSender<Self>) {
2981 let Some(index) = self.rows.iter().position(|r| r.path == path) else {
2982 return;
2983 };
2984 if self.rows[index].is_dir {
2985 self.open_root(path, sender);
2989 } else {
2990 open_in_app(path);
2991 }
2992 }
2993
2994 fn confirm_trash(&mut self, paths: Vec<PathBuf>, sender: ComponentSender<Self>) {
2995 let message = match paths.as_slice() {
2996 [single] => {
2997 let name = single
2998 .file_name()
2999 .map(|n| n.to_string_lossy().into_owned())
3000 .unwrap_or_else(|| single.display().to_string());
3001 format!("Move \u{201c}{name}\u{201d} to trash?")
3002 }
3003 many => format!("Move {} items to trash?", many.len()),
3004 };
3005 let dialog = gtk::AlertDialog::builder()
3006 .modal(true)
3007 .message(message)
3008 .detail("The panel cannot restore trashed items.")
3009 .buttons(["Cancel", "Trash"])
3010 .cancel_button(0)
3011 .default_button(1)
3012 .build();
3013 dialog.choose(Some(&self.parent), None::<&gio::Cancellable>, move |res| {
3014 if matches!(res, Ok(1)) {
3015 sender.input(TreeMsg::ConfirmTrash(paths));
3016 }
3017 });
3018 }
3019
3020 fn confirm_permanent_delete(&mut self, paths: Vec<PathBuf>, sender: ComponentSender<Self>) {
3021 let message = match paths.as_slice() {
3022 [single] => {
3023 let name = single
3024 .file_name()
3025 .map(|n| n.to_string_lossy().into_owned())
3026 .unwrap_or_else(|| single.display().to_string());
3027 format!("Permanently delete \u{201c}{name}\u{201d}?")
3028 }
3029 many => format!("Permanently delete {} items?", many.len()),
3030 };
3031 let dialog = gtk::AlertDialog::builder()
3032 .modal(true)
3033 .message(message)
3034 .detail("This cannot be undone. Files will not go to the trash.")
3035 .buttons(["Cancel", "Delete Permanently"])
3036 .cancel_button(0)
3037 .default_button(1)
3038 .build();
3039 dialog.choose(Some(&self.parent), None::<&gio::Cancellable>, move |res| {
3040 if matches!(res, Ok(1)) {
3041 sender.input(TreeMsg::ConfirmPermanentDelete(paths));
3042 }
3043 });
3044 }
3045
3046 fn open_menu(&mut self, path: &Path) {
3047 let in_selection = self.selected.len() > 1 && self.selected.iter().any(|p| p == path);
3051 if !in_selection {
3052 self.select(path);
3053 } else if let Some(i) = self.rows.iter().position(|r| r.path == path) {
3054 self.cursor = Some(i);
3055 }
3056 if let Some(previous) = self.popover.take() {
3057 previous.unparent();
3058 }
3059 self.menu_target = Some(path.to_path_buf());
3060 self.menu_point = None;
3061 }
3062
3063 fn open_menu_at(&mut self, path: &Path, at: (f64, f64)) {
3067 if let Some(previous) = self.popover.take() {
3068 previous.unparent();
3069 }
3070 self.menu_target = Some(path.to_path_buf());
3071 self.menu_point = Some(at);
3072 }
3073
3074 fn paste(&mut self, sender: &ComponentSender<Self>) {
3075 let Some(clip) = self.clipboard.clone() else {
3076 self.paste_from_system_clipboard(sender);
3079 return;
3080 };
3081 let Some(dir) = self.target_dir() else {
3082 return;
3083 };
3084 let mut count = 0usize;
3085 let mut failed: Option<String> = None;
3086 for src in clip.paths {
3087 if src == dir {
3088 continue;
3089 }
3090 let result = match clip.op {
3091 ClipboardOp::Copy => self.ops.copy(&src, &dir),
3092 ClipboardOp::Cut => self.ops.move_(&src, &dir),
3093 };
3094 match result {
3095 Ok(dest) => {
3096 count += 1;
3097 self.apply_change(Change::Created { path: dest });
3098 if clip.op == ClipboardOp::Cut {
3099 self.apply_change(Change::Removed { path: src });
3100 }
3101 }
3102 Err(err) => {
3103 failed = Some(err.to_string());
3104 break;
3105 }
3106 }
3107 }
3108 if let Some(failed) = failed {
3109 self.status(failed, sender);
3110 return;
3111 }
3112 let verb = match clip.op {
3113 ClipboardOp::Copy => "Copied",
3114 ClipboardOp::Cut => "Moved",
3115 };
3116 self.status(format!("{verb} {count} item(s) into {}", dir.display()), sender);
3117 if clip.op == ClipboardOp::Cut {
3118 self.clipboard = None;
3119 }
3120 self.cursor_snap();
3121 }
3122
3123 fn paste_from_system_clipboard(&mut self, sender: &ComponentSender<Self>) {
3127 if self.target_dir().is_none() {
3128 return;
3129 }
3130 let s = sender.clone();
3131 read_clipboard_files(move |paths| s.input(TreeMsg::PastePaths(paths)));
3132 }
3133
3134 fn paste_paths(&mut self, paths: Vec<PathBuf>, sender: &ComponentSender<Self>) {
3136 let Some(dir) = self.target_dir() else {
3137 return;
3138 };
3139 if paths.is_empty() {
3140 self.status("Clipboard has no files to paste".to_string(), sender);
3141 return;
3142 }
3143 let mut count = 0usize;
3144 let mut failed: Option<String> = None;
3145 for src in paths {
3146 if src == dir {
3147 continue;
3148 }
3149 match self.ops.copy(&src, &dir) {
3150 Ok(dest) => {
3151 count += 1;
3152 self.apply_change(Change::Created { path: dest });
3153 }
3154 Err(err) => {
3155 failed = Some(err.to_string());
3156 break;
3157 }
3158 }
3159 }
3160 if let Some(failed) = failed {
3161 self.status(failed, sender);
3162 return;
3163 }
3164 self.status(format!("Pasted {count} item(s) into {}", dir.display()), sender);
3165 self.cursor_snap();
3166 }
3167
3168 fn request_drop_into(
3173 &mut self,
3174 target: PathBuf,
3175 sources: Vec<PathBuf>,
3176 copy: bool,
3177 sender: &ComponentSender<Self>,
3178 ) {
3179 match resolve_drop(&target, sources, copy) {
3180 DropPlan::Ignore => {}
3181 DropPlan::Reject(message) => self.status(message, sender),
3182 DropPlan::Copy(sources) => self.drop_into(&target, &sources, true, sender),
3183 DropPlan::Move(sources) => {
3184 if self.config.confirm_drop_move {
3185 self.confirm_move(target, sources, sender);
3186 } else {
3187 self.drop_into(&target, &sources, false, sender);
3188 }
3189 }
3190 }
3191 }
3192
3193 fn confirm_move(
3197 &mut self,
3198 target: PathBuf,
3199 sources: Vec<PathBuf>,
3200 sender: &ComponentSender<Self>,
3201 ) {
3202 let target_name = target
3203 .file_name()
3204 .map(|n| n.to_string_lossy().into_owned())
3205 .unwrap_or_else(|| target.display().to_string());
3206 let message = match sources.as_slice() {
3207 [single] => {
3208 let name = single
3209 .file_name()
3210 .map(|n| n.to_string_lossy().into_owned())
3211 .unwrap_or_else(|| single.display().to_string());
3212 format!("Move \u{201c}{name}\u{201d} into \u{201c}{target_name}\u{201d}?")
3213 }
3214 many => format!("Move {} items into \u{201c}{target_name}\u{201d}?", many.len()),
3215 };
3216 let dialog = gtk::AlertDialog::builder()
3217 .modal(true)
3218 .message(message)
3219 .detail("Hold Ctrl while dropping to copy instead of move.")
3220 .buttons(["Cancel", "Move"])
3221 .cancel_button(0)
3222 .default_button(1)
3223 .build();
3224 let s = sender.clone();
3225 dialog.choose(Some(&self.parent), None::<&gio::Cancellable>, move |res| {
3226 if matches!(res, Ok(1)) {
3227 s.input(TreeMsg::DropIntoConfirmed { target, sources });
3228 }
3229 });
3230 }
3231
3232 fn drop_into(
3236 &mut self,
3237 target: &Path,
3238 sources: &[PathBuf],
3239 copy: bool,
3240 sender: &ComponentSender<Self>,
3241 ) {
3242 let mut count = 0usize;
3243 let mut failed: Option<String> = None;
3244 for src in sources {
3245 if src == target || src.parent() == Some(target) {
3246 continue; }
3248 if !copy && target.starts_with(src) {
3249 failed = Some("Cannot move a folder into itself".to_string());
3250 break;
3251 }
3252 let result = if copy { self.ops.copy(src, target) } else { self.ops.move_(src, target) };
3253 match result {
3254 Ok(dest) => {
3255 count += 1;
3256 self.apply_change(Change::Created { path: dest });
3257 if !copy {
3258 self.apply_change(Change::Removed { path: src.clone() });
3259 }
3260 }
3261 Err(err) => {
3262 failed = Some(err.to_string());
3263 break;
3264 }
3265 }
3266 }
3267 if let Some(failed) = failed {
3268 self.status(failed, sender);
3269 return;
3270 }
3271 if count > 0 {
3272 let verb = if copy { "Copied" } else { "Moved" };
3273 self.status(format!("{verb} {count} item(s) into {}", target.display()), sender);
3274 }
3275 self.cursor_snap();
3276 }
3277
3278 fn create_entry(&mut self, is_dir: bool, sender: &ComponentSender<Self>) {
3279 let Some(dir) = self.target_dir() else {
3280 return;
3281 };
3282 let name = fresh_name(&dir, if is_dir { "new folder" } else { "new file" });
3283 let result = if is_dir {
3284 self.ops.create_dir(&dir, &name)
3285 } else {
3286 self.ops.create_file(&dir, &name)
3287 };
3288 match result {
3289 Ok(new_path) => {
3290 self.apply_change(Change::Created { path: new_path.clone() });
3291 self.renaming = Some(new_path.clone());
3292 self.renaming_state.set(true);
3293 self.select(&new_path);
3294 }
3295 Err(err) => self.status(format!("Could not create: {err}"), sender),
3296 }
3297 }
3298
3299 fn target_dir(&self) -> Option<PathBuf> {
3302 if let Some(path) = self.cursor_path() {
3303 let is_dir = self.rows.iter().find(|r| r.path == path).map(|r| r.is_dir).unwrap_or(false);
3304 if is_dir {
3305 return Some(path);
3306 }
3307 return path.parent().map(Path::to_path_buf);
3308 }
3309 self.tree.as_ref().map(|t| t.root().to_path_buf())
3310 }
3311
3312 fn apply_change(&mut self, change: Change) {
3313 if let Some(tree) = self.tree.as_mut() {
3314 tree.apply(&change, &StdDirSource);
3315 self.refresh_rows();
3316 }
3317 }
3318
3319 fn toggle_hidden(&mut self, sender: &ComponentSender<Self>) {
3322 let show = !self.config.show_hidden;
3323 self.config.show_hidden = show;
3324 if let Some(tree) = self.tree.as_mut() {
3325 tree.set_show_hidden(show);
3326 tree.reload_all(&StdDirSource);
3327 self.refresh_rows();
3328 }
3329 let label = if show { "Showing hidden files." } else { "Hiding hidden files." };
3330 self.status(label.to_string(), sender);
3331 }
3332
3333 fn set_sort_key(&mut self, key: SortKey, sender: &ComponentSender<Self>) {
3335 let mut opts = self.config.sort_options();
3336 opts.key = key;
3337 self.config.sort_key = key.as_str().to_owned();
3338 if let Some(tree) = self.tree.as_mut() {
3339 tree.set_sort(opts);
3340 tree.reload_all(&StdDirSource);
3341 self.refresh_rows();
3342 }
3343 self.status(format!("Sorting by {}.", key.as_str()), sender);
3344 }
3345
3346 fn toggle_sort_direction(&mut self, sender: &ComponentSender<Self>) {
3348 let mut opts = self.config.sort_options();
3349 opts.ascending = !opts.ascending;
3350 self.config.sort_ascending = opts.ascending;
3351 if let Some(tree) = self.tree.as_mut() {
3352 tree.set_sort(opts);
3353 tree.reload_all(&StdDirSource);
3354 self.refresh_rows();
3355 }
3356 let dir = if opts.ascending { "ascending" } else { "descending" };
3357 self.status(format!("Sort order: {dir}."), sender);
3358 }
3359
3360 fn status(&mut self, message: String, sender: &ComponentSender<Self>) {
3361 let _ = sender.output(TreeOutput::Status(message));
3362 }
3363
3364 fn run_shortcut(&mut self, target: ShortcutTarget, sender: &ComponentSender<Self>) {
3369 if let ShortcutTarget::Builtin(builtin) = &target
3372 && builtin.is_pane_action()
3373 {
3374 let _ = sender.output(TreeOutput::PaneAction(*builtin));
3375 return;
3376 }
3377 let has_selection = self.cursor.is_some() || !self.selected.is_empty();
3381 let path = match self.cursor_path() {
3382 Some(path) => path,
3383 None => match self.tree.as_ref().map(|t| t.root().to_path_buf()) {
3384 Some(root) => root,
3385 None => return,
3386 },
3387 };
3388
3389 if let ShortcutTarget::Command(cmd) = &target {
3391 if !has_selection {
3392 return;
3393 }
3394 let command = action_command(&cmd.command, &path);
3395 match spawn_command(&command) {
3396 Ok(()) => {
3397 self.status(format!("Ran {} on {}", cmd.command, path.display()), sender);
3398 }
3399 Err(err) => {
3400 self.status(format!("Could not run {}: {err}", cmd.command), sender);
3401 }
3402 }
3403 return;
3404 }
3405 let ShortcutTarget::Builtin(builtin) = target else {
3406 return;
3407 };
3408 if builtin == BuiltinAction::Separator
3409 || (builtin.is_directory_only() && !path.is_dir())
3410 {
3411 return;
3412 }
3413 if matches!(builtin, BuiltinAction::OpenWithDefault) && path.is_dir() {
3414 return;
3415 }
3416 if builtin == BuiltinAction::ViewThumbnail && !path.is_dir() && !is_thumbnailable(&path) {
3417 return;
3418 }
3419 if !has_selection && needs_selection(builtin) {
3421 return;
3422 }
3423 if has_selection && !self.selected.iter().any(|p| p == &path) {
3427 self.select(&path);
3428 }
3429 let selection = self.selected.clone();
3432 sender.input(action_message(builtin, &path, &selection));
3433 }
3434}
3435
3436fn fresh_name(dir: &Path, stem: &str) -> String {
3441 for n in 0.. {
3442 let name = if n == 0 { stem.to_string() } else { format!("{stem} {n}") };
3443 if !dir.join(&name).exists() {
3444 return name;
3445 }
3446 }
3447 unreachable!("the loop above always finds a free name")
3448}
3449
3450fn open_in_app(path: &Path) {
3451 let file = gio::File::for_path(path);
3452 let _ = gio::AppInfo::launch_default_for_uri(&file.uri(), None::<&gio::AppLaunchContext>);
3453}
3454
3455fn content_type_for(path: &Path) -> Option<glib::GString> {
3462 let file = gio::File::for_path(path);
3463 let info = file
3464 .query_info(
3465 gio::FILE_ATTRIBUTE_STANDARD_CONTENT_TYPE,
3466 gio::FileQueryInfoFlags::NONE,
3467 None::<&gio::Cancellable>,
3468 )
3469 .ok()?;
3470 info.content_type()
3471}
3472
3473fn default_app_label(path: &Path) -> Option<String> {
3477 let content_type = content_type_for(path)?;
3478 gio::AppInfo::default_for_type(&content_type, true).map(|app| {
3479 format!("Open With {}", app.display_name())
3480 })
3481}
3482
3483#[allow(deprecated)] fn open_with_dialog(parent: >k::Window, path: &Path, sender: &ComponentSender<Tree>) {
3487 let Some(content_type) = content_type_for(path) else {
3488 let _ = sender.output(TreeOutput::Status(format!(
3489 "Could not determine the type of {}",
3490 path.display()
3491 )));
3492 return;
3493 };
3494 let dialog = gtk::AppChooserDialog::for_content_type(
3495 Some(parent),
3496 gtk::DialogFlags::MODAL,
3497 &content_type,
3498 );
3499 dialog.set_title(Some(&format!("Open {} with...", path.display())));
3500
3501 if gtk4_layer_shell::is_supported() && !dialog.is_layer_window() {
3507 dialog.init_layer_shell();
3508 dialog.set_layer(Layer::Overlay);
3509 dialog.set_keyboard_mode(KeyboardMode::Exclusive);
3510 dialog.set_default_size(540, 620);
3511 }
3513
3514 let dialog = dialog.clone();
3515 let path = path.to_path_buf();
3516 let s = sender.clone();
3517 dialog.connect_response(move |dialog, response| {
3518 if response == gtk::ResponseType::Ok
3519 && let Some(app) = dialog.app_info()
3520 {
3521 let file = gio::File::for_path(&path);
3522 match app.launch(&[file], None::<&gio::AppLaunchContext>) {
3523 Ok(()) => {
3524 let _ = s.output(TreeOutput::Status(format!(
3525 "Opened {} with {}",
3526 path.display(),
3527 app.display_name()
3528 )));
3529 }
3530 Err(err) => {
3531 let _ = s.output(TreeOutput::Status(format!(
3532 "Could not open {} with {}: {err}",
3533 path.display(),
3534 app.display_name()
3535 )));
3536 }
3537 }
3538 }
3539 dialog.close();
3540 });
3541 dialog.present();
3542}
3543
3544fn set_clipboard_text(text: &str) {
3546 if let Some(display) = gdk::Display::default() {
3547 display.clipboard().set_text(text);
3548 }
3549}
3550
3551const URI_LIST_MIME: &str = "text/uri-list";
3554
3555const GNOME_CLIP_MIME: &str = "x-special/gnome-copied-files";
3559
3560fn read_clipboard_files(done: impl FnOnce(Vec<PathBuf>) + 'static) {
3565 let Some(display) = gdk::Display::default() else {
3566 done(Vec::new());
3567 return;
3568 };
3569 let clipboard = display.clipboard();
3570 let formats = clipboard.formats();
3571 let has_uri = formats.contain_mime_type(URI_LIST_MIME);
3572 let has_gnome = formats.contain_mime_type(GNOME_CLIP_MIME);
3573 if !has_uri && !has_gnome {
3574 done(Vec::new());
3575 return;
3576 }
3577 clipboard.read_text_async(gio::Cancellable::NONE, move |result| {
3578 let text = result.ok().flatten().unwrap_or_default();
3579 done(parse_clipboard_uris(&text, has_gnome && !has_uri));
3580 });
3581}
3582
3583fn parse_clipboard_uris(text: &str, verb_line: bool) -> Vec<PathBuf> {
3589 text.lines()
3590 .map(str::trim)
3591 .filter(|line| !line.is_empty() && !line.starts_with('#'))
3592 .filter_map(|line| {
3593 if verb_line && (line.eq_ignore_ascii_case("copy") || line.eq_ignore_ascii_case("cut")) {
3594 return None;
3595 }
3596 let (path, _host) = glib::filename_from_uri(line).ok()?;
3597 path.is_absolute().then_some(path)
3598 })
3599 .collect()
3600}
3601
3602fn spawn_command(cmd: &[String]) -> Result<(), std::io::Error> {
3603 if cmd.is_empty() {
3604 return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "empty command"));
3605 }
3606 let mut process = std::process::Command::new(&cmd[0]);
3607 for arg in &cmd[1..] {
3608 process.arg(arg);
3609 }
3610 process.spawn().map(|_| ())
3611}
3612
3613#[cfg(test)]
3614mod tests {
3615 use super::*;
3616
3617 #[test]
3618 fn media_detection_is_extension_based_and_case_insensitive() {
3619 for name in ["a.JPG", "b.jpeg", "c.png", "d.webp", "e.HEIC", "f.tiff"] {
3620 assert_eq!(media_kind(Path::new(name)), Some(MediaKind::Image), "{name}");
3621 }
3622 for name in ["loop.GIF", "x.gif"] {
3623 assert_eq!(media_kind(Path::new(name)), Some(MediaKind::Gif), "{name}");
3624 }
3625 for name in ["clip.mp4", "b.MKV", "c.webm", "d.mov", "e.OGV"] {
3626 assert_eq!(media_kind(Path::new(name)), Some(MediaKind::Video), "{name}");
3627 }
3628 for name in ["song.MP3", "b.flac", "c.wav", "d.m4a", "e.opus", "f.oga"] {
3629 if cfg!(feature = "audio") {
3630 assert_eq!(media_kind(Path::new(name)), Some(MediaKind::Audio), "{name}");
3631 assert!(is_thumbnailable(Path::new(name)), "{name}");
3632 } else {
3633 assert_eq!(media_kind(Path::new(name)), None, "{name}");
3635 }
3636 }
3637 for name in ["a.svg", "b.txt", "c.rs", "noext", "d.png.bak"] {
3638 assert_eq!(media_kind(Path::new(name)), None, "{name}");
3639 assert!(!is_thumbnailable(Path::new(name)), "{name}");
3640 }
3641 }
3642
3643 #[test]
3644 fn thumbnail_width_tracks_the_panel_and_stays_positive() {
3645 let panel = PanelConfig { width: 300, margin: 0, ..PanelConfig::default() };
3646 assert_eq!(thumbnail_content_width(panel), 276);
3647
3648 let panel = PanelConfig { width: 500, margin: 10, ..PanelConfig::default() };
3649 assert_eq!(thumbnail_content_width(panel), 456);
3650
3651 let tiny = PanelConfig { width: 40, margin: 0, ..PanelConfig::default() };
3652 assert_eq!(thumbnail_content_width(tiny), 64);
3653 }
3654
3655 #[test]
3656 fn accelerator_normalization_supports_both_syntaxes() {
3657 assert_eq!(normalize_accelerator("Ctrl+x"), "<Control>x");
3659 assert_eq!(normalize_accelerator("Ctrl+Shift+m"), "<Control><Shift>m");
3660 assert_eq!(normalize_accelerator("Shift+Delete"), "<Shift>Delete");
3661 assert_eq!(normalize_accelerator("ctrl+alt+t"), "<Control><Alt>t");
3662 assert_eq!(normalize_accelerator("<Control>x"), "<Control>x");
3664 assert_eq!(normalize_accelerator("F2"), "F2");
3665 assert_eq!(normalize_accelerator("Delete"), "Delete");
3666 }
3667
3668 fn row(path: &str) -> VisibleRow {
3669 VisibleRow {
3670 path: PathBuf::from(path),
3671 name: path.rsplit('/').next().unwrap().to_string(),
3672 depth: 0,
3673 is_dir: false,
3674 is_symlink: false,
3675 expanded: false,
3676 has_children: false,
3677 matches: false,
3678 }
3679 }
3680
3681 #[test]
3682 fn parse_clipboard_uris_handles_both_formats() {
3683 let uri_list = "#comment\r\nfile:///tmp/a.txt\r\n\r\nfile:///tmp/b%20c.txt\r\n";
3685 assert_eq!(
3686 parse_clipboard_uris(uri_list, false),
3687 vec![PathBuf::from("/tmp/a.txt"), PathBuf::from("/tmp/b c.txt")]
3688 );
3689 let gnome = "cut\nfile:///home/u/x\n";
3691 assert_eq!(parse_clipboard_uris(gnome, true), vec![PathBuf::from("/home/u/x")]);
3692 assert_eq!(parse_clipboard_uris("copy\n", false), Vec::<PathBuf>::new());
3694 assert_eq!(
3696 parse_clipboard_uris("https://example.com/x\nrelative/path\nfile:///ok\n", false),
3697 vec![PathBuf::from("/ok")]
3698 );
3699 assert!(parse_clipboard_uris("", false).is_empty());
3700 }
3701
3702 #[test]
3703 fn icon_name_matches_extensions_case_insensitively() {
3704 assert_eq!(icon_name(&row("/a/Photo.JPG")), "image-x-generic-symbolic");
3705 assert_eq!(icon_name(&row("/a/song.Flac")), "audio-x-generic-symbolic");
3706 assert_eq!(icon_name(&row("/a/clip.mkv")), "video-x-generic-symbolic");
3707 assert_eq!(icon_name(&row("/a/archive.tar")), "package-x-generic-symbolic");
3708 assert_eq!(icon_name(&row("/a/doc.pdf")), "application-pdf-symbolic");
3709 assert_eq!(icon_name(&row("/a/notes.txt")), "text-x-generic-symbolic");
3710 let mut dir = row("/a/pictures");
3712 dir.is_dir = true;
3713 assert_eq!(icon_name(&dir), "folder-symbolic");
3714 dir.expanded = true;
3715 assert_eq!(icon_name(&dir), "folder-open-symbolic");
3716 assert_eq!(icon_name(&row("/a/.bashrc")), "text-x-generic-symbolic");
3718 }
3719
3720 #[test]
3721 fn resolve_drop_filters_noops_and_detects_cycles() {
3722 let target = Path::new("/a/b");
3723 assert_eq!(resolve_drop(target, vec![PathBuf::from("/a/b")], false), DropPlan::Ignore);
3725 assert_eq!(resolve_drop(target, vec![PathBuf::from("/a/b/c")], false), DropPlan::Ignore);
3726 assert!(matches!(
3728 resolve_drop(target, vec![PathBuf::from("/a")], false),
3729 DropPlan::Reject(_)
3730 ));
3731 assert!(matches!(
3733 resolve_drop(target, vec![PathBuf::from("/x/y")], false),
3734 DropPlan::Move(_)
3735 ));
3736 assert!(matches!(
3737 resolve_drop(target, vec![PathBuf::from("/x/y")], true),
3738 DropPlan::Copy(_)
3739 ));
3740 }
3741
3742 #[test]
3743 fn drag_paths_uses_the_selection_only_when_the_row_is_in_it() {
3744 let sel = vec![PathBuf::from("/r/a"), PathBuf::from("/r/b")];
3745 assert_eq!(drag_paths(&sel, Path::new("/r/b")), sel);
3747 assert_eq!(
3749 drag_paths(&sel, Path::new("/r/c")),
3750 vec![PathBuf::from("/r/c")]
3751 );
3752 assert_eq!(
3754 drag_paths(&[PathBuf::from("/r/a")], Path::new("/r/a")),
3755 vec![PathBuf::from("/r/a")]
3756 );
3757 assert_eq!(
3759 drag_paths(&[], Path::new("/r/z")),
3760 vec![PathBuf::from("/r/z")]
3761 );
3762 }
3763
3764 #[test]
3765 fn index_range_is_inclusive_and_order_independent() {
3766 assert_eq!(index_range(2, 2).collect::<Vec<_>>(), vec![2]);
3767 assert_eq!(index_range(1, 4).collect::<Vec<_>>(), vec![1, 2, 3, 4]);
3768 assert_eq!(index_range(4, 1).collect::<Vec<_>>(), vec![1, 2, 3, 4]);
3770 }
3771
3772 #[test]
3773 fn format_uri_list_serializes_file_uris() {
3774 let text = format_uri_list(&[PathBuf::from("/tmp/a.txt"), PathBuf::from("/tmp/b c.txt")]);
3775 assert_eq!(text, "file:///tmp/a.txt\r\nfile:///tmp/b%20c.txt\r\n");
3776 assert!(format_uri_list(&[]).is_empty());
3777 assert_eq!(
3779 parse_clipboard_uris(&text, false),
3780 vec![PathBuf::from("/tmp/a.txt"), PathBuf::from("/tmp/b c.txt")]
3781 );
3782 }
3783
3784 #[test]
3785 fn typeahead_finds_the_next_matching_row_and_wraps() {
3786 let rows = vec![row("/r/apple"), row("/r/banana"), row("/r/apricot"), row("/r/cherry")];
3787 assert_eq!(typeahead_target(&rows, "ap", None), Some(0));
3790 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);
3793 }
3794
3795 #[test]
3796 fn typeahead_buffer_resets_after_a_pause_and_caps_length() {
3797 let mut ta = TypeAhead::default();
3798 let t0 = std::time::Instant::now();
3799 assert_eq!(ta.push('a', t0).as_deref(), Some("a"));
3800 assert_eq!(ta.push('b', t0 + Duration::from_millis(100)).as_deref(), Some("ab"));
3801 assert_eq!(ta.push('c', t0 + Duration::from_millis(2000)).as_deref(), Some("c"));
3803 assert_eq!(ta.push('\u{1}', t0), None);
3805 }
3806}