mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-21 16:02:20 +00:00
fix(input): give the PTY back the Ctrl chords tty7 was eating
Follow-up to #682, which handed Ctrl+V to a full-screen program but left three neighbouring holes of the same shape: a key the terminal answers without the keymap ever seeing it. The C0 table was half a table. `input.rs` mapped the alphabet, `[ \ ]` and Ctrl+2, and nothing else — so `Ctrl-^` (Ctrl+6, vim's alternate file), `Ctrl-_` (readline's undo, typed as Ctrl+/ or Ctrl+Shift+-) and Ctrl+3..8 produced no bytes at all. They were not mis-encoded, they were silent: gpui filters control characters out of `key_char` on all three backends, so the text fallback had nothing to offer either. The table is now the VT-220 one, each digit beside the punctuation that shares its key, because every platform hands Ctrl+Shift+6 over as `^` with the Shift already spent. Ctrl+/ is xterm's addition rather than VT-220's and is spelled out with the reason. The twenty-six letters fold to `& 0x1f`. `on_key_down` swallowed plain Ctrl+1..9 off macOS with a bare `return`, left over from when tabs lived on ctrl-digits — they have been on Alt+1..9 for a long time, so nothing claimed those chords and the block only deleted keys. It also sat before `keystroke_to_bytes`, so not even the kitty protocol got through it. Gone. Ctrl+V is now a binding. `AlternatePaste` carries `ctrl-v` off macOS in a `Terminal && !alt_screen` context, and the pane declares `alt_screen` whenever a full-screen program owns the grid, so the behaviour #682 settled on is unchanged — paste at a prompt, SYN inside vim — while the keymap can finally express it, the Keybindings page lists it, and the user gets a say: `"AlternatePaste": ""` hands Ctrl+V to the shell everywhere, including readline's `quoted-insert`, and `"PasteText": "ctrl-v"` pastes on every screen the way Windows Terminal does. That cohort is real — Warp keeps Ctrl+V pasting on Windows on purpose, as a removable binding, for exactly this reason. The hardcoded arm in `handle_cmd_shortcut` now answers Cmd+V alone, which is macOS's only paste chord and carries no control code to lose. Last, the rule about control codes is one function instead of an assertion buried in a test. `steals_a_control_code` plus a commented `control_code_binding_allowed` back both the defaults test and a new runtime warning, so a hand-edited config.json that takes EOF away from every shell says so in the log. It warns rather than refuses: a chord the user asked for by name is theirs to spend, the way the tmux preset spends Ctrl+B. The invariant that still fails a build is that no *default* spends one silently. Tests: `cargo test --bin tty7-app` 1360 passed, 1 known flake (`a_routed_auth_prompt_carries_the_machine_that_raised_it`, green on a rerun and on a clean tree). New: the whole VT-220 table asserted byte by byte, with Ctrl+- held out; `ctrl_6_reaches_the_pty_as_rs`, `ctrl_v_pastes_at_a_prompt` and `ctrl_v_reaches_a_full_screen_program_as_syn` drive the real keymap through `simulate_keystrokes` rather than calling into the view; the keymap tests cover both escape hatches and the context that withholds the binding. #682's two `handle_cmd_shortcut` tests are replaced by those three, which assert the same behaviour at the layer that now decides it; its end-to-end SYN test stands unchanged. The gpui tests are unix-only, so CI is what runs them.
This commit is contained in:
@@ -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 <kbd>Ctrl ⇧ V</kbd> or <kbd>⇧ Insert</kbd>.
|
||||
|
||||
That convenience is the **Paste (outside full-screen apps)** binding, and it is
|
||||
yours to move. Clearing it hands <kbd>Ctrl V</kbd> 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 <kbd>Ctrl</kbd> chord reaches the program you are running, control
|
||||
codes included: <kbd>Ctrl 6</kbd> and <kbd>Ctrl ⇧ 6</kbd> are `^^` (vim's
|
||||
alternate file), <kbd>Ctrl /</kbd> and <kbd>Ctrl ⇧ −</kbd> are `^_` (readline's
|
||||
undo), <kbd>Ctrl 3</kbd> is Escape.
|
||||
|
||||
## Git and SSH
|
||||
|
||||
| Action | macOS | Windows / Linux |
|
||||
|
||||
+70
-34
@@ -239,45 +239,45 @@ fn associated_text(ks: &gpui::Keystroke) -> Option<Vec<u32>> {
|
||||
(!cps.is_empty()).then_some(cps)
|
||||
}
|
||||
|
||||
/// The C0 control byte a `Ctrl+<key>` 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 `<C-/>` — 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<u8> {
|
||||
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<Vec<u8>> {
|
||||
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 {
|
||||
|
||||
+96
-43
@@ -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")]
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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: プロファイルを管理…",
|
||||
|
||||
@@ -1182,6 +1182,7 @@ l10n_keys! {
|
||||
CmdCopy,
|
||||
CmdCut,
|
||||
CmdPaste,
|
||||
CmdAlternatePaste,
|
||||
CmdSelectAll,
|
||||
CmdSshAddConnection,
|
||||
CmdSshManageProfiles,
|
||||
|
||||
@@ -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:管理主机配置…",
|
||||
|
||||
+151
-25
@@ -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<KeyBinding> {
|
||||
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<KeyBinding> {
|
||||
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<KeyBinding> {
|
||||
"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
|
||||
|
||||
Reference in New Issue
Block a user