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