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};
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 widget: gtk::Overlay,
169}
170
171impl Pane {
172 fn set_root(&mut self, root: PathBuf) {
175 self.canonical_root = std::fs::canonicalize(&root).ok();
176 self.root = Some(root);
177 }
178
179 fn show_bookmarks(&self) {
181 self.body.set_visible_child_name("bookmarks");
182 self.toolbar.emit(ToolbarMsg::SetBookmarks(true));
183 }
184
185 fn show_tree(&self) {
187 self.body.set_visible_child_name("tree");
188 self.toolbar.emit(ToolbarMsg::SetBookmarks(false));
189 }
190
191 fn on_bookmarks(&self) -> bool {
193 self.body.visible_child_name().as_deref() == Some("bookmarks")
194 }
195}
196
197struct Dock {
199 side: PanelSide,
200 window: gtk::Window,
203 container: gtk::Box,
206 panes: Vec<Pane>,
207 active_pane: Option<u64>,
209}
210
211#[derive(Debug, Clone, Copy, PartialEq, Eq)]
215pub enum VisibilityIntent {
216 ShowAll,
218 HideAll,
220 ToggleAll,
222 ShowSide(PanelSide),
224 HideSide(PanelSide),
226 ToggleSide(PanelSide),
228}
229
230#[derive(Debug, Clone, PartialEq, Eq)]
236pub struct VisibilityPlan {
237 pub show: Vec<PanelSide>,
239 pub hide: Vec<PanelSide>,
241 pub create: Option<PanelSide>,
243 pub seed: bool,
246}
247
248pub fn resolve_visibility(
252 intent: VisibilityIntent,
253 existing: &[PanelSide],
254 shown: &[PanelSide],
255) -> VisibilityPlan {
256 match intent {
257 VisibilityIntent::ShowAll => VisibilityPlan {
258 show: existing.to_vec(),
259 hide: Vec::new(),
260 create: None,
261 seed: true,
262 },
263 VisibilityIntent::HideAll => VisibilityPlan {
264 show: Vec::new(),
265 hide: PanelSide::ALL.to_vec(),
266 create: None,
267 seed: false,
268 },
269 VisibilityIntent::ToggleAll => {
270 if shown.is_empty() {
271 VisibilityPlan {
272 show: existing.to_vec(),
273 hide: Vec::new(),
274 create: None,
275 seed: true,
276 }
277 } else {
278 VisibilityPlan {
279 show: Vec::new(),
280 hide: PanelSide::ALL.to_vec(),
281 create: None,
282 seed: false,
283 }
284 }
285 }
286 VisibilityIntent::ShowSide(side) => VisibilityPlan {
287 show: vec![side],
288 hide: Vec::new(),
289 create: (!existing.contains(&side)).then_some(side),
290 seed: true,
291 },
292 VisibilityIntent::HideSide(side) => VisibilityPlan {
293 show: Vec::new(),
294 hide: vec![side],
295 create: None,
296 seed: false,
297 },
298 VisibilityIntent::ToggleSide(side) => {
299 if !existing.contains(&side) {
300 VisibilityPlan {
301 show: vec![side],
302 hide: Vec::new(),
303 create: Some(side),
304 seed: true,
305 }
306 } else if shown.contains(&side) {
307 VisibilityPlan {
308 show: Vec::new(),
309 hide: vec![side],
310 create: None,
311 seed: false,
312 }
313 } else {
314 VisibilityPlan {
315 show: vec![side],
316 hide: Vec::new(),
317 create: None,
318 seed: true,
319 }
320 }
321 }
322 }
323}
324
325#[derive(Debug)]
327pub enum AppMsg {
328 PaneToolbar { id: u64, out: ToolbarOutput },
330 PaneTree { id: u64, out: TreeOutput },
332 PaneShortcut { id: u64, action: ContextAction },
335 OpenFolderPicked { id: u64, path: Option<PathBuf> },
337 LaunchRequest { command: Command },
339 SplitFromPane { id: u64 },
341 OpenSplitFrom { id: u64, root: PathBuf },
344 OpenOppositeFrom { id: u64, root: PathBuf },
347 ClosePane { id: u64 },
350 FilterChanged { id: u64, filter: String },
352 FilterClosed { id: u64 },
354 Shutdown,
356 ResizeBy { side: PanelSide, delta: i32 },
358 ResizeCommit,
360 FocusPane { id: u64 },
362 BookmarkEvent { id: u64, event: BookmarkEvent },
364 BookmarkEditSaved { index: usize, name: String, path: PathBuf },
366}
367
368pub struct AppInit {
371 pub command: Command,
372 pub listener: Option<std::os::unix::net::UnixListener>,
373}
374
375pub struct App {
377 config: Config,
378 status: String,
379 window: gtk::Window,
380 file_dialog: gtk::FileDialog,
381 docks: Vec<Dock>,
383 pane_container: gtk::Box,
385 primary_side: PanelSide,
387 next_id: u64,
389 visible: bool,
391 widths: HashMap<PanelSide, u32>,
395 last_root: Option<PathBuf>,
397 bookmarks: Vec<Bookmark>,
400 save_generation: Rc<Cell<u64>>,
404}
405
406#[relm4::component(pub)]
407impl SimpleComponent for App {
408 type Init = AppInit;
409 type Input = AppMsg;
410 type Output = ();
411
412 view! {
413 gtk::Window {
414 set_default_size: (model.config.panel.width as i32, 520),
415
416 gtk::Box {
417 set_orientation: gtk::Orientation::Vertical,
418 set_spacing: 0,
419 add_css_class: "panel",
420
421 append: &model.pane_container,
422
423 append: status = >k::Label {
424 #[watch]
425 set_label: &model.status,
426 set_halign: gtk::Align::Start,
427 set_ellipsize: gtk::pango::EllipsizeMode::End,
428 add_css_class: "status-bar",
429 }
430 }
431 }
432 }
433
434 fn init(
435 init: Self::Init,
436 root: Self::Root,
437 sender: ComponentSender<Self>,
438 ) -> ComponentParts<Self> {
439 let loaded = Config::load();
440 let mut config = loaded.config;
441 let parent = root.clone();
442
443 let (bookmarks, bookmark_problem) = crate::config::load_bookmarks(&config.bookmarks);
446 if let Err(problem) = crate::config::ensure_bookmarks_file(&config.bookmarks, &bookmarks) {
447 eprintln!("tree-space: could not create bookmarks file: {problem:?}");
448 }
449
450 let session = SessionState::load();
451 let default_width = config.panel.width;
454 let widths: HashMap<PanelSide, u32> = [
455 (
456 PanelSide::Left,
457 session.left_width.unwrap_or(default_width).clamp(PANEL_MIN_WIDTH, PANEL_MAX_WIDTH),
458 ),
459 (
460 PanelSide::Right,
461 session.right_width.unwrap_or(default_width).clamp(PANEL_MIN_WIDTH, PANEL_MAX_WIDTH),
462 ),
463 ]
464 .into_iter()
465 .collect();
466
467 let primary_side = init.command.side.unwrap_or(config.panel.side);
468 config.panel.width = widths[&primary_side];
470 let primary_width = config.panel.width;
471 let roots = if !init.command.roots.is_empty() {
475 init.command.roots.clone()
476 } else if config.startup.is_bookmarks() {
477 Vec::new()
478 } else {
479 let last = session.last_root.clone().filter(|p| p.is_dir());
480 let fallback = config
481 .startup
482 .resolve(last)
483 .filter(|p| p.is_dir())
484 .or_else(home_dir);
485 if let Some(p) = fallback { vec![p] } else { Vec::new() }
486 };
487
488 let mut panes: Vec<Pane> = Vec::new();
489 let mut next_id = 0u64;
490 for root in roots.iter() {
491 let pane =
492 make_pane(&config, parent.clone(), next_id, primary_side, primary_width, sender.clone());
493 pane.tree.emit(TreeMsg::OpenRoot(root.clone()));
494 panes.push(pane);
495 panes.last_mut().unwrap().set_root(root.clone());
496 next_id += 1;
497 }
498 if panes.is_empty() {
501 let mut pane = make_pane(
502 &config,
503 parent.clone(),
504 next_id,
505 primary_side,
506 primary_width,
507 sender.clone(),
508 );
509 pane.show_bookmarks();
510 pane.history.record(ViewEntry::Bookmarks);
511 panes.push(pane);
512 next_id += 1;
513 }
514
515 let pane_container = gtk::Box::new(gtk::Orientation::Vertical, 0);
516 pane_container.set_vexpand(true);
517 fill_pane_container(&pane_container, &panes);
518
519 let mut status = String::new();
520 if let Some(problem) = loaded.problem {
521 status = format!("config: {problem:?}");
522 } else if let Some(problem) = bookmark_problem {
523 status = format!("bookmarks: {problem:?}");
524 }
525
526 let mut model = App {
527 config,
528 status,
529 window: root.clone(),
530 file_dialog: gtk::FileDialog::new(),
531 docks: vec![Dock {
532 side: primary_side,
533 window: root.clone(),
534 container: pane_container.clone(),
535 panes,
536 active_pane: None,
537 }],
538 pane_container,
539 primary_side,
540 next_id,
541 visible: !init.command.hidden,
542 widths,
543 last_root: session.last_root.clone(),
544 bookmarks,
545 save_generation: Rc::new(Cell::new(0)),
546 };
547
548 init_layer_window(&model.window, &model.config, primary_side, primary_width);
549 install_css(&model.config);
550 if layer_shell_available() {
553 attach_resize_controls(&model.window, primary_side, &sender);
554 }
555
556 if let Some(listener) = init.listener {
559 let ipc_sender = sender.input_sender().clone();
560 ipc::spawn_listener(listener, move |command| {
561 let sender = ipc_sender.clone();
562 glib::MainContext::default().invoke(move || {
563 let _ = sender.send(AppMsg::LaunchRequest { command });
564 });
565 });
566 }
567
568 let widgets = view_output!();
569
570 model.refresh_all_bookmarks(&sender);
572
573 let shutting_down = Rc::new(Cell::new(false));
580 let flag = shutting_down.clone();
581 let shutdown_sender = sender.clone();
582 model.window.connect_close_request(move |_| {
583 if flag.get() {
584 return glib::Propagation::Proceed;
585 }
586 flag.set(true);
587 shutdown_sender.input(AppMsg::Shutdown);
588 glib::Propagation::Stop
589 });
590
591 ComponentParts { model, widgets }
592 }
593
594 fn update(&mut self, msg: Self::Input, sender: ComponentSender<Self>) {
595 match msg {
596 AppMsg::PaneToolbar { id, out } => match out {
597 ToolbarOutput::OpenFolder => {
598 self.set_active(id);
599 self.show_open_folder(id, &sender);
600 }
601 ToolbarOutput::NavigateTo(path) => {
602 self.open_root_in_pane(id, path);
603 }
604 ToolbarOutput::FilterRequested => {
605 self.set_active(id);
606 self.open_filter(id);
607 }
608 ToolbarOutput::SplitView => {
609 sender.input(AppMsg::SplitFromPane { id });
610 }
611 ToolbarOutput::Collapse => {
612 self.collapse_dock(id);
613 }
614 ToolbarOutput::ClosePane => {
615 sender.input(AppMsg::ClosePane { id });
616 }
617 ToolbarOutput::PaneItem(target) => {
618 self.set_active(id);
619 if let ShortcutTarget::Builtin(action) = &target
622 && action.is_pane_action()
623 {
624 self.dispatch_pane_builtin(id, *action, &sender);
625 } else if let Some((di, pi)) = self.dock_pane_of(id)
626 && let Some(msg) = pane_item_message(&target)
627 {
628 self.docks[di].panes[pi].tree.emit(msg);
629 }
630 }
631 },
632
633 AppMsg::PaneTree { id, out } => match out {
634 TreeOutput::Status(message) => {
635 self.set_active(id);
636 self.status = message;
637 }
638 TreeOutput::OpenFolderRequested => {
639 self.set_active(id);
640 self.show_open_folder(id, &sender);
641 }
642 TreeOutput::OpenSplit(root) => {
643 self.set_active(id);
644 sender.input(AppMsg::OpenSplitFrom { id, root });
645 }
646 TreeOutput::OpenOpposite(root) => {
647 self.set_active(id);
648 sender.input(AppMsg::OpenOppositeFrom { id, root });
649 }
650 TreeOutput::PaneAction(action) => {
651 self.dispatch_pane_builtin(id, action, &sender);
652 }
653 TreeOutput::AddBookmark(path) => {
654 self.set_active(id);
655 self.add_bookmark(path);
656 self.refresh_all_bookmarks(&sender);
657 }
658 TreeOutput::RootChanged(root) => {
659 self.set_active(id);
660 if let Some((di, pi)) = self.dock_pane_of(id) {
661 let pane = &mut self.docks[di].panes[pi];
662 pane.history.record(ViewEntry::Dir(root.clone()));
666 pane.history.finish_navigation();
667 pane.show_tree();
668 pane.set_root(root.clone());
669 pane.toolbar.emit(ToolbarMsg::SetRoot(root.clone()));
670 }
671 self.status = String::new();
672 self.last_root = Some(root);
674 self.persist_session();
675 }
676 },
677
678 AppMsg::PaneShortcut { id, action } => {
679 self.run_pane_shortcut(id, action, &sender);
680 }
681
682 AppMsg::OpenFolderPicked { id, path: Some(path) } => {
683 self.open_root_in_pane(id, path);
684 }
685 AppMsg::OpenFolderPicked { path: None, .. } => {}
686
687 AppMsg::LaunchRequest { command } => {
688 self.handle_launch(command, &sender);
689 }
690
691 AppMsg::SplitFromPane { id } => {
692 let di = self.dock_pane_of(id).map(|(di, _)| di).unwrap_or(0);
695 self.add_pane(di, None, &sender);
696 }
697
698 AppMsg::OpenSplitFrom { id, root } => {
699 if let Some((di, _pi)) = self.dock_pane_of(id) {
700 self.add_pane(di, Some(root), &sender);
701 }
702 }
703
704 AppMsg::OpenOppositeFrom { id, root } => {
705 if let Some((di, _pi)) = self.dock_pane_of(id) {
706 let side = self.docks[di].side.opposite();
707 let target = self.ensure_dock(side, false, &sender);
708 self.add_pane(target, Some(root), &sender);
709 self.set_docks_visible(self.visible);
712 }
713 }
714
715 AppMsg::ClosePane { id } => {
716 let Some((di, pi)) = self.dock_pane_of(id) else {
717 return;
718 };
719 self.docks[di].panes.remove(pi);
720 if self.docks[di].active_pane == Some(id) {
721 self.docks[di].active_pane = self.docks[di].panes.last().map(|p| p.id);
722 }
723 let panes_left: usize = self.docks.iter().map(|d| d.panes.len()).sum();
725 if panes_left == 0 {
726 self.window.close();
727 return;
728 }
729 if self.docks[di].panes.is_empty() {
730 if di == 0 {
731 self.visible = false;
735 self.docks[di].window.set_visible(false);
736 } else {
737 let dock = self.docks.remove(di);
738 dock.window.close();
739 }
740 } else {
741 fill_pane_container(&self.docks[di].container.clone(), &self.docks[di].panes);
742 }
743 }
744
745 AppMsg::FilterChanged { id, filter } => {
746 if let Some((di, pi)) = self.dock_pane_of(id) {
747 if self.docks[di].panes[pi].on_bookmarks() {
748 self.docks[di].panes[pi].bookmark_filter = filter;
749 self.refresh_all_bookmarks(&sender);
750 } else {
751 self.docks[di].panes[pi].tree.emit(TreeMsg::SetFilter(filter));
752 }
753 }
754 }
755 AppMsg::FilterClosed { id } => {
756 if let Some((di, pi)) = self.dock_pane_of(id) {
757 let pane = &mut self.docks[di].panes[pi];
758 pane.filter_entry.set_text("");
759 pane.tree.emit(TreeMsg::SetFilter(String::new()));
760 pane.bookmark_filter.clear();
761 pane.filter_bar.set_visible(false);
762 }
763 self.refresh_all_bookmarks(&sender);
764 }
765
766 AppMsg::FocusPane { id } => {
767 if let Some((di, pi)) = self.dock_pane_of(id) {
768 self.docks[di].panes[pi].tree.emit(TreeMsg::Focus);
769 }
770 }
771
772 AppMsg::Shutdown => {
773 for dock in &self.docks {
778 stop_video_widgets(dock.window.upcast_ref());
779 }
780 for dock in &self.docks {
782 for pane in &dock.panes {
783 pane.tree.emit(TreeMsg::Shutdown);
784 }
785 }
786 let window = self.window.clone();
789 glib::timeout_add_local_once(std::time::Duration::from_millis(SHUTDOWN_GRACE_MS), move || {
790 window.close();
791 });
792 }
793
794 AppMsg::ResizeBy { side, delta } => self.resize_by(side, delta),
795 AppMsg::ResizeCommit => self.persist_session(),
796
797 AppMsg::BookmarkEvent { id, event } => match event {
798 BookmarkEvent::Open(path) => self.open_bookmark(id, path, &sender),
799 BookmarkEvent::Edit(index) => self.edit_bookmark(id, index, &sender),
800 BookmarkEvent::Action { path, target } => {
801 self.run_bookmark_action(id, path, target);
802 }
803 BookmarkEvent::Delete(index) => {
804 if index < self.bookmarks.len() {
805 self.bookmarks.remove(index);
806 self.save_bookmarks();
807 self.refresh_all_bookmarks(&sender);
808 }
809 }
810 },
811 AppMsg::BookmarkEditSaved { index, name, path } => {
812 if let Some(bookmark) = self.bookmarks.get_mut(index) {
813 bookmark.name = name;
814 bookmark.path = path;
815 self.save_bookmarks();
816 self.refresh_all_bookmarks(&sender);
817 }
818 }
819 }
820 }
821}
822
823impl App {
824 fn handle_launch(&mut self, command: Command, sender: &ComponentSender<Self>) {
826 let hidden = command.hidden;
827
828 if let Some(width) = command.width {
831 let side = command.side.unwrap_or(self.primary_side);
832 match width {
833 WidthArg::To(px) => self.set_width(side, px),
834 WidthArg::By(delta) => self.resize_by(side, delta),
835 }
836 self.persist_session();
837 if command.roots.is_empty() && command.reveal.is_empty() {
838 return;
839 }
840 }
841
842 if command.roots.is_empty() && command.reveal.is_empty() {
843 let intent = match (command.side, hidden) {
846 (Some(side), true) => VisibilityIntent::HideSide(side),
847 (Some(side), false) => VisibilityIntent::ToggleSide(side),
848 (None, true) => VisibilityIntent::HideAll,
849 (None, false) => VisibilityIntent::ToggleAll,
850 };
851 self.apply_visibility(intent, sender);
852 return;
853 }
854
855 for root in &command.roots {
856 if self.find_pane_with_dir(root).is_some() {
857 continue;
860 }
861 let side = command.side.unwrap_or(self.primary_side);
862 let di = self.ensure_dock(side, false, sender);
863 self.add_pane(di, Some(root.clone()), sender);
864 }
865
866 for (root, select) in command.reveal_targets() {
871 if !root.is_dir() {
872 continue;
873 }
874 let side = command.side.unwrap_or(self.primary_side);
875 let di = self.ensure_dock(side, false, sender);
876 let id = self.add_pane(di, Some(root), sender);
877 if let Some(select) = select
878 && let Some((di, pi)) = self.dock_pane_of(id)
879 {
880 self.docks[di].panes[pi].tree.emit(TreeMsg::SelectPath(select));
881 }
882 }
883 self.set_docks_visible(!hidden);
885 }
886
887 fn apply_visibility(&mut self, intent: VisibilityIntent, sender: &ComponentSender<Self>) {
891 let existing: Vec<PanelSide> = self.docks.iter().map(|d| d.side).collect();
892 let shown: Vec<PanelSide> = self
893 .docks
894 .iter()
895 .filter(|d| d.window.is_visible())
896 .map(|d| d.side)
897 .collect();
898 let plan = resolve_visibility(intent, &existing, &shown);
899
900 if let Some(side) = plan.create {
901 self.ensure_dock(side, plan.seed, sender);
902 }
903 for side in &plan.show {
904 let di = self.ensure_dock(*side, plan.seed, sender);
907 self.docks[di].window.set_visible(true);
908 }
909 for side in &plan.hide {
910 if let Some(di) = self.dock_of_side(*side) {
911 self.docks[di].window.set_visible(false);
912 }
913 }
914 self.refresh_visible();
916 let focus = plan
919 .show
920 .iter()
921 .filter_map(|side| self.dock_of_side(*side))
922 .filter_map(|di| self.docks[di].panes.last())
923 .map(|pane| pane.tree.emit(TreeMsg::Focus))
924 .next();
925 let _ = focus;
926 }
927
928 fn set_active(&mut self, id: u64) {
930 if let Some((di, _pi)) = self.dock_pane_of(id) {
931 self.docks[di].active_pane = Some(id);
932 }
933 }
934
935 fn open_filter(&mut self, id: u64) {
939 if let Some((di, pi)) = self.dock_pane_of(id) {
940 let pane = &mut self.docks[di].panes[pi];
941 pane.filter_bar.set_visible(true);
942 let entry = pane.filter_entry.clone();
943 glib::idle_add_local_once(move || {
944 entry.grab_focus();
945 });
946 }
947 }
948
949 fn add_bookmark(&mut self, path: PathBuf) {
951 if self.bookmarks.iter().any(|b| b.path == path) {
952 self.status = format!("{} is already bookmarked", path.display());
953 return;
954 }
955 self.bookmarks.push(Bookmark {
956 name: Bookmark::default_name(&path),
957 path: path.clone(),
958 });
959 self.save_bookmarks();
960 self.status = format!("Bookmarked {}", path.display());
961 }
962
963 fn open_root_in_pane(&mut self, id: u64, path: PathBuf) {
966 if let Some((di, pi)) = self.dock_pane_of(id) {
967 self.docks[di].panes[pi].show_tree();
968 self.docks[di].panes[pi].tree.emit(TreeMsg::OpenRoot(path));
969 self.set_active(id);
970 }
971 }
972
973 fn open_bookmark(&mut self, id: u64, path: PathBuf, _sender: &ComponentSender<Self>) {
976 let path = crate::config::expand_bookmark_path(&path);
977 if !path.is_dir() {
978 self.status = format!("{} is not a directory", path.display());
979 return;
980 }
981 self.open_root_in_pane(id, path);
982 }
983
984 fn run_bookmark_action(&mut self, id: u64, path: PathBuf, target: ShortcutTarget) {
989 let path = crate::config::expand_bookmark_path(&path);
990 let Some((di, pi)) = self.dock_pane_of(id) else {
991 return;
992 };
993 match target {
994 ShortcutTarget::Builtin(BuiltinAction::Open) => {
995 if path.is_dir() {
996 self.open_root_in_pane(id, path);
997 } else {
998 self.docks[di].panes[pi].tree.emit(TreeMsg::OpenWithDefault(path));
999 }
1000 }
1001 ShortcutTarget::Builtin(action) => {
1002 if let Some(msg) = crate::ui::tree::path_action_message(action, &path) {
1003 self.docks[di].panes[pi].tree.emit(msg);
1004 }
1005 }
1006 ShortcutTarget::Command(cmd) => {
1007 self.docks[di].panes[pi]
1008 .tree
1009 .emit(TreeMsg::RunCommand { command: cmd.command, path });
1010 }
1011 }
1012 }
1013
1014 fn show_bookmarks_view(&mut self, id: u64) {
1016 if let Some((di, pi)) = self.dock_pane_of(id) {
1017 self.docks[di].panes[pi].show_bookmarks();
1018 self.docks[di].panes[pi].history.record(ViewEntry::Bookmarks);
1019 self.set_active(id);
1020 }
1021 }
1022
1023 fn edit_bookmark(&mut self, id: u64, index: usize, sender: &ComponentSender<Self>) {
1026 let Some(bookmark) = self.bookmarks.get(index).cloned() else {
1027 return;
1028 };
1029 let Some((di, _pi)) = self.dock_pane_of(id) else { return };
1030 let parent = self.docks[di].window.clone();
1031 let sender = sender.clone();
1032 let path = crate::config::expand_bookmark_path(&bookmark.path);
1033 bookmarks::show_bookmark_editor(&parent, &bookmark.name, &path, move |name, path| {
1034 sender.input(AppMsg::BookmarkEditSaved { index, name, path });
1035 });
1036 }
1037
1038 fn save_bookmarks(&mut self) {
1040 let path = bookmark_file_path(&self.config.bookmarks.file);
1041 if let Err(err) = save_bookmarks_to_path(&path, &self.bookmarks) {
1042 self.status = format!("Could not save bookmarks: {err:?}");
1043 }
1044 }
1045
1046 fn refresh_all_bookmarks(&mut self, sender: &ComponentSender<Self>) {
1050 let context = self.config.context_menu.clone();
1051 let extras = self.config.bookmarks.context.clone();
1052 let bookmarks = self.bookmarks.clone();
1053 for di in 0..self.docks.len() {
1054 for pi in 0..self.docks[di].panes.len() {
1055 let pane = &self.docks[di].panes[pi];
1056 let menu = bookmarks::BookmarkMenuConfig {
1057 context: &context,
1058 extras: &extras,
1059 side: self.docks[di].side,
1060 };
1061 let id = pane.id;
1062 let filter = pane.bookmark_filter.clone();
1063 let sender = sender.clone();
1064 let on_event: Rc<dyn Fn(BookmarkEvent)> =
1065 Rc::new(move |event| sender.input(AppMsg::BookmarkEvent { id, event }));
1066 bookmarks::fill_bookmarks(&pane.bookmarks_list, &bookmarks, &filter, &menu, on_event);
1067 }
1068 }
1069 }
1070
1071 fn dispatch_pane_builtin(
1075 &mut self,
1076 id: u64,
1077 action: BuiltinAction,
1078 sender: &ComponentSender<Self>,
1079 ) {
1080 self.set_active(id);
1081 match action {
1082 BuiltinAction::OpenFolder => self.show_open_folder(id, sender),
1083 BuiltinAction::Filter => self.open_filter(id),
1084 BuiltinAction::SplitView => sender.input(AppMsg::SplitFromPane { id }),
1085 BuiltinAction::Up => self.go_up(id),
1086 BuiltinAction::Back => self.go_back(id),
1087 BuiltinAction::Forward => self.go_forward(id),
1088 BuiltinAction::Collapse => self.collapse_dock(id),
1089 BuiltinAction::ClosePane => sender.input(AppMsg::ClosePane { id }),
1090 BuiltinAction::ToggleBookmarks => self.show_bookmarks_view(id),
1091 _ => {}
1092 }
1093 }
1094
1095 fn go_up(&mut self, id: u64) {
1098 let Some((di, pi)) = self.dock_pane_of(id) else {
1099 return;
1100 };
1101 let Some(parent) = self.docks[di].panes[pi]
1102 .root
1103 .as_deref()
1104 .and_then(Path::parent)
1105 .map(Path::to_path_buf)
1106 else {
1107 return;
1108 };
1109 self.docks[di].panes[pi].tree.emit(TreeMsg::OpenRoot(parent));
1110 }
1111
1112 fn go_back(&mut self, id: u64) {
1114 let Some((di, pi)) = self.dock_pane_of(id) else {
1115 return;
1116 };
1117 if let Some(target) = self.docks[di].panes[pi].history.back() {
1118 self.show_view_entry(id, target);
1119 }
1120 }
1121
1122 fn go_forward(&mut self, id: u64) {
1124 let Some((di, pi)) = self.dock_pane_of(id) else {
1125 return;
1126 };
1127 if let Some(target) = self.docks[di].panes[pi].history.forward() {
1128 self.show_view_entry(id, target);
1129 }
1130 }
1131
1132 fn show_view_entry(&mut self, id: u64, entry: ViewEntry) {
1136 let Some((di, pi)) = self.dock_pane_of(id) else {
1137 return;
1138 };
1139 match entry {
1140 ViewEntry::Dir(path) => {
1141 self.docks[di].panes[pi].show_tree();
1142 self.docks[di].panes[pi].tree.emit(TreeMsg::OpenRoot(path));
1143 }
1144 ViewEntry::Bookmarks => {
1145 self.docks[di].panes[pi].show_bookmarks();
1146 self.docks[di].panes[pi].history.finish_navigation();
1147 }
1148 }
1149 self.set_active(id);
1150 }
1151
1152 fn run_pane_shortcut(
1156 &mut self,
1157 id: u64,
1158 action: ContextAction,
1159 sender: &ComponentSender<Self>,
1160 ) {
1161 self.set_active(id);
1162 let Some(target) = ShortcutTarget::from_action(&action) else {
1163 return;
1164 };
1165 if let ShortcutTarget::Builtin(builtin) = &target
1166 && builtin.is_pane_action()
1167 {
1168 self.dispatch_pane_builtin(id, *builtin, sender);
1169 return;
1170 }
1171 if let Some((di, pi)) = self.dock_pane_of(id)
1172 && let Some(msg) = pane_item_message(&target)
1173 {
1174 self.docks[di].panes[pi].tree.emit(msg);
1175 }
1176 }
1177
1178 fn dock_pane_of(&self, id: u64) -> Option<(usize, usize)> {
1180 self.docks.iter().enumerate().find_map(|(di, dock)| {
1181 dock.panes
1182 .iter()
1183 .position(|pane| pane.id == id)
1184 .map(|pi| (di, pi))
1185 })
1186 }
1187
1188 fn find_pane_with_dir(&self, path: &PathBuf) -> Option<(usize, usize)> {
1193 let canonical = std::fs::canonicalize(path).ok().unwrap_or_else(|| path.clone());
1194 self.docks.iter().enumerate().find_map(|(di, dock)| {
1195 dock.panes.iter().enumerate().find_map(|(pi, pane)| {
1196 if pane.canonical_root.as_deref() == Some(canonical.as_path()) {
1197 Some((di, pi))
1198 } else {
1199 None
1200 }
1201 })
1202 })
1203 }
1204
1205 fn dock_of_side(&self, side: PanelSide) -> Option<usize> {
1207 self.docks.iter().position(|dock| dock.side == side)
1208 }
1209
1210 fn collapse_dock(&mut self, id: u64) {
1213 if let Some((di, _)) = self.dock_pane_of(id) {
1214 self.set_dock_visible(di, false);
1215 }
1216 }
1217
1218 fn set_dock_visible(&mut self, di: usize, visible: bool) {
1220 self.docks[di].window.set_visible(visible);
1221 self.refresh_visible();
1222 }
1223
1224 fn set_docks_visible(&mut self, visible: bool) {
1226 for dock in &self.docks {
1227 dock.window.set_visible(visible);
1228 if visible
1230 && let Some(pane) = dock.panes.last()
1231 {
1232 pane.tree.emit(TreeMsg::Focus);
1233 }
1234 }
1235 self.visible = visible;
1236 }
1237
1238 fn refresh_visible(&mut self) {
1241 self.visible = self.docks.iter().any(|dock| dock.window.is_visible());
1242 }
1243
1244 fn width_for(&self, side: PanelSide) -> u32 {
1246 self.widths.get(&side).copied().unwrap_or(self.config.panel.width)
1247 }
1248
1249 fn set_width(&mut self, side: PanelSide, width: u32) {
1252 let width = width.clamp(PANEL_MIN_WIDTH, PANEL_MAX_WIDTH);
1253 if width == self.width_for(side) {
1254 return;
1255 }
1256 self.widths.insert(side, width);
1257 let panel = PanelConfig { width, ..self.config.panel };
1258 for dock in self.docks.iter().filter(|dock| dock.side == side) {
1259 apply_window_width(&dock.window, width);
1260 for pane in &dock.panes {
1261 pane.tree.emit(TreeMsg::SetPanel(panel));
1262 }
1263 }
1264 if side == self.primary_side {
1266 self.config.panel.width = width;
1267 }
1268 self.schedule_save();
1269 }
1270
1271 fn resize_by(&mut self, side: PanelSide, delta: i32) {
1273 let target = (self.width_for(side) as i64 + delta as i64)
1274 .clamp(PANEL_MIN_WIDTH as i64, PANEL_MAX_WIDTH as i64) as u32;
1275 self.set_width(side, target);
1276 }
1277
1278 fn session_state(&self) -> SessionState {
1280 SessionState {
1281 last_root: self.last_root.clone(),
1282 left_width: Some(self.width_for(PanelSide::Left)),
1283 right_width: Some(self.width_for(PanelSide::Right)),
1284 }
1285 }
1286
1287 fn persist_session(&mut self) {
1289 self.save_generation.set(self.save_generation.get().wrapping_add(1));
1290 if let Err(err) = self.session_state().save() {
1291 self.status = format!("Could not save session: {err}");
1292 }
1293 }
1294
1295 fn schedule_save(&mut self) {
1299 let generation = self.save_generation.get().wrapping_add(1);
1300 self.save_generation.set(generation);
1301 let current = self.save_generation.clone();
1302 let state = self.session_state();
1303 glib::timeout_add_local_once(std::time::Duration::from_millis(SAVE_DEBOUNCE_MS), move || {
1304 if current.get() == generation {
1305 let _ = state.save();
1306 }
1307 });
1308 }
1309
1310 fn ensure_dock(&mut self, side: PanelSide, seed: bool, sender: &ComponentSender<Self>) -> usize {
1314 if let Some((di, _)) = self.docks.iter().enumerate().find(|(_, d)| d.side == side) {
1315 if seed && self.docks[di].panes.is_empty() {
1316 let startup = self.config.startup.clone();
1318 if let Some(root) = default_root(&startup) {
1319 self.add_pane(di, Some(root), sender);
1320 }
1321 }
1322 return di;
1323 }
1324 let config = &self.config;
1325 let width = self.width_for(side);
1326 let window = gtk::Window::new();
1327 init_layer_window(&window, config, side, width);
1328 window.set_default_size(width as i32, 520);
1329 if layer_shell_available() {
1330 attach_resize_controls(&window, side, sender);
1331 }
1332 let outer = gtk::Box::new(gtk::Orientation::Vertical, 0);
1334 outer.set_vexpand(true);
1335 let container = gtk::Box::new(gtk::Orientation::Vertical, 0);
1336 container.set_vexpand(true);
1337 outer.append(&container);
1338 window.set_child(Some(&outer));
1339
1340 let di = self.docks.len();
1341 self.docks.push(Dock {
1342 side,
1343 window,
1344 container,
1345 panes: Vec::new(),
1346 active_pane: None,
1347 });
1348 if seed {
1349 let startup = self.config.startup.clone();
1350 if let Some(root) = default_root(&startup) {
1351 self.add_pane(di, Some(root), sender);
1352 } else {
1353 self.add_pane(di, None, sender);
1355 }
1356 }
1357 di
1358 }
1359
1360 fn add_pane(&mut self, di: usize, root: Option<PathBuf>, sender: &ComponentSender<Self>) -> u64 {
1363 let id = self.next_id;
1364 self.next_id += 1;
1365 let parent = self.docks[di].window.clone();
1366 let side = self.docks[di].side;
1367 let width = self.width_for(side);
1368 let pane = make_pane(&self.config, parent, id, side, width, sender.clone());
1369 let mut pane = pane;
1370 match &root {
1371 Some(root) => pane.tree.emit(TreeMsg::OpenRoot(root.clone())),
1372 None => {
1373 pane.show_bookmarks();
1374 pane.history.record(ViewEntry::Bookmarks);
1375 }
1376 }
1377 if let Some(root) = &root {
1378 pane.set_root(root.clone());
1379 }
1380 let dock = &mut self.docks[di];
1381 dock.panes.push(pane);
1382 dock.active_pane = Some(id);
1383 let container = dock.container.clone();
1384 fill_pane_container(&container, &dock.panes);
1385 let focus_sender = sender.clone();
1388 glib::idle_add_local_once(move || focus_sender.input(AppMsg::FocusPane { id }));
1389 self.refresh_all_bookmarks(sender);
1391 id
1392 }
1393
1394 fn show_open_folder(&self, id: u64, sender: &ComponentSender<Self>) {
1395 let sender = sender.clone();
1396 self.file_dialog.select_folder(
1397 None::<>k::Window>,
1398 None::<&gio::Cancellable>,
1399 move |result| {
1400 let path = result.ok().and_then(|file| file.path());
1401 sender.input(AppMsg::OpenFolderPicked { id, path });
1402 },
1403 );
1404 }
1405}
1406
1407fn pane_item_message(target: &ShortcutTarget) -> Option<TreeMsg> {
1415 match target {
1416 ShortcutTarget::Builtin(action) => match action {
1417 BuiltinAction::ToggleHidden => Some(TreeMsg::ToggleHidden),
1418 BuiltinAction::SortByName => Some(TreeMsg::SetSortKey(SortKey::Name)),
1419 BuiltinAction::SortBySize => Some(TreeMsg::SetSortKey(SortKey::Size)),
1420 BuiltinAction::SortByModified => Some(TreeMsg::SetSortKey(SortKey::Modified)),
1421 BuiltinAction::SortByType => Some(TreeMsg::SetSortKey(SortKey::Type)),
1422 BuiltinAction::ToggleSortAscending => Some(TreeMsg::ToggleSortDirection),
1423 other => Some(TreeMsg::RunShortcut {
1424 target: ShortcutTarget::Builtin(*other),
1425 }),
1426 },
1427 ShortcutTarget::Command(_) => Some(TreeMsg::RunShortcut { target: target.clone() }),
1428 }
1429}
1430
1431fn make_pane(
1432 config: &Config,
1433 parent: gtk::Window,
1434 id: u64,
1435 side: PanelSide,
1436 width: u32,
1437 sender: ComponentSender<App>,
1438) -> Pane {
1439 let overlay = gtk::Overlay::new();
1442 let toolbar = Toolbar::builder()
1443 .launch(ToolbarInit {
1444 overlay: overlay.clone(),
1445 pane_menu: config.pane_menu.clone(),
1446 bookmarks_menu: config.bookmarks.menu.clone(),
1447 })
1448 .forward(sender.input_sender(), move |out| AppMsg::PaneToolbar { id, out });
1449 let tree = Tree::builder()
1450 .launch(TreeInit {
1451 config: config.tree.clone(),
1452 parent,
1453 menu: config.context_menu.clone(),
1454 side,
1455 panel: PanelConfig { width, ..config.panel },
1456 })
1457 .forward(sender.input_sender(), move |out| AppMsg::PaneTree { id, out });
1458
1459 let widget = gtk::Box::new(gtk::Orientation::Vertical, 0);
1460 widget.append(toolbar.widget());
1461
1462 let filter_bar = gtk::Box::new(gtk::Orientation::Horizontal, 4);
1464 filter_bar.add_css_class("filter-bar");
1465 filter_bar.set_visible(false);
1466
1467 let filter_entry = gtk::SearchEntry::new();
1468 filter_entry.set_placeholder_text(Some("Filter..."));
1469 filter_entry.set_hexpand(true);
1470 filter_entry.set_valign(gtk::Align::Center);
1471 filter_entry.add_css_class("filter-entry");
1472 {
1473 let s = sender.clone();
1474 filter_entry.connect_search_changed(move |entry| {
1475 s.input(AppMsg::FilterChanged {
1476 id,
1477 filter: entry.text().to_string(),
1478 });
1479 });
1480 }
1481 filter_bar.append(&filter_entry);
1482
1483 let close_btn = gtk::Button::from_icon_name("window-close-symbolic");
1484 close_btn.set_tooltip_text(Some("Clear filter"));
1485 close_btn.set_valign(gtk::Align::Center);
1486 close_btn.add_css_class("flat");
1487 {
1488 let s = sender.clone();
1489 close_btn.connect_clicked(move |_| {
1490 s.input(AppMsg::FilterClosed { id });
1491 });
1492 }
1493 filter_bar.append(&close_btn);
1494
1495 widget.append(&filter_bar);
1496
1497 let tree_widget = tree.widget();
1499 tree_widget.set_vexpand(true);
1500
1501 let bookmarks_list = gtk::Box::new(gtk::Orientation::Vertical, 0);
1502 bookmarks_list.add_css_class("bookmarks-list");
1503 let bookmarks_scroll = gtk::ScrolledWindow::new();
1504 bookmarks_scroll.set_policy(gtk::PolicyType::Never, gtk::PolicyType::Automatic);
1505 bookmarks_scroll.set_vexpand(true);
1506 bookmarks_scroll.add_css_class("bookmarks-view");
1507 bookmarks_scroll.set_child(Some(&bookmarks_list));
1508
1509 let body = gtk::Stack::new();
1510 body.set_vexpand(true);
1511 body.add_named(tree_widget, Some("tree"));
1512 body.add_named(&bookmarks_scroll, Some("bookmarks"));
1513 body.set_visible_child_name("tree");
1514 widget.append(&body);
1515 widget.set_vexpand(true);
1516
1517 overlay.set_child(Some(&widget));
1518
1519 {
1523 let mut items = config.pane_menu.items.clone();
1526 items.extend(config.bookmarks.menu.iter().cloned());
1527 let shortcuts = Rc::new(PaneShortcuts::compile(&items));
1528 let s = sender.clone();
1529 let keys = gtk::EventControllerKey::new();
1530 keys.set_propagation_phase(gtk::PropagationPhase::Capture);
1531 keys.connect_key_pressed(move |_, key, _, state| {
1532 if let Some(action) = shortcuts.action_for(key, state) {
1533 s.input(AppMsg::PaneShortcut { id, action });
1534 glib::Propagation::Stop
1535 } else {
1536 glib::Propagation::Proceed
1537 }
1538 });
1539 widget.add_controller(keys);
1540 }
1541
1542 Pane {
1543 id,
1544 toolbar,
1545 tree,
1546 root: None,
1547 canonical_root: None,
1548 history: NavHistory::default(),
1549 filter_bar,
1550 filter_entry,
1551 body,
1552 bookmarks_list,
1553 bookmark_filter: String::new(),
1554 widget: overlay,
1555 }
1556}
1557
1558fn remove_from_parent(w: >k::Widget) {
1566 let Some(parent) = w.parent() else { return };
1567 if let Ok(paned) = parent.clone().downcast::<gtk::Paned>() {
1568 let none: Option<>k::Widget> = None;
1569 if paned.start_child().is_some_and(|c| c == *w) {
1570 paned.set_start_child(none);
1571 }
1572 let none: Option<>k::Widget> = None;
1573 if paned.end_child().is_some_and(|c| c == *w) {
1574 paned.set_end_child(none);
1575 }
1576 } else if let Ok(b) = parent.downcast::<gtk::Box>() {
1577 b.remove(w);
1578 } else {
1579 w.unparent();
1580 }
1581}
1582
1583fn pane_widget(pane: &Pane) -> gtk::Widget {
1584 pane.widget.clone().upcast()
1585}
1586
1587fn fill_pane_container(container: >k::Box, panes: &[Pane]) {
1598 for pane in panes {
1603 remove_from_parent(pane.widget.upcast_ref());
1604 }
1605 while let Some(child) = container.first_child() {
1607 container.remove(&child);
1608 }
1609
1610 match panes {
1611 [] => {}
1612 [single] => {
1613 let w = pane_widget(single);
1614 w.set_vexpand(true);
1615 container.append(&w);
1616 }
1617 panes => {
1618 let n = panes.len();
1621 let last_w = pane_widget(&panes[n - 1]);
1622 last_w.set_vexpand(true);
1623 let mut right: gtk::Widget = last_w.clone().upcast();
1624 for pane in panes[..n - 1].iter().rev() {
1625 let left_w = pane_widget(pane);
1626 left_w.set_vexpand(true);
1627 let split = gtk::Paned::new(gtk::Orientation::Vertical);
1628 split.set_vexpand(true);
1629 split.set_wide_handle(true);
1630 split.set_shrink_start_child(true);
1632 split.set_shrink_end_child(true);
1633 split.set_resize_start_child(true);
1634 split.set_resize_end_child(true);
1635 split.set_start_child(Some(&left_w));
1636 split.set_end_child(Some(&right));
1637 {
1640 let split2 = split.clone();
1641 relm4::gtk::glib::timeout_add_local_once(
1642 std::time::Duration::from_millis(PANED_CENTER_DELAY_MS),
1643 move || {
1644 let alloc = split2.height();
1647 if alloc > 0 {
1648 split2.set_position(alloc / 2);
1649 }
1650 },
1651 );
1652 }
1653 right = split.upcast();
1654 }
1655 container.append(&right);
1656 }
1657 }
1658}
1659
1660fn stop_video_widgets(widget: >k::Widget) {
1666 if let Some(video) = widget.downcast_ref::<gtk::Video>() {
1667 video.set_media_stream(None::<>k::MediaStream>);
1668 }
1669 let mut child = widget.first_child();
1670 while let Some(current) = child {
1671 stop_video_widgets(¤t);
1672 child = current.next_sibling();
1673 }
1674}
1675
1676const WIDTH_STEP: i32 = 24;
1678
1679const SAVE_DEBOUNCE_MS: u64 = 300;
1681
1682const PANED_CENTER_DELAY_MS: u64 = 100;
1685
1686const SHUTDOWN_GRACE_MS: u64 = 150;
1689
1690fn apply_window_width(window: >k::Window, width: u32) {
1692 let (_, height) = window.default_size();
1693 if window.is_layer_window() {
1696 window.set_exclusive_zone(width as i32);
1697 }
1698 window.set_size_request(width as i32, -1);
1699 window.set_default_size(width as i32, if height > 0 { height } else { -1 });
1700}
1701
1702fn attach_resize_controls(window: >k::Window, side: PanelSide, sender: &ComponentSender<App>) {
1705 attach_resize_drag(window, side, sender.input_sender().clone());
1706 attach_resize_keys(window, side, sender.input_sender().clone());
1707}
1708
1709fn attach_resize_drag(window: >k::Window, side: PanelSide, sender: relm4::Sender<AppMsg>) {
1710 let drag = gtk::GestureDrag::new();
1711 drag.set_button(gdk::BUTTON_SECONDARY);
1712 drag.set_propagation_phase(gtk::PropagationPhase::Capture);
1713 let state = Rc::new(RefCell::new((false, 0.0f64)));
1715
1716 let begin_state = state.clone();
1717 drag.connect_drag_begin(move |gesture, _, _| {
1718 let super_held = gesture
1719 .current_event_state()
1720 .contains(gdk::ModifierType::SUPER_MASK);
1721 *begin_state.borrow_mut() = (super_held, 0.0);
1722 });
1723
1724 let update_sender = sender.clone();
1725 let update_state = state.clone();
1726 drag.connect_drag_update(move |_, offset_x, _| {
1727 let mut state = update_state.borrow_mut();
1728 if !state.0 {
1729 return;
1730 }
1731 let step = offset_x - state.1;
1732 state.1 = offset_x;
1733 let widen = match side {
1735 PanelSide::Left => step,
1736 PanelSide::Right => -step,
1737 };
1738 let delta = widen.round() as i32;
1739 if delta != 0 {
1740 let _ = update_sender.send(AppMsg::ResizeBy { side, delta });
1741 }
1742 });
1743
1744 let end_sender = sender;
1745 let end_state = state.clone();
1746 drag.connect_drag_end(move |_, _, _| {
1747 if end_state.borrow().0 {
1748 let _ = end_sender.send(AppMsg::ResizeCommit);
1749 }
1750 });
1751
1752 window.add_controller(drag);
1753}
1754
1755fn attach_resize_keys(window: >k::Window, side: PanelSide, sender: relm4::Sender<AppMsg>) {
1756 let keys = gtk::EventControllerKey::new();
1757 keys.set_propagation_phase(gtk::PropagationPhase::Capture);
1758 keys.connect_key_pressed(move |_, key, _, state| {
1759 if !state.contains(gdk::ModifierType::SUPER_MASK) {
1760 return glib::Propagation::Proceed;
1761 }
1762 match key {
1763 gdk::Key::minus | gdk::Key::underscore | gdk::Key::KP_Subtract => {
1764 let _ = sender.send(AppMsg::ResizeBy { side, delta: -WIDTH_STEP });
1765 glib::Propagation::Stop
1766 }
1767 gdk::Key::equal | gdk::Key::plus | gdk::Key::KP_Add => {
1768 let _ = sender.send(AppMsg::ResizeBy { side, delta: WIDTH_STEP });
1769 glib::Propagation::Stop
1770 }
1771 _ => glib::Propagation::Proceed,
1772 }
1773 });
1774 window.add_controller(keys);
1775}
1776
1777fn layer_shell_available() -> bool {
1785 static SUPPORTED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
1786 *SUPPORTED.get_or_init(gtk4_layer_shell::is_supported)
1787}
1788
1789fn init_layer_window(window: >k::Window, config: &Config, side: PanelSide, width: u32) {
1796 if !layer_shell_available() {
1797 configure_plain_window(window, width);
1798 return;
1799 }
1800 if window.is_layer_window() {
1801 return;
1802 }
1803 window.init_layer_shell();
1804 window.set_layer(layer_of(config.panel.layer));
1805 let edge = match side {
1806 PanelSide::Left => Edge::Left,
1807 PanelSide::Right => Edge::Right,
1808 };
1809 window.set_anchor(Edge::Top, true);
1810 window.set_anchor(Edge::Bottom, true);
1811 window.set_anchor(edge, true);
1812 window.set_exclusive_zone(width as i32);
1813 window.set_size_request(width as i32, -1);
1814 let margin = config.panel.margin as i32;
1815 window.set_margin(Edge::Top, margin);
1816 window.set_margin(Edge::Bottom, margin);
1817 window.set_margin(Edge::Left, margin);
1818 window.set_margin(Edge::Right, margin);
1819
1820 window.set_keyboard_mode(KeyboardMode::OnDemand);
1835 let motion = gtk::EventControllerMotion::new();
1836 {
1837 let kb_window = window.clone();
1838 motion.connect_enter(move |_, _, _| kb_window.set_keyboard_mode(KeyboardMode::OnDemand));
1839 }
1840 {
1841 let kb_window = window.clone();
1842 motion.connect_leave(move |_| {
1843 if has_visible_popover(kb_window.upcast_ref()) {
1848 return;
1849 }
1850 kb_window.set_keyboard_mode(KeyboardMode::None);
1851 });
1852 }
1853 window.add_controller(motion);
1854}
1855
1856fn configure_plain_window(window: >k::Window, width: u32) {
1862 static ANNOUNCED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
1863 ANNOUNCED.get_or_init(|| {
1864 eprintln!(
1865 "tree-space: this compositor does not support wlr-layer-shell, so the panel \
1866 cannot dock; opening it as a normal window instead."
1867 );
1868 });
1869 window.set_title(Some("tree-space"));
1870 window.set_default_size(width as i32, 720);
1871 window.set_size_request(width as i32, -1);
1872}
1873
1874fn has_visible_popover(widget: >k::Widget) -> bool {
1877 if widget.is::<gtk::Popover>() && widget.is_visible() {
1878 return true;
1879 }
1880 let mut child = widget.first_child();
1881 while let Some(current) = child {
1882 if has_visible_popover(¤t) {
1883 return true;
1884 }
1885 child = current.next_sibling();
1886 }
1887 false
1888}
1889
1890fn layer_of(layer: PanelLayer) -> Layer {
1891 use PanelLayer::*;
1892 match layer {
1893 Background => Layer::Background,
1894 Bottom => Layer::Bottom,
1895 Top => Layer::Top,
1896 Overlay => Layer::Overlay,
1897 }
1898}
1899
1900fn install_css(config: &Config) {
1904 let stylesheet = load_stylesheet();
1905 if let Some(problem) = &stylesheet.problem {
1906 eprintln!("tree-space: could not read stylesheet: {problem:?}");
1907 }
1908 let css = format!(
1909 "{}\n.tree-row label, .tree-rename-entry, .tree-menu, .hamburger-menu {{ font-size: {}px; }}",
1910 stylesheet.css, config.tree.font_size
1911 );
1912 let provider = gtk::CssProvider::new();
1913 provider.load_from_string(&css);
1914 if let Some(display) = gdk::Display::default() {
1915 gtk::style_context_add_provider_for_display(
1916 &display,
1917 &provider,
1918 gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
1919 );
1920 }
1921}
1922
1923fn home_dir() -> Option<PathBuf> {
1924 std::env::var_os("HOME").map(PathBuf::from)
1925}
1926
1927fn default_root(startup: &StartupRoot) -> Option<PathBuf> {
1930 if startup.is_bookmarks() {
1932 return None;
1933 }
1934 let last = SessionState::load().last_root.filter(|p| p.is_dir());
1935 startup.resolve(last).filter(|p| p.is_dir()).or_else(home_dir)
1936}
1937#[cfg(test)]
1938mod visibility_tests {
1939 use super::*;
1940
1941 const L: PanelSide = PanelSide::Left;
1942 const R: PanelSide = PanelSide::Right;
1943
1944 fn plan(intent: VisibilityIntent, existing: &[PanelSide], shown: &[PanelSide]) -> VisibilityPlan {
1945 resolve_visibility(intent, existing, shown)
1946 }
1947
1948 #[test]
1949 fn show_all_shows_both() {
1950 let p = plan(VisibilityIntent::ShowAll, &[L], &[]);
1951 assert_eq!(p.show, vec![L]);
1953 assert!(p.seed);
1954 let both = plan(VisibilityIntent::ShowAll, &[L, R], &[]);
1955 assert_eq!(both.show, vec![L, R]);
1956 }
1957
1958 #[test]
1959 fn hide_all_hides_both() {
1960 let p = plan(VisibilityIntent::HideAll, &[L, R], &[L, R]);
1961 assert_eq!(p.hide, vec![L, R]);
1962 assert!(p.show.is_empty());
1963 }
1964
1965 #[test]
1966 fn toggle_all_shows_when_nothing_visible_and_hides_otherwise() {
1967 let shown = plan(VisibilityIntent::ToggleAll, &[L], &[]);
1968 assert_eq!(shown.show, vec![L]);
1970 let hidden = plan(VisibilityIntent::ToggleAll, &[L, R], &[L]);
1971 assert_eq!(hidden.hide, vec![L, R]);
1972 }
1973
1974 #[test]
1975 fn show_side_creates_a_missing_dock() {
1976 let p = plan(VisibilityIntent::ShowSide(R), &[L], &[L]);
1977 assert_eq!(p.create, Some(R));
1978 assert_eq!(p.show, vec![R]);
1979 }
1980
1981 #[test]
1982 fn hide_side_never_creates() {
1983 let p = plan(VisibilityIntent::HideSide(R), &[L], &[L]);
1984 assert_eq!(p.create, None);
1985 assert_eq!(p.hide, vec![R]);
1986 }
1987
1988 #[test]
1989 fn toggle_side_is_granular() {
1990 let create = plan(VisibilityIntent::ToggleSide(R), &[L], &[L]);
1992 assert_eq!(create.create, Some(R));
1993 assert_eq!(create.show, vec![R]);
1994 let hide = plan(VisibilityIntent::ToggleSide(L), &[L, R], &[L, R]);
1996 assert_eq!(hide.hide, vec![L]);
1997 let show = plan(VisibilityIntent::ToggleSide(L), &[L, R], &[R]);
1999 assert_eq!(show.show, vec![L]);
2000 }
2001
2002 #[test]
2003 fn toggling_one_side_leaves_the_other_alone() {
2004 let p = plan(VisibilityIntent::ToggleSide(R), &[L, R], &[L]);
2006 assert_eq!(p.show, vec![R]);
2007 assert!(!p.hide.contains(&L));
2008 }
2009}
2010
2011#[cfg(test)]
2012mod nav_history_tests {
2013 use super::{NavHistory, ViewEntry};
2014 use std::path::PathBuf;
2015
2016 fn dir(s: &str) -> ViewEntry {
2017 ViewEntry::Dir(PathBuf::from(s))
2018 }
2019
2020 #[test]
2021 fn records_visits_in_order_and_navigates_both_ways() {
2022 let mut h = NavHistory::default();
2023 h.record(dir("/a"));
2024 h.record(dir("/b"));
2025 h.record(dir("/c"));
2026 assert!(h.can_back());
2027 assert!(!h.can_forward());
2028
2029 assert_eq!(h.back(), Some(dir("/b")));
2030 h.finish_navigation();
2031 assert_eq!(h.back(), Some(dir("/a")));
2032 h.finish_navigation();
2033 assert!(!h.can_back());
2034 assert!(h.can_forward());
2035
2036 assert_eq!(h.forward(), Some(dir("/b")));
2037 h.finish_navigation();
2038 assert_eq!(h.forward(), Some(dir("/c")));
2039 h.finish_navigation();
2040 assert!(h.can_back());
2041 assert!(!h.can_forward());
2042 }
2043
2044 #[test]
2045 fn a_new_visit_after_going_back_truncates_the_forward_tail() {
2046 let mut h = NavHistory::default();
2047 h.record(dir("/a"));
2048 h.record(dir("/b"));
2049 h.record(dir("/c"));
2050 assert_eq!(h.back(), Some(dir("/b")));
2051 h.finish_navigation();
2052 h.record(dir("/d"));
2054 assert!(!h.can_forward());
2055 assert_eq!(h.back(), Some(dir("/b")));
2056 }
2057
2058 #[test]
2059 fn navigating_does_not_record_and_repeats_are_ignored() {
2060 let mut h = NavHistory::default();
2061 h.record(dir("/a"));
2062 h.record(dir("/b"));
2063 let target = h.back().unwrap();
2065 assert_eq!(target, dir("/a"));
2066 h.record(target.clone());
2067 h.finish_navigation();
2068 assert!(h.can_forward());
2070 assert_eq!(h.forward(), Some(dir("/b")));
2071 h.finish_navigation();
2072
2073 h.record(dir("/b"));
2075 assert_eq!(h.cursor, 1);
2076 assert_eq!(h.entries.len(), 2);
2077 }
2078
2079 #[test]
2080 fn bookmarks_view_is_a_history_entry() {
2081 let mut h = NavHistory::default();
2082 h.record(ViewEntry::Bookmarks);
2083 h.record(dir("/a"));
2084 assert_eq!(h.back(), Some(ViewEntry::Bookmarks));
2086 h.finish_navigation();
2087 h.record(ViewEntry::Bookmarks);
2089 assert_eq!(h.entries.len(), 2);
2090 assert_eq!(h.forward(), Some(dir("/a")));
2092 h.finish_navigation();
2093 }
2094
2095 #[test]
2096 fn cannot_navigate_an_empty_history() {
2097 let mut h = NavHistory::default();
2098 assert!(!h.can_back());
2099 assert!(!h.can_forward());
2100 assert_eq!(h.back(), None);
2101 assert_eq!(h.forward(), None);
2102 }
2103}