diff --git a/src/terminal/input.rs b/src/terminal/input.rs index 0fe392ac..03ec4344 100644 --- a/src/terminal/input.rs +++ b/src/terminal/input.rs @@ -142,10 +142,31 @@ fn encode_kitty(ks: &gpui::Keystroke, kitty: KeyFlags) -> Option> { 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 { 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> { }; 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;` 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> { None } +/// The two shapes `xterm-256color` gives a function key. +enum FunctionKey { + /// `SS3 ` unmodified, `CSI 1; ` with a modifier. + Ss3(char), + /// `CSI ~`, or `CSI ; ~` 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 { + 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 { + 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 { 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 { diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 50f6c259..e5c2f206 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -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(); diff --git a/src/ui/keymap.rs b/src/ui/keymap.rs index 615b63e0..6fb2d808 100644 --- a/src/ui/keymap.rs +++ b/src/ui/keymap.rs @@ -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;~`, 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