From 2fcf9b678d0f154b2d41aa5fafa25aaa73ec7f17 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Mon, 1 Jun 2026 03:56:48 +0300 Subject: [PATCH] feat: add configurable right-click passthrough refs #148 --- docs/next/CHANGELOG.md | 3 + .../src/content/docs/configuration.mdx | 3 + .../website/src/content/docs/quick-start.mdx | 2 + src/app/input/modal.rs | 9 +- src/app/input/mouse.rs | 271 +++++++++++++++++- src/app/mod.rs | 10 +- src/app/state.rs | 10 + src/config/model.rs | 135 ++++++++- src/main.rs | 4 + 9 files changed, 442 insertions(+), 5 deletions(-) diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index aba66c98..13612ccf 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +### Added +- Added `ui.right_click_passthrough_modifier` so a configured modifier such as `ctrl` can forward right-click hold and drag gestures to mouse-reporting pane apps while normal right-click still opens Herdr's pane menu. (#148) + ## [0.6.6] - 2026-05-31 ### Added diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx index 189e2121..e1a5b8fa 100644 --- a/docs/next/website/src/content/docs/configuration.mdx +++ b/docs/next/website/src/content/docs/configuration.mdx @@ -242,6 +242,7 @@ sidebar_min_width = 18 sidebar_max_width = 36 mobile_width_threshold = 64 mouse_capture = true +right_click_passthrough_modifier = "" redraw_on_focus_gained = true mouse_scroll_lines = 3 confirm_close = true @@ -261,6 +262,8 @@ accent = "cyan" Set `mouse_capture = false` if you want your terminal to handle normal clicks, such as command-clicking URLs. With mouse capture enabled, Ctrl-click opens pane links when your terminal sends that modified click to Herdr; use Shift-Ctrl-click on Linux or Shift-Cmd-click on macOS for the terminal-native bypass path. +Set `right_click_passthrough_modifier = "ctrl"` if you want Ctrl-right-click, hold, and drag gestures inside mouse-reporting pane apps to reach the app instead of opening Herdr's pane menu. The default is empty, which disables this passthrough. Supported modifiers are `ctrl`, `alt`, `cmd`, `super`, `meta`, and `hyper`; `shift` is rejected because many terminals reserve Shift+mouse for their own mouse bypass. + Set `redraw_on_focus_gained = false` to avoid the visible full-screen refresh when switching back to Herdr. The default is `true` because a full redraw recovers from rare stale or dirty host terminal surfaces. Set `mouse_scroll_lines` to change how many pane scrollback lines each mouse wheel notch scrolls. The default is 3. Pane apps that request mouse reporting still receive wheel events directly. diff --git a/docs/next/website/src/content/docs/quick-start.mdx b/docs/next/website/src/content/docs/quick-start.mdx index b6b1728b..80563260 100644 --- a/docs/next/website/src/content/docs/quick-start.mdx +++ b/docs/next/website/src/content/docs/quick-start.mdx @@ -51,6 +51,8 @@ After detaching, run `herdr` again to reattach to the same session. Herdr is mouse-native. You can click panes, tabs, workspaces, and agents; drag borders; drag-select text to copy it to your clipboard; double-click a token to copy it directly; and use right-click menus. Copying does not require Ctrl+C. +If you configure `ui.right_click_passthrough_modifier`, that modifier plus right-click sends right-click, hold, and drag gestures to mouse-reporting pane apps. + Ctrl-click opens pane links when your terminal sends the modified click to Herdr. This works for OSC 8 hyperlinks and visible `http://` or `https://` URLs. The portable terminal-native fallback is Shift-Ctrl-click on Linux or Shift-Cmd-click on macOS. ## Copy from the keyboard 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, + pub right_click_passthrough: Option, 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); + +impl RightClickPassthroughModifierConfig { + pub fn modifiers(self) -> Option { + self.0 + } +} + +impl<'de> Deserialize<'de> for RightClickPassthroughModifierConfig { + fn deserialize(deserializer: D) -> Result + 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> { + 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 { + self.right_click_passthrough_modifier.modifiers() + } } impl Default for ToastConfig { @@ -701,6 +762,78 @@ mouse_capture = false assert!(!config.ui.mouse_capture); } + #[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::(&toml).is_err(), + "value {value:?} should be rejected" + ); + } + } + #[test] fn redraw_on_focus_gained_default_on_and_parse() { let default_config = Config::default(); 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.