diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-08-08 16:26:47 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-08-08 16:26:47 +0300 |
| commit | 10974c822d607f03e20e9741ec027910f0c1f93a (patch) | |
| tree | 85453f5ba5bcfcc56c37cabdbbced8a7e701cbe3 /src/app/input | |
| parent | 1d6d1fdc6ba8a4fd0385a2071f157504871201a2 (diff) | |
feat(input): add per-pane right-click routing (#2504)
Diffstat (limited to 'src/app/input')
| -rw-r--r-- | src/app/input/modal.rs | 56 | ||||
| -rw-r--r-- | src/app/input/mouse.rs | 121 | ||||
| -rw-r--r-- | src/app/input/navigate.rs | 1 |
3 files changed, 170 insertions, 8 deletions
diff --git a/src/app/input/modal.rs b/src/app/input/modal.rs index 0a5c13e5..7a9ffe46 100644 --- a/src/app/input/modal.rs +++ b/src/app/input/modal.rs @@ -1288,6 +1288,27 @@ impl App { } ( ContextMenuKind::Pane { + ws_idx, pane_id, .. + }, + Some(action @ ("Send right-clicks to pane" | "Use Herdr right-click menu")), + ) => { + if let Some(pane_id) = self.public_pane_id(ws_idx, pane_id) { + self.runtime_pane_input_set( + "tui.pane.input.set", + crate::api::schema::PaneInputSetParams { + pane_id, + right_click: if action == "Send right-clicks to pane" { + crate::api::schema::PaneRightClickTarget::Pane + } else { + crate::api::schema::PaneRightClickTarget::Herdr + }, + }, + ); + } + self.state.mode = Mode::Terminal; + } + ( + ContextMenuKind::Pane { ws_idx, pane_id, source_pane_id: Some(source_pane_id), @@ -2180,6 +2201,39 @@ mod tests { } #[test] + fn context_menu_toggles_pane_right_click_passthrough() { + let mut app = app_with_test_workspaces(&["main"]); + app.state.active = Some(0); + let pane_id = app.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, + right_click_passthrough: false, + }, + x: 0, + y: 0, + list: MenuListState::new(0), + }; + let idx = menu + .items() + .iter() + .position(|item| *item == "Send right-clicks to pane") + .unwrap(); + app.apply_context_menu_action_via_api(menu, idx); + + assert!( + app.state.workspaces[0] + .pane_state(pane_id) + .unwrap() + .right_click_passthrough + ); + } + + #[test] fn context_menu_close_pane_last_parent_group_pane_keeps_confirmation_mode() { let mut state = state_with_workspaces(&["main", "issue"]); state.active = Some(0); @@ -2206,6 +2260,7 @@ mod tests { pane_id, source_pane_id: None, has_manual_label: false, + right_click_passthrough: false, }, x: 0, y: 0, @@ -2271,6 +2326,7 @@ mod tests { pane_id, source_pane_id: None, has_manual_label: false, + right_click_passthrough: false, }, x: 0, y: 0, diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs index d43dcfc4..2555d95d 100644 --- a/src/app/input/mouse.rs +++ b/src/app/input/mouse.rs @@ -1099,13 +1099,16 @@ impl AppState { .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 + let pane_state = self .workspaces .get(ws_idx) - .and_then(|ws| ws.pane_state(info.id)) + .and_then(|ws| ws.pane_state(info.id)); + let has_manual_label = pane_state .and_then(|pane| self.terminals.get(&pane.attached_terminal_id)) .and_then(|terminal| terminal.manual_label.as_ref()) .is_some(); + let right_click_passthrough = + pane_state.is_some_and(|pane| pane.right_click_passthrough); self.context_menu = Some(ContextMenuState { kind: ContextMenuKind::Pane { ws_idx, @@ -1113,6 +1116,7 @@ impl AppState { pane_id: info.id, source_pane_id, has_manual_label, + right_click_passthrough, }, x: mouse.column, y: mouse.row, @@ -1565,14 +1569,22 @@ impl AppState { return false; } - let Some(modifiers) = self.right_click_passthrough_modifiers else { + let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() else { return false; }; - if mouse.modifiers != modifiers { - return false; - } - - let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() else { + let configured_modifiers = self + .right_click_passthrough_modifiers + .filter(|modifiers| mouse.modifiers == *modifiers); + let pane_passthrough = mouse.modifiers.is_empty() + && self.active.is_some_and(|ws_idx| { + self.workspaces + .get(ws_idx) + .and_then(|workspace| workspace.pane_state(info.id)) + .is_some_and(|pane| pane.right_click_passthrough) + }); + let Some(modifiers) = configured_modifiers + .or_else(|| pane_passthrough.then(crossterm::event::KeyModifiers::empty)) + else { return false; }; @@ -2042,6 +2054,98 @@ mod tests { } #[tokio::test] + async fn pane_right_click_passthrough_is_isolated() { + let mut app = app_for_mouse_test(); + let mut ws = Workspace::test_new("test"); + let passthrough_pane = ws.tabs[0].root_pane; + let default_pane = ws.test_split(Direction::Horizontal); + ws.pane_state_mut(passthrough_pane) + .unwrap() + .right_click_passthrough = true; + 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 passthrough_info = app.state.pane_info_by_id(passthrough_pane).unwrap().clone(); + let default_info = app.state.pane_info_by_id(default_pane).unwrap().clone(); + let (passthrough_runtime, mut passthrough_input) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + passthrough_info.inner_rect.width, + passthrough_info.inner_rect.height, + 0, + b"\x1b[?1002h\x1b[?1006h", + 4, + ); + let (default_runtime, mut default_input) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + default_info.inner_rect.width, + default_info.inner_rect.height, + 0, + b"\x1b[?1002h\x1b[?1006h", + 4, + ); + app.state + .insert_test_runtime(passthrough_pane, passthrough_runtime); + app.state.insert_test_runtime(default_pane, default_runtime); + + let col = passthrough_info.inner_rect.x + 2; + let row = passthrough_info.inner_rect.y + 3; + app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Right), col, row)); + + assert_eq!(app.state.mode, Mode::Terminal); + assert!(app.state.context_menu.is_none()); + assert_eq!( + passthrough_input.try_recv().unwrap(), + Bytes::from_static(b"\x1b[<2;3;4M") + ); + + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Right), + default_info.inner_rect.x + 2, + default_info.inner_rect.y + 3, + )); + + assert!(default_input.try_recv().is_err()); + assert!(matches!( + app.state.context_menu.as_ref().map(|menu| &menu.kind), + Some(ContextMenuKind::Pane { pane_id, .. }) if *pane_id == default_pane + )); + } + + #[tokio::test] + async fn pane_right_click_passthrough_falls_back_when_mouse_reporting_is_off() { + let mut app = app_for_mouse_test(); + let mut ws = Workspace::test_new("test"); + let pane_id = ws.tabs[0].root_pane; + ws.pane_state_mut(pane_id).unwrap().right_click_passthrough = true; + 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 info = app.state.pane_info_by_id(pane_id).unwrap().clone(); + app.state.insert_test_runtime( + pane_id, + crate::terminal::TerminalRuntime::test_with_screen_bytes( + info.inner_rect.width, + info.inner_rect.height, + b"", + ), + ); + + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Right), + info.inner_rect.x + 2, + info.inner_rect.y + 3, + )); + + assert_eq!(app.state.mode, Mode::ContextMenu); + assert!(app.state.context_menu.is_some()); + } + + #[tokio::test] async fn configured_right_click_passthrough_forwards_full_gesture_to_pane() { let mut app = app_for_mouse_test(); let mut ws = Workspace::test_new("test"); @@ -2966,6 +3070,7 @@ mod tests { pane_id, source_pane_id: None, has_manual_label: false, + right_click_passthrough: false, }, x: 2, y: 2, diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs index 1c892028..64168035 100644 --- a/src/app/input/navigate.rs +++ b/src/app/input/navigate.rs @@ -578,6 +578,7 @@ impl App { ratio: None, cwd: None, focus: true, + right_click: Default::default(), env: Default::default(), }, ); |
