From bf780d3f36e6df1543fed5b0bbc43fd83e3b3a90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Ram=C3=ADrez=20Norambuena?= Date: Thu, 16 Jul 2026 17:51:08 -0400 Subject: [PATCH] =?UTF-8?q?feat(input):=20smart=20Ctrl+C=20=E2=80=94=20cop?= =?UTF-8?q?y=20selection=20or=20send=20SIGINT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ctrl+C previously mapped to Action::Copy for any Visual mode, including un-anchored navigation where do_visual_copy no-ops, swallowing the key. Now Ctrl+C copies only when there is a real (anchored) selection and sends SIGINT (0x03) in every other case (Insert, Normal, un-anchored Visual). --- CHANGELOG.md | 1 + README.md | 2 +- doc/SPEC.md | 2 +- src/input/keybindings.rs | 14 ++++++++-- src/input/keybindings_test.rs | 49 +++++++++++++++++++++++++++++++++-- 5 files changed, 62 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a35216a..d8dfcc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed - input mode (normal/insert/visual/search) is now tracked per tab; switching tabs restores each tab's own mode +- Ctrl+C now copies only when there is an active selection, otherwise it sends SIGINT ## [0.9.0] - 2026-07-05 diff --git a/README.md b/README.md index c55a473..58440e4 100644 --- a/README.md +++ b/README.md @@ -326,7 +326,7 @@ Navigate freely to position the cursor, press `v` to set the selection anchor, t | `g` / `G` | Top / bottom of viewport | | `v` | Set selection anchor at cursor (starts highlighting) | | `o` | Swap anchor and cursor | -| `y` / `Ctrl+C` | Copy selection and exit | +| `y` / `Ctrl+C` | Copy selection and exit (`Ctrl+C` copies only with an active selection; otherwise it sends SIGINT) | | `Y` | Yank (copy) the entire line at cursor | | `q` / `Escape` | Exit to Insert mode | diff --git a/doc/SPEC.md b/doc/SPEC.md index e3f10a6..adca21d 100644 --- a/doc/SPEC.md +++ b/doc/SPEC.md @@ -563,7 +563,7 @@ actions shift the anchor coordinates so the selected content stays stable. | `g` / `G` | Top / bottom of viewport | | `v` | Set selection anchor at cursor (activates highlight) | | `o` | Swap anchor and cursor (extend from either end) | -| `y` / `Ctrl+C` | Copy selection to clipboard, return to Insert mode | +| `y` / `Ctrl+C` | Copy selection to clipboard, return to Insert mode (`Ctrl+C` copies only with an active selection; otherwise it sends SIGINT) | | `Y` | Yank (copy) the entire line at the cursor, return to Insert mode | | `q` / `Escape` | Exit to Insert mode | diff --git a/src/input/keybindings.rs b/src/input/keybindings.rs index 5bb1dd3..a2fc01e 100644 --- a/src/input/keybindings.rs +++ b/src/input/keybindings.rs @@ -246,6 +246,16 @@ fn shift_scroll_action(key: &Key, grid_rows: usize) -> Option { } } +/// Ctrl+C copies only when there is a real (anchored) selection; in every +/// other mode (Insert, Normal, un-anchored Visual) it sends SIGINT (0x03). +fn ctrl_c_action(mode: &InputMode) -> Action { + if matches!(mode, InputMode::Visual { anchored: true, .. }) { + Action::Copy + } else { + Action::SendToPty(vec![3]) + } +} + fn handle_ctrl_only(key: &Key, alt: bool, mode: &InputMode) -> Option { if let Key::Character(s) = key { if s.eq_ignore_ascii_case("w") { @@ -254,8 +264,8 @@ fn handle_ctrl_only(key: &Key, alt: bool, mode: &InputMode) -> Option { if let Some(a) = ctrl_special_char_action(s, mode) { return Some(a); } - if s.eq_ignore_ascii_case("c") && matches!(mode, InputMode::Visual { .. }) { - return Some(Action::Copy); + if s.eq_ignore_ascii_case("c") { + return Some(ctrl_c_action(mode)); } } ctrl_char_action(key, alt) diff --git a/src/input/keybindings_test.rs b/src/input/keybindings_test.rs index cd48e61..c1dd58f 100644 --- a/src/input/keybindings_test.rs +++ b/src/input/keybindings_test.rs @@ -328,12 +328,39 @@ fn ctrl_shift_end_scrolls_to_bottom() { assert!(matches!(a, Action::ScrollToBottom)); } +fn visual_anchored() -> InputMode { + InputMode::Visual { + start_col: 0, + start_row: 0, + cur_col: 0, + cur_row: 0, + anchored: true, + } +} + #[test] -fn ctrl_c_in_visual_copies() { - let a = handle_key_inner(&char_key("c"), true, false, false, &visual(), 80, 24, false); +fn ctrl_c_in_anchored_visual_copies() { + let a = handle_key_inner( + &char_key("c"), + true, + false, + false, + &visual_anchored(), + 80, + 24, + false, + ); assert!(matches!(a, Action::Copy)); } +#[test] +fn ctrl_c_in_unanchored_visual_sends_sigint() { + // Un-anchored Visual has no highlighted selection; ctrl+c must send SIGINT, + // not a no-op Copy. + let a = handle_key_inner(&char_key("c"), true, false, false, &visual(), 80, 24, false); + assert!(matches!(a, Action::SendToPty(ref v) if v == &[3])); +} + #[test] fn ctrl_c_in_insert_does_not_copy() { // In insert mode ctrl+c is sent as byte 0x03 to the PTY @@ -341,6 +368,24 @@ fn ctrl_c_in_insert_does_not_copy() { assert!(matches!(a, Action::SendToPty(ref v) if v == &[3])); } +#[test] +fn ctrl_c_in_normal_sends_sigint() { + let a = handle_key_inner(&char_key("c"), true, false, false, &normal(), 80, 24, false); + assert!(matches!(a, Action::SendToPty(ref v) if v == &[3])); +} + +#[test] +fn ctrl_shift_c_in_insert_does_not_copy() { + let a = handle_key_inner(&char_key("c"), true, true, false, &insert(), 80, 24, false); + assert!(!matches!(a, Action::Copy)); +} + +#[test] +fn ctrl_alt_c_in_insert_does_not_copy() { + let a = handle_key_inner(&char_key("c"), true, false, true, &insert(), 80, 24, false); + assert!(!matches!(a, Action::Copy)); +} + #[test] fn ctrl_q_quits() { let a = handle_key_inner(&char_key("q"), true, false, false, &insert(), 80, 24, false);