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 { 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 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) { - 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) { + 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) { - 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 @@ -1006,6 +1006,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 = 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 @@ -565,6 +565,23 @@ mod tests { assert_eq!(msg, decoded); } + #[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 };