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
186pub(crate) fn 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
222pub(crate) fn 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 focus_pending: bool,
589
590 drag_payload: Rc<RefCell<Vec<PathBuf>>>,
595 drag_active: Rc<Cell<bool>>,
599 pending_click: Option<PathBuf>,
603 drag_landed_internal: Rc<Cell<bool>>,
607 drag_origin: Rc<RefCell<Option<DragOrigin>>>,
611 drag_button_down: Rc<Cell<bool>>,
616}
617
618#[relm4::component(pub)]
619impl Component for Tree {
620 type Init = TreeInit;
621 type Input = TreeMsg;
622 type Output = TreeOutput;
623 type CommandOutput = ();
624
625 view! {
626 scrolled = gtk::ScrolledWindow {
627 set_vexpand: true,
628 set_hexpand: true,
629 set_min_content_width: 160,
630 add_css_class: "tree-scroll",
631
632 #[name = "list"]
633 gtk::ListBox {
634 set_selection_mode: gtk::SelectionMode::None,
635 set_activate_on_single_click: false,
636 }
637 }
638 }
639
640 fn init(
641 init: Self::Init,
642 root: Self::Root,
643 sender: ComponentSender<Self>,
644 ) -> ComponentParts<Self> {
645 let renaming_state = Rc::new(Cell::new(false));
646 let keys = compile_shortcuts(&init.menu);
647 let widgets = view_output!();
648
649 let controller = gtk::EventControllerKey::new();
650 controller.set_propagation_phase(gtk::PropagationPhase::Capture);
651 let capture_sender = sender.clone();
652 let capture_state = renaming_state.clone();
653 let capture_keys = keys.clone();
654 controller.connect_key_pressed(move |_ctrl, key, _keycode, state| {
655 handle_key(
656 key,
657 state,
658 capture_state.get(),
659 &capture_keys,
660 &capture_sender,
661 )
662 });
663 widgets.scrolled.add_controller(controller);
664
665 let root_drop = gtk::DropTarget::new(gdk::FileList::static_type(), gdk::DragAction::MOVE | gdk::DragAction::COPY);
671 let root_drop_sender = sender.clone();
672 root_drop.connect_drop(move |target, value, _x, _y| {
673 let Ok(list) = value.get::<gdk::FileList>() else {
674 return false;
675 };
676 let sources: Vec<PathBuf> = list.files().iter().filter_map(gio::File::path).collect();
677 if sources.is_empty() {
678 return false;
679 }
680 let copy = target.current_event_state().contains(gdk::ModifierType::CONTROL_MASK);
681 root_drop_sender.input(TreeMsg::DropIntoRoot { sources, copy });
682 true
683 });
684 widgets.list.add_controller(root_drop);
685
686 let blank_scrolled = widgets.scrolled.clone();
692 let blank_click = gtk::GestureClick::new();
693 blank_click.set_button(0);
694 blank_click.set_propagation_phase(gtk::PropagationPhase::Capture);
695 let blank_sender = sender.clone();
696 blank_click.connect_pressed(move |gesture, _n_press, x, y| {
697 if gesture.current_button() == 3
700 && gesture.current_event_state().contains(gdk::ModifierType::SUPER_MASK)
701 {
702 gesture.set_state(gtk::EventSequenceState::Denied);
703 return;
704 }
705 let on_row = blank_scrolled
706 .pick(x, y, gtk::PickFlags::DEFAULT)
707 .is_some_and(|widget| in_list_row(&widget));
708 if on_row {
709 return;
710 }
711 match gesture.current_button() {
712 1 => blank_sender.input(TreeMsg::Deselect),
713 3 => blank_sender.input(TreeMsg::MenuAt { x, y }),
714 _ => {}
715 }
716 });
717 widgets.scrolled.add_controller(blank_click);
718
719 let model = Tree {
720 tree: None,
721 ops: FileOps::new(),
722 config: init.config,
723 parent: init.parent,
724 menu: init.menu,
725 side: init.side,
726 thumbnails: HashSet::new(),
727 thumb_cache: HashMap::new(),
728 media: HashMap::new(),
729 audio: HashMap::new(),
730 playing: HashSet::new(),
731 gif_anims: Rc::new(RefCell::new(HashMap::new())),
732 gif_widgets: Rc::new(RefCell::new(HashMap::new())),
733 gif_tick: Rc::new(RefCell::new(None)),
734 audio_widgets: Rc::new(RefCell::new(HashMap::new())),
735 audio_tick: Rc::new(RefCell::new(None)),
736 content_width: thumbnail_content_width(init.panel),
737 rows: Vec::new(),
738 cursor: None,
739 selected: Vec::new(),
740 anchor: None,
741 last_press: None,
742 renaming: None,
743 rename_entry: None,
744 clipboard: None,
745 pending: Vec::new(),
746 flush_source: None,
747 _watcher: None,
748 watched: HashSet::new(),
749 popover: None,
750 menu_target: None,
751 menu_point: None,
752 renaming_state,
753 selection_dirty: false,
754 typeahead: TypeAhead::default(),
755 scroll_to: None,
756 focus_pending: false,
757 drag_payload: Rc::new(RefCell::new(Vec::new())),
758 drag_active: Rc::new(Cell::new(false)),
759 pending_click: None,
760 drag_landed_internal: Rc::new(Cell::new(false)),
761 drag_origin: Rc::new(RefCell::new(None)),
762 drag_button_down: Rc::new(Cell::new(false)),
763 };
764
765 {
775 let legacy = gtk::EventControllerLegacy::new();
776 legacy.set_propagation_phase(gtk::PropagationPhase::Capture);
777 let origin = model.drag_origin.clone();
778 let payload = model.drag_payload.clone();
779 let active = model.drag_active.clone();
780 let landed = model.drag_landed_internal.clone();
781 let down = model.drag_button_down.clone();
782 let drag_root = widgets.scrolled.clone();
783 let s = sender.clone();
784 legacy.connect_event(move |_controller, event| {
785 match event.event_type() {
786 gdk::EventType::ButtonPress => {
787 let button = event
788 .downcast_ref::<gdk::ButtonEvent>()
789 .map(|b| b.button())
790 .unwrap_or(0);
791 if button == 1
792 && let Some((x, y)) = event.position()
793 {
794 down.set(true);
795 *origin.borrow_mut() = Some(DragOrigin { x, y });
796 }
797 }
798 gdk::EventType::ButtonRelease => {
799 let button = event
800 .downcast_ref::<gdk::ButtonEvent>()
801 .map(|b| b.button())
802 .unwrap_or(0);
803 if button == 1 {
804 down.set(false);
805 if !active.get() {
806 *origin.borrow_mut() = None;
807 }
808 }
809 }
810 gdk::EventType::MotionNotify => {
811 if active.get() || !down.get() {
812 return glib::Propagation::Proceed;
813 }
814 let Some((x, y)) = event.position() else {
815 return glib::Propagation::Proceed;
816 };
817 let start = {
818 let guard = origin.borrow();
819 match guard.as_ref() {
820 Some(o) => (o.x, o.y),
821 None => return glib::Propagation::Proceed,
822 }
823 };
824 if !past_drag_threshold(drag_root.upcast_ref(), start, (x, y)) {
825 return glib::Propagation::Proceed;
826 }
827 let paths = payload.borrow().clone();
828 if paths.is_empty() {
829 return glib::Propagation::Proceed;
830 }
831 *origin.borrow_mut() = None;
833 let finished = {
834 let active = active.clone();
835 let landed = landed.clone();
836 let s = s.clone();
837 let payload = payload.clone();
838 move |_drag: gdk::Drag, delete: bool| {
839 active.set(false);
840 if delete && !landed.replace(false) {
843 let paths = payload.borrow().clone();
844 if !paths.is_empty() {
845 s.input(TreeMsg::DroppedAsMove(paths));
846 }
847 }
848 }
849 };
850 let cancelled = {
851 let active = active.clone();
852 let landed = landed.clone();
853 move |_drag: gdk::Drag, _reason: gdk::DragCancelReason| {
854 active.set(false);
855 landed.set(false);
856 }
857 };
858 if begin_row_drag(
859 drag_root.upcast_ref(),
860 &paths,
861 start,
862 gdk::DragAction::MOVE | gdk::DragAction::COPY,
863 finished,
864 cancelled,
865 ) {
866 active.set(true);
867 s.input(TreeMsg::DragStarted);
868 }
869 }
870 _ => {}
871 }
872 glib::Propagation::Proceed
873 });
874 widgets.scrolled.add_controller(legacy);
875 }
876
877 ComponentParts { model, widgets }
878 }
879
880 fn update_with_view(
881 &mut self,
882 widgets: &mut Self::Widgets,
883 message: Self::Input,
884 sender: ComponentSender<Self>,
885 root: &Self::Root,
886 ) {
887 let _ = root;
888 if let TreeMsg::Focus = message {
891 if widgets.list.row_at_index(0).is_none() {
895 self.focus_pending = true;
898 return;
899 }
900 let index = self.cursor.unwrap_or(0) as i32;
901 if let Some(row) = widgets
902 .list
903 .row_at_index(index)
904 .or_else(|| widgets.list.row_at_index(0))
905 {
906 row.grab_focus();
907 } else if !widgets.list.grab_focus() {
908 widgets.scrolled.grab_focus();
909 }
910 return;
911 }
912 self.handle_message(message, sender.clone());
913 if let Some(index) = self.scroll_to.take() {
917 scroll_row_into_view(widgets, index);
918 }
919 self.reconcile_watches();
923 if self.selection_dirty {
924 self.selection_dirty = false;
927 if update_selection_classes(self, widgets) {
928 return;
929 }
930 }
931 <Self as Component>::update_view(self, widgets, sender.clone());
932 rebuild(self, widgets, sender);
933 if self.focus_pending && widgets.list.row_at_index(0).is_some() {
936 self.focus_pending = false;
937 let index = self.cursor.unwrap_or(0) as i32;
938 if let Some(row) = widgets
939 .list
940 .row_at_index(index)
941 .or_else(|| widgets.list.row_at_index(0))
942 {
943 row.grab_focus();
944 }
945 }
946 }
947}
948
949impl Tree {
950 fn handle_message(&mut self, msg: TreeMsg, sender: ComponentSender<Self>) {
951 match msg {
952 TreeMsg::OpenRoot(path) => self.open_root(&path, &sender),
953 TreeMsg::SelectPath(path) => self.reveal_path(path),
954 TreeMsg::Focus => {}
956 TreeMsg::SetFilter(filter) => {
957 if let Some(tree) = self.tree.as_mut() {
958 tree.set_filter(&filter);
959 self.refresh_rows();
960 }
961 }
962 TreeMsg::SetPanel(panel) => self.content_width = thumbnail_content_width(panel),
963 TreeMsg::Shutdown => self.shutdown(),
964 TreeMsg::Noop => {}
965
966 TreeMsg::MoveUp => self.cursor_delta(-1),
967 TreeMsg::MoveDown => self.cursor_delta(1),
968 TreeMsg::ExtendSelection(delta) => self.cursor_extend(delta, false),
969 TreeMsg::MoveExtendingSelection(delta) => self.cursor_extend(delta, true),
970 TreeMsg::CollapseCursor => {
971 if let Some(path) = self.cursor_path()
972 && self.tree.as_ref().is_some_and(|t| t.is_expanded(&path))
973 {
974 self.collapse_dir(&path);
975 }
976 }
977 TreeMsg::ExpandCursor => {
978 if let Some(path) = self.cursor_path() {
979 self.expand_dir(&path, &sender);
980 }
981 }
982 TreeMsg::ActivateCursor => {
983 if let Some(path) = self.cursor_path() {
984 self.activate(&path, &sender);
985 }
986 }
987 TreeMsg::Rename => {
988 if let Some(path) = self.cursor_path() {
989 self.renaming = Some(path);
990 self.renaming_state.set(true);
991 }
992 }
993
994 TreeMsg::Activate(path) => self.activate(&path, &sender),
995 TreeMsg::Toggle(path) => self.toggle_dir(&path, &sender),
996 TreeMsg::Select(path) => {
997 self.select(&path);
998 self.anchor = Some(path);
999 self.selection_dirty = true;
1000 }
1001 TreeMsg::ToggleSelect(path) => {
1002 if let Some(i) = self.rows.iter().position(|r| r.path == path) {
1003 self.cursor = Some(i);
1004 }
1005 if let Some(i) = self.selected.iter().position(|p| p == &path) {
1006 self.selected.remove(i);
1007 } else {
1008 self.selected.push(path.clone());
1009 }
1010 self.anchor = Some(path);
1011 self.selection_dirty = true;
1012 }
1013 TreeMsg::RangeSelect(path) => {
1014 self.range_select(&path);
1015 self.selection_dirty = true;
1016 }
1017 TreeMsg::RowPress { path, is_dir, ctrl, shift } => {
1018 if ctrl {
1023 sender.input(TreeMsg::ToggleSelect(path));
1024 return;
1025 }
1026 if shift {
1027 sender.input(TreeMsg::RangeSelect(path));
1028 return;
1029 }
1030 *self.drag_payload.borrow_mut() = drag_paths(&self.selected, &path);
1035
1036 if self.selected.len() > 1 && self.selected.iter().any(|p| p == &path) {
1040 self.pending_click = Some(path.clone());
1041 if is_dir {
1042 self.toggle_dir(&path, &sender);
1043 }
1044 return;
1045 }
1046 self.pending_click = None;
1047 self.press_default(&path, is_dir, &sender);
1048 }
1049 TreeMsg::RowRelease { path } => {
1050 *self.drag_origin.borrow_mut() = None;
1052 self.drag_button_down.set(false);
1053 if self.drag_active.get() {
1056 return;
1057 }
1058 if self.pending_click.take().as_deref() == Some(path.as_path()) {
1059 let is_dir = self
1060 .rows
1061 .iter()
1062 .find(|r| r.path == path)
1063 .map(|r| r.is_dir)
1064 .unwrap_or(false);
1065 self.press_default(&path, is_dir, &sender);
1066 }
1067 }
1068 TreeMsg::DragStarted => {
1069 self.drag_active.set(true);
1071 self.pending_click = None;
1072 *self.drag_origin.borrow_mut() = None;
1073 self.drag_button_down.set(false);
1074 }
1075 TreeMsg::DroppedAsMove(paths) => {
1076 match self.ops.trash(&paths) {
1080 Ok(()) => {
1081 for path in &paths {
1082 self.apply_change(Change::Removed { path: path.clone() });
1083 }
1084 let n = paths.len();
1085 self.status(format!("Moved {n} item(s) out of the panel"), &sender);
1086 }
1087 Err(err) => self.status(format!("Could not finish move: {err}"), &sender),
1088 }
1089 }
1090 TreeMsg::Deselect => {
1091 self.selected.clear();
1092 self.cursor = None;
1093 self.anchor = None;
1094 self.typeahead.clear();
1095 self.selection_dirty = true;
1096 }
1097 TreeMsg::Menu(path) => self.open_menu(&path),
1098 TreeMsg::MenuAt { x, y } => {
1099 if let Some(root) = self.tree.as_ref().map(|t| t.root().to_path_buf()) {
1100 self.open_menu_at(&root, (x, y));
1101 }
1102 }
1103 TreeMsg::RenameAt(path) => {
1104 self.select(&path);
1105 self.renaming = Some(path);
1106 self.renaming_state.set(true);
1107 }
1108 TreeMsg::Trash(path) => self.confirm_trash(vec![path], sender.clone()),
1109 TreeMsg::DeleteSelected => {
1110 let paths = self.selected_for_clipboard();
1111 if !paths.is_empty() {
1112 self.confirm_trash(paths, sender.clone());
1113 }
1114 }
1115 TreeMsg::PermanentDelete(path) => {
1116 self.confirm_permanent_delete(vec![path], sender.clone());
1117 }
1118 TreeMsg::PermanentDeleteSelected => {
1119 let paths = self.selected_for_clipboard();
1120 if !paths.is_empty() {
1121 self.confirm_permanent_delete(paths, sender.clone());
1122 }
1123 }
1124 TreeMsg::ConfirmPermanentDelete(paths) => {
1125 if let Some(renaming) = self.renaming.as_ref()
1126 && paths.contains(renaming)
1127 {
1128 self.renaming = None;
1129 self.renaming_state.set(false);
1130 }
1131 match self.ops.delete_permanently(&paths) {
1132 Ok(()) => {
1133 for path in paths {
1134 self.apply_change(Change::Removed { path });
1135 }
1136 self.selected.clear();
1137 self.cursor_snap();
1138 }
1139 Err(err) => self.status(err.to_string(), &sender),
1140 }
1141 }
1142 TreeMsg::ConfirmTrash(paths) => {
1143 if let Some(renaming) = self.renaming.as_ref()
1144 && paths.contains(renaming)
1145 {
1146 self.renaming = None;
1147 self.renaming_state.set(false);
1148 }
1149 match self.ops.trash(&paths) {
1150 Ok(()) => {
1151 for path in paths {
1152 self.apply_change(Change::Removed { path });
1153 }
1154 self.selected.clear();
1155 self.cursor_snap();
1156 }
1157 Err(err) => self.status(err.to_string(), &sender),
1160 }
1161 }
1162 TreeMsg::Duplicate(path) => match self.ops.duplicate(&path) {
1163 Ok(new_path) => {
1164 self.apply_change(Change::Created { path: new_path.clone() });
1165 self.select(&new_path);
1166 }
1167 Err(err) => self.status(format!("Could not duplicate: {err}"), &sender),
1168 },
1169 TreeMsg::RequestDuplicateCursor => {
1170 if let Some(path) = self.cursor_path() {
1171 sender.input(TreeMsg::Duplicate(path));
1172 }
1173 }
1174 TreeMsg::CopyPath(path) => {
1175 set_clipboard_text(&path.display().to_string());
1176 self.status("Copied path to clipboard".to_string(), &sender);
1177 }
1178 TreeMsg::CopyRelativePath(path) => {
1179 let relative = self
1180 .tree
1181 .as_ref()
1182 .and_then(|t| path.strip_prefix(t.root()).ok())
1183 .map(|p| p.display().to_string())
1184 .unwrap_or_else(|| path.display().to_string());
1185 set_clipboard_text(&relative);
1186 self.status("Copied relative path to clipboard".to_string(), &sender);
1187 }
1188 TreeMsg::AddBookmark(path) => {
1189 let _ = sender.output(TreeOutput::AddBookmark(path));
1190 }
1191 TreeMsg::RunCommand { command, path } => {
1192 let cmd = action_command(&command, &path);
1193 match spawn_command(&cmd) {
1194 Ok(()) => self.status(format!("Ran {command} on {}", path.display()), &sender),
1195 Err(err) => self.status(format!("Could not run {command}: {err}"), &sender),
1196 }
1197 }
1198 TreeMsg::OpenSplit(path) => {
1199 let _ = sender.output(TreeOutput::OpenSplit(path));
1200 }
1201 TreeMsg::OpenInOppositePanel(path) => {
1202 let _ = sender.output(TreeOutput::OpenOpposite(path));
1203 }
1204 TreeMsg::OpenWith(path) => open_with_dialog(&self.parent.clone(), &path, &sender),
1205 TreeMsg::OpenWithDefault(path) => match open_in_app(&path) {
1206 Ok(app) => {
1207 self.status(format!("Opened {} with {app}", path.display()), &sender);
1208 }
1209 Err(err) => self.status(err, &sender),
1210 },
1211 TreeMsg::CreateLink(path) => match self.ops.create_link(&path) {
1212 Ok(new_path) => {
1213 self.apply_change(Change::Created { path: new_path.clone() });
1214 }
1215 Err(err) => self.status(format!("Could not create link: {err}"), &sender),
1216 },
1217 TreeMsg::Properties(path) => {
1218 crate::ui::props::show_properties_dialog(&self.parent.clone(), &path, &sender);
1219 }
1220 TreeMsg::PropertiesSelected(paths) => {
1221 crate::ui::props::show_multi_properties_dialog(&self.parent.clone(), &paths);
1222 }
1223 TreeMsg::CopyPaths(paths) => {
1224 let text = paths
1225 .iter()
1226 .map(|p| p.display().to_string())
1227 .collect::<Vec<_>>()
1228 .join("\n");
1229 set_clipboard_text(&text);
1230 self.status(format!("Copied {} paths to clipboard", paths.len()), &sender);
1231 }
1232 TreeMsg::CopyRelativePaths(paths) => {
1233 let root = self.tree.as_ref().map(|t| t.root().to_path_buf());
1234 let text = paths
1235 .iter()
1236 .map(|p| {
1237 root.as_deref()
1238 .and_then(|root| p.strip_prefix(root).ok())
1239 .map(|rel| rel.display().to_string())
1240 .unwrap_or_else(|| p.display().to_string())
1241 })
1242 .collect::<Vec<_>>()
1243 .join("\n");
1244 set_clipboard_text(&text);
1245 self.status(
1246 format!("Copied {} relative paths to clipboard", paths.len()),
1247 &sender,
1248 );
1249 }
1250 TreeMsg::RunCommandEach { command, paths } => {
1251 let mut failures = 0usize;
1252 for path in &paths {
1253 let cmd = action_command(&command, path);
1254 if spawn_command(&cmd).is_err() {
1255 failures += 1;
1256 }
1257 }
1258 let total = paths.len();
1259 if failures == 0 {
1260 self.status(format!("Ran {command} on {total} item(s)"), &sender);
1261 } else {
1262 self.status(
1263 format!("Ran {command} on {total} item(s); {failures} failed"),
1264 &sender,
1265 );
1266 }
1267 }
1268 TreeMsg::RenameTo { path, name } => match self.ops.rename(&path, &name) {
1269 Ok(new_path) => {
1270 self.apply_change(Change::Renamed {
1271 from: path,
1272 to: new_path.clone(),
1273 });
1274 self.select(&new_path);
1275 }
1276 Err(err) => self.status(format!("Could not rename: {err}"), &sender),
1277 },
1278 TreeMsg::ToggleThumbnail(path) => {
1279 if self.thumbnails.contains(&path) {
1280 self.clear_thumbnails_under(&path);
1282 } else {
1283 self.thumbnails.insert(path.clone());
1284 if path.is_dir() {
1286 self.expand_dir(&path, &sender);
1287 }
1288 }
1289 }
1290 TreeMsg::ToggleThumbnailPlay(path) => self.toggle_thumbnail_play(&path),
1291 TreeMsg::RunShortcut { target } => self.run_shortcut(target, &sender),
1292
1293 TreeMsg::SelectAllRows => {
1294 if !self.rows.is_empty() {
1295 self.selected = self.rows.iter().map(|r| r.path.clone()).collect();
1296 self.cursor = Some(0);
1297 self.anchor = self.rows.first().map(|r| r.path.clone());
1298 self.selection_dirty = true;
1299 }
1300 }
1301 TreeMsg::TypeAhead(ch) => {
1302 let now = std::time::Instant::now();
1303 if let Some(prefix) = self.typeahead.push(ch, now)
1304 && let Some(index) = typeahead_target(&self.rows, &prefix, self.cursor)
1305 {
1306 let path = self.rows[index].path.clone();
1307 self.cursor = Some(index);
1308 self.set_single_selection(path);
1309 self.selection_dirty = true;
1310 }
1311 }
1312
1313 TreeMsg::RenameCommit => {
1314 let text = self
1315 .rename_entry
1316 .as_ref()
1317 .map(|e| e.text().to_string())
1318 .unwrap_or_default();
1319 let Some(old) = self.renaming.take() else {
1320 return;
1321 };
1322 self.renaming_state.set(false);
1323 self.rename_entry = None;
1324 let trimmed = text.trim().to_string();
1325 let unchanged = old
1326 .file_name()
1327 .map(|n| n.to_string_lossy() == trimmed.as_str())
1328 .unwrap_or(false);
1329 if trimmed.is_empty() || unchanged {
1330 return;
1331 }
1332 match self.ops.rename(&old, &trimmed) {
1333 Ok(new_path) => {
1334 self.apply_change(Change::Renamed {
1335 from: old,
1336 to: new_path.clone(),
1337 });
1338 self.select(&new_path);
1339 }
1340 Err(err) => self.status(format!("Could not rename: {err}"), &sender),
1341 }
1342 }
1343 TreeMsg::RenameCancel => {
1344 self.renaming = None;
1345 self.renaming_state.set(false);
1346 self.rename_entry = None;
1347 }
1348
1349 TreeMsg::Copy => {
1350 let paths = self.selected_for_clipboard();
1351 if !paths.is_empty() {
1352 self.clipboard = Some(Clipboard { op: ClipboardOp::Copy, paths: paths.clone() });
1353 self.status(format!("Copying {} item(s)", paths.len()), &sender);
1354 }
1355 }
1356 TreeMsg::Cut => {
1357 let paths = self.selected_for_clipboard();
1358 if !paths.is_empty() {
1359 self.clipboard = Some(Clipboard { op: ClipboardOp::Cut, paths: paths.clone() });
1360 self.status(format!("Cutting {} item(s)", paths.len()), &sender);
1361 }
1362 }
1363 TreeMsg::Paste => self.paste(&sender),
1364 TreeMsg::PastePaths(paths) => self.paste_paths(paths, &sender),
1365
1366 TreeMsg::NewFile => self.create_entry(false, &sender),
1367 TreeMsg::NewFolder => self.create_entry(true, &sender),
1368
1369 TreeMsg::ToggleHidden => self.toggle_hidden(&sender),
1370 TreeMsg::SetSortKey(key) => self.set_sort_key(key, &sender),
1371 TreeMsg::ToggleSortDirection => self.toggle_sort_direction(&sender),
1372
1373 TreeMsg::FsChange(change) => {
1374 self.pending.push(change);
1376 if self.flush_source.is_none() {
1377 let s = sender.clone();
1378 let id = glib::timeout_add_local_once(
1379 Duration::from_millis(100),
1380 move || {
1381 s.input(TreeMsg::FlushChanges);
1382 }
1383 );
1384 self.flush_source = Some(id);
1385 }
1386 }
1387 TreeMsg::FlushChanges => {
1388 self.flush_source = None;
1389 let changes = std::mem::take(&mut self.pending);
1390 for change in changes {
1391 self.apply_change(change);
1392 }
1393 self.cursor_snap();
1394 }
1395 TreeMsg::RequestOpenFolder => {
1396 self.status("Pick a folder to browse...".to_string(), &sender);
1397 let _ = sender.output(TreeOutput::OpenFolderRequested);
1398 }
1399
1400 TreeMsg::DropInto { target, sources, copy } => {
1401 self.request_drop_into(target, sources, copy, &sender);
1402 }
1403 TreeMsg::DropIntoRoot { sources, copy } => {
1404 if let Some(root) = self.tree.as_ref().map(|t| t.root().to_path_buf()) {
1405 self.request_drop_into(root, sources, copy, &sender);
1406 }
1407 }
1408 TreeMsg::DropIntoConfirmed { target, sources } => {
1409 self.drop_into(&target, &sources, false, &sender);
1410 }
1411 }
1412 }
1413}
1414
1415fn accel_mods(m: gdk::ModifierType) -> gdk::ModifierType {
1422 m & (gdk::ModifierType::CONTROL_MASK
1423 | gdk::ModifierType::SHIFT_MASK
1424 | gdk::ModifierType::ALT_MASK
1425 | gdk::ModifierType::SUPER_MASK
1426 | gdk::ModifierType::HYPER_MASK
1427 | gdk::ModifierType::META_MASK)
1428}
1429
1430#[derive(Clone)]
1432struct KeyBinding {
1433 key: gdk::Key,
1434 mods: gdk::ModifierType,
1435 target: ShortcutTarget,
1436}
1437
1438fn compile_shortcuts(menu: &ContextMenu) -> Rc<Vec<KeyBinding>> {
1442 let mut out = Vec::new();
1443 for (accel, target) in menu.shortcuts() {
1444 if let Some((key, mods)) = parse_accelerator(&accel) {
1445 out.push(KeyBinding { key, mods, target });
1446 }
1447 }
1448 Rc::new(out)
1449}
1450
1451fn parse_accelerator(accel: &str) -> Option<(gdk::Key, gdk::ModifierType)> {
1457 gtk::accelerator_parse(normalize_accelerator(accel))
1458}
1459
1460fn normalize_accelerator(accel: &str) -> String {
1463 if !accel.contains('+') {
1464 return accel.to_owned();
1465 }
1466 let mut mods = String::new();
1467 let mut key = String::new();
1468 for token in accel.split('+') {
1469 let token = token.trim();
1470 if token.is_empty() {
1471 continue;
1472 }
1473 match token.to_ascii_lowercase().as_str() {
1474 "ctrl" | "control" | "primary" => mods.push_str("<Control>"),
1475 "shift" => mods.push_str("<Shift>"),
1476 "alt" => mods.push_str("<Alt>"),
1477 "super" | "win" | "mod4" => mods.push_str("<Super>"),
1478 "meta" => mods.push_str("<Meta>"),
1479 "hyper" => mods.push_str("<Hyper>"),
1480 _ => key = token.to_owned(),
1481 }
1482 }
1483 format!("{mods}{key}")
1484}
1485
1486fn handle_key(
1487 key: gdk::Key,
1488 state: gdk::ModifierType,
1489 renaming: bool,
1490 keys: &[KeyBinding],
1491 sender: &ComponentSender<Tree>,
1492) -> glib::Propagation {
1493 use gdk::Key;
1494
1495 if renaming {
1497 return glib::Propagation::Proceed;
1498 }
1499
1500 let ctrl = state.contains(gdk::ModifierType::CONTROL_MASK);
1501 let shift = state.contains(gdk::ModifierType::SHIFT_MASK);
1502
1503 let structural = match key {
1506 Key::Up if ctrl && shift => Some(TreeMsg::MoveExtendingSelection(-1)),
1507 Key::Down if ctrl && shift => Some(TreeMsg::MoveExtendingSelection(1)),
1508 Key::Up if shift => Some(TreeMsg::ExtendSelection(-1)),
1509 Key::Down if shift => Some(TreeMsg::ExtendSelection(1)),
1510 Key::Up => Some(TreeMsg::MoveUp),
1511 Key::Down => Some(TreeMsg::MoveDown),
1512 Key::Left => Some(TreeMsg::CollapseCursor),
1513 Key::Right => Some(TreeMsg::ExpandCursor),
1514 Key::Return | Key::KP_Enter | Key::space => Some(TreeMsg::ActivateCursor),
1515 Key::Escape => Some(TreeMsg::RenameCancel),
1516 Key::o | Key::O if ctrl && !shift => Some(TreeMsg::RequestOpenFolder),
1517 Key::a | Key::A if ctrl && !shift => Some(TreeMsg::SelectAllRows),
1518 _ => None,
1519 };
1520
1521 let msg = match structural {
1522 Some(msg) => msg,
1523 None => {
1524 let mut hit = None;
1528 for binding in keys.iter() {
1529 if key.to_lower() == binding.key.to_lower()
1530 && accel_mods(state) == accel_mods(binding.mods)
1531 {
1532 hit = Some(TreeMsg::RunShortcut {
1533 target: binding.target.clone(),
1534 });
1535 break;
1536 }
1537 }
1538 match hit {
1539 Some(msg) => msg,
1540 None if !ctrl
1543 && !state.contains(gdk::ModifierType::ALT_MASK)
1544 && !state.contains(gdk::ModifierType::SUPER_MASK) =>
1545 {
1546 if let Some(ch) = key.to_unicode()
1547 && ch.is_ascii_graphic()
1548 {
1549 TreeMsg::TypeAhead(ch)
1550 } else {
1551 return glib::Propagation::Proceed;
1552 }
1553 }
1554 None => return glib::Propagation::Proceed,
1555 }
1556 }
1557 };
1558
1559 sender.input(msg);
1560 glib::Propagation::Stop
1561}
1562
1563fn update_selection_classes(
1572 tree: &mut Tree,
1573 widgets: &mut <Tree as Component>::Widgets,
1574) -> bool {
1575 let mut existing = 0;
1577 let mut child = widgets.list.first_child();
1578 while let Some(current) = child {
1579 if current.is::<gtk::ListBoxRow>() {
1580 existing += 1;
1581 }
1582 child = current.next_sibling();
1583 }
1584 if existing != tree.rows.len() {
1585 return false;
1586 }
1587 for (index, row) in tree.rows.iter().enumerate() {
1588 let Some(list_row) = widgets.list.row_at_index(index as i32) else {
1589 return false;
1590 };
1591 let selected = tree.cursor == Some(index) || tree.selected.contains(&row.path);
1592 if selected {
1593 list_row.add_css_class("tree-row-selected");
1594 } else {
1595 list_row.remove_css_class("tree-row-selected");
1596 }
1597 }
1598 true
1599}
1600
1601fn scroll_row_into_view(widgets: &<Tree as Component>::Widgets, index: usize) {
1606 let Some(row) = widgets.list.row_at_index(index as i32) else {
1607 return;
1608 };
1609 let adj = widgets.scrolled.vadjustment();
1612 let page = adj.page_size();
1613 if page <= 0.0 {
1614 return;
1615 }
1616 let Some(bounds) = row.compute_bounds(&widgets.list) else {
1619 return;
1620 };
1621 let y = bounds.y() as f64;
1622 let height = bounds.height() as f64;
1623 let top = adj.value();
1624 if y < top {
1625 adj.set_value(y);
1626 } else if y + height > top + page {
1627 adj.set_value((y + height - page).max(0.0));
1628 }
1629}
1630
1631fn rebuild(tree: &mut Tree, widgets: &mut <Tree as Component>::Widgets, sender: ComponentSender<Tree>) {
1632 let list = &widgets.list;
1633
1634 if let Some(previous) = tree.popover.take() {
1643 previous.unparent();
1644 }
1645
1646 widgets.scrolled.set_child(None::<>k::Widget>);
1651 list.remove_all();
1652
1653 if tree.tree.is_none() {
1654 widgets.scrolled.set_child(Some(list));
1655 return;
1656 }
1657
1658 let rows = std::mem::take(&mut tree.rows);
1661 let mut menu_row: Option<gtk::ListBoxRow> = None;
1662 tree.rename_entry = None;
1663 tree.gif_widgets.borrow_mut().clear();
1666 tree.audio_widgets.borrow_mut().clear();
1669
1670 for (index, row) in rows.iter().enumerate() {
1671 let list_row = gtk::ListBoxRow::new();
1672 list_row.set_focusable(true);
1673 list_row.set_can_focus(true);
1674 list_row.add_css_class("tree-row");
1675
1676 let row_indent = row.depth as i32 * 16 + 4;
1677 let container = gtk::Box::new(gtk::Orientation::Vertical, 0);
1679 container.set_hexpand(true);
1680 list_row.set_child(Some(&container));
1681
1682 let hbox = gtk::Box::new(gtk::Orientation::Horizontal, 6);
1683 hbox.set_margin_start(row_indent);
1684 hbox.set_hexpand(true);
1685 container.append(&hbox);
1686
1687 let icon = gtk::Image::from_icon_name(icon_name(row));
1688 icon.set_pixel_size(tree.config.icon_size as i32);
1689 icon.set_valign(gtk::Align::Center);
1690 hbox.append(&icon);
1691
1692 if tree.renaming.as_ref() == Some(&row.path) {
1693 let entry = gtk::Entry::new();
1697 entry.set_text(&row.name);
1698 entry.set_hexpand(true);
1699 entry.add_css_class("tree-rename-entry");
1700 {
1701 let s = sender.clone();
1702 entry.connect_activate(move |_| s.input(TreeMsg::RenameCommit));
1703 }
1704 let esc_controller = gtk::EventControllerKey::new();
1705 let s = sender.clone();
1706 esc_controller.connect_key_pressed(move |_, key, _code, _state| {
1707 if key == gdk::Key::Escape {
1708 s.input(TreeMsg::RenameCancel);
1709 glib::Propagation::Stop
1710 } else {
1711 glib::Propagation::Proceed
1712 }
1713 });
1714 entry.add_controller(esc_controller);
1715 hbox.append(&entry);
1716 tree.rename_entry = Some(entry.clone());
1717 entry.grab_focus();
1718 } else {
1719 let label = gtk::Label::new(Some(&row.name));
1720 label.set_xalign(0.0);
1721 label.set_hexpand(true);
1722 label.set_ellipsize(pango::EllipsizeMode::Middle);
1723 label.set_tooltip_text(Some(&row.path.to_string_lossy()));
1724 hbox.append(&label);
1725 }
1726
1727 if tree.cursor == Some(index) || tree.selected.contains(&row.path) {
1728 list_row.add_css_class("tree-row-selected");
1729 }
1730
1731 if row.matches {
1732 list_row.add_css_class("tree-row-match");
1733 }
1734
1735 if tree.menu_target.as_ref() == Some(&row.path) {
1736 menu_row = Some(list_row.clone());
1737 }
1738
1739 if tree.wants_thumbnail(row) {
1742 tree.append_thumbnail(&row.path, row_indent, &container, &sender);
1743 }
1744
1745 let path = row.path.clone();
1746 let release_path = row.path.clone();
1747 let is_dir = row.is_dir;
1748 let click = gtk::GestureClick::new();
1749 click.set_button(0);
1753 let s = sender.clone();
1754 let focus_row = list_row.clone();
1755 click.connect_pressed(move |gesture, _n_press, x, y| {
1756 if focus_row
1761 .pick(x, y, gtk::PickFlags::DEFAULT)
1762 .is_some_and(|widget| in_inline_media(&widget))
1763 {
1764 return;
1765 }
1766 focus_row.grab_focus();
1771 let button = gesture.current_button();
1772 let state = gesture.current_event_state();
1773 if button == 3 {
1774 if state.contains(gdk::ModifierType::SUPER_MASK) {
1778 gesture.set_state(gtk::EventSequenceState::Denied);
1779 return;
1780 }
1781 s.input(TreeMsg::Menu(path.clone()));
1782 return;
1783 }
1784 if button != 1 {
1785 return;
1786 }
1787 let ctrl = state.contains(gdk::ModifierType::CONTROL_MASK);
1788 let shift = state.contains(gdk::ModifierType::SHIFT_MASK);
1789 s.input(TreeMsg::RowPress { path: path.clone(), is_dir, ctrl, shift });
1792 });
1793 {
1794 let s = sender.clone();
1798 click.connect_released(move |gesture, _n_press, _x, _y| {
1799 if gesture.current_button() == 1 {
1800 s.input(TreeMsg::RowRelease { path: release_path.clone() });
1801 }
1802 });
1803 }
1804 list_row.add_controller(click);
1805
1806 if row.is_dir {
1816 let drop = gtk::DropTarget::new(gdk::FileList::static_type(), gdk::DragAction::MOVE | gdk::DragAction::COPY);
1817 let s = sender.clone();
1818 let target_dir = row.path.clone();
1819 let active = tree.drag_active.clone();
1820 let landed = tree.drag_landed_internal.clone();
1821 drop.connect_drop(move |target, value, _x, _y| {
1822 let Ok(list) = value.get::<gdk::FileList>() else {
1823 return false;
1824 };
1825 let sources: Vec<PathBuf> = list.files().iter().filter_map(gio::File::path).collect();
1826 if sources.is_empty() {
1827 return false;
1828 }
1829 let copy = target.current_event_state().contains(gdk::ModifierType::CONTROL_MASK);
1830 if active.get() {
1833 landed.set(true);
1834 }
1835 s.input(TreeMsg::DropInto { target: target_dir.clone(), sources, copy });
1836 true
1837 });
1838 list_row.add_controller(drop);
1839 }
1840
1841 list.append(&list_row);
1842 }
1843
1844 tree.rows = rows;
1845 widgets.scrolled.set_child(Some(list));
1846
1847 if let Some(row) = menu_row {
1851 let target = tree.menu_target.take().expect("menu_row set implies a target");
1852 let selection = tree.selected.clone();
1853 tree.popover = Some(build_menu(&row, &target, &tree.menu, &selection, tree.side, &sender));
1854 tree.menu_point = None;
1855 } else if let (Some(target), Some(at)) = (tree.menu_target.take(), tree.menu_point.take()) {
1856 let selection = tree.selected.clone();
1857 tree.popover = Some(build_menu_at(&widgets.scrolled, at, &target, &tree.menu, &selection, tree.side, &sender));
1858 }
1859 tree.menu_target = None;
1860 tree.menu_point = None;
1861}
1862
1863fn action_builtin(action: &ContextAction) -> Option<BuiltinAction> {
1865 match action {
1866 ContextAction::Builtin(b) => Some(*b),
1867 ContextAction::Entry(e) => Some(e.action),
1868 ContextAction::Command(_) | ContextAction::Submenu(_) => None,
1869 }
1870}
1871
1872pub(crate) fn menu_label(action: &ContextAction, path: &Path, side: PanelSide) -> String {
1877 let dynamic = match action_builtin(action) {
1878 Some(BuiltinAction::OpenWithDefault) => default_app_label(path),
1879 Some(BuiltinAction::InOppositePanel) => Some(format!("In {} panel", side.opposite().name())),
1880 Some(BuiltinAction::ViewThumbnail) if media_kind(path) == Some(MediaKind::Audio) => {
1882 Some("Show Player".to_owned())
1883 }
1884 _ => None,
1885 };
1886 dynamic.unwrap_or_else(|| action.label())
1887}
1888
1889pub(crate) fn accel_display(accel: &str) -> Option<String> {
1892 let (key, mods) = parse_accelerator(accel)?;
1893 Some(gtk::accelerator_get_label(key, mods).to_string())
1894}
1895
1896fn build_menu_box(
1901 path: &Path,
1902 menu: &ContextMenu,
1903 selection: &[PathBuf],
1904 side: PanelSide,
1905 sender: &ComponentSender<Tree>,
1906) -> gtk::Box {
1907 let menu_box = gtk::Box::new(gtk::Orientation::Vertical, 0);
1908 menu_box.add_css_class("tree-menu");
1909 let path = path.to_path_buf();
1910 let actions = menu.actions_for_selection(&path, selection.len());
1911 append_menu_items(&menu_box, &actions, &path, selection, side, sender);
1912 menu_box
1913}
1914
1915fn append_menu_items(
1918 menu_box: >k::Box,
1919 actions: &[ContextAction],
1920 path: &Path,
1921 selection: &[PathBuf],
1922 side: PanelSide,
1923 sender: &ComponentSender<Tree>,
1924) {
1925 let multi = selection.len() > 1;
1926 for action in actions {
1927 if action.is_hidden() {
1930 continue;
1931 }
1932
1933 if action_builtin(action) == Some(BuiltinAction::Separator) {
1935 let separator = gtk::Separator::new(gtk::Orientation::Horizontal);
1936 separator.add_css_class("tree-menu-separator");
1937 menu_box.append(&separator);
1938 continue;
1939 }
1940
1941 if let Some(builtin) = action_builtin(action) {
1945 if builtin.is_directory_only() && !path.is_dir() {
1946 continue;
1947 }
1948 if multi && builtin.is_single_row_only() {
1949 continue;
1950 }
1951 if matches!(builtin, BuiltinAction::OpenWithDefault) && path.is_dir() {
1952 continue;
1953 }
1954 if builtin == BuiltinAction::ViewThumbnail
1956 && !path.is_dir()
1957 && !is_thumbnailable(path)
1958 {
1959 continue;
1960 }
1961 }
1962
1963 let row_box = gtk::Box::new(gtk::Orientation::Horizontal, 12);
1964 row_box.add_css_class("tree-menu-item");
1965
1966 if let ContextAction::Submenu(sub) = action {
1968 let button = submenu_row_button(&sub.label);
1969 let popover = build_submenu_popover(&sub.items, path, selection, side, sender);
1970 let child = popover.clone();
1971 let anchor = button.clone();
1972 button.connect_clicked(move |_| {
1973 child.set_parent(&anchor);
1976 child.popup();
1977 });
1978 let child = popover.clone();
1979 popover.connect_closed(move |_| child.unparent());
1980 row_box.append(&button);
1981 row_box.append(&submenu_arrow());
1982 menu_box.append(&row_box);
1983 continue;
1984 }
1985
1986 let label = menu_label(action, path, side);
1987 let msg = match action {
1988 ContextAction::Builtin(b) => action_message(*b, path, selection),
1989 ContextAction::Entry(e) => action_message(e.action, path, selection),
1990 ContextAction::Command(cmd) => {
1991 if multi {
1992 TreeMsg::RunCommandEach {
1993 command: cmd.command.clone(),
1994 paths: selection.to_vec(),
1995 }
1996 } else {
1997 TreeMsg::RunCommand {
1998 command: cmd.command.clone(),
1999 path: path.to_path_buf(),
2000 }
2001 }
2002 }
2003 ContextAction::Submenu(_) => unreachable!("handled above"),
2004 };
2005
2006 let button = gtk::Button::new();
2009 button.set_halign(gtk::Align::Fill);
2010 button.set_hexpand(true);
2011 let label_widget = gtk::Label::new(Some(&label));
2012 label_widget.set_xalign(0.0);
2013 label_widget.set_hexpand(true);
2014 button.set_child(Some(&label_widget));
2015 let s = sender.clone();
2016 button.connect_clicked(move |_| s.input(msg.clone()));
2017 row_box.append(&button);
2018
2019 if let Some(shortcut) = action.shortcut()
2020 && let Some(display) = accel_display(&shortcut)
2021 {
2022 let hint = gtk::Label::new(Some(&display));
2023 hint.add_css_class("tree-menu-shortcut");
2024 hint.set_halign(gtk::Align::End);
2025 hint.set_valign(gtk::Align::Center);
2026 row_box.append(&hint);
2027 }
2028
2029 menu_box.append(&row_box);
2030 }
2031}
2032
2033fn submenu_row_button(label: &str) -> gtk::Button {
2035 let button = gtk::Button::new();
2036 button.set_halign(gtk::Align::Fill);
2037 button.set_hexpand(true);
2038 let text = gtk::Label::new(Some(label));
2039 text.set_xalign(0.0);
2040 text.set_hexpand(true);
2041 button.set_child(Some(&text));
2042 button
2043}
2044
2045fn submenu_arrow() -> gtk::Label {
2047 let arrow = gtk::Label::new(Some("\u{25b8}"));
2048 arrow.add_css_class("tree-menu-submenu-arrow");
2049 arrow.set_halign(gtk::Align::End);
2050 arrow.set_valign(gtk::Align::Center);
2051 arrow
2052}
2053
2054fn build_submenu_popover(
2057 items: &[ContextAction],
2058 path: &Path,
2059 selection: &[PathBuf],
2060 side: PanelSide,
2061 sender: &ComponentSender<Tree>,
2062) -> gtk::Popover {
2063 let menu_box = gtk::Box::new(gtk::Orientation::Vertical, 0);
2064 menu_box.add_css_class("tree-menu");
2065 append_menu_items(&menu_box, items, path, selection, side, sender);
2066
2067 let popover = gtk::Popover::new();
2068 popover.add_css_class("tree-menu-popover");
2069 popover.set_has_arrow(false);
2070 popover.set_position(gtk::PositionType::Right);
2071 popover.set_child(Some(&menu_box));
2072 popover
2073}
2074
2075fn show_menu(
2078 anchor: &impl IsA<gtk::Widget>,
2079 menu_box: >k::Box,
2080 point: Option<(f64, f64)>,
2081) -> gtk::Popover {
2082 let popover = gtk::Popover::new();
2083 popover.add_css_class("tree-menu-popover");
2084 popover.set_child(Some(menu_box));
2085 popover.set_autohide(true);
2086 popover.set_parent(anchor);
2087 if let Some((x, y)) = point {
2088 popover.set_pointing_to(Some(&gdk::Rectangle::new(x as i32, y as i32, 1, 1)));
2089 }
2090 popover.popup();
2091 popover
2092}
2093
2094fn build_menu(
2096 row: >k::ListBoxRow,
2097 path: &Path,
2098 menu: &ContextMenu,
2099 selection: &[PathBuf],
2100 side: PanelSide,
2101 sender: &ComponentSender<Tree>,
2102) -> gtk::Popover {
2103 let menu_box = build_menu_box(path, menu, selection, side, sender);
2104 show_menu(row, &menu_box, None)
2105}
2106
2107fn build_menu_at(
2110 anchor: &impl IsA<gtk::Widget>,
2111 at: (f64, f64),
2112 path: &Path,
2113 menu: &ContextMenu,
2114 selection: &[PathBuf],
2115 side: PanelSide,
2116 sender: &ComponentSender<Tree>,
2117) -> gtk::Popover {
2118 let menu_box = build_menu_box(path, menu, selection, side, sender);
2119 show_menu(anchor, &menu_box, Some(at))
2120}
2121
2122fn action_message(action: BuiltinAction, path: &Path, selection: &[PathBuf]) -> TreeMsg {
2128 if selection.len() > 1 {
2129 match action {
2130 BuiltinAction::Trash => return TreeMsg::DeleteSelected,
2131 BuiltinAction::DeletePermanently => return TreeMsg::PermanentDeleteSelected,
2132 BuiltinAction::CopyPath => return TreeMsg::CopyPaths(selection.to_vec()),
2133 BuiltinAction::CopyRelativePath => return TreeMsg::CopyRelativePaths(selection.to_vec()),
2134 BuiltinAction::Properties => return TreeMsg::PropertiesSelected(selection.to_vec()),
2135 _ => {}
2136 }
2137 }
2138 builtin_message(action, path)
2139}
2140
2141fn builtin_message(action: BuiltinAction, path: &Path) -> TreeMsg {
2143 match action {
2144 BuiltinAction::Open => TreeMsg::Activate(path.to_path_buf()),
2145 BuiltinAction::OpenSplit => TreeMsg::OpenSplit(path.to_path_buf()),
2146 BuiltinAction::InNewPanel => TreeMsg::OpenSplit(path.to_path_buf()),
2147 BuiltinAction::InOppositePanel => TreeMsg::OpenInOppositePanel(path.to_path_buf()),
2148 BuiltinAction::OpenWith => TreeMsg::OpenWith(path.to_path_buf()),
2149 BuiltinAction::OpenWithDefault => TreeMsg::OpenWithDefault(path.to_path_buf()),
2150 BuiltinAction::ViewThumbnail => TreeMsg::ToggleThumbnail(path.to_path_buf()),
2151 BuiltinAction::NewFile => TreeMsg::NewFile,
2152 BuiltinAction::NewFolder => TreeMsg::NewFolder,
2153 BuiltinAction::Duplicate => TreeMsg::Duplicate(path.to_path_buf()),
2154 BuiltinAction::CreateLink => TreeMsg::CreateLink(path.to_path_buf()),
2155 BuiltinAction::Cut => TreeMsg::Cut,
2156 BuiltinAction::Copy => TreeMsg::Copy,
2157 BuiltinAction::Paste => TreeMsg::Paste,
2158 BuiltinAction::Rename => TreeMsg::RenameAt(path.to_path_buf()),
2159 BuiltinAction::CopyPath => TreeMsg::CopyPath(path.to_path_buf()),
2160 BuiltinAction::CopyRelativePath => TreeMsg::CopyRelativePath(path.to_path_buf()),
2161 BuiltinAction::AddBookmark => TreeMsg::AddBookmark(path.to_path_buf()),
2162 BuiltinAction::Properties => TreeMsg::Properties(path.to_path_buf()),
2163 BuiltinAction::Trash => TreeMsg::Trash(path.to_path_buf()),
2164 BuiltinAction::DeletePermanently => TreeMsg::PermanentDelete(path.to_path_buf()),
2165 BuiltinAction::ToggleHidden => TreeMsg::ToggleHidden,
2166 BuiltinAction::SortByName => TreeMsg::SetSortKey(SortKey::Name),
2167 BuiltinAction::SortBySize => TreeMsg::SetSortKey(SortKey::Size),
2168 BuiltinAction::SortByModified => TreeMsg::SetSortKey(SortKey::Modified),
2169 BuiltinAction::SortByType => TreeMsg::SetSortKey(SortKey::Type),
2170 BuiltinAction::ToggleSortAscending => TreeMsg::ToggleSortDirection,
2171 BuiltinAction::OpenFolder
2173 | BuiltinAction::Filter
2174 | BuiltinAction::SplitView
2175 | BuiltinAction::Up
2176 | BuiltinAction::Back
2177 | BuiltinAction::Forward
2178 | BuiltinAction::Collapse
2179 | BuiltinAction::ClosePane
2180 | BuiltinAction::ToggleBookmarks
2181 | BuiltinAction::NewBookmark
2182 | BuiltinAction::NewBookmarkFolder => unreachable!("pane action in a row context menu"),
2183 BuiltinAction::EditBookmark | BuiltinAction::DeleteBookmark => TreeMsg::Noop,
2185 BuiltinAction::Separator => unreachable!("separators are rendered, not dispatched"),
2186 }
2187}
2188
2189pub(crate) fn is_path_safe(action: BuiltinAction) -> bool {
2194 matches!(
2195 action,
2196 BuiltinAction::Open
2197 | BuiltinAction::OpenSplit
2198 | BuiltinAction::InNewPanel
2199 | BuiltinAction::InOppositePanel
2200 | BuiltinAction::OpenWith
2201 | BuiltinAction::OpenWithDefault
2202 | BuiltinAction::Duplicate
2203 | BuiltinAction::CreateLink
2204 | BuiltinAction::CopyPath
2205 | BuiltinAction::CopyRelativePath
2206 | BuiltinAction::Properties
2207 | BuiltinAction::Trash
2208 | BuiltinAction::DeletePermanently
2209 | BuiltinAction::AddBookmark
2210 )
2211}
2212
2213pub(crate) fn path_action_message(action: BuiltinAction, path: &Path) -> Option<TreeMsg> {
2218 if !is_path_safe(action) || action == BuiltinAction::Open {
2219 return None;
2220 }
2221 Some(builtin_message(action, path))
2222}
2223
2224const ARCHIVE_EXTENSIONS: [&str; 8] = ["zip", "tar", "gz", "xz", "bz2", "zst", "7z", "rar"];
2226
2227fn icon_name(row: &VisibleRow) -> &'static str {
2228 if row.is_dir {
2229 return if row.expanded { "folder-open-symbolic" } else { "folder-symbolic" };
2230 }
2231 if has_extension(&row.path, &["pdf"]) {
2235 "application-pdf-symbolic"
2236 } else if has_extension(&row.path, &["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp", "avif"])
2237 {
2238 "image-x-generic-symbolic"
2239 } else if has_extension(&row.path, &["mp3", "flac", "wav", "ogg", "m4a", "opus"]) {
2240 "audio-x-generic-symbolic"
2241 } else if has_extension(&row.path, &["mp4", "mkv", "webm", "mov", "avi"]) {
2242 "video-x-generic-symbolic"
2243 } else if has_extension(&row.path, &ARCHIVE_EXTENSIONS) {
2244 "package-x-generic-symbolic"
2245 } else {
2246 "text-x-generic-symbolic"
2247 }
2248}
2249
2250#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2256enum MediaKind {
2257 Image,
2259 Gif,
2261 Video,
2263 Audio,
2265}
2266
2267const IMAGE_EXTENSIONS: [&str; 10] =
2271 ["jpg", "jpeg", "png", "webp", "bmp", "tif", "tiff", "avif", "heic", "heif"];
2272
2273const VIDEO_EXTENSIONS: [&str; 15] = [
2276 "mp4", "m4v", "mkv", "webm", "mov", "avi", "wmv", "flv", "ogv", "ogg", "ts", "mpg",
2277 "mpeg", "3gp", "m2ts",
2278];
2279
2280const AUDIO_EXTENSIONS: [&str; 12] = [
2284 "mp3", "m4a", "m4b", "aac", "flac", "wav", "opus", "oga", "wma", "aif", "aiff", "alac",
2285];
2286
2287fn has_extension(path: &Path, extensions: &[&str]) -> bool {
2288 path.extension()
2289 .and_then(|ext| ext.to_str())
2290 .is_some_and(|ext| extensions.iter().any(|candidate| ext.eq_ignore_ascii_case(candidate)))
2291}
2292
2293fn media_kind(path: &Path) -> Option<MediaKind> {
2295 if has_extension(path, &["gif"]) {
2296 Some(MediaKind::Gif)
2297 } else if has_extension(path, &IMAGE_EXTENSIONS) {
2298 Some(MediaKind::Image)
2299 } else if has_extension(path, &VIDEO_EXTENSIONS) {
2300 Some(MediaKind::Video)
2301 } else if cfg!(feature = "audio") && has_extension(path, &AUDIO_EXTENSIONS) {
2302 Some(MediaKind::Audio)
2303 } else {
2304 None
2305 }
2306}
2307
2308fn is_thumbnailable(path: &Path) -> bool {
2310 media_kind(path).is_some()
2311}
2312
2313struct GifAnim {
2316 iter: gdk::gdk_pixbuf::PixbufAnimationIter,
2317}
2318
2319struct AudioWidgets {
2323 player: Rc<AudioPlayer>,
2324 play: gtk::Button,
2325 seek: gtk::Scale,
2326 time: gtk::Label,
2327 seeking: Rc<Cell<bool>>,
2330}
2331
2332fn needs_selection(action: BuiltinAction) -> bool {
2336 matches!(
2337 action,
2338 BuiltinAction::Rename
2339 | BuiltinAction::Duplicate
2340 | BuiltinAction::CreateLink
2341 | BuiltinAction::Cut
2342 | BuiltinAction::Copy
2343 | BuiltinAction::OpenWith
2344 | BuiltinAction::Trash
2345 | BuiltinAction::DeletePermanently
2346 )
2347}
2348
2349fn in_list_row(widget: >k::Widget) -> bool {
2352 let mut current = Some(widget.clone());
2353 while let Some(widget) = current {
2354 if widget.downcast_ref::<gtk::ListBoxRow>().is_some() {
2355 return true;
2356 }
2357 current = widget.parent();
2358 }
2359 false
2360}
2361
2362fn in_inline_media(widget: >k::Widget) -> bool {
2365 let mut current = Some(widget.clone());
2366 while let Some(widget) = current {
2367 if widget.downcast_ref::<gtk::Video>().is_some() || widget.has_css_class("tree-audio") {
2368 return true;
2369 }
2370 current = widget.parent();
2371 }
2372 false
2373}
2374
2375fn thumbnail_content_width(panel: PanelConfig) -> i32 {
2378 (panel.width as i32 - 2 * panel.margin as i32 - 24).max(64)
2379}
2380
2381fn thumbnail_size(texture: &gdk::Texture, available: i32) -> (i32, i32) {
2384 let natural_w = texture.width().max(1);
2385 let natural_h = texture.height().max(1);
2386 let available = available.max(32);
2387 if natural_w <= available {
2388 return (natural_w, natural_h);
2389 }
2390 let height = (natural_h as i64 * available as i64 / natural_w as i64).max(1) as i32;
2391 (available, height)
2392}
2393
2394fn thumbnail_picture(
2397 texture: &gdk::Texture,
2398 margin_start: i32,
2399 width: i32,
2400 height: i32,
2401) -> gtk::Picture {
2402 let picture = gtk::Picture::for_paintable(texture);
2403 picture.add_css_class("tree-thumbnail");
2404 picture.set_can_shrink(true);
2405 picture.set_content_fit(gtk::ContentFit::ScaleDown);
2406 picture.set_halign(gtk::Align::Start);
2407 picture.set_size_request(width, height);
2408 picture.set_margin_start(margin_start);
2409 picture.set_margin_bottom(6);
2410 picture
2411}
2412
2413fn format_time(micros: i64) -> String {
2416 if micros <= 0 {
2417 return "-:--".to_owned();
2418 }
2419 let total = micros / 1_000_000;
2420 let (h, m, s) = (total / 3600, (total % 3600) / 60, total % 60);
2421 if h > 0 {
2422 format!("{h}:{m:02}:{s:02}")
2423 } else {
2424 format!("{m}:{s:02}")
2425 }
2426}
2427
2428fn build_audio_player(player: &Rc<AudioPlayer>, margin_start: i32) -> (gtk::Box, AudioWidgets) {
2432 let controls = gtk::Box::new(gtk::Orientation::Horizontal, 6);
2433 controls.add_css_class("tree-audio");
2434 controls.set_margin_start(margin_start);
2435 controls.set_margin_bottom(6);
2436 controls.set_hexpand(true);
2437
2438 let play = gtk::Button::from_icon_name("media-playback-start-symbolic");
2439 play.add_css_class("flat");
2440 play.set_tooltip_text(Some("Play"));
2441 play.set_valign(gtk::Align::Center);
2442 {
2443 let player = player.clone();
2444 play.connect_clicked(move |button| {
2445 let playing = player.is_playing();
2446 player.set_playing(!playing);
2447 button.set_icon_name(if playing {
2448 "media-playback-start-symbolic"
2449 } else {
2450 "media-playback-pause-symbolic"
2451 });
2452 });
2453 }
2454 controls.append(&play);
2455
2456 let seek = gtk::Scale::with_range(gtk::Orientation::Horizontal, 0.0, 1.0, 1.0);
2459 seek.set_draw_value(false);
2460 seek.set_hexpand(true);
2461 seek.set_valign(gtk::Align::Center);
2462 seek.set_size_request(60, -1);
2463 let seeking = Rc::new(Cell::new(false));
2464 {
2465 let player = player.clone();
2466 let seeking = seeking.clone();
2467 seek.connect_change_value(move |_, _, value| {
2468 seeking.set(true);
2470 let flag = seeking.clone();
2471 glib::timeout_add_local_once(Duration::from_millis(200), move || flag.set(false));
2472 player.seek(value.round() as i64);
2473 glib::Propagation::Proceed
2474 });
2475 }
2476 controls.append(&seek);
2477
2478 let time = gtk::Label::new(Some("0:00 / -:--"));
2479 time.add_css_class("tree-audio-time");
2480 time.set_valign(gtk::Align::Center);
2481 time.set_width_chars(13);
2482 time.set_xalign(1.0);
2483 controls.append(&time);
2484
2485 let volume = gtk::Scale::with_range(gtk::Orientation::Horizontal, 0.0, 1.0, 0.05);
2486 volume.set_draw_value(false);
2487 volume.set_value(player.volume());
2488 volume.set_valign(gtk::Align::Center);
2489 volume.set_size_request(56, -1);
2490 volume.set_tooltip_text(Some("Volume"));
2491 {
2492 let player = player.clone();
2493 volume.connect_value_changed(move |scale| player.set_volume(scale.value()));
2494 }
2495 controls.append(&volume);
2496
2497 let widgets = AudioWidgets {
2498 player: player.clone(),
2499 play,
2500 seek,
2501 time,
2502 seeking,
2503 };
2504 (controls, widgets)
2505}
2506
2507impl Tree {
2508 fn open_root(&mut self, path: &Path, sender: &ComponentSender<Self>) {
2509 if let Some(id) = self.flush_source.take() {
2510 id.remove();
2511 }
2512 self.pending.clear();
2513
2514 let mut tree = TreeModel::new(
2515 path.to_path_buf(),
2516 self.config.sort_options(),
2517 self.config.show_hidden,
2518 );
2519 if let Err(err) = tree.expand(path, &StdDirSource) {
2520 self.status(format!("Could not open {}: {err}", path.display()), sender);
2521 return;
2522 }
2523 self.tree = Some(tree);
2524 self.refresh_rows();
2525 self.cursor = if self.rows.is_empty() { None } else { Some(0) };
2526 self.selected = Vec::new();
2527 self.renaming = None;
2528 self.renaming_state.set(false);
2529 self.rename_entry = None;
2530 self.menu_target = None;
2531 self.thumbnails.clear();
2532 self.thumb_cache.clear();
2533 self.playing.clear();
2534 for media in self.media.values() {
2535 media.set_playing(false);
2536 }
2537 self.media.clear();
2538 self.audio.clear();
2539 self.gif_anims.borrow_mut().clear();
2540 self.gif_widgets.borrow_mut().clear();
2541 self.audio_widgets.borrow_mut().clear();
2542 if let Some(previous) = self.popover.take() {
2543 previous.unparent();
2544 }
2545
2546 let root = path.to_path_buf();
2547 let watcher_sender = sender.clone();
2548 self._watcher = spawn_watcher(path, move |change| {
2549 watcher_sender.input(TreeMsg::FsChange(change));
2550 })
2551 .ok();
2552 self.watched.clear();
2553 if self._watcher.is_some() {
2554 self.watched.insert(root.clone());
2555 }
2556
2557 let _ = sender.output(TreeOutput::RootChanged(root));
2558 }
2559
2560 fn refresh_rows(&mut self) {
2561 if let Some(tree) = self.tree.as_ref() {
2562 tree.visible_rows_into(&mut self.rows);
2563 } else {
2564 self.rows.clear();
2565 }
2566 }
2567
2568 fn reconcile_watches(&mut self) {
2574 let Some(watcher) = self._watcher.as_mut() else {
2575 return;
2576 };
2577 let Some(model) = self.tree.as_ref() else {
2578 return;
2579 };
2580 let desired: HashSet<PathBuf> = model.expanded_loaded_dirs().into_iter().collect();
2581
2582 let stale: Vec<PathBuf> = self.watched.difference(&desired).cloned().collect();
2583 for path in stale {
2584 let _ = watcher.unwatch(&path);
2585 self.watched.remove(&path);
2586 }
2587
2588 let fresh: Vec<PathBuf> = desired.difference(&self.watched).cloned().collect();
2589 for path in fresh {
2590 if watcher.watch(&path, RecursiveMode::NonRecursive).is_ok() {
2591 self.watched.insert(path);
2592 }
2593 }
2594 }
2595
2596 fn cursor_path(&self) -> Option<PathBuf> {
2597 self.cursor.and_then(|i| self.rows.get(i)).map(|r| r.path.clone())
2598 }
2599
2600 fn cursor_delta(&mut self, delta: isize) {
2601 if self.rows.is_empty() {
2602 return;
2603 }
2604 let next = (self.cursor.unwrap_or(0) as isize + delta).clamp(0, self.rows.len() as isize - 1) as usize;
2605 let path = self.rows[next].path.clone();
2606 self.cursor = Some(next);
2607 self.set_single_selection(path);
2608 self.selection_dirty = true;
2609 }
2610
2611 fn cursor_extend(&mut self, delta: isize, _keep_anchor: bool) {
2618 if self.rows.is_empty() {
2619 return;
2620 }
2621 let anchor = self
2622 .anchor
2623 .clone()
2624 .or_else(|| self.cursor.map(|i| self.rows[i].path.clone()))
2625 .unwrap_or_else(|| self.rows[0].path.clone());
2626 let next = (self.cursor.unwrap_or(0) as isize + delta)
2627 .clamp(0, self.rows.len() as isize - 1) as usize;
2628 self.cursor = Some(next);
2629 self.anchor = Some(anchor.clone());
2630 let (Some(a), Some(b)) = (self.rows.iter().position(|r| r.path == anchor), Some(next)) else {
2631 return;
2632 };
2633 self.selected = self.rows[index_range(a, b)].iter().map(|r| r.path.clone()).collect();
2634 self.scroll_to = Some(next);
2635 self.selection_dirty = true;
2636 }
2637
2638 fn cursor_snap(&mut self) {
2639 let deselected = self.cursor.is_none();
2640 let prev = self.cursor_path();
2641 self.refresh_rows();
2642 if self.rows.is_empty() {
2643 self.cursor = None;
2644 self.selected.clear();
2645 return;
2646 }
2647 if deselected {
2648 self.cursor = None;
2650 return;
2651 }
2652 let index = prev
2653 .and_then(|p| self.rows.iter().position(|r| r.path == p))
2654 .unwrap_or(0);
2655 self.cursor = Some(index);
2656 }
2657
2658 fn select(&mut self, path: &Path) {
2659 if let Some(i) = self.rows.iter().position(|r| r.path == path) {
2660 self.cursor = Some(i);
2661 }
2662 self.selected = vec![path.to_path_buf()];
2663 }
2664
2665 fn press_default(&mut self, path: &Path, is_dir: bool, sender: &ComponentSender<Self>) {
2670 let now = std::time::Instant::now();
2673 let is_double = self.last_press.as_ref().is_some_and(|(last, at)| {
2674 last == path && now.duration_since(*at) < Duration::from_millis(400)
2675 });
2676 self.last_press = Some((path.to_path_buf(), now));
2677
2678 self.select(path);
2679 self.anchor = Some(path.to_path_buf());
2680 if is_dir {
2681 self.toggle_dir(path, sender);
2685 } else if is_double {
2686 self.activate(path, sender);
2688 }
2689 }
2690
2691 fn reveal_path(&mut self, path: PathBuf) {
2696 if let Some(i) = self.rows.iter().position(|r| r.path == path) {
2697 self.cursor = Some(i);
2698 self.selected = vec![path.clone()];
2699 self.anchor = Some(path);
2700 self.selection_dirty = true;
2701 self.scroll_to = Some(i);
2702 }
2703 }
2704
2705 fn range_select(&mut self, path: &Path) {
2709 let Some(anchor) = self.anchor.clone() else {
2710 self.select(path);
2711 self.anchor = Some(path.to_path_buf());
2712 return;
2713 };
2714 let (Some(a), Some(b)) = (
2715 self.rows.iter().position(|r| r.path == anchor),
2716 self.rows.iter().position(|r| r.path == path),
2717 ) else {
2718 self.select(path);
2719 return;
2720 };
2721 self.selected = self.rows[index_range(a, b)].iter().map(|r| r.path.clone()).collect();
2722 self.cursor = Some(b);
2723 }
2724
2725 fn set_single_selection(&mut self, path: PathBuf) {
2726 self.selected = vec![path.clone()];
2727 if let Some(i) = self.rows.iter().position(|r| r.path == path) {
2728 self.cursor = Some(i);
2729 }
2730 }
2731
2732 fn selected_for_clipboard(&self) -> Vec<PathBuf> {
2733 if self.selected.is_empty() {
2734 if let Some(path) = self.cursor_path() {
2735 return vec![path];
2736 }
2737 return Vec::new();
2738 }
2739 self.selected.clone()
2740 }
2741
2742 fn collapse_dir(&mut self, path: &Path) {
2745 if let Some(tree) = self.tree.as_mut() {
2746 tree.collapse(path);
2747 }
2748 self.clear_thumbnails_under(path);
2749 self.cursor_snap();
2750 }
2751
2752 fn toggle_dir(&mut self, path: &Path, sender: &ComponentSender<Self>) {
2754 let expanded = self.tree.as_ref().is_some_and(|t| t.is_expanded(path));
2755 if expanded {
2756 self.collapse_dir(path);
2757 } else {
2758 self.expand_dir(path, sender);
2759 }
2760 }
2761
2762 fn shutdown(&mut self) {
2769 if let Some(id) = self.gif_tick.borrow_mut().take() {
2770 id.remove();
2771 }
2772 if let Some(id) = self.audio_tick.borrow_mut().take() {
2773 id.remove();
2774 }
2775 self.gif_anims.borrow_mut().clear();
2776 self.gif_widgets.borrow_mut().clear();
2777 self.audio_widgets.borrow_mut().clear();
2778 self.playing.clear();
2779 self.thumbnails.clear();
2780 for media in self.media.values() {
2781 media.set_playing(false);
2782 }
2783 self.media.clear();
2784 self.audio.clear();
2786 }
2787
2788 fn clear_thumbnails_under(&mut self, prefix: &Path) {
2790 self.thumbnails.retain(|path| !path.starts_with(prefix));
2791 self.thumb_cache.retain(|path, _| !path.starts_with(prefix));
2792 self.playing.retain(|path| !path.starts_with(prefix));
2793 self.media.retain(|path, media| {
2794 if path.starts_with(prefix) {
2795 media.set_playing(false);
2796 false
2797 } else {
2798 true
2799 }
2800 });
2801 self.gif_anims.borrow_mut().retain(|path, _| !path.starts_with(prefix));
2802 self.gif_widgets.borrow_mut().retain(|path, _| !path.starts_with(prefix));
2803 self.audio_widgets.borrow_mut().retain(|path, _| !path.starts_with(prefix));
2804 self.audio.retain(|path, _| !path.starts_with(prefix));
2806 }
2807
2808 fn wants_thumbnail(&self, row: &VisibleRow) -> bool {
2811 if row.is_dir || !is_thumbnailable(&row.path) {
2812 return false;
2813 }
2814 self.thumbnails.contains(&row.path)
2815 || row
2816 .path
2817 .ancestors()
2818 .skip(1)
2819 .any(|ancestor| self.thumbnails.contains(ancestor))
2820 }
2821
2822 fn static_texture(&mut self, path: &Path) -> Option<gdk::Texture> {
2825 if let Some(texture) = self.thumb_cache.get(path) {
2826 return Some(texture.clone());
2827 }
2828 let texture = match media_kind(path)? {
2829 MediaKind::Image => gdk::Texture::from_filename(path).ok()?,
2830 MediaKind::Gif => {
2831 let animation = gdk::gdk_pixbuf::PixbufAnimation::from_file(path).ok()?;
2832 let pixbuf = animation.static_image()?;
2833 gdk::Texture::for_pixbuf(&pixbuf)
2834 }
2835 MediaKind::Video | MediaKind::Audio => return None,
2837 };
2838 self.thumb_cache.insert(path.to_path_buf(), texture.clone());
2839 Some(texture)
2840 }
2841
2842 fn media_file(&mut self, path: &Path) -> Option<gtk::MediaFile> {
2847 if let Some(media) = self.media.get(path) {
2848 return Some(media.clone());
2849 }
2850 let media = gtk::MediaFile::for_filename(path);
2851 media.set_loop(true);
2852 media.set_muted(false);
2857 media.set_volume(1.0);
2858 self.media.insert(path.to_path_buf(), media.clone());
2859 Some(media)
2860 }
2861
2862 fn audio_player(&mut self, path: &Path) -> Option<Rc<AudioPlayer>> {
2865 if let Some(player) = self.audio.get(path) {
2866 return Some(player.clone());
2867 }
2868 let player = AudioPlayer::new(path)?;
2869 self.audio.insert(path.to_path_buf(), player.clone());
2870 Some(player)
2871 }
2872
2873 fn toggle_thumbnail_play(&mut self, path: &Path) {
2876 if media_kind(path) != Some(MediaKind::Gif) {
2877 return;
2878 }
2879 if self.playing.remove(path) {
2880 self.gif_anims.borrow_mut().remove(path);
2881 } else {
2882 let Ok(animation) = gdk::gdk_pixbuf::PixbufAnimation::from_file(path) else {
2883 return;
2884 };
2885 let iter = animation.iter(Some(SystemTime::now()));
2886 self.gif_anims.borrow_mut().insert(path.to_path_buf(), GifAnim { iter });
2887 self.playing.insert(path.to_path_buf());
2888 self.ensure_gif_ticker();
2889 }
2890 }
2891
2892 fn ensure_gif_ticker(&mut self) {
2896 if self.gif_tick.borrow().is_some() {
2897 return;
2898 }
2899 let anims = self.gif_anims.clone();
2900 let widgets = self.gif_widgets.clone();
2901 let tick = self.gif_tick.clone();
2902 let id = glib::timeout_add_local(Duration::from_millis(33), move || {
2903 let mut anims = anims.borrow_mut();
2904 if anims.is_empty() {
2905 *tick.borrow_mut() = None;
2906 return glib::ControlFlow::Break;
2907 }
2908 let now = SystemTime::now();
2909 let widgets = widgets.borrow();
2910 for (path, anim) in anims.iter_mut() {
2911 let _ = anim.iter.advance(now);
2912 if let Some(picture) = widgets.get(path) {
2913 let pixbuf = anim.iter.pixbuf();
2914 picture.set_paintable(Some(&gdk::Texture::for_pixbuf(&pixbuf)));
2915 }
2916 }
2917 glib::ControlFlow::Continue
2918 });
2919 *self.gif_tick.borrow_mut() = Some(id);
2920 }
2921
2922 fn ensure_audio_ticker(&mut self) {
2925 if self.audio_tick.borrow().is_some() {
2926 return;
2927 }
2928 let players = self.audio_widgets.clone();
2929 let tick = self.audio_tick.clone();
2930 let id = glib::timeout_add_local(Duration::from_millis(200), move || {
2931 let players = players.borrow();
2932 if players.is_empty() {
2933 *tick.borrow_mut() = None;
2934 return glib::ControlFlow::Break;
2935 }
2936 for widgets in players.values() {
2937 let position = widgets.player.position();
2938 let duration = widgets.player.duration();
2939 if duration > 0
2940 && (widgets.seek.adjustment().upper() - duration as f64).abs() > 0.5
2941 {
2942 widgets.seek.adjustment().set_upper(duration as f64);
2943 }
2944 if !widgets.seeking.get() {
2945 widgets.seek.set_value(position as f64);
2946 }
2947 widgets.time.set_text(&format!(
2948 "{} / {}",
2949 format_time(position),
2950 format_time(duration)
2951 ));
2952 widgets.play.set_icon_name(if widgets.player.is_playing() {
2953 "media-playback-pause-symbolic"
2954 } else {
2955 "media-playback-start-symbolic"
2956 });
2957 }
2958 glib::ControlFlow::Continue
2959 });
2960 *self.audio_tick.borrow_mut() = Some(id);
2961 }
2962
2963 fn append_thumbnail(
2966 &mut self,
2967 path: &Path,
2968 row_indent: i32,
2969 container: >k::Box,
2970 sender: &ComponentSender<Self>,
2971 ) {
2972 let Some(kind) = media_kind(path) else {
2973 return;
2974 };
2975 let label_start = row_indent + self.config.icon_size as i32 + 6;
2976 let available = self.content_width - label_start - 6;
2977
2978 match kind {
2979 MediaKind::Image => {
2980 if let Some(texture) = self.static_texture(path) {
2981 let (width, height) = thumbnail_size(&texture, available);
2982 container.append(&thumbnail_picture(&texture, label_start, width, height));
2983 }
2984 }
2985 MediaKind::Gif => {
2986 let playing = self.playing.contains(path);
2988 let texture = if playing {
2989 self.gif_anims
2990 .borrow()
2991 .get(path)
2992 .map(|anim| gdk::Texture::for_pixbuf(&anim.iter.pixbuf()))
2993 } else {
2994 self.static_texture(path)
2995 };
2996 if let Some(texture) = texture {
2997 let (width, height) = thumbnail_size(&texture, available);
2998 let picture = thumbnail_picture(&texture, label_start, width, height);
2999 picture.set_tooltip_text(Some("Click to play"));
3000 if playing {
3001 self.gif_widgets
3002 .borrow_mut()
3003 .insert(path.to_path_buf(), picture.clone());
3004 }
3005 let msg = TreeMsg::ToggleThumbnailPlay(path.to_path_buf());
3006 let s = sender.clone();
3007 let click = gtk::GestureClick::new();
3008 click.set_button(1);
3009 click.connect_pressed(move |_, _, _, _| s.input(msg.clone()));
3010 picture.add_controller(click);
3011 container.append(&picture);
3012 }
3013 }
3014 MediaKind::Video => {
3015 if let Some(media) = self.media_file(path) {
3016 let video = gtk::Video::new();
3022 video.set_autoplay(false);
3023 video.set_media_stream(Some(&media));
3024 video.set_loop(true);
3025 video.add_css_class("tree-thumbnail");
3026 video.set_halign(gtk::Align::Start);
3027 let width = available.max(64);
3028 video.set_size_request(width, (width * 9 / 16).max(36));
3029 video.set_margin_start(label_start);
3030 video.set_margin_bottom(6);
3031 container.append(&video);
3032 }
3033 }
3034 MediaKind::Audio => {
3035 if let Some(player) = self.audio_player(path) {
3036 let (controls, widgets) = build_audio_player(&player, label_start);
3040 self.audio_widgets.borrow_mut().insert(path.to_path_buf(), widgets);
3041 container.append(&controls);
3042 self.ensure_audio_ticker();
3043 }
3044 }
3045 }
3046 }
3047
3048 fn expand_dir(&mut self, path: &Path, sender: &ComponentSender<Self>) {
3049 let Some(tree) = self.tree.as_mut() else {
3050 return;
3051 };
3052 if let Err(err) = tree.expand(path, &StdDirSource) {
3053 self.status(format!("Could not open {}: {err}", path.display()), sender);
3054 }
3055 self.refresh_rows();
3056 }
3057
3058 fn activate(&mut self, path: &Path, sender: &ComponentSender<Self>) {
3059 let Some(index) = self.rows.iter().position(|r| r.path == path) else {
3060 return;
3061 };
3062 if self.rows[index].is_dir {
3063 self.open_root(path, sender);
3067 } else if let Err(err) = open_in_app(path) {
3068 self.status(err, sender);
3069 }
3070 }
3071
3072 fn confirm_trash(&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!("Move \u{201c}{name}\u{201d} to trash?")
3080 }
3081 many => format!("Move {} items to trash?", many.len()),
3082 };
3083 let dialog = gtk::AlertDialog::builder()
3084 .modal(true)
3085 .message(message)
3086 .detail("The panel cannot restore trashed items.")
3087 .buttons(["Cancel", "Trash"])
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::ConfirmTrash(paths));
3094 }
3095 });
3096 }
3097
3098 fn confirm_permanent_delete(&mut self, paths: Vec<PathBuf>, sender: ComponentSender<Self>) {
3099 let message = match paths.as_slice() {
3100 [single] => {
3101 let name = single
3102 .file_name()
3103 .map(|n| n.to_string_lossy().into_owned())
3104 .unwrap_or_else(|| single.display().to_string());
3105 format!("Permanently delete \u{201c}{name}\u{201d}?")
3106 }
3107 many => format!("Permanently delete {} items?", many.len()),
3108 };
3109 let dialog = gtk::AlertDialog::builder()
3110 .modal(true)
3111 .message(message)
3112 .detail("This cannot be undone. Files will not go to the trash.")
3113 .buttons(["Cancel", "Delete Permanently"])
3114 .cancel_button(0)
3115 .default_button(1)
3116 .build();
3117 dialog.choose(Some(&self.parent), None::<&gio::Cancellable>, move |res| {
3118 if matches!(res, Ok(1)) {
3119 sender.input(TreeMsg::ConfirmPermanentDelete(paths));
3120 }
3121 });
3122 }
3123
3124 fn open_menu(&mut self, path: &Path) {
3125 let in_selection = self.selected.len() > 1 && self.selected.iter().any(|p| p == path);
3129 if !in_selection {
3130 self.select(path);
3131 } else if let Some(i) = self.rows.iter().position(|r| r.path == path) {
3132 self.cursor = Some(i);
3133 }
3134 if let Some(previous) = self.popover.take() {
3135 previous.unparent();
3136 }
3137 self.menu_target = Some(path.to_path_buf());
3138 self.menu_point = None;
3139 }
3140
3141 fn open_menu_at(&mut self, path: &Path, at: (f64, f64)) {
3145 if let Some(previous) = self.popover.take() {
3146 previous.unparent();
3147 }
3148 self.menu_target = Some(path.to_path_buf());
3149 self.menu_point = Some(at);
3150 }
3151
3152 fn paste(&mut self, sender: &ComponentSender<Self>) {
3153 let Some(clip) = self.clipboard.clone() else {
3154 self.paste_from_system_clipboard(sender);
3157 return;
3158 };
3159 let Some(dir) = self.target_dir() else {
3160 return;
3161 };
3162 let mut count = 0usize;
3163 let mut failed: Option<String> = None;
3164 for src in clip.paths {
3165 if src == dir {
3166 continue;
3167 }
3168 let result = match clip.op {
3169 ClipboardOp::Copy => self.ops.copy(&src, &dir),
3170 ClipboardOp::Cut => self.ops.move_(&src, &dir),
3171 };
3172 match result {
3173 Ok(dest) => {
3174 count += 1;
3175 self.apply_change(Change::Created { path: dest });
3176 if clip.op == ClipboardOp::Cut {
3177 self.apply_change(Change::Removed { path: src });
3178 }
3179 }
3180 Err(err) => {
3181 failed = Some(err.to_string());
3182 break;
3183 }
3184 }
3185 }
3186 if let Some(failed) = failed {
3187 self.status(failed, sender);
3188 return;
3189 }
3190 let verb = match clip.op {
3191 ClipboardOp::Copy => "Copied",
3192 ClipboardOp::Cut => "Moved",
3193 };
3194 self.status(format!("{verb} {count} item(s) into {}", dir.display()), sender);
3195 if clip.op == ClipboardOp::Cut {
3196 self.clipboard = None;
3197 }
3198 self.cursor_snap();
3199 }
3200
3201 fn paste_from_system_clipboard(&mut self, sender: &ComponentSender<Self>) {
3205 if self.target_dir().is_none() {
3206 return;
3207 }
3208 let s = sender.clone();
3209 read_clipboard_files(move |paths| s.input(TreeMsg::PastePaths(paths)));
3210 }
3211
3212 fn paste_paths(&mut self, paths: Vec<PathBuf>, sender: &ComponentSender<Self>) {
3214 let Some(dir) = self.target_dir() else {
3215 return;
3216 };
3217 if paths.is_empty() {
3218 self.status("Clipboard has no files to paste".to_string(), sender);
3219 return;
3220 }
3221 let mut count = 0usize;
3222 let mut failed: Option<String> = None;
3223 for src in paths {
3224 if src == dir {
3225 continue;
3226 }
3227 match self.ops.copy(&src, &dir) {
3228 Ok(dest) => {
3229 count += 1;
3230 self.apply_change(Change::Created { path: dest });
3231 }
3232 Err(err) => {
3233 failed = Some(err.to_string());
3234 break;
3235 }
3236 }
3237 }
3238 if let Some(failed) = failed {
3239 self.status(failed, sender);
3240 return;
3241 }
3242 self.status(format!("Pasted {count} item(s) into {}", dir.display()), sender);
3243 self.cursor_snap();
3244 }
3245
3246 fn request_drop_into(
3251 &mut self,
3252 target: PathBuf,
3253 sources: Vec<PathBuf>,
3254 copy: bool,
3255 sender: &ComponentSender<Self>,
3256 ) {
3257 match resolve_drop(&target, sources, copy) {
3258 DropPlan::Ignore => {}
3259 DropPlan::Reject(message) => self.status(message, sender),
3260 DropPlan::Copy(sources) => self.drop_into(&target, &sources, true, sender),
3261 DropPlan::Move(sources) => {
3262 if self.config.confirm_drop_move {
3263 self.confirm_move(target, sources, sender);
3264 } else {
3265 self.drop_into(&target, &sources, false, sender);
3266 }
3267 }
3268 }
3269 }
3270
3271 fn confirm_move(
3275 &mut self,
3276 target: PathBuf,
3277 sources: Vec<PathBuf>,
3278 sender: &ComponentSender<Self>,
3279 ) {
3280 let target_name = target
3281 .file_name()
3282 .map(|n| n.to_string_lossy().into_owned())
3283 .unwrap_or_else(|| target.display().to_string());
3284 let message = match sources.as_slice() {
3285 [single] => {
3286 let name = single
3287 .file_name()
3288 .map(|n| n.to_string_lossy().into_owned())
3289 .unwrap_or_else(|| single.display().to_string());
3290 format!("Move \u{201c}{name}\u{201d} into \u{201c}{target_name}\u{201d}?")
3291 }
3292 many => format!("Move {} items into \u{201c}{target_name}\u{201d}?", many.len()),
3293 };
3294 let dialog = gtk::AlertDialog::builder()
3295 .modal(true)
3296 .message(message)
3297 .detail("Hold Ctrl while dropping to copy instead of move.")
3298 .buttons(["Cancel", "Move"])
3299 .cancel_button(0)
3300 .default_button(1)
3301 .build();
3302 let s = sender.clone();
3303 dialog.choose(Some(&self.parent), None::<&gio::Cancellable>, move |res| {
3304 if matches!(res, Ok(1)) {
3305 s.input(TreeMsg::DropIntoConfirmed { target, sources });
3306 }
3307 });
3308 }
3309
3310 fn drop_into(
3314 &mut self,
3315 target: &Path,
3316 sources: &[PathBuf],
3317 copy: bool,
3318 sender: &ComponentSender<Self>,
3319 ) {
3320 let mut count = 0usize;
3321 let mut failed: Option<String> = None;
3322 for src in sources {
3323 if src == target || src.parent() == Some(target) {
3324 continue; }
3326 if !copy && target.starts_with(src) {
3327 failed = Some("Cannot move a folder into itself".to_string());
3328 break;
3329 }
3330 let result = if copy { self.ops.copy(src, target) } else { self.ops.move_(src, target) };
3331 match result {
3332 Ok(dest) => {
3333 count += 1;
3334 self.apply_change(Change::Created { path: dest });
3335 if !copy {
3336 self.apply_change(Change::Removed { path: src.clone() });
3337 }
3338 }
3339 Err(err) => {
3340 failed = Some(err.to_string());
3341 break;
3342 }
3343 }
3344 }
3345 if let Some(failed) = failed {
3346 self.status(failed, sender);
3347 return;
3348 }
3349 if count > 0 {
3350 let verb = if copy { "Copied" } else { "Moved" };
3351 self.status(format!("{verb} {count} item(s) into {}", target.display()), sender);
3352 }
3353 self.cursor_snap();
3354 }
3355
3356 fn create_entry(&mut self, is_dir: bool, sender: &ComponentSender<Self>) {
3357 let Some(dir) = self.target_dir() else {
3358 return;
3359 };
3360 let name = fresh_name(&dir, if is_dir { "new folder" } else { "new file" });
3361 let result = if is_dir {
3362 self.ops.create_dir(&dir, &name)
3363 } else {
3364 self.ops.create_file(&dir, &name)
3365 };
3366 match result {
3367 Ok(new_path) => {
3368 self.apply_change(Change::Created { path: new_path.clone() });
3369 self.renaming = Some(new_path.clone());
3370 self.renaming_state.set(true);
3371 self.select(&new_path);
3372 }
3373 Err(err) => self.status(format!("Could not create: {err}"), sender),
3374 }
3375 }
3376
3377 fn target_dir(&self) -> Option<PathBuf> {
3380 if let Some(path) = self.cursor_path() {
3381 let is_dir = self.rows.iter().find(|r| r.path == path).map(|r| r.is_dir).unwrap_or(false);
3382 if is_dir {
3383 return Some(path);
3384 }
3385 return path.parent().map(Path::to_path_buf);
3386 }
3387 self.tree.as_ref().map(|t| t.root().to_path_buf())
3388 }
3389
3390 fn apply_change(&mut self, change: Change) {
3391 if let Some(tree) = self.tree.as_mut() {
3392 tree.apply(&change, &StdDirSource);
3393 self.refresh_rows();
3394 }
3395 }
3396
3397 fn toggle_hidden(&mut self, sender: &ComponentSender<Self>) {
3400 let show = !self.config.show_hidden;
3401 self.config.show_hidden = show;
3402 if let Some(tree) = self.tree.as_mut() {
3403 tree.set_show_hidden(show);
3404 tree.reload_all(&StdDirSource);
3405 self.refresh_rows();
3406 }
3407 let label = if show { "Showing hidden files." } else { "Hiding hidden files." };
3408 self.status(label.to_string(), sender);
3409 }
3410
3411 fn set_sort_key(&mut self, key: SortKey, sender: &ComponentSender<Self>) {
3413 let mut opts = self.config.sort_options();
3414 opts.key = key;
3415 self.config.sort_key = key.as_str().to_owned();
3416 if let Some(tree) = self.tree.as_mut() {
3417 tree.set_sort(opts);
3418 tree.reload_all(&StdDirSource);
3419 self.refresh_rows();
3420 }
3421 self.status(format!("Sorting by {}.", key.as_str()), sender);
3422 }
3423
3424 fn toggle_sort_direction(&mut self, sender: &ComponentSender<Self>) {
3426 let mut opts = self.config.sort_options();
3427 opts.ascending = !opts.ascending;
3428 self.config.sort_ascending = opts.ascending;
3429 if let Some(tree) = self.tree.as_mut() {
3430 tree.set_sort(opts);
3431 tree.reload_all(&StdDirSource);
3432 self.refresh_rows();
3433 }
3434 let dir = if opts.ascending { "ascending" } else { "descending" };
3435 self.status(format!("Sort order: {dir}."), sender);
3436 }
3437
3438 fn status(&mut self, message: String, sender: &ComponentSender<Self>) {
3439 let _ = sender.output(TreeOutput::Status(message));
3440 }
3441
3442 fn run_shortcut(&mut self, target: ShortcutTarget, sender: &ComponentSender<Self>) {
3447 if let ShortcutTarget::Builtin(builtin) = &target
3450 && builtin.is_pane_action()
3451 {
3452 let _ = sender.output(TreeOutput::PaneAction(*builtin));
3453 return;
3454 }
3455 let has_selection = self.cursor.is_some() || !self.selected.is_empty();
3459 let path = match self.cursor_path() {
3460 Some(path) => path,
3461 None => match self.tree.as_ref().map(|t| t.root().to_path_buf()) {
3462 Some(root) => root,
3463 None => return,
3464 },
3465 };
3466
3467 if let ShortcutTarget::Command(cmd) = &target {
3469 if !has_selection {
3470 return;
3471 }
3472 let command = action_command(&cmd.command, &path);
3473 match spawn_command(&command) {
3474 Ok(()) => {
3475 self.status(format!("Ran {} on {}", cmd.command, path.display()), sender);
3476 }
3477 Err(err) => {
3478 self.status(format!("Could not run {}: {err}", cmd.command), sender);
3479 }
3480 }
3481 return;
3482 }
3483 let ShortcutTarget::Builtin(builtin) = target else {
3484 return;
3485 };
3486 if builtin == BuiltinAction::Separator
3487 || (builtin.is_directory_only() && !path.is_dir())
3488 {
3489 return;
3490 }
3491 if matches!(builtin, BuiltinAction::OpenWithDefault) && path.is_dir() {
3492 return;
3493 }
3494 if builtin == BuiltinAction::ViewThumbnail && !path.is_dir() && !is_thumbnailable(&path) {
3495 return;
3496 }
3497 if !has_selection && needs_selection(builtin) {
3499 return;
3500 }
3501 if has_selection && !self.selected.iter().any(|p| p == &path) {
3505 self.select(&path);
3506 }
3507 let selection = self.selected.clone();
3510 sender.input(action_message(builtin, &path, &selection));
3511 }
3512}
3513
3514fn fresh_name(dir: &Path, stem: &str) -> String {
3519 for n in 0.. {
3520 let name = if n == 0 { stem.to_string() } else { format!("{stem} {n}") };
3521 if !dir.join(&name).exists() {
3522 return name;
3523 }
3524 }
3525 unreachable!("the loop above always finds a free name")
3526}
3527
3528fn open_in_app(path: &Path) -> Result<String, String> {
3537 let content_type = content_type_for(path)
3538 .ok_or_else(|| format!("Could not determine the type of {}", path.display()))?;
3539 let app = gio::AppInfo::default_for_type(&content_type, false)
3540 .ok_or_else(|| format!("No application is registered to open {}", path.display()))?;
3541 let file = gio::File::for_path(path);
3542 app.launch(&[file], None::<&gio::AppLaunchContext>)
3543 .map_err(|err| format!("Could not open {}: {err}", path.display()))?;
3544 Ok(app.display_name().to_string())
3545}
3546
3547fn content_type_for(path: &Path) -> Option<glib::GString> {
3554 let file = gio::File::for_path(path);
3555 let info = file
3556 .query_info(
3557 gio::FILE_ATTRIBUTE_STANDARD_CONTENT_TYPE,
3558 gio::FileQueryInfoFlags::NONE,
3559 None::<&gio::Cancellable>,
3560 )
3561 .ok()?;
3562 info.content_type()
3563}
3564
3565fn default_app_label(path: &Path) -> Option<String> {
3569 let content_type = content_type_for(path)?;
3570 gio::AppInfo::default_for_type(&content_type, false).map(|app| {
3571 format!("Open With {}", app.display_name())
3572 })
3573}
3574
3575#[allow(deprecated)] fn open_with_dialog(parent: >k::Window, path: &Path, sender: &ComponentSender<Tree>) {
3579 let Some(content_type) = content_type_for(path) else {
3580 let _ = sender.output(TreeOutput::Status(format!(
3581 "Could not determine the type of {}",
3582 path.display()
3583 )));
3584 return;
3585 };
3586 let dialog = gtk::AppChooserDialog::for_content_type(
3587 Some(parent),
3588 gtk::DialogFlags::MODAL,
3589 &content_type,
3590 );
3591 dialog.set_title(Some(&format!("Open {} with...", path.display())));
3592
3593 if gtk4_layer_shell::is_supported() && !dialog.is_layer_window() {
3599 dialog.init_layer_shell();
3600 dialog.set_layer(Layer::Overlay);
3601 dialog.set_keyboard_mode(KeyboardMode::Exclusive);
3602 dialog.set_default_size(540, 620);
3603 }
3605
3606 let dialog = dialog.clone();
3607 let path = path.to_path_buf();
3608 let s = sender.clone();
3609 dialog.connect_response(move |dialog, response| {
3610 if response == gtk::ResponseType::Ok
3611 && let Some(app) = dialog.app_info()
3612 {
3613 let file = gio::File::for_path(&path);
3614 match app.launch(&[file], None::<&gio::AppLaunchContext>) {
3615 Ok(()) => {
3616 let _ = s.output(TreeOutput::Status(format!(
3617 "Opened {} with {}",
3618 path.display(),
3619 app.display_name()
3620 )));
3621 }
3622 Err(err) => {
3623 let _ = s.output(TreeOutput::Status(format!(
3624 "Could not open {} with {}: {err}",
3625 path.display(),
3626 app.display_name()
3627 )));
3628 }
3629 }
3630 }
3631 dialog.close();
3632 });
3633 dialog.present();
3634}
3635
3636fn set_clipboard_text(text: &str) {
3638 if let Some(display) = gdk::Display::default() {
3639 display.clipboard().set_text(text);
3640 }
3641}
3642
3643const URI_LIST_MIME: &str = "text/uri-list";
3646
3647const GNOME_CLIP_MIME: &str = "x-special/gnome-copied-files";
3651
3652fn read_clipboard_files(done: impl FnOnce(Vec<PathBuf>) + 'static) {
3657 let Some(display) = gdk::Display::default() else {
3658 done(Vec::new());
3659 return;
3660 };
3661 let clipboard = display.clipboard();
3662 let formats = clipboard.formats();
3663 let has_uri = formats.contain_mime_type(URI_LIST_MIME);
3664 let has_gnome = formats.contain_mime_type(GNOME_CLIP_MIME);
3665 if !has_uri && !has_gnome {
3666 done(Vec::new());
3667 return;
3668 }
3669 clipboard.read_text_async(gio::Cancellable::NONE, move |result| {
3670 let text = result.ok().flatten().unwrap_or_default();
3671 done(parse_clipboard_uris(&text, has_gnome && !has_uri));
3672 });
3673}
3674
3675fn parse_clipboard_uris(text: &str, verb_line: bool) -> Vec<PathBuf> {
3681 text.lines()
3682 .map(str::trim)
3683 .filter(|line| !line.is_empty() && !line.starts_with('#'))
3684 .filter_map(|line| {
3685 if verb_line && (line.eq_ignore_ascii_case("copy") || line.eq_ignore_ascii_case("cut")) {
3686 return None;
3687 }
3688 let (path, _host) = glib::filename_from_uri(line).ok()?;
3689 path.is_absolute().then_some(path)
3690 })
3691 .collect()
3692}
3693
3694fn spawn_command(cmd: &[String]) -> Result<(), std::io::Error> {
3695 if cmd.is_empty() {
3696 return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "empty command"));
3697 }
3698 let mut process = std::process::Command::new(&cmd[0]);
3699 for arg in &cmd[1..] {
3700 process.arg(arg);
3701 }
3702 process.spawn().map(|_| ())
3703}
3704
3705#[cfg(test)]
3706mod tests {
3707 use super::*;
3708
3709 #[test]
3710 fn media_detection_is_extension_based_and_case_insensitive() {
3711 for name in ["a.JPG", "b.jpeg", "c.png", "d.webp", "e.HEIC", "f.tiff"] {
3712 assert_eq!(media_kind(Path::new(name)), Some(MediaKind::Image), "{name}");
3713 }
3714 for name in ["loop.GIF", "x.gif"] {
3715 assert_eq!(media_kind(Path::new(name)), Some(MediaKind::Gif), "{name}");
3716 }
3717 for name in ["clip.mp4", "b.MKV", "c.webm", "d.mov", "e.OGV"] {
3718 assert_eq!(media_kind(Path::new(name)), Some(MediaKind::Video), "{name}");
3719 }
3720 for name in ["song.MP3", "b.flac", "c.wav", "d.m4a", "e.opus", "f.oga"] {
3721 if cfg!(feature = "audio") {
3722 assert_eq!(media_kind(Path::new(name)), Some(MediaKind::Audio), "{name}");
3723 assert!(is_thumbnailable(Path::new(name)), "{name}");
3724 } else {
3725 assert_eq!(media_kind(Path::new(name)), None, "{name}");
3727 }
3728 }
3729 for name in ["a.svg", "b.txt", "c.rs", "noext", "d.png.bak"] {
3730 assert_eq!(media_kind(Path::new(name)), None, "{name}");
3731 assert!(!is_thumbnailable(Path::new(name)), "{name}");
3732 }
3733 }
3734
3735 #[test]
3736 fn thumbnail_width_tracks_the_panel_and_stays_positive() {
3737 let panel = PanelConfig { width: 300, margin: 0, ..PanelConfig::default() };
3738 assert_eq!(thumbnail_content_width(panel), 276);
3739
3740 let panel = PanelConfig { width: 500, margin: 10, ..PanelConfig::default() };
3741 assert_eq!(thumbnail_content_width(panel), 456);
3742
3743 let tiny = PanelConfig { width: 40, margin: 0, ..PanelConfig::default() };
3744 assert_eq!(thumbnail_content_width(tiny), 64);
3745 }
3746
3747 #[test]
3748 fn accelerator_normalization_supports_both_syntaxes() {
3749 assert_eq!(normalize_accelerator("Ctrl+x"), "<Control>x");
3751 assert_eq!(normalize_accelerator("Ctrl+Shift+m"), "<Control><Shift>m");
3752 assert_eq!(normalize_accelerator("Shift+Delete"), "<Shift>Delete");
3753 assert_eq!(normalize_accelerator("ctrl+alt+t"), "<Control><Alt>t");
3754 assert_eq!(normalize_accelerator("<Control>x"), "<Control>x");
3756 assert_eq!(normalize_accelerator("F2"), "F2");
3757 assert_eq!(normalize_accelerator("Delete"), "Delete");
3758 }
3759
3760 fn row(path: &str) -> VisibleRow {
3761 VisibleRow {
3762 path: PathBuf::from(path),
3763 name: path.rsplit('/').next().unwrap().to_string(),
3764 depth: 0,
3765 is_dir: false,
3766 is_symlink: false,
3767 expanded: false,
3768 has_children: false,
3769 matches: false,
3770 }
3771 }
3772
3773 #[test]
3774 fn parse_clipboard_uris_handles_both_formats() {
3775 let uri_list = "#comment\r\nfile:///tmp/a.txt\r\n\r\nfile:///tmp/b%20c.txt\r\n";
3777 assert_eq!(
3778 parse_clipboard_uris(uri_list, false),
3779 vec![PathBuf::from("/tmp/a.txt"), PathBuf::from("/tmp/b c.txt")]
3780 );
3781 let gnome = "cut\nfile:///home/u/x\n";
3783 assert_eq!(parse_clipboard_uris(gnome, true), vec![PathBuf::from("/home/u/x")]);
3784 assert_eq!(parse_clipboard_uris("copy\n", false), Vec::<PathBuf>::new());
3786 assert_eq!(
3788 parse_clipboard_uris("https://example.com/x\nrelative/path\nfile:///ok\n", false),
3789 vec![PathBuf::from("/ok")]
3790 );
3791 assert!(parse_clipboard_uris("", false).is_empty());
3792 }
3793
3794 #[test]
3795 fn icon_name_matches_extensions_case_insensitively() {
3796 assert_eq!(icon_name(&row("/a/Photo.JPG")), "image-x-generic-symbolic");
3797 assert_eq!(icon_name(&row("/a/song.Flac")), "audio-x-generic-symbolic");
3798 assert_eq!(icon_name(&row("/a/clip.mkv")), "video-x-generic-symbolic");
3799 assert_eq!(icon_name(&row("/a/archive.tar")), "package-x-generic-symbolic");
3800 assert_eq!(icon_name(&row("/a/doc.pdf")), "application-pdf-symbolic");
3801 assert_eq!(icon_name(&row("/a/notes.txt")), "text-x-generic-symbolic");
3802 let mut dir = row("/a/pictures");
3804 dir.is_dir = true;
3805 assert_eq!(icon_name(&dir), "folder-symbolic");
3806 dir.expanded = true;
3807 assert_eq!(icon_name(&dir), "folder-open-symbolic");
3808 assert_eq!(icon_name(&row("/a/.bashrc")), "text-x-generic-symbolic");
3810 }
3811
3812 #[test]
3813 fn resolve_drop_filters_noops_and_detects_cycles() {
3814 let target = Path::new("/a/b");
3815 assert_eq!(resolve_drop(target, vec![PathBuf::from("/a/b")], false), DropPlan::Ignore);
3817 assert_eq!(resolve_drop(target, vec![PathBuf::from("/a/b/c")], false), DropPlan::Ignore);
3818 assert!(matches!(
3820 resolve_drop(target, vec![PathBuf::from("/a")], false),
3821 DropPlan::Reject(_)
3822 ));
3823 assert!(matches!(
3825 resolve_drop(target, vec![PathBuf::from("/x/y")], false),
3826 DropPlan::Move(_)
3827 ));
3828 assert!(matches!(
3829 resolve_drop(target, vec![PathBuf::from("/x/y")], true),
3830 DropPlan::Copy(_)
3831 ));
3832 }
3833
3834 #[test]
3835 fn drag_paths_uses_the_selection_only_when_the_row_is_in_it() {
3836 let sel = vec![PathBuf::from("/r/a"), PathBuf::from("/r/b")];
3837 assert_eq!(drag_paths(&sel, Path::new("/r/b")), sel);
3839 assert_eq!(
3841 drag_paths(&sel, Path::new("/r/c")),
3842 vec![PathBuf::from("/r/c")]
3843 );
3844 assert_eq!(
3846 drag_paths(&[PathBuf::from("/r/a")], Path::new("/r/a")),
3847 vec![PathBuf::from("/r/a")]
3848 );
3849 assert_eq!(
3851 drag_paths(&[], Path::new("/r/z")),
3852 vec![PathBuf::from("/r/z")]
3853 );
3854 }
3855
3856 #[test]
3857 fn index_range_is_inclusive_and_order_independent() {
3858 assert_eq!(index_range(2, 2).collect::<Vec<_>>(), vec![2]);
3859 assert_eq!(index_range(1, 4).collect::<Vec<_>>(), vec![1, 2, 3, 4]);
3860 assert_eq!(index_range(4, 1).collect::<Vec<_>>(), vec![1, 2, 3, 4]);
3862 }
3863
3864 #[test]
3865 fn format_uri_list_serializes_file_uris() {
3866 let text = format_uri_list(&[PathBuf::from("/tmp/a.txt"), PathBuf::from("/tmp/b c.txt")]);
3867 assert_eq!(text, "file:///tmp/a.txt\r\nfile:///tmp/b%20c.txt\r\n");
3868 assert!(format_uri_list(&[]).is_empty());
3869 assert_eq!(
3871 parse_clipboard_uris(&text, false),
3872 vec![PathBuf::from("/tmp/a.txt"), PathBuf::from("/tmp/b c.txt")]
3873 );
3874 }
3875
3876 #[test]
3877 fn typeahead_finds_the_next_matching_row_and_wraps() {
3878 let rows = vec![row("/r/apple"), row("/r/banana"), row("/r/apricot"), row("/r/cherry")];
3879 assert_eq!(typeahead_target(&rows, "ap", None), Some(0));
3882 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);
3885 }
3886
3887 #[test]
3888 fn typeahead_buffer_resets_after_a_pause_and_caps_length() {
3889 let mut ta = TypeAhead::default();
3890 let t0 = std::time::Instant::now();
3891 assert_eq!(ta.push('a', t0).as_deref(), Some("a"));
3892 assert_eq!(ta.push('b', t0 + Duration::from_millis(100)).as_deref(), Some("ab"));
3893 assert_eq!(ta.push('c', t0 + Duration::from_millis(2000)).as_deref(), Some("c"));
3895 assert_eq!(ta.push('\u{1}', t0), None);
3897 }
3898}