aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-12 15:54:19 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-12 15:54:19 +0300
commit4af28fbb6551cd33596096af422895c1998fa3e8 (patch)
tree06d92a8a1cf53a2d85129f0e6d3e9701a75eda1b /src
parent8375922c069a244133d083a0bb50273e134be6b1 (diff)
test: add ime input regressions
Diffstat (limited to 'src')
-rw-r--r--src/app/mod.rs50
-rw-r--r--src/client/mod.rs8
-rw-r--r--src/input/mod.rs5
-rw-r--r--src/input/model.rs18
-rw-r--r--src/main.rs8
-rw-r--r--src/pane.rs15
-rw-r--r--src/raw_input.rs97
-rw-r--r--src/server/protocol.rs17
8 files changed, 201 insertions, 17 deletions
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 43d15bec..7534559f 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -1979,6 +1979,56 @@ mod tests {
assert!(rx.try_recv().is_err());
}
+ #[tokio::test]
+ async fn route_client_input_forwards_multilingual_ime_text_to_focused_pane() {
+ let mut app = test_app();
+ let mut workspace = Workspace::test_new("test");
+ let focused = workspace.focused_pane_id().unwrap();
+ let text = "中日한🙂";
+ let (runtime, mut rx) =
+ PaneRuntime::test_with_channel_capacity(80, 24, text.chars().count());
+ workspace.tabs[0].runtimes.insert(focused, runtime);
+ app.state.workspaces = vec![workspace];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+
+ app.route_client_input(text.as_bytes().to_vec());
+
+ let mut forwarded = Vec::new();
+ for _ in text.chars() {
+ let chunk = rx.recv().await.unwrap();
+ forwarded.extend_from_slice(&chunk);
+ }
+ assert_eq!(forwarded, text.as_bytes());
+ assert!(rx.try_recv().is_err());
+ }
+
+ #[tokio::test]
+ async fn route_client_input_forwards_long_voice_like_cjk_text_without_truncation() {
+ let mut app = test_app();
+ let mut workspace = Workspace::test_new("test");
+ let focused = workspace.focused_pane_id().unwrap();
+ let text = "你好,今天我们测试一段比较长的语音输入。こんにちは。안녕하세요.🙂".repeat(64);
+ let char_count = text.chars().count();
+ let (runtime, mut rx) = PaneRuntime::test_with_channel_capacity(80, 24, char_count);
+ workspace.tabs[0].runtimes.insert(focused, runtime);
+ app.state.workspaces = vec![workspace];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+
+ app.route_client_input(text.as_bytes().to_vec());
+
+ let mut forwarded = Vec::new();
+ for _ in 0..char_count {
+ let chunk = rx.recv().await.unwrap();
+ forwarded.extend_from_slice(&chunk);
+ }
+ assert_eq!(forwarded, text.as_bytes());
+ assert!(rx.try_recv().is_err());
+ }
+
#[test]
fn route_client_input_handles_mouse_events() {
let mut app = test_app();
diff --git a/src/client/mod.rs b/src/client/mod.rs
index 475a079f..e378427f 100644
--- a/src/client/mod.rs
+++ b/src/client/mod.rs
@@ -23,7 +23,7 @@ use std::time::Duration;
use crossterm::event::{
DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste,
- EnableFocusChange, EnableMouseCapture, KeyboardEnhancementFlags, PopKeyboardEnhancementFlags,
+ EnableFocusChange, EnableMouseCapture, PopKeyboardEnhancementFlags,
PushKeyboardEnhancementFlags,
};
use crossterm::execute;
@@ -146,11 +146,7 @@ fn setup_terminal() -> io::Result<TerminalGuard> {
EnableMouseCapture,
EnableBracketedPaste,
EnableFocusChange,
- PushKeyboardEnhancementFlags(
- KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
- | KeyboardEnhancementFlags::REPORT_EVENT_TYPES
- | KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS
- )
+ PushKeyboardEnhancementFlags(crate::input::ime_compatible_keyboard_enhancement_flags())
)?;
// tmux doesn't understand kitty keyboard protocol push.
diff --git a/src/input/mod.rs b/src/input/mod.rs
index a2775e40..b4886256 100644
--- a/src/input/mod.rs
+++ b/src/input/mod.rs
@@ -6,5 +6,8 @@ mod parse;
pub use encode::{
encode_cursor_key, encode_mouse_button, encode_mouse_scroll, encode_terminal_key,
};
-pub use model::{KeyboardProtocol, MouseProtocolEncoding, MouseProtocolMode, TerminalKey};
+pub use model::{
+ ime_compatible_keyboard_enhancement_flags, KeyboardProtocol, MouseProtocolEncoding,
+ MouseProtocolMode, TerminalKey,
+};
pub use parse::parse_terminal_key_sequence;
diff --git a/src/input/model.rs b/src/input/model.rs
index 181ba7b3..95732bbc 100644
--- a/src/input/model.rs
+++ b/src/input/model.rs
@@ -1,4 +1,4 @@
-use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
+use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, KeyboardEnhancementFlags};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TerminalKey {
@@ -40,6 +40,12 @@ impl From<KeyEvent> for TerminalKey {
}
}
+pub fn ime_compatible_keyboard_enhancement_flags() -> KeyboardEnhancementFlags {
+ KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
+ | KeyboardEnhancementFlags::REPORT_EVENT_TYPES
+ | KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS
+}
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KeyboardProtocol {
Legacy,
@@ -97,4 +103,14 @@ mod tests {
KeyboardProtocol::Kitty { flags: 7 }
);
}
+
+ #[test]
+ fn keyboard_enhancement_flags_stay_ime_compatible() {
+ let flags = ime_compatible_keyboard_enhancement_flags();
+
+ assert!(flags.contains(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES));
+ assert!(flags.contains(KeyboardEnhancementFlags::REPORT_EVENT_TYPES));
+ assert!(flags.contains(KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS));
+ assert!(!flags.contains(KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES));
+ }
}
diff --git a/src/main.rs b/src/main.rs
index f4588bdc..dceb1230 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -2,7 +2,7 @@ use std::io;
use crossterm::event::{
DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste,
- EnableFocusChange, EnableMouseCapture, KeyboardEnhancementFlags, PopKeyboardEnhancementFlags,
+ EnableFocusChange, EnableMouseCapture, PopKeyboardEnhancementFlags,
PushKeyboardEnhancementFlags,
};
use crossterm::execute;
@@ -454,11 +454,7 @@ fn main() -> io::Result<()> {
EnableMouseCapture,
EnableBracketedPaste,
EnableFocusChange,
- PushKeyboardEnhancementFlags(
- KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
- | KeyboardEnhancementFlags::REPORT_EVENT_TYPES
- | KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS
- )
+ PushKeyboardEnhancementFlags(crate::input::ime_compatible_keyboard_enhancement_flags())
)?;
// tmux doesn't understand kitty keyboard protocol push (\e[>1u).
diff --git a/src/pane.rs b/src/pane.rs
index bf669ecf..98edf403 100644
--- a/src/pane.rs
+++ b/src/pane.rs
@@ -925,7 +925,15 @@ impl PaneRuntime {
#[cfg(test)]
impl PaneRuntime {
pub(crate) fn test_with_channel(cols: u16, rows: u16) -> (Self, mpsc::Receiver<Bytes>) {
- Self::test_with_channel_and_scrollback_bytes(cols, rows, 0, &[])
+ Self::test_with_channel_and_scrollback_bytes(cols, rows, 0, &[], 4)
+ }
+
+ pub(crate) fn test_with_channel_capacity(
+ cols: u16,
+ rows: u16,
+ capacity: usize,
+ ) -> (Self, mpsc::Receiver<Bytes>) {
+ Self::test_with_channel_and_scrollback_bytes(cols, rows, 0, &[], capacity)
}
pub(crate) fn test_with_screen_bytes(cols: u16, rows: u16, bytes: &[u8]) -> Self {
@@ -938,7 +946,7 @@ impl PaneRuntime {
scrollback_limit_bytes: usize,
bytes: &[u8],
) -> Self {
- Self::test_with_channel_and_scrollback_bytes(cols, rows, scrollback_limit_bytes, bytes).0
+ Self::test_with_channel_and_scrollback_bytes(cols, rows, scrollback_limit_bytes, bytes, 4).0
}
fn test_with_channel_and_scrollback_bytes(
@@ -946,8 +954,9 @@ impl PaneRuntime {
rows: u16,
scrollback_limit_bytes: usize,
bytes: &[u8],
+ channel_capacity: usize,
) -> (Self, mpsc::Receiver<Bytes>) {
- let (tx, rx) = mpsc::channel(4);
+ let (tx, rx) = mpsc::channel(channel_capacity);
let (resize_tx, _resize_rx) = watch::channel((rows, cols));
let mut terminal =
crate::ghostty::Terminal::new(cols, rows, scrollback_limit_bytes).unwrap();
diff --git a/src/raw_input.rs b/src/raw_input.rs
index 0cf9d84b..aa231595 100644
--- a/src/raw_input.rs
+++ b/src/raw_input.rs
@@ -1007,6 +1007,103 @@ mod tests {
}
#[test]
+ fn chunked_cjk_utf8_waits_for_all_continuation_bytes() {
+ let (tx, mut rx) = mpsc::channel(8);
+ let mut buffer = Vec::new();
+ let bytes = "好".as_bytes();
+
+ drain_chunk(&mut buffer, &tx, &bytes[..1]);
+ assert_eq!(buffer, bytes[..1]);
+ assert!(collect_events(&mut rx).is_empty());
+ flush_incomplete_buffer(&mut buffer, &tx);
+ assert_eq!(buffer, bytes[..1]);
+ assert!(collect_events(&mut rx).is_empty());
+
+ drain_chunk(&mut buffer, &tx, &bytes[1..2]);
+ assert_eq!(buffer, bytes[..2]);
+ assert!(collect_events(&mut rx).is_empty());
+ flush_incomplete_buffer(&mut buffer, &tx);
+ assert_eq!(buffer, bytes[..2]);
+ assert!(collect_events(&mut rx).is_empty());
+
+ drain_chunk(&mut buffer, &tx, &bytes[2..]);
+ assert!(buffer.is_empty());
+ let events = collect_events(&mut rx);
+ assert_eq!(events.len(), 1);
+ assert_raw_key(
+ events.into_iter().next().unwrap(),
+ KeyCode::Char('好'),
+ KeyModifiers::empty(),
+ );
+ }
+
+ #[test]
+ fn chunked_four_byte_utf8_waits_for_all_continuation_bytes() {
+ let (tx, mut rx) = mpsc::channel(8);
+ let mut buffer = Vec::new();
+ let bytes = "🙂".as_bytes();
+
+ for split in 1..bytes.len() {
+ drain_chunk(&mut buffer, &tx, &bytes[split - 1..split]);
+ assert_eq!(buffer, bytes[..split]);
+ assert!(collect_events(&mut rx).is_empty());
+ flush_incomplete_buffer(&mut buffer, &tx);
+ assert_eq!(buffer, bytes[..split]);
+ assert!(collect_events(&mut rx).is_empty());
+ }
+
+ drain_chunk(&mut buffer, &tx, &bytes[bytes.len() - 1..]);
+ assert!(buffer.is_empty());
+ let events = collect_events(&mut rx);
+ assert_eq!(events.len(), 1);
+ assert_raw_key(
+ events.into_iter().next().unwrap(),
+ KeyCode::Char('🙂'),
+ KeyModifiers::empty(),
+ );
+ }
+
+ #[test]
+ fn long_multilingual_voice_like_burst_drains_without_truncation() {
+ let text = "你好,今天我们测试一段比较长的语音输入。こんにちは。안녕하세요.🙂".repeat(128);
+ assert!(
+ text.len() > 4096,
+ "test input should exceed the client read buffer"
+ );
+ let mut buffer = text.as_bytes().to_vec();
+
+ let chunks = drain_complete_input_bytes(&mut buffer);
+ let rebuilt: Vec<u8> = chunks.into_iter().flatten().collect();
+
+ assert!(buffer.is_empty());
+ assert_eq!(rebuilt, text.as_bytes());
+ }
+
+ #[test]
+ fn long_multilingual_burst_survives_one_byte_chunks_and_timeouts() {
+ let text = "中文かなカナ한글🙂,。".repeat(64);
+ let mut buffer = Vec::new();
+ let mut rebuilt = Vec::new();
+
+ for byte in text.as_bytes() {
+ buffer.push(*byte);
+ for chunk in drain_complete_input_bytes(&mut buffer) {
+ rebuilt.extend(chunk);
+ }
+ if !buffer.is_empty() {
+ assert_eq!(flush_incomplete_input_bytes(&mut buffer), None);
+ }
+ }
+
+ for chunk in drain_complete_input_bytes(&mut buffer) {
+ rebuilt.extend(chunk);
+ }
+
+ assert!(buffer.is_empty());
+ assert_eq!(rebuilt, text.as_bytes());
+ }
+
+ #[test]
fn parse_with_ranges_tracks_byte_offsets() {
use super::parse_raw_input_bytes_with_ranges;
diff --git a/src/server/protocol.rs b/src/server/protocol.rs
index 15aa3784..5eda0231 100644
--- a/src/server/protocol.rs
+++ b/src/server/protocol.rs
@@ -566,6 +566,23 @@ mod tests {
}
#[test]
+ fn client_input_large_multilingual_payload_roundtrip() {
+ let text = "你好,今天我们测试一段比较长的语音输入。こんにちは。안녕하세요.🙂".repeat(1024);
+ assert!(text.len() > 64 * 1024);
+ assert!(text.len() < MAX_FRAME_SIZE);
+ let msg = ClientMessage::Input {
+ data: text.as_bytes().to_vec(),
+ };
+
+ let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
+ let (decoded, consumed): (ClientMessage, _) =
+ bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
+
+ assert_eq!(consumed, encoded.len());
+ assert_eq!(decoded, msg);
+ }
+
+ #[test]
fn client_resize_roundtrip() {
let msg = ClientMessage::Resize { cols: 80, rows: 24 };
let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();