diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index 4a0f5e9f..fad2404c 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -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) diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx index ade42b00..9f19d4fd 100644 --- a/docs/next/website/src/content/docs/configuration.mdx +++ b/docs/next/website/src/content/docs/configuration.mdx @@ -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 diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs index 9e54fce1..8d7fc35d 100644 --- a/src/app/input/mouse.rs +++ b/src/app/input/mouse.rs @@ -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(); diff --git a/src/app/input/selection.rs b/src/app/input/selection.rs index 06de30b5..0ccb5506 100644 --- a/src/app/input/selection.rs +++ b/src/app/input/selection.rs @@ -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, } diff --git a/src/app/mod.rs b/src/app/mod.rs index 6a94f116..107a4393 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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 = diff --git a/src/app/state.rs b/src/app/state.rs index 13958d03..b38afdb3 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -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, diff --git a/src/config.rs b/src/config.rs index b6406874..4eb29af0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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 { diff --git a/src/config/model.rs b/src/config/model.rs index e099d510..f270a69c 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -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, /// 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::(toml).is_err()); + } + #[test] fn toast_config_parses() { let toml = r#" diff --git a/src/main.rs b/src/main.rs index 5eb48d9f..59aac658 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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