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 BuiltinAction, Config, ContextAction, PANEL_MAX_WIDTH, PANEL_MIN_WIDTH, PanelConfig,
49 PanelLayer, PanelSide, SessionState, ShortcutTarget, StartupRoot, load_stylesheet,
50};
51use crate::fs::SortKey;
52use crate::ipc;
53use crate::ui::toolbar::{PaneShortcuts, Toolbar, ToolbarInit, ToolbarMsg, ToolbarOutput};
54use crate::ui::tree::{Tree, TreeInit, TreeOutput, TreeMsg};
55
56#[derive(Debug, Default, Clone, PartialEq, Eq)]
63struct NavHistory {
64 entries: Vec<PathBuf>,
65 cursor: usize,
67 navigating: bool,
70}
71
72impl NavHistory {
73 fn record(&mut self, path: &Path) {
77 if self.navigating {
78 return;
79 }
80 if self.entries.get(self.cursor).is_some_and(|cur| cur == path) {
81 return;
82 }
83 if self.entries.is_empty() {
84 self.entries.push(path.to_path_buf());
85 self.cursor = 0;
86 return;
87 }
88 self.entries.truncate(self.cursor + 1);
89 self.entries.push(path.to_path_buf());
90 self.cursor = self.entries.len() - 1;
91 }
92
93 fn can_back(&self) -> bool {
94 self.cursor > 0 && self.cursor < self.entries.len()
95 }
96
97 fn can_forward(&self) -> bool {
98 !self.entries.is_empty() && self.cursor + 1 < self.entries.len()
99 }
100
101 fn back(&mut self) -> Option<PathBuf> {
103 if !self.can_back() {
104 return None;
105 }
106 self.cursor -= 1;
107 self.navigating = true;
108 Some(self.entries[self.cursor].clone())
109 }
110
111 fn forward(&mut self) -> Option<PathBuf> {
113 if !self.can_forward() {
114 return None;
115 }
116 self.cursor += 1;
117 self.navigating = true;
118 Some(self.entries[self.cursor].clone())
119 }
120
121 fn finish_navigation(&mut self) {
123 self.navigating = false;
124 }
125}
126
127pub struct Pane {
129 id: u64,
130 toolbar: Controller<Toolbar>,
131 tree: Controller<Tree>,
132 root: Option<PathBuf>,
135 canonical_root: Option<PathBuf>,
138 history: NavHistory,
140 filter_bar: gtk::Box,
143 filter_entry: gtk::SearchEntry,
144 widget: gtk::Overlay,
150}
151
152impl Pane {
153 fn set_root(&mut self, root: PathBuf) {
156 self.canonical_root = std::fs::canonicalize(&root).ok();
157 self.root = Some(root);
158 }
159}
160
161struct Dock {
163 side: PanelSide,
164 window: gtk::Window,
167 container: gtk::Box,
170 panes: Vec<Pane>,
171 active_pane: Option<u64>,
173}
174
175#[derive(Debug, Clone, Copy, PartialEq, Eq)]
179pub enum VisibilityIntent {
180 ShowAll,
182 HideAll,
184 ToggleAll,
186 ShowSide(PanelSide),
188 HideSide(PanelSide),
190 ToggleSide(PanelSide),
192}
193
194#[derive(Debug, Clone, PartialEq, Eq)]
200pub struct VisibilityPlan {
201 pub show: Vec<PanelSide>,
203 pub hide: Vec<PanelSide>,
205 pub create: Option<PanelSide>,
207 pub seed: bool,
210}
211
212pub fn resolve_visibility(
216 intent: VisibilityIntent,
217 existing: &[PanelSide],
218 shown: &[PanelSide],
219) -> VisibilityPlan {
220 match intent {
221 VisibilityIntent::ShowAll => VisibilityPlan {
222 show: existing.to_vec(),
223 hide: Vec::new(),
224 create: None,
225 seed: true,
226 },
227 VisibilityIntent::HideAll => VisibilityPlan {
228 show: Vec::new(),
229 hide: PanelSide::ALL.to_vec(),
230 create: None,
231 seed: false,
232 },
233 VisibilityIntent::ToggleAll => {
234 if shown.is_empty() {
235 VisibilityPlan {
236 show: existing.to_vec(),
237 hide: Vec::new(),
238 create: None,
239 seed: true,
240 }
241 } else {
242 VisibilityPlan {
243 show: Vec::new(),
244 hide: PanelSide::ALL.to_vec(),
245 create: None,
246 seed: false,
247 }
248 }
249 }
250 VisibilityIntent::ShowSide(side) => VisibilityPlan {
251 show: vec![side],
252 hide: Vec::new(),
253 create: (!existing.contains(&side)).then_some(side),
254 seed: true,
255 },
256 VisibilityIntent::HideSide(side) => VisibilityPlan {
257 show: Vec::new(),
258 hide: vec![side],
259 create: None,
260 seed: false,
261 },
262 VisibilityIntent::ToggleSide(side) => {
263 if !existing.contains(&side) {
264 VisibilityPlan {
265 show: vec![side],
266 hide: Vec::new(),
267 create: Some(side),
268 seed: true,
269 }
270 } else if shown.contains(&side) {
271 VisibilityPlan {
272 show: Vec::new(),
273 hide: vec![side],
274 create: None,
275 seed: false,
276 }
277 } else {
278 VisibilityPlan {
279 show: vec![side],
280 hide: Vec::new(),
281 create: None,
282 seed: true,
283 }
284 }
285 }
286 }
287}
288
289#[derive(Debug)]
291pub enum AppMsg {
292 PaneToolbar { id: u64, out: ToolbarOutput },
294 PaneTree { id: u64, out: TreeOutput },
296 PaneShortcut { id: u64, action: ContextAction },
299 OpenFolderPicked { id: u64, path: Option<PathBuf> },
301 LaunchRequest { command: Command },
303 SplitFromPane { id: u64 },
305 OpenSplitFrom { id: u64, root: PathBuf },
308 OpenOppositeFrom { id: u64, root: PathBuf },
311 ClosePane { id: u64 },
314 FilterChanged { id: u64, filter: String },
316 FilterClosed { id: u64 },
318 Shutdown,
320 ResizeBy { side: PanelSide, delta: i32 },
322 ResizeCommit,
324 FocusPane { id: u64 },
326}
327
328pub struct AppInit {
331 pub command: Command,
332 pub listener: Option<std::os::unix::net::UnixListener>,
333}
334
335pub struct App {
337 config: Config,
338 status: String,
339 window: gtk::Window,
340 file_dialog: gtk::FileDialog,
341 docks: Vec<Dock>,
343 pane_container: gtk::Box,
345 primary_side: PanelSide,
347 next_id: u64,
349 visible: bool,
351 widths: HashMap<PanelSide, u32>,
355 last_root: Option<PathBuf>,
357 save_generation: Rc<Cell<u64>>,
361}
362
363#[relm4::component(pub)]
364impl SimpleComponent for App {
365 type Init = AppInit;
366 type Input = AppMsg;
367 type Output = ();
368
369 view! {
370 gtk::Window {
371 set_default_size: (model.config.panel.width as i32, 520),
372
373 gtk::Box {
374 set_orientation: gtk::Orientation::Vertical,
375 set_spacing: 0,
376 add_css_class: "panel",
377
378 append: &model.pane_container,
379
380 append: status = >k::Label {
381 #[watch]
382 set_label: &model.status,
383 set_halign: gtk::Align::Start,
384 set_ellipsize: gtk::pango::EllipsizeMode::End,
385 add_css_class: "status-bar",
386 }
387 }
388 }
389 }
390
391 fn init(
392 init: Self::Init,
393 root: Self::Root,
394 sender: ComponentSender<Self>,
395 ) -> ComponentParts<Self> {
396 let loaded = Config::load();
397 let mut config = loaded.config;
398 let parent = root.clone();
399
400 let session = SessionState::load();
401 let default_width = config.panel.width;
404 let widths: HashMap<PanelSide, u32> = [
405 (
406 PanelSide::Left,
407 session.left_width.unwrap_or(default_width).clamp(PANEL_MIN_WIDTH, PANEL_MAX_WIDTH),
408 ),
409 (
410 PanelSide::Right,
411 session.right_width.unwrap_or(default_width).clamp(PANEL_MIN_WIDTH, PANEL_MAX_WIDTH),
412 ),
413 ]
414 .into_iter()
415 .collect();
416
417 let primary_side = init.command.side.unwrap_or(config.panel.side);
418 config.panel.width = widths[&primary_side];
420 let primary_width = config.panel.width;
421 let roots = if init.command.roots.is_empty() {
425 let last = session.last_root.clone().filter(|p| p.is_dir());
426 let fallback = config
427 .startup
428 .resolve(last)
429 .filter(|p| p.is_dir())
430 .or_else(home_dir);
431 if let Some(p) = fallback { vec![p] } else { Vec::new() }
432 } else {
433 init.command.roots.clone()
434 };
435
436 let mut panes: Vec<Pane> = Vec::new();
437 let mut next_id = 0u64;
438 for root in roots.iter() {
439 let pane =
440 make_pane(&config, parent.clone(), next_id, primary_side, primary_width, sender.clone());
441 pane.tree.emit(TreeMsg::OpenRoot(root.clone()));
442 panes.push(pane);
443 panes.last_mut().unwrap().set_root(root.clone());
444 next_id += 1;
445 }
446 if panes.is_empty() {
448 panes.push(make_pane(
449 &config,
450 parent.clone(),
451 next_id,
452 primary_side,
453 primary_width,
454 sender.clone(),
455 ));
456 next_id += 1;
457 }
458
459 let pane_container = gtk::Box::new(gtk::Orientation::Vertical, 0);
460 pane_container.set_vexpand(true);
461 fill_pane_container(&pane_container, &panes);
462
463 let mut status = String::new();
464 if let Some(problem) = loaded.problem {
465 status = format!("config: {problem:?}");
466 }
467
468 let model = App {
469 config,
470 status,
471 window: root.clone(),
472 file_dialog: gtk::FileDialog::new(),
473 docks: vec![Dock {
474 side: primary_side,
475 window: root.clone(),
476 container: pane_container.clone(),
477 panes,
478 active_pane: None,
479 }],
480 pane_container,
481 primary_side,
482 next_id,
483 visible: !init.command.hidden,
484 widths,
485 last_root: session.last_root.clone(),
486 save_generation: Rc::new(Cell::new(0)),
487 };
488
489 init_layer_window(&model.window, &model.config, primary_side, primary_width);
490 install_css(&model.config);
491 if layer_shell_available() {
494 attach_resize_controls(&model.window, primary_side, &sender);
495 }
496
497 if let Some(listener) = init.listener {
500 let ipc_sender = sender.input_sender().clone();
501 ipc::spawn_listener(listener, move |command| {
502 let sender = ipc_sender.clone();
503 glib::MainContext::default().invoke(move || {
504 let _ = sender.send(AppMsg::LaunchRequest { command });
505 });
506 });
507 }
508
509 let widgets = view_output!();
510
511 let shutting_down = Rc::new(Cell::new(false));
518 let flag = shutting_down.clone();
519 let shutdown_sender = sender.clone();
520 model.window.connect_close_request(move |_| {
521 if flag.get() {
522 return glib::Propagation::Proceed;
523 }
524 flag.set(true);
525 shutdown_sender.input(AppMsg::Shutdown);
526 glib::Propagation::Stop
527 });
528
529 ComponentParts { model, widgets }
530 }
531
532 fn update(&mut self, msg: Self::Input, sender: ComponentSender<Self>) {
533 match msg {
534 AppMsg::PaneToolbar { id, out } => match out {
535 ToolbarOutput::OpenFolder => {
536 self.set_active(id);
537 self.show_open_folder(id, &sender);
538 }
539 ToolbarOutput::NavigateTo(path) => {
540 self.set_active(id);
541 if let Some((di, pi)) = self.dock_pane_of(id) {
542 self.docks[di].panes[pi].tree.emit(TreeMsg::OpenRoot(path));
543 }
544 }
545 ToolbarOutput::FilterRequested => {
546 self.set_active(id);
547 self.open_filter(id);
548 }
549 ToolbarOutput::SplitView => {
550 sender.input(AppMsg::SplitFromPane { id });
551 }
552 ToolbarOutput::Collapse => {
553 self.collapse_dock(id);
554 }
555 ToolbarOutput::ClosePane => {
556 sender.input(AppMsg::ClosePane { id });
557 }
558 ToolbarOutput::PaneItem(target) => {
559 self.set_active(id);
560 if let ShortcutTarget::Builtin(action) = &target
563 && action.is_pane_action()
564 {
565 self.dispatch_pane_builtin(id, *action, &sender);
566 } else if let Some((di, pi)) = self.dock_pane_of(id)
567 && let Some(msg) = pane_item_message(&target)
568 {
569 self.docks[di].panes[pi].tree.emit(msg);
570 }
571 }
572 },
573
574 AppMsg::PaneTree { id, out } => match out {
575 TreeOutput::Status(message) => {
576 self.set_active(id);
577 self.status = message;
578 }
579 TreeOutput::OpenFolderRequested => {
580 self.set_active(id);
581 self.show_open_folder(id, &sender);
582 }
583 TreeOutput::OpenSplit(root) => {
584 self.set_active(id);
585 sender.input(AppMsg::OpenSplitFrom { id, root });
586 }
587 TreeOutput::OpenOpposite(root) => {
588 self.set_active(id);
589 sender.input(AppMsg::OpenOppositeFrom { id, root });
590 }
591 TreeOutput::PaneAction(action) => {
592 self.dispatch_pane_builtin(id, action, &sender);
593 }
594 TreeOutput::RootChanged(root) => {
595 self.set_active(id);
596 if let Some((di, pi)) = self.dock_pane_of(id) {
597 let pane = &mut self.docks[di].panes[pi];
598 pane.history.record(&root);
602 pane.history.finish_navigation();
603 pane.set_root(root.clone());
604 pane.toolbar.emit(ToolbarMsg::SetRoot(root.clone()));
605 }
606 self.status = String::new();
607 self.last_root = Some(root);
609 self.persist_session();
610 }
611 },
612
613 AppMsg::PaneShortcut { id, action } => {
614 self.run_pane_shortcut(id, action, &sender);
615 }
616
617 AppMsg::OpenFolderPicked { id, path: Some(path) } => {
618 if let Some((di, pi)) = self.dock_pane_of(id) {
619 self.docks[di].panes[pi].tree.emit(TreeMsg::OpenRoot(path));
620 }
621 }
622 AppMsg::OpenFolderPicked { path: None, .. } => {}
623
624 AppMsg::LaunchRequest { command } => {
625 self.handle_launch(command, &sender);
626 }
627
628 AppMsg::SplitFromPane { id } => {
629 let (di, pi) = self.dock_pane_of(id).unwrap_or((0, 0));
630 let seed = self.docks[di].panes.get(pi).and_then(|p| p.root.clone()).or_else(home_dir);
631 self.add_pane(di, seed, &sender);
632 }
633
634 AppMsg::OpenSplitFrom { id, root } => {
635 if let Some((di, _pi)) = self.dock_pane_of(id) {
636 self.add_pane(di, Some(root), &sender);
637 }
638 }
639
640 AppMsg::OpenOppositeFrom { id, root } => {
641 if let Some((di, _pi)) = self.dock_pane_of(id) {
642 let side = self.docks[di].side.opposite();
643 let target = self.ensure_dock(side, false, &sender);
644 self.add_pane(target, Some(root), &sender);
645 self.set_docks_visible(self.visible);
648 }
649 }
650
651 AppMsg::ClosePane { id } => {
652 let Some((di, pi)) = self.dock_pane_of(id) else {
653 return;
654 };
655 self.docks[di].panes.remove(pi);
656 if self.docks[di].active_pane == Some(id) {
657 self.docks[di].active_pane = self.docks[di].panes.last().map(|p| p.id);
658 }
659 let panes_left: usize = self.docks.iter().map(|d| d.panes.len()).sum();
661 if panes_left == 0 {
662 self.window.close();
663 return;
664 }
665 if self.docks[di].panes.is_empty() {
666 if di == 0 {
668 self.visible = false;
672 self.docks[di].window.set_visible(false);
673 } else {
674 let dock = self.docks.remove(di);
675 dock.window.close();
676 }
677 } else {
678 fill_pane_container(&self.docks[di].container.clone(), &self.docks[di].panes);
679 }
680 }
681
682 AppMsg::FilterChanged { id, filter } => {
683 if let Some((di, pi)) = self.dock_pane_of(id) {
684 self.docks[di].panes[pi].tree.emit(TreeMsg::SetFilter(filter));
685 }
686 }
687 AppMsg::FilterClosed { id } => {
688 if let Some((di, pi)) = self.dock_pane_of(id) {
689 let pane = &mut self.docks[di].panes[pi];
690 pane.filter_entry.set_text("");
691 pane.tree.emit(TreeMsg::SetFilter(String::new()));
692 pane.filter_bar.set_visible(false);
693 }
694 }
695
696 AppMsg::FocusPane { id } => {
697 if let Some((di, pi)) = self.dock_pane_of(id) {
698 self.docks[di].panes[pi].tree.emit(TreeMsg::Focus);
699 }
700 }
701
702 AppMsg::Shutdown => {
703 for dock in &self.docks {
708 stop_video_widgets(dock.window.upcast_ref());
709 }
710 for dock in &self.docks {
712 for pane in &dock.panes {
713 pane.tree.emit(TreeMsg::Shutdown);
714 }
715 }
716 let window = self.window.clone();
719 glib::timeout_add_local_once(std::time::Duration::from_millis(SHUTDOWN_GRACE_MS), move || {
720 window.close();
721 });
722 }
723
724 AppMsg::ResizeBy { side, delta } => self.resize_by(side, delta),
725 AppMsg::ResizeCommit => self.persist_session(),
726 }
727 }
728}
729
730impl App {
731 fn handle_launch(&mut self, command: Command, sender: &ComponentSender<Self>) {
733 let hidden = command.hidden;
734
735 if let Some(width) = command.width {
738 let side = command.side.unwrap_or(self.primary_side);
739 match width {
740 WidthArg::To(px) => self.set_width(side, px),
741 WidthArg::By(delta) => self.resize_by(side, delta),
742 }
743 self.persist_session();
744 if command.roots.is_empty() && command.reveal.is_empty() {
745 return;
746 }
747 }
748
749 if command.roots.is_empty() && command.reveal.is_empty() {
750 let intent = match (command.side, hidden) {
753 (Some(side), true) => VisibilityIntent::HideSide(side),
754 (Some(side), false) => VisibilityIntent::ToggleSide(side),
755 (None, true) => VisibilityIntent::HideAll,
756 (None, false) => VisibilityIntent::ToggleAll,
757 };
758 self.apply_visibility(intent, sender);
759 return;
760 }
761
762 for root in &command.roots {
763 if self.find_pane_with_dir(root).is_some() {
764 continue;
767 }
768 let side = command.side.unwrap_or(self.primary_side);
769 let di = self.ensure_dock(side, false, sender);
770 self.add_pane(di, Some(root.clone()), sender);
771 }
772
773 for (root, select) in command.reveal_targets() {
778 if !root.is_dir() {
779 continue;
780 }
781 let side = command.side.unwrap_or(self.primary_side);
782 let di = self.ensure_dock(side, false, sender);
783 let id = self.add_pane(di, Some(root), sender);
784 if let Some(select) = select
785 && let Some((di, pi)) = self.dock_pane_of(id)
786 {
787 self.docks[di].panes[pi].tree.emit(TreeMsg::SelectPath(select));
788 }
789 }
790 self.set_docks_visible(!hidden);
792 }
793
794 fn apply_visibility(&mut self, intent: VisibilityIntent, sender: &ComponentSender<Self>) {
798 let existing: Vec<PanelSide> = self.docks.iter().map(|d| d.side).collect();
799 let shown: Vec<PanelSide> = self
800 .docks
801 .iter()
802 .filter(|d| d.window.is_visible())
803 .map(|d| d.side)
804 .collect();
805 let plan = resolve_visibility(intent, &existing, &shown);
806
807 if let Some(side) = plan.create {
808 self.ensure_dock(side, plan.seed, sender);
809 }
810 for side in &plan.show {
811 let di = self.ensure_dock(*side, plan.seed, sender);
814 self.docks[di].window.set_visible(true);
815 }
816 for side in &plan.hide {
817 if let Some(di) = self.dock_of_side(*side) {
818 self.docks[di].window.set_visible(false);
819 }
820 }
821 self.refresh_visible();
823 let focus = plan
826 .show
827 .iter()
828 .filter_map(|side| self.dock_of_side(*side))
829 .filter_map(|di| self.docks[di].panes.last())
830 .map(|pane| pane.tree.emit(TreeMsg::Focus))
831 .next();
832 let _ = focus;
833 }
834
835 fn set_active(&mut self, id: u64) {
837 if let Some((di, _pi)) = self.dock_pane_of(id) {
838 self.docks[di].active_pane = Some(id);
839 }
840 }
841
842 fn open_filter(&mut self, id: u64) {
846 if let Some((di, pi)) = self.dock_pane_of(id) {
847 let pane = &mut self.docks[di].panes[pi];
848 pane.filter_bar.set_visible(true);
849 let entry = pane.filter_entry.clone();
850 glib::idle_add_local_once(move || {
851 entry.grab_focus();
852 });
853 }
854 }
855
856 fn dispatch_pane_builtin(
860 &mut self,
861 id: u64,
862 action: BuiltinAction,
863 sender: &ComponentSender<Self>,
864 ) {
865 self.set_active(id);
866 match action {
867 BuiltinAction::OpenFolder => self.show_open_folder(id, sender),
868 BuiltinAction::Filter => self.open_filter(id),
869 BuiltinAction::SplitView => sender.input(AppMsg::SplitFromPane { id }),
870 BuiltinAction::Up => self.go_up(id),
871 BuiltinAction::Back => self.go_back(id),
872 BuiltinAction::Forward => self.go_forward(id),
873 BuiltinAction::Collapse => self.collapse_dock(id),
874 BuiltinAction::ClosePane => sender.input(AppMsg::ClosePane { id }),
875 _ => {}
876 }
877 }
878
879 fn go_up(&mut self, id: u64) {
882 let Some((di, pi)) = self.dock_pane_of(id) else {
883 return;
884 };
885 let Some(parent) = self.docks[di].panes[pi]
886 .root
887 .as_deref()
888 .and_then(Path::parent)
889 .map(Path::to_path_buf)
890 else {
891 return;
892 };
893 self.docks[di].panes[pi].tree.emit(TreeMsg::OpenRoot(parent));
894 }
895
896 fn go_back(&mut self, id: u64) {
898 let Some((di, pi)) = self.dock_pane_of(id) else {
899 return;
900 };
901 if let Some(target) = self.docks[di].panes[pi].history.back() {
902 self.docks[di].panes[pi].tree.emit(TreeMsg::OpenRoot(target));
903 }
904 }
905
906 fn go_forward(&mut self, id: u64) {
908 let Some((di, pi)) = self.dock_pane_of(id) else {
909 return;
910 };
911 if let Some(target) = self.docks[di].panes[pi].history.forward() {
912 self.docks[di].panes[pi].tree.emit(TreeMsg::OpenRoot(target));
913 }
914 }
915
916 fn run_pane_shortcut(
920 &mut self,
921 id: u64,
922 action: ContextAction,
923 sender: &ComponentSender<Self>,
924 ) {
925 self.set_active(id);
926 let Some(target) = ShortcutTarget::from_action(&action) else {
927 return;
928 };
929 if let ShortcutTarget::Builtin(builtin) = &target
930 && builtin.is_pane_action()
931 {
932 self.dispatch_pane_builtin(id, *builtin, sender);
933 return;
934 }
935 if let Some((di, pi)) = self.dock_pane_of(id)
936 && let Some(msg) = pane_item_message(&target)
937 {
938 self.docks[di].panes[pi].tree.emit(msg);
939 }
940 }
941
942 fn dock_pane_of(&self, id: u64) -> Option<(usize, usize)> {
944 self.docks.iter().enumerate().find_map(|(di, dock)| {
945 dock.panes
946 .iter()
947 .position(|pane| pane.id == id)
948 .map(|pi| (di, pi))
949 })
950 }
951
952 fn find_pane_with_dir(&self, path: &PathBuf) -> Option<(usize, usize)> {
957 let canonical = std::fs::canonicalize(path).ok().unwrap_or_else(|| path.clone());
958 self.docks.iter().enumerate().find_map(|(di, dock)| {
959 dock.panes.iter().enumerate().find_map(|(pi, pane)| {
960 if pane.canonical_root.as_deref() == Some(canonical.as_path()) {
961 Some((di, pi))
962 } else {
963 None
964 }
965 })
966 })
967 }
968
969 fn dock_of_side(&self, side: PanelSide) -> Option<usize> {
971 self.docks.iter().position(|dock| dock.side == side)
972 }
973
974 fn collapse_dock(&mut self, id: u64) {
977 if let Some((di, _)) = self.dock_pane_of(id) {
978 self.set_dock_visible(di, false);
979 }
980 }
981
982 fn set_dock_visible(&mut self, di: usize, visible: bool) {
984 self.docks[di].window.set_visible(visible);
985 self.refresh_visible();
986 }
987
988 fn set_docks_visible(&mut self, visible: bool) {
990 for dock in &self.docks {
991 dock.window.set_visible(visible);
992 if visible
994 && let Some(pane) = dock.panes.last()
995 {
996 pane.tree.emit(TreeMsg::Focus);
997 }
998 }
999 self.visible = visible;
1000 }
1001
1002 fn refresh_visible(&mut self) {
1005 self.visible = self.docks.iter().any(|dock| dock.window.is_visible());
1006 }
1007
1008 fn width_for(&self, side: PanelSide) -> u32 {
1010 self.widths.get(&side).copied().unwrap_or(self.config.panel.width)
1011 }
1012
1013 fn set_width(&mut self, side: PanelSide, width: u32) {
1016 let width = width.clamp(PANEL_MIN_WIDTH, PANEL_MAX_WIDTH);
1017 if width == self.width_for(side) {
1018 return;
1019 }
1020 self.widths.insert(side, width);
1021 let panel = PanelConfig { width, ..self.config.panel };
1022 for dock in self.docks.iter().filter(|dock| dock.side == side) {
1023 apply_window_width(&dock.window, width);
1024 for pane in &dock.panes {
1025 pane.tree.emit(TreeMsg::SetPanel(panel));
1026 }
1027 }
1028 if side == self.primary_side {
1030 self.config.panel.width = width;
1031 }
1032 self.schedule_save();
1033 }
1034
1035 fn resize_by(&mut self, side: PanelSide, delta: i32) {
1037 let target = (self.width_for(side) as i64 + delta as i64)
1038 .clamp(PANEL_MIN_WIDTH as i64, PANEL_MAX_WIDTH as i64) as u32;
1039 self.set_width(side, target);
1040 }
1041
1042 fn session_state(&self) -> SessionState {
1044 SessionState {
1045 last_root: self.last_root.clone(),
1046 left_width: Some(self.width_for(PanelSide::Left)),
1047 right_width: Some(self.width_for(PanelSide::Right)),
1048 }
1049 }
1050
1051 fn persist_session(&mut self) {
1053 self.save_generation.set(self.save_generation.get().wrapping_add(1));
1054 if let Err(err) = self.session_state().save() {
1055 self.status = format!("Could not save session: {err}");
1056 }
1057 }
1058
1059 fn schedule_save(&mut self) {
1063 let generation = self.save_generation.get().wrapping_add(1);
1064 self.save_generation.set(generation);
1065 let current = self.save_generation.clone();
1066 let state = self.session_state();
1067 glib::timeout_add_local_once(std::time::Duration::from_millis(SAVE_DEBOUNCE_MS), move || {
1068 if current.get() == generation {
1069 let _ = state.save();
1070 }
1071 });
1072 }
1073
1074 fn ensure_dock(&mut self, side: PanelSide, seed: bool, sender: &ComponentSender<Self>) -> usize {
1078 if let Some((di, _)) = self.docks.iter().enumerate().find(|(_, d)| d.side == side) {
1079 if seed && self.docks[di].panes.is_empty() {
1080 let startup = self.config.startup.clone();
1082 if let Some(root) = default_root(&startup) {
1083 self.add_pane(di, Some(root), sender);
1084 }
1085 }
1086 return di;
1087 }
1088 let config = &self.config;
1089 let width = self.width_for(side);
1090 let window = gtk::Window::new();
1091 init_layer_window(&window, config, side, width);
1092 window.set_default_size(width as i32, 520);
1093 if layer_shell_available() {
1094 attach_resize_controls(&window, side, sender);
1095 }
1096 let container = gtk::Box::new(gtk::Orientation::Vertical, 0);
1097 container.set_vexpand(true);
1098 window.set_child(Some(&container));
1099
1100 let di = self.docks.len();
1101 self.docks.push(Dock {
1102 side,
1103 window,
1104 container,
1105 panes: Vec::new(),
1106 active_pane: None,
1107 });
1108 if seed {
1109 let startup = self.config.startup.clone();
1110 if let Some(root) = default_root(&startup) {
1111 self.add_pane(di, Some(root), sender);
1112 }
1113 }
1114 di
1115 }
1116
1117 fn add_pane(&mut self, di: usize, root: Option<PathBuf>, sender: &ComponentSender<Self>) -> u64 {
1119 let id = self.next_id;
1120 self.next_id += 1;
1121 let parent = self.docks[di].window.clone();
1122 let side = self.docks[di].side;
1123 let width = self.width_for(side);
1124 let pane = make_pane(&self.config, parent, id, side, width, sender.clone());
1125 let mut pane = pane;
1126 if let Some(root) = &root {
1127 pane.tree.emit(TreeMsg::OpenRoot(root.clone()));
1128 pane.set_root(root.clone());
1129 }
1130 let dock = &mut self.docks[di];
1131 dock.panes.push(pane);
1132 dock.active_pane = Some(id);
1133 let container = dock.container.clone();
1134 fill_pane_container(&container, &dock.panes);
1135 let focus_sender = sender.clone();
1138 glib::idle_add_local_once(move || focus_sender.input(AppMsg::FocusPane { id }));
1139 id
1140 }
1141
1142 fn show_open_folder(&self, id: u64, sender: &ComponentSender<Self>) {
1143 let sender = sender.clone();
1144 self.file_dialog.select_folder(
1145 None::<>k::Window>,
1146 None::<&gio::Cancellable>,
1147 move |result| {
1148 let path = result.ok().and_then(|file| file.path());
1149 sender.input(AppMsg::OpenFolderPicked { id, path });
1150 },
1151 );
1152 }
1153}
1154
1155fn pane_item_message(target: &ShortcutTarget) -> Option<TreeMsg> {
1163 match target {
1164 ShortcutTarget::Builtin(action) => match action {
1165 BuiltinAction::ToggleHidden => Some(TreeMsg::ToggleHidden),
1166 BuiltinAction::SortByName => Some(TreeMsg::SetSortKey(SortKey::Name)),
1167 BuiltinAction::SortBySize => Some(TreeMsg::SetSortKey(SortKey::Size)),
1168 BuiltinAction::SortByModified => Some(TreeMsg::SetSortKey(SortKey::Modified)),
1169 BuiltinAction::SortByType => Some(TreeMsg::SetSortKey(SortKey::Type)),
1170 BuiltinAction::ToggleSortAscending => Some(TreeMsg::ToggleSortDirection),
1171 other => Some(TreeMsg::RunShortcut {
1172 target: ShortcutTarget::Builtin(*other),
1173 }),
1174 },
1175 ShortcutTarget::Command(_) => Some(TreeMsg::RunShortcut { target: target.clone() }),
1176 }
1177}
1178
1179fn make_pane(
1180 config: &Config,
1181 parent: gtk::Window,
1182 id: u64,
1183 side: PanelSide,
1184 width: u32,
1185 sender: ComponentSender<App>,
1186) -> Pane {
1187 let overlay = gtk::Overlay::new();
1190 let toolbar = Toolbar::builder()
1191 .launch(ToolbarInit {
1192 overlay: overlay.clone(),
1193 pane_menu: config.pane_menu.clone(),
1194 })
1195 .forward(sender.input_sender(), move |out| AppMsg::PaneToolbar { id, out });
1196 let tree = Tree::builder()
1197 .launch(TreeInit {
1198 config: config.tree.clone(),
1199 parent,
1200 menu: config.context_menu.clone(),
1201 side,
1202 panel: PanelConfig { width, ..config.panel },
1203 })
1204 .forward(sender.input_sender(), move |out| AppMsg::PaneTree { id, out });
1205
1206 let widget = gtk::Box::new(gtk::Orientation::Vertical, 0);
1207 widget.append(toolbar.widget());
1208
1209 let filter_bar = gtk::Box::new(gtk::Orientation::Horizontal, 4);
1211 filter_bar.add_css_class("filter-bar");
1212 filter_bar.set_visible(false);
1213
1214 let filter_entry = gtk::SearchEntry::new();
1215 filter_entry.set_placeholder_text(Some("Filter..."));
1216 filter_entry.set_hexpand(true);
1217 filter_entry.set_valign(gtk::Align::Center);
1218 filter_entry.add_css_class("filter-entry");
1219 {
1220 let s = sender.clone();
1221 filter_entry.connect_search_changed(move |entry| {
1222 s.input(AppMsg::FilterChanged {
1223 id,
1224 filter: entry.text().to_string(),
1225 });
1226 });
1227 }
1228 filter_bar.append(&filter_entry);
1229
1230 let close_btn = gtk::Button::from_icon_name("window-close-symbolic");
1231 close_btn.set_tooltip_text(Some("Clear filter"));
1232 close_btn.set_valign(gtk::Align::Center);
1233 close_btn.add_css_class("flat");
1234 {
1235 let s = sender.clone();
1236 close_btn.connect_clicked(move |_| {
1237 s.input(AppMsg::FilterClosed { id });
1238 });
1239 }
1240 filter_bar.append(&close_btn);
1241
1242 widget.append(&filter_bar);
1243
1244 let tree_widget = tree.widget();
1245 tree_widget.set_vexpand(true);
1246 widget.append(tree_widget);
1247 widget.set_vexpand(true);
1248
1249 overlay.set_child(Some(&widget));
1250
1251 {
1255 let shortcuts = Rc::new(PaneShortcuts::compile(&config.pane_menu));
1256 let s = sender.clone();
1257 let keys = gtk::EventControllerKey::new();
1258 keys.set_propagation_phase(gtk::PropagationPhase::Capture);
1259 keys.connect_key_pressed(move |_, key, _, state| {
1260 if let Some(action) = shortcuts.action_for(key, state) {
1261 s.input(AppMsg::PaneShortcut { id, action });
1262 glib::Propagation::Stop
1263 } else {
1264 glib::Propagation::Proceed
1265 }
1266 });
1267 widget.add_controller(keys);
1268 }
1269
1270 Pane {
1271 id,
1272 toolbar,
1273 tree,
1274 root: None,
1275 canonical_root: None,
1276 history: NavHistory::default(),
1277 filter_bar,
1278 filter_entry,
1279 widget: overlay,
1280 }
1281}
1282
1283fn remove_from_parent(w: >k::Widget) {
1291 let Some(parent) = w.parent() else { return };
1292 if let Ok(paned) = parent.clone().downcast::<gtk::Paned>() {
1293 let none: Option<>k::Widget> = None;
1294 if paned.start_child().is_some_and(|c| c == *w) {
1295 paned.set_start_child(none);
1296 }
1297 let none: Option<>k::Widget> = None;
1298 if paned.end_child().is_some_and(|c| c == *w) {
1299 paned.set_end_child(none);
1300 }
1301 } else if let Ok(b) = parent.downcast::<gtk::Box>() {
1302 b.remove(w);
1303 } else {
1304 w.unparent();
1305 }
1306}
1307
1308fn pane_widget(pane: &Pane) -> gtk::Widget {
1309 pane.widget.clone().upcast()
1310}
1311
1312fn fill_pane_container(container: >k::Box, panes: &[Pane]) {
1323 for pane in panes {
1328 remove_from_parent(pane.widget.upcast_ref());
1329 }
1330 while let Some(child) = container.first_child() {
1332 container.remove(&child);
1333 }
1334
1335 match panes {
1336 [] => {}
1337 [single] => {
1338 let w = pane_widget(single);
1339 w.set_vexpand(true);
1340 container.append(&w);
1341 }
1342 panes => {
1343 let n = panes.len();
1346 let last_w = pane_widget(&panes[n - 1]);
1347 last_w.set_vexpand(true);
1348 let mut right: gtk::Widget = last_w.clone().upcast();
1349 for pane in panes[..n - 1].iter().rev() {
1350 let left_w = pane_widget(pane);
1351 left_w.set_vexpand(true);
1352 let split = gtk::Paned::new(gtk::Orientation::Vertical);
1353 split.set_vexpand(true);
1354 split.set_wide_handle(true);
1355 split.set_shrink_start_child(true);
1357 split.set_shrink_end_child(true);
1358 split.set_resize_start_child(true);
1359 split.set_resize_end_child(true);
1360 split.set_start_child(Some(&left_w));
1361 split.set_end_child(Some(&right));
1362 {
1365 let split2 = split.clone();
1366 relm4::gtk::glib::timeout_add_local_once(
1367 std::time::Duration::from_millis(PANED_CENTER_DELAY_MS),
1368 move || {
1369 let alloc = split2.height();
1372 if alloc > 0 {
1373 split2.set_position(alloc / 2);
1374 }
1375 },
1376 );
1377 }
1378 right = split.upcast();
1379 }
1380 container.append(&right);
1381 }
1382 }
1383}
1384
1385fn stop_video_widgets(widget: >k::Widget) {
1391 if let Some(video) = widget.downcast_ref::<gtk::Video>() {
1392 video.set_media_stream(None::<>k::MediaStream>);
1393 }
1394 let mut child = widget.first_child();
1395 while let Some(current) = child {
1396 stop_video_widgets(¤t);
1397 child = current.next_sibling();
1398 }
1399}
1400
1401const WIDTH_STEP: i32 = 24;
1403
1404const SAVE_DEBOUNCE_MS: u64 = 300;
1406
1407const PANED_CENTER_DELAY_MS: u64 = 100;
1410
1411const SHUTDOWN_GRACE_MS: u64 = 150;
1414
1415fn apply_window_width(window: >k::Window, width: u32) {
1417 let (_, height) = window.default_size();
1418 if window.is_layer_window() {
1421 window.set_exclusive_zone(width as i32);
1422 }
1423 window.set_size_request(width as i32, -1);
1424 window.set_default_size(width as i32, if height > 0 { height } else { -1 });
1425}
1426
1427fn attach_resize_controls(window: >k::Window, side: PanelSide, sender: &ComponentSender<App>) {
1430 attach_resize_drag(window, side, sender.input_sender().clone());
1431 attach_resize_keys(window, side, sender.input_sender().clone());
1432}
1433
1434fn attach_resize_drag(window: >k::Window, side: PanelSide, sender: relm4::Sender<AppMsg>) {
1435 let drag = gtk::GestureDrag::new();
1436 drag.set_button(gdk::BUTTON_SECONDARY);
1437 drag.set_propagation_phase(gtk::PropagationPhase::Capture);
1438 let state = Rc::new(RefCell::new((false, 0.0f64)));
1440
1441 let begin_state = state.clone();
1442 drag.connect_drag_begin(move |gesture, _, _| {
1443 let super_held = gesture
1444 .current_event_state()
1445 .contains(gdk::ModifierType::SUPER_MASK);
1446 *begin_state.borrow_mut() = (super_held, 0.0);
1447 });
1448
1449 let update_sender = sender.clone();
1450 let update_state = state.clone();
1451 drag.connect_drag_update(move |_, offset_x, _| {
1452 let mut state = update_state.borrow_mut();
1453 if !state.0 {
1454 return;
1455 }
1456 let step = offset_x - state.1;
1457 state.1 = offset_x;
1458 let widen = match side {
1460 PanelSide::Left => step,
1461 PanelSide::Right => -step,
1462 };
1463 let delta = widen.round() as i32;
1464 if delta != 0 {
1465 let _ = update_sender.send(AppMsg::ResizeBy { side, delta });
1466 }
1467 });
1468
1469 let end_sender = sender;
1470 let end_state = state.clone();
1471 drag.connect_drag_end(move |_, _, _| {
1472 if end_state.borrow().0 {
1473 let _ = end_sender.send(AppMsg::ResizeCommit);
1474 }
1475 });
1476
1477 window.add_controller(drag);
1478}
1479
1480fn attach_resize_keys(window: >k::Window, side: PanelSide, sender: relm4::Sender<AppMsg>) {
1481 let keys = gtk::EventControllerKey::new();
1482 keys.set_propagation_phase(gtk::PropagationPhase::Capture);
1483 keys.connect_key_pressed(move |_, key, _, state| {
1484 if !state.contains(gdk::ModifierType::SUPER_MASK) {
1485 return glib::Propagation::Proceed;
1486 }
1487 match key {
1488 gdk::Key::minus | gdk::Key::underscore | gdk::Key::KP_Subtract => {
1489 let _ = sender.send(AppMsg::ResizeBy { side, delta: -WIDTH_STEP });
1490 glib::Propagation::Stop
1491 }
1492 gdk::Key::equal | gdk::Key::plus | gdk::Key::KP_Add => {
1493 let _ = sender.send(AppMsg::ResizeBy { side, delta: WIDTH_STEP });
1494 glib::Propagation::Stop
1495 }
1496 _ => glib::Propagation::Proceed,
1497 }
1498 });
1499 window.add_controller(keys);
1500}
1501
1502fn layer_shell_available() -> bool {
1510 static SUPPORTED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
1511 *SUPPORTED.get_or_init(gtk4_layer_shell::is_supported)
1512}
1513
1514fn init_layer_window(window: >k::Window, config: &Config, side: PanelSide, width: u32) {
1521 if !layer_shell_available() {
1522 configure_plain_window(window, width);
1523 return;
1524 }
1525 if window.is_layer_window() {
1526 return;
1527 }
1528 window.init_layer_shell();
1529 window.set_layer(layer_of(config.panel.layer));
1530 let edge = match side {
1531 PanelSide::Left => Edge::Left,
1532 PanelSide::Right => Edge::Right,
1533 };
1534 window.set_anchor(Edge::Top, true);
1535 window.set_anchor(Edge::Bottom, true);
1536 window.set_anchor(edge, true);
1537 window.set_exclusive_zone(width as i32);
1538 window.set_size_request(width as i32, -1);
1539 let margin = config.panel.margin as i32;
1540 window.set_margin(Edge::Top, margin);
1541 window.set_margin(Edge::Bottom, margin);
1542 window.set_margin(Edge::Left, margin);
1543 window.set_margin(Edge::Right, margin);
1544
1545 window.set_keyboard_mode(KeyboardMode::OnDemand);
1560 let motion = gtk::EventControllerMotion::new();
1561 {
1562 let kb_window = window.clone();
1563 motion.connect_enter(move |_, _, _| kb_window.set_keyboard_mode(KeyboardMode::OnDemand));
1564 }
1565 {
1566 let kb_window = window.clone();
1567 motion.connect_leave(move |_| {
1568 if has_visible_popover(kb_window.upcast_ref()) {
1573 return;
1574 }
1575 kb_window.set_keyboard_mode(KeyboardMode::None);
1576 });
1577 }
1578 window.add_controller(motion);
1579}
1580
1581fn configure_plain_window(window: >k::Window, width: u32) {
1587 static ANNOUNCED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
1588 ANNOUNCED.get_or_init(|| {
1589 eprintln!(
1590 "tree-space: this compositor does not support wlr-layer-shell, so the panel \
1591 cannot dock; opening it as a normal window instead."
1592 );
1593 });
1594 window.set_title(Some("tree-space"));
1595 window.set_default_size(width as i32, 720);
1596 window.set_size_request(width as i32, -1);
1597}
1598
1599fn has_visible_popover(widget: >k::Widget) -> bool {
1602 if widget.is::<gtk::Popover>() && widget.is_visible() {
1603 return true;
1604 }
1605 let mut child = widget.first_child();
1606 while let Some(current) = child {
1607 if has_visible_popover(¤t) {
1608 return true;
1609 }
1610 child = current.next_sibling();
1611 }
1612 false
1613}
1614
1615fn layer_of(layer: PanelLayer) -> Layer {
1616 use PanelLayer::*;
1617 match layer {
1618 Background => Layer::Background,
1619 Bottom => Layer::Bottom,
1620 Top => Layer::Top,
1621 Overlay => Layer::Overlay,
1622 }
1623}
1624
1625fn install_css(config: &Config) {
1629 let stylesheet = load_stylesheet();
1630 if let Some(problem) = &stylesheet.problem {
1631 eprintln!("tree-space: could not read stylesheet: {problem:?}");
1632 }
1633 let css = format!(
1634 "{}\n.tree-row label, .tree-rename-entry, .tree-menu, .hamburger-menu {{ font-size: {}px; }}",
1635 stylesheet.css, config.tree.font_size
1636 );
1637 let provider = gtk::CssProvider::new();
1638 provider.load_from_string(&css);
1639 if let Some(display) = gdk::Display::default() {
1640 gtk::style_context_add_provider_for_display(
1641 &display,
1642 &provider,
1643 gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
1644 );
1645 }
1646}
1647
1648fn home_dir() -> Option<PathBuf> {
1649 std::env::var_os("HOME").map(PathBuf::from)
1650}
1651
1652fn default_root(startup: &StartupRoot) -> Option<PathBuf> {
1655 let last = SessionState::load().last_root.filter(|p| p.is_dir());
1656 startup.resolve(last).filter(|p| p.is_dir()).or_else(home_dir)
1657}
1658#[cfg(test)]
1659mod visibility_tests {
1660 use super::*;
1661
1662 const L: PanelSide = PanelSide::Left;
1663 const R: PanelSide = PanelSide::Right;
1664
1665 fn plan(intent: VisibilityIntent, existing: &[PanelSide], shown: &[PanelSide]) -> VisibilityPlan {
1666 resolve_visibility(intent, existing, shown)
1667 }
1668
1669 #[test]
1670 fn show_all_shows_both() {
1671 let p = plan(VisibilityIntent::ShowAll, &[L], &[]);
1672 assert_eq!(p.show, vec![L]);
1674 assert!(p.seed);
1675 let both = plan(VisibilityIntent::ShowAll, &[L, R], &[]);
1676 assert_eq!(both.show, vec![L, R]);
1677 }
1678
1679 #[test]
1680 fn hide_all_hides_both() {
1681 let p = plan(VisibilityIntent::HideAll, &[L, R], &[L, R]);
1682 assert_eq!(p.hide, vec![L, R]);
1683 assert!(p.show.is_empty());
1684 }
1685
1686 #[test]
1687 fn toggle_all_shows_when_nothing_visible_and_hides_otherwise() {
1688 let shown = plan(VisibilityIntent::ToggleAll, &[L], &[]);
1689 assert_eq!(shown.show, vec![L]);
1691 let hidden = plan(VisibilityIntent::ToggleAll, &[L, R], &[L]);
1692 assert_eq!(hidden.hide, vec![L, R]);
1693 }
1694
1695 #[test]
1696 fn show_side_creates_a_missing_dock() {
1697 let p = plan(VisibilityIntent::ShowSide(R), &[L], &[L]);
1698 assert_eq!(p.create, Some(R));
1699 assert_eq!(p.show, vec![R]);
1700 }
1701
1702 #[test]
1703 fn hide_side_never_creates() {
1704 let p = plan(VisibilityIntent::HideSide(R), &[L], &[L]);
1705 assert_eq!(p.create, None);
1706 assert_eq!(p.hide, vec![R]);
1707 }
1708
1709 #[test]
1710 fn toggle_side_is_granular() {
1711 let create = plan(VisibilityIntent::ToggleSide(R), &[L], &[L]);
1713 assert_eq!(create.create, Some(R));
1714 assert_eq!(create.show, vec![R]);
1715 let hide = plan(VisibilityIntent::ToggleSide(L), &[L, R], &[L, R]);
1717 assert_eq!(hide.hide, vec![L]);
1718 let show = plan(VisibilityIntent::ToggleSide(L), &[L, R], &[R]);
1720 assert_eq!(show.show, vec![L]);
1721 }
1722
1723 #[test]
1724 fn toggling_one_side_leaves_the_other_alone() {
1725 let p = plan(VisibilityIntent::ToggleSide(R), &[L, R], &[L]);
1727 assert_eq!(p.show, vec![R]);
1728 assert!(!p.hide.contains(&L));
1729 }
1730}
1731
1732#[cfg(test)]
1733mod nav_history_tests {
1734 use super::NavHistory;
1735 use std::path::{Path, PathBuf};
1736
1737 fn p(s: &str) -> PathBuf {
1738 PathBuf::from(s)
1739 }
1740
1741 #[test]
1742 fn records_visits_in_order_and_navigates_both_ways() {
1743 let mut h = NavHistory::default();
1744 h.record(Path::new("/a"));
1745 h.record(Path::new("/b"));
1746 h.record(Path::new("/c"));
1747 assert!(h.can_back());
1748 assert!(!h.can_forward());
1749
1750 assert_eq!(h.back(), Some(p("/b")));
1751 h.finish_navigation();
1752 assert_eq!(h.back(), Some(p("/a")));
1753 h.finish_navigation();
1754 assert!(!h.can_back());
1755 assert!(h.can_forward());
1756
1757 assert_eq!(h.forward(), Some(p("/b")));
1758 h.finish_navigation();
1759 assert_eq!(h.forward(), Some(p("/c")));
1760 h.finish_navigation();
1761 assert!(h.can_back());
1762 assert!(!h.can_forward());
1763 }
1764
1765 #[test]
1766 fn a_new_visit_after_going_back_truncates_the_forward_tail() {
1767 let mut h = NavHistory::default();
1768 h.record(Path::new("/a"));
1769 h.record(Path::new("/b"));
1770 h.record(Path::new("/c"));
1771 assert_eq!(h.back(), Some(p("/b")));
1772 h.finish_navigation();
1773 h.record(Path::new("/d"));
1775 assert!(!h.can_forward());
1776 assert_eq!(h.back(), Some(p("/b")));
1777 }
1778
1779 #[test]
1780 fn navigating_does_not_record_and_repeats_are_ignored() {
1781 let mut h = NavHistory::default();
1782 h.record(Path::new("/a"));
1783 h.record(Path::new("/b"));
1784 let target = h.back().unwrap();
1786 assert_eq!(target, p("/a"));
1787 h.record(&target);
1788 h.finish_navigation();
1789 assert!(h.can_forward());
1791 assert_eq!(h.forward(), Some(p("/b")));
1792 h.finish_navigation();
1793
1794 h.record(Path::new("/b"));
1796 assert_eq!(h.cursor, 1);
1797 assert_eq!(h.entries.len(), 2);
1798 }
1799
1800 #[test]
1801 fn cannot_navigate_an_empty_history() {
1802 let mut h = NavHistory::default();
1803 assert!(!h.can_back());
1804 assert!(!h.can_forward());
1805 assert_eq!(h.back(), None);
1806 assert_eq!(h.forward(), None);
1807 }
1808}