feat: add configurable mouse scroll lines

refs #236
This commit is contained in:
Ogulcan Celik 2026-05-22 00:12:53 +03:00
parent 659badf106
commit a9ff4012bd
9 changed files with 102 additions and 12 deletions

View File

@ -3,6 +3,7 @@
## Unreleased
### Added
- Added `ui.mouse_scroll_lines` to configure how many pane scrollback lines each mouse wheel notch scrolls. The default remains 3. (#236)
- Added `--remote-keybindings local|server` for `herdr --remote`. Remote attach now uses the launching client's local keybindings by default without copying config files to the remote host; use `--remote-keybindings server` to keep the remote server's keybindings. The client/server protocol is now version 9.
- Added `experimental.reveal_hidden_cursor_for_cjk_ime = false` (opt-in), `experimental.cjk_ime_agents = []` (optional allow-list), and `experimental.cjk_ime_cursor_shape = "steady_block"` to expose the focused pane's cursor anchor to the outer terminal even when the pane requested `?25l`, restoring macOS IME candidate-window tracking for TUIs that paint their own cursor (Claude Code, pi, codex). When `cjk_ime_agents` is non-empty, the reveal applies only to focused panes whose detected agent matches one of the listed names. When the pane reports no cursor position, the anchor falls back to the pane's top-left so a stable IME hint is always available. Trade-off when enabled: an extra hardware cursor may appear in the outer terminal for apps that hide the cursor without painting a replacement. (#149, thanks @ChihGodlee)

View File

@ -183,6 +183,7 @@ sidebar_width = 32
sidebar_min_width = 18
sidebar_max_width = 36
mouse_capture = true
mouse_scroll_lines = 3
confirm_close = true
prompt_new_tab_name = true
show_agent_labels_on_pane_borders = false
@ -198,6 +199,8 @@ accent = "cyan"
Set `mouse_capture = false` if you want your terminal to handle normal clicks, such as command-clicking URLs.
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.
Set `show_agent_labels_on_pane_borders = true` if you want detected agent labels in split pane borders when no manual pane label is set.
## Notifications

View File

@ -1133,7 +1133,7 @@ impl AppState {
terminal_runtimes: &TerminalRuntimeRegistry,
mouse: MouseEvent,
) {
const LINES_PER_NOTCH: usize = 3;
let lines_per_notch = self.mouse_scroll_lines;
if let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() {
self.focus_pane(info.id);
@ -1142,10 +1142,10 @@ impl AppState {
}
match mouse.kind {
MouseEventKind::ScrollUp => {
self.scroll_pane_up(terminal_runtimes, info.id, LINES_PER_NOTCH)
self.scroll_pane_up(terminal_runtimes, info.id, lines_per_notch)
}
MouseEventKind::ScrollDown => {
self.scroll_pane_down(terminal_runtimes, info.id, LINES_PER_NOTCH)
self.scroll_pane_down(terminal_runtimes, info.id, lines_per_notch)
}
_ => {}
}
@ -1156,10 +1156,10 @@ impl AppState {
self.focus_pane(info.id);
match mouse.kind {
MouseEventKind::ScrollUp => {
self.scroll_pane_up(terminal_runtimes, info.id, LINES_PER_NOTCH)
self.scroll_pane_up(terminal_runtimes, info.id, lines_per_notch)
}
MouseEventKind::ScrollDown => {
self.scroll_pane_down(terminal_runtimes, info.id, LINES_PER_NOTCH)
self.scroll_pane_down(terminal_runtimes, info.id, lines_per_notch)
}
_ => {}
}
@ -1169,8 +1169,8 @@ impl AppState {
if let Some(ws_idx) = self.active {
if let Some(rt) = self.focused_runtime_in_workspace(terminal_runtimes, ws_idx) {
match mouse.kind {
MouseEventKind::ScrollUp => rt.scroll_up(LINES_PER_NOTCH),
MouseEventKind::ScrollDown => rt.scroll_down(LINES_PER_NOTCH),
MouseEventKind::ScrollUp => rt.scroll_up(lines_per_notch),
MouseEventKind::ScrollDown => rt.scroll_down(lines_per_notch),
_ => {}
}
}
@ -1385,7 +1385,8 @@ mod tests {
use ratatui::layout::{Direction, Rect};
use super::super::{
app_for_mouse_test, capture_snapshot, handle_context_menu_key, mouse, root_layout_ratio,
app_for_mouse_test, capture_snapshot, handle_context_menu_key, mouse, numbered_lines_bytes,
root_layout_ratio,
};
use super::*;
use crate::{
@ -1394,6 +1395,44 @@ mod tests {
workspace::Workspace,
};
#[tokio::test]
async fn terminal_wheel_uses_configured_mouse_scroll_lines() {
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();
ws.tabs[0].runtimes.insert(
pane_id,
crate::terminal::TerminalRuntime::test_with_scrollback_bytes(
info.inner_rect.width,
info.inner_rect.height,
16 * 1024,
&numbered_lines_bytes(64),
),
);
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.mouse_scroll_lines = 7;
app.handle_mouse(mouse(
MouseEventKind::ScrollUp,
info.inner_rect.x + 1,
info.inner_rect.y + 1,
));
let metrics = app
.state
.runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id)
.and_then(crate::terminal::TerminalRuntime::scroll_metrics)
.expect("scroll metrics after wheel");
assert_eq!(metrics.offset_from_bottom, 7);
}
#[test]
fn hovering_context_menu_updates_highlight() {
let mut app = app_for_mouse_test();

View File

@ -144,7 +144,7 @@ impl AppState {
terminal_runtimes: &TerminalRuntimeRegistry,
mouse: MouseEvent,
) -> bool {
const LINES_PER_NOTCH: usize = 3;
let lines_per_notch = self.mouse_scroll_lines;
let Some(selection) = self.selection.as_ref() else {
return false;
@ -156,10 +156,10 @@ impl AppState {
self.focus_pane(pane_id);
match mouse.kind {
MouseEventKind::ScrollUp => {
self.scroll_pane_up(terminal_runtimes, pane_id, LINES_PER_NOTCH)
self.scroll_pane_up(terminal_runtimes, pane_id, lines_per_notch)
}
MouseEventKind::ScrollDown => {
self.scroll_pane_down(terminal_runtimes, pane_id, LINES_PER_NOTCH)
self.scroll_pane_down(terminal_runtimes, pane_id, lines_per_notch)
}
_ => return false,
}

View File

@ -424,6 +424,7 @@ impl App {
sidebar_section_split,
agent_panel_scope,
mouse_capture: config.ui.mouse_capture,
mouse_scroll_lines: config.ui.mouse_scroll_lines(),
confirm_close: config.ui.confirm_close,
prompt_new_tab_name: config.ui.prompt_new_tab_name,
show_agent_labels_on_pane_borders: config.ui.show_agent_labels_on_pane_borders,
@ -903,6 +904,7 @@ impl App {
.sidebar_width
.clamp(self.state.sidebar_min_width, self.state.sidebar_max_width);
self.state.mouse_capture = config.ui.mouse_capture;
self.state.mouse_scroll_lines = config.ui.mouse_scroll_lines();
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 =

View File

@ -960,6 +960,7 @@ 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 mouse_scroll_lines: usize,
pub confirm_close: bool,
pub prompt_new_tab_name: bool,
pub show_agent_labels_on_pane_borders: bool,
@ -1239,6 +1240,7 @@ impl AppState {
sidebar_section_split: 0.5,
agent_panel_scope: AgentPanelScope::AllWorkspaces,
mouse_capture: true,
mouse_scroll_lines: crate::config::DEFAULT_MOUSE_SCROLL_LINES,
confirm_close: true,
prompt_new_tab_name: true,
show_agent_labels_on_pane_borders: false,

View File

@ -29,6 +29,7 @@ pub(crate) use self::io::upsert_top_level_bool;
pub const CONFIG_PATH_ENV_VAR: &str = "HERDR_CONFIG_PATH";
pub const DEFAULT_SCROLLBACK_LIMIT_BYTES: usize = 10_000_000;
pub const DEFAULT_MOUSE_SCROLL_LINES: usize = 3;
#[cfg(test)]
pub(crate) fn app_dir_name() -> &'static str {

View File

@ -1,7 +1,10 @@
use std::num::NonZeroUsize;
use serde::{Deserialize, Deserializer, Serialize};
use super::{
BindingConfig, CommandKeybindConfig, SoundConfig, ThemeConfig, DEFAULT_SCROLLBACK_LIMIT_BYTES,
BindingConfig, CommandKeybindConfig, SoundConfig, ThemeConfig, DEFAULT_MOUSE_SCROLL_LINES,
DEFAULT_SCROLLBACK_LIMIT_BYTES,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)]
@ -193,6 +196,8 @@ pub struct UiConfig {
pub sidebar_max_width: u16,
/// Capture mouse input for Herdr's mouse UI. Default: true.
pub mouse_capture: bool,
/// Lines to scroll per mouse wheel notch. Default: 3.
pub mouse_scroll_lines: Option<NonZeroUsize>,
/// Ask for confirmation before closing a workspace. Default: true.
pub confirm_close: bool,
/// Ask for a tab name before creating a new tab. Default: true.
@ -328,6 +333,7 @@ impl Default for UiConfig {
sidebar_min_width: 18,
sidebar_max_width: 36,
mouse_capture: true,
mouse_scroll_lines: None,
confirm_close: true,
prompt_new_tab_name: true,
show_agent_labels_on_pane_borders: false,
@ -339,6 +345,14 @@ impl Default for UiConfig {
}
}
impl UiConfig {
pub fn mouse_scroll_lines(&self) -> usize {
self.mouse_scroll_lines
.map(NonZeroUsize::get)
.unwrap_or(DEFAULT_MOUSE_SCROLL_LINES)
}
}
impl Default for ToastConfig {
fn default() -> Self {
Self {
@ -516,6 +530,31 @@ mouse_capture = false
assert!(!config.ui.mouse_capture);
}
#[test]
fn mouse_scroll_lines_defaults_to_three_and_parses() {
let default_config = Config::default();
assert_eq!(
default_config.ui.mouse_scroll_lines(),
DEFAULT_MOUSE_SCROLL_LINES
);
let toml = r#"
[ui]
mouse_scroll_lines = 1
"#;
let config: Config = toml::from_str(toml).unwrap();
assert_eq!(config.ui.mouse_scroll_lines(), 1);
}
#[test]
fn mouse_scroll_lines_rejects_zero() {
let toml = r#"
[ui]
mouse_scroll_lines = 0
"#;
assert!(toml::from_str::<Config>(toml).is_err());
}
#[test]
fn toast_config_parses() {
let toml = r#"

View File

@ -159,6 +159,9 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
# Pane apps like lazygit and btop can still receive mouse when they request it.
# mouse_capture = true
# Pane scrollback lines to scroll per mouse wheel notch.
# mouse_scroll_lines = 3
# Ask for confirmation before closing a workspace
# confirm_close = true