Merge pull request #132 from l0ng-ai/fix/macos-ime-text-input

fix(input): route macOS text through the IME so synthesized keys keep their text
This commit is contained in:
l0ng-ai
2026-07-19 19:15:23 +08:00
committed by GitHub
2 changed files with 204 additions and 23 deletions
+153 -2
View File
@@ -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 <code>;<mods>[;<text>]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 <code>;<mods>[;<text>]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<Vec<u8>> {
@@ -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 {
+51 -21
View File
@@ -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<TerminalView>,
) {
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<Vec<u8>> {
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!(