1use std::{cell::{Cell, RefCell}, collections::HashMap, path::{Path, PathBuf}, rc::Rc};
41
42use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
43use relm4::gtk::{gdk, gio, glib, prelude::*};
44use relm4::prelude::*;
45
46use crate::cmd::{Command, WidthArg};
47use crate::config::{
48 Bookmark, BuiltinAction, Config, ContextAction, PANEL_MAX_WIDTH, PANEL_MIN_WIDTH, PanelConfig,
49 PanelLayer, PanelSide, SessionState, ShortcutTarget, StartupRoot, bookmark_file_path,
50 load_stylesheet, save_bookmarks_to_path,
51};
52use crate::fs::SortKey;
53use crate::ipc;
54use crate::ui::bookmarks::{self, BookmarkEvent, MoveTarget};
55use crate::ui::toolbar::{PaneShortcuts, Toolbar, ToolbarInit, ToolbarMsg, ToolbarOutput};
56use crate::ui::tree::{Tree, TreeInit, TreeOutput, TreeMsg};
57
58#[derive(Debug, Clone, PartialEq, Eq)]
60enum ViewEntry {
61 Dir(PathBuf),
62 Bookmarks,
63}
64
65#[derive(Debug, Default, Clone, PartialEq, Eq)]
72struct NavHistory {
73 entries: Vec<ViewEntry>,
74 cursor: usize,
76 navigating: bool,
79}
80
81impl NavHistory {
82 fn record(&mut self, entry: ViewEntry) {
86 if self.navigating {
87 return;
88 }
89 if self.entries.get(self.cursor).is_some_and(|cur| cur == &entry) {
90 return;
91 }
92 if self.entries.is_empty() {
93 self.entries.push(entry);
94 self.cursor = 0;
95 return;
96 }
97 self.entries.truncate(self.cursor + 1);
98 self.entries.push(entry);
99 self.cursor = self.entries.len() - 1;
100 }
101
102 fn can_back(&self) -> bool {
103 self.cursor > 0 && self.cursor < self.entries.len()
104 }
105
106 fn can_forward(&self) -> bool {
107 !self.entries.is_empty() && self.cursor + 1 < self.entries.len()
108 }
109
110 fn back(&mut self) -> Option<ViewEntry> {
112 if !self.can_back() {
113 return None;
114 }
115 self.cursor -= 1;
116 self.navigating = true;
117 Some(self.entries[self.cursor].clone())
118 }
119
120 fn forward(&mut self) -> Option<ViewEntry> {
122 if !self.can_forward() {
123 return None;
124 }
125 self.cursor += 1;
126 self.navigating = true;
127 Some(self.entries[self.cursor].clone())
128 }
129
130 fn finish_navigation(&mut self) {
132 self.navigating = false;
133 }
134}
135
136pub struct Pane {
138 id: u64,
139 toolbar: Controller<Toolbar>,
140 tree: Controller<Tree>,
141 root: Option<PathBuf>,
144 canonical_root: Option<PathBuf>,
147 history: NavHistory,
149 filter_bar: gtk::Box,
152 filter_entry: gtk::SearchEntry,
153 body: gtk::Stack,
157 bookmarks_list: gtk::Box,
160 bookmark_filter: String,
163 bookmark_drag: Rc<bookmarks::BookmarkDrag>,
165 bookmark_nav: Rc<bookmarks::BookmarkNav>,
167 widget: gtk::Overlay,
173 nav_buttons: Option<NavButtons>,
176}
177
178struct NavButtons {
180 up: gtk::Button,
181 back: gtk::Button,
182 forward: gtk::Button,
183}
184
185impl Pane {
186 fn set_root(&mut self, root: PathBuf) {
189 self.canonical_root = std::fs::canonicalize(&root).ok();
190 self.root = Some(root);
191 self.refresh_nav();
192 }
193
194 fn show_bookmarks(&self) {
196 self.body.set_visible_child_name("bookmarks");
197 self.toolbar.emit(ToolbarMsg::SetBookmarks(true));
198 self.refresh_nav();
199 }
200
201 fn show_tree(&self) {
203 self.body.set_visible_child_name("tree");
204 self.toolbar.emit(ToolbarMsg::SetBookmarks(false));
205 self.refresh_nav();
206 }
207
208 fn on_bookmarks(&self) -> bool {
210 self.body.visible_child_name().as_deref() == Some("bookmarks")
211 }
212
213 fn refresh_nav(&self) {
218 let Some(nav) = &self.nav_buttons else {
219 return;
220 };
221 let has_parent = !self.on_bookmarks()
222 && self.root.as_deref().and_then(Path::parent).is_some();
223 nav.up.set_sensitive(has_parent);
224 nav.back.set_sensitive(self.history.can_back());
225 nav.forward.set_sensitive(self.history.can_forward());
226 }
227}
228
229struct Dock {
231 side: PanelSide,
232 window: gtk::Window,
235 container: gtk::Box,
238 panes: Vec<Pane>,
239 active_pane: Option<u64>,
241}
242
243#[derive(Debug, Clone, Copy, PartialEq, Eq)]
247pub enum VisibilityIntent {
248 ShowAll,
250 HideAll,
252 ToggleAll,
254 ShowSide(PanelSide),
256 HideSide(PanelSide),
258 ToggleSide(PanelSide),
260}
261
262#[derive(Debug, Clone, PartialEq, Eq)]
268pub struct VisibilityPlan {
269 pub show: Vec<PanelSide>,
271 pub hide: Vec<PanelSide>,
273 pub create: Option<PanelSide>,
275 pub seed: bool,
278}
279
280pub fn resolve_visibility(
284 intent: VisibilityIntent,
285 existing: &[PanelSide],
286 shown: &[PanelSide],
287) -> VisibilityPlan {
288 match intent {
289 VisibilityIntent::ShowAll => VisibilityPlan {
290 show: existing.to_vec(),
291 hide: Vec::new(),
292 create: None,
293 seed: true,
294 },
295 VisibilityIntent::HideAll => VisibilityPlan {
296 show: Vec::new(),
297 hide: PanelSide::ALL.to_vec(),
298 create: None,
299 seed: false,
300 },
301 VisibilityIntent::ToggleAll => {
302 if shown.is_empty() {
303 VisibilityPlan {
304 show: existing.to_vec(),
305 hide: Vec::new(),
306 create: None,
307 seed: true,
308 }
309 } else {
310 VisibilityPlan {
311 show: Vec::new(),
312 hide: PanelSide::ALL.to_vec(),
313 create: None,
314 seed: false,
315 }
316 }
317 }
318 VisibilityIntent::ShowSide(side) => VisibilityPlan {
319 show: vec![side],
320 hide: Vec::new(),
321 create: (!existing.contains(&side)).then_some(side),
322 seed: true,
323 },
324 VisibilityIntent::HideSide(side) => VisibilityPlan {
325 show: Vec::new(),
326 hide: vec![side],
327 create: None,
328 seed: false,
329 },
330 VisibilityIntent::ToggleSide(side) => {
331 if !existing.contains(&side) {
332 VisibilityPlan {
333 show: vec![side],
334 hide: Vec::new(),
335 create: Some(side),
336 seed: true,
337 }
338 } else if shown.contains(&side) {
339 VisibilityPlan {
340 show: Vec::new(),
341 hide: vec![side],
342 create: None,
343 seed: false,
344 }
345 } else {
346 VisibilityPlan {
347 show: vec![side],
348 hide: Vec::new(),
349 create: None,
350 seed: true,
351 }
352 }
353 }
354 }
355}
356
357#[derive(Debug)]
359pub enum AppMsg {
360 PaneToolbar { id: u64, out: ToolbarOutput },
362 PaneTree { id: u64, out: TreeOutput },
364 PaneShortcut { id: u64, action: ContextAction },
367 OpenFolderPicked { id: u64, path: Option<PathBuf> },
369 LaunchRequest { command: Command },
371 SplitFromPane { id: u64 },
373 OpenSplitFrom { id: u64, root: PathBuf },
376 OpenOppositeFrom { id: u64, root: PathBuf },
379 ClosePane { id: u64 },
382 FilterChanged { id: u64, filter: String },
384 FilterClosed { id: u64 },
386 Shutdown,
388 ResizeBy { side: PanelSide, delta: i32 },
390 ResizeCommit,
392 FocusPane { id: u64 },
394 FocusVisible,
397 BookmarkEvent { id: u64, event: BookmarkEvent },
399 BookmarkEditSaved { index_path: Vec<usize>, name: String, path: Option<PathBuf> },
401 BookmarkAdded { name: String, path: PathBuf },
403 BookmarkFolderAdded { name: String },
405}
406
407pub struct AppInit {
410 pub command: Command,
411 pub listener: Option<std::os::unix::net::UnixListener>,
412}
413
414pub struct App {
416 config: Config,
417 status: String,
418 window: gtk::Window,
419 file_dialog: gtk::FileDialog,
420 docks: Vec<Dock>,
422 pane_container: gtk::Box,
424 primary_side: PanelSide,
426 next_id: u64,
428 visible: bool,
430 widths: HashMap<PanelSide, u32>,
434 last_root: Option<PathBuf>,
436 bookmarks: Vec<Bookmark>,
439 save_generation: Rc<Cell<u64>>,
443 sender: ComponentSender<App>,
447}
448
449#[relm4::component(pub)]
450impl SimpleComponent for App {
451 type Init = AppInit;
452 type Input = AppMsg;
453 type Output = ();
454
455 view! {
456 gtk::Window {
457 set_default_size: (model.config.panel.width as i32, 520),
458
459 gtk::Box {
460 set_orientation: gtk::Orientation::Vertical,
461 set_spacing: 0,
462 add_css_class: "panel",
463
464 append: &model.pane_container,
465
466 append: status = >k::Label {
467 #[watch]
468 set_label: &model.status,
469 set_halign: gtk::Align::Start,
470 set_ellipsize: gtk::pango::EllipsizeMode::End,
471 add_css_class: "status-bar",
472 }
473 }
474 }
475 }
476
477 fn init(
478 init: Self::Init,
479 root: Self::Root,
480 sender: ComponentSender<Self>,
481 ) -> ComponentParts<Self> {
482 let loaded = Config::load();
483 let mut config = loaded.config;
484 let parent = root.clone();
485
486 let (bookmarks, bookmark_problem) = crate::config::load_bookmarks(&config.bookmarks);
489 if let Err(problem) = crate::config::ensure_bookmarks_file(&config.bookmarks, &bookmarks) {
490 eprintln!("tree-space: could not create bookmarks file: {problem:?}");
491 }
492
493 let session = SessionState::load();
494 let default_width = config.panel.width;
497 let widths: HashMap<PanelSide, u32> = [
498 (
499 PanelSide::Left,
500 session.left_width.unwrap_or(default_width).clamp(PANEL_MIN_WIDTH, PANEL_MAX_WIDTH),
501 ),
502 (
503 PanelSide::Right,
504 session.right_width.unwrap_or(default_width).clamp(PANEL_MIN_WIDTH, PANEL_MAX_WIDTH),
505 ),
506 ]
507 .into_iter()
508 .collect();
509
510 let primary_side = init.command.side.unwrap_or(config.panel.side);
511 config.panel.width = widths[&primary_side];
513 let primary_width = config.panel.width;
514 let roots = if !init.command.roots.is_empty() {
518 init.command.roots.clone()
519 } else if config.startup.is_bookmarks() {
520 Vec::new()
521 } else {
522 let last = session.last_root.clone().filter(|p| p.is_dir());
523 let fallback = config
524 .startup
525 .resolve(last)
526 .filter(|p| p.is_dir())
527 .or_else(home_dir);
528 if let Some(p) = fallback { vec![p] } else { Vec::new() }
529 };
530
531 let mut panes: Vec<Pane> = Vec::new();
532 let mut next_id = 0u64;
533 for root in roots.iter() {
534 let pane =
535 make_pane(&config, parent.clone(), next_id, primary_side, primary_width, sender.clone());
536 pane.tree.emit(TreeMsg::OpenRoot(root.clone()));
537 panes.push(pane);
538 panes.last_mut().unwrap().set_root(root.clone());
539 next_id += 1;
540 }
541 if panes.is_empty() {
544 let mut pane = make_pane(
545 &config,
546 parent.clone(),
547 next_id,
548 primary_side,
549 primary_width,
550 sender.clone(),
551 );
552 pane.show_bookmarks();
553 pane.history.record(ViewEntry::Bookmarks);
554 pane.refresh_nav();
555 panes.push(pane);
556 next_id += 1;
557 }
558
559 let pane_container = gtk::Box::new(gtk::Orientation::Vertical, 0);
560 pane_container.set_vexpand(true);
561 fill_pane_container(&pane_container, &panes);
562
563 let mut status = String::new();
564 if let Some(problem) = loaded.problem {
565 status = format!("config: {problem:?}");
566 } else if let Some(problem) = bookmark_problem {
567 status = format!("bookmarks: {problem:?}");
568 }
569
570 let mut model = App {
571 config,
572 status,
573 window: root.clone(),
574 file_dialog: gtk::FileDialog::new(),
575 docks: vec![Dock {
576 side: primary_side,
577 window: root.clone(),
578 container: pane_container.clone(),
579 panes,
580 active_pane: None,
581 }],
582 pane_container,
583 primary_side,
584 next_id,
585 visible: !init.command.hidden,
586 widths,
587 last_root: session.last_root.clone(),
588 bookmarks,
589 save_generation: Rc::new(Cell::new(0)),
590 sender: sender.clone(),
591 };
592
593 init_layer_window(&model.window, &model.config, primary_side, primary_width);
594 install_css(&model.config);
595 if layer_shell_available() {
598 attach_resize_controls(&model.window, primary_side, &sender);
599 }
600
601 if let Some(listener) = init.listener {
604 let ipc_sender = sender.input_sender().clone();
605 ipc::spawn_listener(listener, move |command| {
606 let sender = ipc_sender.clone();
607 glib::MainContext::default().invoke(move || {
608 let _ = sender.send(AppMsg::LaunchRequest { command });
609 });
610 });
611 }
612
613 let widgets = view_output!();
614
615 {
619 let sender = sender.clone();
620 model.window.connect_map(move |_| {
621 let sender = sender.clone();
622 glib::idle_add_local_once(move || sender.input(AppMsg::FocusVisible));
623 });
624 }
625
626 model.refresh_all_bookmarks(&sender);
628
629 let shutting_down = Rc::new(Cell::new(false));
636 let flag = shutting_down.clone();
637 let shutdown_sender = sender.clone();
638 model.window.connect_close_request(move |_| {
639 if flag.get() {
640 return glib::Propagation::Proceed;
641 }
642 flag.set(true);
643 shutdown_sender.input(AppMsg::Shutdown);
644 glib::Propagation::Stop
645 });
646
647 ComponentParts { model, widgets }
648 }
649
650 fn update(&mut self, msg: Self::Input, sender: ComponentSender<Self>) {
651 match msg {
652 AppMsg::PaneToolbar { id, out } => match out {
653 ToolbarOutput::OpenFolder => {
654 self.set_active(id);
655 self.show_open_folder(id, &sender);
656 }
657 ToolbarOutput::NavigateTo(path) => {
658 self.open_root_in_pane(id, path);
659 }
660 ToolbarOutput::FilterRequested => {
661 self.set_active(id);
662 self.open_filter(id);
663 }
664 ToolbarOutput::SplitView => {
665 sender.input(AppMsg::SplitFromPane { id });
666 }
667 ToolbarOutput::Collapse => {
668 self.collapse_dock(id);
669 }
670 ToolbarOutput::ClosePane => {
671 sender.input(AppMsg::ClosePane { id });
672 }
673 ToolbarOutput::PaneItem(target) => {
674 self.set_active(id);
675 if let ShortcutTarget::Builtin(action) = &target
678 && action.is_pane_action()
679 {
680 self.dispatch_pane_builtin(id, *action, &sender);
681 } else if let Some((di, pi)) = self.dock_pane_of(id)
682 && let Some(msg) = pane_item_message(&target)
683 {
684 self.docks[di].panes[pi].tree.emit(msg);
685 }
686 }
687 },
688
689 AppMsg::PaneTree { id, out } => match out {
690 TreeOutput::Status(message) => {
691 self.set_active(id);
692 self.status = message;
693 }
694 TreeOutput::OpenFolderRequested => {
695 self.set_active(id);
696 self.show_open_folder(id, &sender);
697 }
698 TreeOutput::OpenSplit(root) => {
699 self.set_active(id);
700 sender.input(AppMsg::OpenSplitFrom { id, root });
701 }
702 TreeOutput::OpenOpposite(root) => {
703 self.set_active(id);
704 sender.input(AppMsg::OpenOppositeFrom { id, root });
705 }
706 TreeOutput::PaneAction(action) => {
707 self.dispatch_pane_builtin(id, action, &sender);
708 }
709 TreeOutput::AddBookmark(path) => {
710 self.set_active(id);
711 self.add_bookmark(path);
712 self.refresh_all_bookmarks(&sender);
713 }
714 TreeOutput::RootChanged(root) => {
715 self.set_active(id);
716 if let Some((di, pi)) = self.dock_pane_of(id) {
717 let pane = &mut self.docks[di].panes[pi];
718 pane.history.record(ViewEntry::Dir(root.clone()));
722 pane.history.finish_navigation();
723 pane.show_tree();
724 pane.set_root(root.clone());
725 pane.toolbar.emit(ToolbarMsg::SetRoot(root.clone()));
726 }
727 self.status = String::new();
728 self.last_root = Some(root);
730 self.persist_session();
731 }
732 },
733
734 AppMsg::PaneShortcut { id, action } => {
735 self.run_pane_shortcut(id, action, &sender);
736 }
737
738 AppMsg::OpenFolderPicked { id, path: Some(path) } => {
739 self.open_root_in_pane(id, path);
740 }
741 AppMsg::OpenFolderPicked { path: None, .. } => {}
742
743 AppMsg::LaunchRequest { command } => {
744 self.handle_launch(command, &sender);
745 }
746
747 AppMsg::SplitFromPane { id } => {
748 let di = self.dock_pane_of(id).map(|(di, _)| di).unwrap_or(0);
751 self.add_pane(di, None, &sender);
752 }
753
754 AppMsg::OpenSplitFrom { id, root } => {
755 if let Some((di, _pi)) = self.dock_pane_of(id) {
756 self.add_pane(di, Some(root), &sender);
757 }
758 }
759
760 AppMsg::OpenOppositeFrom { id, root } => {
761 if let Some((di, _pi)) = self.dock_pane_of(id) {
762 let side = self.docks[di].side.opposite();
763 let target = self.ensure_dock(side, false, &sender);
764 self.add_pane(target, Some(root), &sender);
765 self.set_docks_visible(self.visible);
768 }
769 }
770
771 AppMsg::ClosePane { id } => {
772 let Some((di, pi)) = self.dock_pane_of(id) else {
773 return;
774 };
775 self.docks[di].panes.remove(pi);
776 if self.docks[di].active_pane == Some(id) {
777 self.docks[di].active_pane = self.docks[di].panes.last().map(|p| p.id);
778 }
779 let panes_left: usize = self.docks.iter().map(|d| d.panes.len()).sum();
781 if panes_left == 0 {
782 self.window.close();
783 return;
784 }
785 if self.docks[di].panes.is_empty() {
786 if di == 0 {
787 self.visible = false;
791 self.docks[di].window.set_visible(false);
792 } else {
793 let dock = self.docks.remove(di);
794 dock.window.close();
795 }
796 } else {
797 fill_pane_container(&self.docks[di].container.clone(), &self.docks[di].panes);
798 }
799 }
800
801 AppMsg::FilterChanged { id, filter } => {
802 if let Some((di, pi)) = self.dock_pane_of(id) {
803 if self.docks[di].panes[pi].on_bookmarks() {
804 self.docks[di].panes[pi].bookmark_filter = filter;
805 self.refresh_all_bookmarks(&sender);
806 } else {
807 self.docks[di].panes[pi].tree.emit(TreeMsg::SetFilter(filter));
808 }
809 }
810 }
811 AppMsg::FilterClosed { id } => {
812 if let Some((di, pi)) = self.dock_pane_of(id) {
813 let pane = &mut self.docks[di].panes[pi];
814 pane.filter_entry.set_text("");
815 pane.tree.emit(TreeMsg::SetFilter(String::new()));
816 pane.bookmark_filter.clear();
817 pane.filter_bar.set_visible(false);
818 }
819 self.refresh_all_bookmarks(&sender);
820 }
821
822 AppMsg::FocusPane { id } => {
823 self.focus_pane(id);
824 }
825
826 AppMsg::FocusVisible => {
827 let ids: Vec<u64> = self
828 .docks
829 .iter()
830 .filter(|dock| dock.window.is_visible())
831 .filter_map(|dock| dock.panes.last().map(|pane| pane.id))
832 .collect();
833 for id in ids {
834 self.focus_pane(id);
835 }
836 }
837
838 AppMsg::Shutdown => {
839 for dock in &self.docks {
844 stop_video_widgets(dock.window.upcast_ref());
845 }
846 for dock in &self.docks {
848 for pane in &dock.panes {
849 pane.tree.emit(TreeMsg::Shutdown);
850 }
851 }
852 let window = self.window.clone();
855 glib::timeout_add_local_once(std::time::Duration::from_millis(SHUTDOWN_GRACE_MS), move || {
856 window.close();
857 });
858 }
859
860 AppMsg::ResizeBy { side, delta } => self.resize_by(side, delta),
861 AppMsg::ResizeCommit => self.persist_session(),
862
863 AppMsg::BookmarkEvent { id, event } => match event {
864 BookmarkEvent::Open(path) => self.open_bookmark(id, path, &sender),
865 BookmarkEvent::Toggle(index_path) => {
866 if let Some(entry) = Bookmark::get_mut(&mut self.bookmarks, &index_path) {
867 entry.expanded = !entry.expanded;
868 }
869 self.refresh_all_bookmarks(&sender);
870 }
871 BookmarkEvent::Edit(index_path) => self.edit_bookmark(id, index_path, &sender),
872 BookmarkEvent::NewBookmark => self.new_bookmark(id, &sender),
873 BookmarkEvent::NewBookmarkFolder => self.new_bookmark_folder(id, &sender),
874 BookmarkEvent::Action { path, target } => {
875 self.run_bookmark_action(id, path, target);
876 }
877 BookmarkEvent::Move { from, to } => self.move_bookmark(from, to, &sender),
878 BookmarkEvent::Delete(index_path) => {
879 if Bookmark::remove(&mut self.bookmarks, &index_path) {
880 self.save_bookmarks();
881 self.refresh_all_bookmarks(&sender);
882 }
883 }
884 },
885 AppMsg::BookmarkEditSaved { index_path, name, path } => {
886 if let Some(entry) = Bookmark::get_mut(&mut self.bookmarks, &index_path) {
887 entry.name = name;
888 if path.is_some() {
890 entry.path = path;
891 }
892 self.save_bookmarks();
893 self.refresh_all_bookmarks(&sender);
894 }
895 }
896 AppMsg::BookmarkAdded { name, path } => {
897 if Bookmark::contains_path(&self.bookmarks, &path) {
898 self.status = format!("{} is already bookmarked", path.display());
899 return;
900 }
901 self.bookmarks.push(Bookmark::leaf(name, path));
902 self.save_bookmarks();
903 self.refresh_all_bookmarks(&sender);
904 }
905 AppMsg::BookmarkFolderAdded { name } => {
906 self.bookmarks.push(Bookmark {
907 name,
908 path: None,
909 items: Vec::new(),
910 expanded: false,
911 });
912 self.save_bookmarks();
913 self.refresh_all_bookmarks(&sender);
914 }
915 }
916 }
917}
918
919impl App {
920 fn handle_launch(&mut self, command: Command, sender: &ComponentSender<Self>) {
922 let hidden = command.hidden;
923
924 if let Some(width) = command.width {
927 let side = command.side.unwrap_or(self.primary_side);
928 match width {
929 WidthArg::To(px) => self.set_width(side, px),
930 WidthArg::By(delta) => self.resize_by(side, delta),
931 }
932 self.persist_session();
933 if command.roots.is_empty() && command.reveal.is_empty() {
934 return;
935 }
936 }
937
938 if command.roots.is_empty() && command.reveal.is_empty() {
939 let intent = match (command.side, hidden) {
942 (Some(side), true) => VisibilityIntent::HideSide(side),
943 (Some(side), false) => VisibilityIntent::ToggleSide(side),
944 (None, true) => VisibilityIntent::HideAll,
945 (None, false) => VisibilityIntent::ToggleAll,
946 };
947 self.apply_visibility(intent, sender);
948 return;
949 }
950
951 for root in &command.roots {
952 if self.find_pane_with_dir(root).is_some() {
953 continue;
956 }
957 let side = command.side.unwrap_or(self.primary_side);
958 let di = self.ensure_dock(side, false, sender);
959 self.add_pane(di, Some(root.clone()), sender);
960 }
961
962 for (root, select) in command.reveal_targets() {
967 if !root.is_dir() {
968 continue;
969 }
970 let side = command.side.unwrap_or(self.primary_side);
971 let di = self.ensure_dock(side, false, sender);
972 let id = self.add_pane(di, Some(root), sender);
973 if let Some(select) = select
974 && let Some((di, pi)) = self.dock_pane_of(id)
975 {
976 self.docks[di].panes[pi].tree.emit(TreeMsg::SelectPath(select));
977 }
978 }
979 self.set_docks_visible(!hidden);
981 }
982
983 fn apply_visibility(&mut self, intent: VisibilityIntent, sender: &ComponentSender<Self>) {
987 let existing: Vec<PanelSide> = self.docks.iter().map(|d| d.side).collect();
988 let shown: Vec<PanelSide> = self
989 .docks
990 .iter()
991 .filter(|d| d.window.is_visible())
992 .map(|d| d.side)
993 .collect();
994 let plan = resolve_visibility(intent, &existing, &shown);
995
996 if let Some(side) = plan.create {
997 self.ensure_dock(side, plan.seed, sender);
998 }
999 for side in &plan.show {
1000 let di = self.ensure_dock(*side, plan.seed, sender);
1003 self.docks[di].window.set_visible(true);
1004 }
1005 for side in &plan.hide {
1006 if let Some(di) = self.dock_of_side(*side) {
1007 self.docks[di].window.set_visible(false);
1008 }
1009 }
1010 self.refresh_visible();
1012 let focus: Vec<u64> = plan
1015 .show
1016 .iter()
1017 .filter_map(|side| self.dock_of_side(*side))
1018 .filter_map(|di| self.docks[di].panes.last())
1019 .map(|pane| pane.id)
1020 .collect();
1021 for id in focus {
1022 self.focus_pane(id);
1023 }
1024 }
1025
1026 fn set_active(&mut self, id: u64) {
1028 if let Some((di, _pi)) = self.dock_pane_of(id) {
1029 self.docks[di].active_pane = Some(id);
1030 }
1031 }
1032
1033 fn focus_pane(&mut self, id: u64) {
1038 let Some((di, pi)) = self.dock_pane_of(id) else {
1039 return;
1040 };
1041 let pane = &self.docks[di].panes[pi];
1042 if pane.on_bookmarks() {
1043 pane.bookmark_nav.focus_start();
1044 } else {
1045 pane.tree.emit(TreeMsg::Focus);
1046 }
1047 }
1048
1049 fn focus_pane_later(&self, id: u64) {
1053 let sender = self.sender.clone();
1054 glib::idle_add_local_once(move || sender.input(AppMsg::FocusPane { id }));
1055 }
1056
1057 fn open_filter(&mut self, id: u64) {
1061 if let Some((di, pi)) = self.dock_pane_of(id) {
1062 let pane = &mut self.docks[di].panes[pi];
1063 pane.filter_bar.set_visible(true);
1064 let entry = pane.filter_entry.clone();
1065 glib::idle_add_local_once(move || {
1066 entry.grab_focus();
1067 });
1068 }
1069 }
1070
1071 fn add_bookmark(&mut self, path: PathBuf) {
1074 if Bookmark::contains_path(&self.bookmarks, &path) {
1075 self.status = format!("{} is already bookmarked", path.display());
1076 return;
1077 }
1078 self.bookmarks.push(Bookmark::leaf(Bookmark::default_name(&path), path.clone()));
1079 self.save_bookmarks();
1080 self.status = format!("Bookmarked {}", path.display());
1081 }
1082
1083 fn open_root_in_pane(&mut self, id: u64, path: PathBuf) {
1086 if let Some((di, pi)) = self.dock_pane_of(id) {
1087 self.docks[di].panes[pi].show_tree();
1088 self.docks[di].panes[pi].tree.emit(TreeMsg::OpenRoot(path));
1089 self.set_active(id);
1090 }
1091 self.focus_pane_later(id);
1092 }
1093
1094 fn open_bookmark(&mut self, id: u64, path: PathBuf, _sender: &ComponentSender<Self>) {
1097 let path = crate::config::expand_bookmark_path(&path);
1098 if !path.is_dir() {
1099 self.status = format!("{} is not a directory", path.display());
1100 return;
1101 }
1102 self.open_root_in_pane(id, path);
1103 }
1104
1105 fn run_bookmark_action(&mut self, id: u64, path: PathBuf, target: ShortcutTarget) {
1110 let path = crate::config::expand_bookmark_path(&path);
1111 let Some((di, pi)) = self.dock_pane_of(id) else {
1112 return;
1113 };
1114 match target {
1115 ShortcutTarget::Builtin(BuiltinAction::Open) => {
1116 if path.is_dir() {
1117 self.open_root_in_pane(id, path);
1118 } else {
1119 self.docks[di].panes[pi].tree.emit(TreeMsg::OpenWithDefault(path));
1120 }
1121 }
1122 ShortcutTarget::Builtin(action) => {
1123 if let Some(msg) = crate::ui::tree::path_action_message(action, &path) {
1124 self.docks[di].panes[pi].tree.emit(msg);
1125 }
1126 }
1127 ShortcutTarget::Command(cmd) => {
1128 self.docks[di].panes[pi]
1129 .tree
1130 .emit(TreeMsg::RunCommand { command: cmd.command, path });
1131 }
1132 }
1133 }
1134
1135 fn show_bookmarks_view(&mut self, id: u64) {
1137 if let Some((di, pi)) = self.dock_pane_of(id) {
1138 self.docks[di].panes[pi].show_bookmarks();
1139 self.docks[di].panes[pi].history.record(ViewEntry::Bookmarks);
1140 self.docks[di].panes[pi].refresh_nav();
1141 self.set_active(id);
1142 }
1143 self.focus_pane_later(id);
1144 }
1145
1146 fn edit_bookmark(&mut self, id: u64, index_path: Vec<usize>, sender: &ComponentSender<Self>) {
1149 let Some(bookmark) = Bookmark::get(&self.bookmarks, &index_path).cloned() else {
1150 return;
1151 };
1152 let Some((di, _pi)) = self.dock_pane_of(id) else { return };
1153 let parent = self.docks[di].window.clone();
1154 let sender = sender.clone();
1155 let path = bookmark.path.as_deref().map(crate::config::expand_bookmark_path);
1156 let title = if bookmark.is_folder() { "Edit Folder" } else { "Edit Bookmark" };
1157 bookmarks::show_bookmark_dialog(
1158 &parent,
1159 title,
1160 &bookmark.name,
1161 path.as_deref(),
1162 move |name, path| {
1163 sender.input(AppMsg::BookmarkEditSaved { index_path: index_path.clone(), name, path });
1164 },
1165 );
1166 }
1167
1168 fn new_bookmark(&mut self, id: u64, sender: &ComponentSender<Self>) {
1171 let Some((di, _pi)) = self.dock_pane_of(id) else { return };
1172 let parent = self.docks[di].window.clone();
1173 let sender = sender.clone();
1174 bookmarks::show_bookmark_dialog(
1175 &parent,
1176 "New Bookmark",
1177 "",
1178 Some(Path::new("")),
1179 move |name, path| {
1180 if let Some(path) = path {
1181 sender.input(AppMsg::BookmarkAdded { name, path });
1182 }
1183 },
1184 );
1185 }
1186
1187 fn new_bookmark_folder(&mut self, id: u64, sender: &ComponentSender<Self>) {
1189 let Some((di, _pi)) = self.dock_pane_of(id) else { return };
1190 let parent = self.docks[di].window.clone();
1191 let sender = sender.clone();
1192 bookmarks::show_bookmark_dialog(&parent, "New Folder", "", None, move |name, _path| {
1193 sender.input(AppMsg::BookmarkFolderAdded { name });
1194 });
1195 }
1196
1197 fn move_bookmark(&mut self, from: Vec<usize>, to: MoveTarget, sender: &ComponentSender<Self>) {
1200 if from.is_empty() {
1201 return;
1202 }
1203 let (parent, index) = match &to {
1206 MoveTarget::Root => (Vec::new(), usize::MAX),
1207 MoveTarget::Into(folder) => {
1208 if folder.starts_with(&from) {
1209 return;
1210 }
1211 (adjust_path_after_removal(&from, folder), usize::MAX)
1212 }
1213 MoveTarget::Before(leaf) => {
1214 if leaf.is_empty() || leaf.starts_with(&from) {
1215 return;
1216 }
1217 let parent_orig = leaf[..leaf.len() - 1].to_vec();
1218 let parent = adjust_path_after_removal(&from, &parent_orig);
1219 let mut index = leaf[leaf.len() - 1];
1220 if from.len() == leaf.len()
1222 && from[..from.len() - 1] == parent_orig[..]
1223 && from[from.len() - 1] < index
1224 {
1225 index -= 1;
1226 }
1227 (parent, index)
1228 }
1229 };
1230 let Some(entry) = Bookmark::take(&mut self.bookmarks, &from) else {
1231 return;
1232 };
1233 if !Bookmark::insert(&mut self.bookmarks, &parent, index, entry) {
1234 return;
1235 }
1236 self.save_bookmarks();
1237 self.refresh_all_bookmarks(sender);
1238 }
1239
1240 fn save_bookmarks(&mut self) {
1242 let path = bookmark_file_path(&self.config.bookmarks.file);
1243 if let Err(err) = save_bookmarks_to_path(&path, &self.bookmarks) {
1244 self.status = format!("Could not save bookmarks: {err:?}");
1245 }
1246 }
1247
1248 fn refresh_all_bookmarks(&mut self, sender: &ComponentSender<Self>) {
1252 let context = self.config.context_menu.clone();
1253 let extras = self.config.bookmarks.context.clone();
1254 let bookmarks = self.bookmarks.clone();
1255 for di in 0..self.docks.len() {
1256 for pi in 0..self.docks[di].panes.len() {
1257 let pane = &self.docks[di].panes[pi];
1258 let menu = bookmarks::BookmarkMenuConfig {
1259 context: &context,
1260 extras: &extras,
1261 side: self.docks[di].side,
1262 };
1263 let id = pane.id;
1264 let filter = pane.bookmark_filter.clone();
1265 let sender = sender.clone();
1266 let on_event: Rc<dyn Fn(BookmarkEvent)> =
1267 Rc::new(move |event| sender.input(AppMsg::BookmarkEvent { id, event }));
1268 bookmarks::fill_bookmarks(
1269 &pane.bookmarks_list,
1270 &bookmarks,
1271 &filter,
1272 &menu,
1273 &pane.bookmark_drag,
1274 &pane.bookmark_nav,
1275 on_event,
1276 );
1277 }
1278 }
1279 }
1280
1281 fn dispatch_pane_builtin(
1285 &mut self,
1286 id: u64,
1287 action: BuiltinAction,
1288 sender: &ComponentSender<Self>,
1289 ) {
1290 self.set_active(id);
1291 match action {
1292 BuiltinAction::OpenFolder => self.show_open_folder(id, sender),
1293 BuiltinAction::Filter => self.open_filter(id),
1294 BuiltinAction::SplitView => sender.input(AppMsg::SplitFromPane { id }),
1295 BuiltinAction::Up => self.go_up(id),
1296 BuiltinAction::Back => self.go_back(id),
1297 BuiltinAction::Forward => self.go_forward(id),
1298 BuiltinAction::Collapse => self.collapse_dock(id),
1299 BuiltinAction::ClosePane => sender.input(AppMsg::ClosePane { id }),
1300 BuiltinAction::ToggleBookmarks => self.show_bookmarks_view(id),
1301 BuiltinAction::NewBookmark => self.new_bookmark(id, sender),
1302 BuiltinAction::NewBookmarkFolder => self.new_bookmark_folder(id, sender),
1303 _ => {}
1304 }
1305 }
1306
1307 fn go_up(&mut self, id: u64) {
1310 let Some((di, pi)) = self.dock_pane_of(id) else {
1311 return;
1312 };
1313 let Some(parent) = self.docks[di].panes[pi]
1314 .root
1315 .as_deref()
1316 .and_then(Path::parent)
1317 .map(Path::to_path_buf)
1318 else {
1319 return;
1320 };
1321 self.docks[di].panes[pi].tree.emit(TreeMsg::OpenRoot(parent));
1322 self.focus_pane_later(id);
1323 }
1324
1325 fn go_back(&mut self, id: u64) {
1327 let Some((di, pi)) = self.dock_pane_of(id) else {
1328 return;
1329 };
1330 if let Some(target) = self.docks[di].panes[pi].history.back() {
1331 self.show_view_entry(id, target);
1332 }
1333 }
1334
1335 fn go_forward(&mut self, id: u64) {
1337 let Some((di, pi)) = self.dock_pane_of(id) else {
1338 return;
1339 };
1340 if let Some(target) = self.docks[di].panes[pi].history.forward() {
1341 self.show_view_entry(id, target);
1342 }
1343 }
1344
1345 fn show_view_entry(&mut self, id: u64, entry: ViewEntry) {
1349 let Some((di, pi)) = self.dock_pane_of(id) else {
1350 return;
1351 };
1352 match entry {
1353 ViewEntry::Dir(path) => {
1354 self.docks[di].panes[pi].show_tree();
1355 self.docks[di].panes[pi].tree.emit(TreeMsg::OpenRoot(path));
1356 }
1357 ViewEntry::Bookmarks => {
1358 self.docks[di].panes[pi].show_bookmarks();
1359 self.docks[di].panes[pi].history.finish_navigation();
1360 self.docks[di].panes[pi].refresh_nav();
1361 }
1362 }
1363 self.set_active(id);
1364 self.focus_pane_later(id);
1365 }
1366
1367 fn run_pane_shortcut(
1371 &mut self,
1372 id: u64,
1373 action: ContextAction,
1374 sender: &ComponentSender<Self>,
1375 ) {
1376 self.set_active(id);
1377 let Some(target) = ShortcutTarget::from_action(&action) else {
1378 return;
1379 };
1380 if let ShortcutTarget::Builtin(builtin) = &target
1381 && builtin.is_pane_action()
1382 {
1383 self.dispatch_pane_builtin(id, *builtin, sender);
1384 return;
1385 }
1386 if let Some((di, pi)) = self.dock_pane_of(id)
1387 && let Some(msg) = pane_item_message(&target)
1388 {
1389 self.docks[di].panes[pi].tree.emit(msg);
1390 }
1391 }
1392
1393 fn dock_pane_of(&self, id: u64) -> Option<(usize, usize)> {
1395 self.docks.iter().enumerate().find_map(|(di, dock)| {
1396 dock.panes
1397 .iter()
1398 .position(|pane| pane.id == id)
1399 .map(|pi| (di, pi))
1400 })
1401 }
1402
1403 fn find_pane_with_dir(&self, path: &PathBuf) -> Option<(usize, usize)> {
1408 let canonical = std::fs::canonicalize(path).ok().unwrap_or_else(|| path.clone());
1409 self.docks.iter().enumerate().find_map(|(di, dock)| {
1410 dock.panes.iter().enumerate().find_map(|(pi, pane)| {
1411 if pane.canonical_root.as_deref() == Some(canonical.as_path()) {
1412 Some((di, pi))
1413 } else {
1414 None
1415 }
1416 })
1417 })
1418 }
1419
1420 fn dock_of_side(&self, side: PanelSide) -> Option<usize> {
1422 self.docks.iter().position(|dock| dock.side == side)
1423 }
1424
1425 fn collapse_dock(&mut self, id: u64) {
1428 if let Some((di, _)) = self.dock_pane_of(id) {
1429 self.set_dock_visible(di, false);
1430 }
1431 }
1432
1433 fn set_dock_visible(&mut self, di: usize, visible: bool) {
1435 self.docks[di].window.set_visible(visible);
1436 self.refresh_visible();
1437 }
1438
1439 fn set_docks_visible(&mut self, visible: bool) {
1441 let focus: Vec<u64> = self
1442 .docks
1443 .iter()
1444 .filter_map(|dock| dock.panes.last().map(|pane| pane.id))
1445 .collect();
1446 for dock in &self.docks {
1447 dock.window.set_visible(visible);
1448 }
1449 self.visible = visible;
1450 if visible {
1454 for id in focus {
1455 self.focus_pane(id);
1456 }
1457 }
1458 }
1459
1460 fn refresh_visible(&mut self) {
1463 self.visible = self.docks.iter().any(|dock| dock.window.is_visible());
1464 }
1465
1466 fn width_for(&self, side: PanelSide) -> u32 {
1468 self.widths.get(&side).copied().unwrap_or(self.config.panel.width)
1469 }
1470
1471 fn set_width(&mut self, side: PanelSide, width: u32) {
1474 let width = width.clamp(PANEL_MIN_WIDTH, PANEL_MAX_WIDTH);
1475 if width == self.width_for(side) {
1476 return;
1477 }
1478 self.widths.insert(side, width);
1479 let panel = PanelConfig { width, ..self.config.panel };
1480 for dock in self.docks.iter().filter(|dock| dock.side == side) {
1481 apply_window_width(&dock.window, width);
1482 for pane in &dock.panes {
1483 pane.tree.emit(TreeMsg::SetPanel(panel));
1484 }
1485 }
1486 if side == self.primary_side {
1488 self.config.panel.width = width;
1489 }
1490 self.schedule_save();
1491 }
1492
1493 fn resize_by(&mut self, side: PanelSide, delta: i32) {
1495 let target = (self.width_for(side) as i64 + delta as i64)
1496 .clamp(PANEL_MIN_WIDTH as i64, PANEL_MAX_WIDTH as i64) as u32;
1497 self.set_width(side, target);
1498 }
1499
1500 fn session_state(&self) -> SessionState {
1502 SessionState {
1503 last_root: self.last_root.clone(),
1504 left_width: Some(self.width_for(PanelSide::Left)),
1505 right_width: Some(self.width_for(PanelSide::Right)),
1506 }
1507 }
1508
1509 fn persist_session(&mut self) {
1511 self.save_generation.set(self.save_generation.get().wrapping_add(1));
1512 if let Err(err) = self.session_state().save() {
1513 self.status = format!("Could not save session: {err}");
1514 }
1515 }
1516
1517 fn schedule_save(&mut self) {
1521 let generation = self.save_generation.get().wrapping_add(1);
1522 self.save_generation.set(generation);
1523 let current = self.save_generation.clone();
1524 let state = self.session_state();
1525 glib::timeout_add_local_once(std::time::Duration::from_millis(SAVE_DEBOUNCE_MS), move || {
1526 if current.get() == generation {
1527 let _ = state.save();
1528 }
1529 });
1530 }
1531
1532 fn ensure_dock(&mut self, side: PanelSide, seed: bool, sender: &ComponentSender<Self>) -> usize {
1536 if let Some((di, _)) = self.docks.iter().enumerate().find(|(_, d)| d.side == side) {
1537 if seed && self.docks[di].panes.is_empty() {
1538 let startup = self.config.startup.clone();
1540 if let Some(root) = default_root(&startup) {
1541 self.add_pane(di, Some(root), sender);
1542 }
1543 }
1544 return di;
1545 }
1546 let config = &self.config;
1547 let width = self.width_for(side);
1548 let window = gtk::Window::new();
1549 init_layer_window(&window, config, side, width);
1550 window.set_default_size(width as i32, 520);
1551 {
1554 let sender = sender.clone();
1555 window.connect_map(move |_| {
1556 let sender = sender.clone();
1557 glib::idle_add_local_once(move || sender.input(AppMsg::FocusVisible));
1558 });
1559 }
1560 if layer_shell_available() {
1561 attach_resize_controls(&window, side, sender);
1562 }
1563 let outer = gtk::Box::new(gtk::Orientation::Vertical, 0);
1565 outer.set_vexpand(true);
1566 let container = gtk::Box::new(gtk::Orientation::Vertical, 0);
1567 container.set_vexpand(true);
1568 outer.append(&container);
1569 window.set_child(Some(&outer));
1570
1571 let di = self.docks.len();
1572 self.docks.push(Dock {
1573 side,
1574 window,
1575 container,
1576 panes: Vec::new(),
1577 active_pane: None,
1578 });
1579 if seed {
1580 let startup = self.config.startup.clone();
1581 if let Some(root) = default_root(&startup) {
1582 self.add_pane(di, Some(root), sender);
1583 } else {
1584 self.add_pane(di, None, sender);
1586 }
1587 }
1588 di
1589 }
1590
1591 fn add_pane(&mut self, di: usize, root: Option<PathBuf>, sender: &ComponentSender<Self>) -> u64 {
1594 let id = self.next_id;
1595 self.next_id += 1;
1596 let parent = self.docks[di].window.clone();
1597 let side = self.docks[di].side;
1598 let width = self.width_for(side);
1599 let pane = make_pane(&self.config, parent, id, side, width, sender.clone());
1600 let mut pane = pane;
1601 match &root {
1602 Some(root) => pane.tree.emit(TreeMsg::OpenRoot(root.clone())),
1603 None => {
1604 pane.show_bookmarks();
1605 pane.history.record(ViewEntry::Bookmarks);
1606 pane.refresh_nav();
1607 }
1608 }
1609 if let Some(root) = &root {
1610 pane.set_root(root.clone());
1611 }
1612 let dock = &mut self.docks[di];
1613 dock.panes.push(pane);
1614 dock.active_pane = Some(id);
1615 let container = dock.container.clone();
1616 fill_pane_container(&container, &dock.panes);
1617 let focus_sender = sender.clone();
1620 glib::idle_add_local_once(move || focus_sender.input(AppMsg::FocusPane { id }));
1621 self.refresh_all_bookmarks(sender);
1623 id
1624 }
1625
1626 fn show_open_folder(&self, id: u64, sender: &ComponentSender<Self>) {
1627 let sender = sender.clone();
1628 self.file_dialog.select_folder(
1629 None::<>k::Window>,
1630 None::<&gio::Cancellable>,
1631 move |result| {
1632 let path = result.ok().and_then(|file| file.path());
1633 sender.input(AppMsg::OpenFolderPicked { id, path });
1634 },
1635 );
1636 }
1637}
1638
1639fn pane_item_message(target: &ShortcutTarget) -> Option<TreeMsg> {
1647 match target {
1648 ShortcutTarget::Builtin(action) => match action {
1649 BuiltinAction::ToggleHidden => Some(TreeMsg::ToggleHidden),
1650 BuiltinAction::SortByName => Some(TreeMsg::SetSortKey(SortKey::Name)),
1651 BuiltinAction::SortBySize => Some(TreeMsg::SetSortKey(SortKey::Size)),
1652 BuiltinAction::SortByModified => Some(TreeMsg::SetSortKey(SortKey::Modified)),
1653 BuiltinAction::SortByType => Some(TreeMsg::SetSortKey(SortKey::Type)),
1654 BuiltinAction::ToggleSortAscending => Some(TreeMsg::ToggleSortDirection),
1655 other => Some(TreeMsg::RunShortcut {
1656 target: ShortcutTarget::Builtin(*other),
1657 }),
1658 },
1659 ShortcutTarget::Command(_) => Some(TreeMsg::RunShortcut { target: target.clone() }),
1660 }
1661}
1662
1663fn build_nav_bar(id: u64, sender: &ComponentSender<App>) -> (gtk::Box, NavButtons) {
1667 let bar = gtk::Box::new(gtk::Orientation::Horizontal, 2);
1668 bar.add_css_class("nav-toolbar");
1669
1670 let up = nav_button("pan-up-symbolic", "Up One Level", BuiltinAction::Up, id, sender);
1671 let back = nav_button("pan-start-symbolic", "Back", BuiltinAction::Back, id, sender);
1672 let forward = nav_button("pan-end-symbolic", "Forward", BuiltinAction::Forward, id, sender);
1673
1674 bar.append(&up);
1675 bar.append(&back);
1676 bar.append(&forward);
1677 (bar, NavButtons { up, back, forward })
1678}
1679
1680fn nav_button(
1683 icon: &str,
1684 tooltip: &str,
1685 action: BuiltinAction,
1686 id: u64,
1687 sender: &ComponentSender<App>,
1688) -> gtk::Button {
1689 let button = gtk::Button::from_icon_name(icon);
1690 button.add_css_class("nav-button");
1691 button.add_css_class("flat");
1692 button.set_tooltip_text(Some(tooltip));
1693 button.set_focusable(false);
1694 button.set_can_focus(false);
1695 button.set_valign(gtk::Align::Center);
1696 let sender = sender.clone();
1697 button.connect_clicked(move |_| {
1698 sender.input(AppMsg::PaneToolbar {
1699 id,
1700 out: ToolbarOutput::PaneItem(ShortcutTarget::Builtin(action)),
1701 });
1702 });
1703 button
1704}
1705
1706fn make_pane(
1707 config: &Config,
1708 parent: gtk::Window,
1709 id: u64,
1710 side: PanelSide,
1711 width: u32,
1712 sender: ComponentSender<App>,
1713) -> Pane {
1714 let overlay = gtk::Overlay::new();
1717 let toolbar = Toolbar::builder()
1718 .launch(ToolbarInit {
1719 overlay: overlay.clone(),
1720 pane_menu: config.pane_menu.clone(),
1721 bookmarks_menu: config.bookmarks.menu.clone(),
1722 })
1723 .forward(sender.input_sender(), move |out| AppMsg::PaneToolbar { id, out });
1724 let tree = Tree::builder()
1725 .launch(TreeInit {
1726 config: config.tree.clone(),
1727 parent,
1728 menu: config.context_menu.clone(),
1729 side,
1730 panel: PanelConfig { width, ..config.panel },
1731 })
1732 .forward(sender.input_sender(), move |out| AppMsg::PaneTree { id, out });
1733
1734 let widget = gtk::Box::new(gtk::Orientation::Vertical, 0);
1735 widget.append(toolbar.widget());
1736
1737 let nav_buttons = if config.panel.nav_toolbar {
1739 let (bar, buttons) = build_nav_bar(id, &sender);
1740 widget.append(&bar);
1741 Some(buttons)
1742 } else {
1743 None
1744 };
1745
1746 let filter_bar = gtk::Box::new(gtk::Orientation::Horizontal, 4);
1748 filter_bar.add_css_class("filter-bar");
1749 filter_bar.set_visible(false);
1750
1751 let filter_entry = gtk::SearchEntry::new();
1752 filter_entry.set_placeholder_text(Some("Filter..."));
1753 filter_entry.set_hexpand(true);
1754 filter_entry.set_valign(gtk::Align::Center);
1755 filter_entry.add_css_class("filter-entry");
1756 {
1757 let s = sender.clone();
1758 filter_entry.connect_search_changed(move |entry| {
1759 s.input(AppMsg::FilterChanged {
1760 id,
1761 filter: entry.text().to_string(),
1762 });
1763 });
1764 }
1765 filter_bar.append(&filter_entry);
1766
1767 let close_btn = gtk::Button::from_icon_name("window-close-symbolic");
1768 close_btn.set_tooltip_text(Some("Clear filter"));
1769 close_btn.set_valign(gtk::Align::Center);
1770 close_btn.add_css_class("flat");
1771 {
1772 let s = sender.clone();
1773 close_btn.connect_clicked(move |_| {
1774 s.input(AppMsg::FilterClosed { id });
1775 });
1776 }
1777 filter_bar.append(&close_btn);
1778
1779 widget.append(&filter_bar);
1780
1781 let tree_widget = tree.widget();
1783 tree_widget.set_vexpand(true);
1784
1785 let bookmarks_list = gtk::Box::new(gtk::Orientation::Vertical, 0);
1786 bookmarks_list.add_css_class("bookmarks-list");
1787 let bookmarks_scroll = gtk::ScrolledWindow::new();
1788 bookmarks_scroll.set_policy(gtk::PolicyType::Never, gtk::PolicyType::Automatic);
1789 bookmarks_scroll.set_vexpand(true);
1790 bookmarks_scroll.add_css_class("bookmarks-view");
1791 bookmarks_scroll.set_child(Some(&bookmarks_list));
1792 let bookmark_drag = bookmarks::BookmarkDrag::new();
1793 let bookmark_nav = bookmarks::BookmarkNav::new(&bookmarks_list);
1794 {
1797 let items = config.bookmarks.blank.clone();
1798 let sender = sender.clone();
1799 let on_event: Rc<dyn Fn(BookmarkEvent)> =
1800 Rc::new(move |event| sender.input(AppMsg::BookmarkEvent { id, event }));
1801 bookmarks::attach_bookmarks_scroller(&bookmarks_scroll, &items, side, &bookmark_drag, &bookmark_nav, on_event);
1802 }
1803
1804 let body = gtk::Stack::new();
1805 body.set_vexpand(true);
1806 body.add_named(tree_widget, Some("tree"));
1807 body.add_named(&bookmarks_scroll, Some("bookmarks"));
1808 body.set_visible_child_name("tree");
1809 widget.append(&body);
1810 widget.set_vexpand(true);
1811
1812 overlay.set_child(Some(&widget));
1813
1814 {
1818 let mut items = config.pane_menu.items.clone();
1821 items.extend(config.bookmarks.menu.iter().cloned());
1822 let shortcuts = Rc::new(PaneShortcuts::compile(&items));
1823 let s = sender.clone();
1824 let keys = gtk::EventControllerKey::new();
1825 keys.set_propagation_phase(gtk::PropagationPhase::Capture);
1826 keys.connect_key_pressed(move |_, key, _, state| {
1827 if let Some(action) = shortcuts.action_for(key, state) {
1828 s.input(AppMsg::PaneShortcut { id, action });
1829 glib::Propagation::Stop
1830 } else {
1831 glib::Propagation::Proceed
1832 }
1833 });
1834 widget.add_controller(keys);
1835 }
1836
1837 Pane {
1838 id,
1839 toolbar,
1840 tree,
1841 root: None,
1842 canonical_root: None,
1843 history: NavHistory::default(),
1844 filter_bar,
1845 filter_entry,
1846 body,
1847 bookmarks_list,
1848 bookmark_filter: String::new(),
1849 bookmark_drag,
1850 bookmark_nav,
1851 nav_buttons,
1852 widget: overlay,
1853 }
1854}
1855
1856fn remove_from_parent(w: >k::Widget) {
1864 let Some(parent) = w.parent() else { return };
1865 if let Ok(paned) = parent.clone().downcast::<gtk::Paned>() {
1866 let none: Option<>k::Widget> = None;
1867 if paned.start_child().is_some_and(|c| c == *w) {
1868 paned.set_start_child(none);
1869 }
1870 let none: Option<>k::Widget> = None;
1871 if paned.end_child().is_some_and(|c| c == *w) {
1872 paned.set_end_child(none);
1873 }
1874 } else if let Ok(b) = parent.downcast::<gtk::Box>() {
1875 b.remove(w);
1876 } else {
1877 w.unparent();
1878 }
1879}
1880
1881fn pane_widget(pane: &Pane) -> gtk::Widget {
1882 pane.widget.clone().upcast()
1883}
1884
1885fn fill_pane_container(container: >k::Box, panes: &[Pane]) {
1896 for pane in panes {
1901 remove_from_parent(pane.widget.upcast_ref());
1902 }
1903 while let Some(child) = container.first_child() {
1905 container.remove(&child);
1906 }
1907
1908 match panes {
1909 [] => {}
1910 [single] => {
1911 let w = pane_widget(single);
1912 w.set_vexpand(true);
1913 container.append(&w);
1914 }
1915 panes => {
1916 let n = panes.len();
1919 let last_w = pane_widget(&panes[n - 1]);
1920 last_w.set_vexpand(true);
1921 let mut right: gtk::Widget = last_w.clone().upcast();
1922 for pane in panes[..n - 1].iter().rev() {
1923 let left_w = pane_widget(pane);
1924 left_w.set_vexpand(true);
1925 let split = gtk::Paned::new(gtk::Orientation::Vertical);
1926 split.set_vexpand(true);
1927 split.set_wide_handle(true);
1928 split.set_shrink_start_child(true);
1930 split.set_shrink_end_child(true);
1931 split.set_resize_start_child(true);
1932 split.set_resize_end_child(true);
1933 split.set_start_child(Some(&left_w));
1934 split.set_end_child(Some(&right));
1935 {
1938 let split2 = split.clone();
1939 relm4::gtk::glib::timeout_add_local_once(
1940 std::time::Duration::from_millis(PANED_CENTER_DELAY_MS),
1941 move || {
1942 let alloc = split2.height();
1945 if alloc > 0 {
1946 split2.set_position(alloc / 2);
1947 }
1948 },
1949 );
1950 }
1951 right = split.upcast();
1952 }
1953 container.append(&right);
1954 }
1955 }
1956}
1957
1958fn stop_video_widgets(widget: >k::Widget) {
1964 if let Some(video) = widget.downcast_ref::<gtk::Video>() {
1965 video.set_media_stream(None::<>k::MediaStream>);
1966 }
1967 let mut child = widget.first_child();
1968 while let Some(current) = child {
1969 stop_video_widgets(¤t);
1970 child = current.next_sibling();
1971 }
1972}
1973
1974const WIDTH_STEP: i32 = 24;
1976
1977const SAVE_DEBOUNCE_MS: u64 = 300;
1979
1980const PANED_CENTER_DELAY_MS: u64 = 100;
1983
1984const SHUTDOWN_GRACE_MS: u64 = 150;
1987
1988fn apply_window_width(window: >k::Window, width: u32) {
1990 let (_, height) = window.default_size();
1991 if window.is_layer_window() {
1994 window.set_exclusive_zone(width as i32);
1995 }
1996 window.set_size_request(width as i32, -1);
1997 window.set_default_size(width as i32, if height > 0 { height } else { -1 });
1998}
1999
2000fn attach_resize_controls(window: >k::Window, side: PanelSide, sender: &ComponentSender<App>) {
2003 attach_resize_drag(window, side, sender.input_sender().clone());
2004 attach_resize_keys(window, side, sender.input_sender().clone());
2005}
2006
2007fn attach_resize_drag(window: >k::Window, side: PanelSide, sender: relm4::Sender<AppMsg>) {
2008 let drag = gtk::GestureDrag::new();
2009 drag.set_button(gdk::BUTTON_SECONDARY);
2010 drag.set_propagation_phase(gtk::PropagationPhase::Capture);
2011 let state = Rc::new(RefCell::new((false, 0.0f64)));
2013
2014 let begin_state = state.clone();
2015 drag.connect_drag_begin(move |gesture, _, _| {
2016 let super_held = gesture
2017 .current_event_state()
2018 .contains(gdk::ModifierType::SUPER_MASK);
2019 *begin_state.borrow_mut() = (super_held, 0.0);
2020 });
2021
2022 let update_sender = sender.clone();
2023 let update_state = state.clone();
2024 drag.connect_drag_update(move |_, offset_x, _| {
2025 let mut state = update_state.borrow_mut();
2026 if !state.0 {
2027 return;
2028 }
2029 let step = offset_x - state.1;
2030 state.1 = offset_x;
2031 let widen = match side {
2033 PanelSide::Left => step,
2034 PanelSide::Right => -step,
2035 };
2036 let delta = widen.round() as i32;
2037 if delta != 0 {
2038 let _ = update_sender.send(AppMsg::ResizeBy { side, delta });
2039 }
2040 });
2041
2042 let end_sender = sender;
2043 let end_state = state.clone();
2044 drag.connect_drag_end(move |_, _, _| {
2045 if end_state.borrow().0 {
2046 let _ = end_sender.send(AppMsg::ResizeCommit);
2047 }
2048 });
2049
2050 window.add_controller(drag);
2051}
2052
2053fn attach_resize_keys(window: >k::Window, side: PanelSide, sender: relm4::Sender<AppMsg>) {
2054 let keys = gtk::EventControllerKey::new();
2055 keys.set_propagation_phase(gtk::PropagationPhase::Capture);
2056 keys.connect_key_pressed(move |_, key, _, state| {
2057 if !state.contains(gdk::ModifierType::SUPER_MASK) {
2058 return glib::Propagation::Proceed;
2059 }
2060 match key {
2061 gdk::Key::minus | gdk::Key::underscore | gdk::Key::KP_Subtract => {
2062 let _ = sender.send(AppMsg::ResizeBy { side, delta: -WIDTH_STEP });
2063 glib::Propagation::Stop
2064 }
2065 gdk::Key::equal | gdk::Key::plus | gdk::Key::KP_Add => {
2066 let _ = sender.send(AppMsg::ResizeBy { side, delta: WIDTH_STEP });
2067 glib::Propagation::Stop
2068 }
2069 _ => glib::Propagation::Proceed,
2070 }
2071 });
2072 window.add_controller(keys);
2073}
2074
2075fn layer_shell_available() -> bool {
2083 static SUPPORTED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
2084 *SUPPORTED.get_or_init(gtk4_layer_shell::is_supported)
2085}
2086
2087fn init_layer_window(window: >k::Window, config: &Config, side: PanelSide, width: u32) {
2094 if !layer_shell_available() {
2095 configure_plain_window(window, width);
2096 return;
2097 }
2098 if window.is_layer_window() {
2099 return;
2100 }
2101 window.init_layer_shell();
2102 window.set_layer(layer_of(config.panel.layer));
2103 let edge = match side {
2104 PanelSide::Left => Edge::Left,
2105 PanelSide::Right => Edge::Right,
2106 };
2107 window.set_anchor(Edge::Top, true);
2108 window.set_anchor(Edge::Bottom, true);
2109 window.set_anchor(edge, true);
2110 window.set_exclusive_zone(width as i32);
2111 window.set_size_request(width as i32, -1);
2112 let margin = config.panel.margin as i32;
2113 window.set_margin(Edge::Top, margin);
2114 window.set_margin(Edge::Bottom, margin);
2115 window.set_margin(Edge::Left, margin);
2116 window.set_margin(Edge::Right, margin);
2117
2118 window.set_keyboard_mode(KeyboardMode::OnDemand);
2133 let motion = gtk::EventControllerMotion::new();
2134 {
2135 let kb_window = window.clone();
2136 motion.connect_enter(move |_, _, _| kb_window.set_keyboard_mode(KeyboardMode::OnDemand));
2137 }
2138 {
2139 let kb_window = window.clone();
2140 motion.connect_leave(move |_| {
2141 if has_visible_popover(kb_window.upcast_ref()) {
2146 return;
2147 }
2148 kb_window.set_keyboard_mode(KeyboardMode::None);
2149 });
2150 }
2151 window.add_controller(motion);
2152}
2153
2154fn configure_plain_window(window: >k::Window, width: u32) {
2160 static ANNOUNCED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
2161 ANNOUNCED.get_or_init(|| {
2162 eprintln!(
2163 "tree-space: this compositor does not support wlr-layer-shell, so the panel \
2164 cannot dock; opening it as a normal window instead."
2165 );
2166 });
2167 window.set_title(Some("tree-space"));
2168 window.set_default_size(width as i32, 720);
2169 window.set_size_request(width as i32, -1);
2170}
2171
2172fn has_visible_popover(widget: >k::Widget) -> bool {
2175 if widget.is::<gtk::Popover>() && widget.is_visible() {
2176 return true;
2177 }
2178 let mut child = widget.first_child();
2179 while let Some(current) = child {
2180 if has_visible_popover(¤t) {
2181 return true;
2182 }
2183 child = current.next_sibling();
2184 }
2185 false
2186}
2187
2188fn layer_of(layer: PanelLayer) -> Layer {
2189 use PanelLayer::*;
2190 match layer {
2191 Background => Layer::Background,
2192 Bottom => Layer::Bottom,
2193 Top => Layer::Top,
2194 Overlay => Layer::Overlay,
2195 }
2196}
2197
2198fn install_css(config: &Config) {
2202 let stylesheet = load_stylesheet();
2203 if let Some(problem) = &stylesheet.problem {
2204 eprintln!("tree-space: could not read stylesheet: {problem:?}");
2205 }
2206 let css = format!(
2207 "{}\n.tree-row label, .tree-rename-entry, .tree-menu, .hamburger-menu {{ font-size: {}px; }}",
2208 stylesheet.css, config.tree.font_size
2209 );
2210 let provider = gtk::CssProvider::new();
2211 provider.load_from_string(&css);
2212 if let Some(display) = gdk::Display::default() {
2213 gtk::style_context_add_provider_for_display(
2214 &display,
2215 &provider,
2216 gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
2217 );
2218 }
2219}
2220
2221fn home_dir() -> Option<PathBuf> {
2222 std::env::var_os("HOME").map(PathBuf::from)
2223}
2224
2225fn default_root(startup: &StartupRoot) -> Option<PathBuf> {
2228 if startup.is_bookmarks() {
2230 return None;
2231 }
2232 let last = SessionState::load().last_root.filter(|p| p.is_dir());
2233 startup.resolve(last).filter(|p| p.is_dir()).or_else(home_dir)
2234}
2235
2236fn adjust_path_after_removal(from: &[usize], path: &[usize]) -> Vec<usize> {
2240 let mut out = path.to_vec();
2241 for level in 0..path.len().min(from.len()) {
2242 if from[level] < path[level] {
2243 out[level] -= 1;
2244 return out;
2245 } else if from[level] > path[level] {
2246 return out;
2247 }
2248 }
2249 out
2250}
2251
2252#[cfg(test)]
2253mod adjust_path_tests {
2254 use super::adjust_path_after_removal;
2255
2256 #[test]
2257 fn adjusts_sibling_indices_after_a_removal() {
2258 assert_eq!(adjust_path_after_removal(&[0], &[2]), vec![1]);
2260 assert_eq!(adjust_path_after_removal(&[2], &[0]), vec![0]);
2262 assert_eq!(adjust_path_after_removal(&[0], &[1, 3]), vec![0, 3]);
2264 assert_eq!(adjust_path_after_removal(&[1, 2], &[1]), vec![1]);
2266 assert_eq!(adjust_path_after_removal(&[1, 0], &[1, 2]), vec![1, 1]);
2268 }
2269}
2270
2271#[cfg(test)]
2272mod visibility_tests {
2273 use super::*;
2274
2275 const L: PanelSide = PanelSide::Left;
2276 const R: PanelSide = PanelSide::Right;
2277
2278 fn plan(intent: VisibilityIntent, existing: &[PanelSide], shown: &[PanelSide]) -> VisibilityPlan {
2279 resolve_visibility(intent, existing, shown)
2280 }
2281
2282 #[test]
2283 fn show_all_shows_both() {
2284 let p = plan(VisibilityIntent::ShowAll, &[L], &[]);
2285 assert_eq!(p.show, vec![L]);
2287 assert!(p.seed);
2288 let both = plan(VisibilityIntent::ShowAll, &[L, R], &[]);
2289 assert_eq!(both.show, vec![L, R]);
2290 }
2291
2292 #[test]
2293 fn hide_all_hides_both() {
2294 let p = plan(VisibilityIntent::HideAll, &[L, R], &[L, R]);
2295 assert_eq!(p.hide, vec![L, R]);
2296 assert!(p.show.is_empty());
2297 }
2298
2299 #[test]
2300 fn toggle_all_shows_when_nothing_visible_and_hides_otherwise() {
2301 let shown = plan(VisibilityIntent::ToggleAll, &[L], &[]);
2302 assert_eq!(shown.show, vec![L]);
2304 let hidden = plan(VisibilityIntent::ToggleAll, &[L, R], &[L]);
2305 assert_eq!(hidden.hide, vec![L, R]);
2306 }
2307
2308 #[test]
2309 fn show_side_creates_a_missing_dock() {
2310 let p = plan(VisibilityIntent::ShowSide(R), &[L], &[L]);
2311 assert_eq!(p.create, Some(R));
2312 assert_eq!(p.show, vec![R]);
2313 }
2314
2315 #[test]
2316 fn hide_side_never_creates() {
2317 let p = plan(VisibilityIntent::HideSide(R), &[L], &[L]);
2318 assert_eq!(p.create, None);
2319 assert_eq!(p.hide, vec![R]);
2320 }
2321
2322 #[test]
2323 fn toggle_side_is_granular() {
2324 let create = plan(VisibilityIntent::ToggleSide(R), &[L], &[L]);
2326 assert_eq!(create.create, Some(R));
2327 assert_eq!(create.show, vec![R]);
2328 let hide = plan(VisibilityIntent::ToggleSide(L), &[L, R], &[L, R]);
2330 assert_eq!(hide.hide, vec![L]);
2331 let show = plan(VisibilityIntent::ToggleSide(L), &[L, R], &[R]);
2333 assert_eq!(show.show, vec![L]);
2334 }
2335
2336 #[test]
2337 fn toggling_one_side_leaves_the_other_alone() {
2338 let p = plan(VisibilityIntent::ToggleSide(R), &[L, R], &[L]);
2340 assert_eq!(p.show, vec![R]);
2341 assert!(!p.hide.contains(&L));
2342 }
2343}
2344
2345#[cfg(test)]
2346mod nav_history_tests {
2347 use super::{NavHistory, ViewEntry};
2348 use std::path::PathBuf;
2349
2350 fn dir(s: &str) -> ViewEntry {
2351 ViewEntry::Dir(PathBuf::from(s))
2352 }
2353
2354 #[test]
2355 fn records_visits_in_order_and_navigates_both_ways() {
2356 let mut h = NavHistory::default();
2357 h.record(dir("/a"));
2358 h.record(dir("/b"));
2359 h.record(dir("/c"));
2360 assert!(h.can_back());
2361 assert!(!h.can_forward());
2362
2363 assert_eq!(h.back(), Some(dir("/b")));
2364 h.finish_navigation();
2365 assert_eq!(h.back(), Some(dir("/a")));
2366 h.finish_navigation();
2367 assert!(!h.can_back());
2368 assert!(h.can_forward());
2369
2370 assert_eq!(h.forward(), Some(dir("/b")));
2371 h.finish_navigation();
2372 assert_eq!(h.forward(), Some(dir("/c")));
2373 h.finish_navigation();
2374 assert!(h.can_back());
2375 assert!(!h.can_forward());
2376 }
2377
2378 #[test]
2379 fn a_new_visit_after_going_back_truncates_the_forward_tail() {
2380 let mut h = NavHistory::default();
2381 h.record(dir("/a"));
2382 h.record(dir("/b"));
2383 h.record(dir("/c"));
2384 assert_eq!(h.back(), Some(dir("/b")));
2385 h.finish_navigation();
2386 h.record(dir("/d"));
2388 assert!(!h.can_forward());
2389 assert_eq!(h.back(), Some(dir("/b")));
2390 }
2391
2392 #[test]
2393 fn navigating_does_not_record_and_repeats_are_ignored() {
2394 let mut h = NavHistory::default();
2395 h.record(dir("/a"));
2396 h.record(dir("/b"));
2397 let target = h.back().unwrap();
2399 assert_eq!(target, dir("/a"));
2400 h.record(target.clone());
2401 h.finish_navigation();
2402 assert!(h.can_forward());
2404 assert_eq!(h.forward(), Some(dir("/b")));
2405 h.finish_navigation();
2406
2407 h.record(dir("/b"));
2409 assert_eq!(h.cursor, 1);
2410 assert_eq!(h.entries.len(), 2);
2411 }
2412
2413 #[test]
2414 fn bookmarks_view_is_a_history_entry() {
2415 let mut h = NavHistory::default();
2416 h.record(ViewEntry::Bookmarks);
2417 h.record(dir("/a"));
2418 assert_eq!(h.back(), Some(ViewEntry::Bookmarks));
2420 h.finish_navigation();
2421 h.record(ViewEntry::Bookmarks);
2423 assert_eq!(h.entries.len(), 2);
2424 assert_eq!(h.forward(), Some(dir("/a")));
2426 h.finish_navigation();
2427 }
2428
2429 #[test]
2430 fn cannot_navigate_an_empty_history() {
2431 let mut h = NavHistory::default();
2432 assert!(!h.can_back());
2433 assert!(!h.can_forward());
2434 assert_eq!(h.back(), None);
2435 assert_eq!(h.forward(), None);
2436 }
2437}