diff --git a/docs/reference/keyboard-shortcuts.mdx b/docs/reference/keyboard-shortcuts.mdx
index be91bb37..65071485 100644
--- a/docs/reference/keyboard-shortcuts.mdx
+++ b/docs/reference/keyboard-shortcuts.mdx
@@ -58,6 +58,26 @@ Inside a full-screen application — vim, less, tmux — it is passed through as
the key, so it means what the application says it means (blockwise Visual mode
in vim); paste there with Ctrl ⇧ V or ⇧ Insert.
+That convenience is the **Paste (outside full-screen apps)** binding, and it is
+yours to move. Clearing it hands Ctrl V to the shell everywhere,
+including readline's `quoted-insert`:
+
+```json
+{ "keybindings": { "AlternatePaste": "" } }
+```
+
+Putting Paste itself on the chord goes the other way, pasting on every screen
+the way Windows Terminal does out of the box:
+
+```json
+{ "keybindings": { "PasteText": "ctrl-v" } }
+```
+
+Every other Ctrl chord reaches the program you are running, control
+codes included: Ctrl 6 and Ctrl ⇧ 6 are `^^` (vim's
+alternate file), Ctrl / and Ctrl ⇧ − are `^_` (readline's
+undo), Ctrl 3 is Escape.
+
## Git and SSH
| Action | macOS | Windows / Linux |
diff --git a/src/terminal/input.rs b/src/terminal/input.rs
index b5473dd1..0fe392ac 100644
--- a/src/terminal/input.rs
+++ b/src/terminal/input.rs
@@ -239,45 +239,45 @@ fn associated_text(ks: &gpui::Keystroke) -> Option> {
(!cps.is_empty()).then_some(cps)
}
+/// The C0 control byte a `Ctrl+` chord stands for, or `None` when the
+/// chord is not a control code at all.
+///
+/// The letters fold onto `0x01..=0x1A` — `Ctrl+A` is 1, `Ctrl+Z` is 26 — which
+/// is the whole alphabet in one line instead of twenty-six. The rest is the
+/// VT-220 table (chapter 3.2.5): the digits 2..8, and beside each the
+/// punctuation that shares its key, because `Ctrl+^` is typed as Ctrl+Shift+6
+/// and every platform hands that over as `^` with the Shift already spent.
+///
+/// `Ctrl+/` is not in that table. xterm and every terminal since encode it as
+/// US and editors bind against it — vim's `` — but unlike `Ctrl+[` and
+/// its neighbours no keyboard layer folds it into a control byte for us, so it
+/// has to be spelled out here. `Ctrl+-` is deliberately absent: off macOS that
+/// is Decrease Font Size, and the chord this table owes readline's undo is
+/// `Ctrl+_`, which arrives as `_`.
+fn ctrl_c0(key: &str) -> Option {
+ if let [b] = key.as_bytes()
+ && b.is_ascii_alphabetic()
+ {
+ return Some(b.to_ascii_uppercase() & 0x1f);
+ }
+ Some(match key {
+ "space" | "2" | "@" => 0x00,
+ "3" | "[" => 0x1b,
+ "4" | "\\" => 0x1c,
+ "5" | "]" => 0x1d,
+ "6" | "^" => 0x1e,
+ "7" | "_" | "/" => 0x1f,
+ "8" | "?" => 0x7f,
+ _ => return None,
+ })
+}
+
fn legacy_keystroke_to_bytes(ks: &gpui::Keystroke, flags: KeyFlags) -> Option> {
let m = &ks.modifiers;
let key = ks.key.as_str();
if m.control && !m.platform {
- let b = match key {
- "space" | "2" => Some(0x00),
- "a" => Some(0x01),
- "b" => Some(0x02),
- "c" => Some(0x03),
- "d" => Some(0x04),
- "e" => Some(0x05),
- "f" => Some(0x06),
- "g" => Some(0x07),
- "h" => Some(0x08),
- "i" => Some(0x09),
- "j" => Some(0x0a),
- "k" => Some(0x0b),
- "l" => Some(0x0c),
- "m" => Some(0x0d),
- "n" => Some(0x0e),
- "o" => Some(0x0f),
- "p" => Some(0x10),
- "q" => Some(0x11),
- "r" => Some(0x12),
- "s" => Some(0x13),
- "t" => Some(0x14),
- "u" => Some(0x15),
- "v" => Some(0x16),
- "w" => Some(0x17),
- "x" => Some(0x18),
- "y" => Some(0x19),
- "z" => Some(0x1a),
- "[" => Some(0x1b),
- "\\" => Some(0x1c),
- "]" => Some(0x1d),
- _ => None,
- };
- if let Some(b) = b {
+ if let Some(b) = ctrl_c0(key) {
if m.alt {
return Some(vec![0x1b, b]);
}
@@ -610,6 +610,42 @@ mod tests {
assert_eq!(legacy(&ks(ctrl, "z", None)), Some(vec![0x1a]));
}
+ #[test]
+ fn keystroke_to_bytes_maps_the_whole_vt220_control_table() {
+ let ctrl = Modifiers {
+ control: true,
+ ..Default::default()
+ };
+ // The VT-220 table, each digit next to the punctuation that shares its
+ // key: whichever of the two the platform reports, the byte is the same.
+ let cases: &[(&str, u8)] = &[
+ ("2", 0x00),
+ ("@", 0x00),
+ ("3", 0x1b),
+ ("4", 0x1c),
+ ("5", 0x1d),
+ ("6", 0x1e),
+ // vim's `Ctrl-^`, the whole reason the digits are here.
+ ("^", 0x1e),
+ ("7", 0x1f),
+ ("_", 0x1f),
+ // readline's undo; xterm's addition to the table, not VT-220's.
+ ("/", 0x1f),
+ ("8", 0x7f),
+ ("?", 0x7f),
+ ];
+ for (key, byte) in cases {
+ assert_eq!(
+ legacy(&ks(ctrl, key, None)),
+ Some(vec![*byte]),
+ "ctrl-{key}"
+ );
+ }
+ // Decrease Font Size owns Ctrl+- off macOS, and readline is served by
+ // Ctrl+_ above, so the bare minus stays out of the table.
+ assert_eq!(legacy(&ks(ctrl, "-", None)), None);
+ }
+
#[test]
fn keystroke_to_bytes_ctrl_plus_cmd_is_not_a_c0_byte() {
let ctrl_cmd = Modifiers {
diff --git a/src/terminal/view.rs b/src/terminal/view.rs
index 53fd74b6..72fe8f1c 100644
--- a/src/terminal/view.rs
+++ b/src/terminal/view.rs
@@ -88,6 +88,7 @@ actions!(
CopyText,
CutText,
PasteText,
+ AlternatePaste,
SelectAll,
UndoEdit,
RedoEdit,
@@ -1844,14 +1845,20 @@ impl TerminalView {
}
}
- // Ctrl+Shift+C/V/X are the keymap's alone: rebinding Paste has to
- // retire Ctrl+Shift+V, which it cannot if this path answers it too.
+ // Ctrl+V is not here: off macOS it is the `AlternatePaste` binding,
+ // which the keymap withholds on the alternate screen so a full-screen
+ // program gets its SYN, and which the user can retire outright. Copy
+ // and cut stay, because both answer a selection this view owns and
+ // fall through to the PTY when there is none.
+ //
+ // Ctrl+Shift+C/X are the keymap's alone: rebinding Copy has to retire
+ // Ctrl+Shift+C, which it cannot if this path answers it too.
if cfg!(not(target_os = "macos"))
&& m.control
&& !m.platform
&& !m.alt
&& !m.shift
- && matches!(ks.key.as_str(), "c" | "v" | "x")
+ && matches!(ks.key.as_str(), "c" | "x")
{
match self.handle_cmd_shortcut(ks, window, cx) {
CmdKey::Consumed => {
@@ -1862,18 +1869,6 @@ impl TerminalView {
}
}
- if cfg!(not(target_os = "macos"))
- && m.control
- && !m.platform
- && !m.alt
- && matches!(
- ks.key.as_str(),
- "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"
- )
- {
- return;
- }
-
if !self.accepts_input(cx) {
return;
}
@@ -1985,17 +1980,12 @@ impl TerminalView {
CmdKey::FallThrough
}
}
+ // Cmd+V only: macOS leaves `PasteText` unbound and pastes from
+ // here, and Cmd carries no control code to lose. The Ctrl+V half
+ // of this key lives in the keymap as `AlternatePaste`.
"v" => {
- // Off macOS Ctrl+V is a control code first: on the alternate
- // screen the key is the program's (vim's blockwise select), the
- // way Ctrl+C is SIGINT when there is nothing to copy. Cmd+V
- // pastes anywhere.
- if m.control && !m.platform && self.on_alt_screen() {
- CmdKey::FallThrough
- } else {
- self.paste_from_clipboard(cx);
- CmdKey::Consumed
- }
+ self.paste_from_clipboard(cx);
+ CmdKey::Consumed
}
"a" => {
self.select_all_contextual(cx);
@@ -2466,6 +2456,21 @@ impl TerminalView {
self.has_selection() || (self.input_active() && self.cmd.selected_text().is_some())
}
+ /// The keymap context this pane declares each frame.
+ ///
+ /// `alt_screen` is how a binding steps aside for a full-screen program:
+ /// `AlternatePaste` carries `Terminal && !alt_screen`, so Ctrl+V pastes at
+ /// a prompt, reaches vim as SYN, and can still be handed the whole screen
+ /// by rebinding `PasteText` onto it (#677).
+ pub(super) fn key_context(&self) -> gpui::KeyContext {
+ let mut context = gpui::KeyContext::new_with_defaults();
+ context.add("Terminal");
+ if self.on_alt_screen() {
+ context.add("alt_screen");
+ }
+ context
+ }
+
pub(super) fn key_flags(&self) -> super::input::KeyFlags {
super::input::KeyFlags::from_mode(self.terminal.term.lock().mode())
}
@@ -6057,7 +6062,7 @@ impl Render for TerminalView {
div()
.id("terminal-surface")
.track_focus(&self.focus_handle)
- .key_context("Terminal")
+ .key_context(self.key_context())
.size_full()
.relative()
.overflow_hidden()
@@ -6101,6 +6106,9 @@ impl Render for TerminalView {
this.cut_contextual(cx);
}))
.on_action(cx.listener(|this, _: &PasteText, _w, cx| this.paste_from_clipboard(cx)))
+ .on_action(
+ cx.listener(|this, _: &AlternatePaste, _w, cx| this.paste_from_clipboard(cx)),
+ )
.on_action(cx.listener(|this, _: &SelectAll, _w, cx| this.select_all_contextual(cx)))
.on_action(cx.listener(|this, _: &UndoEdit, _w, cx| this.undo_edit(false, cx)))
.on_action(cx.listener(|this, _: &RedoEdit, _w, cx| this.undo_edit(true, cx)))
@@ -12129,46 +12137,91 @@ mod gpui_tests {
}
#[gpui::test]
- fn ctrl_v_reaches_a_tui_on_the_alternate_screen(cx: &mut TestAppContext) {
+ fn cmd_v_pastes_on_the_alternate_screen(cx: &mut TestAppContext) {
let (window, mut daemon) = harness(cx);
cx.update(|cx| cx.write_to_clipboard(ClipboardItem::new_string("echo hi".into())));
alt_screen_ready(&window, cx, &mut daemon);
window
.update(cx, |view, window, cx| {
- let fell_through = view.handle_cmd_shortcut(&key("ctrl-v"), window, cx);
- assert!(
- matches!(fell_through, CmdKey::FallThrough),
- "a full-screen program owns Ctrl+V"
- );
let pasted = view.handle_cmd_shortcut(&key("cmd-v"), window, cx);
assert!(
matches!(pasted, CmdKey::Consumed),
- "Cmd+V is a paste chord on every screen"
+ "Cmd+V carries no control code and pastes on every screen"
);
})
.unwrap();
- assert_eq!(
- next_input(&mut daemon),
- b"echo hi".to_vec(),
- "only the Cmd+V paste may reach the PTY"
- );
+ assert_eq!(next_input(&mut daemon), b"echo hi".to_vec());
assert_eq!(next_input_until_timeout(&mut daemon), None);
}
+ /// The Ctrl+V half of the same key, through the real keymap: whether it
+ /// pastes is the `AlternatePaste` binding's decision, not this view's, so
+ /// these two drive it the way a user does rather than calling in.
+ #[cfg(not(target_os = "macos"))]
#[gpui::test]
- fn ctrl_v_pastes_off_the_alternate_screen(cx: &mut TestAppContext) {
+ fn ctrl_v_pastes_at_a_prompt(cx: &mut TestAppContext) {
+ crate::core::config::pin_test_config_dir();
let (window, mut daemon) = harness(cx);
+ cx.update(|cx| crate::ui::keymap::init(cx));
cx.update(|cx| cx.write_to_clipboard(ClipboardItem::new_string("echo hi".into())));
-
window
.update(cx, |view, window, cx| {
assert!(!view.on_alt_screen());
- let consumed = view.handle_cmd_shortcut(&key("ctrl-v"), window, cx);
- assert!(matches!(consumed, CmdKey::Consumed));
+ window.activate_window();
+ view.focus_handle.focus(window, cx);
})
.unwrap();
- assert_eq!(next_input(&mut daemon), b"echo hi".to_vec());
+
+ let mut vcx = gpui::VisualTestContext::from_window(window.into(), cx);
+ vcx.simulate_keystrokes("ctrl-v");
+
+ assert_eq!(
+ next_input_until_timeout(&mut daemon),
+ Some(b"echo hi".to_vec())
+ );
+ }
+
+ #[cfg(not(target_os = "macos"))]
+ #[gpui::test]
+ fn ctrl_v_reaches_a_full_screen_program_as_syn(cx: &mut TestAppContext) {
+ crate::core::config::pin_test_config_dir();
+ let (window, mut daemon) = harness(cx);
+ cx.update(|cx| crate::ui::keymap::init(cx));
+ cx.update(|cx| cx.write_to_clipboard(ClipboardItem::new_string("echo hi".into())));
+ alt_screen_ready(&window, cx, &mut daemon);
+ window
+ .update(cx, |view, window, cx| {
+ window.activate_window();
+ view.focus_handle.focus(window, cx);
+ })
+ .unwrap();
+
+ let mut vcx = gpui::VisualTestContext::from_window(window.into(), cx);
+ vcx.simulate_keystrokes("ctrl-v");
+
+ assert_eq!(next_input_until_timeout(&mut daemon), Some(vec![0x16]));
+ }
+
+ /// `Ctrl-^`, which used to die in a hardcoded block that swallowed every
+ /// Ctrl+digit off macOS — nothing has claimed those chords since tabs
+ /// moved to Alt+1..9.
+ #[gpui::test]
+ fn ctrl_6_reaches_the_pty_as_rs(cx: &mut TestAppContext) {
+ crate::core::config::pin_test_config_dir();
+ let (window, mut daemon) = harness(cx);
+ cx.update(|cx| crate::ui::keymap::init(cx));
+ window
+ .update(cx, |view, window, cx| {
+ window.activate_window();
+ view.focus_handle.focus(window, cx);
+ })
+ .unwrap();
+
+ let mut vcx = gpui::VisualTestContext::from_window(window.into(), cx);
+ vcx.simulate_keystrokes("ctrl-6");
+
+ assert_eq!(next_input_until_timeout(&mut daemon), Some(vec![0x1e]));
}
#[cfg(target_os = "macos")]
diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs
index 1db56b4f..ce843c1b 100644
--- a/src/ui/i18n/en.rs
+++ b/src/ui/i18n/en.rs
@@ -1434,6 +1434,7 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::CmdCopy => "Copy",
L10nKey::CmdCut => "Cut",
L10nKey::CmdPaste => "Paste",
+ L10nKey::CmdAlternatePaste => "Paste (outside full-screen apps)",
L10nKey::CmdSelectAll => "Select All",
L10nKey::CmdSshAddConnection => "SSH: Add Connection…",
L10nKey::CmdSshManageProfiles => "SSH: Manage Profiles…",
diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs
index 4b7edcd9..633e98bd 100644
--- a/src/ui/i18n/ja.rs
+++ b/src/ui/i18n/ja.rs
@@ -1489,6 +1489,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::CmdCopy => "コピー",
L10nKey::CmdCut => "切り取り",
L10nKey::CmdPaste => "貼り付け",
+ L10nKey::CmdAlternatePaste => "貼り付け(全画面アプリを除く)",
L10nKey::CmdSelectAll => "すべて選択",
L10nKey::CmdSshAddConnection => "SSH: 接続を追加…",
L10nKey::CmdSshManageProfiles => "SSH: プロファイルを管理…",
diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs
index 61872198..5f7e060b 100644
--- a/src/ui/i18n/mod.rs
+++ b/src/ui/i18n/mod.rs
@@ -1182,6 +1182,7 @@ l10n_keys! {
CmdCopy,
CmdCut,
CmdPaste,
+ CmdAlternatePaste,
CmdSelectAll,
CmdSshAddConnection,
CmdSshManageProfiles,
diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs
index da4ed249..731aefe0 100644
--- a/src/ui/i18n/zh.rs
+++ b/src/ui/i18n/zh.rs
@@ -1352,6 +1352,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::CmdCopy => "复制",
L10nKey::CmdCut => "剪切",
L10nKey::CmdPaste => "粘贴",
+ L10nKey::CmdAlternatePaste => "粘贴(全屏程序中除外)",
L10nKey::CmdSelectAll => "全选",
L10nKey::CmdSshAddConnection => "SSH:添加连接…",
L10nKey::CmdSshManageProfiles => "SSH:管理主机配置…",
diff --git a/src/ui/keymap.rs b/src/ui/keymap.rs
index ccc07265..e7f4d1d3 100644
--- a/src/ui/keymap.rs
+++ b/src/ui/keymap.rs
@@ -3,8 +3,8 @@ use gpui::{App, Global, KeyBinding, Keystroke, NoAction};
use crate::core::actions::*;
use crate::core::config::Config;
use crate::terminal::view::{
- ClearScrollback, CopyText, FindInTerminal, FindNext, FindPrevious, InsertNewline,
- InsertNewlineFallback, PasteText,
+ AlternatePaste, ClearScrollback, CopyText, FindInTerminal, FindNext, FindPrevious,
+ InsertNewline, InsertNewlineFallback, PasteText,
};
use crate::ui::i18n::{L10nKey, t, t_fmt};
use crate::ui::palette::CommandGroup;
@@ -119,6 +119,18 @@ fn paste_text_default() -> &'static str {
per_platform("", "ctrl-shift-v")
}
+/// Windows and Linux paste with Ctrl+V everywhere else in the desktop, so the
+/// terminal answers it too — but only off the alternate screen, where the key
+/// is a control code a full-screen program is waiting for (#677). It is a
+/// binding of its own rather than a second keystroke on `PasteText` so that it
+/// can carry that narrower context, and so that a user who wants the Windows
+/// Terminal behaviour back can say so: `"AlternatePaste": ""` hands Ctrl+V to
+/// the shell at the prompt as well, and `"PasteText": "ctrl-v"` pastes with it
+/// on every screen.
+fn alternate_paste_default() -> &'static str {
+ per_platform("", "ctrl-v")
+}
+
fn extra_defaults() -> Vec<(&'static str, &'static str, &'static str)> {
vec![
(
@@ -166,6 +178,20 @@ fn action_bindings(effective: &[(String, String)]) -> Vec {
log::warn!("ignoring keybinding for '{action}': invalid keystroke '{key}'");
continue;
}
+ // Said once, and the binding still installs: a chord the user asked
+ // for by name is the user's to spend, the way the tmux preset spends
+ // Ctrl+B. The invariant this guards is that no *default* spends one
+ // without saying so — `no_default_binding_sits_on_a_terminal_control_code`
+ // is the half of it that fails a build. A single chord only, since a
+ // prefix like `ctrl-b n` is that choice made deliberately.
+ if !key.contains(' ')
+ && steals_a_control_code(key)
+ && !control_code_binding_allowed(action, key)
+ {
+ log::warn!(
+ "keybinding '{key}' for '{action}' takes a control code away from the shell"
+ );
+ }
match make_binding(action, key) {
Some(b) => {
bindings.push(b);
@@ -183,6 +209,44 @@ fn action_bindings(effective: &[(String, String)]) -> Vec {
bindings
}
+/// Whether a chord is one the terminal owes the PTY as a control code.
+///
+/// Ctrl and nothing else, over the keys that carry a C0 byte: the alphabet,
+/// `[ \ ] ^ _ / ?`, the digits 2..8 and Space. A binding sitting on one of
+/// these does not merely shadow the shell, it deletes a byte the program on
+/// the far end is waiting for — Ctrl+D is EOF, Ctrl+W deletes a word, Ctrl+^
+/// is vim's alternate file.
+fn steals_a_control_code(chord: &str) -> bool {
+ let Ok(ks) = Keystroke::parse(chord) else {
+ return false;
+ };
+ let m = &ks.modifiers;
+ if !m.control || m.alt || m.shift || m.platform || m.function {
+ return false;
+ }
+ ks.key == "space"
+ || ks.key.len() == 1
+ && ks.key.chars().next().is_some_and(|c| {
+ c.is_ascii_alphabetic() || "[]\\`^_/?@".contains(c) || ('2'..='8').contains(&c)
+ })
+}
+
+/// The bindings allowed to sit on a control code anyway.
+///
+/// `EditorSave` stays on Ctrl+S because its handler in `app.rs` calls
+/// `cx.propagate()` whenever the editor does not have focus, so the keystroke
+/// reaches the terminal as XOFF instead of dying at the window. Ctrl+V is the
+/// paste chord every Windows and Linux desktop trains its users on; tty7
+/// answers it the way Windows Terminal does, and keeps it off the alternate
+/// screen (see `alternate_paste_default`), so it is allowed under any action —
+/// including a `PasteText` a user deliberately moves onto it (#677).
+///
+/// Anything else added here needs a fall-through of its own; a binding that
+/// simply swallows the byte does not belong on this list.
+fn control_code_binding_allowed(action: &str, chord: &str) -> bool {
+ action == "EditorSave" || (cfg!(not(target_os = "macos")) && chord == "ctrl-v")
+}
+
fn per_platform(mac: &'static str, other: &'static str) -> &'static str {
if cfg!(target_os = "macos") {
mac
@@ -323,6 +387,7 @@ pub(crate) fn default_bindings() -> Vec<(&'static str, &'static str)> {
("InsertNewline", INSERT_NEWLINE_DEFAULT),
("CopyText", per_platform("", "ctrl-shift-c")),
("PasteText", paste_text_default()),
+ ("AlternatePaste", alternate_paste_default()),
("OpenSettings", "secondary-,"),
(
"ShowKeyboardShortcuts",
@@ -595,6 +660,10 @@ fn authored_entry(action: &str) -> Option<(CommandGroup, String)> {
),
"CopyText" => (CommandGroup::Terminal, t(L10nKey::CmdCopy).to_string()),
"PasteText" => (CommandGroup::Terminal, t(L10nKey::CmdPaste).to_string()),
+ "AlternatePaste" => (
+ CommandGroup::Terminal,
+ t(L10nKey::CmdAlternatePaste).to_string(),
+ ),
"InsertNewline" => (
CommandGroup::Terminal,
t(L10nKey::KeybindInsertNewline).to_string(),
@@ -921,6 +990,10 @@ fn action_context(action: &str) -> Option<&'static str> {
match action {
"FindInTerminal" | "FindNext" | "FindPrevious" | "ClearScrollback" | "InsertNewline"
| "CopyText" | "PasteText" => Some("Terminal"),
+ // `alt_screen` is declared by the pane whenever a full-screen program
+ // owns the grid, so this binding is simply absent there and Ctrl+V
+ // carries on to the PTY as SYN (#677).
+ "AlternatePaste" => Some("Terminal && !alt_screen"),
"ScmCommit" | "ScmCommitAmend" => Some("ScmCommit"),
_ => None,
}
@@ -1015,6 +1088,7 @@ fn make_binding(action: &str, keystroke: &str) -> Option {
"InsertNewline" => KeyBinding::new(keystroke, InsertNewline, action_context(action)),
"CopyText" => KeyBinding::new(keystroke, CopyText, action_context(action)),
"PasteText" => KeyBinding::new(keystroke, PasteText, action_context(action)),
+ "AlternatePaste" => KeyBinding::new(keystroke, AlternatePaste, action_context(action)),
"OpenSettings" => KeyBinding::new(keystroke, OpenSettings, None),
"ShowKeyboardShortcuts" => KeyBinding::new(keystroke, ShowKeyboardShortcuts, None),
"About" => KeyBinding::new(keystroke, About, None),
@@ -1328,11 +1402,34 @@ mod tests {
"{key} is a terminal chord and must not paste outside one"
);
}
- // Plain Ctrl+V is the terminal's, not the keymap's: the pane pastes on
- // it at a prompt and hands it to a full-screen program otherwise.
+ // Plain Ctrl+V is `AlternatePaste`, and only where no full-screen
+ // program is running: on the alternate screen the chord belongs to
+ // that program and reaches it as SYN.
assert!(
- dispatched(&effective, "ctrl-v", "Terminal").is_empty(),
- "ctrl-v is a control code and no default may claim it"
+ dispatched(&effective, "ctrl-v", "Terminal").contains(&AlternatePaste::name_for_type()),
+ "ctrl-v pastes at a prompt off macOS"
+ );
+ assert!(
+ dispatched(&effective, "ctrl-v", "Terminal alt_screen").is_empty(),
+ "a full-screen program owns ctrl-v"
+ );
+ assert!(
+ dispatched(&effective, "ctrl-shift-v", "Terminal alt_screen")
+ .contains(&PasteText::name_for_type()),
+ "Ctrl+Shift+V is the paste that works on every screen"
+ );
+ // The two ways out, both of which the control-code validator has to
+ // let through: retire the chord, or hand it the whole screen.
+ let retired = vec![("AlternatePaste".to_string(), String::new())];
+ assert!(
+ dispatched(&retired, "ctrl-v", "Terminal").is_empty(),
+ "an emptied AlternatePaste gives Ctrl+V back to the shell"
+ );
+ let everywhere = vec![("PasteText".to_string(), "ctrl-v".to_string())];
+ assert!(
+ dispatched(&everywhere, "ctrl-v", "Terminal alt_screen")
+ .contains(&PasteText::name_for_type()),
+ "a user may put Paste itself on Ctrl+V and have it everywhere"
);
let rebound = vec![("PasteText".to_string(), "ctrl-alt-v".to_string())];
assert!(
@@ -1449,28 +1546,14 @@ mod tests {
#[test]
fn no_default_binding_sits_on_a_terminal_control_code() {
// The invariant is "no default may *swallow* a terminal control code".
- // EditorSave deliberately stays on Ctrl+S: its handler in `app.rs` calls
- // `cx.propagate()` whenever the editor does not have focus, so the
- // keystroke falls through to the terminal as XOFF instead of dying at
- // the window. Anything added here must have such a fall-through.
- const FALLS_THROUGH_TO_TERMINAL: [&str; 1] = ["EditorSave"];
+ // The exceptions are named and justified in
+ // `control_code_binding_allowed`; anything new needs a fall-through of
+ // its own to join them.
for (action, spec) in default_bindings() {
- if FALLS_THROUGH_TO_TERMINAL.contains(&action) {
- continue;
- }
for chord in spec.split_whitespace() {
- let ks = Keystroke::parse(chord).expect("default chords parse");
- let m = &ks.modifiers;
- if !m.control || m.alt || m.shift || m.platform || m.function {
- continue;
- }
- let steals = ks.key.len() == 1
- && ks.key.chars().next().is_some_and(|c| {
- c.is_ascii_alphabetic() || "[]\\`".contains(c) || ('2'..='8').contains(&c)
- })
- || ks.key == "space";
+ Keystroke::parse(chord).expect("default chords parse");
assert!(
- !steals,
+ !steals_a_control_code(chord) || control_code_binding_allowed(action, chord),
"{action} is bound to {chord}, which the shell needs as a control code \
(Ctrl+[ is ESC, Ctrl+D is EOF, Ctrl+W deletes a word, \
Ctrl+2..8 are NUL/ESC/FS/GS/RS/US/DEL). \
@@ -1480,6 +1563,49 @@ mod tests {
}
}
+ #[test]
+ fn the_control_code_rule_knows_what_the_shell_needs() {
+ // The keys with a C0 byte behind them, and the modifier shape that
+ // reaches it: Ctrl alone. This is the predicate the defaults are held
+ // to above and the one `action_bindings` warns on.
+ for chord in [
+ "ctrl-d",
+ "ctrl-c",
+ "ctrl-[",
+ "ctrl-2",
+ "ctrl-6",
+ "ctrl-8",
+ "ctrl-/",
+ "ctrl-space",
+ ] {
+ assert!(steals_a_control_code(chord), "{chord} is a control code");
+ }
+ for chord in [
+ "ctrl-shift-v",
+ "ctrl-alt-v",
+ "secondary-shift-t",
+ "ctrl-1",
+ "ctrl-9",
+ "ctrl--",
+ "ctrl-f3",
+ "alt-enter",
+ ] {
+ assert!(
+ !steals_a_control_code(chord),
+ "{chord} carries no control code"
+ );
+ }
+ // Ctrl+V is allowed to anyone off macOS — it is how the default paste
+ // reaches the chord, and how a user moves the full-screen paste onto
+ // it — while Ctrl+D stays refused whoever asks.
+ assert_eq!(
+ control_code_binding_allowed("PasteText", "ctrl-v"),
+ cfg!(not(target_os = "macos"))
+ );
+ assert!(!control_code_binding_allowed("PasteText", "ctrl-d"));
+ assert!(control_code_binding_allowed("EditorSave", "secondary-s"));
+ }
+
#[test]
fn every_default_chord_is_claimed_by_exactly_one_action() {
// Per context, not globally: gpui resolves a keystroke by walking the