diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-14 23:23:19 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-14 23:26:02 +0300 |
| commit | 464c658fdd3a9257b1eb5095b0ae024a306b29ee (patch) | |
| tree | 30ed49f5ec85c296ec0ed5c3580b1d0795de58c3 /src | |
| parent | 30232287361f753d071ecd6bca7b9dda276ec0f9 (diff) | |
fix: reassemble split sgr mouse input
refs #1382
Diffstat (limited to 'src')
| -rw-r--r-- | src/client/input.rs | 39 | ||||
| -rw-r--r-- | src/raw_input.rs | 154 |
2 files changed, 176 insertions, 17 deletions
diff --git a/src/client/input.rs b/src/client/input.rs index bc7faf35..a49c4316 100644 --- a/src/client/input.rs +++ b/src/client/input.rs @@ -133,8 +133,10 @@ fn idle_flush_timeout_ms( framer: &crate::raw_input::RawInputByteFramer, host_mouse_capture_active: bool, ) -> i32 { - if host_mouse_capture_active && framer.has_pending_lone_escape() { - crate::raw_input::MOUSE_ACTIVE_LONE_ESCAPE_FLUSH_TIMEOUT_MS + if host_mouse_capture_active + && (framer.has_pending_lone_escape() || framer.has_pending_incomplete_sgr_mouse_sequence()) + { + crate::raw_input::MOUSE_ACTIVE_ESCAPE_SEQUENCE_FLUSH_TIMEOUT_MS } else { crate::raw_input::RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS } @@ -402,20 +404,33 @@ mod tests { } #[test] - fn mouse_active_lone_escape_gets_longer_reassembly_window() { - let mut framer = crate::raw_input::RawInputByteFramer::default(); - assert!(framer.push(b"\x1b").is_empty()); - + fn mouse_active_escape_sequences_get_longer_reassembly_window() { + let mut escape = crate::raw_input::RawInputByteFramer::default(); + assert!(escape.push(b"\x1b").is_empty()); + let mut mouse = crate::raw_input::RawInputByteFramer::default(); + assert!(mouse.push(b"\x1b[<3").is_empty()); + let mut unrelated = crate::raw_input::RawInputByteFramer::default(); + assert!(unrelated.push(b"\x1b[49:33;2:").is_empty()); + + for framer in [&escape, &mouse, &unrelated] { + assert_eq!( + idle_flush_timeout_ms(framer, false), + crate::raw_input::RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS + ); + } + for framer in [&escape, &mouse] { + assert_eq!( + idle_flush_timeout_ms(framer, true), + crate::raw_input::MOUSE_ACTIVE_ESCAPE_SEQUENCE_FLUSH_TIMEOUT_MS + ); + } assert_eq!( - idle_flush_timeout_ms(&framer, false), + idle_flush_timeout_ms(&unrelated, true), crate::raw_input::RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS ); - assert_eq!( - idle_flush_timeout_ms(&framer, true), - crate::raw_input::MOUSE_ACTIVE_LONE_ESCAPE_FLUSH_TIMEOUT_MS - ); + let mouse_timeout_ms = - std::hint::black_box(crate::raw_input::MOUSE_ACTIVE_LONE_ESCAPE_FLUSH_TIMEOUT_MS); + std::hint::black_box(crate::raw_input::MOUSE_ACTIVE_ESCAPE_SEQUENCE_FLUSH_TIMEOUT_MS); assert!(mouse_timeout_ms > 100); } } diff --git a/src/raw_input.rs b/src/raw_input.rs index 3bf8cae9..445b4be3 100644 --- a/src/raw_input.rs +++ b/src/raw_input.rs @@ -101,8 +101,7 @@ use crate::terminal_theme::{ const ESC: u8 = 0x1b; pub(crate) const RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS: i32 = 10; -#[cfg(any(not(windows), test))] -pub(crate) const MOUSE_ACTIVE_LONE_ESCAPE_FLUSH_TIMEOUT_MS: i32 = 150; +pub(crate) const MOUSE_ACTIVE_ESCAPE_SEQUENCE_FLUSH_TIMEOUT_MS: i32 = 150; pub(crate) const GHOSTTY_COLOR_SCHEME_DARK_REPORT: &[u8] = b"\x1b[?997;1n"; pub(crate) const GHOSTTY_COLOR_SCHEME_LIGHT_REPORT: &[u8] = b"\x1b[?997;2n"; const BRACKETED_PASTE_START: &[u8] = b"\x1b[200~"; @@ -145,6 +144,10 @@ impl RawInputFramer { self.byte_framer.has_pending_input() } + pub(crate) fn has_pending_incomplete_sgr_mouse_sequence(&self) -> bool { + self.byte_framer.has_pending_incomplete_sgr_mouse_sequence() + } + #[cfg(any(windows, test))] pub(crate) fn has_pending_bracketed_paste(&self) -> bool { self.byte_framer.has_pending_bracketed_paste() @@ -213,6 +216,10 @@ impl RawInputByteFramer { self.buffer.as_slice() == [ESC] } + pub(crate) fn has_pending_incomplete_sgr_mouse_sequence(&self) -> bool { + starts_with_incomplete_sgr_mouse_sequence(&self.buffer) + } + #[cfg(any(windows, test))] pub(crate) fn has_pending_bracketed_paste(&self) -> bool { self.buffer.starts_with(BRACKETED_PASTE_START) @@ -223,6 +230,13 @@ impl RawInputByteFramer { let mut chunks = self.drain_available_chunks(); if let Some(family) = self.discard_until { + if family == ControlStringFamily::OrphanedSgrMouseTail { + self.buffer.clear(); + self.discard_until = None; + self.discarded_tail_bytes = 0; + return chunks; + } + let keep_split_st = self.buffer.last() == Some(&ESC); let keep_discarding = plausible_control_string_tail(family, &self.buffer); self.discarded_tail_bytes = self.discarded_tail_bytes.saturating_add(self.buffer.len()); @@ -256,6 +270,18 @@ impl RawInputByteFramer { return chunks; } + if starts_with_incomplete_sgr_mouse_sequence(&self.buffer) { + tracing::debug!( + bytes = ?self.buffer, + "discarding incomplete SGR mouse sequence after input timeout" + ); + self.discarded_tail_bytes = self.buffer.len(); + self.discard_until = (self.discarded_tail_bytes <= MAX_DISCARDED_CONTROL_TAIL_BYTES) + .then_some(ControlStringFamily::OrphanedSgrMouseTail); + self.buffer.clear(); + return chunks; + } + if self.buffer.starts_with(BRACKETED_PASTE_START) && find_subsequence(&self.buffer, BRACKETED_PASTE_END).is_none() { @@ -356,6 +382,18 @@ impl RawInputByteFramer { } if let Some(family) = self.discard_until { + if family == ControlStringFamily::OrphanedSgrMouseTail { + if discard_orphaned_sgr_mouse_tail( + &mut self.buffer, + &mut self.discarded_tail_bytes, + ) { + self.discard_until = None; + self.discarded_tail_bytes = 0; + continue; + } + break; + } + let Some(terminator_len) = control_string_terminator_for_family(&self.buffer, family) else { @@ -438,6 +476,14 @@ pub(crate) fn events_require_host_terminal_theme_query(events: &[RawInputEvent]) .any(|event| matches!(event, RawInputEvent::HostColorSchemeChanged(_))) } +fn input_flush_timeout_ms(framer: &RawInputFramer) -> i32 { + if framer.has_pending_incomplete_sgr_mouse_sequence() { + MOUSE_ACTIVE_ESCAPE_SEQUENCE_FLUSH_TIMEOUT_MS + } else { + RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS + } +} + pub fn spawn_input_reader() -> mpsc::Receiver<RawInputEvent> { let (tx, rx) = mpsc::channel(256); @@ -455,7 +501,7 @@ pub fn spawn_input_reader() -> mpsc::Receiver<RawInputEvent> { Ok(n) => { send_raw_input_events(framer.push(&scratch[..n]), &tx); - if stdin_read_ready(&reader, RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS) == Some(false) { + if stdin_read_ready(&reader, input_flush_timeout_ms(&framer)) == Some(false) { let had_pending = framer.has_pending_input(); let events = framer.flush_timeout(); let held_escape = had_pending && events.is_empty(); @@ -753,6 +799,13 @@ fn complete_escape_sequence_len(buffer: &[u8]) -> Option<usize> { Some(1 + escaped_char_width) } +fn starts_with_incomplete_sgr_mouse_sequence(buffer: &[u8]) -> bool { + buffer.starts_with(b"\x1b[<") + && buffer[3..] + .iter() + .all(|byte| byte.is_ascii_digit() || *byte == b';') +} + fn starts_with_incomplete_orphaned_sgr_mouse_tail(buffer: &[u8]) -> bool { if buffer.len() > MAX_ORPHANED_SGR_MOUSE_TAIL_BYTES { return false; @@ -792,10 +845,39 @@ fn discard_or_buffer_orphaned_sgr_mouse_tail( discarded_tail_bytes: &mut usize, ) { if !discard_complete_orphaned_sgr_mouse_tail(buffer) { - *discard_until = Some(ControlStringFamily::OrphanedSgrMouseTail); - *discarded_tail_bytes = 0; + *discarded_tail_bytes = buffer.len(); + *discard_until = (*discarded_tail_bytes <= MAX_DISCARDED_CONTROL_TAIL_BYTES) + .then_some(ControlStringFamily::OrphanedSgrMouseTail); + buffer.clear(); + } +} + +fn discard_orphaned_sgr_mouse_tail(buffer: &mut Vec<u8>, discarded_tail_bytes: &mut usize) -> bool { + let remaining = MAX_DISCARDED_CONTROL_TAIL_BYTES.saturating_sub(*discarded_tail_bytes); + let inspected = buffer.len().min(remaining); + + for index in 0..inspected { + match buffer[index] { + b'0'..=b'9' | b';' => {} + b'M' | b'm' => { + buffer.drain(..=index); + return true; + } + _ => { + buffer.drain(..index); + return true; + } + } + } + + *discarded_tail_bytes = discarded_tail_bytes.saturating_add(inspected); + if buffer.len() > inspected { buffer.clear(); + return true; } + + buffer.clear(); + false } fn osc_string_terminator(buffer: &[u8]) -> Option<usize> { @@ -1491,6 +1573,68 @@ mod tests { } #[test] + fn legacy_reader_extends_only_incomplete_sgr_mouse_timeout() { + let mut mouse = RawInputFramer::default(); + assert!(mouse.push(b"\x1b[<3").is_empty()); + assert_eq!( + input_flush_timeout_ms(&mouse), + MOUSE_ACTIVE_ESCAPE_SEQUENCE_FLUSH_TIMEOUT_MS + ); + + let mut escape = RawInputFramer::default(); + assert!(escape.push(b"\x1b").is_empty()); + assert_eq!( + input_flush_timeout_ms(&escape), + RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS + ); + } + + #[test] + fn sgr_mouse_sequence_split_after_button_prefix_is_reassembled_before_timeout() { + let mut framer = RawInputFramer::default(); + + assert!(framer.push(b"\x1b[<3").is_empty()); + let events = framer.push(b"5;58;30M"); + + assert_eq!(events.len(), 1); + assert!(matches!( + events[0], + RawInputEvent::Mouse(MouseEvent { + kind: MouseEventKind::Moved, + column: 57, + row: 29, + .. + }) + )); + } + + #[test] + fn timed_out_split_sgr_mouse_tail_is_discarded_and_following_input_is_preserved() { + let mut framer = RawInputFramer::default(); + + assert!(framer.push(b"\x1b[<3").is_empty()); + assert!(framer.flush_timeout().is_empty()); + let events = framer.push(b"5;58;30Mx"); + + assert_eq!(events.len(), 1); + assert_raw_key( + events.into_iter().next().unwrap(), + KeyCode::Char('x'), + KeyModifiers::empty(), + ); + } + + #[test] + fn timed_out_sgr_mouse_discard_state_clears_at_quiescence() { + let mut framer = RawInputByteFramer::default(); + + assert!(framer.push(b"\x1b[<3").is_empty()); + assert!(framer.flush_timeout().is_empty()); + assert!(framer.flush_timeout().is_empty()); + assert_eq!(framer.push(b"M"), vec![b"M".to_vec()]); + } + + #[test] fn sgr_mouse_tail_after_lone_escape_timeout_is_discarded() { let mut framer = RawInputFramer::default(); |
