fix(input): carry key and preedit text as bytes like Zig

Zig's KeyEvent.utf8 and the embedded API's key/preedit text are byte
slices passed through to key encoding and the pty; the port converted
them lossily to UTF-8.
- KeyEvent.utf8 is &[u8] end to end (ghostty-input encoder, core,
  ghostty-lib C API, libghostty-vt key events, GTK callers). Where Zig
  validates with Utf8View (alternates, modify-other-keys, ctrl CSI-u,
  associated text, binding unicode lookup) invalid text takes the same
  "no UTF-8" fallback.
- Invalid preedit UTF-8 behaves like Zig's `try Utf8View.init`: the
  prior preedit is cleared and marked dirty, the callback returns an
  error (logged by the C API) and no preedit is set.
- The inspector key widget shows invalid key text lossily, display only.

Test Plan:
- cargo test --workspace --no-fail-fast (3407 passed)
- cargo test -p ghostty-lib --features test-platform (C-API key text
  0xff 0xfe reaches the pty unchanged)
- just check-macos (both), just check-windows, just check-wasm
This commit is contained in:
ddidderr committed 2026-10-12 11:01:17 +02:00
1 parent 1327e63811
commit c8113345a5
19 files changed
+277 -144

No files matched your search

+3 -3
View File
@@ -92,7 +92,7 @@ pub struct OwnedKeyEvent {
pub mods: Mods,
pub consumed_mods: Mods,
pub composing: bool,
pub utf8: String,
pub utf8: Vec<u8>,
pub unshifted_codepoint: u32,
}
@@ -112,7 +112,7 @@ impl OwnedKeyEvent {
mods: e.mods,
consumed_mods: e.consumed_mods,
composing: e.composing,
utf8: e.utf8.to_owned(),
utf8: e.utf8.to_vec(),
unshifted_codepoint: e.unshifted_codepoint,
}
}
@@ -1067,7 +1067,7 @@ mod tests {
ctrl: true,
..Mods::default()
},
utf8: "a",
utf8: "a".as_bytes(),
unshifted_codepoint: 'a' as u32,
..KeyEvent::default()
};
+12 -3
View File
@@ -1679,7 +1679,7 @@ impl Surface {
/// The core surface will NOT reset the preedit state on charCallback or
/// keyCallback and we rely completely on the apprt implementation to
/// track the preedit state correctly.
pub fn preedit_callback(&self, preedit: Option<&str>) -> Result<(), CoreError> {
pub fn preedit_callback(&self, preedit: Option<&[u8]>) -> Result<(), CoreError> {
let cfg = self.cfg();
let mut st = self.lock_state();
@@ -1704,7 +1704,16 @@ impl Surface {
return Ok(());
};
// We convert the UTF-8 text to codepoints.
// We convert the UTF-8 text to codepoints. Like Zig's
// `Utf8View.init(text)`, invalid UTF-8 is an error: the prior preedit
// is already cleared and marked dirty, and no new preedit is set.
let text = match std::str::from_utf8(text) {
Ok(t) => t,
Err(e) => {
drop(st);
return Err(Box::new(e));
}
};
let mut codepoints: Vec<PreeditCodepoint> = Vec::new();
for cp in text.chars() {
let width = usize::from(ghostty_unicode::table().get(u32::from(cp)).width);
@@ -1993,7 +2002,7 @@ impl CoreSurface for Surface {
Surface::key_callback(self, event)
}
fn preedit_callback(&self, text: Option<&str>) -> Result<(), CoreError> {
fn preedit_callback(&self, text: Option<&[u8]>) -> Result<(), CoreError> {
let _crash = self.crash_guard();
Surface::preedit_callback(self, text)
}
+2 -2
View File
@@ -486,7 +486,7 @@ impl Harness {
Mods::default()
},
composing: false,
utf8,
utf8: utf8.as_bytes(),
unshifted_codepoint: unshifted,
});
// GTK reports the same text/codepoint on release.
@@ -496,7 +496,7 @@ impl Harness {
mods,
consumed_mods: Mods::default(),
composing: false,
utf8,
utf8: utf8.as_bytes(),
unshifted_codepoint: unshifted,
});
eff
+20 -6
View File
@@ -36,7 +36,7 @@ fn preedit_state_and_ime_point() {
let h = harness!(Harness::recorder("", &[]));
let before = h.surface.ime_point();
h.surface
.preedit_callback(Some("\u{3042}\u{3044}"))
.preedit_callback(Some("\u{3042}\u{3044}".as_bytes()))
.unwrap();
{
let st = h.surface.lock_state();
@@ -57,6 +57,20 @@ fn preedit_state_and_ime_point() {
assert_eq!(h.recorded(), Vec::<u8>::new());
}
#[test]
fn preedit_with_invalid_utf8_is_an_error_and_clears_prior() {
let h = harness!(Harness::recorder("", &[]));
// Like Zig's preeditCallback (`try Utf8View.init(text)`): invalid UTF-8
// returns an error after the prior preedit has been cleared, and no new
// preedit is set. Nothing is written to the pty.
h.surface.preedit_callback(Some(b"ab")).unwrap();
assert!(h.surface.lock_state().preedit.is_some());
assert!(h.surface.preedit_callback(Some(b"a\xffb\xc3")).is_err());
assert!(h.surface.lock_state().preedit.is_none());
h.settle(50);
assert_eq!(h.recorded(), Vec::<u8>::new());
}
#[test]
fn preedit_is_rendered_and_cleared() {
let h = harness!(Harness::recorder("", &[]));
@@ -71,7 +85,7 @@ fn preedit_is_rendered_and_cleared() {
});
b
};
h.surface.preedit_callback(Some("abcd")).unwrap();
h.surface.preedit_callback(Some("abcd".as_bytes())).unwrap();
h.wait("preedit visible", |h| {
let r = region(h, cols.clone(), 0);
!r.is_empty() && r != baseline
@@ -89,7 +103,7 @@ fn commit_sends_text_and_composing_keys_do_not() {
h.key_event(KeyEvent {
key: Key::KeyK,
composing: true,
utf8: "k",
utf8: "k".as_bytes(),
unshifted_codepoint: 'k' as u32,
..KeyEvent::default()
});
@@ -98,12 +112,12 @@ fn commit_sends_text_and_composing_keys_do_not() {
// Commit: preedit ends, committed text is sent with no key.
h.surface
.preedit_callback(Some("\u{3053}\u{3093}"))
.preedit_callback(Some("\u{3053}\u{3093}".as_bytes()))
.unwrap();
h.surface.preedit_callback(None).unwrap();
h.key_event(KeyEvent {
key: Key::Unidentified,
utf8: "\u{3053}\u{3093}\u{306b}\u{3061}\u{306f}",
utf8: "\u{3053}\u{3093}\u{306b}\u{3061}\u{306f}".as_bytes(),
..KeyEvent::default()
});
h.expect_input("\u{3053}\u{3093}\u{306b}\u{3061}\u{306f}".as_bytes());
@@ -115,7 +129,7 @@ fn enter_with_committed_text_sends_text_not_cr() {
let h = harness!(Harness::recorder("", &[]));
h.key_event(KeyEvent {
key: Key::Enter,
utf8: "\u{6f22}",
utf8: "\u{6f22}".as_bytes(),
..KeyEvent::default()
});
h.expect_input("\u{6f22}".as_bytes());
+1 -1
View File
@@ -250,7 +250,7 @@ fn key_release_without_press_is_ignored_in_legacy() {
h.key_event(ghostty_input::KeyEvent {
action: ghostty_input::key::Action::Release,
key: Key::KeyA,
utf8: "a",
utf8: "a".as_bytes(),
..Default::default()
});
h.type_text("b");