diff --git a/src/terminal/input.rs b/src/terminal/input.rs index e4fc4bfe..5e0259bc 100644 --- a/src/terminal/input.rs +++ b/src/terminal/input.rs @@ -105,6 +105,51 @@ pub(super) fn reshape_option_keystroke( } } +/// True when a keystroke is ordinary text that macOS should deliver through the +/// input context (`insertText:` → `replace_text_in_range` → `commit_text`) +/// rather than the raw `key_char` path. +/// +/// gpui derives `key_char` by running the event's *virtual keycode* back through +/// the current layout (`chars_for_modified_key` in its macOS backend); it never +/// reads the event's Unicode payload. That is fine for a physical keyboard, where +/// the keycode is the truth, but wrong for any event whose text lives only in the +/// payload — notably remote-control apps, which synthesize keystrokes as +/// `CGEventCreateKeyboardEvent(src, 0, …)` + `CGEventKeyboardSetUnicodeString()`. +/// Keycode 0 is `a`, so every remotely typed character arrived as `a`. +/// +/// gpui already diverts printable keys to the input context, but only while a +/// composing input source is active (`is_ime_input_source_active`), so the bug +/// appeared and vanished depending on which input method was selected — and the +/// plain ABC layout, the macOS default, always lost the text. Declining the key +/// here instead makes the IME the single delivery path for text on macOS: gpui +/// falls through to `handleEvent:`, and the Unicode payload survives. +/// +/// Chords are deliberately excluded: Ctrl/Cmd/Fn belong to the encoders below, +/// and Option is owned by [`reshape_option_keystroke`]'s Meta policy. +/// +/// REPORT_ALL_KEYS_AS_ESC is excluded too: it asks for every key as +/// `CSI ;[;]u`, and the IME path terminates in +/// `write_gap_text`, which writes raw UTF-8 with no Kitty awareness. Under that +/// mode text keys must stay on the [`keystroke_to_bytes`] path so they get +/// encoded. Disambiguate-only sessions are unaffected — [`encode_kitty`] +/// declines unmodified text keys there, so the IME route is equivalent. +/// +/// Compiled under `test` on every platform so the routing rule is covered by +/// CI everywhere, not just on the macOS runner. +#[cfg(any(target_os = "macos", test))] +pub(super) fn defer_to_ime(ks: &gpui::Keystroke, kitty: KittyFlags) -> bool { + if kitty.report_all_keys { + return false; + } + let m = &ks.modifiers; + if m.control || m.platform || m.function || m.alt { + return false; + } + ks.key_char + .as_deref() + .is_some_and(|ch| !ch.is_empty() && ch.chars().all(|c| c >= '\u{20}' && c != '\u{7f}')) +} + /// Translate a GPUI keystroke into the bytes a PTY expects. /// /// When the app has enabled the Kitty keyboard protocol (`kitty.active()`) we try @@ -522,7 +567,26 @@ impl InputHandler for TerminalInputHandler { None } - fn prefers_ime_for_printable_keys(&mut self, window: &mut Window, _cx: &mut App) -> bool { + fn apple_press_and_hold_enabled(&mut self) -> bool { + // A terminal wants auto-repeat, not the accent palette: holding `j` in + // vim scrolls, it does not offer `ĵ`. This used to be moot because + // `on_key_down` consumed printable keys before gpui consulted it; now + // that text defers to the IME (see `defer_to_ime`), gpui reaches its + // held-key branch, and answering `false` there makes it repeat the + // character instead of handing the key to press-and-hold. + false + } + + fn prefers_ime_for_printable_keys(&mut self, window: &mut Window, cx: &mut App) -> bool { + // REPORT_ALL_KEYS_AS_ESC wants every key as `CSI ;[;]u`, + // which only `keystroke_to_bytes` produces — the IME path commits raw + // UTF-8. Keep printable keys on the dispatch path so they get encoded, + // matching the same gate in `on_key_down`. CJK composition and "escape + // every key" are mutually exclusive by construction; an app that asks + // for the latter gets it. + if self.view.read(cx).kitty_flags().report_all_keys { + return false; + } // While a multi-key keybinding is mid-sequence — e.g. the tmux preset's // `ctrl-b` prefix is held pending — the next key belongs to the keymap, // not the IME. macOS otherwise diverts printable keys straight to the IME @@ -554,9 +618,29 @@ impl InputHandler for TerminalInputHandler { #[cfg(test)] mod tests { - use super::{KittyFlags, keystroke_to_bytes, reshape_option_keystroke, tab_bytes}; + use super::{ + KittyFlags, defer_to_ime, keystroke_to_bytes, reshape_option_keystroke, tab_bytes, + }; use gpui::{Keystroke, Modifiers}; + /// Kitty full mode: every key escaped, with the produced text attached. + fn full_mode() -> KittyFlags { + KittyFlags { + disambiguate: true, + report_all_keys: true, + report_text: true, + } + } + + /// Level 1 only — the mode a shell leaves on after a TUI exits. + fn disambiguate_only() -> KittyFlags { + KittyFlags { + disambiguate: true, + report_all_keys: false, + report_text: false, + } + } + /// The legacy call shape used by the pre-existing tests: encode with the Kitty /// protocol off, exercising exactly the byte output shells see by default. fn legacy(ks: &Keystroke) -> Option> { @@ -571,6 +655,73 @@ mod tests { } } + #[test] + fn plain_text_defers_to_the_ime_unless_kitty_wants_every_key() { + let plain = Modifiers::default(); + let a = ks(plain, "a", Some("a")); + + // Default and disambiguate-only: text belongs to the IME, which is the + // only path that carries a synthesized event's real Unicode payload. + assert!(defer_to_ime(&a, KittyFlags::default())); + assert!(defer_to_ime(&a, disambiguate_only())); + + // Full mode: the IME commits raw UTF-8, so deferring would drop the + // `CSI 97;1;97u` the app negotiated for. Stay on the encoder path. + assert!(!defer_to_ime(&a, full_mode())); + assert_eq!( + keystroke_to_bytes(&a, full_mode()), + Some(b"\x1b[97;1;97u".to_vec()), + ); + + // Space is text too, and follows the same rule. + let space = ks(plain, "space", Some(" ")); + assert!(defer_to_ime(&space, KittyFlags::default())); + assert!(!defer_to_ime(&space, full_mode())); + } + + #[test] + fn shifted_text_follows_the_same_ime_rule() { + let shift = Modifiers { + shift: true, + ..Default::default() + }; + let upper = ks(shift, "a", Some("A")); + assert!(defer_to_ime(&upper, KittyFlags::default())); + assert!(!defer_to_ime(&upper, full_mode())); + } + + #[test] + fn non_text_keys_never_defer_to_the_ime() { + let plain = Modifiers::default(); + // No `key_char` at all — arrows, F-keys, backspace, escape. + assert!(!defer_to_ime( + &ks(plain, "left", None), + KittyFlags::default() + )); + assert!(!defer_to_ime( + &ks(plain, "backspace", None), + KittyFlags::default() + )); + // Control chars are filtered even when a `key_char` is present. + assert!(!defer_to_ime( + &ks(plain, "enter", Some("\n")), + KittyFlags::default() + )); + assert!(!defer_to_ime( + &ks(plain, "tab", Some("\t")), + KittyFlags::default() + )); + // Chords belong to the encoders, not the IME. + let ctrl = Modifiers { + control: true, + ..Default::default() + }; + assert!(!defer_to_ime( + &ks(ctrl, "c", Some("c")), + KittyFlags::default() + )); + } + #[test] fn keystroke_to_bytes_maps_control_letters() { let ctrl = Modifiers { diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 2fc21a5f..9c0f6ad4 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -1367,6 +1367,26 @@ impl TerminalView { return; } + // On macOS all ordinary text goes out through the IME, never through + // `key_char` — see `input::defer_to_ime` for why (gpui reconstructs + // `key_char` from the virtual keycode, which is a lie for synthesized + // events). Decline the key without consuming it and gpui hands the + // native event to the input context, which delivers the real text via + // `commit_text`. + // + // Kitty's REPORT_ALL_KEYS_AS_ESC is the exception — `defer_to_ime` + // declines under it so the key reaches the encoder below. + // + // A pending multi-key chord is already handled before this point: a key + // that completes a sequence is dispatched as an action and never + // reaches `on_key_down`. The check below is belt-and-braces (gpui takes + // `pending_input` earlier in `dispatch_key_event`, so it never fires + // here) and mirrors `prefers_ime_for_printable_keys`, which *is* live. + #[cfg(target_os = "macos")] + if !window.has_pending_keystrokes() && super::input::defer_to_ime(ks, self.kitty_flags()) { + return; + } + // While idle at the prompt, our local command editor owns the keyboard: // editing keys act on the in-memory line and Enter ships it to the PTY. // Printable text is delivered through the IME path (`commit_text`), so we @@ -2058,7 +2078,7 @@ impl TerminalView { /// local `Term`'s mode bits (the reader thread keeps them current by advancing /// the emulator over all child output). Consulted by the key encoder so TUIs /// that opt into the protocol get `CSI u` reports. - fn kitty_flags(&self) -> super::input::KittyFlags { + pub(super) fn kitty_flags(&self) -> super::input::KittyFlags { super::input::KittyFlags::from_mode(self.terminal.term.lock().mode()) } @@ -6097,6 +6117,34 @@ mod gpui_tests { } } + /// Deliver one printable character the way the running platform actually + /// does. macOS hands all text to the input context, which arrives as + /// `commit_text` (see `input::defer_to_ime`); elsewhere it travels the + /// `on_key_down` / `key_char` path. Tests that assert on *text* input must + /// go through here, or they exercise a path the platform never takes. + fn type_char( + view: &mut TerminalView, + ch: &str, + window: &mut Window, + cx: &mut Context, + ) { + if cfg!(target_os = "macos") { + let _ = window; + view.commit_text(ch, cx); + } else { + let ev = KeyDownEvent { + keystroke: gpui::Keystroke { + modifiers: gpui::Modifiers::default(), + key: ch.to_string(), + key_char: Some(ch.to_string()), + }, + is_held: false, + prefer_character_input: false, + }; + view.on_key_down(&ev, window, cx); + } + } + fn next_input_until_timeout(daemon: &mut UnixStream) -> Option> { use std::io::ErrorKind; @@ -6168,16 +6216,7 @@ mod gpui_tests { !view.input_active(), "shell vi-mode lets the shell line editor own prompt input" ); - let a = KeyDownEvent { - keystroke: gpui::Keystroke { - modifiers: gpui::Modifiers::default(), - key: "a".to_string(), - key_char: Some("a".to_string()), - }, - is_held: false, - prefer_character_input: false, - }; - view.on_key_down(&a, window, cx); + type_char(view, "a", window, cx); assert_eq!( view.cmd.text(), "", @@ -6243,16 +6282,7 @@ mod gpui_tests { window .update(cx, |view, window, cx| { - let i = KeyDownEvent { - keystroke: gpui::Keystroke { - modifiers: gpui::Modifiers::default(), - key: "i".to_string(), - key_char: Some("i".to_string()), - }, - is_held: false, - prefer_character_input: false, - }; - view.on_key_down(&i, window, cx); + type_char(view, "i", window, cx); }) .unwrap(); assert_eq!(