diff options
| author | akbash <akbash@herdr.dev> | 2026-09-13 16:33:58 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-13 15:33:58 +0200 |
| commit | 7505c08d47d2ba9843066ecbc78ce46eccf0096a (patch) | |
| tree | 78b9dcb891346b038b84c0c9f64fe748d024b092 /src/client | |
| parent | 540b20fdf0bb653c6cfed34cc1f3c10c129c99e6 (diff) | |
fix: retain validated split mouse reports across idle gaps (#4048)
refs #3911
Co-authored-by: akbash-bot <300245827+akbash-bot@users.noreply.github.com>
Co-authored-by: JJ Liebig <jonathan.liebig@gmail.com>
Diffstat (limited to 'src/client')
| -rw-r--r-- | src/client/input.rs | 126 | ||||
| -rw-r--r-- | src/client/input/windows_vti.rs | 95 |
2 files changed, 192 insertions, 29 deletions
diff --git a/src/client/input.rs b/src/client/input.rs index 456f59b5..c86f0878 100644 --- a/src/client/input.rs +++ b/src/client/input.rs @@ -365,31 +365,11 @@ fn windows_crossterm_reader_loop( Err(_) => break, }; - let raw_sequence_pending = framer.has_pending_input(); - if let Some(bytes) = windows_key_raw_bytes(&event, raw_sequence_pending) { - tracing::debug!( - bytes = ?bytes, - pending_before = raw_sequence_pending, - "windows input routed through raw framer" - ); - if !send_windows_raw_events(framer.push(&bytes), &event_tx) { - return; - } - continue; - } - - if raw_sequence_pending { - tracing::debug!("windows input raw sequence interrupted by semantic event; flushing"); - if !send_windows_raw_events(framer.flush_timeout(), &event_tx) { - return; - } - } - - if windows_event_is_control_key(&event) { - tracing::debug!(event = ?event, "windows control key forwarded as semantic input"); + let (raw_events, event) = frame_windows_crossterm_event(&mut framer, event); + if !send_windows_raw_events(raw_events, &event_tx) { + return; } - - let Some(event) = windows_crossterm_input_event(event) else { + let Some(event) = event else { continue; }; if event_tx @@ -401,8 +381,48 @@ fn windows_crossterm_reader_loop( } if framer.has_pending_input() { - let _ = send_windows_raw_events(framer.flush_timeout(), &event_tx); + let _ = send_windows_raw_events(framer.flush_interrupted(), &event_tx); + } +} + +#[cfg(any(windows, test))] +fn frame_windows_crossterm_event( + framer: &mut crate::raw_input::RawInputFramer, + event: crossterm::event::Event, +) -> ( + Vec<crate::raw_input::RawInputEvent>, + Option<crate::protocol::ClientInputEvent>, +) { + let raw_sequence_pending = framer.has_pending_input(); + if let Some(bytes) = windows_key_raw_bytes(&event, raw_sequence_pending) { + tracing::debug!( + bytes = ?bytes, + pending_before = raw_sequence_pending, + "windows input routed through raw framer" + ); + return (framer.push(&bytes), None); + } + + if windows_event_is_control_key(&event) { + tracing::debug!(event = ?event, "windows control key forwarded as semantic input"); + } + let Some(event) = windows_crossterm_input_event(event) else { + // Preserve the existing flush for unrelated pending input, but do not + // cancel mouse recovery for an event that will not be forwarded. + return ( + if raw_sequence_pending { + framer.flush_timeout() + } else { + Vec::new() + }, + None, + ); + }; + if raw_sequence_pending { + tracing::debug!("windows input raw sequence interrupted by semantic event; flushing"); } + // Even a dormant mouse prefix must stop claiming bytes after semantic input. + (framer.flush_interrupted(), Some(event)) } #[cfg(any(windows, test))] @@ -429,7 +449,7 @@ fn windows_crossterm_input_event( } } -#[cfg(windows)] +#[cfg(any(windows, test))] fn windows_event_is_control_key(event: &crossterm::event::Event) -> bool { use crossterm::event::{Event, KeyModifiers}; @@ -774,6 +794,60 @@ mod windows_tests { } #[test] + fn windows_crossterm_semantic_input_cancels_dormant_and_buffered_mouse_recovery() { + for buffered in [false, true] { + let mut framer = crate::raw_input::RawInputFramer::for_host_input(); + assert!(framer.push(b"\x1b[<3").is_empty()); + assert!(framer.flush_timeout().is_empty()); + assert!(framer.flush_timeout().is_empty()); + if buffered { + assert!(framer.push(b"5").is_empty()); + } + // Printable input bypasses the raw route when no bytes are pending; + // an arrow bypasses it even when a continuation is buffered. + let key = if buffered { + KeyCode::Up + } else { + KeyCode::Char('x') + }; + let event = Event::Key(KeyEvent::new(key, KeyModifiers::empty())); + let (raw, semantic) = frame_windows_crossterm_event(&mut framer, event.clone()); + let raw_bytes: Vec<_> = raw + .into_iter() + .filter_map(|event| { + let crate::raw_input::RawInputEvent::Key(key) = event else { + return None; + }; + key.vt_bytes().map(ToOwned::to_owned) + }) + .collect(); + assert_eq!( + raw_bytes.concat(), + if buffered { b"5".as_slice() } else { b"" } + ); + assert_eq!(semantic, windows_crossterm_input_event(event)); + assert_eq!(framer.push(b"5;28;31M").len(), 8); + } + } + + #[test] + fn windows_crossterm_ignored_event_preserves_mouse_recovery() { + let mut framer = crate::raw_input::RawInputFramer::for_host_input(); + assert!(framer.push(b"\x1b[<3").is_empty()); + assert!(framer.flush_timeout().is_empty()); + assert!(framer.push(b"5").is_empty()); + let (raw, semantic) = frame_windows_crossterm_event(&mut framer, Event::Resize(80, 24)); + assert!(raw.is_empty()); + assert!(semantic.is_none()); + let events = framer.push(b";28;31Mx"); + assert!( + matches!(events.as_slice(), [crate::raw_input::RawInputEvent::Key(key)] + if key.code == KeyCode::Char('x')) + ); + assert!(!framer.has_pending_input()); + } + + #[test] fn windows_crossterm_printable_press_keeps_key_semantics_and_text() { let event = Event::Key(KeyEvent::new(KeyCode::Char('你'), KeyModifiers::empty())); diff --git a/src/client/input/windows_vti.rs b/src/client/input/windows_vti.rs index 679d5b31..0a00c146 100644 --- a/src/client/input/windows_vti.rs +++ b/src/client/input/windows_vti.rs @@ -364,7 +364,7 @@ impl WindowsInputPump { } if let Some(escape) = item.physical_escape_press() { if !self.framer.has_pending_bracketed_paste() { - let raw_events = self.framer.flush_timeout(); + let raw_events = self.framer.flush_interrupted(); events.extend(self.process_raw_events(raw_events)); self.pending_physical_escape = Some((escape, false)); return events; @@ -377,7 +377,7 @@ impl WindowsInputPump { self.process_raw_events(raw_events) } PlatformInputItem::Semantic(event) => { - let raw_events = self.framer.flush_timeout(); + let raw_events = self.framer.flush_interrupted(); let mut events = self.process_raw_events(raw_events); events.push(event); events @@ -436,7 +436,11 @@ impl WindowsInputPump { let raw_events = self.framer.push(&win32_paste_bytes); self.process_raw_events(raw_events) } else { - let raw_events = self.framer.flush_timeout(); + let raw_events = if events.is_empty() { + self.framer.flush_timeout() + } else { + self.framer.flush_interrupted() + }; let mut output = self.process_raw_events(raw_events); output.extend(events); output @@ -3450,6 +3454,91 @@ mod tests { } #[test] + fn vti_mouse_recovery_survives_idle_and_split_continuations() { + let mut translator = WindowsInputTranslator::default(); + for record in "\x1b[<3".chars().map(key_char) { + assert!(translator.translate(record).is_empty()); + } + assert!(translator.idle().is_empty()); + assert!(translator.idle().is_empty()); + for record in "5;28;31M".chars().map(key_char) { + assert!(translator.translate(record).is_empty()); + assert!(translator.idle().is_empty()); + } + assert_eq!( + translator.translate(key_char('x')), + translate_with_provenance([key_char('x')]) + ); + } + + #[test] + fn vti_ignored_modifier_record_preserves_mouse_recovery() { + for buffered in [false, true] { + let mut translator = WindowsInputTranslator::default(); + for record in "\x1b[<3".chars().map(key_char) { + assert!(translator.translate(record).is_empty()); + } + assert!(translator.idle().is_empty()); + if buffered { + assert!(translator.translate(key_char('5')).is_empty()); + } + // A modifier-only Win32 input record emits no semantic input. + for record in "\x1b[17;0;0;1;8;3_".chars().map(key_char) { + assert!(translator.translate(record).is_empty()); + } + let tail = if buffered { ";28;31M" } else { "5;28;31M" }; + for record in tail.chars().map(key_char) { + assert!(translator.translate(record).is_empty()); + } + assert_eq!( + translator.translate(key_char('x')), + translate_with_provenance([key_char('x')]) + ); + } + } + + #[test] + fn vti_semantic_input_cancels_mouse_recovery_and_preserves_order() { + // These hit Semantic, PasteAwareKey, and physical-Escape forwarding. + for interruption in [ + WindowsInputRecord::Focus(true), + key_vk_with_unicode(0x41, 'a', 0), + key_vk_with_scan_unicode(0x1b, 1, '\x1b', 0), + ] { + for buffered in [false, true] { + let mut translator = WindowsInputTranslator::default(); + for record in "\x1b[<3".chars().map(key_char) { + assert!(translator.translate(record).is_empty()); + } + assert!(translator.idle().is_empty()); + if buffered { + assert!(translator.translate(key_char('5')).is_empty()); + } + let mut events = translator.translate(interruption); + events.extend(translator.idle()); + let mut expected = if buffered { + translate_with_provenance([key_char('5')]) + } else { + Vec::new() + }; + let mut baseline = WindowsInputTranslator::default(); + expected.extend(baseline.translate(interruption)); + expected.extend(baseline.idle()); + assert_eq!(events, expected); + + let mut later = Vec::new(); + for record in "5;28;31M".chars().map(key_char) { + later.extend(translator.translate(record)); + } + assert_eq!( + later, + translate_with_provenance("5;28;31M".chars().map(key_char)) + ); + } + } + } + + #[test] fn vti_lone_escape_flushes_only_after_idle() { let mut translator = WindowsInputTranslator::default(); assert!(translator.translate(key_char('\x1b')).is_empty()); |
