aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app/input/modal.rs9
-rw-r--r--src/app/input/mouse.rs271
-rw-r--r--src/app/mod.rs10
-rw-r--r--src/app/state.rs10
-rw-r--r--src/config/model.rs135
-rw-r--r--src/main.rs4
6 files changed, 434 insertions, 5 deletions
diff --git a/src/app/input/modal.rs b/src/app/input/modal.rs
index e85e9bb1..aedc3adb 100644
--- a/src/app/input/modal.rs
+++ b/src/app/input/modal.rs
@@ -1290,11 +1290,16 @@ mod tests {
},
x: 0,
y: 0,
- list: MenuListState::new(4),
+ list: MenuListState::new(0),
};
+ let idx = menu
+ .items()
+ .iter()
+ .position(|item| *item == "Close pane")
+ .expect("close pane item");
let mut terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new();
- apply_context_menu_action(&mut state, &mut terminal_runtimes, menu, 4);
+ apply_context_menu_action(&mut state, &mut terminal_runtimes, menu, idx);
assert_eq!(state.selected, 0);
assert_eq!(state.mode, Mode::ConfirmClose);
diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs
index b991828a..7bd68330 100644
--- a/src/app/input/mouse.rs
+++ b/src/app/input/mouse.rs
@@ -6,7 +6,8 @@ use tracing::warn;
use crate::{
app::state::{
AgentPanelScope, AppState, ContextMenuKind, ContextMenuState, DragState, DragTarget,
- MenuListState, Mode, TabPressState, ViewLayout, WorkspacePressState,
+ MenuListState, Mode, RightClickPassthroughGesture, TabPressState, ViewLayout,
+ WorkspacePressState,
},
layout::{PaneInfo, SplitBorder},
selection::Selection,
@@ -141,6 +142,10 @@ impl AppState {
&& mouse.row >= sidebar.y
&& mouse.row < sidebar.y + sidebar.height;
+ if self.handle_right_click_passthrough(terminal_runtimes, mouse, in_sidebar) {
+ return None;
+ }
+
if self.mode == Mode::OpenExistingWorktree {
match mouse.kind {
MouseEventKind::ScrollUp => {
@@ -1335,6 +1340,82 @@ impl AppState {
.and_then(crate::terminal::TerminalRuntime::scroll_metrics)
}
+ fn handle_right_click_passthrough(
+ &mut self,
+ terminal_runtimes: &TerminalRuntimeRegistry,
+ mouse: MouseEvent,
+ in_sidebar: bool,
+ ) -> bool {
+ if let Some(gesture) = self.right_click_passthrough.clone() {
+ match mouse.kind {
+ MouseEventKind::Drag(MouseButton::Right)
+ | MouseEventKind::Up(MouseButton::Right) => {
+ let forwarded_mouse =
+ self.strip_right_click_passthrough_modifiers(mouse, gesture.modifiers);
+ let _ = self.forward_pane_mouse_button(
+ terminal_runtimes,
+ &gesture.pane_info,
+ forwarded_mouse,
+ );
+ if matches!(mouse.kind, MouseEventKind::Up(MouseButton::Right)) {
+ self.right_click_passthrough = None;
+ }
+ return true;
+ }
+ _ => {
+ self.right_click_passthrough = None;
+ }
+ }
+ }
+
+ if self.mode != Mode::Terminal
+ || in_sidebar
+ || !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Right))
+ {
+ return false;
+ }
+
+ let Some(modifiers) = self.right_click_passthrough_modifiers else {
+ return false;
+ };
+ if mouse.modifiers != modifiers {
+ return false;
+ }
+
+ let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() else {
+ return false;
+ };
+
+ self.focus_pane(info.id);
+ let forwarded_mouse = self.strip_right_click_passthrough_modifiers(mouse, modifiers);
+ if !self.forward_pane_mouse_button(terminal_runtimes, &info, forwarded_mouse) {
+ return false;
+ }
+
+ self.selection = None;
+ self.selection_autoscroll = None;
+ self.workspace_press = None;
+ self.tab_press = None;
+ self.drag = None;
+ self.context_menu = None;
+ self.right_click_passthrough = Some(RightClickPassthroughGesture {
+ pane_info: info,
+ modifiers,
+ });
+ true
+ }
+
+ fn strip_right_click_passthrough_modifiers(
+ &self,
+ mouse: MouseEvent,
+ modifiers: crossterm::event::KeyModifiers,
+ ) -> MouseEvent {
+ MouseEvent {
+ modifiers: mouse.modifiers.difference(modifiers),
+ ..mouse
+ }
+ }
+
pub(super) fn handle_terminal_wheel(
&mut self,
terminal_runtimes: &TerminalRuntimeRegistry,
@@ -1640,6 +1721,194 @@ mod tests {
assert_eq!(metrics.offset_from_bottom, 7);
}
+ #[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");
+ let pane_id = ws.tabs[0].root_pane;
+ let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18));
+ let info = pane_infos[0].clone();
+ let (runtime, mut input_rx) =
+ crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
+ info.inner_rect.width,
+ info.inner_rect.height,
+ 0,
+ b"\x1b[?1002h\x1b[?1006h",
+ 4,
+ );
+ ws.insert_test_runtime(pane_id, runtime);
+
+ app.state.workspaces = vec![ws];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+ app.state.view.pane_infos = pane_infos;
+ app.state.right_click_passthrough_modifiers = Some(KeyModifiers::CONTROL);
+
+ let col = info.inner_rect.x + 2;
+ let row = info.inner_rect.y + 3;
+ app.handle_mouse(MouseEvent {
+ modifiers: KeyModifiers::CONTROL,
+ ..mouse(MouseEventKind::Down(MouseButton::Right), col, row)
+ });
+ app.handle_mouse(MouseEvent {
+ modifiers: KeyModifiers::CONTROL,
+ ..mouse(MouseEventKind::Drag(MouseButton::Right), col + 1, row + 1)
+ });
+ app.handle_mouse(MouseEvent {
+ modifiers: KeyModifiers::CONTROL,
+ ..mouse(MouseEventKind::Up(MouseButton::Right), col + 1, row + 1)
+ });
+
+ assert_eq!(app.state.mode, Mode::Terminal);
+ assert!(app.state.context_menu.is_none());
+ assert!(app.state.right_click_passthrough.is_none());
+ assert_eq!(
+ input_rx.try_recv().expect("forwarded right mouse down"),
+ Bytes::from_static(b"\x1b[<2;3;4M")
+ );
+ assert_eq!(
+ input_rx.try_recv().expect("forwarded right mouse drag"),
+ Bytes::from_static(b"\x1b[<34;4;5M")
+ );
+ assert_eq!(
+ input_rx.try_recv().expect("forwarded right mouse up"),
+ Bytes::from_static(b"\x1b[<2;4;5m")
+ );
+ assert!(input_rx.try_recv().is_err());
+ }
+
+ #[tokio::test]
+ async fn unset_right_click_passthrough_keeps_modified_right_click_as_herdr_menu() {
+ let mut app = app_for_mouse_test();
+ let mut ws = Workspace::test_new("test");
+ let pane_id = ws.tabs[0].root_pane;
+ let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18));
+ let info = pane_infos[0].clone();
+ let (runtime, mut input_rx) =
+ crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
+ info.inner_rect.width,
+ info.inner_rect.height,
+ 0,
+ b"\x1b[?1002h\x1b[?1006h",
+ 4,
+ );
+ ws.insert_test_runtime(pane_id, runtime);
+
+ app.state.workspaces = vec![ws];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+ app.state.view.pane_infos = pane_infos;
+ app.state.right_click_passthrough_modifiers = None;
+
+ app.handle_mouse(MouseEvent {
+ modifiers: KeyModifiers::CONTROL,
+ ..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());
+ assert!(app.state.right_click_passthrough.is_none());
+ assert!(input_rx.try_recv().is_err());
+ }
+
+ #[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");
+ let pane_id = ws.tabs[0].root_pane;
+ let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18));
+ let info = pane_infos[0].clone();
+ let (runtime, mut input_rx) =
+ crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
+ info.inner_rect.width,
+ info.inner_rect.height,
+ 0,
+ b"\x1b[?1002h\x1b[?1006h",
+ 4,
+ );
+ ws.insert_test_runtime(pane_id, runtime);
+
+ app.state.workspaces = vec![ws];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+ app.state.view.pane_infos = pane_infos;
+ app.state.right_click_passthrough_modifiers = Some(KeyModifiers::CONTROL);
+
+ let col = info.inner_rect.x + 2;
+ let row = info.inner_rect.y + 3;
+ app.handle_mouse(MouseEvent {
+ modifiers: KeyModifiers::CONTROL | KeyModifiers::SHIFT,
+ ..mouse(MouseEventKind::Down(MouseButton::Right), col, row)
+ });
+
+ assert_eq!(app.state.mode, Mode::ContextMenu);
+ assert!(app.state.context_menu.is_some());
+ assert!(app.state.right_click_passthrough.is_none());
+ assert!(input_rx.try_recv().is_err());
+ }
+
+ #[tokio::test]
+ async fn right_click_passthrough_does_not_forward_pane_frame_clicks() {
+ let mut app = app_for_mouse_test();
+ let mut ws = Workspace::test_new("test");
+ let pane_id = ws.tabs[0].root_pane;
+ let other_pane = ws.test_split(Direction::Vertical);
+ app.state.workspaces = vec![ws];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+ app.state.right_click_passthrough_modifiers = Some(KeyModifiers::CONTROL);
+ crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20));
+
+ let info = app
+ .state
+ .view
+ .pane_infos
+ .iter()
+ .find(|info| info.id == pane_id)
+ .expect("pane info")
+ .clone();
+ let (runtime, mut input_rx) =
+ crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
+ info.inner_rect.width,
+ info.inner_rect.height,
+ 0,
+ b"\x1b[?1002h\x1b[?1006h",
+ 4,
+ );
+ app.state.insert_test_runtime(pane_id, runtime);
+ app.state.insert_test_runtime(
+ other_pane,
+ crate::terminal::TerminalRuntime::test_with_screen_bytes(10, 5, b""),
+ );
+
+ assert!(app.state.pane_at(info.rect.x, info.rect.y).is_none());
+ assert!(app
+ .state
+ .pane_mouse_target(info.rect.x, info.rect.y)
+ .is_some());
+ app.handle_mouse(MouseEvent {
+ modifiers: KeyModifiers::CONTROL,
+ ..mouse(
+ MouseEventKind::Down(MouseButton::Right),
+ info.rect.x,
+ info.rect.y,
+ )
+ });
+
+ assert_eq!(app.state.mode, Mode::ContextMenu);
+ assert!(app.state.context_menu.is_some());
+ assert!(app.state.right_click_passthrough.is_none());
+ assert!(input_rx.try_recv().is_err());
+ }
+
fn sample_worktree_open_state() -> crate::app::state::WorktreeOpenState {
crate::app::state::WorktreeOpenState {
source_workspace_id: "source".into(),
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 661926ee..0dcd0372 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -490,6 +490,8 @@ impl App {
sidebar_section_split,
agent_panel_scope,
mouse_capture: config.ui.mouse_capture,
+ right_click_passthrough_modifiers: config.ui.right_click_passthrough_modifiers(),
+ right_click_passthrough: None,
redraw_on_focus_gained: config.ui.redraw_on_focus_gained,
mouse_scroll_lines: config.ui.mouse_scroll_lines(),
confirm_close: config.ui.confirm_close,
@@ -1107,6 +1109,8 @@ impl App {
}
self.state.redraw_on_focus_gained = config.ui.redraw_on_focus_gained;
self.state.mouse_scroll_lines = config.ui.mouse_scroll_lines();
+ self.state.right_click_passthrough_modifiers =
+ config.ui.right_click_passthrough_modifiers();
self.state.confirm_close = config.ui.confirm_close;
self.state.prompt_new_tab_name = config.ui.prompt_new_tab_name;
self.state.show_agent_labels_on_pane_borders =
@@ -1717,7 +1721,7 @@ mod tests {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(
&path,
- "[terminal]\ndefault_shell = \"nu\"\nshell_mode = \"non_login\"\nnew_cwd = \"home\"\n[keys]\nnew_workspace = \"prefix+m\"\nprefix = \"ctrl+a\"\n[ui]\nagent_panel_scope = \"current\"\nredraw_on_focus_gained = false\n[ui.toast]\ndelivery = \"herdr\"\n",
+ "[terminal]\ndefault_shell = \"nu\"\nshell_mode = \"non_login\"\nnew_cwd = \"home\"\n[keys]\nnew_workspace = \"prefix+m\"\nprefix = \"ctrl+a\"\n[ui]\nagent_panel_scope = \"current\"\nredraw_on_focus_gained = false\nright_click_passthrough_modifier = \"ctrl\"\n[ui.toast]\ndelivery = \"herdr\"\n",
)
.unwrap();
std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
@@ -1742,6 +1746,10 @@ mod tests {
state::AgentPanelScope::CurrentWorkspace
);
assert!(!app.state.redraw_on_focus_gained);
+ assert_eq!(
+ app.state.right_click_passthrough_modifiers,
+ Some(KeyModifiers::CONTROL)
+ );
assert!(app.state.request_client_config_reload);
assert_eq!(app.state.default_shell, "nu");
assert_eq!(
diff --git a/src/app/state.rs b/src/app/state.rs
index 9466fcce..1584bc19 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -31,6 +31,12 @@ pub(crate) struct SelectionAutoscroll {
pub last_mouse_screen_row: u16,
pub inner_rect: Rect,
}
+
+#[derive(Clone)]
+pub(crate) struct RightClickPassthroughGesture {
+ pub pane_info: PaneInfo,
+ pub modifiers: KeyModifiers,
+}
use crate::terminal_theme::TerminalTheme;
use crate::workspace::Workspace;
@@ -1238,6 +1244,8 @@ pub struct AppState {
/// Capture mouse input for Herdr's own mouse UI. When false, Herdr only
/// captures mouse while the focused pane app requests mouse reporting.
pub mouse_capture: bool,
+ pub right_click_passthrough_modifiers: Option<KeyModifiers>,
+ pub right_click_passthrough: Option<RightClickPassthroughGesture>,
pub redraw_on_focus_gained: bool,
pub mouse_scroll_lines: usize,
pub confirm_close: bool,
@@ -1548,6 +1556,8 @@ impl AppState {
sidebar_section_split: 0.5,
agent_panel_scope: AgentPanelScope::AllWorkspaces,
mouse_capture: true,
+ right_click_passthrough_modifiers: None,
+ right_click_passthrough: None,
redraw_on_focus_gained: true,
mouse_scroll_lines: crate::config::DEFAULT_MOUSE_SCROLL_LINES,
confirm_close: true,
diff --git a/src/config/model.rs b/src/config/model.rs
index 77af2f75..0776e09b 100644
--- a/src/config/model.rs
+++ b/src/config/model.rs
@@ -1,6 +1,7 @@
use std::num::NonZeroUsize;
-use serde::{Deserialize, Deserializer, Serialize};
+use crossterm::event::KeyModifiers;
+use serde::{de, Deserialize, Deserializer, Serialize};
use super::{
BindingConfig, CommandKeybindConfig, SoundConfig, ThemeConfig, DEFAULT_MOBILE_WIDTH_THRESHOLD,
@@ -34,6 +35,59 @@ impl AgentPanelScopeConfig {
}
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub struct RightClickPassthroughModifierConfig(Option<KeyModifiers>);
+
+impl RightClickPassthroughModifierConfig {
+ pub fn modifiers(self) -> Option<KeyModifiers> {
+ self.0
+ }
+}
+
+impl<'de> Deserialize<'de> for RightClickPassthroughModifierConfig {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: Deserializer<'de>,
+ {
+ let value = String::deserialize(deserializer)?;
+ parse_right_click_passthrough_modifier(&value)
+ .map(Self)
+ .ok_or_else(|| {
+ de::Error::custom(
+ "right_click_passthrough_modifier must be empty, off, none, disabled, ctrl/control, alt/option, cmd/command/super, meta, hyper, or a + separated combination without shift",
+ )
+ })
+ }
+}
+
+fn parse_right_click_passthrough_modifier(value: &str) -> Option<Option<KeyModifiers>> {
+ let trimmed = value.trim();
+ if trimmed.is_empty()
+ || trimmed.eq_ignore_ascii_case("off")
+ || trimmed.eq_ignore_ascii_case("none")
+ || trimmed.eq_ignore_ascii_case("disabled")
+ {
+ return Some(None);
+ }
+
+ let mut modifiers = KeyModifiers::empty();
+ for token in trimmed.split('+') {
+ let token = token.trim().to_ascii_lowercase();
+ let modifier = match token.as_str() {
+ "ctrl" | "control" => KeyModifiers::CONTROL,
+ "alt" | "option" => KeyModifiers::ALT,
+ "cmd" | "command" | "super" => KeyModifiers::SUPER,
+ "meta" => KeyModifiers::META,
+ "hyper" => KeyModifiers::HYPER,
+ "shift" => return None,
+ _ => return None,
+ };
+ modifiers |= modifier;
+ }
+
+ (!modifiers.is_empty()).then_some(Some(modifiers))
+}
+
#[derive(Debug, Clone)]
pub struct ToastConfig {
pub delivery: ToastDelivery,
@@ -276,6 +330,8 @@ pub struct UiConfig {
pub mobile_width_threshold: u16,
/// Capture mouse input for Herdr's mouse UI. Default: true.
pub mouse_capture: bool,
+ /// Modifier that lets right-click gestures pass through to pane apps. Empty disables it.
+ pub right_click_passthrough_modifier: RightClickPassthroughModifierConfig,
/// Force a full host-terminal redraw when the outer terminal regains focus. Default: true.
pub redraw_on_focus_gained: bool,
/// Lines to scroll per mouse wheel notch. Default: 3.
@@ -438,6 +494,7 @@ impl Default for UiConfig {
sidebar_max_width: 36,
mobile_width_threshold: DEFAULT_MOBILE_WIDTH_THRESHOLD,
mouse_capture: true,
+ right_click_passthrough_modifier: RightClickPassthroughModifierConfig::default(),
redraw_on_focus_gained: true,
mouse_scroll_lines: None,
confirm_close: true,
@@ -457,6 +514,10 @@ impl UiConfig {
.map(NonZeroUsize::get)
.unwrap_or(DEFAULT_MOUSE_SCROLL_LINES)
}
+
+ pub fn right_click_passthrough_modifiers(&self) -> Option<KeyModifiers> {
+ self.right_click_passthrough_modifier.modifiers()
+ }
}
impl Default for ToastConfig {
@@ -702,6 +763,78 @@ mouse_capture = false
}
#[test]
+ fn right_click_passthrough_modifier_defaults_off_and_parses() {
+ let default_config = Config::default();
+ assert_eq!(default_config.ui.right_click_passthrough_modifiers(), None);
+
+ for value in ["", "off", "none", "disabled"] {
+ let toml = format!(
+ r#"
+[ui]
+right_click_passthrough_modifier = "{value}"
+"#
+ );
+ let config: Config = toml::from_str(&toml).unwrap();
+ assert_eq!(
+ config.ui.right_click_passthrough_modifiers(),
+ None,
+ "value {value:?} should disable passthrough"
+ );
+ }
+
+ for (value, expected) in [
+ ("ctrl", KeyModifiers::CONTROL),
+ ("control", KeyModifiers::CONTROL),
+ ("alt", KeyModifiers::ALT),
+ ("option", KeyModifiers::ALT),
+ ("cmd", KeyModifiers::SUPER),
+ ("command", KeyModifiers::SUPER),
+ ("super", KeyModifiers::SUPER),
+ ("meta", KeyModifiers::META),
+ ("hyper", KeyModifiers::HYPER),
+ ] {
+ let toml = format!(
+ r#"
+[ui]
+right_click_passthrough_modifier = "{value}"
+"#
+ );
+ let config: Config = toml::from_str(&toml).unwrap();
+ assert_eq!(
+ config.ui.right_click_passthrough_modifiers(),
+ Some(expected),
+ "value {value:?} should parse"
+ );
+ }
+
+ let toml = r#"
+[ui]
+right_click_passthrough_modifier = "cmd+alt"
+"#;
+ let config: Config = toml::from_str(toml).unwrap();
+ assert_eq!(
+ config.ui.right_click_passthrough_modifiers(),
+ Some(KeyModifiers::SUPER | KeyModifiers::ALT)
+ );
+ }
+
+ #[test]
+ fn right_click_passthrough_modifier_rejects_shift() {
+ for value in ["shift", "shift+ctrl", "ctrl+", "ctrl++alt", "banana"] {
+ let toml = format!(
+ r#"
+[ui]
+right_click_passthrough_modifier = "{value}"
+"#
+ );
+ assert!(
+ toml::from_str::<Config>(&toml).is_err(),
+ "value {value:?} should be rejected"
+ );
+ }
+ }
+
+ #[test]
fn redraw_on_focus_gained_default_on_and_parse() {
let default_config = Config::default();
assert!(default_config.ui.redraw_on_focus_gained);
diff --git a/src/main.rs b/src/main.rs
index 6493ae81..b317c2da 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -193,6 +193,10 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
# Pane apps like lazygit and btop can still receive mouse when they request it.
# mouse_capture = true
+# Optional modifier that forwards right-click hold/drag gestures to pane apps instead of opening Herdr's pane menu.
+# Empty/off disables this. Shift is intentionally unsupported because terminals commonly reserve Shift+mouse.
+# right_click_passthrough_modifier = ""
+
# Force a full redraw when the outer terminal regains focus.
# Set false to reduce visible flashing when switching back to Herdr.
# Trade-off: rare host terminal surface corruption may persist until the next full redraw.