diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-05 21:59:10 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-05 22:06:02 +0300 |
| commit | 556b16b2df7ea2c3e9b8ad13db4ad7370c1ad838 (patch) | |
| tree | 0a6f61701f2c8992c6cd1ee6d61171ef92a92533 /src/app/input | |
| parent | ae3c88ab8c64931740d89623f688f7d3f408da5e (diff) | |
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
Diffstat (limited to 'src/app/input')
| -rw-r--r-- | src/app/input/modal.rs | 107 | ||||
| -rw-r--r-- | src/app/input/mouse.rs | 138 | ||||
| -rw-r--r-- | src/app/input/navigate.rs | 41 | ||||
| -rw-r--r-- | src/app/input/terminal.rs | 10 |
4 files changed, 271 insertions, 25 deletions
diff --git a/src/app/input/modal.rs b/src/app/input/modal.rs index 334b008e..5b179e9b 100644 --- a/src/app/input/modal.rs +++ b/src/app/input/modal.rs @@ -722,33 +722,111 @@ pub(super) fn apply_context_menu_action( (ContextMenuKind::Pane { pane_id, .. }, Some("Rename pane")) => { open_rename_pane(state, pane_id); } - (ContextMenuKind::Pane { pane_id, .. }, Some("Clear pane name")) => { - if let Some(ws_idx) = state.active { - if let Some(ws) = state.workspaces.get(ws_idx) { - if let Some(pane) = ws.pane_state(pane_id) { - let terminal_id = pane.attached_terminal_id.clone(); - if let Some(terminal) = state.terminals.get_mut(&terminal_id) { - terminal.clear_manual_label(); - state.mark_session_dirty(); - } + ( + ContextMenuKind::Pane { + ws_idx, pane_id, .. + }, + Some("Clear pane name"), + ) => { + if let Some(ws) = state.workspaces.get(ws_idx) { + if let Some(pane) = ws.pane_state(pane_id) { + let terminal_id = pane.attached_terminal_id.clone(); + if let Some(terminal) = state.terminals.get_mut(&terminal_id) { + terminal.clear_manual_label(); + state.mark_session_dirty(); } } } state.mode = Mode::Terminal; } - (ContextMenuKind::Pane { .. }, Some("Split vertical")) => { + ( + ContextMenuKind::Pane { + ws_idx, + tab_idx, + pane_id, + source_pane_id, + .. + }, + Some("Swap with focused pane"), + ) => { + if let Some(source_pane_id) = source_pane_id { + state.selected = ws_idx; + state.active = Some(ws_idx); + state.switch_tab(tab_idx); + if let Some(tab) = state + .workspaces + .get_mut(ws_idx) + .and_then(|ws| ws.tabs.get_mut(tab_idx)) + { + if tab.layout.swap_panes(source_pane_id, pane_id) { + tab.layout.focus_pane(source_pane_id); + state.mark_session_dirty(); + } + } + } + state.mode = Mode::Terminal; + } + ( + ContextMenuKind::Pane { + ws_idx, + tab_idx, + pane_id, + .. + }, + Some("Split right"), + ) => { + state.selected = ws_idx; + state.active = Some(ws_idx); + state.switch_tab(tab_idx); + state.focus_pane_in_workspace(ws_idx, pane_id); state.split_pane(terminal_runtimes, Direction::Horizontal); state.mode = Mode::Terminal; } - (ContextMenuKind::Pane { .. }, Some("Split horizontal")) => { + ( + ContextMenuKind::Pane { + ws_idx, + tab_idx, + pane_id, + .. + }, + Some("Split down"), + ) => { + state.selected = ws_idx; + state.active = Some(ws_idx); + state.switch_tab(tab_idx); + state.focus_pane_in_workspace(ws_idx, pane_id); state.split_pane(terminal_runtimes, Direction::Vertical); state.mode = Mode::Terminal; } - (ContextMenuKind::Pane { .. }, Some("Zoom")) => { + ( + ContextMenuKind::Pane { + ws_idx, + tab_idx, + pane_id, + .. + }, + Some("Zoom"), + ) => { + state.selected = ws_idx; + state.active = Some(ws_idx); + state.switch_tab(tab_idx); + state.focus_pane_in_workspace(ws_idx, pane_id); state.toggle_zoom(); state.mode = Mode::Terminal; } - (ContextMenuKind::Pane { .. }, Some("Close pane")) => { + ( + ContextMenuKind::Pane { + ws_idx, + tab_idx, + pane_id, + .. + }, + Some("Close pane"), + ) => { + state.selected = ws_idx; + state.active = Some(ws_idx); + state.switch_tab(tab_idx); + state.focus_pane_in_workspace(ws_idx, pane_id); if !state.close_pane() { state.mode = if state.active.is_some() { Mode::Terminal @@ -1301,7 +1379,10 @@ mod tests { let pane_id = state.workspaces[0].tabs[0].root_pane; let menu = ContextMenuState { kind: ContextMenuKind::Pane { + ws_idx: 0, + tab_idx: 0, pane_id, + source_pane_id: None, has_manual_label: false, }, x: 0, diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs index 51a9763e..9272c279 100644 --- a/src/app/input/mouse.rs +++ b/src/app/input/mouse.rs @@ -950,7 +950,6 @@ impl AppState { if let (Some(ws_idx), Some(tab_idx)) = (self.active, self.tab_at(mouse.column, mouse.row)) { - self.switch_tab(tab_idx); self.context_menu = Some(ContextMenuState { kind: ContextMenuKind::Tab { ws_idx, tab_idx }, x: mouse.column, @@ -963,17 +962,30 @@ impl AppState { MouseEventKind::Down(MouseButton::Right) if !in_sidebar => { if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() { - self.focus_pane(info.id); + let ws_idx = self.active?; + let tab_idx = self + .workspaces + .get(ws_idx) + .map(|ws| ws.active_tab_index())?; + let previous_focused_pane_id = self + .workspaces + .get(ws_idx) + .and_then(|ws| ws.focused_pane_id()); + let source_pane_id = + previous_focused_pane_id.filter(|pane_id| *pane_id != info.id); let has_manual_label = self - .active - .and_then(|ws_idx| self.workspaces.get(ws_idx)) + .workspaces + .get(ws_idx) .and_then(|ws| ws.pane_state(info.id)) .and_then(|pane| self.terminals.get(&pane.attached_terminal_id)) .and_then(|terminal| terminal.manual_label.as_ref()) .is_some(); self.context_menu = Some(ContextMenuState { kind: ContextMenuKind::Pane { + ws_idx, + tab_idx, pane_id: info.id, + source_pane_id, has_manual_label, }, x: mouse.column, @@ -1997,6 +2009,90 @@ mod tests { } #[tokio::test] + async fn pane_right_click_keeps_focus_and_swap_menu_swaps_with_focused_pane() { + let mut app = app_for_mouse_test(); + let mut ws = Workspace::test_new("test"); + let source = ws.tabs[0].root_pane; + let target = ws.test_split(Direction::Horizontal); + ws.tabs[0].layout.focus_pane(source); + app.state.workspaces = vec![ws]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 100, 20)); + let target_info = app + .state + .view + .pane_infos + .iter() + .find(|info| info.id == target) + .expect("target pane info") + .clone(); + let source_rect_before = app + .state + .view + .pane_infos + .iter() + .find(|info| info.id == source) + .expect("source pane info") + .rect; + let target_rect_before = target_info.rect; + + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Right), + target_info.inner_rect.x, + target_info.inner_rect.y, + )); + + assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(source)); + let menu = app.state.context_menu.as_mut().expect("pane context menu"); + assert!(matches!( + menu.kind, + ContextMenuKind::Pane { + pane_id, + source_pane_id: Some(source_pane_id), + .. + } if pane_id == target && source_pane_id == source + )); + let swap_idx = menu + .items() + .iter() + .position(|item| *item == "Swap with focused pane") + .expect("swap item"); + menu.list.highlighted = swap_idx; + + handle_context_menu_key( + &mut app.state, + &mut app.terminal_runtimes, + KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), + ); + crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 100, 20)); + + assert_eq!(app.state.mode, Mode::Terminal); + assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(source)); + assert_eq!( + app.state + .view + .pane_infos + .iter() + .find(|info| info.id == source) + .unwrap() + .rect, + target_rect_before + ); + assert_eq!( + app.state + .view + .pane_infos + .iter() + .find(|info| info.id == target) + .unwrap() + .rect, + source_rect_before + ); + } + + #[tokio::test] async fn right_click_passthrough_requires_exact_modifier_match() { let mut app = app_for_mouse_test(); let mut ws = Workspace::test_new("test"); @@ -2446,7 +2542,10 @@ mod tests { let runtime_count = app.terminal_runtimes.len(); app.state.context_menu = Some(ContextMenuState { kind: ContextMenuKind::Pane { + ws_idx: 0, + tab_idx: 0, pane_id, + source_pane_id: None, has_manual_label: false, }, x: 2, @@ -2796,6 +2895,37 @@ mod tests { } #[test] + fn right_click_inactive_tab_opens_menu_without_switching_tabs() { + let mut app = app_for_mouse_test(); + let mut ws = Workspace::test_new("one"); + ws.test_add_tab(Some("two")); + app.state.workspaces = vec![ws]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + + crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); + let second_tab = app.state.view.tab_hit_areas[1]; + + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Right), + second_tab.x + 1, + second_tab.y, + )); + + assert_eq!(app.state.workspaces[0].active_tab, 0); + let menu = app.state.context_menu.as_ref().expect("tab context menu"); + assert_eq!( + menu.kind, + ContextMenuKind::Tab { + ws_idx: 0, + tab_idx: 1 + } + ); + assert_eq!(app.state.mode, Mode::ContextMenu); + } + + #[test] fn wheel_over_overflowing_tab_bar_switches_tabs() { let mut app = app_for_mouse_test(); let mut ws = Workspace::test_new("one"); diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs index 2ac86aeb..7f803825 100644 --- a/src/app/input/navigate.rs +++ b/src/app/input/navigate.rs @@ -499,6 +499,10 @@ pub(crate) enum NavigateAction { FocusPaneDown, FocusPaneUp, FocusPaneRight, + SwapPaneLeft, + SwapPaneDown, + SwapPaneUp, + SwapPaneRight, SplitVertical, SplitHorizontal, ClosePane, @@ -601,6 +605,10 @@ fn action_for_key( (&kb.focus_pane_down, NavigateAction::FocusPaneDown), (&kb.focus_pane_up, NavigateAction::FocusPaneUp), (&kb.focus_pane_right, NavigateAction::FocusPaneRight), + (&kb.swap_pane_left, NavigateAction::SwapPaneLeft), + (&kb.swap_pane_down, NavigateAction::SwapPaneDown), + (&kb.swap_pane_up, NavigateAction::SwapPaneUp), + (&kb.swap_pane_right, NavigateAction::SwapPaneRight), (&kb.last_pane, NavigateAction::LastPane), (&kb.cycle_pane_next, NavigateAction::CyclePaneNext), (&kb.cycle_pane_previous, NavigateAction::CyclePanePrevious), @@ -778,6 +786,22 @@ pub(super) fn execute_navigate_action_in_context( NavigateAction::FocusPaneDown => state.navigate_pane(NavDirection::Down), NavigateAction::FocusPaneUp => state.navigate_pane(NavDirection::Up), NavigateAction::FocusPaneRight => state.navigate_pane(NavDirection::Right), + NavigateAction::SwapPaneLeft => { + state.swap_pane(NavDirection::Left); + leave_navigate_mode(state); + } + NavigateAction::SwapPaneDown => { + state.swap_pane(NavDirection::Down); + leave_navigate_mode(state); + } + NavigateAction::SwapPaneUp => { + state.swap_pane(NavDirection::Up); + leave_navigate_mode(state); + } + NavigateAction::SwapPaneRight => { + state.swap_pane(NavDirection::Right); + leave_navigate_mode(state); + } NavigateAction::SplitVertical => { state.split_pane(terminal_runtimes, Direction::Horizontal); leave_navigate_mode(state); @@ -1501,6 +1525,19 @@ navigate_pane_right = "ctrl+l" } #[test] + fn terminal_direct_swap_pane_shortcut_maps_to_navigation_action() { + let mut state = state_with_workspaces(&["test"]); + state.keybinds.swap_pane_right = crate::config::ActionKeybinds::direct("alt+shift+l"); + + let action = terminal_direct_navigation_action( + &state, + TerminalKey::new(KeyCode::Char('l'), KeyModifiers::ALT | KeyModifiers::SHIFT), + ); + + assert_eq!(action, Some(NavigateAction::SwapPaneRight)); + } + + #[test] fn terminal_direct_last_pane_shortcut_maps_to_navigation_action() { let mut state = state_with_workspaces(&["test"]); state.keybinds.last_pane = crate::config::ActionKeybinds::direct("alt+l"); @@ -1788,11 +1825,11 @@ last_pane = "prefix+tab" #[test] fn modified_navigate_local_key_can_be_bound_as_prefix_rhs() { let mut state = state_with_workspaces(&["test"]); - state.keybinds.toggle_sidebar = crate::config::ActionKeybinds::prefix("shift+h"); + state.keybinds.toggle_sidebar = crate::config::ActionKeybinds::prefix("shift+u"); handle_navigate_key( &mut state, - KeyEvent::new(KeyCode::Char('H'), KeyModifiers::SHIFT), + KeyEvent::new(KeyCode::Char('U'), KeyModifiers::SHIFT), ); assert!(state.sidebar_collapsed); diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs index 99ae3bf1..c60a9f48 100644 --- a/src/app/input/terminal.rs +++ b/src/app/input/terminal.rs @@ -652,7 +652,7 @@ mod tests { } #[tokio::test] - async fn clicking_unfocused_pane_with_mouse_reporting_focuses_it_via_right_button() { + async fn right_clicking_unfocused_mouse_reporting_pane_keeps_focus_for_context_menu() { let mut app = app_for_mouse_test(); let mut ws = Workspace::test_new("test"); let first_pane = ws.tabs[0].root_pane; @@ -702,12 +702,10 @@ mod tests { second_info.inner_rect.y + 2, )); - assert_eq!( - app.state.workspaces[0].tabs[0].layout.focused(), - second_pane - ); + assert_eq!(app.state.workspaces[0].tabs[0].layout.focused(), first_pane); assert_eq!(app.state.mode, Mode::ContextMenu); - assert!(app.state.context_menu.is_some()); + let menu = app.state.context_menu.as_ref().expect("pane context menu"); + assert!(menu.items().contains(&"Swap with focused pane")); } #[tokio::test] |
