aboutsummaryrefslogtreecommitdiff
path: root/src/client
diff options
context:
space:
mode:
authorakbash <akbash@herdr.dev>2026-09-13 16:33:58 +0300
committerGitHub <noreply@github.com>2026-09-13 15:33:58 +0200
commit7505c08d47d2ba9843066ecbc78ce46eccf0096a (patch)
tree78b9dcb891346b038b84c0c9f64fe748d024b092 /src/client
parent540b20fdf0bb653c6cfed34cc1f3c10c129c99e6 (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.rs126
-rw-r--r--src/client/input/windows_vti.rs95
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());