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