aboutsummaryrefslogtreecommitdiff
path: root/src/app/input
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-05 21:59:10 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-05 22:06:02 +0300
commit556b16b2df7ea2c3e9b8ad13db4ad7370c1ad838 (patch)
tree0a6f61701f2c8992c6cd1ee6d61171ef92a92533 /src/app/input
parentae3c88ab8c64931740d89623f688f7d3f408da5e (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.rs107
-rw-r--r--src/app/input/mouse.rs138
-rw-r--r--src/app/input/navigate.rs41
-rw-r--r--src/app/input/terminal.rs10
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]