parent
3ce4212e5e
commit
2fcf9b678d
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
@ -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::<Config>(&toml).is_err(),
|
||||
"value {value:?} should be rejected"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redraw_on_focus_gained_default_on_and_parse() {
|
||||
let default_config = Config::default();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue