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_HEIGHT: i32 = 260;
2612
2613fn append_text_lines(container: >k::Box, margin_start: i32, lines: &DocumentLines) {
2617 if lines.lines.is_empty() {
2618 return;
2619 }
2620 let mut text = lines.lines.join("\n");
2621 if lines.more {
2622 text.push_str("\n…");
2623 }
2624
2625 let view = gtk::TextView::new();
2626 view.add_css_class("tree-preview-text");
2627 view.set_editable(false);
2628 view.set_cursor_visible(false);
2629 view.set_can_focus(true);
2630 view.set_focus_on_click(true);
2631 view.set_monospace(true);
2632 view.set_wrap_mode(gtk::WrapMode::WordChar);
2633 view.set_left_margin(6);
2634 view.set_right_margin(6);
2635 view.set_top_margin(3);
2636 view.set_bottom_margin(3);
2637 let buffer = view.buffer();
2638 buffer.set_text(&text);
2639 apply_syntax(&buffer, &lines.syntax);
2640
2641 let scrolled = gtk::ScrolledWindow::new();
2645 scrolled.add_css_class("tree-preview-scroll");
2646 scrolled.set_policy(gtk::PolicyType::Never, gtk::PolicyType::Automatic);
2647 scrolled.set_propagate_natural_height(true);
2648 scrolled.set_max_content_height(PREVIEW_TEXT_MAX_HEIGHT);
2649 scrolled.set_child(Some(&view));
2650 scrolled.set_margin_start(margin_start);
2651 scrolled.set_margin_bottom(6);
2652 container.append(&scrolled);
2653}
2654
2655fn apply_syntax(buffer: >k::TextBuffer, spans: &[Span]) {
2658 if spans.is_empty() {
2659 return;
2660 }
2661 let palette = highlight::palette();
2662 let table = buffer.tag_table();
2663 let mut tags: HashMap<TokenClass, gtk::TextTag> = HashMap::new();
2664 for span in spans {
2665 let tag = tags.entry(span.class).or_insert_with(|| {
2666 let tag = gtk::TextTag::new(None);
2667 tag.set_foreground(Some(palette.color(span.class)));
2668 table.add(&tag);
2669 tag
2670 });
2671 let start = buffer.iter_at_offset(span.start as i32);
2672 let end = buffer.iter_at_offset(span.end as i32);
2673 buffer.apply_tag(tag, &start, &end);
2674 }
2675}
2676
2677fn append_status(container: >k::Box, margin_start: i32, status: &ParseStatus) {
2679 if status.detail.is_empty() {
2680 return;
2681 }
2682 let label = gtk::Label::new(Some(&status.detail));
2683 label.add_css_class("tree-preview-status");
2684 label.add_css_class(if status.ok { "ok" } else { "error" });
2685 label.set_xalign(0.0);
2686 label.set_hexpand(true);
2687 label.set_wrap(true);
2688 label.set_wrap_mode(pango::WrapMode::WordChar);
2689 label.set_margin_start(margin_start);
2690 label.set_margin_bottom(6);
2691 container.append(&label);
2692}
2693
2694fn append_table(container: >k::Box, margin_start: i32, table: &TableData) {
2697 if table.rows.is_empty() {
2698 return;
2699 }
2700 let grid = gtk::Grid::new();
2701 grid.add_css_class("tree-preview-table");
2702 grid.set_margin_start(margin_start);
2703 grid.set_margin_bottom(6);
2704 grid.set_column_spacing(10);
2705 grid.set_row_spacing(1);
2706 for (r, row) in table.rows.iter().enumerate() {
2707 for (c, cell) in row.iter().enumerate() {
2708 let label = gtk::Label::new(Some(cell));
2709 label.set_xalign(0.0);
2710 label.set_ellipsize(pango::EllipsizeMode::End);
2711 label.set_max_width_chars(PREVIEW_CELL_CHARS);
2712 if r == 0 {
2713 label.add_css_class("tree-preview-header");
2714 }
2715 grid.attach(&label, c as i32, r as i32, 1, 1);
2716 }
2717 }
2718 container.append(&grid);
2719 if table.more {
2720 let more = gtk::Label::new(Some("…"));
2721 more.add_css_class("tree-preview-status");
2722 more.set_xalign(0.0);
2723 more.set_margin_start(margin_start);
2724 container.append(&more);
2725 }
2726}
2727
2728fn append_archive(container: >k::Box, margin_start: i32, archive: &ArchiveData) {
2731 let list = gtk::Box::new(gtk::Orientation::Vertical, 1);
2732 list.add_css_class("tree-preview-archive");
2733 list.set_margin_start(margin_start);
2734 list.set_margin_bottom(6);
2735 match archive {
2736 ArchiveData::Unsupported(reason) => {
2737 let label = gtk::Label::new(Some(reason));
2738 label.add_css_class("tree-preview-status");
2739 label.set_xalign(0.0);
2740 label.set_wrap(true);
2741 list.append(&label);
2742 }
2743 ArchiveData::Listed(listing) => {
2744 for entry in &listing.entries {
2745 let row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
2746 let name = gtk::Label::new(Some(&entry.name));
2747 name.add_css_class("tree-preview-archive-name");
2748 name.set_xalign(0.0);
2749 name.set_hexpand(true);
2750 name.set_ellipsize(pango::EllipsizeMode::Middle);
2751 row.append(&name);
2752 if !entry.is_dir {
2753 let size = gtk::Label::new(Some(&format_size(entry.size)));
2754 size.add_css_class("tree-preview-archive-size");
2755 size.set_xalign(1.0);
2756 row.append(&size);
2757 }
2758 list.append(&row);
2759 }
2760 if listing.more {
2761 let more = gtk::Label::new(Some("…"));
2762 more.add_css_class("tree-preview-status");
2763 more.set_xalign(0.0);
2764 list.append(&more);
2765 }
2766 }
2767 }
2768 container.append(&list);
2769}
2770
2771impl Tree {
2772 fn open_root(&mut self, path: &Path, sender: &ComponentSender<Self>) {
2773 if let Some(id) = self.flush_source.take() {
2774 id.remove();
2775 }
2776 self.pending.clear();
2777
2778 let mut tree = TreeModel::new(
2779 path.to_path_buf(),
2780 self.config.sort_options(),
2781 self.config.show_hidden,
2782 );
2783 if let Err(err) = tree.expand(path, &StdDirSource) {
2784 self.status(format!("Could not open {}: {err}", path.display()), sender);
2785 return;
2786 }
2787 self.tree = Some(tree);
2788 self.refresh_rows();
2789 self.cursor = if self.rows.is_empty() { None } else { Some(0) };
2790 self.selected = Vec::new();
2791 self.renaming = None;
2792 self.renaming_state.set(false);
2793 self.rename_entry = None;
2794 self.menu_target = None;
2795 self.thumbnails.clear();
2796 self.preview_kinds.clear();
2797 self.documents.clear();
2798 self.thumb_cache.clear();
2799 self.playing.clear();
2800 self.stop_all_videos();
2801 self.media.clear();
2802 self.audio.clear();
2803 self.gif_anims.borrow_mut().clear();
2804 self.gif_widgets.borrow_mut().clear();
2805 self.audio_widgets.borrow_mut().clear();
2806 if let Some(previous) = self.popover.take() {
2807 previous.unparent();
2808 }
2809
2810 let root = path.to_path_buf();
2811 let watcher_sender = sender.clone();
2812 self._watcher = spawn_watcher(path, move |change| {
2813 watcher_sender.input(TreeMsg::FsChange(change));
2814 })
2815 .ok();
2816 self.watched.clear();
2817 if self._watcher.is_some() {
2818 self.watched.insert(root.clone());
2819 }
2820
2821 let _ = sender.output(TreeOutput::RootChanged(root));
2822 }
2823
2824 fn refresh_rows(&mut self) {
2825 if let Some(tree) = self.tree.as_ref() {
2826 tree.visible_rows_into(&mut self.rows);
2827 } else {
2828 self.rows.clear();
2829 }
2830 }
2831
2832 fn reconcile_watches(&mut self) {
2838 let Some(watcher) = self._watcher.as_mut() else {
2839 return;
2840 };
2841 let Some(model) = self.tree.as_ref() else {
2842 return;
2843 };
2844 let desired: HashSet<PathBuf> = model.expanded_loaded_dirs().into_iter().collect();
2845
2846 let stale: Vec<PathBuf> = self.watched.difference(&desired).cloned().collect();
2847 for path in stale {
2848 let _ = watcher.unwatch(&path);
2849 self.watched.remove(&path);
2850 }
2851
2852 let fresh: Vec<PathBuf> = desired.difference(&self.watched).cloned().collect();
2853 for path in fresh {
2854 if watcher.watch(&path, RecursiveMode::NonRecursive).is_ok() {
2855 self.watched.insert(path);
2856 }
2857 }
2858 }
2859
2860 fn cursor_path(&self) -> Option<PathBuf> {
2861 self.cursor.and_then(|i| self.rows.get(i)).map(|r| r.path.clone())
2862 }
2863
2864 fn cursor_delta(&mut self, delta: isize) {
2865 if self.rows.is_empty() {
2866 return;
2867 }
2868 let next = (self.cursor.unwrap_or(0) as isize + delta).clamp(0, self.rows.len() as isize - 1) as usize;
2869 let path = self.rows[next].path.clone();
2870 self.cursor = Some(next);
2871 self.set_single_selection(path);
2872 self.selection_dirty = true;
2873 }
2874
2875 fn cursor_extend(&mut self, delta: isize, _keep_anchor: bool) {
2882 if self.rows.is_empty() {
2883 return;
2884 }
2885 let anchor = self
2886 .anchor
2887 .clone()
2888 .or_else(|| self.cursor.map(|i| self.rows[i].path.clone()))
2889 .unwrap_or_else(|| self.rows[0].path.clone());
2890 let next = (self.cursor.unwrap_or(0) as isize + delta)
2891 .clamp(0, self.rows.len() as isize - 1) as usize;
2892 self.cursor = Some(next);
2893 self.anchor = Some(anchor.clone());
2894 let (Some(a), Some(b)) = (self.rows.iter().position(|r| r.path == anchor), Some(next)) else {
2895 return;
2896 };
2897 self.selected = self.rows[index_range(a, b)].iter().map(|r| r.path.clone()).collect();
2898 self.scroll_to = Some(next);
2899 self.selection_dirty = true;
2900 }
2901
2902 fn cursor_snap(&mut self) {
2903 let deselected = self.cursor.is_none();
2904 let prev = self.cursor_path();
2905 self.refresh_rows();
2906 if self.rows.is_empty() {
2907 self.cursor = None;
2908 self.selected.clear();
2909 return;
2910 }
2911 if deselected {
2912 self.cursor = None;
2914 return;
2915 }
2916 let index = prev
2917 .and_then(|p| self.rows.iter().position(|r| r.path == p))
2918 .unwrap_or(0);
2919 self.cursor = Some(index);
2920 }
2921
2922 fn select(&mut self, path: &Path) {
2923 if let Some(i) = self.rows.iter().position(|r| r.path == path) {
2924 self.cursor = Some(i);
2925 }
2926 self.selected = vec![path.to_path_buf()];
2927 }
2928
2929 fn press_default(&mut self, path: &Path, is_dir: bool, sender: &ComponentSender<Self>) {
2934 let now = std::time::Instant::now();
2937 let is_double = self.last_press.as_ref().is_some_and(|(last, at)| {
2938 last == path && now.duration_since(*at) < Duration::from_millis(400)
2939 });
2940 self.last_press = Some((path.to_path_buf(), now));
2941
2942 self.select(path);
2943 self.anchor = Some(path.to_path_buf());
2944 if is_dir {
2945 self.toggle_dir(path, sender);
2949 } else if is_double {
2950 self.activate(path, sender);
2952 }
2953 }
2954
2955 fn reveal_path(&mut self, path: PathBuf) {
2960 if let Some(i) = self.rows.iter().position(|r| r.path == path) {
2961 self.cursor = Some(i);
2962 self.selected = vec![path.clone()];
2963 self.anchor = Some(path);
2964 self.selection_dirty = true;
2965 self.scroll_to = Some(i);
2966 }
2967 }
2968
2969 fn range_select(&mut self, path: &Path) {
2973 let Some(anchor) = self.anchor.clone() else {
2974 self.select(path);
2975 self.anchor = Some(path.to_path_buf());
2976 return;
2977 };
2978 let (Some(a), Some(b)) = (
2979 self.rows.iter().position(|r| r.path == anchor),
2980 self.rows.iter().position(|r| r.path == path),
2981 ) else {
2982 self.select(path);
2983 return;
2984 };
2985 self.selected = self.rows[index_range(a, b)].iter().map(|r| r.path.clone()).collect();
2986 self.cursor = Some(b);
2987 }
2988
2989 fn set_single_selection(&mut self, path: PathBuf) {
2990 self.selected = vec![path.clone()];
2991 if let Some(i) = self.rows.iter().position(|r| r.path == path) {
2992 self.cursor = Some(i);
2993 }
2994 }
2995
2996 fn selected_for_clipboard(&self) -> Vec<PathBuf> {
2997 if self.selected.is_empty() {
2998 if let Some(path) = self.cursor_path() {
2999 return vec![path];
3000 }
3001 return Vec::new();
3002 }
3003 self.selected.clone()
3004 }
3005
3006 fn collapse_dir(&mut self, path: &Path) {
3009 if let Some(tree) = self.tree.as_mut() {
3010 tree.collapse(path);
3011 }
3012 self.clear_thumbnails_under(path);
3013 self.cursor_snap();
3014 }
3015
3016 fn toggle_dir(&mut self, path: &Path, sender: &ComponentSender<Self>) {
3018 let expanded = self.tree.as_ref().is_some_and(|t| t.is_expanded(path));
3019 if expanded {
3020 self.collapse_dir(path);
3021 } else {
3022 self.expand_dir(path, sender);
3023 }
3024 }
3025
3026 fn suspend_media(&mut self) {
3031 for video in self.video_widgets.values() {
3032 if let Some(stream) = video.media_stream() {
3033 stream.set_playing(false);
3034 }
3035 }
3036 for player in self.audio.values() {
3037 player.set_playing(false);
3038 }
3039 self.gif_anims.borrow_mut().clear();
3041 self.playing.clear();
3042 }
3043
3044 fn shutdown(&mut self) {
3051 if let Some(id) = self.gif_tick.borrow_mut().take() {
3052 id.remove();
3053 }
3054 if let Some(id) = self.audio_tick.borrow_mut().take() {
3055 id.remove();
3056 }
3057 self.gif_anims.borrow_mut().clear();
3058 self.gif_widgets.borrow_mut().clear();
3059 self.audio_widgets.borrow_mut().clear();
3060 self.playing.clear();
3061 self.thumbnails.clear();
3062 self.stop_all_videos();
3063 self.media.clear();
3064 self.audio.clear();
3066 }
3067
3068 fn clear_thumbnails_under(&mut self, prefix: &Path) {
3073 self.thumbnails.retain(|path| !path.starts_with(prefix));
3074 self.preview_kinds.retain(|path, _| !path.starts_with(prefix));
3075 self.documents.retain(|path, _| !path.starts_with(prefix));
3076 self.thumb_cache.retain(|path, _| !path.starts_with(prefix));
3077 self.playing.retain(|path| !path.starts_with(prefix));
3078 self.stop_videos_under(prefix);
3079 self.media.retain(|path, _| !path.starts_with(prefix));
3080 self.gif_anims.borrow_mut().retain(|path, _| !path.starts_with(prefix));
3081 self.gif_widgets.borrow_mut().retain(|path, _| !path.starts_with(prefix));
3082 self.audio_widgets.borrow_mut().retain(|path, _| !path.starts_with(prefix));
3083 self.audio.retain(|path, _| !path.starts_with(prefix));
3085 }
3086
3087 fn stop_videos_under(&mut self, prefix: &Path) {
3091 self.video_widgets.retain(|path, video| {
3092 if path.starts_with(prefix) {
3093 crate::ui::stop_video_stream(video);
3094 false
3095 } else {
3096 true
3097 }
3098 });
3099 }
3100
3101 fn stop_all_videos(&mut self) {
3103 for video in self.video_widgets.values() {
3104 crate::ui::stop_video_stream(video);
3105 }
3106 self.video_widgets.clear();
3107 }
3108
3109 fn retain_visible_media(&mut self, visible: &HashSet<PathBuf>) {
3115 self.video_widgets.retain(|path, video| {
3116 if visible.contains(path) {
3117 true
3118 } else {
3119 crate::ui::stop_video_stream(video);
3120 false
3121 }
3122 });
3123 self.media.retain(|path, _| visible.contains(path));
3124 self.playing.retain(|path| visible.contains(path));
3125 self.gif_anims.borrow_mut().retain(|path, _| visible.contains(path));
3126 self.audio.retain(|path, _| visible.contains(path));
3128 }
3129
3130 fn wants_preview(&self, row: &VisibleRow) -> bool {
3134 if row.is_dir {
3135 return false;
3136 }
3137 self.thumbnails.contains(&row.path)
3138 || row
3139 .path
3140 .ancestors()
3141 .skip(1)
3142 .any(|ancestor| self.thumbnails.contains(ancestor))
3143 }
3144
3145 fn preview_kind(&mut self, path: &Path) -> Option<PreviewKind> {
3148 if let Some(kind) = self.preview_kinds.get(path) {
3149 return *kind;
3150 }
3151 let kind = preview::detect(path);
3152 self.preview_kinds.insert(path.to_path_buf(), kind);
3153 kind
3154 }
3155
3156 fn static_texture(&mut self, path: &Path, kind: PreviewKind) -> Option<gdk::Texture> {
3159 if let Some(texture) = self.thumb_cache.get(path) {
3160 return Some(texture.clone());
3161 }
3162 let texture = match kind {
3163 PreviewKind::Image => gdk::Texture::from_filename(path).ok()?,
3164 PreviewKind::Gif => {
3165 let animation = gdk::gdk_pixbuf::PixbufAnimation::from_file(path).ok()?;
3166 let pixbuf = animation.static_image()?;
3167 gdk::Texture::for_pixbuf(&pixbuf)
3168 }
3169 _ => return None,
3171 };
3172 self.thumb_cache.insert(path.to_path_buf(), texture.clone());
3173 Some(texture)
3174 }
3175
3176 fn media_file(&mut self, path: &Path) -> Option<gtk::MediaFile> {
3181 if let Some(media) = self.media.get(path) {
3182 return Some(media.clone());
3183 }
3184 let media = gtk::MediaFile::for_filename(path);
3185 media.set_loop(true);
3186 media.set_muted(false);
3191 media.set_volume(1.0);
3192 self.media.insert(path.to_path_buf(), media.clone());
3193 Some(media)
3194 }
3195
3196 fn audio_player(&mut self, path: &Path) -> Option<Rc<AudioPlayer>> {
3199 if let Some(player) = self.audio.get(path) {
3200 return Some(player.clone());
3201 }
3202 let player = AudioPlayer::new(path)?;
3203 self.audio.insert(path.to_path_buf(), player.clone());
3204 Some(player)
3205 }
3206
3207 fn toggle_thumbnail_play(&mut self, path: &Path) {
3210 if self.preview_kind(path) != Some(PreviewKind::Gif) {
3211 return;
3212 }
3213 if self.playing.remove(path) {
3214 self.gif_anims.borrow_mut().remove(path);
3215 } else {
3216 let Ok(animation) = gdk::gdk_pixbuf::PixbufAnimation::from_file(path) else {
3217 return;
3218 };
3219 let iter = animation.iter(Some(SystemTime::now()));
3220 self.gif_anims.borrow_mut().insert(path.to_path_buf(), GifAnim { iter });
3221 self.playing.insert(path.to_path_buf());
3222 self.ensure_gif_ticker();
3223 }
3224 }
3225
3226 fn ensure_gif_ticker(&mut self) {
3230 if self.gif_tick.borrow().is_some() {
3231 return;
3232 }
3233 let anims = self.gif_anims.clone();
3234 let widgets = self.gif_widgets.clone();
3235 let tick = self.gif_tick.clone();
3236 let id = glib::timeout_add_local(Duration::from_millis(33), move || {
3237 let mut anims = anims.borrow_mut();
3238 if anims.is_empty() {
3239 *tick.borrow_mut() = None;
3240 return glib::ControlFlow::Break;
3241 }
3242 let now = SystemTime::now();
3243 let widgets = widgets.borrow();
3244 for (path, anim) in anims.iter_mut() {
3245 let _ = anim.iter.advance(now);
3246 if let Some(picture) = widgets.get(path) {
3247 let pixbuf = anim.iter.pixbuf();
3248 picture.set_paintable(Some(&gdk::Texture::for_pixbuf(&pixbuf)));
3249 }
3250 }
3251 glib::ControlFlow::Continue
3252 });
3253 *self.gif_tick.borrow_mut() = Some(id);
3254 }
3255
3256 fn ensure_audio_ticker(&mut self) {
3259 if self.audio_tick.borrow().is_some() {
3260 return;
3261 }
3262 let players = self.audio_widgets.clone();
3263 let tick = self.audio_tick.clone();
3264 let id = glib::timeout_add_local(Duration::from_millis(200), move || {
3265 let players = players.borrow();
3266 if players.is_empty() {
3267 *tick.borrow_mut() = None;
3268 return glib::ControlFlow::Break;
3269 }
3270 for widgets in players.values() {
3271 let Some(player) = widgets.player.upgrade() else {
3274 continue;
3275 };
3276 let position = player.position();
3277 let duration = player.duration();
3278 if duration > 0
3279 && (widgets.seek.adjustment().upper() - duration as f64).abs() > 0.5
3280 {
3281 widgets.seek.adjustment().set_upper(duration as f64);
3282 }
3283 if !widgets.seeking.get() {
3284 widgets.seek.set_value(position as f64);
3285 }
3286 widgets.time.set_text(&format!(
3287 "{} / {}",
3288 format_time(position),
3289 format_time(duration)
3290 ));
3291 widgets.play.set_icon_name(if player.is_playing() {
3292 "media-playback-pause-symbolic"
3293 } else {
3294 "media-playback-start-symbolic"
3295 });
3296 }
3297 glib::ControlFlow::Continue
3298 });
3299 *self.audio_tick.borrow_mut() = Some(id);
3300 }
3301
3302 fn append_preview(
3306 &mut self,
3307 path: &Path,
3308 row_indent: i32,
3309 container: >k::Box,
3310 sender: &ComponentSender<Self>,
3311 ) {
3312 let Some(kind) = self.preview_kind(path) else {
3313 return;
3314 };
3315 let label_start = row_indent + self.config.icon_size as i32 + 6;
3316 if kind.is_media() {
3317 let available = self.content_width - label_start - 6;
3318 self.append_media(path, kind, label_start, available, container, sender);
3319 } else {
3320 self.append_document(path, kind, label_start, container);
3321 }
3322 }
3323
3324 fn append_media(
3326 &mut self,
3327 path: &Path,
3328 kind: PreviewKind,
3329 label_start: i32,
3330 available: i32,
3331 container: >k::Box,
3332 sender: &ComponentSender<Self>,
3333 ) {
3334 match kind {
3335 PreviewKind::Image => {
3336 if let Some(texture) = self.static_texture(path, kind) {
3337 let (width, height) = thumbnail_size(&texture, available);
3338 container.append(&thumbnail_picture(&texture, label_start, width, height));
3339 }
3340 }
3341 PreviewKind::Gif => {
3342 let playing = self.playing.contains(path);
3344 let texture = if playing {
3345 self.gif_anims
3346 .borrow()
3347 .get(path)
3348 .map(|anim| gdk::Texture::for_pixbuf(&anim.iter.pixbuf()))
3349 } else {
3350 self.static_texture(path, kind)
3351 };
3352 if let Some(texture) = texture {
3353 let (width, height) = thumbnail_size(&texture, available);
3354 let picture = thumbnail_picture(&texture, label_start, width, height);
3355 picture.set_tooltip_text(Some("Click to play"));
3356 if playing {
3357 self.gif_widgets
3358 .borrow_mut()
3359 .insert(path.to_path_buf(), picture.clone());
3360 }
3361 let msg = TreeMsg::ToggleThumbnailPlay(path.to_path_buf());
3362 let s = sender.clone();
3363 let click = gtk::GestureClick::new();
3364 click.set_button(1);
3365 click.connect_pressed(move |_, _, _, _| s.input(msg.clone()));
3366 picture.add_controller(click);
3367 container.append(&picture);
3368 }
3369 }
3370 PreviewKind::Video => {
3371 if let Some(media) = self.media_file(path) {
3372 let video = gtk::Video::new();
3378 video.set_autoplay(false);
3379 video.set_media_stream(Some(&media));
3380 video.set_loop(true);
3381 video.add_css_class("tree-thumbnail");
3382 video.set_halign(gtk::Align::Start);
3383 let width = available.max(64);
3384 video.set_size_request(width, (width * 9 / 16).max(36));
3385 video.set_margin_start(label_start);
3386 video.set_margin_bottom(6);
3387 self.video_widgets.insert(path.to_path_buf(), video.clone());
3388 container.append(&video);
3389 }
3390 }
3391 PreviewKind::Audio => {
3392 if let Some(player) = self.audio_player(path) {
3393 let (controls, widgets) = build_audio_player(&player, label_start);
3397 self.audio_widgets.borrow_mut().insert(path.to_path_buf(), widgets);
3398 container.append(&controls);
3399 self.ensure_audio_ticker();
3400 }
3401 }
3402 PreviewKind::Text
3404 | PreviewKind::Csv
3405 | PreviewKind::Json
3406 | PreviewKind::Toml
3407 | PreviewKind::Yaml
3408 | PreviewKind::Archive => {}
3409 }
3410 }
3411
3412 fn append_document(
3415 &mut self,
3416 path: &Path,
3417 kind: PreviewKind,
3418 label_start: i32,
3419 container: >k::Box,
3420 ) {
3421 if !self.documents.contains_key(path) {
3422 let data = preview::load_document(path, kind);
3423 self.documents.insert(path.to_path_buf(), data);
3424 }
3425 let Some(Some(data)) = self.documents.get(path) else {
3426 return;
3427 };
3428 match data {
3429 DocumentData::Lines(lines) => append_text_lines(container, label_start, lines),
3430 DocumentData::Structured { lines, status } => {
3431 append_text_lines(container, label_start, lines);
3432 append_status(container, label_start, status);
3433 }
3434 DocumentData::Table(table) => append_table(container, label_start, table),
3435 DocumentData::Archive(archive) => append_archive(container, label_start, archive),
3436 }
3437 }
3438
3439 fn expand_dir(&mut self, path: &Path, sender: &ComponentSender<Self>) {
3440 let Some(tree) = self.tree.as_mut() else {
3441 return;
3442 };
3443 if let Err(err) = tree.expand(path, &StdDirSource) {
3444 self.status(format!("Could not open {}: {err}", path.display()), sender);
3445 }
3446 self.refresh_rows();
3447 }
3448
3449 fn activate(&mut self, path: &Path, sender: &ComponentSender<Self>) {
3450 let Some(index) = self.rows.iter().position(|r| r.path == path) else {
3451 return;
3452 };
3453 if self.rows[index].is_dir {
3454 self.open_root(path, sender);
3458 } else if let Err(err) = open_in_app(path) {
3459 self.status(err, sender);
3460 }
3461 }
3462
3463 fn confirm_trash(&mut self, paths: Vec<PathBuf>, sender: ComponentSender<Self>) {
3464 let message = match paths.as_slice() {
3465 [single] => {
3466 let name = single
3467 .file_name()
3468 .map(|n| n.to_string_lossy().into_owned())
3469 .unwrap_or_else(|| single.display().to_string());
3470 format!("Move \u{201c}{name}\u{201d} to trash?")
3471 }
3472 many => format!("Move {} items to trash?", many.len()),
3473 };
3474 let dialog = gtk::AlertDialog::builder()
3475 .modal(true)
3476 .message(message)
3477 .detail("The panel cannot restore trashed items.")
3478 .buttons(["Cancel", "Trash"])
3479 .cancel_button(0)
3480 .default_button(1)
3481 .build();
3482 dialog.choose(Some(&self.parent), None::<&gio::Cancellable>, move |res| {
3483 if matches!(res, Ok(1)) {
3484 sender.input(TreeMsg::ConfirmTrash(paths));
3485 }
3486 });
3487 }
3488
3489 fn confirm_permanent_delete(&mut self, paths: Vec<PathBuf>, sender: ComponentSender<Self>) {
3490 let message = match paths.as_slice() {
3491 [single] => {
3492 let name = single
3493 .file_name()
3494 .map(|n| n.to_string_lossy().into_owned())
3495 .unwrap_or_else(|| single.display().to_string());
3496 format!("Permanently delete \u{201c}{name}\u{201d}?")
3497 }
3498 many => format!("Permanently delete {} items?", many.len()),
3499 };
3500 let dialog = gtk::AlertDialog::builder()
3501 .modal(true)
3502 .message(message)
3503 .detail("This cannot be undone. Files will not go to the trash.")
3504 .buttons(["Cancel", "Delete Permanently"])
3505 .cancel_button(0)
3506 .default_button(1)
3507 .build();
3508 dialog.choose(Some(&self.parent), None::<&gio::Cancellable>, move |res| {
3509 if matches!(res, Ok(1)) {
3510 sender.input(TreeMsg::ConfirmPermanentDelete(paths));
3511 }
3512 });
3513 }
3514
3515 fn open_menu(&mut self, path: &Path) {
3516 let in_selection = self.selected.len() > 1 && self.selected.iter().any(|p| p == path);
3520 if !in_selection {
3521 self.select(path);
3522 } else if let Some(i) = self.rows.iter().position(|r| r.path == path) {
3523 self.cursor = Some(i);
3524 }
3525 if let Some(previous) = self.popover.take() {
3526 previous.unparent();
3527 }
3528 self.menu_target = Some(path.to_path_buf());
3529 self.menu_point = None;
3530 }
3531
3532 fn open_menu_at(&mut self, path: &Path, at: (f64, f64)) {
3536 if let Some(previous) = self.popover.take() {
3537 previous.unparent();
3538 }
3539 self.menu_target = Some(path.to_path_buf());
3540 self.menu_point = Some(at);
3541 }
3542
3543 fn paste(&mut self, sender: &ComponentSender<Self>) {
3544 let Some(clip) = self.clipboard.clone() else {
3545 self.paste_from_system_clipboard(sender);
3548 return;
3549 };
3550 let Some(dir) = self.target_dir() else {
3551 return;
3552 };
3553 let mut count = 0usize;
3554 let mut failed: Option<String> = None;
3555 for src in clip.paths {
3556 if src == dir {
3557 continue;
3558 }
3559 let result = match clip.op {
3560 ClipboardOp::Copy => self.ops.copy(&src, &dir),
3561 ClipboardOp::Cut => self.ops.move_(&src, &dir),
3562 };
3563 match result {
3564 Ok(dest) => {
3565 count += 1;
3566 self.apply_change(Change::Created { path: dest });
3567 if clip.op == ClipboardOp::Cut {
3568 self.apply_change(Change::Removed { path: src });
3569 }
3570 }
3571 Err(err) => {
3572 failed = Some(err.to_string());
3573 break;
3574 }
3575 }
3576 }
3577 if let Some(failed) = failed {
3578 self.status(failed, sender);
3579 return;
3580 }
3581 let verb = match clip.op {
3582 ClipboardOp::Copy => "Copied",
3583 ClipboardOp::Cut => "Moved",
3584 };
3585 self.status(format!("{verb} {count} item(s) into {}", dir.display()), sender);
3586 if clip.op == ClipboardOp::Cut {
3587 self.clipboard = None;
3588 }
3589 self.cursor_snap();
3590 }
3591
3592 fn paste_from_system_clipboard(&mut self, sender: &ComponentSender<Self>) {
3596 if self.target_dir().is_none() {
3597 return;
3598 }
3599 let s = sender.clone();
3600 read_clipboard_files(move |paths| s.input(TreeMsg::PastePaths(paths)));
3601 }
3602
3603 fn paste_paths(&mut self, paths: Vec<PathBuf>, sender: &ComponentSender<Self>) {
3605 let Some(dir) = self.target_dir() else {
3606 return;
3607 };
3608 if paths.is_empty() {
3609 self.status("Clipboard has no files to paste".to_string(), sender);
3610 return;
3611 }
3612 let mut count = 0usize;
3613 let mut failed: Option<String> = None;
3614 for src in paths {
3615 if src == dir {
3616 continue;
3617 }
3618 match self.ops.copy(&src, &dir) {
3619 Ok(dest) => {
3620 count += 1;
3621 self.apply_change(Change::Created { path: dest });
3622 }
3623 Err(err) => {
3624 failed = Some(err.to_string());
3625 break;
3626 }
3627 }
3628 }
3629 if let Some(failed) = failed {
3630 self.status(failed, sender);
3631 return;
3632 }
3633 self.status(format!("Pasted {count} item(s) into {}", dir.display()), sender);
3634 self.cursor_snap();
3635 }
3636
3637 fn request_drop_into(
3642 &mut self,
3643 target: PathBuf,
3644 sources: Vec<PathBuf>,
3645 copy: bool,
3646 sender: &ComponentSender<Self>,
3647 ) {
3648 match resolve_drop(&target, sources, copy) {
3649 DropPlan::Ignore => {}
3650 DropPlan::Reject(message) => self.status(message, sender),
3651 DropPlan::Copy(sources) => self.drop_into(&target, &sources, true, sender),
3652 DropPlan::Move(sources) => {
3653 if self.config.confirm_drop_move {
3654 self.confirm_move(target, sources, sender);
3655 } else {
3656 self.drop_into(&target, &sources, false, sender);
3657 }
3658 }
3659 }
3660 }
3661
3662 fn confirm_move(
3666 &mut self,
3667 target: PathBuf,
3668 sources: Vec<PathBuf>,
3669 sender: &ComponentSender<Self>,
3670 ) {
3671 let target_name = target
3672 .file_name()
3673 .map(|n| n.to_string_lossy().into_owned())
3674 .unwrap_or_else(|| target.display().to_string());
3675 let message = match sources.as_slice() {
3676 [single] => {
3677 let name = single
3678 .file_name()
3679 .map(|n| n.to_string_lossy().into_owned())
3680 .unwrap_or_else(|| single.display().to_string());
3681 format!("Move \u{201c}{name}\u{201d} into \u{201c}{target_name}\u{201d}?")
3682 }
3683 many => format!("Move {} items into \u{201c}{target_name}\u{201d}?", many.len()),
3684 };
3685 let dialog = gtk::AlertDialog::builder()
3686 .modal(true)
3687 .message(message)
3688 .detail("Hold Ctrl while dropping to copy instead of move.")
3689 .buttons(["Cancel", "Move"])
3690 .cancel_button(0)
3691 .default_button(1)
3692 .build();
3693 let s = sender.clone();
3694 dialog.choose(Some(&self.parent), None::<&gio::Cancellable>, move |res| {
3695 if matches!(res, Ok(1)) {
3696 s.input(TreeMsg::DropIntoConfirmed { target, sources });
3697 }
3698 });
3699 }
3700
3701 fn drop_into(
3705 &mut self,
3706 target: &Path,
3707 sources: &[PathBuf],
3708 copy: bool,
3709 sender: &ComponentSender<Self>,
3710 ) {
3711 let mut count = 0usize;
3712 let mut failed: Option<String> = None;
3713 for src in sources {
3714 if src == target || src.parent() == Some(target) {
3715 continue; }
3717 if !copy && target.starts_with(src) {
3718 failed = Some("Cannot move a folder into itself".to_string());
3719 break;
3720 }
3721 let result = if copy { self.ops.copy(src, target) } else { self.ops.move_(src, target) };
3722 match result {
3723 Ok(dest) => {
3724 count += 1;
3725 self.apply_change(Change::Created { path: dest });
3726 if !copy {
3727 self.apply_change(Change::Removed { path: src.clone() });
3728 }
3729 }
3730 Err(err) => {
3731 failed = Some(err.to_string());
3732 break;
3733 }
3734 }
3735 }
3736 if let Some(failed) = failed {
3737 self.status(failed, sender);
3738 return;
3739 }
3740 if count > 0 {
3741 let verb = if copy { "Copied" } else { "Moved" };
3742 self.status(format!("{verb} {count} item(s) into {}", target.display()), sender);
3743 }
3744 self.cursor_snap();
3745 }
3746
3747 fn create_entry(&mut self, is_dir: bool, sender: &ComponentSender<Self>) {
3748 let Some(dir) = self.target_dir() else {
3749 return;
3750 };
3751 let name = fresh_name(&dir, if is_dir { "new folder" } else { "new file" });
3752 let result = if is_dir {
3753 self.ops.create_dir(&dir, &name)
3754 } else {
3755 self.ops.create_file(&dir, &name)
3756 };
3757 match result {
3758 Ok(new_path) => {
3759 self.apply_change(Change::Created { path: new_path.clone() });
3760 self.renaming = Some(new_path.clone());
3761 self.renaming_state.set(true);
3762 self.select(&new_path);
3763 }
3764 Err(err) => self.status(format!("Could not create: {err}"), sender),
3765 }
3766 }
3767
3768 fn target_dir(&self) -> Option<PathBuf> {
3771 if let Some(path) = self.cursor_path() {
3772 let is_dir = self.rows.iter().find(|r| r.path == path).map(|r| r.is_dir).unwrap_or(false);
3773 if is_dir {
3774 return Some(path);
3775 }
3776 return path.parent().map(Path::to_path_buf);
3777 }
3778 self.tree.as_ref().map(|t| t.root().to_path_buf())
3779 }
3780
3781 fn apply_change(&mut self, change: Change) {
3782 if let Some(tree) = self.tree.as_mut() {
3783 tree.apply(&change, &StdDirSource);
3784 self.refresh_rows();
3785 }
3786 }
3787
3788 fn toggle_hidden(&mut self, sender: &ComponentSender<Self>) {
3791 let show = !self.config.show_hidden;
3792 self.config.show_hidden = show;
3793 if let Some(tree) = self.tree.as_mut() {
3794 tree.set_show_hidden(show);
3795 tree.reload_all(&StdDirSource);
3796 self.refresh_rows();
3797 }
3798 let label = if show { "Showing hidden files." } else { "Hiding hidden files." };
3799 self.status(label.to_string(), sender);
3800 }
3801
3802 fn set_sort_key(&mut self, key: SortKey, sender: &ComponentSender<Self>) {
3804 let mut opts = self.config.sort_options();
3805 opts.key = key;
3806 self.config.sort_key = key.as_str().to_owned();
3807 if let Some(tree) = self.tree.as_mut() {
3808 tree.set_sort(opts);
3809 tree.reload_all(&StdDirSource);
3810 self.refresh_rows();
3811 }
3812 self.status(format!("Sorting by {}.", key.as_str()), sender);
3813 }
3814
3815 fn toggle_sort_direction(&mut self, sender: &ComponentSender<Self>) {
3817 let mut opts = self.config.sort_options();
3818 opts.ascending = !opts.ascending;
3819 self.config.sort_ascending = opts.ascending;
3820 if let Some(tree) = self.tree.as_mut() {
3821 tree.set_sort(opts);
3822 tree.reload_all(&StdDirSource);
3823 self.refresh_rows();
3824 }
3825 let dir = if opts.ascending { "ascending" } else { "descending" };
3826 self.status(format!("Sort order: {dir}."), sender);
3827 }
3828
3829 fn status(&mut self, message: String, sender: &ComponentSender<Self>) {
3830 let _ = sender.output(TreeOutput::Status(message));
3831 }
3832
3833 fn run_shortcut(&mut self, target: ShortcutTarget, sender: &ComponentSender<Self>) {
3838 if let ShortcutTarget::Builtin(builtin) = &target
3841 && builtin.is_pane_action()
3842 {
3843 let _ = sender.output(TreeOutput::PaneAction(*builtin));
3844 return;
3845 }
3846 let has_selection = self.cursor.is_some() || !self.selected.is_empty();
3850 let path = match self.cursor_path() {
3851 Some(path) => path,
3852 None => match self.tree.as_ref().map(|t| t.root().to_path_buf()) {
3853 Some(root) => root,
3854 None => return,
3855 },
3856 };
3857
3858 if let ShortcutTarget::Command(cmd) = &target {
3860 if !has_selection {
3861 return;
3862 }
3863 let command = action_command(&cmd.command, &path);
3864 match spawn_command(&command) {
3865 Ok(()) => {
3866 self.status(format!("Ran {} on {}", cmd.command, path.display()), sender);
3867 }
3868 Err(err) => {
3869 self.status(format!("Could not run {}: {err}", cmd.command), sender);
3870 }
3871 }
3872 return;
3873 }
3874 let ShortcutTarget::Builtin(builtin) = target else {
3875 return;
3876 };
3877 if builtin == BuiltinAction::Separator
3878 || (builtin.is_directory_only() && !path.is_dir())
3879 {
3880 return;
3881 }
3882 if matches!(builtin, BuiltinAction::OpenWithDefault) && path.is_dir() {
3883 return;
3884 }
3885 if builtin == BuiltinAction::ViewThumbnail
3886 && !path.is_dir()
3887 && preview::detect(&path).is_none()
3888 {
3889 return;
3890 }
3891 if !has_selection && needs_selection(builtin) {
3893 return;
3894 }
3895 if has_selection && !self.selected.iter().any(|p| p == &path) {
3899 self.select(&path);
3900 }
3901 let selection = self.selected.clone();
3904 sender.input(action_message(builtin, &path, &selection));
3905 }
3906}
3907
3908fn fresh_name(dir: &Path, stem: &str) -> String {
3913 for n in 0.. {
3914 let name = if n == 0 { stem.to_string() } else { format!("{stem} {n}") };
3915 if !dir.join(&name).exists() {
3916 return name;
3917 }
3918 }
3919 unreachable!("the loop above always finds a free name")
3920}
3921
3922fn open_in_app(path: &Path) -> Result<String, String> {
3931 let content_type = content_type_for(path)
3932 .ok_or_else(|| format!("Could not determine the type of {}", path.display()))?;
3933 let app = gio::AppInfo::default_for_type(&content_type, false)
3934 .ok_or_else(|| format!("No application is registered to open {}", path.display()))?;
3935 let file = gio::File::for_path(path);
3936 app.launch(&[file], None::<&gio::AppLaunchContext>)
3937 .map_err(|err| format!("Could not open {}: {err}", path.display()))?;
3938 Ok(app.display_name().to_string())
3939}
3940
3941fn content_type_for(path: &Path) -> Option<glib::GString> {
3948 let file = gio::File::for_path(path);
3949 let info = file
3950 .query_info(
3951 gio::FILE_ATTRIBUTE_STANDARD_CONTENT_TYPE,
3952 gio::FileQueryInfoFlags::NONE,
3953 None::<&gio::Cancellable>,
3954 )
3955 .ok()?;
3956 info.content_type()
3957}
3958
3959fn default_app_label(path: &Path) -> Option<String> {
3963 let content_type = content_type_for(path)?;
3964 gio::AppInfo::default_for_type(&content_type, false).map(|app| {
3965 format!("Open With {}", app.display_name())
3966 })
3967}
3968
3969#[allow(deprecated)] fn open_with_dialog(parent: >k::Window, path: &Path, sender: &ComponentSender<Tree>) {
3973 let Some(content_type) = content_type_for(path) else {
3974 let _ = sender.output(TreeOutput::Status(format!(
3975 "Could not determine the type of {}",
3976 path.display()
3977 )));
3978 return;
3979 };
3980 let dialog = gtk::AppChooserDialog::for_content_type(
3981 Some(parent),
3982 gtk::DialogFlags::MODAL,
3983 &content_type,
3984 );
3985 dialog.set_title(Some(&format!("Open {} with...", path.display())));
3986
3987 if gtk4_layer_shell::is_supported() && !dialog.is_layer_window() {
3993 dialog.init_layer_shell();
3994 dialog.set_namespace(Some(crate::ui::LAYER_NAMESPACE));
3995 dialog.set_layer(Layer::Overlay);
3996 dialog.set_keyboard_mode(KeyboardMode::Exclusive);
3997 dialog.set_default_size(540, 620);
3998 }
4000
4001 let dialog = dialog.clone();
4002 let path = path.to_path_buf();
4003 let s = sender.clone();
4004 dialog.connect_response(move |dialog, response| {
4005 if response == gtk::ResponseType::Ok
4006 && let Some(app) = dialog.app_info()
4007 {
4008 let file = gio::File::for_path(&path);
4009 match app.launch(&[file], None::<&gio::AppLaunchContext>) {
4010 Ok(()) => {
4011 let _ = s.output(TreeOutput::Status(format!(
4012 "Opened {} with {}",
4013 path.display(),
4014 app.display_name()
4015 )));
4016 }
4017 Err(err) => {
4018 let _ = s.output(TreeOutput::Status(format!(
4019 "Could not open {} with {}: {err}",
4020 path.display(),
4021 app.display_name()
4022 )));
4023 }
4024 }
4025 }
4026 dialog.close();
4027 });
4028 dialog.present();
4029}
4030
4031fn set_clipboard_text(text: &str) {
4033 if let Some(display) = gdk::Display::default() {
4034 display.clipboard().set_text(text);
4035 }
4036}
4037
4038const URI_LIST_MIME: &str = "text/uri-list";
4041
4042const GNOME_CLIP_MIME: &str = "x-special/gnome-copied-files";
4046
4047fn read_clipboard_files(done: impl FnOnce(Vec<PathBuf>) + 'static) {
4052 let Some(display) = gdk::Display::default() else {
4053 done(Vec::new());
4054 return;
4055 };
4056 let clipboard = display.clipboard();
4057 let formats = clipboard.formats();
4058 let has_uri = formats.contain_mime_type(URI_LIST_MIME);
4059 let has_gnome = formats.contain_mime_type(GNOME_CLIP_MIME);
4060 if !has_uri && !has_gnome {
4061 done(Vec::new());
4062 return;
4063 }
4064 clipboard.read_text_async(gio::Cancellable::NONE, move |result| {
4065 let text = result.ok().flatten().unwrap_or_default();
4066 done(parse_clipboard_uris(&text, has_gnome && !has_uri));
4067 });
4068}
4069
4070fn parse_clipboard_uris(text: &str, verb_line: bool) -> Vec<PathBuf> {
4076 text.lines()
4077 .map(str::trim)
4078 .filter(|line| !line.is_empty() && !line.starts_with('#'))
4079 .filter_map(|line| {
4080 if verb_line && (line.eq_ignore_ascii_case("copy") || line.eq_ignore_ascii_case("cut")) {
4081 return None;
4082 }
4083 let (path, _host) = glib::filename_from_uri(line).ok()?;
4084 path.is_absolute().then_some(path)
4085 })
4086 .collect()
4087}
4088
4089fn spawn_command(cmd: &[String]) -> Result<(), std::io::Error> {
4090 if cmd.is_empty() {
4091 return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "empty command"));
4092 }
4093 let mut process = std::process::Command::new(&cmd[0]);
4094 for arg in &cmd[1..] {
4095 process.arg(arg);
4096 }
4097 process.spawn().map(|_| ())
4098}
4099
4100#[cfg(test)]
4101mod tests {
4102 use super::*;
4103
4104 #[test]
4105 fn preview_menu_label_reflects_the_file_kind() {
4106 use tempfile::tempdir;
4107 let dir = tempdir().unwrap();
4108 let write = |name: &str, bytes: &[u8]| {
4109 let path = dir.path().join(name);
4110 std::fs::write(&path, bytes).unwrap();
4111 path
4112 };
4113 let label = |path: &Path| {
4114 menu_label(
4115 &ContextAction::Builtin(BuiltinAction::ViewThumbnail),
4116 path,
4117 PanelSide::Left,
4118 )
4119 };
4120 let png = write("a.png", &[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]);
4122 assert_eq!(label(&png), "View Thumbnail");
4123 let text = write("a.txt", b"hello\n");
4124 assert_eq!(label(&text), "Show Preview");
4125 let json = write("a.json", br#"{"a":1}"#);
4126 assert_eq!(label(&json), "Show Preview");
4127 let zip = write("a.zip", b"PK\x03\x04rest");
4129 assert_eq!(label(&zip), "Show Contents");
4130 assert_eq!(label(dir.path()), "View Thumbnail");
4132 }
4133
4134 #[test]
4135 fn thumbnail_width_tracks_the_panel_and_stays_positive() {
4136 let panel = PanelConfig { width: 300, margin: 0, ..PanelConfig::default() };
4137 assert_eq!(thumbnail_content_width(panel), 276);
4138
4139 let panel = PanelConfig { width: 500, margin: 10, ..PanelConfig::default() };
4140 assert_eq!(thumbnail_content_width(panel), 456);
4141
4142 let tiny = PanelConfig { width: 40, margin: 0, ..PanelConfig::default() };
4143 assert_eq!(thumbnail_content_width(tiny), 64);
4144 }
4145
4146 #[test]
4147 fn accelerator_normalization_supports_both_syntaxes() {
4148 assert_eq!(normalize_accelerator("Ctrl+x"), "<Control>x");
4150 assert_eq!(normalize_accelerator("Ctrl+Shift+m"), "<Control><Shift>m");
4151 assert_eq!(normalize_accelerator("Shift+Delete"), "<Shift>Delete");
4152 assert_eq!(normalize_accelerator("ctrl+alt+t"), "<Control><Alt>t");
4153 assert_eq!(normalize_accelerator("<Control>x"), "<Control>x");
4155 assert_eq!(normalize_accelerator("F2"), "F2");
4156 assert_eq!(normalize_accelerator("Delete"), "Delete");
4157 }
4158
4159 fn row(path: &str) -> VisibleRow {
4160 VisibleRow {
4161 path: PathBuf::from(path),
4162 name: path.rsplit('/').next().unwrap().to_string(),
4163 depth: 0,
4164 is_dir: false,
4165 is_symlink: false,
4166 expanded: false,
4167 has_children: false,
4168 matches: false,
4169 }
4170 }
4171
4172 #[test]
4173 fn parse_clipboard_uris_handles_both_formats() {
4174 let uri_list = "#comment\r\nfile:///tmp/a.txt\r\n\r\nfile:///tmp/b%20c.txt\r\n";
4176 assert_eq!(
4177 parse_clipboard_uris(uri_list, false),
4178 vec![PathBuf::from("/tmp/a.txt"), PathBuf::from("/tmp/b c.txt")]
4179 );
4180 let gnome = "cut\nfile:///home/u/x\n";
4182 assert_eq!(parse_clipboard_uris(gnome, true), vec![PathBuf::from("/home/u/x")]);
4183 assert_eq!(parse_clipboard_uris("copy\n", false), Vec::<PathBuf>::new());
4185 assert_eq!(
4187 parse_clipboard_uris("https://example.com/x\nrelative/path\nfile:///ok\n", false),
4188 vec![PathBuf::from("/ok")]
4189 );
4190 assert!(parse_clipboard_uris("", false).is_empty());
4191 }
4192
4193 #[test]
4194 fn icon_name_matches_extensions_case_insensitively() {
4195 assert_eq!(icon_name(&row("/a/Photo.JPG")), "image-x-generic-symbolic");
4196 assert_eq!(icon_name(&row("/a/song.Flac")), "audio-x-generic-symbolic");
4197 assert_eq!(icon_name(&row("/a/clip.mkv")), "video-x-generic-symbolic");
4198 assert_eq!(icon_name(&row("/a/archive.tar")), "package-x-generic-symbolic");
4199 assert_eq!(icon_name(&row("/a/doc.pdf")), "application-pdf-symbolic");
4200 assert_eq!(icon_name(&row("/a/notes.txt")), "text-x-generic-symbolic");
4201 let mut dir = row("/a/pictures");
4203 dir.is_dir = true;
4204 assert_eq!(icon_name(&dir), "folder-symbolic");
4205 dir.expanded = true;
4206 assert_eq!(icon_name(&dir), "folder-open-symbolic");
4207 assert_eq!(icon_name(&row("/a/.bashrc")), "text-x-generic-symbolic");
4209 }
4210
4211 #[test]
4212 fn resolve_drop_filters_noops_and_detects_cycles() {
4213 let target = Path::new("/a/b");
4214 assert_eq!(resolve_drop(target, vec![PathBuf::from("/a/b")], false), DropPlan::Ignore);
4216 assert_eq!(resolve_drop(target, vec![PathBuf::from("/a/b/c")], false), DropPlan::Ignore);
4217 assert!(matches!(
4219 resolve_drop(target, vec![PathBuf::from("/a")], false),
4220 DropPlan::Reject(_)
4221 ));
4222 assert!(matches!(
4224 resolve_drop(target, vec![PathBuf::from("/x/y")], false),
4225 DropPlan::Move(_)
4226 ));
4227 assert!(matches!(
4228 resolve_drop(target, vec![PathBuf::from("/x/y")], true),
4229 DropPlan::Copy(_)
4230 ));
4231 }
4232
4233 #[test]
4234 fn drag_paths_uses_the_selection_only_when_the_row_is_in_it() {
4235 let sel = vec![PathBuf::from("/r/a"), PathBuf::from("/r/b")];
4236 assert_eq!(drag_paths(&sel, Path::new("/r/b")), sel);
4238 assert_eq!(
4240 drag_paths(&sel, Path::new("/r/c")),
4241 vec![PathBuf::from("/r/c")]
4242 );
4243 assert_eq!(
4245 drag_paths(&[PathBuf::from("/r/a")], Path::new("/r/a")),
4246 vec![PathBuf::from("/r/a")]
4247 );
4248 assert_eq!(
4250 drag_paths(&[], Path::new("/r/z")),
4251 vec![PathBuf::from("/r/z")]
4252 );
4253 }
4254
4255 #[test]
4256 fn index_range_is_inclusive_and_order_independent() {
4257 assert_eq!(index_range(2, 2).collect::<Vec<_>>(), vec![2]);
4258 assert_eq!(index_range(1, 4).collect::<Vec<_>>(), vec![1, 2, 3, 4]);
4259 assert_eq!(index_range(4, 1).collect::<Vec<_>>(), vec![1, 2, 3, 4]);
4261 }
4262
4263 #[test]
4264 fn format_uri_list_serializes_file_uris() {
4265 let text = format_uri_list(&[PathBuf::from("/tmp/a.txt"), PathBuf::from("/tmp/b c.txt")]);
4266 assert_eq!(text, "file:///tmp/a.txt\r\nfile:///tmp/b%20c.txt\r\n");
4267 assert!(format_uri_list(&[]).is_empty());
4268 assert_eq!(
4270 parse_clipboard_uris(&text, false),
4271 vec![PathBuf::from("/tmp/a.txt"), PathBuf::from("/tmp/b c.txt")]
4272 );
4273 }
4274
4275 #[test]
4276 fn typeahead_finds_the_next_matching_row_and_wraps() {
4277 let rows = vec![row("/r/apple"), row("/r/banana"), row("/r/apricot"), row("/r/cherry")];
4278 assert_eq!(typeahead_target(&rows, "ap", None), Some(0));
4281 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);
4284 }
4285
4286 #[test]
4287 fn typeahead_buffer_resets_after_a_pause_and_caps_length() {
4288 let mut ta = TypeAhead::default();
4289 let t0 = std::time::Instant::now();
4290 assert_eq!(ta.push('a', t0).as_deref(), Some("a"));
4291 assert_eq!(ta.push('b', t0 + Duration::from_millis(100)).as_deref(), Some("ab"));
4292 assert_eq!(ta.push('c', t0 + Duration::from_millis(2000)).as_deref(), Some("c"));
4294 assert_eq!(ta.push('\u{1}', t0), None);
4296 }
4297}