aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-07-11 02:46:41 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-07-11 02:46:58 +0300
commit46174563489273199a17c982356c6e4674ef00d4 (patch)
treee87a16bdb34e45b66124732c3458eed1e64d91a0 /src
parent3b8aeee166d1594e1f8e41631a51748104d76834 (diff)
feat: add copy-on-select setting
Diffstat (limited to 'src')
-rw-r--r--src/app/input/mod.rs5
-rw-r--r--src/app/input/mouse.rs14
-rw-r--r--src/app/input/terminal.rs81
-rw-r--r--src/app/mod.rs55
-rw-r--r--src/app/state.rs2
-rw-r--r--src/config/model.rs16
-rw-r--r--src/main.rs4
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.