From 556b16b2df7ea2c3e9b8ad13db4ad7370c1ad838 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Fri, 5 Jun 2026 21:59:10 +0300 Subject: feat: add pane navigation controls refs #421 refs #330 related discussions: https://github.com/ogulcancelik/herdr/discussions/422 https://github.com/ogulcancelik/herdr/discussions/438 https://github.com/ogulcancelik/herdr/discussions/442 --- src/app/actions.rs | 120 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) (limited to 'src/app/actions.rs') diff --git a/src/app/actions.rs b/src/app/actions.rs index 9bce43ed..8124be93 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -1371,6 +1371,41 @@ impl AppState { } } + pub fn swap_pane(&mut self, direction: NavDirection) -> bool { + let Some(ws_idx) = self.active else { + return false; + }; + let Some(tab) = self.workspaces.get(ws_idx).and_then(|ws| ws.active_tab()) else { + return false; + }; + let panes = if tab.zoomed { + tab.layout.panes(self.view.terminal_area) + } else { + self.view.pane_infos.clone() + }; + + let Some(focused) = panes.iter().find(|p| p.is_focused) else { + return false; + }; + let Some(target) = find_in_direction(focused, direction, &panes) else { + return false; + }; + let source = focused.id; + let Some(tab) = self + .workspaces + .get_mut(ws_idx) + .and_then(|ws| ws.active_tab_mut()) + else { + return false; + }; + if tab.layout.swap_panes(source, target) { + self.mark_session_dirty(); + true + } else { + false + } + } + pub fn resize_pane(&mut self, direction: NavDirection) { if let Some(first) = self.view.pane_infos.first() { let area = self @@ -4092,6 +4127,91 @@ mod tests { assert!(state.view.pane_infos[0].inner_rect.x > state.view.pane_infos[0].rect.x); } + #[test] + fn swap_pane_direction_preserves_focus_and_swaps_layout_cells() { + let mut state = app_with_workspaces(&["test"]); + let root = state.workspaces[0].tabs[0].root_pane; + let right = state.workspaces[0].test_split(Direction::Horizontal); + state.workspaces[0].layout.focus_pane(root); + crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 100, 20)); + let before_root_rect = state + .view + .pane_infos + .iter() + .find(|info| info.id == root) + .unwrap() + .rect; + let before_right_rect = state + .view + .pane_infos + .iter() + .find(|info| info.id == right) + .unwrap() + .rect; + + assert!(state.swap_pane(NavDirection::Right)); + crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 100, 20)); + + assert_eq!(state.workspaces[0].focused_pane_id(), Some(root)); + assert_eq!( + state + .view + .pane_infos + .iter() + .find(|info| info.id == root) + .unwrap() + .rect, + before_right_rect + ); + assert_eq!( + state + .view + .pane_infos + .iter() + .find(|info| info.id == right) + .unwrap() + .rect, + before_root_rect + ); + } + + #[test] + fn swap_pane_direction_stays_zoomed_and_mutates_hidden_layout() { + let mut state = app_with_workspaces(&["test"]); + let root = state.workspaces[0].tabs[0].root_pane; + let right = state.workspaces[0].test_split(Direction::Horizontal); + state.workspaces[0].layout.focus_pane(root); + state.workspaces[0].zoomed = true; + crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 100, 20)); + + assert!(state.swap_pane(NavDirection::Right)); + crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 100, 20)); + + assert!(state.workspaces[0].zoomed); + assert_eq!(state.workspaces[0].focused_pane_id(), Some(root)); + assert_eq!(state.view.pane_infos.len(), 1); + assert_eq!(state.view.pane_infos[0].id, root); + + state.workspaces[0].zoomed = false; + crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 100, 20)); + let root_rect = state + .view + .pane_infos + .iter() + .find(|info| info.id == root) + .unwrap() + .rect; + let right_rect = state + .view + .pane_infos + .iter() + .find(|info| info.id == right) + .unwrap() + .rect; + + assert!(root_rect.x > right_rect.x); + } + #[test] fn close_pane_removes_from_workspace() { let mut state = app_with_workspaces(&["test"]); -- cgit v1.2.3