1use std::cell::{Cell, RefCell};
19use std::collections::{HashMap, HashSet};
20use std::path::{Path, PathBuf};
21use std::rc::{Rc, Weak};
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::meta::format_size;
30use crate::fs::model::{Change, SortKey, StdDirSource, TreeModel, VisibleRow};
31use crate::fs::ops::FileOps;
32use crate::fs::watcher::{RecursiveMode, RecommendedWatcher, Watcher, spawn as spawn_watcher};
33use crate::highlight::{self, Span, TokenClass};
34use crate::preview::{
35 self, ArchiveData, DocumentData, DocumentLines, ParseStatus, PreviewKind, TableData,
36};
37use gtk4_layer_shell::{KeyboardMode, Layer, LayerShell};
38use relm4::gtk::{gdk, gio, glib, pango, prelude::*};
39use relm4::prelude::*;
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43enum ClipboardOp {
44 Copy,
45 Cut,
46}
47
48#[derive(Debug, Default)]
52struct TypeAhead {
53 prefix: String,
55 last: Option<std::time::Instant>,
58}
59
60const TYPEAHEAD_TIMEOUT: Duration = Duration::from_millis(700);
62const TYPEAHEAD_MAX: usize = 32;
64
65impl TypeAhead {
66 fn push(&mut self, ch: char, now: std::time::Instant) -> Option<String> {
69 if !ch.is_alphanumeric() && ch != '_' && ch != '-' && ch != '.' && ch != ' ' {
70 return None;
71 }
72 let expired = self
73 .last
74 .is_some_and(|last| now.duration_since(last) > TYPEAHEAD_TIMEOUT);
75 if expired {
76 self.prefix.clear();
77 }
78 if self.prefix.len() >= TYPEAHEAD_MAX {
79 self.prefix.clear();
80 }
81 self.prefix.push(ch.to_ascii_lowercase());
82 self.last = Some(now);
83 Some(self.prefix.clone())
84 }
85
86 fn clear(&mut self) {
88 self.prefix.clear();
89 self.last = None;
90 }
91}
92
93fn typeahead_target(rows: &[VisibleRow], prefix: &str, cursor: Option<usize>) -> Option<usize> {
96 if prefix.is_empty() || rows.is_empty() {
97 return None;
98 }
99 let len = rows.len();
100 let start = cursor.map_or(0, |c| (c + 1) % len);
101 for offset in 0..len {
102 let index = (start + offset) % len;
103 if rows[index].name.to_ascii_lowercase().starts_with(prefix) {
104 return Some(index);
105 }
106 }
107 None
108}
109
110#[derive(Debug, Clone)]
111struct Clipboard {
112 op: ClipboardOp,
113 paths: Vec<PathBuf>,
114}
115
116#[derive(Debug, Clone, PartialEq, Eq)]
119enum DropPlan {
120 Ignore,
122 Copy(Vec<PathBuf>),
124 Move(Vec<PathBuf>),
126 Reject(String),
128}
129
130fn resolve_drop(target: &Path, sources: Vec<PathBuf>, copy: bool) -> DropPlan {
133 let sources: Vec<PathBuf> = sources
134 .into_iter()
135 .filter(|src| src != target && src.parent() != Some(target))
136 .collect();
137 if sources.is_empty() {
138 return DropPlan::Ignore;
139 }
140 if sources.iter().any(|src| target.starts_with(src)) {
141 return DropPlan::Reject("Cannot move a folder into itself".to_string());
142 }
143 if copy { DropPlan::Copy(sources) } else { DropPlan::Move(sources) }
144}
145
146fn index_range(a: usize, b: usize) -> std::ops::RangeInclusive<usize> {
149 if a <= b { a..=b } else { b..=a }
150}
151
152fn drag_paths(selected: &[PathBuf], pressed: &Path) -> Vec<PathBuf> {
157 if selected.len() > 1 && selected.iter().any(|p| p == pressed) {
158 selected.to_vec()
159 } else {
160 vec![pressed.to_path_buf()]
161 }
162}
163
164fn format_uri_list(paths: &[PathBuf]) -> String {
167 let mut out = String::new();
168 for path in paths {
169 if let Ok(uri) = glib::filename_to_uri(path, None) {
170 out.push_str(&uri);
171 out.push_str("\r\n");
172 }
173 }
174 out
175}
176
177fn provider_for_paths(paths: &[PathBuf]) -> gdk::ContentProvider {
182 let files: Vec<gio::File> = paths.iter().map(gio::File::for_path).collect();
183 let list = gdk::FileList::from_array(&files);
184 let native = gdk::ContentProvider::for_value(&list.to_value());
185 let uris = format_uri_list(paths);
186 let bytes = glib::Bytes::from(uris.as_bytes());
187 let text = gdk::ContentProvider::for_bytes(URI_LIST_MIME, &bytes);
188 gdk::ContentProvider::new_union(&[native, text])
189}
190
191pub(crate) fn past_drag_threshold(row: >k::Widget, start: (f64, f64), now: (f64, f64)) -> bool {
198 let threshold = row
199 .settings()
200 .gtk_dnd_drag_threshold()
201 .max(1) as f64;
202 (now.0 - start.0).abs() > threshold || (now.1 - start.1).abs() > threshold
203}
204
205fn drag_badge(count: usize) -> gtk::Widget {
208 let label = gtk::Label::new(Some(&if count <= 1 {
209 "1 item".to_owned()
210 } else {
211 format!("{count} items")
212 }));
213 label.add_css_class("tree-drag-badge");
214 label.upcast()
215}
216
217#[derive(Debug, Clone)]
221struct DragOrigin {
222 x: f64,
224 y: f64,
225}
226
227pub(crate) fn begin_row_drag(
240 widget: >k::Widget,
241 paths: &[PathBuf],
242 start: (f64, f64),
243 actions: gdk::DragAction,
244 on_finished: impl Fn(gdk::Drag, bool) + 'static,
245 on_cancelled: impl Fn(gdk::Drag, gdk::DragCancelReason) + 'static,
246) -> bool {
247 let Some(native) = widget.native() else {
248 return false;
249 };
250 let Some(surface) = native.surface() else {
251 return false;
252 };
253 let Some(device) = widget
254 .display()
255 .default_seat()
256 .and_then(|seat| seat.pointer())
257 else {
258 return false;
259 };
260
261 let (dx, dy) = match (
264 surface.device_position(&device),
265 widget.compute_point(
266 &native,
267 &relm4::gtk::graphene::Point::new(start.0 as f32, start.1 as f32),
268 ),
269 ) {
270 (Some((px, py, _)), Some(p)) => ((px - p.x() as f64).round(), (py - p.y() as f64).round()),
271 _ => (0.0, 0.0),
272 };
273
274 let content = provider_for_paths(paths);
275 let Some(drag) = gdk::Drag::begin(&surface, &device, &content, actions, dx, dy) else {
276 return false;
277 };
278
279 let icon = gtk::DragIcon::for_drag(&drag);
280 icon.set_child(Some(&drag_badge(paths.len())));
281
282 {
283 let on_finished = Rc::new(on_finished);
284 let drag = drag.clone();
285 drag.connect_dnd_finished(move |drag| {
286 let delete = drag.selected_action() == gdk::DragAction::MOVE;
287 on_finished(drag.clone(), delete);
288 });
289 }
290 {
291 let on_cancelled = Rc::new(on_cancelled);
292 let drag = drag.clone();
293 drag.connect_cancel(move |drag, reason| on_cancelled(drag.clone(), reason));
294 }
295 true
296}
297
298pub struct TreeInit {
300 pub config: TreeConfig,
302 pub parent: gtk::Window,
304 pub menu: ContextMenu,
306 pub side: PanelSide,
309 pub panel: PanelConfig,
311}
312
313#[derive(Debug, Clone)]
315pub enum TreeMsg {
316 OpenRoot(PathBuf),
317 SelectPath(PathBuf),
321 Focus,
324 SetFilter(String),
325 SetPanel(PanelConfig),
328 SetSide(PanelSide),
331
332 MoveUp,
333 MoveDown,
334 ExtendSelection(isize),
338 MoveExtendingSelection(isize),
343 CollapseCursor,
344 ExpandCursor,
345 ActivateCursor,
346 Rename,
347
348 Activate(PathBuf),
349 Toggle(PathBuf),
350 Select(PathBuf),
351 ToggleSelect(PathBuf),
354 RangeSelect(PathBuf),
357 RowPress { path: PathBuf, is_dir: bool, ctrl: bool, shift: bool },
362 RowRelease { path: PathBuf },
367 DragStarted,
370 DroppedAsMove(Vec<PathBuf>),
374 Deselect,
377 Menu(PathBuf),
378 MenuAt { x: f64, y: f64 },
381 RenameAt(PathBuf),
382 Trash(PathBuf),
385 DeleteSelected,
387 ConfirmTrash(Vec<PathBuf>),
388 PermanentDelete(PathBuf),
390 PermanentDeleteSelected,
392 ConfirmPermanentDelete(Vec<PathBuf>),
394 RunCommand { command: String, path: PathBuf },
397 OpenSplit(PathBuf),
399 OpenInOppositePanel(PathBuf),
401 OpenWith(PathBuf),
403 OpenWithDefault(PathBuf),
405 CreateLink(PathBuf),
407 Properties(PathBuf),
409 PropertiesSelected(Vec<PathBuf>),
412 CopyPaths(Vec<PathBuf>),
415 CopyRelativePaths(Vec<PathBuf>),
418 RunCommandEach { command: String, paths: Vec<PathBuf> },
421 RenameTo { path: PathBuf, name: String },
424 ToggleThumbnail(PathBuf),
427 ToggleThumbnailPlay(PathBuf),
429 RunShortcut { target: ShortcutTarget },
431 RunAccelerator(String),
435 Duplicate(PathBuf),
436 RequestDuplicateCursor,
438 SelectAllRows,
440 TypeAhead(char),
443 CopyPath(PathBuf),
444 CopyRelativePath(PathBuf),
445 AddBookmark(PathBuf),
447
448 RenameCommit,
449 RenameCancel,
450
451 Copy,
452 Cut,
453 Paste,
454 PastePaths(Vec<PathBuf>),
457 NewFile,
458 NewFolder,
459
460 ToggleHidden,
462 SetSortKey(SortKey),
464 ToggleSortDirection,
466
467 FsChange(Change),
468 FlushChanges,
469 RequestOpenFolder,
470
471 DropInto { target: PathBuf, sources: Vec<PathBuf>, copy: bool },
476 DropIntoRoot { sources: Vec<PathBuf>, copy: bool },
479 DropIntoConfirmed { target: PathBuf, sources: Vec<PathBuf> },
481 Suspend,
486 Shutdown,
488 Noop,
491}
492
493#[derive(Debug)]
495pub enum TreeOutput {
496 Status(String),
498 RootChanged(PathBuf),
500 OpenFolderRequested,
502 OpenSplit(PathBuf),
504 OpenOpposite(PathBuf),
506 PaneAction(BuiltinAction),
510 AddBookmark(PathBuf),
512}
513
514pub struct Tree {
516 tree: Option<TreeModel>,
517 ops: FileOps,
518 config: TreeConfig,
519 parent: gtk::Window,
520 menu: ContextMenu,
521 side: PanelSide,
522
523 thumbnails: HashSet<PathBuf>,
527 preview_kinds: HashMap<PathBuf, Option<PreviewKind>>,
531 documents: HashMap<PathBuf, Option<DocumentData>>,
535 thumb_cache: HashMap<PathBuf, gdk::Texture>,
538 media: HashMap<PathBuf, gtk::MediaFile>,
541 video_widgets: HashMap<PathBuf, gtk::Video>,
546 audio: HashMap<PathBuf, Rc<AudioPlayer>>,
549 playing: HashSet<PathBuf>,
551 gif_anims: Rc<RefCell<HashMap<PathBuf, GifAnim>>>,
553 gif_widgets: Rc<RefCell<HashMap<PathBuf, gtk::Picture>>>,
556 gif_tick: Rc<RefCell<Option<glib::SourceId>>>,
558 audio_widgets: Rc<RefCell<HashMap<PathBuf, AudioWidgets>>>,
562 audio_tick: Rc<RefCell<Option<glib::SourceId>>>,
564 content_width: i32,
566
567 rows: Vec<VisibleRow>,
568 cursor: Option<usize>,
569 selected: Vec<PathBuf>,
570 anchor: Option<PathBuf>,
574 last_press: Option<(PathBuf, std::time::Instant)>,
578
579 renaming: Option<PathBuf>,
580 rename_entry: Option<gtk::Entry>,
581 clipboard: Option<Clipboard>,
582
583 pending: Vec<Change>,
584 flush_source: Option<glib::SourceId>,
585 _watcher: Option<RecommendedWatcher>,
586 watched: HashSet<PathBuf>,
589
590 popover: Option<gtk::Popover>,
593 menu_target: Option<PathBuf>,
597 menu_point: Option<(f64, f64)>,
600
601 renaming_state: Rc<Cell<bool>>,
603
604 selection_dirty: bool,
609 typeahead: TypeAhead,
612 scroll_to: Option<usize>,
615 focus_pending: bool,
619
620 drag_payload: Rc<RefCell<Vec<PathBuf>>>,
625 drag_active: Rc<Cell<bool>>,
629 pending_click: Option<PathBuf>,
633 drag_landed_internal: Rc<Cell<bool>>,
637 drag_origin: Rc<RefCell<Option<DragOrigin>>>,
641 drag_button_down: Rc<Cell<bool>>,
646}
647
648#[relm4::component(pub)]
649impl Component for Tree {
650 type Init = TreeInit;
651 type Input = TreeMsg;
652 type Output = TreeOutput;
653 type CommandOutput = ();
654
655 view! {
656 scrolled = gtk::ScrolledWindow {
657 set_vexpand: true,
658 set_hexpand: true,
659 set_min_content_width: 160,
660 add_css_class: "tree-scroll",
661
662 #[name = "list"]
663 gtk::ListBox {
664 set_selection_mode: gtk::SelectionMode::None,
665 set_activate_on_single_click: false,
666 }
667 }
668 }
669
670 fn init(
671 init: Self::Init,
672 root: Self::Root,
673 sender: ComponentSender<Self>,
674 ) -> ComponentParts<Self> {
675 let renaming_state = Rc::new(Cell::new(false));
676 let keys = compile_shortcuts(&init.menu);
677 let widgets = view_output!();
678
679 let controller = gtk::EventControllerKey::new();
680 controller.set_propagation_phase(gtk::PropagationPhase::Capture);
681 let capture_sender = sender.clone();
682 let capture_state = renaming_state.clone();
683 let capture_keys = keys.clone();
684 let capture_focus = widgets.scrolled.clone();
685 controller.connect_key_pressed(move |_ctrl, key, _keycode, state| {
686 let preview_focused = capture_focus
690 .root()
691 .and_then(|root| gtk::prelude::RootExt::focus(&root))
692 .is_some_and(|focus| focus.has_css_class("tree-preview-text"));
693 handle_key(
694 key,
695 state,
696 capture_state.get(),
697 preview_focused,
698 &capture_keys,
699 &capture_sender,
700 )
701 });
702 widgets.scrolled.add_controller(controller);
703
704 let root_drop = gtk::DropTarget::new(gdk::FileList::static_type(), gdk::DragAction::MOVE | gdk::DragAction::COPY);
710 let root_drop_sender = sender.clone();
711 root_drop.connect_drop(move |target, value, _x, _y| {
712 let Ok(list) = value.get::<gdk::FileList>() else {
713 return false;
714 };
715 let sources: Vec<PathBuf> = list.files().iter().filter_map(gio::File::path).collect();
716 if sources.is_empty() {
717 return false;
718 }
719 let copy = target.current_event_state().contains(gdk::ModifierType::CONTROL_MASK);
720 root_drop_sender.input(TreeMsg::DropIntoRoot { sources, copy });
721 true
722 });
723 widgets.list.add_controller(root_drop);
724
725 let blank_scrolled = widgets.scrolled.clone();
731 let blank_click = gtk::GestureClick::new();
732 blank_click.set_button(0);
733 blank_click.set_propagation_phase(gtk::PropagationPhase::Capture);
734 let blank_sender = sender.clone();
735 blank_click.connect_pressed(move |gesture, _n_press, x, y| {
736 if gesture.current_button() == 3
739 && gesture.current_event_state().contains(gdk::ModifierType::SUPER_MASK)
740 {
741 gesture.set_state(gtk::EventSequenceState::Denied);
742 return;
743 }
744 let on_row = blank_scrolled
745 .pick(x, y, gtk::PickFlags::DEFAULT)
746 .is_some_and(|widget| in_list_row(&widget));
747 if on_row {
748 return;
749 }
750 match gesture.current_button() {
751 1 => blank_sender.input(TreeMsg::Deselect),
752 3 => blank_sender.input(TreeMsg::MenuAt { x, y }),
753 _ => {}
754 }
755 });
756 widgets.scrolled.add_controller(blank_click);
757
758 let model = Tree {
759 tree: None,
760 ops: FileOps::new(),
761 config: init.config,
762 parent: init.parent,
763 menu: init.menu,
764 side: init.side,
765 thumbnails: HashSet::new(),
766 preview_kinds: HashMap::new(),
767 documents: HashMap::new(),
768 thumb_cache: HashMap::new(),
769 media: HashMap::new(),
770 video_widgets: HashMap::new(),
771 audio: HashMap::new(),
772 playing: HashSet::new(),
773 gif_anims: Rc::new(RefCell::new(HashMap::new())),
774 gif_widgets: Rc::new(RefCell::new(HashMap::new())),
775 gif_tick: Rc::new(RefCell::new(None)),
776 audio_widgets: Rc::new(RefCell::new(HashMap::new())),
777 audio_tick: Rc::new(RefCell::new(None)),
778 content_width: thumbnail_content_width(init.panel),
779 rows: Vec::new(),
780 cursor: None,
781 selected: Vec::new(),
782 anchor: None,
783 last_press: None,
784 renaming: None,
785 rename_entry: None,
786 clipboard: None,
787 pending: Vec::new(),
788 flush_source: None,
789 _watcher: None,
790 watched: HashSet::new(),
791 popover: None,
792 menu_target: None,
793 menu_point: None,
794 renaming_state,
795 selection_dirty: false,
796 typeahead: TypeAhead::default(),
797 scroll_to: None,
798 focus_pending: false,
799 drag_payload: Rc::new(RefCell::new(Vec::new())),
800 drag_active: Rc::new(Cell::new(false)),
801 pending_click: None,
802 drag_landed_internal: Rc::new(Cell::new(false)),
803 drag_origin: Rc::new(RefCell::new(None)),
804 drag_button_down: Rc::new(Cell::new(false)),
805 };
806
807 {
817 let legacy = gtk::EventControllerLegacy::new();
818 legacy.set_propagation_phase(gtk::PropagationPhase::Capture);
819 let origin = model.drag_origin.clone();
820 let payload = model.drag_payload.clone();
821 let active = model.drag_active.clone();
822 let landed = model.drag_landed_internal.clone();
823 let down = model.drag_button_down.clone();
824 let drag_root = widgets.scrolled.clone();
825 let s = sender.clone();
826 legacy.connect_event(move |_controller, event| {
827 match event.event_type() {
828 gdk::EventType::ButtonPress => {
829 let button = event
830 .downcast_ref::<gdk::ButtonEvent>()
831 .map(|b| b.button())
832 .unwrap_or(0);
833 if button == 1
834 && let Some((x, y)) = event.position()
835 {
836 let on_text = drag_root
840 .pick(x, y, gtk::PickFlags::DEFAULT)
841 .is_some_and(|widget| in_text_preview(&widget));
842 if !on_text {
843 down.set(true);
844 *origin.borrow_mut() = Some(DragOrigin { x, y });
845 }
846 }
847 }
848 gdk::EventType::ButtonRelease => {
849 let button = event
850 .downcast_ref::<gdk::ButtonEvent>()
851 .map(|b| b.button())
852 .unwrap_or(0);
853 if button == 1 {
854 down.set(false);
855 if !active.get() {
856 *origin.borrow_mut() = None;
857 }
858 }
859 }
860 gdk::EventType::MotionNotify => {
861 if active.get() || !down.get() {
862 return glib::Propagation::Proceed;
863 }
864 let Some((x, y)) = event.position() else {
865 return glib::Propagation::Proceed;
866 };
867 let start = {
868 let guard = origin.borrow();
869 match guard.as_ref() {
870 Some(o) => (o.x, o.y),
871 None => return glib::Propagation::Proceed,
872 }
873 };
874 if !past_drag_threshold(drag_root.upcast_ref(), start, (x, y)) {
875 return glib::Propagation::Proceed;
876 }
877 let paths = payload.borrow().clone();
878 if paths.is_empty() {
879 return glib::Propagation::Proceed;
880 }
881 *origin.borrow_mut() = None;
883 let finished = {
884 let active = active.clone();
885 let landed = landed.clone();
886 let s = s.clone();
887 let payload = payload.clone();
888 move |_drag: gdk::Drag, delete: bool| {
889 active.set(false);
890 if delete && !landed.replace(false) {
893 let paths = payload.borrow().clone();
894 if !paths.is_empty() {
895 s.input(TreeMsg::DroppedAsMove(paths));
896 }
897 }
898 }
899 };
900 let cancelled = {
901 let active = active.clone();
902 let landed = landed.clone();
903 move |_drag: gdk::Drag, _reason: gdk::DragCancelReason| {
904 active.set(false);
905 landed.set(false);
906 }
907 };
908 if begin_row_drag(
909 drag_root.upcast_ref(),
910 &paths,
911 start,
912 gdk::DragAction::MOVE | gdk::DragAction::COPY,
913 finished,
914 cancelled,
915 ) {
916 active.set(true);
917 s.input(TreeMsg::DragStarted);
918 }
919 }
920 _ => {}
921 }
922 glib::Propagation::Proceed
923 });
924 widgets.scrolled.add_controller(legacy);
925 }
926
927 ComponentParts { model, widgets }
928 }
929
930 fn update_with_view(
931 &mut self,
932 widgets: &mut Self::Widgets,
933 message: Self::Input,
934 sender: ComponentSender<Self>,
935 root: &Self::Root,
936 ) {
937 let _ = root;
938 if let TreeMsg::Focus = message {
941 if widgets.list.row_at_index(0).is_none() {
945 self.focus_pending = true;
948 return;
949 }
950 let index = self.cursor.unwrap_or(0) as i32;
951 if let Some(row) = widgets
952 .list
953 .row_at_index(index)
954 .or_else(|| widgets.list.row_at_index(0))
955 {
956 row.grab_focus();
957 } else if !widgets.list.grab_focus() {
958 widgets.scrolled.grab_focus();
959 }
960 return;
961 }
962 self.handle_message(message, sender.clone());
963 if let Some(index) = self.scroll_to.take() {
967 scroll_row_into_view(widgets, index);
968 }
969 self.reconcile_watches();
973 if self.selection_dirty {
974 self.selection_dirty = false;
977 if update_selection_classes(self, widgets) {
978 return;
979 }
980 }
981 <Self as Component>::update_view(self, widgets, sender.clone());
982 rebuild(self, widgets, sender);
983 if self.focus_pending && widgets.list.row_at_index(0).is_some() {
986 self.focus_pending = false;
987 let index = self.cursor.unwrap_or(0) as i32;
988 if let Some(row) = widgets
989 .list
990 .row_at_index(index)
991 .or_else(|| widgets.list.row_at_index(0))
992 {
993 row.grab_focus();
994 }
995 }
996 }
997}
998
999impl Tree {
1000 fn handle_message(&mut self, msg: TreeMsg, sender: ComponentSender<Self>) {
1001 match msg {
1002 TreeMsg::OpenRoot(path) => self.open_root(&path, &sender),
1003 TreeMsg::SelectPath(path) => self.reveal_path(path),
1004 TreeMsg::Focus => {}
1006 TreeMsg::SetFilter(filter) => {
1007 if let Some(tree) = self.tree.as_mut() {
1008 tree.set_filter(&filter);
1009 self.refresh_rows();
1010 }
1011 }
1012 TreeMsg::SetPanel(panel) => self.content_width = thumbnail_content_width(panel),
1013 TreeMsg::SetSide(side) => self.side = side,
1014 TreeMsg::Suspend => self.suspend_media(),
1015 TreeMsg::Shutdown => self.shutdown(),
1016 TreeMsg::Noop => {}
1017
1018 TreeMsg::MoveUp => self.cursor_delta(-1),
1019 TreeMsg::MoveDown => self.cursor_delta(1),
1020 TreeMsg::ExtendSelection(delta) => self.cursor_extend(delta, false),
1021 TreeMsg::MoveExtendingSelection(delta) => self.cursor_extend(delta, true),
1022 TreeMsg::CollapseCursor => {
1023 if let Some(path) = self.cursor_path()
1024 && self.tree.as_ref().is_some_and(|t| t.is_expanded(&path))
1025 {
1026 self.collapse_dir(&path);
1027 }
1028 }
1029 TreeMsg::ExpandCursor => {
1030 if let Some(path) = self.cursor_path() {
1031 self.expand_dir(&path, &sender);
1032 }
1033 }
1034 TreeMsg::ActivateCursor => {
1035 if let Some(path) = self.cursor_path() {
1036 self.activate(&path, &sender);
1037 }
1038 }
1039 TreeMsg::Rename => {
1040 if let Some(path) = self.cursor_path() {
1041 self.renaming = Some(path);
1042 self.renaming_state.set(true);
1043 }
1044 }
1045
1046 TreeMsg::Activate(path) => self.activate(&path, &sender),
1047 TreeMsg::Toggle(path) => self.toggle_dir(&path, &sender),
1048 TreeMsg::Select(path) => {
1049 self.select(&path);
1050 self.anchor = Some(path);
1051 self.selection_dirty = true;
1052 }
1053 TreeMsg::ToggleSelect(path) => {
1054 if let Some(i) = self.rows.iter().position(|r| r.path == path) {
1055 self.cursor = Some(i);
1056 }
1057 if let Some(i) = self.selected.iter().position(|p| p == &path) {
1058 self.selected.remove(i);
1059 } else {
1060 self.selected.push(path.clone());
1061 }
1062 self.anchor = Some(path);
1063 self.selection_dirty = true;
1064 }
1065 TreeMsg::RangeSelect(path) => {
1066 self.range_select(&path);
1067 self.selection_dirty = true;
1068 }
1069 TreeMsg::RowPress { path, is_dir, ctrl, shift } => {
1070 if ctrl {
1075 sender.input(TreeMsg::ToggleSelect(path));
1076 return;
1077 }
1078 if shift {
1079 sender.input(TreeMsg::RangeSelect(path));
1080 return;
1081 }
1082 *self.drag_payload.borrow_mut() = drag_paths(&self.selected, &path);
1087
1088 if self.selected.len() > 1 && self.selected.iter().any(|p| p == &path) {
1092 self.pending_click = Some(path.clone());
1093 if is_dir {
1094 self.toggle_dir(&path, &sender);
1095 }
1096 return;
1097 }
1098 self.pending_click = None;
1099 self.press_default(&path, is_dir, &sender);
1100 }
1101 TreeMsg::RowRelease { path } => {
1102 *self.drag_origin.borrow_mut() = None;
1104 self.drag_button_down.set(false);
1105 if self.drag_active.get() {
1108 return;
1109 }
1110 if self.pending_click.take().as_deref() == Some(path.as_path()) {
1111 let is_dir = self
1112 .rows
1113 .iter()
1114 .find(|r| r.path == path)
1115 .map(|r| r.is_dir)
1116 .unwrap_or(false);
1117 self.press_default(&path, is_dir, &sender);
1118 }
1119 }
1120 TreeMsg::DragStarted => {
1121 self.drag_active.set(true);
1123 self.pending_click = None;
1124 *self.drag_origin.borrow_mut() = None;
1125 self.drag_button_down.set(false);
1126 }
1127 TreeMsg::DroppedAsMove(paths) => {
1128 match self.ops.trash(&paths) {
1132 Ok(()) => {
1133 for path in &paths {
1134 self.apply_change(Change::Removed { path: path.clone() });
1135 }
1136 let n = paths.len();
1137 self.status(format!("Moved {n} item(s) out of the panel"), &sender);
1138 }
1139 Err(err) => self.status(format!("Could not finish move: {err}"), &sender),
1140 }
1141 }
1142 TreeMsg::Deselect => {
1143 self.selected.clear();
1144 self.cursor = None;
1145 self.anchor = None;
1146 self.typeahead.clear();
1147 self.selection_dirty = true;
1148 }
1149 TreeMsg::Menu(path) => self.open_menu(&path),
1150 TreeMsg::MenuAt { x, y } => {
1151 if let Some(root) = self.tree.as_ref().map(|t| t.root().to_path_buf()) {
1152 self.open_menu_at(&root, (x, y));
1153 }
1154 }
1155 TreeMsg::RenameAt(path) => {
1156 self.select(&path);
1157 self.renaming = Some(path);
1158 self.renaming_state.set(true);
1159 }
1160 TreeMsg::Trash(path) => self.confirm_trash(vec![path], sender.clone()),
1161 TreeMsg::DeleteSelected => {
1162 let paths = self.selected_for_clipboard();
1163 if !paths.is_empty() {
1164 self.confirm_trash(paths, sender.clone());
1165 }
1166 }
1167 TreeMsg::PermanentDelete(path) => {
1168 self.confirm_permanent_delete(vec![path], sender.clone());
1169 }
1170 TreeMsg::PermanentDeleteSelected => {
1171 let paths = self.selected_for_clipboard();
1172 if !paths.is_empty() {
1173 self.confirm_permanent_delete(paths, sender.clone());
1174 }
1175 }
1176 TreeMsg::ConfirmPermanentDelete(paths) => {
1177 if let Some(renaming) = self.renaming.as_ref()
1178 && paths.contains(renaming)
1179 {
1180 self.renaming = None;
1181 self.renaming_state.set(false);
1182 }
1183 match self.ops.delete_permanently(&paths) {
1184 Ok(()) => {
1185 for path in paths {
1186 self.apply_change(Change::Removed { path });
1187 }
1188 self.selected.clear();
1189 self.cursor_snap();
1190 }
1191 Err(err) => self.status(err.to_string(), &sender),
1192 }
1193 }
1194 TreeMsg::ConfirmTrash(paths) => {
1195 if let Some(renaming) = self.renaming.as_ref()
1196 && paths.contains(renaming)
1197 {
1198 self.renaming = None;
1199 self.renaming_state.set(false);
1200 }
1201 match self.ops.trash(&paths) {
1202 Ok(()) => {
1203 for path in paths {
1204 self.apply_change(Change::Removed { path });
1205 }
1206 self.selected.clear();
1207 self.cursor_snap();
1208 }
1209 Err(err) => self.status(err.to_string(), &sender),
1212 }
1213 }
1214 TreeMsg::Duplicate(path) => match self.ops.duplicate(&path) {
1215 Ok(new_path) => {
1216 self.apply_change(Change::Created { path: new_path.clone() });
1217 self.select(&new_path);
1218 }
1219 Err(err) => self.status(format!("Could not duplicate: {err}"), &sender),
1220 },
1221 TreeMsg::RequestDuplicateCursor => {
1222 if let Some(path) = self.cursor_path() {
1223 sender.input(TreeMsg::Duplicate(path));
1224 }
1225 }
1226 TreeMsg::CopyPath(path) => {
1227 set_clipboard_text(&path.display().to_string());
1228 self.status("Copied path to clipboard".to_string(), &sender);
1229 }
1230 TreeMsg::CopyRelativePath(path) => {
1231 let relative = self
1232 .tree
1233 .as_ref()
1234 .and_then(|t| path.strip_prefix(t.root()).ok())
1235 .map(|p| p.display().to_string())
1236 .unwrap_or_else(|| path.display().to_string());
1237 set_clipboard_text(&relative);
1238 self.status("Copied relative path to clipboard".to_string(), &sender);
1239 }
1240 TreeMsg::AddBookmark(path) => {
1241 let _ = sender.output(TreeOutput::AddBookmark(path));
1242 }
1243 TreeMsg::RunCommand { command, path } => {
1244 let cmd = action_command(&command, &path);
1245 match spawn_command(&cmd) {
1246 Ok(()) => self.status(format!("Ran {command} on {}", path.display()), &sender),
1247 Err(err) => self.status(format!("Could not run {command}: {err}"), &sender),
1248 }
1249 }
1250 TreeMsg::OpenSplit(path) => {
1251 let _ = sender.output(TreeOutput::OpenSplit(path));
1252 }
1253 TreeMsg::OpenInOppositePanel(path) => {
1254 let _ = sender.output(TreeOutput::OpenOpposite(path));
1255 }
1256 TreeMsg::OpenWith(path) => open_with_dialog(&self.parent.clone(), &path, &sender),
1257 TreeMsg::OpenWithDefault(path) => match open_in_app(&path) {
1258 Ok(app) => {
1259 self.status(format!("Opened {} with {app}", path.display()), &sender);
1260 }
1261 Err(err) => self.status(err, &sender),
1262 },
1263 TreeMsg::CreateLink(path) => match self.ops.create_link(&path) {
1264 Ok(new_path) => {
1265 self.apply_change(Change::Created { path: new_path.clone() });
1266 }
1267 Err(err) => self.status(format!("Could not create link: {err}"), &sender),
1268 },
1269 TreeMsg::Properties(path) => {
1270 crate::ui::props::show_properties_dialog(&self.parent.clone(), &path, &sender);
1271 }
1272 TreeMsg::PropertiesSelected(paths) => {
1273 crate::ui::props::show_multi_properties_dialog(&self.parent.clone(), &paths);
1274 }
1275 TreeMsg::CopyPaths(paths) => {
1276 let text = paths
1277 .iter()
1278 .map(|p| p.display().to_string())
1279 .collect::<Vec<_>>()
1280 .join("\n");
1281 set_clipboard_text(&text);
1282 self.status(format!("Copied {} paths to clipboard", paths.len()), &sender);
1283 }
1284 TreeMsg::CopyRelativePaths(paths) => {
1285 let root = self.tree.as_ref().map(|t| t.root().to_path_buf());
1286 let text = paths
1287 .iter()
1288 .map(|p| {
1289 root.as_deref()
1290 .and_then(|root| p.strip_prefix(root).ok())
1291 .map(|rel| rel.display().to_string())
1292 .unwrap_or_else(|| p.display().to_string())
1293 })
1294 .collect::<Vec<_>>()
1295 .join("\n");
1296 set_clipboard_text(&text);
1297 self.status(
1298 format!("Copied {} relative paths to clipboard", paths.len()),
1299 &sender,
1300 );
1301 }
1302 TreeMsg::RunCommandEach { command, paths } => {
1303 let mut failures = 0usize;
1304 for path in &paths {
1305 let cmd = action_command(&command, path);
1306 if spawn_command(&cmd).is_err() {
1307 failures += 1;
1308 }
1309 }
1310 let total = paths.len();
1311 if failures == 0 {
1312 self.status(format!("Ran {command} on {total} item(s)"), &sender);
1313 } else {
1314 self.status(
1315 format!("Ran {command} on {total} item(s); {failures} failed"),
1316 &sender,
1317 );
1318 }
1319 }
1320 TreeMsg::RenameTo { path, name } => match self.ops.rename(&path, &name) {
1321 Ok(new_path) => {
1322 self.apply_change(Change::Renamed {
1323 from: path,
1324 to: new_path.clone(),
1325 });
1326 self.select(&new_path);
1327 }
1328 Err(err) => self.status(format!("Could not rename: {err}"), &sender),
1329 },
1330 TreeMsg::ToggleThumbnail(path) => {
1331 if self.thumbnails.contains(&path) {
1332 self.clear_thumbnails_under(&path);
1334 } else {
1335 self.thumbnails.insert(path.clone());
1336 if path.is_dir() {
1338 self.expand_dir(&path, &sender);
1339 }
1340 }
1341 }
1342 TreeMsg::ToggleThumbnailPlay(path) => self.toggle_thumbnail_play(&path),
1343 TreeMsg::RunShortcut { target } => self.run_shortcut(target, &sender),
1344 TreeMsg::RunAccelerator(accel) => {
1345 if let Some((key, mods)) = parse_accelerator(&accel) {
1346 let keys = compile_shortcuts(&self.menu);
1347 if let Some(msg) = resolve_key(key, mods, &keys) {
1348 sender.input(msg);
1349 }
1350 }
1351 }
1352
1353 TreeMsg::SelectAllRows => {
1354 if !self.rows.is_empty() {
1355 self.selected = self.rows.iter().map(|r| r.path.clone()).collect();
1356 self.cursor = Some(0);
1357 self.anchor = self.rows.first().map(|r| r.path.clone());
1358 self.selection_dirty = true;
1359 }
1360 }
1361 TreeMsg::TypeAhead(ch) => {
1362 let now = std::time::Instant::now();
1363 if let Some(prefix) = self.typeahead.push(ch, now)
1364 && let Some(index) = typeahead_target(&self.rows, &prefix, self.cursor)
1365 {
1366 let path = self.rows[index].path.clone();
1367 self.cursor = Some(index);
1368 self.set_single_selection(path);
1369 self.selection_dirty = true;
1370 }
1371 }
1372
1373 TreeMsg::RenameCommit => {
1374 let text = self
1375 .rename_entry
1376 .as_ref()
1377 .map(|e| e.text().to_string())
1378 .unwrap_or_default();
1379 let Some(old) = self.renaming.take() else {
1380 return;
1381 };
1382 self.renaming_state.set(false);
1383 self.rename_entry = None;
1384 let trimmed = text.trim().to_string();
1385 let unchanged = old
1386 .file_name()
1387 .map(|n| n.to_string_lossy() == trimmed.as_str())
1388 .unwrap_or(false);
1389 if trimmed.is_empty() || unchanged {
1390 return;
1391 }
1392 match self.ops.rename(&old, &trimmed) {
1393 Ok(new_path) => {
1394 self.apply_change(Change::Renamed {
1395 from: old,
1396 to: new_path.clone(),
1397 });
1398 self.select(&new_path);
1399 }
1400 Err(err) => self.status(format!("Could not rename: {err}"), &sender),
1401 }
1402 }
1403 TreeMsg::RenameCancel => {
1404 self.renaming = None;
1405 self.renaming_state.set(false);
1406 self.rename_entry = None;
1407 }
1408
1409 TreeMsg::Copy => {
1410 let paths = self.selected_for_clipboard();
1411 if !paths.is_empty() {
1412 self.clipboard = Some(Clipboard { op: ClipboardOp::Copy, paths: paths.clone() });
1413 self.status(format!("Copying {} item(s)", paths.len()), &sender);
1414 }
1415 }
1416 TreeMsg::Cut => {
1417 let paths = self.selected_for_clipboard();
1418 if !paths.is_empty() {
1419 self.clipboard = Some(Clipboard { op: ClipboardOp::Cut, paths: paths.clone() });
1420 self.status(format!("Cutting {} item(s)", paths.len()), &sender);
1421 }
1422 }
1423 TreeMsg::Paste => self.paste(&sender),
1424 TreeMsg::PastePaths(paths) => self.paste_paths(paths, &sender),
1425
1426 TreeMsg::NewFile => self.create_entry(false, &sender),
1427 TreeMsg::NewFolder => self.create_entry(true, &sender),
1428
1429 TreeMsg::ToggleHidden => self.toggle_hidden(&sender),
1430 TreeMsg::SetSortKey(key) => self.set_sort_key(key, &sender),
1431 TreeMsg::ToggleSortDirection => self.toggle_sort_direction(&sender),
1432
1433 TreeMsg::FsChange(change) => {
1434 self.pending.push(change);
1436 if self.flush_source.is_none() {
1437 let s = sender.clone();
1438 let id = glib::timeout_add_local_once(
1439 Duration::from_millis(100),
1440 move || {
1441 s.input(TreeMsg::FlushChanges);
1442 }
1443 );
1444 self.flush_source = Some(id);
1445 }
1446 }
1447 TreeMsg::FlushChanges => {
1448 self.flush_source = None;
1449 let changes = std::mem::take(&mut self.pending);
1450 for change in changes {
1451 self.apply_change(change);
1452 }
1453 self.cursor_snap();
1454 }
1455 TreeMsg::RequestOpenFolder => {
1456 self.status("Pick a folder to browse...".to_string(), &sender);
1457 let _ = sender.output(TreeOutput::OpenFolderRequested);
1458 }
1459
1460 TreeMsg::DropInto { target, sources, copy } => {
1461 self.request_drop_into(target, sources, copy, &sender);
1462 }
1463 TreeMsg::DropIntoRoot { sources, copy } => {
1464 if let Some(root) = self.tree.as_ref().map(|t| t.root().to_path_buf()) {
1465 self.request_drop_into(root, sources, copy, &sender);
1466 }
1467 }
1468 TreeMsg::DropIntoConfirmed { target, sources } => {
1469 self.drop_into(&target, &sources, false, &sender);
1470 }
1471 }
1472 }
1473}
1474
1475fn accel_mods(m: gdk::ModifierType) -> gdk::ModifierType {
1482 m & (gdk::ModifierType::CONTROL_MASK
1483 | gdk::ModifierType::SHIFT_MASK
1484 | gdk::ModifierType::ALT_MASK
1485 | gdk::ModifierType::SUPER_MASK
1486 | gdk::ModifierType::HYPER_MASK
1487 | gdk::ModifierType::META_MASK)
1488}
1489
1490#[derive(Clone)]
1492struct KeyBinding {
1493 key: gdk::Key,
1494 mods: gdk::ModifierType,
1495 target: ShortcutTarget,
1496}
1497
1498fn compile_shortcuts(menu: &ContextMenu) -> Rc<Vec<KeyBinding>> {
1502 let mut out = Vec::new();
1503 for (accel, target) in menu.shortcuts() {
1504 if let Some((key, mods)) = parse_accelerator(&accel) {
1505 out.push(KeyBinding { key, mods, target });
1506 }
1507 }
1508 Rc::new(out)
1509}
1510
1511fn parse_accelerator(accel: &str) -> Option<(gdk::Key, gdk::ModifierType)> {
1517 gtk::accelerator_parse(normalize_accelerator(accel))
1518}
1519
1520fn normalize_accelerator(accel: &str) -> String {
1523 if !accel.contains('+') {
1524 return normalize_key_name(accel);
1525 }
1526 let mut mods = String::new();
1527 let mut key = String::new();
1528 for token in accel.split('+') {
1529 let token = token.trim();
1530 if token.is_empty() {
1531 continue;
1532 }
1533 match token.to_ascii_lowercase().as_str() {
1534 "ctrl" | "control" | "primary" => mods.push_str("<Control>"),
1535 "shift" => mods.push_str("<Shift>"),
1536 "alt" => mods.push_str("<Alt>"),
1537 "super" | "win" | "mod4" => mods.push_str("<Super>"),
1538 "meta" => mods.push_str("<Meta>"),
1539 "hyper" => mods.push_str("<Hyper>"),
1540 _ => key = normalize_key_name(token),
1541 }
1542 }
1543 format!("{mods}{key}")
1544}
1545
1546fn normalize_key_name(name: &str) -> String {
1549 if name.eq_ignore_ascii_case("enter") {
1550 "Return".to_owned()
1551 } else {
1552 name.to_owned()
1553 }
1554}
1555
1556fn resolve_key(key: gdk::Key, state: gdk::ModifierType, keys: &[KeyBinding]) -> Option<TreeMsg> {
1561 use gdk::Key;
1562
1563 let ctrl = state.contains(gdk::ModifierType::CONTROL_MASK);
1564 let shift = state.contains(gdk::ModifierType::SHIFT_MASK);
1565
1566 let structural = match key {
1569 Key::Up if ctrl && shift => Some(TreeMsg::MoveExtendingSelection(-1)),
1570 Key::Down if ctrl && shift => Some(TreeMsg::MoveExtendingSelection(1)),
1571 Key::Up if shift => Some(TreeMsg::ExtendSelection(-1)),
1572 Key::Down if shift => Some(TreeMsg::ExtendSelection(1)),
1573 Key::Up => Some(TreeMsg::MoveUp),
1574 Key::Down => Some(TreeMsg::MoveDown),
1575 Key::Left => Some(TreeMsg::CollapseCursor),
1576 Key::Right => Some(TreeMsg::ExpandCursor),
1577 Key::Return | Key::KP_Enter | Key::space => Some(TreeMsg::ActivateCursor),
1578 Key::Escape => Some(TreeMsg::RenameCancel),
1579 Key::o | Key::O if ctrl && !shift => Some(TreeMsg::RequestOpenFolder),
1580 Key::a | Key::A if ctrl && !shift => Some(TreeMsg::SelectAllRows),
1581 _ => None,
1582 };
1583 if let Some(msg) = structural {
1584 return Some(msg);
1585 }
1586
1587 keys.iter()
1591 .find(|binding| {
1592 key.to_lower() == binding.key.to_lower()
1593 && accel_mods(state) == accel_mods(binding.mods)
1594 })
1595 .map(|binding| TreeMsg::RunShortcut {
1596 target: binding.target.clone(),
1597 })
1598}
1599
1600fn handle_key(
1601 key: gdk::Key,
1602 state: gdk::ModifierType,
1603 renaming: bool,
1604 preview_focused: bool,
1605 keys: &[KeyBinding],
1606 sender: &ComponentSender<Tree>,
1607) -> glib::Propagation {
1608 if renaming || preview_focused {
1611 return glib::Propagation::Proceed;
1612 }
1613
1614 if let Some(msg) = resolve_key(key, state, keys) {
1615 sender.input(msg);
1616 return glib::Propagation::Stop;
1617 }
1618
1619 let ctrl = state.contains(gdk::ModifierType::CONTROL_MASK);
1620 if !ctrl
1623 && !state.contains(gdk::ModifierType::ALT_MASK)
1624 && !state.contains(gdk::ModifierType::SUPER_MASK)
1625 && let Some(ch) = key.to_unicode()
1626 && ch.is_ascii_graphic()
1627 {
1628 sender.input(TreeMsg::TypeAhead(ch));
1629 return glib::Propagation::Stop;
1630 }
1631
1632 glib::Propagation::Proceed
1633}
1634
1635fn update_selection_classes(
1644 tree: &mut Tree,
1645 widgets: &mut <Tree as Component>::Widgets,
1646) -> bool {
1647 let mut existing = 0;
1649 let mut child = widgets.list.first_child();
1650 while let Some(current) = child {
1651 if current.is::<gtk::ListBoxRow>() {
1652 existing += 1;
1653 }
1654 child = current.next_sibling();
1655 }
1656 if existing != tree.rows.len() {
1657 return false;
1658 }
1659 for (index, row) in tree.rows.iter().enumerate() {
1660 let Some(list_row) = widgets.list.row_at_index(index as i32) else {
1661 return false;
1662 };
1663 let selected = tree.cursor == Some(index) || tree.selected.contains(&row.path);
1664 if selected {
1665 list_row.add_css_class("tree-row-selected");
1666 } else {
1667 list_row.remove_css_class("tree-row-selected");
1668 }
1669 }
1670 true
1671}
1672
1673fn scroll_row_into_view(widgets: &<Tree as Component>::Widgets, index: usize) {
1678 let Some(row) = widgets.list.row_at_index(index as i32) else {
1679 return;
1680 };
1681 let adj = widgets.scrolled.vadjustment();
1684 let page = adj.page_size();
1685 if page <= 0.0 {
1686 return;
1687 }
1688 let Some(bounds) = row.compute_bounds(&widgets.list) else {
1691 return;
1692 };
1693 let y = bounds.y() as f64;
1694 let height = bounds.height() as f64;
1695 let top = adj.value();
1696 if y < top {
1697 adj.set_value(y);
1698 } else if y + height > top + page {
1699 adj.set_value((y + height - page).max(0.0));
1700 }
1701}
1702
1703fn rebuild(tree: &mut Tree, widgets: &mut <Tree as Component>::Widgets, sender: ComponentSender<Tree>) {
1704 let list = &widgets.list;
1705
1706 if let Some(previous) = tree.popover.take() {
1715 previous.unparent();
1716 }
1717
1718 widgets.scrolled.set_child(None::<>k::Widget>);
1723 list.remove_all();
1724
1725 if tree.tree.is_none() {
1726 widgets.scrolled.set_child(Some(list));
1727 return;
1728 }
1729
1730 let rows = std::mem::take(&mut tree.rows);
1733 let mut menu_row: Option<gtk::ListBoxRow> = None;
1734 tree.rename_entry = None;
1735 tree.gif_widgets.borrow_mut().clear();
1738 tree.audio_widgets.borrow_mut().clear();
1741 let visible: HashSet<PathBuf> = rows
1745 .iter()
1746 .filter(|row| tree.wants_preview(row))
1747 .map(|row| row.path.clone())
1748 .collect();
1749 tree.retain_visible_media(&visible);
1750
1751 for (index, row) in rows.iter().enumerate() {
1752 let list_row = gtk::ListBoxRow::new();
1753 list_row.set_focusable(true);
1754 list_row.set_can_focus(true);
1755 list_row.add_css_class("tree-row");
1756
1757 let row_indent = row.depth as i32 * 16 + 4;
1758 let container = gtk::Box::new(gtk::Orientation::Vertical, 0);
1760 container.set_hexpand(true);
1761 list_row.set_child(Some(&container));
1762
1763 let hbox = gtk::Box::new(gtk::Orientation::Horizontal, 6);
1764 hbox.set_margin_start(row_indent);
1765 hbox.set_hexpand(true);
1766 container.append(&hbox);
1767
1768 let icon = gtk::Image::from_icon_name(icon_name(row));
1769 icon.set_pixel_size(tree.config.icon_size as i32);
1770 icon.set_valign(gtk::Align::Center);
1771 hbox.append(&icon);
1772
1773 if tree.renaming.as_ref() == Some(&row.path) {
1774 let entry = gtk::Entry::new();
1778 entry.set_text(&row.name);
1779 entry.set_hexpand(true);
1780 entry.add_css_class("tree-rename-entry");
1781 {
1782 let s = sender.clone();
1783 entry.connect_activate(move |_| s.input(TreeMsg::RenameCommit));
1784 }
1785 let esc_controller = gtk::EventControllerKey::new();
1786 let s = sender.clone();
1787 esc_controller.connect_key_pressed(move |_, key, _code, _state| {
1788 if key == gdk::Key::Escape {
1789 s.input(TreeMsg::RenameCancel);
1790 glib::Propagation::Stop
1791 } else {
1792 glib::Propagation::Proceed
1793 }
1794 });
1795 entry.add_controller(esc_controller);
1796 hbox.append(&entry);
1797 tree.rename_entry = Some(entry.clone());
1798 entry.grab_focus();
1799 } else {
1800 let label = gtk::Label::new(Some(&row.name));
1801 label.set_xalign(0.0);
1802 label.set_hexpand(true);
1803 label.set_ellipsize(pango::EllipsizeMode::Middle);
1804 label.set_tooltip_text(Some(&row.path.to_string_lossy()));
1805 hbox.append(&label);
1806 }
1807
1808 if tree.cursor == Some(index) || tree.selected.contains(&row.path) {
1809 list_row.add_css_class("tree-row-selected");
1810 }
1811
1812 if row.matches {
1813 list_row.add_css_class("tree-row-match");
1814 }
1815
1816 if tree.menu_target.as_ref() == Some(&row.path) {
1817 menu_row = Some(list_row.clone());
1818 }
1819
1820 if tree.wants_preview(row) {
1823 tree.append_preview(&row.path, row_indent, &container, &sender);
1824 }
1825
1826 let path = row.path.clone();
1827 let release_path = row.path.clone();
1828 let is_dir = row.is_dir;
1829 let click = gtk::GestureClick::new();
1830 click.set_button(0);
1834 let s = sender.clone();
1835 let focus_row = list_row.clone();
1836 click.connect_pressed(move |gesture, _n_press, x, y| {
1837 if let Some(widget) = focus_row.pick(x, y, gtk::PickFlags::DEFAULT)
1842 && in_inline_media(&widget)
1843 {
1844 if let Some(view) = inline_text_view(&widget) {
1849 glib::idle_add_local_once(move || {
1850 view.grab_focus();
1851 });
1852 }
1853 return;
1854 }
1855 focus_row.grab_focus();
1860 let button = gesture.current_button();
1861 let state = gesture.current_event_state();
1862 if button == 3 {
1863 if state.contains(gdk::ModifierType::SUPER_MASK) {
1867 gesture.set_state(gtk::EventSequenceState::Denied);
1868 return;
1869 }
1870 s.input(TreeMsg::Menu(path.clone()));
1871 return;
1872 }
1873 if button != 1 {
1874 return;
1875 }
1876 let ctrl = state.contains(gdk::ModifierType::CONTROL_MASK);
1877 let shift = state.contains(gdk::ModifierType::SHIFT_MASK);
1878 s.input(TreeMsg::RowPress { path: path.clone(), is_dir, ctrl, shift });
1881 });
1882 {
1883 let s = sender.clone();
1887 let release_row = list_row.clone();
1888 click.connect_released(move |gesture, _n_press, x, y| {
1889 if gesture.current_button() != 1 {
1890 return;
1891 }
1892 if let Some(widget) = release_row.pick(x, y, gtk::PickFlags::DEFAULT)
1896 && let Some(view) = inline_text_view(&widget)
1897 {
1898 glib::idle_add_local_once(move || {
1899 view.grab_focus();
1900 });
1901 return;
1902 }
1903 s.input(TreeMsg::RowRelease { path: release_path.clone() });
1904 });
1905 }
1906 list_row.add_controller(click);
1907
1908 if row.is_dir {
1918 let drop = gtk::DropTarget::new(gdk::FileList::static_type(), gdk::DragAction::MOVE | gdk::DragAction::COPY);
1919 let s = sender.clone();
1920 let target_dir = row.path.clone();
1921 let active = tree.drag_active.clone();
1922 let landed = tree.drag_landed_internal.clone();
1923 drop.connect_drop(move |target, value, _x, _y| {
1924 let Ok(list) = value.get::<gdk::FileList>() else {
1925 return false;
1926 };
1927 let sources: Vec<PathBuf> = list.files().iter().filter_map(gio::File::path).collect();
1928 if sources.is_empty() {
1929 return false;
1930 }
1931 let copy = target.current_event_state().contains(gdk::ModifierType::CONTROL_MASK);
1932 if active.get() {
1935 landed.set(true);
1936 }
1937 s.input(TreeMsg::DropInto { target: target_dir.clone(), sources, copy });
1938 true
1939 });
1940 list_row.add_controller(drop);
1941 }
1942
1943 list.append(&list_row);
1944 }
1945
1946 tree.rows = rows;
1947 widgets.scrolled.set_child(Some(list));
1948
1949 if let Some(row) = menu_row {
1953 let target = tree.menu_target.take().expect("menu_row set implies a target");
1954 let selection = tree.selected.clone();
1955 tree.popover = Some(build_menu(&row, &target, &tree.menu, &selection, tree.side, &sender));
1956 tree.menu_point = None;
1957 } else if let (Some(target), Some(at)) = (tree.menu_target.take(), tree.menu_point.take()) {
1958 let selection = tree.selected.clone();
1959 tree.popover = Some(build_menu_at(&widgets.scrolled, at, &target, &tree.menu, &selection, tree.side, &sender));
1960 }
1961 tree.menu_target = None;
1962 tree.menu_point = None;
1963}
1964
1965fn action_builtin(action: &ContextAction) -> Option<BuiltinAction> {
1967 match action {
1968 ContextAction::Builtin(b) => Some(*b),
1969 ContextAction::Entry(e) => Some(e.action),
1970 ContextAction::Command(_) | ContextAction::Submenu(_) => None,
1971 }
1972}
1973
1974pub(crate) fn menu_label(action: &ContextAction, path: &Path, side: PanelSide) -> String {
1979 let dynamic = match action_builtin(action) {
1980 Some(BuiltinAction::OpenWithDefault) => default_app_label(path),
1981 Some(BuiltinAction::InOppositePanel) => Some(format!("In {} panel", side.opposite().name())),
1982 Some(BuiltinAction::ViewThumbnail) => preview::detect(path)
1985 .and_then(PreviewKind::menu_label)
1986 .map(str::to_owned),
1987 _ => None,
1988 };
1989 dynamic.unwrap_or_else(|| action.label())
1990}
1991
1992pub(crate) fn accel_display(accel: &str) -> Option<String> {
1995 let (key, mods) = parse_accelerator(accel)?;
1996 Some(gtk::accelerator_get_label(key, mods).to_string())
1997}
1998
1999fn build_menu_box(
2004 path: &Path,
2005 menu: &ContextMenu,
2006 selection: &[PathBuf],
2007 side: PanelSide,
2008 sender: &ComponentSender<Tree>,
2009) -> gtk::Box {
2010 let menu_box = gtk::Box::new(gtk::Orientation::Vertical, 0);
2011 menu_box.add_css_class("tree-menu");
2012 let path = path.to_path_buf();
2013 let actions = menu.actions_for_selection(&path, selection.len());
2014 append_menu_items(&menu_box, &actions, &path, selection, side, sender);
2015 menu_box
2016}
2017
2018fn append_menu_items(
2021 menu_box: >k::Box,
2022 actions: &[ContextAction],
2023 path: &Path,
2024 selection: &[PathBuf],
2025 side: PanelSide,
2026 sender: &ComponentSender<Tree>,
2027) {
2028 let multi = selection.len() > 1;
2029 for action in actions {
2030 if action.is_hidden() {
2033 continue;
2034 }
2035
2036 if action_builtin(action) == Some(BuiltinAction::Separator) {
2038 let separator = gtk::Separator::new(gtk::Orientation::Horizontal);
2039 separator.add_css_class("tree-menu-separator");
2040 menu_box.append(&separator);
2041 continue;
2042 }
2043
2044 if let Some(builtin) = action_builtin(action) {
2048 if builtin.is_directory_only() && !path.is_dir() {
2049 continue;
2050 }
2051 if multi && builtin.is_single_row_only() {
2052 continue;
2053 }
2054 if matches!(builtin, BuiltinAction::OpenWithDefault) && path.is_dir() {
2055 continue;
2056 }
2057 if builtin == BuiltinAction::ViewThumbnail
2060 && !path.is_dir()
2061 && preview::detect(path).is_none()
2062 {
2063 continue;
2064 }
2065 }
2066
2067 let row_box = gtk::Box::new(gtk::Orientation::Horizontal, 12);
2068 row_box.add_css_class("tree-menu-item");
2069
2070 if let ContextAction::Submenu(sub) = action {
2072 let button = submenu_row_button(&sub.label);
2073 let popover = build_submenu_popover(&sub.items, path, selection, side, sender);
2074 let child = popover.clone();
2075 let anchor = button.clone();
2076 button.connect_clicked(move |_| {
2077 child.set_parent(&anchor);
2080 child.popup();
2081 });
2082 let child = popover.clone();
2083 popover.connect_closed(move |_| child.unparent());
2084 row_box.append(&button);
2085 row_box.append(&submenu_arrow());
2086 menu_box.append(&row_box);
2087 continue;
2088 }
2089
2090 let label = menu_label(action, path, side);
2091 let msg = match action {
2092 ContextAction::Builtin(b) => action_message(*b, path, selection),
2093 ContextAction::Entry(e) => action_message(e.action, path, selection),
2094 ContextAction::Command(cmd) => {
2095 if multi {
2096 TreeMsg::RunCommandEach {
2097 command: cmd.command.clone(),
2098 paths: selection.to_vec(),
2099 }
2100 } else {
2101 TreeMsg::RunCommand {
2102 command: cmd.command.clone(),
2103 path: path.to_path_buf(),
2104 }
2105 }
2106 }
2107 ContextAction::Submenu(_) => unreachable!("handled above"),
2108 };
2109
2110 let button = gtk::Button::new();
2113 button.set_halign(gtk::Align::Fill);
2114 button.set_hexpand(true);
2115 let label_widget = gtk::Label::new(Some(&label));
2116 label_widget.set_xalign(0.0);
2117 label_widget.set_hexpand(true);
2118 button.set_child(Some(&label_widget));
2119 let s = sender.clone();
2120 button.connect_clicked(move |_| s.input(msg.clone()));
2121 row_box.append(&button);
2122
2123 if let Some(shortcut) = action.shortcut()
2124 && let Some(display) = accel_display(&shortcut)
2125 {
2126 let hint = gtk::Label::new(Some(&display));
2127 hint.add_css_class("tree-menu-shortcut");
2128 hint.set_halign(gtk::Align::End);
2129 hint.set_valign(gtk::Align::Center);
2130 row_box.append(&hint);
2131 }
2132
2133 menu_box.append(&row_box);
2134 }
2135}
2136
2137fn submenu_row_button(label: &str) -> gtk::Button {
2139 let button = gtk::Button::new();
2140 button.set_halign(gtk::Align::Fill);
2141 button.set_hexpand(true);
2142 let text = gtk::Label::new(Some(label));
2143 text.set_xalign(0.0);
2144 text.set_hexpand(true);
2145 button.set_child(Some(&text));
2146 button
2147}
2148
2149fn submenu_arrow() -> gtk::Label {
2151 let arrow = gtk::Label::new(Some("\u{25b8}"));
2152 arrow.add_css_class("tree-menu-submenu-arrow");
2153 arrow.set_halign(gtk::Align::End);
2154 arrow.set_valign(gtk::Align::Center);
2155 arrow
2156}
2157
2158fn build_submenu_popover(
2161 items: &[ContextAction],
2162 path: &Path,
2163 selection: &[PathBuf],
2164 side: PanelSide,
2165 sender: &ComponentSender<Tree>,
2166) -> gtk::Popover {
2167 let menu_box = gtk::Box::new(gtk::Orientation::Vertical, 0);
2168 menu_box.add_css_class("tree-menu");
2169 append_menu_items(&menu_box, items, path, selection, side, sender);
2170
2171 let popover = gtk::Popover::new();
2172 popover.add_css_class("tree-menu-popover");
2173 popover.set_has_arrow(false);
2174 popover.set_position(gtk::PositionType::Right);
2175 popover.set_child(Some(&menu_box));
2176 popover
2177}
2178
2179fn show_menu(
2182 anchor: &impl IsA<gtk::Widget>,
2183 menu_box: >k::Box,
2184 point: Option<(f64, f64)>,
2185) -> gtk::Popover {
2186 let popover = gtk::Popover::new();
2187 popover.add_css_class("tree-menu-popover");
2188 popover.set_child(Some(menu_box));
2189 popover.set_autohide(true);
2190 popover.set_parent(anchor);
2191 if let Some((x, y)) = point {
2192 popover.set_pointing_to(Some(&gdk::Rectangle::new(x as i32, y as i32, 1, 1)));
2193 }
2194 popover.popup();
2195 popover
2196}
2197
2198fn build_menu(
2200 row: >k::ListBoxRow,
2201 path: &Path,
2202 menu: &ContextMenu,
2203 selection: &[PathBuf],
2204 side: PanelSide,
2205 sender: &ComponentSender<Tree>,
2206) -> gtk::Popover {
2207 let menu_box = build_menu_box(path, menu, selection, side, sender);
2208 show_menu(row, &menu_box, None)
2209}
2210
2211fn build_menu_at(
2214 anchor: &impl IsA<gtk::Widget>,
2215 at: (f64, f64),
2216 path: &Path,
2217 menu: &ContextMenu,
2218 selection: &[PathBuf],
2219 side: PanelSide,
2220 sender: &ComponentSender<Tree>,
2221) -> gtk::Popover {
2222 let menu_box = build_menu_box(path, menu, selection, side, sender);
2223 show_menu(anchor, &menu_box, Some(at))
2224}
2225
2226fn action_message(action: BuiltinAction, path: &Path, selection: &[PathBuf]) -> TreeMsg {
2232 if selection.len() > 1 {
2233 match action {
2234 BuiltinAction::Trash => return TreeMsg::DeleteSelected,
2235 BuiltinAction::DeletePermanently => return TreeMsg::PermanentDeleteSelected,
2236 BuiltinAction::CopyPath => return TreeMsg::CopyPaths(selection.to_vec()),
2237 BuiltinAction::CopyRelativePath => return TreeMsg::CopyRelativePaths(selection.to_vec()),
2238 BuiltinAction::Properties => return TreeMsg::PropertiesSelected(selection.to_vec()),
2239 _ => {}
2240 }
2241 }
2242 builtin_message(action, path)
2243}
2244
2245fn builtin_message(action: BuiltinAction, path: &Path) -> TreeMsg {
2247 match action {
2248 BuiltinAction::Open => TreeMsg::Activate(path.to_path_buf()),
2249 BuiltinAction::OpenSplit => TreeMsg::OpenSplit(path.to_path_buf()),
2250 BuiltinAction::InNewPanel => TreeMsg::OpenSplit(path.to_path_buf()),
2251 BuiltinAction::InOppositePanel => TreeMsg::OpenInOppositePanel(path.to_path_buf()),
2252 BuiltinAction::OpenWith => TreeMsg::OpenWith(path.to_path_buf()),
2253 BuiltinAction::OpenWithDefault => TreeMsg::OpenWithDefault(path.to_path_buf()),
2254 BuiltinAction::ViewThumbnail => TreeMsg::ToggleThumbnail(path.to_path_buf()),
2255 BuiltinAction::NewFile => TreeMsg::NewFile,
2256 BuiltinAction::NewFolder => TreeMsg::NewFolder,
2257 BuiltinAction::Duplicate => TreeMsg::Duplicate(path.to_path_buf()),
2258 BuiltinAction::CreateLink => TreeMsg::CreateLink(path.to_path_buf()),
2259 BuiltinAction::Cut => TreeMsg::Cut,
2260 BuiltinAction::Copy => TreeMsg::Copy,
2261 BuiltinAction::Paste => TreeMsg::Paste,
2262 BuiltinAction::Rename => TreeMsg::RenameAt(path.to_path_buf()),
2263 BuiltinAction::CopyPath => TreeMsg::CopyPath(path.to_path_buf()),
2264 BuiltinAction::CopyRelativePath => TreeMsg::CopyRelativePath(path.to_path_buf()),
2265 BuiltinAction::AddBookmark => TreeMsg::AddBookmark(path.to_path_buf()),
2266 BuiltinAction::Properties => TreeMsg::Properties(path.to_path_buf()),
2267 BuiltinAction::Trash => TreeMsg::Trash(path.to_path_buf()),
2268 BuiltinAction::DeletePermanently => TreeMsg::PermanentDelete(path.to_path_buf()),
2269 BuiltinAction::ToggleHidden => TreeMsg::ToggleHidden,
2270 BuiltinAction::SortByName => TreeMsg::SetSortKey(SortKey::Name),
2271 BuiltinAction::SortBySize => TreeMsg::SetSortKey(SortKey::Size),
2272 BuiltinAction::SortByModified => TreeMsg::SetSortKey(SortKey::Modified),
2273 BuiltinAction::SortByType => TreeMsg::SetSortKey(SortKey::Type),
2274 BuiltinAction::ToggleSortAscending => TreeMsg::ToggleSortDirection,
2275 BuiltinAction::OpenFolder
2277 | BuiltinAction::Filter
2278 | BuiltinAction::SplitView
2279 | BuiltinAction::Up
2280 | BuiltinAction::Back
2281 | BuiltinAction::Forward
2282 | BuiltinAction::Collapse
2283 | BuiltinAction::ClosePane
2284 | BuiltinAction::ToggleBookmarks
2285 | BuiltinAction::MoveToPreviousWorkspace
2286 | BuiltinAction::MoveToNextWorkspace
2287 | BuiltinAction::MoveToWorkspace
2288 | BuiltinAction::NewBookmark
2289 | BuiltinAction::NewBookmarkFolder => unreachable!("pane action in a row context menu"),
2290 BuiltinAction::EditBookmark | BuiltinAction::DeleteBookmark => TreeMsg::Noop,
2292 BuiltinAction::Separator => unreachable!("separators are rendered, not dispatched"),
2293 }
2294}
2295
2296pub(crate) fn is_path_safe(action: BuiltinAction) -> bool {
2301 matches!(
2302 action,
2303 BuiltinAction::Open
2304 | BuiltinAction::OpenSplit
2305 | BuiltinAction::InNewPanel
2306 | BuiltinAction::InOppositePanel
2307 | BuiltinAction::OpenWith
2308 | BuiltinAction::OpenWithDefault
2309 | BuiltinAction::Duplicate
2310 | BuiltinAction::CreateLink
2311 | BuiltinAction::CopyPath
2312 | BuiltinAction::CopyRelativePath
2313 | BuiltinAction::Properties
2314 | BuiltinAction::Trash
2315 | BuiltinAction::DeletePermanently
2316 | BuiltinAction::AddBookmark
2317 )
2318}
2319
2320pub(crate) fn path_action_message(action: BuiltinAction, path: &Path) -> Option<TreeMsg> {
2325 if !is_path_safe(action) || action == BuiltinAction::Open {
2326 return None;
2327 }
2328 Some(builtin_message(action, path))
2329}
2330
2331const ARCHIVE_EXTENSIONS: [&str; 8] = ["zip", "tar", "gz", "xz", "bz2", "zst", "7z", "rar"];
2333
2334fn has_extension(path: &Path, extensions: &[&str]) -> bool {
2335 path.extension()
2336 .and_then(|ext| ext.to_str())
2337 .is_some_and(|ext| extensions.iter().any(|candidate| ext.eq_ignore_ascii_case(candidate)))
2338}
2339
2340fn icon_name(row: &VisibleRow) -> &'static str {
2341 if row.is_dir {
2342 return if row.expanded { "folder-open-symbolic" } else { "folder-symbolic" };
2343 }
2344 if has_extension(&row.path, &["pdf"]) {
2348 "application-pdf-symbolic"
2349 } else if has_extension(&row.path, &["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp", "avif"])
2350 {
2351 "image-x-generic-symbolic"
2352 } else if has_extension(&row.path, &["mp3", "flac", "wav", "ogg", "m4a", "opus"]) {
2353 "audio-x-generic-symbolic"
2354 } else if has_extension(&row.path, &["mp4", "mkv", "webm", "mov", "avi"]) {
2355 "video-x-generic-symbolic"
2356 } else if has_extension(&row.path, &ARCHIVE_EXTENSIONS) {
2357 "package-x-generic-symbolic"
2358 } else {
2359 "text-x-generic-symbolic"
2360 }
2361}
2362
2363struct GifAnim {
2370 iter: gdk::gdk_pixbuf::PixbufAnimationIter,
2371}
2372
2373struct AudioWidgets {
2377 player: Weak<AudioPlayer>,
2380 play: gtk::Button,
2381 seek: gtk::Scale,
2382 time: gtk::Label,
2383 seeking: Rc<Cell<bool>>,
2386}
2387
2388fn needs_selection(action: BuiltinAction) -> bool {
2392 matches!(
2393 action,
2394 BuiltinAction::Rename
2395 | BuiltinAction::Duplicate
2396 | BuiltinAction::CreateLink
2397 | BuiltinAction::Cut
2398 | BuiltinAction::Copy
2399 | BuiltinAction::OpenWith
2400 | BuiltinAction::Trash
2401 | BuiltinAction::DeletePermanently
2402 )
2403}
2404
2405fn in_list_row(widget: >k::Widget) -> bool {
2408 let mut current = Some(widget.clone());
2409 while let Some(widget) = current {
2410 if widget.downcast_ref::<gtk::ListBoxRow>().is_some() {
2411 return true;
2412 }
2413 current = widget.parent();
2414 }
2415 false
2416}
2417
2418fn in_inline_media(widget: >k::Widget) -> bool {
2421 let mut current = Some(widget.clone());
2422 while let Some(widget) = current {
2423 if widget.downcast_ref::<gtk::Video>().is_some()
2424 || widget.has_css_class("tree-audio")
2425 || widget.has_css_class("tree-preview-scroll")
2426 || widget.has_css_class("tree-preview-text")
2427 {
2428 return true;
2429 }
2430 current = widget.parent();
2431 }
2432 false
2433}
2434
2435fn in_text_preview(widget: >k::Widget) -> bool {
2438 let mut current = Some(widget.clone());
2439 while let Some(widget) = current {
2440 if widget.has_css_class("tree-preview-text") || widget.has_css_class("tree-preview-scroll") {
2441 return true;
2442 }
2443 current = widget.parent();
2444 }
2445 false
2446}
2447
2448fn inline_text_view(widget: >k::Widget) -> Option<gtk::TextView> {
2451 let mut current = Some(widget.clone());
2452 while let Some(widget) = current {
2453 if widget.has_css_class("tree-preview-text") {
2454 return widget.downcast::<gtk::TextView>().ok();
2455 }
2456 current = widget.parent();
2457 }
2458 None
2459}
2460
2461fn thumbnail_content_width(panel: PanelConfig) -> i32 {
2464 (panel.width as i32 - 2 * panel.margin as i32 - 24).max(64)
2465}
2466
2467fn thumbnail_size(texture: &gdk::Texture, available: i32) -> (i32, i32) {
2470 let natural_w = texture.width().max(1);
2471 let natural_h = texture.height().max(1);
2472 let available = available.max(32);
2473 if natural_w <= available {
2474 return (natural_w, natural_h);
2475 }
2476 let height = (natural_h as i64 * available as i64 / natural_w as i64).max(1) as i32;
2477 (available, height)
2478}
2479
2480fn thumbnail_picture(
2483 texture: &gdk::Texture,
2484 margin_start: i32,
2485 width: i32,
2486 height: i32,
2487) -> gtk::Picture {
2488 let picture = gtk::Picture::for_paintable(texture);
2489 picture.add_css_class("tree-thumbnail");
2490 picture.set_can_shrink(true);
2491 picture.set_content_fit(gtk::ContentFit::ScaleDown);
2492 picture.set_halign(gtk::Align::Start);
2493 picture.set_size_request(width, height);
2494 picture.set_margin_start(margin_start);
2495 picture.set_margin_bottom(6);
2496 picture
2497}
2498
2499fn format_time(micros: i64) -> String {
2502 if micros <= 0 {
2503 return "-:--".to_owned();
2504 }
2505 let total = micros / 1_000_000;
2506 let (h, m, s) = (total / 3600, (total % 3600) / 60, total % 60);
2507 if h > 0 {
2508 format!("{h}:{m:02}:{s:02}")
2509 } else {
2510 format!("{m}:{s:02}")
2511 }
2512}
2513
2514fn build_audio_player(player: &Rc<AudioPlayer>, margin_start: i32) -> (gtk::Box, AudioWidgets) {
2518 let controls = gtk::Box::new(gtk::Orientation::Horizontal, 6);
2519 controls.add_css_class("tree-audio");
2520 controls.set_margin_start(margin_start);
2521 controls.set_margin_bottom(6);
2522 controls.set_hexpand(true);
2523
2524 let play = gtk::Button::from_icon_name("media-playback-start-symbolic");
2525 play.add_css_class("flat");
2526 play.set_tooltip_text(Some("Play"));
2527 play.set_valign(gtk::Align::Center);
2528 {
2529 let player = Rc::downgrade(player);
2534 play.connect_clicked(move |button| {
2535 let Some(player) = player.upgrade() else {
2536 return;
2537 };
2538 let playing = player.is_playing();
2539 player.set_playing(!playing);
2540 button.set_icon_name(if playing {
2541 "media-playback-start-symbolic"
2542 } else {
2543 "media-playback-pause-symbolic"
2544 });
2545 });
2546 }
2547 controls.append(&play);
2548
2549 let seek = gtk::Scale::with_range(gtk::Orientation::Horizontal, 0.0, 1.0, 1.0);
2552 seek.set_draw_value(false);
2553 seek.set_hexpand(true);
2554 seek.set_valign(gtk::Align::Center);
2555 seek.set_size_request(60, -1);
2556 let seeking = Rc::new(Cell::new(false));
2557 {
2558 let player = Rc::downgrade(player);
2559 let seeking = seeking.clone();
2560 seek.connect_change_value(move |_, _, value| {
2561 seeking.set(true);
2563 let flag = seeking.clone();
2564 glib::timeout_add_local_once(Duration::from_millis(200), move || flag.set(false));
2565 if let Some(player) = player.upgrade() {
2566 player.seek(value.round() as i64);
2567 }
2568 glib::Propagation::Proceed
2569 });
2570 }
2571 controls.append(&seek);
2572
2573 let time = gtk::Label::new(Some("0:00 / -:--"));
2574 time.add_css_class("tree-audio-time");
2575 time.set_valign(gtk::Align::Center);
2576 time.set_width_chars(13);
2577 time.set_xalign(1.0);
2578 controls.append(&time);
2579
2580 let volume = gtk::Scale::with_range(gtk::Orientation::Horizontal, 0.0, 1.0, 0.05);
2581 volume.set_draw_value(false);
2582 volume.set_value(player.volume());
2583 volume.set_valign(gtk::Align::Center);
2584 volume.set_size_request(56, -1);
2585 volume.set_tooltip_text(Some("Volume"));
2586 {
2587 let player = Rc::downgrade(player);
2588 volume.connect_value_changed(move |scale| {
2589 if let Some(player) = player.upgrade() {
2590 player.set_volume(scale.value());
2591 }
2592 });
2593 }
2594 controls.append(&volume);
2595
2596 let widgets = AudioWidgets {
2597 player: Rc::downgrade(player),
2598 play,
2599 seek,
2600 time,
2601 seeking,
2602 };
2603 (controls, widgets)
2604}
2605
2606const PREVIEW_CELL_CHARS: i32 = 12;
2608
2609const PREVIEW_TEXT_MAX_LINES: usize = 18;
2614
2615fn append_text_lines(container: >k::Box, margin_start: i32, lines: &DocumentLines) {
2619 if lines.lines.is_empty() {
2620 return;
2621 }
2622 let mut text = lines.lines.join("\n");
2623 if lines.more {
2624 text.push_str("\n…");
2625 }
2626
2627 let view = gtk::TextView::new();
2628 view.add_css_class("tree-preview-text");
2629 view.set_editable(false);
2630 view.set_cursor_visible(false);
2631 view.set_can_focus(true);
2632 view.set_focus_on_click(true);
2633 view.set_monospace(true);
2634 view.set_wrap_mode(gtk::WrapMode::WordChar);
2635 view.set_left_margin(6);
2636 view.set_right_margin(6);
2637 view.set_top_margin(3);
2638 view.set_bottom_margin(3);
2639 let buffer = view.buffer();
2640 buffer.set_text(&text);
2641 apply_syntax(&buffer, &lines.syntax);
2642
2643 let line_height = view.create_pango_layout(Some("Mg")).pixel_size().1.max(1);
2648 let shown = lines.lines.len().clamp(1, PREVIEW_TEXT_MAX_LINES) as i32;
2649 let height = line_height * shown + 6; let scrolled = gtk::ScrolledWindow::new();
2651 scrolled.add_css_class("tree-preview-scroll");
2652 scrolled.set_policy(gtk::PolicyType::Never, gtk::PolicyType::Automatic);
2653 scrolled.set_min_content_height(height);
2654 scrolled.set_max_content_height(height);
2655 scrolled.set_propagate_natural_height(false);
2656 scrolled.set_child(Some(&view));
2657 scrolled.set_margin_start(margin_start);
2658 scrolled.set_margin_bottom(6);
2659 container.append(&scrolled);
2660}
2661
2662fn apply_syntax(buffer: >k::TextBuffer, spans: &[Span]) {
2665 if spans.is_empty() {
2666 return;
2667 }
2668 let palette = highlight::palette();
2669 let table = buffer.tag_table();
2670 let mut tags: HashMap<TokenClass, gtk::TextTag> = HashMap::new();
2671 for span in spans {
2672 let tag = tags.entry(span.class).or_insert_with(|| {
2673 let tag = gtk::TextTag::new(None);
2674 tag.set_foreground(Some(palette.color(span.class)));
2675 table.add(&tag);
2676 tag
2677 });
2678 let start = buffer.iter_at_offset(span.start as i32);
2679 let end = buffer.iter_at_offset(span.end as i32);
2680 buffer.apply_tag(tag, &start, &end);
2681 }
2682}
2683
2684fn append_status(container: >k::Box, margin_start: i32, status: &ParseStatus) {
2686 if status.detail.is_empty() {
2687 return;
2688 }
2689 let label = gtk::Label::new(Some(&status.detail));
2690 label.add_css_class("tree-preview-status");
2691 label.add_css_class(if status.ok { "ok" } else { "error" });
2692 label.set_xalign(0.0);
2693 label.set_hexpand(true);
2694 label.set_wrap(true);
2695 label.set_wrap_mode(pango::WrapMode::WordChar);
2696 label.set_margin_start(margin_start);
2697 label.set_margin_bottom(6);
2698 container.append(&label);
2699}
2700
2701fn append_table(container: >k::Box, margin_start: i32, table: &TableData) {
2704 if table.rows.is_empty() {
2705 return;
2706 }
2707 let grid = gtk::Grid::new();
2708 grid.add_css_class("tree-preview-table");
2709 grid.set_margin_start(margin_start);
2710 grid.set_margin_bottom(6);
2711 grid.set_column_spacing(10);
2712 grid.set_row_spacing(1);
2713 for (r, row) in table.rows.iter().enumerate() {
2714 for (c, cell) in row.iter().enumerate() {
2715 let label = gtk::Label::new(Some(cell));
2716 label.set_xalign(0.0);
2717 label.set_ellipsize(pango::EllipsizeMode::End);
2718 label.set_max_width_chars(PREVIEW_CELL_CHARS);
2719 if r == 0 {
2720 label.add_css_class("tree-preview-header");
2721 }
2722 grid.attach(&label, c as i32, r as i32, 1, 1);
2723 }
2724 }
2725 container.append(&grid);
2726 if table.more {
2727 let more = gtk::Label::new(Some("…"));
2728 more.add_css_class("tree-preview-status");
2729 more.set_xalign(0.0);
2730 more.set_margin_start(margin_start);
2731 container.append(&more);
2732 }
2733}
2734
2735fn append_archive(container: >k::Box, margin_start: i32, archive: &ArchiveData) {
2738 let list = gtk::Box::new(gtk::Orientation::Vertical, 1);
2739 list.add_css_class("tree-preview-archive");
2740 list.set_margin_start(margin_start);
2741 list.set_margin_bottom(6);
2742 match archive {
2743 ArchiveData::Unsupported(reason) => {
2744 let label = gtk::Label::new(Some(reason));
2745 label.add_css_class("tree-preview-status");
2746 label.set_xalign(0.0);
2747 label.set_wrap(true);
2748 list.append(&label);
2749 }
2750 ArchiveData::Listed(listing) => {
2751 for entry in &listing.entries {
2752 let row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
2753 let name = gtk::Label::new(Some(&entry.name));
2754 name.add_css_class("tree-preview-archive-name");
2755 name.set_xalign(0.0);
2756 name.set_hexpand(true);
2757 name.set_ellipsize(pango::EllipsizeMode::Middle);
2758 row.append(&name);
2759 if !entry.is_dir {
2760 let size = gtk::Label::new(Some(&format_size(entry.size)));
2761 size.add_css_class("tree-preview-archive-size");
2762 size.set_xalign(1.0);
2763 row.append(&size);
2764 }
2765 list.append(&row);
2766 }
2767 if listing.more {
2768 let more = gtk::Label::new(Some("…"));
2769 more.add_css_class("tree-preview-status");
2770 more.set_xalign(0.0);
2771 list.append(&more);
2772 }
2773 }
2774 }
2775 container.append(&list);
2776}
2777
2778impl Tree {
2779 fn open_root(&mut self, path: &Path, sender: &ComponentSender<Self>) {
2780 if let Some(id) = self.flush_source.take() {
2781 id.remove();
2782 }
2783 self.pending.clear();
2784
2785 let mut tree = TreeModel::new(
2786 path.to_path_buf(),
2787 self.config.sort_options(),
2788 self.config.show_hidden,
2789 );
2790 if let Err(err) = tree.expand(path, &StdDirSource) {
2791 self.status(format!("Could not open {}: {err}", path.display()), sender);
2792 return;
2793 }
2794 self.tree = Some(tree);
2795 self.refresh_rows();
2796 self.cursor = if self.rows.is_empty() { None } else { Some(0) };
2797 self.selected = Vec::new();
2798 self.renaming = None;
2799 self.renaming_state.set(false);
2800 self.rename_entry = None;
2801 self.menu_target = None;
2802 self.thumbnails.clear();
2803 self.preview_kinds.clear();
2804 self.documents.clear();
2805 self.thumb_cache.clear();
2806 self.playing.clear();
2807 self.stop_all_videos();
2808 self.media.clear();
2809 self.audio.clear();
2810 self.gif_anims.borrow_mut().clear();
2811 self.gif_widgets.borrow_mut().clear();
2812 self.audio_widgets.borrow_mut().clear();
2813 if let Some(previous) = self.popover.take() {
2814 previous.unparent();
2815 }
2816
2817 let root = path.to_path_buf();
2818 let watcher_sender = sender.clone();
2819 self._watcher = spawn_watcher(path, move |change| {
2820 watcher_sender.input(TreeMsg::FsChange(change));
2821 })
2822 .ok();
2823 self.watched.clear();
2824 if self._watcher.is_some() {
2825 self.watched.insert(root.clone());
2826 }
2827
2828 let _ = sender.output(TreeOutput::RootChanged(root));
2829 }
2830
2831 fn refresh_rows(&mut self) {
2832 if let Some(tree) = self.tree.as_ref() {
2833 tree.visible_rows_into(&mut self.rows);
2834 } else {
2835 self.rows.clear();
2836 }
2837 }
2838
2839 fn reconcile_watches(&mut self) {
2845 let Some(watcher) = self._watcher.as_mut() else {
2846 return;
2847 };
2848 let Some(model) = self.tree.as_ref() else {
2849 return;
2850 };
2851 let desired: HashSet<PathBuf> = model.expanded_loaded_dirs().into_iter().collect();
2852
2853 let stale: Vec<PathBuf> = self.watched.difference(&desired).cloned().collect();
2854 for path in stale {
2855 let _ = watcher.unwatch(&path);
2856 self.watched.remove(&path);
2857 }
2858
2859 let fresh: Vec<PathBuf> = desired.difference(&self.watched).cloned().collect();
2860 for path in fresh {
2861 if watcher.watch(&path, RecursiveMode::NonRecursive).is_ok() {
2862 self.watched.insert(path);
2863 }
2864 }
2865 }
2866
2867 fn cursor_path(&self) -> Option<PathBuf> {
2868 self.cursor.and_then(|i| self.rows.get(i)).map(|r| r.path.clone())
2869 }
2870
2871 fn cursor_delta(&mut self, delta: isize) {
2872 if self.rows.is_empty() {
2873 return;
2874 }
2875 let next = (self.cursor.unwrap_or(0) as isize + delta).clamp(0, self.rows.len() as isize - 1) as usize;
2876 let path = self.rows[next].path.clone();
2877 self.cursor = Some(next);
2878 self.set_single_selection(path);
2879 self.selection_dirty = true;
2880 }
2881
2882 fn cursor_extend(&mut self, delta: isize, _keep_anchor: bool) {
2889 if self.rows.is_empty() {
2890 return;
2891 }
2892 let anchor = self
2893 .anchor
2894 .clone()
2895 .or_else(|| self.cursor.map(|i| self.rows[i].path.clone()))
2896 .unwrap_or_else(|| self.rows[0].path.clone());
2897 let next = (self.cursor.unwrap_or(0) as isize + delta)
2898 .clamp(0, self.rows.len() as isize - 1) as usize;
2899 self.cursor = Some(next);
2900 self.anchor = Some(anchor.clone());
2901 let (Some(a), Some(b)) = (self.rows.iter().position(|r| r.path == anchor), Some(next)) else {
2902 return;
2903 };
2904 self.selected = self.rows[index_range(a, b)].iter().map(|r| r.path.clone()).collect();
2905 self.scroll_to = Some(next);
2906 self.selection_dirty = true;
2907 }
2908
2909 fn cursor_snap(&mut self) {
2910 let deselected = self.cursor.is_none();
2911 let prev = self.cursor_path();
2912 self.refresh_rows();
2913 if self.rows.is_empty() {
2914 self.cursor = None;
2915 self.selected.clear();
2916 return;
2917 }
2918 if deselected {
2919 self.cursor = None;
2921 return;
2922 }
2923 let index = prev
2924 .and_then(|p| self.rows.iter().position(|r| r.path == p))
2925 .unwrap_or(0);
2926 self.cursor = Some(index);
2927 }
2928
2929 fn select(&mut self, path: &Path) {
2930 if let Some(i) = self.rows.iter().position(|r| r.path == path) {
2931 self.cursor = Some(i);
2932 }
2933 self.selected = vec![path.to_path_buf()];
2934 }
2935
2936 fn press_default(&mut self, path: &Path, is_dir: bool, sender: &ComponentSender<Self>) {
2941 let now = std::time::Instant::now();
2944 let is_double = self.last_press.as_ref().is_some_and(|(last, at)| {
2945 last == path && now.duration_since(*at) < Duration::from_millis(400)
2946 });
2947 self.last_press = Some((path.to_path_buf(), now));
2948
2949 self.select(path);
2950 self.anchor = Some(path.to_path_buf());
2951 if is_dir {
2952 self.toggle_dir(path, sender);
2956 } else if is_double {
2957 self.activate(path, sender);
2959 }
2960 }
2961
2962 fn reveal_path(&mut self, path: PathBuf) {
2967 if let Some(i) = self.rows.iter().position(|r| r.path == path) {
2968 self.cursor = Some(i);
2969 self.selected = vec![path.clone()];
2970 self.anchor = Some(path);
2971 self.selection_dirty = true;
2972 self.scroll_to = Some(i);
2973 }
2974 }
2975
2976 fn range_select(&mut self, path: &Path) {
2980 let Some(anchor) = self.anchor.clone() else {
2981 self.select(path);
2982 self.anchor = Some(path.to_path_buf());
2983 return;
2984 };
2985 let (Some(a), Some(b)) = (
2986 self.rows.iter().position(|r| r.path == anchor),
2987 self.rows.iter().position(|r| r.path == path),
2988 ) else {
2989 self.select(path);
2990 return;
2991 };
2992 self.selected = self.rows[index_range(a, b)].iter().map(|r| r.path.clone()).collect();
2993 self.cursor = Some(b);
2994 }
2995
2996 fn set_single_selection(&mut self, path: PathBuf) {
2997 self.selected = vec![path.clone()];
2998 if let Some(i) = self.rows.iter().position(|r| r.path == path) {
2999 self.cursor = Some(i);
3000 }
3001 }
3002
3003 fn selected_for_clipboard(&self) -> Vec<PathBuf> {
3004 if self.selected.is_empty() {
3005 if let Some(path) = self.cursor_path() {
3006 return vec![path];
3007 }
3008 return Vec::new();
3009 }
3010 self.selected.clone()
3011 }
3012
3013 fn collapse_dir(&mut self, path: &Path) {
3016 if let Some(tree) = self.tree.as_mut() {
3017 tree.collapse(path);
3018 }
3019 self.clear_thumbnails_under(path);
3020 self.cursor_snap();
3021 }
3022
3023 fn toggle_dir(&mut self, path: &Path, sender: &ComponentSender<Self>) {
3025 let expanded = self.tree.as_ref().is_some_and(|t| t.is_expanded(path));
3026 if expanded {
3027 self.collapse_dir(path);
3028 } else {
3029 self.expand_dir(path, sender);
3030 }
3031 }
3032
3033 fn suspend_media(&mut self) {
3038 for video in self.video_widgets.values() {
3039 if let Some(stream) = video.media_stream() {
3040 stream.set_playing(false);
3041 }
3042 }
3043 for player in self.audio.values() {
3044 player.set_playing(false);
3045 }
3046 self.gif_anims.borrow_mut().clear();
3048 self.playing.clear();
3049 }
3050
3051 fn shutdown(&mut self) {
3058 if let Some(id) = self.gif_tick.borrow_mut().take() {
3059 id.remove();
3060 }
3061 if let Some(id) = self.audio_tick.borrow_mut().take() {
3062 id.remove();
3063 }
3064 self.gif_anims.borrow_mut().clear();
3065 self.gif_widgets.borrow_mut().clear();
3066 self.audio_widgets.borrow_mut().clear();
3067 self.playing.clear();
3068 self.thumbnails.clear();
3069 self.stop_all_videos();
3070 self.media.clear();
3071 self.audio.clear();
3073 }
3074
3075 fn clear_thumbnails_under(&mut self, prefix: &Path) {
3080 self.thumbnails.retain(|path| !path.starts_with(prefix));
3081 self.preview_kinds.retain(|path, _| !path.starts_with(prefix));
3082 self.documents.retain(|path, _| !path.starts_with(prefix));
3083 self.thumb_cache.retain(|path, _| !path.starts_with(prefix));
3084 self.playing.retain(|path| !path.starts_with(prefix));
3085 self.stop_videos_under(prefix);
3086 self.media.retain(|path, _| !path.starts_with(prefix));
3087 self.gif_anims.borrow_mut().retain(|path, _| !path.starts_with(prefix));
3088 self.gif_widgets.borrow_mut().retain(|path, _| !path.starts_with(prefix));
3089 self.audio_widgets.borrow_mut().retain(|path, _| !path.starts_with(prefix));
3090 self.audio.retain(|path, _| !path.starts_with(prefix));
3092 }
3093
3094 fn stop_videos_under(&mut self, prefix: &Path) {
3098 self.video_widgets.retain(|path, video| {
3099 if path.starts_with(prefix) {
3100 crate::ui::stop_video_stream(video);
3101 false
3102 } else {
3103 true
3104 }
3105 });
3106 }
3107
3108 fn stop_all_videos(&mut self) {
3110 for video in self.video_widgets.values() {
3111 crate::ui::stop_video_stream(video);
3112 }
3113 self.video_widgets.clear();
3114 }
3115
3116 fn retain_visible_media(&mut self, visible: &HashSet<PathBuf>) {
3122 self.video_widgets.retain(|path, video| {
3123 if visible.contains(path) {
3124 true
3125 } else {
3126 crate::ui::stop_video_stream(video);
3127 false
3128 }
3129 });
3130 self.media.retain(|path, _| visible.contains(path));
3131 self.playing.retain(|path| visible.contains(path));
3132 self.gif_anims.borrow_mut().retain(|path, _| visible.contains(path));
3133 self.audio.retain(|path, _| visible.contains(path));
3135 }
3136
3137 fn wants_preview(&self, row: &VisibleRow) -> bool {
3141 if row.is_dir {
3142 return false;
3143 }
3144 self.thumbnails.contains(&row.path)
3145 || row
3146 .path
3147 .ancestors()
3148 .skip(1)
3149 .any(|ancestor| self.thumbnails.contains(ancestor))
3150 }
3151
3152 fn preview_kind(&mut self, path: &Path) -> Option<PreviewKind> {
3155 if let Some(kind) = self.preview_kinds.get(path) {
3156 return *kind;
3157 }
3158 let kind = preview::detect(path);
3159 self.preview_kinds.insert(path.to_path_buf(), kind);
3160 kind
3161 }
3162
3163 fn static_texture(&mut self, path: &Path, kind: PreviewKind) -> Option<gdk::Texture> {
3166 if let Some(texture) = self.thumb_cache.get(path) {
3167 return Some(texture.clone());
3168 }
3169 let texture = match kind {
3170 PreviewKind::Image => gdk::Texture::from_filename(path).ok()?,
3171 PreviewKind::Gif => {
3172 let animation = gdk::gdk_pixbuf::PixbufAnimation::from_file(path).ok()?;
3173 let pixbuf = animation.static_image()?;
3174 gdk::Texture::for_pixbuf(&pixbuf)
3175 }
3176 _ => return None,
3178 };
3179 self.thumb_cache.insert(path.to_path_buf(), texture.clone());
3180 Some(texture)
3181 }
3182
3183 fn media_file(&mut self, path: &Path) -> Option<gtk::MediaFile> {
3188 if let Some(media) = self.media.get(path) {
3189 return Some(media.clone());
3190 }
3191 let media = gtk::MediaFile::for_filename(path);
3192 media.set_loop(true);
3193 media.set_muted(false);
3198 media.set_volume(1.0);
3199 self.media.insert(path.to_path_buf(), media.clone());
3200 Some(media)
3201 }
3202
3203 fn audio_player(&mut self, path: &Path) -> Option<Rc<AudioPlayer>> {
3206 if let Some(player) = self.audio.get(path) {
3207 return Some(player.clone());
3208 }
3209 let player = AudioPlayer::new(path)?;
3210 self.audio.insert(path.to_path_buf(), player.clone());
3211 Some(player)
3212 }
3213
3214 fn toggle_thumbnail_play(&mut self, path: &Path) {
3217 if self.preview_kind(path) != Some(PreviewKind::Gif) {
3218 return;
3219 }
3220 if self.playing.remove(path) {
3221 self.gif_anims.borrow_mut().remove(path);
3222 } else {
3223 let Ok(animation) = gdk::gdk_pixbuf::PixbufAnimation::from_file(path) else {
3224 return;
3225 };
3226 let iter = animation.iter(Some(SystemTime::now()));
3227 self.gif_anims.borrow_mut().insert(path.to_path_buf(), GifAnim { iter });
3228 self.playing.insert(path.to_path_buf());
3229 self.ensure_gif_ticker();
3230 }
3231 }
3232
3233 fn ensure_gif_ticker(&mut self) {
3237 if self.gif_tick.borrow().is_some() {
3238 return;
3239 }
3240 let anims = self.gif_anims.clone();
3241 let widgets = self.gif_widgets.clone();
3242 let tick = self.gif_tick.clone();
3243 let id = glib::timeout_add_local(Duration::from_millis(33), move || {
3244 let mut anims = anims.borrow_mut();
3245 if anims.is_empty() {
3246 *tick.borrow_mut() = None;
3247 return glib::ControlFlow::Break;
3248 }
3249 let now = SystemTime::now();
3250 let widgets = widgets.borrow();
3251 for (path, anim) in anims.iter_mut() {
3252 let _ = anim.iter.advance(now);
3253 if let Some(picture) = widgets.get(path) {
3254 let pixbuf = anim.iter.pixbuf();
3255 picture.set_paintable(Some(&gdk::Texture::for_pixbuf(&pixbuf)));
3256 }
3257 }
3258 glib::ControlFlow::Continue
3259 });
3260 *self.gif_tick.borrow_mut() = Some(id);
3261 }
3262
3263 fn ensure_audio_ticker(&mut self) {
3266 if self.audio_tick.borrow().is_some() {
3267 return;
3268 }
3269 let players = self.audio_widgets.clone();
3270 let tick = self.audio_tick.clone();
3271 let id = glib::timeout_add_local(Duration::from_millis(200), move || {
3272 let players = players.borrow();
3273 if players.is_empty() {
3274 *tick.borrow_mut() = None;
3275 return glib::ControlFlow::Break;
3276 }
3277 for widgets in players.values() {
3278 let Some(player) = widgets.player.upgrade() else {
3281 continue;
3282 };
3283 let position = player.position();
3284 let duration = player.duration();
3285 if duration > 0
3286 && (widgets.seek.adjustment().upper() - duration as f64).abs() > 0.5
3287 {
3288 widgets.seek.adjustment().set_upper(duration as f64);
3289 }
3290 if !widgets.seeking.get() {
3291 widgets.seek.set_value(position as f64);
3292 }
3293 widgets.time.set_text(&format!(
3294 "{} / {}",
3295 format_time(position),
3296 format_time(duration)
3297 ));
3298 widgets.play.set_icon_name(if player.is_playing() {
3299 "media-playback-pause-symbolic"
3300 } else {
3301 "media-playback-start-symbolic"
3302 });
3303 }
3304 glib::ControlFlow::Continue
3305 });
3306 *self.audio_tick.borrow_mut() = Some(id);
3307 }
3308
3309 fn append_preview(
3313 &mut self,
3314 path: &Path,
3315 row_indent: i32,
3316 container: >k::Box,
3317 sender: &ComponentSender<Self>,
3318 ) {
3319 let Some(kind) = self.preview_kind(path) else {
3320 return;
3321 };
3322 let label_start = row_indent + self.config.icon_size as i32 + 6;
3323 if kind.is_media() {
3324 let available = self.content_width - label_start - 6;
3325 self.append_media(path, kind, label_start, available, container, sender);
3326 } else {
3327 self.append_document(path, kind, label_start, container);
3328 }
3329 }
3330
3331 fn append_media(
3333 &mut self,
3334 path: &Path,
3335 kind: PreviewKind,
3336 label_start: i32,
3337 available: i32,
3338 container: >k::Box,
3339 sender: &ComponentSender<Self>,
3340 ) {
3341 match kind {
3342 PreviewKind::Image => {
3343 if let Some(texture) = self.static_texture(path, kind) {
3344 let (width, height) = thumbnail_size(&texture, available);
3345 container.append(&thumbnail_picture(&texture, label_start, width, height));
3346 }
3347 }
3348 PreviewKind::Gif => {
3349 let playing = self.playing.contains(path);
3351 let texture = if playing {
3352 self.gif_anims
3353 .borrow()
3354 .get(path)
3355 .map(|anim| gdk::Texture::for_pixbuf(&anim.iter.pixbuf()))
3356 } else {
3357 self.static_texture(path, kind)
3358 };
3359 if let Some(texture) = texture {
3360 let (width, height) = thumbnail_size(&texture, available);
3361 let picture = thumbnail_picture(&texture, label_start, width, height);
3362 picture.set_tooltip_text(Some("Click to play"));
3363 if playing {
3364 self.gif_widgets
3365 .borrow_mut()
3366 .insert(path.to_path_buf(), picture.clone());
3367 }
3368 let msg = TreeMsg::ToggleThumbnailPlay(path.to_path_buf());
3369 let s = sender.clone();
3370 let click = gtk::GestureClick::new();
3371 click.set_button(1);
3372 click.connect_pressed(move |_, _, _, _| s.input(msg.clone()));
3373 picture.add_controller(click);
3374 container.append(&picture);
3375 }
3376 }
3377 PreviewKind::Video => {
3378 if let Some(media) = self.media_file(path) {
3379 let video = gtk::Video::new();
3385 video.set_autoplay(false);
3386 video.set_media_stream(Some(&media));
3387 video.set_loop(true);
3388 video.add_css_class("tree-thumbnail");
3389 video.set_halign(gtk::Align::Start);
3390 let width = available.max(64);
3391 video.set_size_request(width, (width * 9 / 16).max(36));
3392 video.set_margin_start(label_start);
3393 video.set_margin_bottom(6);
3394 self.video_widgets.insert(path.to_path_buf(), video.clone());
3395 container.append(&video);
3396 }
3397 }
3398 PreviewKind::Audio => {
3399 if let Some(player) = self.audio_player(path) {
3400 let (controls, widgets) = build_audio_player(&player, label_start);
3404 self.audio_widgets.borrow_mut().insert(path.to_path_buf(), widgets);
3405 container.append(&controls);
3406 self.ensure_audio_ticker();
3407 }
3408 }
3409 PreviewKind::Text
3411 | PreviewKind::Csv
3412 | PreviewKind::Json
3413 | PreviewKind::Toml
3414 | PreviewKind::Yaml
3415 | PreviewKind::Archive => {}
3416 }
3417 }
3418
3419 fn append_document(
3422 &mut self,
3423 path: &Path,
3424 kind: PreviewKind,
3425 label_start: i32,
3426 container: >k::Box,
3427 ) {
3428 if !self.documents.contains_key(path) {
3429 let data = preview::load_document(path, kind);
3430 self.documents.insert(path.to_path_buf(), data);
3431 }
3432 let Some(Some(data)) = self.documents.get(path) else {
3433 return;
3434 };
3435 match data {
3436 DocumentData::Lines(lines) => append_text_lines(container, label_start, lines),
3437 DocumentData::Structured { lines, status } => {
3438 append_text_lines(container, label_start, lines);
3439 append_status(container, label_start, status);
3440 }
3441 DocumentData::Table(table) => append_table(container, label_start, table),
3442 DocumentData::Archive(archive) => append_archive(container, label_start, archive),
3443 }
3444 }
3445
3446 fn expand_dir(&mut self, path: &Path, sender: &ComponentSender<Self>) {
3447 let Some(tree) = self.tree.as_mut() else {
3448 return;
3449 };
3450 if let Err(err) = tree.expand(path, &StdDirSource) {
3451 self.status(format!("Could not open {}: {err}", path.display()), sender);
3452 }
3453 self.refresh_rows();
3454 }
3455
3456 fn activate(&mut self, path: &Path, sender: &ComponentSender<Self>) {
3457 let Some(index) = self.rows.iter().position(|r| r.path == path) else {
3458 return;
3459 };
3460 if self.rows[index].is_dir {
3461 self.open_root(path, sender);
3465 } else if let Err(err) = open_in_app(path) {
3466 self.status(err, sender);
3467 }
3468 }
3469
3470 fn confirm_trash(&mut self, paths: Vec<PathBuf>, sender: ComponentSender<Self>) {
3471 let message = match paths.as_slice() {
3472 [single] => {
3473 let name = single
3474 .file_name()
3475 .map(|n| n.to_string_lossy().into_owned())
3476 .unwrap_or_else(|| single.display().to_string());
3477 format!("Move \u{201c}{name}\u{201d} to trash?")
3478 }
3479 many => format!("Move {} items to trash?", many.len()),
3480 };
3481 let dialog = gtk::AlertDialog::builder()
3482 .modal(true)
3483 .message(message)
3484 .detail("The panel cannot restore trashed items.")
3485 .buttons(["Cancel", "Trash"])
3486 .cancel_button(0)
3487 .default_button(1)
3488 .build();
3489 dialog.choose(Some(&self.parent), None::<&gio::Cancellable>, move |res| {
3490 if matches!(res, Ok(1)) {
3491 sender.input(TreeMsg::ConfirmTrash(paths));
3492 }
3493 });
3494 }
3495
3496 fn confirm_permanent_delete(&mut self, paths: Vec<PathBuf>, sender: ComponentSender<Self>) {
3497 let message = match paths.as_slice() {
3498 [single] => {
3499 let name = single
3500 .file_name()
3501 .map(|n| n.to_string_lossy().into_owned())
3502 .unwrap_or_else(|| single.display().to_string());
3503 format!("Permanently delete \u{201c}{name}\u{201d}?")
3504 }
3505 many => format!("Permanently delete {} items?", many.len()),
3506 };
3507 let dialog = gtk::AlertDialog::builder()
3508 .modal(true)
3509 .message(message)
3510 .detail("This cannot be undone. Files will not go to the trash.")
3511 .buttons(["Cancel", "Delete Permanently"])
3512 .cancel_button(0)
3513 .default_button(1)
3514 .build();
3515 dialog.choose(Some(&self.parent), None::<&gio::Cancellable>, move |res| {
3516 if matches!(res, Ok(1)) {
3517 sender.input(TreeMsg::ConfirmPermanentDelete(paths));
3518 }
3519 });
3520 }
3521
3522 fn open_menu(&mut self, path: &Path) {
3523 let in_selection = self.selected.len() > 1 && self.selected.iter().any(|p| p == path);
3527 if !in_selection {
3528 self.select(path);
3529 } else if let Some(i) = self.rows.iter().position(|r| r.path == path) {
3530 self.cursor = Some(i);
3531 }
3532 if let Some(previous) = self.popover.take() {
3533 previous.unparent();
3534 }
3535 self.menu_target = Some(path.to_path_buf());
3536 self.menu_point = None;
3537 }
3538
3539 fn open_menu_at(&mut self, path: &Path, at: (f64, f64)) {
3543 if let Some(previous) = self.popover.take() {
3544 previous.unparent();
3545 }
3546 self.menu_target = Some(path.to_path_buf());
3547 self.menu_point = Some(at);
3548 }
3549
3550 fn paste(&mut self, sender: &ComponentSender<Self>) {
3551 let Some(clip) = self.clipboard.clone() else {
3552 self.paste_from_system_clipboard(sender);
3555 return;
3556 };
3557 let Some(dir) = self.target_dir() else {
3558 return;
3559 };
3560 let mut count = 0usize;
3561 let mut failed: Option<String> = None;
3562 for src in clip.paths {
3563 if src == dir {
3564 continue;
3565 }
3566 let result = match clip.op {
3567 ClipboardOp::Copy => self.ops.copy(&src, &dir),
3568 ClipboardOp::Cut => self.ops.move_(&src, &dir),
3569 };
3570 match result {
3571 Ok(dest) => {
3572 count += 1;
3573 self.apply_change(Change::Created { path: dest });
3574 if clip.op == ClipboardOp::Cut {
3575 self.apply_change(Change::Removed { path: src });
3576 }
3577 }
3578 Err(err) => {
3579 failed = Some(err.to_string());
3580 break;
3581 }
3582 }
3583 }
3584 if let Some(failed) = failed {
3585 self.status(failed, sender);
3586 return;
3587 }
3588 let verb = match clip.op {
3589 ClipboardOp::Copy => "Copied",
3590 ClipboardOp::Cut => "Moved",
3591 };
3592 self.status(format!("{verb} {count} item(s) into {}", dir.display()), sender);
3593 if clip.op == ClipboardOp::Cut {
3594 self.clipboard = None;
3595 }
3596 self.cursor_snap();
3597 }
3598
3599 fn paste_from_system_clipboard(&mut self, sender: &ComponentSender<Self>) {
3603 if self.target_dir().is_none() {
3604 return;
3605 }
3606 let s = sender.clone();
3607 read_clipboard_files(move |paths| s.input(TreeMsg::PastePaths(paths)));
3608 }
3609
3610 fn paste_paths(&mut self, paths: Vec<PathBuf>, sender: &ComponentSender<Self>) {
3612 let Some(dir) = self.target_dir() else {
3613 return;
3614 };
3615 if paths.is_empty() {
3616 self.status("Clipboard has no files to paste".to_string(), sender);
3617 return;
3618 }
3619 let mut count = 0usize;
3620 let mut failed: Option<String> = None;
3621 for src in paths {
3622 if src == dir {
3623 continue;
3624 }
3625 match self.ops.copy(&src, &dir) {
3626 Ok(dest) => {
3627 count += 1;
3628 self.apply_change(Change::Created { path: dest });
3629 }
3630 Err(err) => {
3631 failed = Some(err.to_string());
3632 break;
3633 }
3634 }
3635 }
3636 if let Some(failed) = failed {
3637 self.status(failed, sender);
3638 return;
3639 }
3640 self.status(format!("Pasted {count} item(s) into {}", dir.display()), sender);
3641 self.cursor_snap();
3642 }
3643
3644 fn request_drop_into(
3649 &mut self,
3650 target: PathBuf,
3651 sources: Vec<PathBuf>,
3652 copy: bool,
3653 sender: &ComponentSender<Self>,
3654 ) {
3655 match resolve_drop(&target, sources, copy) {
3656 DropPlan::Ignore => {}
3657 DropPlan::Reject(message) => self.status(message, sender),
3658 DropPlan::Copy(sources) => self.drop_into(&target, &sources, true, sender),
3659 DropPlan::Move(sources) => {
3660 if self.config.confirm_drop_move {
3661 self.confirm_move(target, sources, sender);
3662 } else {
3663 self.drop_into(&target, &sources, false, sender);
3664 }
3665 }
3666 }
3667 }
3668
3669 fn confirm_move(
3673 &mut self,
3674 target: PathBuf,
3675 sources: Vec<PathBuf>,
3676 sender: &ComponentSender<Self>,
3677 ) {
3678 let target_name = target
3679 .file_name()
3680 .map(|n| n.to_string_lossy().into_owned())
3681 .unwrap_or_else(|| target.display().to_string());
3682 let message = match sources.as_slice() {
3683 [single] => {
3684 let name = single
3685 .file_name()
3686 .map(|n| n.to_string_lossy().into_owned())
3687 .unwrap_or_else(|| single.display().to_string());
3688 format!("Move \u{201c}{name}\u{201d} into \u{201c}{target_name}\u{201d}?")
3689 }
3690 many => format!("Move {} items into \u{201c}{target_name}\u{201d}?", many.len()),
3691 };
3692 let dialog = gtk::AlertDialog::builder()
3693 .modal(true)
3694 .message(message)
3695 .detail("Hold Ctrl while dropping to copy instead of move.")
3696 .buttons(["Cancel", "Move"])
3697 .cancel_button(0)
3698 .default_button(1)
3699 .build();
3700 let s = sender.clone();
3701 dialog.choose(Some(&self.parent), None::<&gio::Cancellable>, move |res| {
3702 if matches!(res, Ok(1)) {
3703 s.input(TreeMsg::DropIntoConfirmed { target, sources });
3704 }
3705 });
3706 }
3707
3708 fn drop_into(
3712 &mut self,
3713 target: &Path,
3714 sources: &[PathBuf],
3715 copy: bool,
3716 sender: &ComponentSender<Self>,
3717 ) {
3718 let mut count = 0usize;
3719 let mut failed: Option<String> = None;
3720 for src in sources {
3721 if src == target || src.parent() == Some(target) {
3722 continue; }
3724 if !copy && target.starts_with(src) {
3725 failed = Some("Cannot move a folder into itself".to_string());
3726 break;
3727 }
3728 let result = if copy { self.ops.copy(src, target) } else { self.ops.move_(src, target) };
3729 match result {
3730 Ok(dest) => {
3731 count += 1;
3732 self.apply_change(Change::Created { path: dest });
3733 if !copy {
3734 self.apply_change(Change::Removed { path: src.clone() });
3735 }
3736 }
3737 Err(err) => {
3738 failed = Some(err.to_string());
3739 break;
3740 }
3741 }
3742 }
3743 if let Some(failed) = failed {
3744 self.status(failed, sender);
3745 return;
3746 }
3747 if count > 0 {
3748 let verb = if copy { "Copied" } else { "Moved" };
3749 self.status(format!("{verb} {count} item(s) into {}", target.display()), sender);
3750 }
3751 self.cursor_snap();
3752 }
3753
3754 fn create_entry(&mut self, is_dir: bool, sender: &ComponentSender<Self>) {
3755 let Some(dir) = self.target_dir() else {
3756 return;
3757 };
3758 let name = fresh_name(&dir, if is_dir { "new folder" } else { "new file" });
3759 let result = if is_dir {
3760 self.ops.create_dir(&dir, &name)
3761 } else {
3762 self.ops.create_file(&dir, &name)
3763 };
3764 match result {
3765 Ok(new_path) => {
3766 self.apply_change(Change::Created { path: new_path.clone() });
3767 self.renaming = Some(new_path.clone());
3768 self.renaming_state.set(true);
3769 self.select(&new_path);
3770 }
3771 Err(err) => self.status(format!("Could not create: {err}"), sender),
3772 }
3773 }
3774
3775 fn target_dir(&self) -> Option<PathBuf> {
3778 if let Some(path) = self.cursor_path() {
3779 let is_dir = self.rows.iter().find(|r| r.path == path).map(|r| r.is_dir).unwrap_or(false);
3780 if is_dir {
3781 return Some(path);
3782 }
3783 return path.parent().map(Path::to_path_buf);
3784 }
3785 self.tree.as_ref().map(|t| t.root().to_path_buf())
3786 }
3787
3788 fn apply_change(&mut self, change: Change) {
3789 if let Some(tree) = self.tree.as_mut() {
3790 tree.apply(&change, &StdDirSource);
3791 self.refresh_rows();
3792 }
3793 }
3794
3795 fn toggle_hidden(&mut self, sender: &ComponentSender<Self>) {
3798 let show = !self.config.show_hidden;
3799 self.config.show_hidden = show;
3800 if let Some(tree) = self.tree.as_mut() {
3801 tree.set_show_hidden(show);
3802 tree.reload_all(&StdDirSource);
3803 self.refresh_rows();
3804 }
3805 let label = if show { "Showing hidden files." } else { "Hiding hidden files." };
3806 self.status(label.to_string(), sender);
3807 }
3808
3809 fn set_sort_key(&mut self, key: SortKey, sender: &ComponentSender<Self>) {
3811 let mut opts = self.config.sort_options();
3812 opts.key = key;
3813 self.config.sort_key = key.as_str().to_owned();
3814 if let Some(tree) = self.tree.as_mut() {
3815 tree.set_sort(opts);
3816 tree.reload_all(&StdDirSource);
3817 self.refresh_rows();
3818 }
3819 self.status(format!("Sorting by {}.", key.as_str()), sender);
3820 }
3821
3822 fn toggle_sort_direction(&mut self, sender: &ComponentSender<Self>) {
3824 let mut opts = self.config.sort_options();
3825 opts.ascending = !opts.ascending;
3826 self.config.sort_ascending = opts.ascending;
3827 if let Some(tree) = self.tree.as_mut() {
3828 tree.set_sort(opts);
3829 tree.reload_all(&StdDirSource);
3830 self.refresh_rows();
3831 }
3832 let dir = if opts.ascending { "ascending" } else { "descending" };
3833 self.status(format!("Sort order: {dir}."), sender);
3834 }
3835
3836 fn status(&mut self, message: String, sender: &ComponentSender<Self>) {
3837 let _ = sender.output(TreeOutput::Status(message));
3838 }
3839
3840 fn run_shortcut(&mut self, target: ShortcutTarget, sender: &ComponentSender<Self>) {
3845 if let ShortcutTarget::Builtin(builtin) = &target
3848 && builtin.is_pane_action()
3849 {
3850 let _ = sender.output(TreeOutput::PaneAction(*builtin));
3851 return;
3852 }
3853 let has_selection = self.cursor.is_some() || !self.selected.is_empty();
3857 let path = match self.cursor_path() {
3858 Some(path) => path,
3859 None => match self.tree.as_ref().map(|t| t.root().to_path_buf()) {
3860 Some(root) => root,
3861 None => return,
3862 },
3863 };
3864
3865 if let ShortcutTarget::Command(cmd) = &target {
3867 if !has_selection {
3868 return;
3869 }
3870 let command = action_command(&cmd.command, &path);
3871 match spawn_command(&command) {
3872 Ok(()) => {
3873 self.status(format!("Ran {} on {}", cmd.command, path.display()), sender);
3874 }
3875 Err(err) => {
3876 self.status(format!("Could not run {}: {err}", cmd.command), sender);
3877 }
3878 }
3879 return;
3880 }
3881 let ShortcutTarget::Builtin(builtin) = target else {
3882 return;
3883 };
3884 if builtin == BuiltinAction::Separator
3885 || (builtin.is_directory_only() && !path.is_dir())
3886 {
3887 return;
3888 }
3889 if matches!(builtin, BuiltinAction::OpenWithDefault) && path.is_dir() {
3890 return;
3891 }
3892 if builtin == BuiltinAction::ViewThumbnail
3893 && !path.is_dir()
3894 && preview::detect(&path).is_none()
3895 {
3896 return;
3897 }
3898 if !has_selection && needs_selection(builtin) {
3900 return;
3901 }
3902 if has_selection && !self.selected.iter().any(|p| p == &path) {
3906 self.select(&path);
3907 }
3908 let selection = self.selected.clone();
3911 sender.input(action_message(builtin, &path, &selection));
3912 }
3913}
3914
3915fn fresh_name(dir: &Path, stem: &str) -> String {
3920 for n in 0.. {
3921 let name = if n == 0 { stem.to_string() } else { format!("{stem} {n}") };
3922 if !dir.join(&name).exists() {
3923 return name;
3924 }
3925 }
3926 unreachable!("the loop above always finds a free name")
3927}
3928
3929fn open_in_app(path: &Path) -> Result<String, String> {
3938 let content_type = content_type_for(path)
3939 .ok_or_else(|| format!("Could not determine the type of {}", path.display()))?;
3940 let app = gio::AppInfo::default_for_type(&content_type, false)
3941 .ok_or_else(|| format!("No application is registered to open {}", path.display()))?;
3942 let file = gio::File::for_path(path);
3943 app.launch(&[file], None::<&gio::AppLaunchContext>)
3944 .map_err(|err| format!("Could not open {}: {err}", path.display()))?;
3945 Ok(app.display_name().to_string())
3946}
3947
3948fn content_type_for(path: &Path) -> Option<glib::GString> {
3955 let file = gio::File::for_path(path);
3956 let info = file
3957 .query_info(
3958 gio::FILE_ATTRIBUTE_STANDARD_CONTENT_TYPE,
3959 gio::FileQueryInfoFlags::NONE,
3960 None::<&gio::Cancellable>,
3961 )
3962 .ok()?;
3963 info.content_type()
3964}
3965
3966fn default_app_label(path: &Path) -> Option<String> {
3970 let content_type = content_type_for(path)?;
3971 gio::AppInfo::default_for_type(&content_type, false).map(|app| {
3972 format!("Open With {}", app.display_name())
3973 })
3974}
3975
3976#[allow(deprecated)] fn open_with_dialog(parent: >k::Window, path: &Path, sender: &ComponentSender<Tree>) {
3980 let Some(content_type) = content_type_for(path) else {
3981 let _ = sender.output(TreeOutput::Status(format!(
3982 "Could not determine the type of {}",
3983 path.display()
3984 )));
3985 return;
3986 };
3987 let dialog = gtk::AppChooserDialog::for_content_type(
3988 Some(parent),
3989 gtk::DialogFlags::MODAL,
3990 &content_type,
3991 );
3992 dialog.set_title(Some(&format!("Open {} with...", path.display())));
3993
3994 if gtk4_layer_shell::is_supported() && !dialog.is_layer_window() {
4000 dialog.init_layer_shell();
4001 dialog.set_namespace(Some(crate::ui::LAYER_NAMESPACE));
4002 dialog.set_layer(Layer::Overlay);
4003 dialog.set_keyboard_mode(KeyboardMode::Exclusive);
4004 dialog.set_default_size(540, 620);
4005 }
4007
4008 let dialog = dialog.clone();
4009 let path = path.to_path_buf();
4010 let s = sender.clone();
4011 dialog.connect_response(move |dialog, response| {
4012 if response == gtk::ResponseType::Ok
4013 && let Some(app) = dialog.app_info()
4014 {
4015 let file = gio::File::for_path(&path);
4016 match app.launch(&[file], None::<&gio::AppLaunchContext>) {
4017 Ok(()) => {
4018 let _ = s.output(TreeOutput::Status(format!(
4019 "Opened {} with {}",
4020 path.display(),
4021 app.display_name()
4022 )));
4023 }
4024 Err(err) => {
4025 let _ = s.output(TreeOutput::Status(format!(
4026 "Could not open {} with {}: {err}",
4027 path.display(),
4028 app.display_name()
4029 )));
4030 }
4031 }
4032 }
4033 dialog.close();
4034 });
4035 dialog.present();
4036}
4037
4038fn set_clipboard_text(text: &str) {
4040 if let Some(display) = gdk::Display::default() {
4041 display.clipboard().set_text(text);
4042 }
4043}
4044
4045const URI_LIST_MIME: &str = "text/uri-list";
4048
4049const GNOME_CLIP_MIME: &str = "x-special/gnome-copied-files";
4053
4054fn read_clipboard_files(done: impl FnOnce(Vec<PathBuf>) + 'static) {
4059 let Some(display) = gdk::Display::default() else {
4060 done(Vec::new());
4061 return;
4062 };
4063 let clipboard = display.clipboard();
4064 let formats = clipboard.formats();
4065 let has_uri = formats.contain_mime_type(URI_LIST_MIME);
4066 let has_gnome = formats.contain_mime_type(GNOME_CLIP_MIME);
4067 if !has_uri && !has_gnome {
4068 done(Vec::new());
4069 return;
4070 }
4071 clipboard.read_text_async(gio::Cancellable::NONE, move |result| {
4072 let text = result.ok().flatten().unwrap_or_default();
4073 done(parse_clipboard_uris(&text, has_gnome && !has_uri));
4074 });
4075}
4076
4077fn parse_clipboard_uris(text: &str, verb_line: bool) -> Vec<PathBuf> {
4083 text.lines()
4084 .map(str::trim)
4085 .filter(|line| !line.is_empty() && !line.starts_with('#'))
4086 .filter_map(|line| {
4087 if verb_line && (line.eq_ignore_ascii_case("copy") || line.eq_ignore_ascii_case("cut")) {
4088 return None;
4089 }
4090 let (path, _host) = glib::filename_from_uri(line).ok()?;
4091 path.is_absolute().then_some(path)
4092 })
4093 .collect()
4094}
4095
4096fn spawn_command(cmd: &[String]) -> Result<(), std::io::Error> {
4097 if cmd.is_empty() {
4098 return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "empty command"));
4099 }
4100 let mut process = std::process::Command::new(&cmd[0]);
4101 for arg in &cmd[1..] {
4102 process.arg(arg);
4103 }
4104 process.spawn().map(|_| ())
4105}
4106
4107#[cfg(test)]
4108mod tests {
4109 use super::*;
4110
4111 #[test]
4112 fn preview_menu_label_reflects_the_file_kind() {
4113 use tempfile::tempdir;
4114 let dir = tempdir().unwrap();
4115 let write = |name: &str, bytes: &[u8]| {
4116 let path = dir.path().join(name);
4117 std::fs::write(&path, bytes).unwrap();
4118 path
4119 };
4120 let label = |path: &Path| {
4121 menu_label(
4122 &ContextAction::Builtin(BuiltinAction::ViewThumbnail),
4123 path,
4124 PanelSide::Left,
4125 )
4126 };
4127 let png = write("a.png", &[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]);
4129 assert_eq!(label(&png), "View Thumbnail");
4130 let text = write("a.txt", b"hello\n");
4131 assert_eq!(label(&text), "Show Preview");
4132 let json = write("a.json", br#"{"a":1}"#);
4133 assert_eq!(label(&json), "Show Preview");
4134 let zip = write("a.zip", b"PK\x03\x04rest");
4136 assert_eq!(label(&zip), "Show Contents");
4137 assert_eq!(label(dir.path()), "View Thumbnail");
4139 }
4140
4141 #[test]
4142 fn thumbnail_width_tracks_the_panel_and_stays_positive() {
4143 let panel = PanelConfig { width: 300, margin: 0, ..PanelConfig::default() };
4144 assert_eq!(thumbnail_content_width(panel), 276);
4145
4146 let panel = PanelConfig { width: 500, margin: 10, ..PanelConfig::default() };
4147 assert_eq!(thumbnail_content_width(panel), 456);
4148
4149 let tiny = PanelConfig { width: 40, margin: 0, ..PanelConfig::default() };
4150 assert_eq!(thumbnail_content_width(tiny), 64);
4151 }
4152
4153 #[test]
4154 fn accelerator_normalization_supports_both_syntaxes() {
4155 assert_eq!(normalize_accelerator("Ctrl+x"), "<Control>x");
4157 assert_eq!(normalize_accelerator("Ctrl+Shift+m"), "<Control><Shift>m");
4158 assert_eq!(normalize_accelerator("Shift+Delete"), "<Shift>Delete");
4159 assert_eq!(normalize_accelerator("ctrl+alt+t"), "<Control><Alt>t");
4160 assert_eq!(normalize_accelerator("<Control>x"), "<Control>x");
4162 assert_eq!(normalize_accelerator("F2"), "F2");
4163 assert_eq!(normalize_accelerator("Delete"), "Delete");
4164 }
4165
4166 fn row(path: &str) -> VisibleRow {
4167 VisibleRow {
4168 path: PathBuf::from(path),
4169 name: path.rsplit('/').next().unwrap().to_string(),
4170 depth: 0,
4171 is_dir: false,
4172 is_symlink: false,
4173 expanded: false,
4174 has_children: false,
4175 matches: false,
4176 }
4177 }
4178
4179 #[test]
4180 fn parse_clipboard_uris_handles_both_formats() {
4181 let uri_list = "#comment\r\nfile:///tmp/a.txt\r\n\r\nfile:///tmp/b%20c.txt\r\n";
4183 assert_eq!(
4184 parse_clipboard_uris(uri_list, false),
4185 vec![PathBuf::from("/tmp/a.txt"), PathBuf::from("/tmp/b c.txt")]
4186 );
4187 let gnome = "cut\nfile:///home/u/x\n";
4189 assert_eq!(parse_clipboard_uris(gnome, true), vec![PathBuf::from("/home/u/x")]);
4190 assert_eq!(parse_clipboard_uris("copy\n", false), Vec::<PathBuf>::new());
4192 assert_eq!(
4194 parse_clipboard_uris("https://example.com/x\nrelative/path\nfile:///ok\n", false),
4195 vec![PathBuf::from("/ok")]
4196 );
4197 assert!(parse_clipboard_uris("", false).is_empty());
4198 }
4199
4200 #[test]
4201 fn icon_name_matches_extensions_case_insensitively() {
4202 assert_eq!(icon_name(&row("/a/Photo.JPG")), "image-x-generic-symbolic");
4203 assert_eq!(icon_name(&row("/a/song.Flac")), "audio-x-generic-symbolic");
4204 assert_eq!(icon_name(&row("/a/clip.mkv")), "video-x-generic-symbolic");
4205 assert_eq!(icon_name(&row("/a/archive.tar")), "package-x-generic-symbolic");
4206 assert_eq!(icon_name(&row("/a/doc.pdf")), "application-pdf-symbolic");
4207 assert_eq!(icon_name(&row("/a/notes.txt")), "text-x-generic-symbolic");
4208 let mut dir = row("/a/pictures");
4210 dir.is_dir = true;
4211 assert_eq!(icon_name(&dir), "folder-symbolic");
4212 dir.expanded = true;
4213 assert_eq!(icon_name(&dir), "folder-open-symbolic");
4214 assert_eq!(icon_name(&row("/a/.bashrc")), "text-x-generic-symbolic");
4216 }
4217
4218 #[test]
4219 fn resolve_drop_filters_noops_and_detects_cycles() {
4220 let target = Path::new("/a/b");
4221 assert_eq!(resolve_drop(target, vec![PathBuf::from("/a/b")], false), DropPlan::Ignore);
4223 assert_eq!(resolve_drop(target, vec![PathBuf::from("/a/b/c")], false), DropPlan::Ignore);
4224 assert!(matches!(
4226 resolve_drop(target, vec![PathBuf::from("/a")], false),
4227 DropPlan::Reject(_)
4228 ));
4229 assert!(matches!(
4231 resolve_drop(target, vec![PathBuf::from("/x/y")], false),
4232 DropPlan::Move(_)
4233 ));
4234 assert!(matches!(
4235 resolve_drop(target, vec![PathBuf::from("/x/y")], true),
4236 DropPlan::Copy(_)
4237 ));
4238 }
4239
4240 #[test]
4241 fn drag_paths_uses_the_selection_only_when_the_row_is_in_it() {
4242 let sel = vec![PathBuf::from("/r/a"), PathBuf::from("/r/b")];
4243 assert_eq!(drag_paths(&sel, Path::new("/r/b")), sel);
4245 assert_eq!(
4247 drag_paths(&sel, Path::new("/r/c")),
4248 vec![PathBuf::from("/r/c")]
4249 );
4250 assert_eq!(
4252 drag_paths(&[PathBuf::from("/r/a")], Path::new("/r/a")),
4253 vec![PathBuf::from("/r/a")]
4254 );
4255 assert_eq!(
4257 drag_paths(&[], Path::new("/r/z")),
4258 vec![PathBuf::from("/r/z")]
4259 );
4260 }
4261
4262 #[test]
4263 fn index_range_is_inclusive_and_order_independent() {
4264 assert_eq!(index_range(2, 2).collect::<Vec<_>>(), vec![2]);
4265 assert_eq!(index_range(1, 4).collect::<Vec<_>>(), vec![1, 2, 3, 4]);
4266 assert_eq!(index_range(4, 1).collect::<Vec<_>>(), vec![1, 2, 3, 4]);
4268 }
4269
4270 #[test]
4271 fn format_uri_list_serializes_file_uris() {
4272 let text = format_uri_list(&[PathBuf::from("/tmp/a.txt"), PathBuf::from("/tmp/b c.txt")]);
4273 assert_eq!(text, "file:///tmp/a.txt\r\nfile:///tmp/b%20c.txt\r\n");
4274 assert!(format_uri_list(&[]).is_empty());
4275 assert_eq!(
4277 parse_clipboard_uris(&text, false),
4278 vec![PathBuf::from("/tmp/a.txt"), PathBuf::from("/tmp/b c.txt")]
4279 );
4280 }
4281
4282 #[test]
4283 fn typeahead_finds_the_next_matching_row_and_wraps() {
4284 let rows = vec![row("/r/apple"), row("/r/banana"), row("/r/apricot"), row("/r/cherry")];
4285 assert_eq!(typeahead_target(&rows, "ap", None), Some(0));
4288 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);
4291 }
4292
4293 #[test]
4294 fn typeahead_buffer_resets_after_a_pause_and_caps_length() {
4295 let mut ta = TypeAhead::default();
4296 let t0 = std::time::Instant::now();
4297 assert_eq!(ta.push('a', t0).as_deref(), Some("a"));
4298 assert_eq!(ta.push('b', t0 + Duration::from_millis(100)).as_deref(), Some("ab"));
4299 assert_eq!(ta.push('c', t0 + Duration::from_millis(2000)).as_deref(), Some("c"));
4301 assert_eq!(ta.push('\u{1}', t0), None);
4303 }
4304}