mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 08:02:24 +00:00
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:
+153
-2
@@ -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
@@ -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!(
|
||||
|
||||
Reference in New Issue
Block a user