diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-11 02:46:41 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-11 02:46:58 +0300 |
| commit | 46174563489273199a17c982356c6e4674ef00d4 (patch) | |
| tree | e87a16bdb34e45b66124732c3458eed1e64d91a0 /src | |
| parent | 3b8aeee166d1594e1f8e41631a51748104d76834 (diff) | |
feat: add copy-on-select setting
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/input/mod.rs | 5 | ||||
| -rw-r--r-- | src/app/input/mouse.rs | 14 | ||||
| -rw-r--r-- | src/app/input/terminal.rs | 81 | ||||
| -rw-r--r-- | src/app/mod.rs | 55 | ||||
| -rw-r--r-- | src/app/state.rs | 2 | ||||
| -rw-r--r-- | src/config/model.rs | 16 | ||||
| -rw-r--r-- | src/main.rs | 4 |
7 files changed, 165 insertions, 12 deletions
diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs index bcd5da98..421b3738 100644 --- a/src/app/input/mod.rs +++ b/src/app/input/mod.rs @@ -408,6 +408,11 @@ impl App { } fn handle_pane_double_click(&mut self, mouse: MouseEvent) -> bool { + if !self.state.copy_on_select { + self.last_pane_click = None; + return false; + } + // A pane press stops being a double-click candidate once it becomes // a drag or completes as a real text selection. match mouse.kind { diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs index 495bcd53..911e27c0 100644 --- a/src/app/input/mouse.rs +++ b/src/app/input/mouse.rs @@ -640,12 +640,14 @@ impl AppState { mouse.row - info.inner_rect.y, mouse.column - info.inner_rect.x, ); - self.selection = Some(Selection::anchor( - info.id, - row, - col, - self.pane_scroll_metrics(terminal_runtimes, info.id), - )); + if self.copy_on_select { + self.selection = Some(Selection::anchor( + info.id, + row, + col, + self.pane_scroll_metrics(terminal_runtimes, info.id), + )); + } if let Some(ws_idx) = self.active { return Some(MouseAction::FocusPane { ws_idx, diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs index 0fc35bd3..dd1e811f 100644 --- a/src/app/input/terminal.rs +++ b/src/app/input/terminal.rs @@ -459,6 +459,34 @@ mod tests { } #[tokio::test] + async fn copy_on_select_disabled_ignores_drag_and_double_click_selection() { + let (mut app, info) = app_with_screen_bytes(b"alpha beta"); + app.state.copy_on_select = false; + let row = info.inner_rect.y; + let start_col = info.inner_rect.x; + let end_col = info.inner_rect.x + 4; + + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + start_col, + row, + )); + app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), end_col, row)); + app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), end_col, row)); + + assert!(app.state.selection.is_none()); + assert!(app.state.selection_autoscroll.is_none()); + assert!(app.event_rx.try_recv().is_err()); + + double_click(&mut app, start_col, row); + + assert!(app.state.selection.is_none()); + assert!(app.last_pane_click.is_none()); + assert!(app.selection_highlight_clear_deadline.is_none()); + assert!(app.event_rx.try_recv().is_err()); + } + + #[tokio::test] async fn double_click_uses_display_columns_for_wide_chars() { let (mut app, info) = app_with_screen_bytes("echo 你好-world done".as_bytes()); let col = info.inner_rect.x + 8; @@ -730,15 +758,58 @@ mod tests { } #[tokio::test] - async fn double_click_is_forwarded_when_mouse_reporting_is_enabled() { - let (mut app, info) = app_with_screen_bytes(b"\x1b[?1002halpha beta"); - let col = info.inner_rect.x + 8; - let row = info.inner_rect.y; - double_click(&mut app, col, row); + async fn copy_on_select_disabled_still_forwards_mouse_reporting_gestures() { + 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.copy_on_select = false; + + let col = info.inner_rect.x + 2; + let row = info.inner_rect.y + 3; + app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), col, row)); + app.handle_mouse(mouse( + MouseEventKind::Drag(MouseButton::Left), + col + 1, + row + 1, + )); + app.handle_mouse(mouse( + MouseEventKind::Up(MouseButton::Left), + col + 1, + row + 1, + )); assert!(app.event_rx.try_recv().is_err()); assert!(app.state.selection.is_none()); assert!(app.selection_highlight_clear_deadline.is_none()); + assert_eq!( + input_rx.try_recv().expect("forwarded left mouse down"), + Bytes::from_static(b"\x1b[<0;3;4M") + ); + assert_eq!( + input_rx.try_recv().expect("forwarded left mouse drag"), + Bytes::from_static(b"\x1b[<32;4;5M") + ); + assert_eq!( + input_rx.try_recv().expect("forwarded left mouse up"), + Bytes::from_static(b"\x1b[<0;4;5m") + ); + assert!(input_rx.try_recv().is_err()); } #[tokio::test] diff --git a/src/app/mod.rs b/src/app/mod.rs index 99181361..1a0c77a3 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -600,6 +600,7 @@ impl App { agent_panel_sort, next_agent_state_change_seq: 0, mouse_capture: config.ui.mouse_capture, + copy_on_select: config.ui.copy_on_select, right_click_passthrough_modifiers: config.ui.right_click_passthrough_modifiers(), right_click_passthrough: None, redraw_on_focus_gained: config.ui.redraw_on_focus_gained, @@ -1358,6 +1359,17 @@ 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.copy_on_select = config.ui.copy_on_select; + if !self.state.copy_on_select { + if self.state.mode == Mode::Copy { + self.state.stop_selection_autoscroll_state(); + } else { + self.state.clear_selection(); + } + self.last_pane_click = None; + self.selection_autoscroll_deadline = None; + self.selection_highlight_clear_deadline = None; + } if self.state.redraw_on_focus_gained != config.ui.redraw_on_focus_gained { self.state.request_client_config_reload = true; } @@ -2524,12 +2536,35 @@ 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[update]\nversion_check = false\nmanifest_check = false\n[ui]\nagent_panel_scope = \"current\"\nagent_panel_sort = \"priority\"\nredraw_on_focus_gained = false\nright_click_passthrough_modifier = \"ctrl\"\n[ui.toast]\ndelivery = \"herdr\"\n[experimental]\nswitch_ascii_input_source_in_prefix = true\n", + "[terminal]\ndefault_shell = \"nu\"\nshell_mode = \"non_login\"\nnew_cwd = \"home\"\n[keys]\nnew_workspace = \"prefix+m\"\nprefix = \"ctrl+a\"\n[update]\nversion_check = false\nmanifest_check = false\n[ui]\nagent_panel_scope = \"current\"\nagent_panel_sort = \"priority\"\nredraw_on_focus_gained = false\ncopy_on_select = false\nright_click_passthrough_modifier = \"ctrl\"\n[ui.toast]\ndelivery = \"herdr\"\n[experimental]\nswitch_ascii_input_source_in_prefix = true\n", ) .unwrap(); std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); let mut app = test_app(); + let selection_pane = crate::layout::PaneId::alloc(); + app.state.selection = Some(crate::selection::Selection::range( + selection_pane, + 0, + 0, + 1, + None, + )); + app.state.selection_autoscroll = Some(state::SelectionAutoscroll { + direction: state::SelectionAutoscrollDirection::Down, + last_mouse_screen_col: 1, + last_mouse_screen_row: 1, + inner_rect: ratatui::layout::Rect::new(0, 0, 2, 2), + }); + let selection_deadline = Instant::now(); + app.selection_autoscroll_deadline = Some(selection_deadline); + app.selection_highlight_clear_deadline = Some(selection_deadline); + app.last_pane_click = Some(PaneClickState { + pane_id: selection_pane, + viewport_row: 0, + col: 0, + at: selection_deadline, + }); app.next_auto_update_check = Some(Instant::now()); app.next_agent_manifest_update_check = Some(Instant::now()); let report = app.reload_config(); @@ -2548,6 +2583,24 @@ mod tests { ); assert_eq!(app.state.agent_panel_sort, state::AgentPanelSort::Priority); assert!(!app.state.redraw_on_focus_gained); + assert!(!app.state.copy_on_select); + assert!(app.state.selection.is_none()); + assert!(app.state.selection_autoscroll.is_none()); + assert!(app.selection_autoscroll_deadline.is_none()); + assert!(app.selection_highlight_clear_deadline.is_none()); + assert!(app.last_pane_click.is_none()); + + app.state.mode = Mode::Copy; + app.state.selection = Some(crate::selection::Selection::range( + selection_pane, + 0, + 0, + 1, + None, + )); + let report = app.reload_config(); + assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); + assert!(app.state.selection.is_some()); assert_eq!( app.state.right_click_passthrough_modifiers, Some(KeyModifiers::CONTROL) diff --git a/src/app/state.rs b/src/app/state.rs index 338c9ca3..be858678 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -1407,6 +1407,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 copy_on_select: bool, pub right_click_passthrough_modifiers: Option<KeyModifiers>, pub right_click_passthrough: Option<RightClickPassthroughGesture>, pub redraw_on_focus_gained: bool, @@ -1765,6 +1766,7 @@ impl AppState { agent_panel_sort: AgentPanelSort::Spaces, next_agent_state_change_seq: 0, mouse_capture: true, + copy_on_select: true, right_click_passthrough_modifiers: None, right_click_passthrough: None, redraw_on_focus_gained: true, diff --git a/src/config/model.rs b/src/config/model.rs index c45f1b7a..16bc595d 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -786,6 +786,8 @@ pub struct UiConfig { pub mobile_width_threshold: u16, /// Capture mouse input for Herdr's mouse UI. Default: true. pub mouse_capture: bool, + /// Copy text selected with the mouse. Default: true. + pub copy_on_select: bool, /// Host cursor policy. Default: auto. pub host_cursor: HostCursorModeConfig, /// Modifier that lets right-click gestures pass through to pane apps. Empty disables it. @@ -989,6 +991,7 @@ impl Default for UiConfig { sidebar_collapsed_mode: SidebarCollapsedModeConfig::Compact, mobile_width_threshold: DEFAULT_MOBILE_WIDTH_THRESHOLD, mouse_capture: true, + copy_on_select: true, host_cursor: HostCursorModeConfig::Auto, right_click_passthrough_modifier: RightClickPassthroughModifierConfig::default(), redraw_on_focus_gained: true, @@ -1399,6 +1402,19 @@ mouse_capture = false } #[test] + fn copy_on_select_default_on_and_parse() { + let default_config = Config::default(); + assert!(default_config.ui.copy_on_select); + + let toml = r#" +[ui] +copy_on_select = false +"#; + let config: Config = toml::from_str(toml).unwrap(); + assert!(!config.ui.copy_on_select); + } + + #[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); diff --git a/src/main.rs b/src/main.rs index a439ad34..57ca4dc2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -259,6 +259,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 +# Copy text selected by mouse drag or double-click. +# Set false to disable mouse text selection and copying. +# copy_on_select = true + # Host cursor policy: "auto", "native", or "drawn". # "auto" draws Herdr's own cursor on native Windows builds and WSL to avoid ConPTY cursor flicker, and uses the native terminal cursor elsewhere. # "native" always uses the outer terminal cursor. "drawn" always draws Herdr's cursor as terminal cell content. |
