Skip to content

Commit 464c658

Browse files
committed
fix: reassemble split sgr mouse input
refs #1382
1 parent 3023228 commit 464c658

2 files changed

Lines changed: 176 additions & 17 deletions

File tree

‎src/client/input.rs‎

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -133,8 +133,10 @@ fn idle_flush_timeout_ms(
133133
framer: &crate::raw_input::RawInputByteFramer,
134134
host_mouse_capture_active: bool,
135135
) -> i32 {
136-
if host_mouse_capture_active && framer.has_pending_lone_escape() {
137-
crate::raw_input::MOUSE_ACTIVE_LONE_ESCAPE_FLUSH_TIMEOUT_MS
136+
if host_mouse_capture_active
137+
&& (framer.has_pending_lone_escape() || framer.has_pending_incomplete_sgr_mouse_sequence())
138+
{
139+
crate::raw_input::MOUSE_ACTIVE_ESCAPE_SEQUENCE_FLUSH_TIMEOUT_MS
138140
} else {
139141
crate::raw_input::RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS
140142
}
@@ -402,20 +404,33 @@ mod tests {
402404
}
403405

404406
#[test]
405-
fn mouse_active_lone_escape_gets_longer_reassembly_window() {
406-
let mut framer = crate::raw_input::RawInputByteFramer::default();
407-
assert!(framer.push(b"\x1b").is_empty());
408-
407+
fn mouse_active_escape_sequences_get_longer_reassembly_window() {
408+
let mut escape = crate::raw_input::RawInputByteFramer::default();
409+
assert!(escape.push(b"\x1b").is_empty());
410+
let mut mouse = crate::raw_input::RawInputByteFramer::default();
411+
assert!(mouse.push(b"\x1b[<3").is_empty());
412+
let mut unrelated = crate::raw_input::RawInputByteFramer::default();
413+
assert!(unrelated.push(b"\x1b[49:33;2:").is_empty());
414+
415+
for framer in [&escape, &mouse, &unrelated] {
416+
assert_eq!(
417+
idle_flush_timeout_ms(framer, false),
418+
crate::raw_input::RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS
419+
);
420+
}
421+
for framer in [&escape, &mouse] {
422+
assert_eq!(
423+
idle_flush_timeout_ms(framer, true),
424+
crate::raw_input::MOUSE_ACTIVE_ESCAPE_SEQUENCE_FLUSH_TIMEOUT_MS
425+
);
426+
}
409427
assert_eq!(
410-
idle_flush_timeout_ms(&framer, false),
428+
idle_flush_timeout_ms(&unrelated, true),
411429
crate::raw_input::RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS
412430
);
413-
assert_eq!(
414-
idle_flush_timeout_ms(&framer, true),
415-
crate::raw_input::MOUSE_ACTIVE_LONE_ESCAPE_FLUSH_TIMEOUT_MS
416-
);
431+
417432
let mouse_timeout_ms =
418-
std::hint::black_box(crate::raw_input::MOUSE_ACTIVE_LONE_ESCAPE_FLUSH_TIMEOUT_MS);
433+
std::hint::black_box(crate::raw_input::MOUSE_ACTIVE_ESCAPE_SEQUENCE_FLUSH_TIMEOUT_MS);
419434
assert!(mouse_timeout_ms > 100);
420435
}
421436
}

‎src/raw_input.rs‎

Lines changed: 149 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -101,8 +101,7 @@ use crate::terminal_theme::{
101101

102102
const ESC: u8 = 0x1b;
103103
pub(crate) const RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS: i32 = 10;
104-
#[cfg(any(not(windows), test))]
105-
pub(crate) const MOUSE_ACTIVE_LONE_ESCAPE_FLUSH_TIMEOUT_MS: i32 = 150;
104+
pub(crate) const MOUSE_ACTIVE_ESCAPE_SEQUENCE_FLUSH_TIMEOUT_MS: i32 = 150;
106105
pub(crate) const GHOSTTY_COLOR_SCHEME_DARK_REPORT: &[u8] = b"\x1b[?997;1n";
107106
pub(crate) const GHOSTTY_COLOR_SCHEME_LIGHT_REPORT: &[u8] = b"\x1b[?997;2n";
108107
const BRACKETED_PASTE_START: &[u8] = b"\x1b[200~";
@@ -145,6 +144,10 @@ impl RawInputFramer {
145144
self.byte_framer.has_pending_input()
146145
}
147146

147+
pub(crate) fn has_pending_incomplete_sgr_mouse_sequence(&self) -> bool {
148+
self.byte_framer.has_pending_incomplete_sgr_mouse_sequence()
149+
}
150+
148151
#[cfg(any(windows, test))]
149152
pub(crate) fn has_pending_bracketed_paste(&self) -> bool {
150153
self.byte_framer.has_pending_bracketed_paste()
@@ -213,6 +216,10 @@ impl RawInputByteFramer {
213216
self.buffer.as_slice() == [ESC]
214217
}
215218

219+
pub(crate) fn has_pending_incomplete_sgr_mouse_sequence(&self) -> bool {
220+
starts_with_incomplete_sgr_mouse_sequence(&self.buffer)
221+
}
222+
216223
#[cfg(any(windows, test))]
217224
pub(crate) fn has_pending_bracketed_paste(&self) -> bool {
218225
self.buffer.starts_with(BRACKETED_PASTE_START)
@@ -223,6 +230,13 @@ impl RawInputByteFramer {
223230
let mut chunks = self.drain_available_chunks();
224231

225232
if let Some(family) = self.discard_until {
233+
if family == ControlStringFamily::OrphanedSgrMouseTail {
234+
self.buffer.clear();
235+
self.discard_until = None;
236+
self.discarded_tail_bytes = 0;
237+
return chunks;
238+
}
239+
226240
let keep_split_st = self.buffer.last() == Some(&ESC);
227241
let keep_discarding = plausible_control_string_tail(family, &self.buffer);
228242
self.discarded_tail_bytes = self.discarded_tail_bytes.saturating_add(self.buffer.len());
@@ -256,6 +270,18 @@ impl RawInputByteFramer {
256270
return chunks;
257271
}
258272

273+
if starts_with_incomplete_sgr_mouse_sequence(&self.buffer) {
274+
tracing::debug!(
275+
bytes = ?self.buffer,
276+
"discarding incomplete SGR mouse sequence after input timeout"
277+
);
278+
self.discarded_tail_bytes = self.buffer.len();
279+
self.discard_until = (self.discarded_tail_bytes <= MAX_DISCARDED_CONTROL_TAIL_BYTES)
280+
.then_some(ControlStringFamily::OrphanedSgrMouseTail);
281+
self.buffer.clear();
282+
return chunks;
283+
}
284+
259285
if self.buffer.starts_with(BRACKETED_PASTE_START)
260286
&& find_subsequence(&self.buffer, BRACKETED_PASTE_END).is_none()
261287
{
@@ -356,6 +382,18 @@ impl RawInputByteFramer {
356382
}
357383

358384
if let Some(family) = self.discard_until {
385+
if family == ControlStringFamily::OrphanedSgrMouseTail {
386+
if discard_orphaned_sgr_mouse_tail(
387+
&mut self.buffer,
388+
&mut self.discarded_tail_bytes,
389+
) {
390+
self.discard_until = None;
391+
self.discarded_tail_bytes = 0;
392+
continue;
393+
}
394+
break;
395+
}
396+
359397
let Some(terminator_len) =
360398
control_string_terminator_for_family(&self.buffer, family)
361399
else {
@@ -438,6 +476,14 @@ pub(crate) fn events_require_host_terminal_theme_query(events: &[RawInputEvent])
438476
.any(|event| matches!(event, RawInputEvent::HostColorSchemeChanged(_)))
439477
}
440478

479+
fn input_flush_timeout_ms(framer: &RawInputFramer) -> i32 {
480+
if framer.has_pending_incomplete_sgr_mouse_sequence() {
481+
MOUSE_ACTIVE_ESCAPE_SEQUENCE_FLUSH_TIMEOUT_MS
482+
} else {
483+
RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS
484+
}
485+
}
486+
441487
pub fn spawn_input_reader() -> mpsc::Receiver<RawInputEvent> {
442488
let (tx, rx) = mpsc::channel(256);
443489

@@ -455,7 +501,7 @@ pub fn spawn_input_reader() -> mpsc::Receiver<RawInputEvent> {
455501
Ok(n) => {
456502
send_raw_input_events(framer.push(&scratch[..n]), &tx);
457503

458-
if stdin_read_ready(&reader, RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS) == Some(false) {
504+
if stdin_read_ready(&reader, input_flush_timeout_ms(&framer)) == Some(false) {
459505
let had_pending = framer.has_pending_input();
460506
let events = framer.flush_timeout();
461507
let held_escape = had_pending && events.is_empty();
@@ -753,6 +799,13 @@ fn complete_escape_sequence_len(buffer: &[u8]) -> Option<usize> {
753799
Some(1 + escaped_char_width)
754800
}
755801

802+
fn starts_with_incomplete_sgr_mouse_sequence(buffer: &[u8]) -> bool {
803+
buffer.starts_with(b"\x1b[<")
804+
&& buffer[3..]
805+
.iter()
806+
.all(|byte| byte.is_ascii_digit() || *byte == b';')
807+
}
808+
756809
fn starts_with_incomplete_orphaned_sgr_mouse_tail(buffer: &[u8]) -> bool {
757810
if buffer.len() > MAX_ORPHANED_SGR_MOUSE_TAIL_BYTES {
758811
return false;
@@ -792,10 +845,39 @@ fn discard_or_buffer_orphaned_sgr_mouse_tail(
792845
discarded_tail_bytes: &mut usize,
793846
) {
794847
if !discard_complete_orphaned_sgr_mouse_tail(buffer) {
795-
*discard_until = Some(ControlStringFamily::OrphanedSgrMouseTail);
796-
*discarded_tail_bytes = 0;
848+
*discarded_tail_bytes = buffer.len();
849+
*discard_until = (*discarded_tail_bytes <= MAX_DISCARDED_CONTROL_TAIL_BYTES)
850+
.then_some(ControlStringFamily::OrphanedSgrMouseTail);
851+
buffer.clear();
852+
}
853+
}
854+
855+
fn discard_orphaned_sgr_mouse_tail(buffer: &mut Vec<u8>, discarded_tail_bytes: &mut usize) -> bool {
856+
let remaining = MAX_DISCARDED_CONTROL_TAIL_BYTES.saturating_sub(*discarded_tail_bytes);
857+
let inspected = buffer.len().min(remaining);
858+
859+
for index in 0..inspected {
860+
match buffer[index] {
861+
b'0'..=b'9' | b';' => {}
862+
b'M' | b'm' => {
863+
buffer.drain(..=index);
864+
return true;
865+
}
866+
_ => {
867+
buffer.drain(..index);
868+
return true;
869+
}
870+
}
871+
}
872+
873+
*discarded_tail_bytes = discarded_tail_bytes.saturating_add(inspected);
874+
if buffer.len() > inspected {
797875
buffer.clear();
876+
return true;
798877
}
878+
879+
buffer.clear();
880+
false
799881
}
800882

801883
fn osc_string_terminator(buffer: &[u8]) -> Option<usize> {
@@ -1490,6 +1572,68 @@ mod tests {
14901572
));
14911573
}
14921574

1575+
#[test]
1576+
fn legacy_reader_extends_only_incomplete_sgr_mouse_timeout() {
1577+
let mut mouse = RawInputFramer::default();
1578+
assert!(mouse.push(b"\x1b[<3").is_empty());
1579+
assert_eq!(
1580+
input_flush_timeout_ms(&mouse),
1581+
MOUSE_ACTIVE_ESCAPE_SEQUENCE_FLUSH_TIMEOUT_MS
1582+
);
1583+
1584+
let mut escape = RawInputFramer::default();
1585+
assert!(escape.push(b"\x1b").is_empty());
1586+
assert_eq!(
1587+
input_flush_timeout_ms(&escape),
1588+
RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS
1589+
);
1590+
}
1591+
1592+
#[test]
1593+
fn sgr_mouse_sequence_split_after_button_prefix_is_reassembled_before_timeout() {
1594+
let mut framer = RawInputFramer::default();
1595+
1596+
assert!(framer.push(b"\x1b[<3").is_empty());
1597+
let events = framer.push(b"5;58;30M");
1598+
1599+
assert_eq!(events.len(), 1);
1600+
assert!(matches!(
1601+
events[0],
1602+
RawInputEvent::Mouse(MouseEvent {
1603+
kind: MouseEventKind::Moved,
1604+
column: 57,
1605+
row: 29,
1606+
..
1607+
})
1608+
));
1609+
}
1610+
1611+
#[test]
1612+
fn timed_out_split_sgr_mouse_tail_is_discarded_and_following_input_is_preserved() {
1613+
let mut framer = RawInputFramer::default();
1614+
1615+
assert!(framer.push(b"\x1b[<3").is_empty());
1616+
assert!(framer.flush_timeout().is_empty());
1617+
let events = framer.push(b"5;58;30Mx");
1618+
1619+
assert_eq!(events.len(), 1);
1620+
assert_raw_key(
1621+
events.into_iter().next().unwrap(),
1622+
KeyCode::Char('x'),
1623+
KeyModifiers::empty(),
1624+
);
1625+
}
1626+
1627+
#[test]
1628+
fn timed_out_sgr_mouse_discard_state_clears_at_quiescence() {
1629+
let mut framer = RawInputByteFramer::default();
1630+
1631+
assert!(framer.push(b"\x1b[<3").is_empty());
1632+
assert!(framer.flush_timeout().is_empty());
1633+
assert!(framer.flush_timeout().is_empty());
1634+
assert_eq!(framer.push(b"M"), vec![b"M".to_vec()]);
1635+
}
1636+
14931637
#[test]
14941638
fn sgr_mouse_tail_after_lone_escape_timeout_is_discarded() {
14951639
let mut framer = RawInputFramer::default();

0 commit comments

Comments
 (0)