mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-21 16:02:20 +00:00
Merge pull request #835 from l0ng-ai/fix/function-keys-834
fix(terminal): encode the function keys and stop swallowing them (#834)
This commit is contained in:
+364
-3
@@ -142,10 +142,31 @@ fn encode_kitty(ks: &gpui::Keystroke, kitty: KeyFlags) -> Option<Vec<u8>> {
|
||||
return Some(csi_u(code, mods, None));
|
||||
}
|
||||
|
||||
// F3 is the one function key the kitty protocol does not share with
|
||||
// terminfo. Its first version allowed both `CSI R` and `CSI 13~`, then
|
||||
// dropped the letter form outright: `CSI 1;2R` is also a Cursor Position
|
||||
// Report for row 1, column 2, so a client that negotiated the protocol
|
||||
// cannot tell Shift+F3 from an answer to its own DSR. The table gives F3
|
||||
// as `CSI 13~` alone -- the VT220 `kf3` -- so that is what an app that
|
||||
// asked for the protocol is told, while the legacy path below keeps the
|
||||
// `\EOR` our `$TERM` spells.
|
||||
if ks.key.as_str() == "f3" {
|
||||
let s = if mods == 1 {
|
||||
"\x1b[13~".to_string()
|
||||
} else {
|
||||
format!("\x1b[13;{mods}~")
|
||||
};
|
||||
return Some(s.into_bytes());
|
||||
}
|
||||
|
||||
if let Some(seq) = functional_key(ks.key.as_str(), mods, kitty.app_cursor()) {
|
||||
return Some(seq);
|
||||
}
|
||||
|
||||
if let Some(code) = kitty_function_key(ks.key.as_str()) {
|
||||
return Some(csi_u(code, mods, None));
|
||||
}
|
||||
|
||||
let modified = m.control || m.alt;
|
||||
if modified || kitty.report_all_keys {
|
||||
if let Some(code) = text_key_code(ks) {
|
||||
@@ -171,7 +192,7 @@ fn csi_u(code: u32, mods: u32, text: Option<&[u32]>) -> Vec<u8> {
|
||||
s.into_bytes()
|
||||
}
|
||||
|
||||
/// The cursor and editing keys, encoded the way `xterm-256color`'s terminfo
|
||||
/// The cursor, editing and function keys, encoded the way `xterm-256color`'s terminfo
|
||||
/// says they are — which is what we advertise in `$TERM`, and what ncurses
|
||||
/// matches against byte for byte.
|
||||
///
|
||||
@@ -197,6 +218,19 @@ fn functional_key(key: &str, mods: u32, app_cursor: bool) -> Option<Vec<u8>> {
|
||||
};
|
||||
return Some(s.into_bytes());
|
||||
}
|
||||
if let Some(form) = function_key(key) {
|
||||
let s = match form {
|
||||
// `kf1=\EOP` .. `kf4=\EOS`, and modified the `CSI 1;<mods>` form
|
||||
// the cursor keys use — terminfo spells Shift+F1 `kf13=\E[1;2P`.
|
||||
// DECCKM has no say here: unlike `kcuu1`, `kf1` is SS3 under both
|
||||
// `smkx` and `rmkx`.
|
||||
FunctionKey::Ss3(l) if mods == 1 => format!("\x1bO{l}"),
|
||||
FunctionKey::Ss3(l) => format!("\x1b[1;{mods}{l}"),
|
||||
FunctionKey::Tilde(n) if mods == 1 => format!("\x1b[{n}~"),
|
||||
FunctionKey::Tilde(n) => format!("\x1b[{n};{mods}~"),
|
||||
};
|
||||
return Some(s.into_bytes());
|
||||
}
|
||||
let num = match key {
|
||||
"insert" => Some(2u32),
|
||||
"delete" => Some(3),
|
||||
@@ -215,6 +249,68 @@ fn functional_key(key: &str, mods: u32, app_cursor: bool) -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The two shapes `xterm-256color` gives a function key.
|
||||
enum FunctionKey {
|
||||
/// `SS3 <letter>` unmodified, `CSI 1;<mods> <letter>` with a modifier.
|
||||
Ss3(char),
|
||||
/// `CSI <n> ~`, or `CSI <n>;<mods> ~` with a modifier.
|
||||
Tilde(u32),
|
||||
}
|
||||
|
||||
/// `f1`..`f12` — the name gpui gives these keys on all three platforms, and
|
||||
/// the range `xterm-256color` gives a key of its own.
|
||||
///
|
||||
/// The numbering is the PC-style table xterm has used since patch #94 and the
|
||||
/// one `kf5`..`kf12` spell: it starts at 15 and skips both 16 and 22, because
|
||||
/// those two were DEC's "do" and "help" on the VT220 keypad. Guessing a
|
||||
/// contiguous run here is the classic way to make F6 arrive as F5.
|
||||
///
|
||||
/// This deliberately stops at F12. In the entry we advertise, `kf13` onwards
|
||||
/// are not further keys — they are the *modified* forms of F1..F8 (`kf13` is
|
||||
/// `\E[1;2P`, Shift+F1), which the `mods` parameter above already produces.
|
||||
/// A physical F13 therefore has no encoding of its own under this `$TERM`;
|
||||
/// sending the VT220 `\E[25~` for it would hand ncurses a sequence its own
|
||||
/// table reads back as Shift+F1, which is worse than sending nothing.
|
||||
/// `kitty_function_key` picks them up for the one protocol that *can* name
|
||||
/// them without that clash.
|
||||
fn function_key(key: &str) -> Option<FunctionKey> {
|
||||
Some(match key {
|
||||
"f1" => FunctionKey::Ss3('P'),
|
||||
"f2" => FunctionKey::Ss3('Q'),
|
||||
"f3" => FunctionKey::Ss3('R'),
|
||||
"f4" => FunctionKey::Ss3('S'),
|
||||
"f5" => FunctionKey::Tilde(15),
|
||||
"f6" => FunctionKey::Tilde(17),
|
||||
"f7" => FunctionKey::Tilde(18),
|
||||
"f8" => FunctionKey::Tilde(19),
|
||||
"f9" => FunctionKey::Tilde(20),
|
||||
"f10" => FunctionKey::Tilde(21),
|
||||
"f11" => FunctionKey::Tilde(23),
|
||||
"f12" => FunctionKey::Tilde(24),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `f13`..`f24`, encodable only once the kitty protocol is negotiated. Kitty
|
||||
/// gives them codepoints of its own in the private use area — `CSI 57376 u` is
|
||||
/// F13, up to `CSI 57387 u` for F24 — so the ambiguity that stops
|
||||
/// `function_key` at F12 does not arise: nothing else in that protocol spells
|
||||
/// 57376. An app that never asked for the protocol still gets nothing, because
|
||||
/// there is nothing in `xterm-256color` to send it.
|
||||
fn kitty_function_key(key: &str) -> Option<u32> {
|
||||
let n: u32 = key.strip_prefix('f')?.parse().ok()?;
|
||||
(13..=24).contains(&n).then(|| 57376 + (n - 13))
|
||||
}
|
||||
|
||||
/// Whether a key name is one of the function keys — the range that means
|
||||
/// nothing to a text editor and everything to a shell. The inline editor asks
|
||||
/// this to know a keystroke it holds no meaning for but the shell does; it
|
||||
/// still only hands over what actually encodes, which for F13 and up is the
|
||||
/// kitty path alone.
|
||||
pub(crate) fn is_function_key(key: &str) -> bool {
|
||||
function_key(key).is_some() || kitty_function_key(key).is_some()
|
||||
}
|
||||
|
||||
fn text_key_code(ks: &gpui::Keystroke) -> Option<u32> {
|
||||
match ks.key.as_str() {
|
||||
"space" => Some(0x20),
|
||||
@@ -682,6 +778,200 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue #834: the function keys produced no bytes at all, on any
|
||||
/// platform. PSReadLine puts CharacterSearch on F3 and HistorySearch on
|
||||
/// F8, so on Windows this took part of the shell's normal editing surface
|
||||
/// away.
|
||||
///
|
||||
/// The table is `xterm-256color`'s `kf1`..`kf12` verbatim, gaps included:
|
||||
/// F5 is 15 and F6 is 17, F10 is 21 and F11 is 23.
|
||||
#[test]
|
||||
fn keystroke_to_bytes_encodes_f1_through_f12_like_terminfo() {
|
||||
let none = Modifiers::default();
|
||||
let cases: &[(&str, &[u8])] = &[
|
||||
("f1", b"\x1bOP"),
|
||||
("f2", b"\x1bOQ"),
|
||||
("f3", b"\x1bOR"),
|
||||
("f4", b"\x1bOS"),
|
||||
("f5", b"\x1b[15~"),
|
||||
("f6", b"\x1b[17~"),
|
||||
("f7", b"\x1b[18~"),
|
||||
("f8", b"\x1b[19~"),
|
||||
("f9", b"\x1b[20~"),
|
||||
("f10", b"\x1b[21~"),
|
||||
("f11", b"\x1b[23~"),
|
||||
("f12", b"\x1b[24~"),
|
||||
];
|
||||
for (key, seq) in cases {
|
||||
assert_eq!(
|
||||
legacy(&ks(none, key, None)).as_deref(),
|
||||
Some(*seq),
|
||||
"{key} unmodified"
|
||||
);
|
||||
// gpui hands a function key over with no `key_char`, but the
|
||||
// Windows backend has been known to attach an empty one; neither
|
||||
// may reach the `key_char` fallback and send nothing.
|
||||
assert_eq!(
|
||||
legacy(&ks(none, key, Some(""))).as_deref(),
|
||||
Some(*seq),
|
||||
"{key} with an empty key_char"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The modified forms are the same ones terminfo lists under `kf13`
|
||||
/// onwards: `kf13=\E[1;2P` is Shift+F1, `kf17=\E[15;2~` is Shift+F5,
|
||||
/// `kf25=\E[1;5P` is Ctrl+F1. Alt is xterm's 3, which PSReadLine wants for
|
||||
/// Alt+F7 (ClearHistory).
|
||||
#[test]
|
||||
fn keystroke_to_bytes_modifies_function_keys_like_terminfo() {
|
||||
let shift = Modifiers {
|
||||
shift: true,
|
||||
..Default::default()
|
||||
};
|
||||
let alt = Modifiers {
|
||||
alt: true,
|
||||
..Default::default()
|
||||
};
|
||||
let ctrl = Modifiers {
|
||||
control: true,
|
||||
..Default::default()
|
||||
};
|
||||
let ctrl_shift = Modifiers {
|
||||
control: true,
|
||||
shift: true,
|
||||
..Default::default()
|
||||
};
|
||||
// kf13, kf16
|
||||
assert_eq!(legacy(&ks(shift, "f1", None)), Some(b"\x1b[1;2P".to_vec()));
|
||||
assert_eq!(legacy(&ks(shift, "f4", None)), Some(b"\x1b[1;2S".to_vec()));
|
||||
// kf25
|
||||
assert_eq!(legacy(&ks(ctrl, "f1", None)), Some(b"\x1b[1;5P".to_vec()));
|
||||
// kf37
|
||||
assert_eq!(
|
||||
legacy(&ks(ctrl_shift, "f1", None)),
|
||||
Some(b"\x1b[1;6P".to_vec())
|
||||
);
|
||||
// kf49
|
||||
assert_eq!(legacy(&ks(alt, "f1", None)), Some(b"\x1b[1;3P".to_vec()));
|
||||
// kf20 -- Shift+F8, PSReadLine's HistorySearchForward
|
||||
assert_eq!(legacy(&ks(shift, "f8", None)), Some(b"\x1b[19;2~".to_vec()));
|
||||
// kf55 -- Alt+F7, PSReadLine's ClearHistory
|
||||
assert_eq!(legacy(&ks(alt, "f7", None)), Some(b"\x1b[18;3~".to_vec()));
|
||||
// kf35 -- Ctrl+F11
|
||||
assert_eq!(legacy(&ks(ctrl, "f11", None)), Some(b"\x1b[23;5~".to_vec()));
|
||||
}
|
||||
|
||||
/// The `mods` parameter is what makes F13 onwards ambiguous: in
|
||||
/// `xterm-256color` those capability names are already spoken for by the
|
||||
/// modified F1..F8, so a physical F13 has no sequence of its own to send.
|
||||
///
|
||||
/// The kitty protocol has no such clash — it puts F13..F24 in the private
|
||||
/// use area, `CSI 57376 u` upwards — so a client that negotiated it does
|
||||
/// get those keys, and only those twelve: F25 is past the end of the range
|
||||
/// gpui names.
|
||||
#[test]
|
||||
fn terminfo_stops_at_f12_and_kitty_carries_on_to_f24() {
|
||||
let none = Modifiers::default();
|
||||
let shift = Modifiers {
|
||||
shift: true,
|
||||
..Default::default()
|
||||
};
|
||||
let ctrl = Modifiers {
|
||||
control: true,
|
||||
..Default::default()
|
||||
};
|
||||
let kitty_cases: &[(&str, &[u8])] = &[
|
||||
("f13", b"\x1b[57376u"),
|
||||
("f14", b"\x1b[57377u"),
|
||||
("f20", b"\x1b[57383u"),
|
||||
("f24", b"\x1b[57387u"),
|
||||
];
|
||||
for (key, seq) in kitty_cases {
|
||||
assert_eq!(
|
||||
legacy(&ks(none, key, None)),
|
||||
None,
|
||||
"{key} has no terminfo capability"
|
||||
);
|
||||
assert_eq!(
|
||||
keystroke_to_bytes(&ks(none, key, None), kitty()).as_deref(),
|
||||
Some(*seq),
|
||||
"{key} under kitty"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
keystroke_to_bytes(&ks(shift, "f13", None), kitty()),
|
||||
Some(b"\x1b[57376;2u".to_vec())
|
||||
);
|
||||
// Ctrl+F13 must not fold into a C0 byte on its first letter either.
|
||||
assert_eq!(legacy(&ks(ctrl, "f13", None)), None);
|
||||
// And the range ends where gpui's names do.
|
||||
assert_eq!(keystroke_to_bytes(&ks(none, "f25", None), kitty()), None);
|
||||
assert_eq!(legacy(&ks(none, "f25", None)), None);
|
||||
}
|
||||
|
||||
/// The one function key where the two protocols disagree. Kitty's spec
|
||||
/// allowed `CSI R` for F3 in its first version and then removed it,
|
||||
/// because `CSI 1;2R` is also a Cursor Position Report for row 1, column
|
||||
/// 2 — so a client that negotiated the protocol is given `CSI 13~`, the
|
||||
/// VT220 `kf3`, while `$TERM`'s own `kf3=\EOR` still goes to everyone
|
||||
/// else. alacritty draws the same line.
|
||||
#[test]
|
||||
fn kitty_spells_f3_thirteen_because_csi_r_is_a_cursor_report() {
|
||||
let none = Modifiers::default();
|
||||
let shift = Modifiers {
|
||||
shift: true,
|
||||
..Default::default()
|
||||
};
|
||||
let ctrl = Modifiers {
|
||||
control: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(legacy(&ks(none, "f3", None)), Some(b"\x1bOR".to_vec()));
|
||||
assert_eq!(legacy(&ks(shift, "f3", None)), Some(b"\x1b[1;2R".to_vec()));
|
||||
let full = KeyFlags {
|
||||
disambiguate: true,
|
||||
report_all_keys: true,
|
||||
report_text: true,
|
||||
app_cursor: false,
|
||||
};
|
||||
for flags in [kitty(), full] {
|
||||
assert_eq!(
|
||||
keystroke_to_bytes(&ks(none, "f3", None), flags),
|
||||
Some(b"\x1b[13~".to_vec())
|
||||
);
|
||||
assert_eq!(
|
||||
keystroke_to_bytes(&ks(shift, "f3", None), flags),
|
||||
Some(b"\x1b[13;2~".to_vec())
|
||||
);
|
||||
assert_eq!(
|
||||
keystroke_to_bytes(&ks(ctrl, "f3", None), flags),
|
||||
Some(b"\x1b[13;5~".to_vec())
|
||||
);
|
||||
}
|
||||
// Cmd is not a kitty modifier either, and the platform key sends the
|
||||
// keystroke down the legacy path in the first place.
|
||||
let cmd = Modifiers {
|
||||
platform: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
keystroke_to_bytes(&ks(cmd, "f3", None), kitty()),
|
||||
Some(b"\x1bOR".to_vec())
|
||||
);
|
||||
}
|
||||
|
||||
/// Cmd is not an xterm modifier, so it must not turn a bare F-key into a
|
||||
/// modified one — and on macOS Cmd+F-keys belong to the window anyway.
|
||||
#[test]
|
||||
fn cmd_does_not_modify_a_function_key() {
|
||||
let cmd = Modifiers {
|
||||
platform: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(legacy(&ks(cmd, "f5", None)), Some(b"\x1b[15~".to_vec()));
|
||||
}
|
||||
|
||||
fn app_cursor() -> KeyFlags {
|
||||
KeyFlags {
|
||||
app_cursor: true,
|
||||
@@ -689,6 +979,21 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// DECCKM governs `kcuu1` and friends, not `kf1`: terminfo spells `kf1`
|
||||
/// `\EOP` under both `smkx` and `rmkx`, so the F keys must not move when
|
||||
/// an ncurses app turns application cursor keys on.
|
||||
#[test]
|
||||
fn app_cursor_mode_leaves_the_function_keys_alone() {
|
||||
let none = Modifiers::default();
|
||||
for key in ["f1", "f4", "f5", "f12"] {
|
||||
assert_eq!(
|
||||
keystroke_to_bytes(&ks(none, key, None), app_cursor()),
|
||||
legacy(&ks(none, key, None)),
|
||||
"{key} does not follow DECCKM"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The bug behind issue #361: htop turns on DECCKM, `xterm-256color` spells
|
||||
/// `kcuu1` as `\EOA`, and ncurses matches nothing else. Sending `\E[A` there
|
||||
/// left htop reading the bytes one at a time -- and `[` is bound to "lower
|
||||
@@ -801,9 +1106,11 @@ mod tests {
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(legacy(&ks(alt, "b", Some("b"))), Some(b"\x1bb".to_vec()));
|
||||
// `f13` stands in for "a named key with nothing behind it" — it used
|
||||
// to be `f7`, back when no function key encoded at all (#834).
|
||||
let none = Modifiers::default();
|
||||
assert_eq!(legacy(&ks(none, "f7", Some(""))), None);
|
||||
assert_eq!(legacy(&ks(none, "f7", None)), None);
|
||||
assert_eq!(legacy(&ks(none, "f13", Some(""))), None);
|
||||
assert_eq!(legacy(&ks(none, "f13", None)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -984,6 +1291,60 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The kitty encoder shares `functional_key`, so the F keys have to come
|
||||
/// out of it byte-identical to the legacy path — kitty's own spec keeps
|
||||
/// the legacy CSI/SS3 forms for F1..F12 and only appends the modifier
|
||||
/// parameter, which is what that shared table already does. F3 is the sole
|
||||
/// exception and has a test of its own:
|
||||
/// `kitty_spells_f3_thirteen_because_csi_r_is_a_cursor_report`.
|
||||
///
|
||||
/// `report_all_keys` changes nothing here either: the F keys are already
|
||||
/// escape sequences, so there is no bare byte for it to promote.
|
||||
#[test]
|
||||
fn kitty_encodes_function_keys_like_the_legacy_path() {
|
||||
let none = Modifiers::default();
|
||||
let shift = Modifiers {
|
||||
shift: true,
|
||||
..Default::default()
|
||||
};
|
||||
let ctrl = Modifiers {
|
||||
control: true,
|
||||
..Default::default()
|
||||
};
|
||||
let full = KeyFlags {
|
||||
disambiguate: true,
|
||||
report_all_keys: true,
|
||||
report_text: true,
|
||||
app_cursor: false,
|
||||
};
|
||||
for key in ["f1", "f2", "f4", "f5", "f7", "f10", "f11", "f12"] {
|
||||
for mods in [none, shift, ctrl] {
|
||||
let want = legacy(&ks(mods, key, None));
|
||||
assert!(want.is_some(), "{key} encodes on the legacy path");
|
||||
assert_eq!(
|
||||
keystroke_to_bytes(&ks(mods, key, None), kitty()),
|
||||
want,
|
||||
"{key} under kitty disambiguate"
|
||||
);
|
||||
assert_eq!(
|
||||
keystroke_to_bytes(&ks(mods, key, None), full),
|
||||
want,
|
||||
"{key} under kitty report-all-keys"
|
||||
);
|
||||
}
|
||||
}
|
||||
// Spot-check the actual bytes, so a change to `legacy` cannot quietly
|
||||
// move both sides at once.
|
||||
assert_eq!(
|
||||
keystroke_to_bytes(&ks(none, "f7", None), full),
|
||||
Some(b"\x1b[18~".to_vec())
|
||||
);
|
||||
assert_eq!(
|
||||
keystroke_to_bytes(&ks(shift, "f1", None), full),
|
||||
Some(b"\x1b[1;2P".to_vec())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kitty_report_all_keys_escapes_plain_text_with_associated_text() {
|
||||
let full = KeyFlags {
|
||||
|
||||
@@ -2482,6 +2482,19 @@ impl TerminalView {
|
||||
|
||||
self.close_completion();
|
||||
|
||||
// A function key means nothing to this editor and everything to the
|
||||
// shell — PSReadLine puts CharacterSearch on F3 and HistorySearch on
|
||||
// F8, and both of those act on the line that is currently on the
|
||||
// prompt. So it takes the same route an unknown Ctrl chord takes:
|
||||
// hand the line over first, then send the key, with every modifier
|
||||
// combination going the same way (Alt+F7 is ClearHistory).
|
||||
if super::input::is_function_key(key) && !m.platform {
|
||||
if let Some(bytes) = super::input::keystroke_to_bytes(ks, self.key_flags()) {
|
||||
self.handoff_line_to_shell(&bytes, cx);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if m.control && !m.platform && !m.alt {
|
||||
if cfg!(not(target_os = "macos")) {
|
||||
match key {
|
||||
@@ -6859,10 +6872,24 @@ impl Render for TerminalView {
|
||||
.on_action(
|
||||
cx.listener(|this, _: &FindInTerminal, window, cx| this.open_search(window, cx)),
|
||||
)
|
||||
// Off macOS these two live on F3 and Shift+F3, which is also where
|
||||
// PSReadLine keeps CharacterSearch and readline users put their
|
||||
// own widgets. With no find bar open there is no next match to
|
||||
// step to, so the keystroke is given back the way `EditorSave`
|
||||
// gives back Ctrl+S — otherwise the action swallows the key and
|
||||
// the shell never sees it (#834).
|
||||
.on_action(cx.listener(|this, _: &FindNext, _w, cx| {
|
||||
if this.search.is_none() {
|
||||
cx.propagate();
|
||||
return;
|
||||
}
|
||||
this.step_match(Direction::Right, cx);
|
||||
}))
|
||||
.on_action(cx.listener(|this, _: &FindPrevious, _w, cx| {
|
||||
if this.search.is_none() {
|
||||
cx.propagate();
|
||||
return;
|
||||
}
|
||||
this.step_match(Direction::Left, cx);
|
||||
}))
|
||||
.on_action(cx.listener(|this, _: &ClearScrollback, _w, cx| this.clear_scrollback(cx)))
|
||||
@@ -11960,6 +11987,59 @@ mod gpui_tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// The whole chain for #834, through the real dispatch tree: F3 is bound
|
||||
/// to Find Next off macOS, and gpui matches bindings before the pane's key
|
||||
/// handler. With no find bar open the action gives the key back, the pane
|
||||
/// encodes it, and PSReadLine's CharacterSearch gets its `\EOR`.
|
||||
///
|
||||
/// F7 has no binding at all and is the control: it takes the same route
|
||||
/// with nothing to fall through.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
#[gpui::test]
|
||||
fn an_unused_find_binding_gives_f3_back_to_the_shell(cx: &mut TestAppContext) {
|
||||
let (window, mut daemon) = harness(cx);
|
||||
cx.update(|cx| crate::ui::keymap::init(cx));
|
||||
prompt_ready(&window, cx, &mut daemon);
|
||||
window
|
||||
.update(cx, |view, window, cx| {
|
||||
window.activate_window();
|
||||
view.focus_handle.focus(window, cx);
|
||||
view.commit_text("echo a", cx);
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let mut vcx = gpui::VisualTestContext::from_window(window.into(), cx);
|
||||
for (chord, seq) in [("f3", b"\x1bOR".to_vec()), ("f7", b"\x1b[18~".to_vec())] {
|
||||
window
|
||||
.update(cx, |view, _, _| {
|
||||
// The previous handoff gave this prompt to the shell for
|
||||
// good; take it back so both keys are tested from the
|
||||
// same starting state.
|
||||
view.editor_handoff = None;
|
||||
view.cmd.set("echo a");
|
||||
assert!(view.search.is_none(), "no find bar is open");
|
||||
})
|
||||
.unwrap();
|
||||
vcx.simulate_keystrokes(chord);
|
||||
window
|
||||
.update(cx, |view, _, _| {
|
||||
assert!(view.search.is_none(), "{chord} did not open the find bar");
|
||||
assert_eq!(view.cmd.text(), "", "{chord} handed the line over");
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
next_input_until_timeout(&mut daemon),
|
||||
Some(b"echo a".to_vec()),
|
||||
"{chord} puts the line on the shell's prompt first"
|
||||
);
|
||||
assert_eq!(
|
||||
next_input_until_timeout(&mut daemon),
|
||||
Some(seq),
|
||||
"{chord} reaches the PTY"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn ctrl_r_fuzzy_search_accepts_into_the_editor(cx: &mut TestAppContext) {
|
||||
let (window, _daemon) = harness(cx);
|
||||
@@ -12027,6 +12107,41 @@ mod gpui_tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The second half of #834. Even once the encoder knew the F keys, the
|
||||
/// inline editor still ate them: `handle_editor_key` had no arm for a
|
||||
/// named key it does not bind, so F8 fell out of the bottom of the match
|
||||
/// and died on a `cx.notify()`. PSReadLine's HistorySearchBackward acts on
|
||||
/// the line that is on the prompt, so the fix is the unknown-chord route —
|
||||
/// the line goes over first, then the key.
|
||||
#[gpui::test]
|
||||
fn function_keys_hand_the_line_to_the_shell(cx: &mut TestAppContext) {
|
||||
let (window, mut daemon) = harness(cx);
|
||||
for (chord, seq) in [
|
||||
("f8", b"\x1b[19~".to_vec()),
|
||||
("shift-f8", b"\x1b[19;2~".to_vec()),
|
||||
("alt-f7", b"\x1b[18;3~".to_vec()),
|
||||
("f3", b"\x1bOR".to_vec()),
|
||||
] {
|
||||
window
|
||||
.update(cx, |view, _, cx| {
|
||||
view.cmd.set("git st");
|
||||
view.handle_editor_key(&key(chord), cx);
|
||||
assert_eq!(view.cmd.text(), "", "{chord} handed the line over");
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
next_input_until_timeout(&mut daemon),
|
||||
Some(b"git st".to_vec()),
|
||||
"{chord} puts the line on the shell's prompt first"
|
||||
);
|
||||
assert_eq!(
|
||||
next_input_until_timeout(&mut daemon),
|
||||
Some(seq),
|
||||
"{chord} follows the line"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn ctrl_j_and_ctrl_m_submit_the_line_like_enter(cx: &mut TestAppContext) {
|
||||
crate::core::config::pin_test_config_dir();
|
||||
|
||||
@@ -402,6 +402,15 @@ pub(crate) fn default_bindings() -> Vec<(&'static str, &'static str)> {
|
||||
per_platform("secondary-shift-t", "alt-shift-t"),
|
||||
),
|
||||
("ToggleMaximizePane", "secondary-shift-enter"),
|
||||
// The one default that sits on a bare function key, and it stays
|
||||
// there: F11 is the fullscreen chord Windows Terminal, GNOME Terminal
|
||||
// and konsole all train their users on, and no shell binds it — the
|
||||
// keys PSReadLine actually wants are F3, F7 and F8. gpui matches this
|
||||
// binding before the pane's key handler runs, so the terminal never
|
||||
// sees a bare F11; Shift/Ctrl/Alt+F11 are not bound and still reach
|
||||
// the PTY as `\E[23;<mods>~`, and the whole chord is one line of
|
||||
// config away from being retired. See
|
||||
// `f11_is_the_only_default_on_a_bare_function_key`.
|
||||
("ToggleFullscreen", per_platform("secondary-enter", "f11")),
|
||||
("ToggleTabSidebar", ""),
|
||||
(
|
||||
@@ -1873,6 +1882,52 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// F1..F12 now encode (#834), so a default sitting on one takes it away
|
||||
/// from the shell the same way a Ctrl binding takes a control code: gpui
|
||||
/// matches bindings before the pane's key handler runs, so a bound
|
||||
/// function key never reaches the PTY at all.
|
||||
///
|
||||
/// The three that do are named here rather than left to be discovered,
|
||||
/// and each answers for the shell key it stands on:
|
||||
///
|
||||
/// * F11 keeps fullscreen outright. It is the chord Windows Terminal,
|
||||
/// GNOME Terminal and konsole all use, and no shell binds it —
|
||||
/// PSReadLine's F keys are F3, F7 and F8.
|
||||
/// * F3 and Shift+F3 are Find Next / Previous, and they fall through:
|
||||
/// with no find bar open the listener in `terminal::view` calls
|
||||
/// `cx.propagate()`, so PSReadLine's CharacterSearch still gets the key.
|
||||
///
|
||||
/// A fourth needs a fall-through of its own to join them.
|
||||
#[test]
|
||||
fn f11_is_the_only_default_on_a_bare_function_key() {
|
||||
let mut bound = Vec::new();
|
||||
for (action, spec) in default_bindings() {
|
||||
for chord in spec.split_whitespace() {
|
||||
let ks = Keystroke::parse(chord).expect("default chords parse");
|
||||
if crate::terminal::input::is_function_key(&ks.key) {
|
||||
bound.push((action, chord));
|
||||
}
|
||||
}
|
||||
}
|
||||
let expected: &[(&str, &str)] = if cfg!(target_os = "macos") {
|
||||
&[]
|
||||
} else {
|
||||
&[
|
||||
("FindNext", "f3"),
|
||||
("FindPrevious", "shift-f3"),
|
||||
("ToggleFullscreen", "f11"),
|
||||
]
|
||||
};
|
||||
bound.sort();
|
||||
let mut expected = expected.to_vec();
|
||||
expected.sort();
|
||||
assert_eq!(
|
||||
bound, expected,
|
||||
"a default on a function key hides it from the shell; \
|
||||
PSReadLine wants F3, F7 and F8, readline's `bind -x` any of them"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_control_code_rule_knows_what_the_shell_needs() {
|
||||
// The keys with a C0 byte behind them, and the modifier shape that
|
||||
|
||||
Reference in New Issue
Block a user