diff options
| author | WakaTaira <50697207+WakaTaira@users.noreply.github.com> | 2026-08-02 22:53:08 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-08-02 16:53:08 +0300 |
| commit | 890113943999bd0332f535864b982190907449c4 (patch) | |
| tree | 602fa09cf67a48ef19eab38ba3cb0b67a9f17189 /src/raw_input.rs | |
| parent | d742e51530a78b77e1debe23a72ea7178810f3fd (diff) | |
fix(client): query host cell size when the ioctl reports no pixels (#2160)
Diffstat (limited to 'src/raw_input.rs')
| -rw-r--r-- | src/raw_input.rs | 291 |
1 files changed, 275 insertions, 16 deletions
diff --git a/src/raw_input.rs b/src/raw_input.rs index ce7aec19..60c57af3 100644 --- a/src/raw_input.rs +++ b/src/raw_input.rs @@ -139,6 +139,12 @@ pub enum RawInputEvent { colors: Vec<(u8, RgbColor)>, }, HostColorSchemeChanged(HostAppearance), + // The dimensions are only read by the Unix client. + #[cfg_attr(not(any(unix, test)), allow(dead_code))] + HostCellSizeReport { + width_px: u32, + height_px: u32, + }, Unsupported, } @@ -209,12 +215,15 @@ pub(crate) struct RawInputByteFramer { discarded_tail_bytes: usize, lone_escape_recently_flushed: bool, host_color_replies_awaited: u16, - held_pending_color_esc: bool, + host_cell_size_replies_awaited: u16, + held_pending_host_reply_esc: bool, host_color_scheme_change_tracking: bool, split_coalesced_escape: bool, } const HOST_COLOR_QUERY_REPLIES: u16 = 258; +#[cfg(any(unix, test))] +const HOST_CELL_SIZE_QUERY_REPLIES: u16 = 1; const MAX_ORPHANED_SGR_MOUSE_TAIL_BYTES: usize = 32; impl RawInputByteFramer { @@ -240,7 +249,19 @@ impl RawInputByteFramer { /// at its ESC introducer stitches back together instead of leaking (#549). pub(crate) fn host_color_query_sent(&mut self) { self.host_color_replies_awaited = HOST_COLOR_QUERY_REPLIES; - self.held_pending_color_esc = false; + self.held_pending_host_reply_esc = false; + } + + /// Same hold window as `host_color_query_sent`, for the XTWINOPS cell size + /// reply. Only the Unix client sends this query. + #[cfg(any(unix, test))] + pub(crate) fn host_cell_size_query_sent(&mut self) { + self.host_cell_size_replies_awaited = HOST_CELL_SIZE_QUERY_REPLIES; + self.held_pending_host_reply_esc = false; + } + + fn awaiting_host_reply(&self) -> bool { + self.host_color_replies_awaited > 0 || self.host_cell_size_replies_awaited > 0 } pub(crate) fn enable_host_color_scheme_change_tracking(&mut self) { @@ -270,6 +291,9 @@ impl RawInputByteFramer { let mut chunks = self.drain_available_chunks(); if let Some(family) = self.discard_until { + if family == ControlStringFamily::HostReplyCsi { + return chunks; + } if family == ControlStringFamily::OrphanedSgrMouseTail { self.buffer.clear(); self.discard_until = None; @@ -340,12 +364,37 @@ impl RawInputByteFramer { return chunks; } + if self.host_cell_size_replies_awaited > 0 && self.buffer.as_slice() == b"\x1b[" { + if !self.held_pending_host_reply_esc { + self.held_pending_host_reply_esc = true; + tracing::trace!("holding incomplete cell size reply one flush"); + return chunks; + } + self.host_cell_size_replies_awaited = 0; + self.held_pending_host_reply_esc = false; + } + + if self.host_cell_size_replies_awaited > 0 + && starts_with_incomplete_host_cell_size_report(&self.buffer) + { + tracing::debug!( + len = self.buffer.len(), + "discarding incomplete host cell size report after input timeout" + ); + self.host_cell_size_replies_awaited = 0; + self.held_pending_host_reply_esc = false; + self.discard_until = Some(ControlStringFamily::HostReplyCsi); + self.discarded_tail_bytes = 0; + self.buffer.clear(); + return chunks; + } + if starts_with_incomplete_host_color_scheme_report(&self.buffer) { tracing::debug!( len = self.buffer.len(), "discarding incomplete host color scheme report after input timeout" ); - self.discard_until = Some(ControlStringFamily::HostColorSchemeCsi); + self.discard_until = Some(ControlStringFamily::HostReplyCsi); self.discarded_tail_bytes = 0; self.buffer.clear(); return chunks; @@ -365,14 +414,15 @@ impl RawInputByteFramer { } if self.buffer.as_slice() == [ESC] { - if self.host_color_replies_awaited > 0 && !self.held_pending_color_esc { - self.held_pending_color_esc = true; - tracing::trace!("holding lone escape one flush while awaiting host color reply"); + if self.awaiting_host_reply() && !self.held_pending_host_reply_esc { + self.held_pending_host_reply_esc = true; + tracing::trace!("holding lone escape one flush while awaiting host reply"); return chunks; } // No continuation arrived; give up the window so Escape is not delayed again. self.host_color_replies_awaited = 0; - self.held_pending_color_esc = false; + self.host_cell_size_replies_awaited = 0; + self.held_pending_host_reply_esc = false; tracing::warn!( bytes = ?self.buffer, "flushing lone escape after input timeout; if this follows an alt chord or focus switch it may reach the pane as plain esc" @@ -422,6 +472,15 @@ impl RawInputByteFramer { } if let Some(family) = self.discard_until { + if family == ControlStringFamily::HostReplyCsi { + if discard_host_reply_csi_tail(&mut self.buffer, &mut self.discarded_tail_bytes) + { + self.discard_until = None; + self.discarded_tail_bytes = 0; + continue; + } + break; + } if family == ControlStringFamily::OrphanedSgrMouseTail { if discard_orphaned_sgr_mouse_tail( &mut self.buffer, @@ -459,12 +518,15 @@ impl RawInputByteFramer { RawInputEvent::HostDefaultColor { .. } | RawInputEvent::HostPaletteColors { .. } ) { self.host_color_replies_awaited = self.host_color_replies_awaited.saturating_sub(1); + } else if matches!(event, RawInputEvent::HostCellSizeReport { .. }) { + self.host_cell_size_replies_awaited = + self.host_cell_size_replies_awaited.saturating_sub(1); } else if self.host_color_scheme_change_tracking && matches!(event, RawInputEvent::HostColorSchemeChanged(_)) { self.host_color_query_sent(); } - self.held_pending_color_esc = false; + self.held_pending_host_reply_esc = false; chunks.push(self.buffer[..consumed].to_vec()); self.buffer.drain(..consumed); } @@ -499,9 +561,7 @@ fn plausible_control_string_tail(family: ControlStringFamily, buffer: &[u8]) -> ) }), ControlStringFamily::StTerminated => buffer.last() == Some(&ESC), - ControlStringFamily::HostColorSchemeCsi => buffer - .iter() - .all(|byte| byte.is_ascii_digit() || matches!(*byte, b';' | b'?' | b'n')), + ControlStringFamily::HostReplyCsi => false, ControlStringFamily::OrphanedSgrMouseTail => buffer .iter() .all(|byte| byte.is_ascii_digit() || matches!(*byte, b';' | b'M' | b'm')), @@ -748,6 +808,16 @@ fn extract_one_event(buffer: &[u8]) -> Option<(RawInputEvent, usize)> { return Some((RawInputEvent::HostColorSchemeChanged(appearance), seq_len)); } + if let Some((width_px, height_px)) = parse_host_cell_size_report(&buffer[..seq_len]) { + return Some(( + RawInputEvent::HostCellSizeReport { + width_px, + height_px, + }, + seq_len, + )); + } + if let Some(mouse) = parse_sgr_mouse(seq) { return Some((RawInputEvent::Mouse(mouse), seq_len)); } @@ -775,7 +845,7 @@ fn extract_one_event(buffer: &[u8]) -> Option<(RawInputEvent, usize)> { enum ControlStringFamily { Osc, StTerminated, - HostColorSchemeCsi, + HostReplyCsi, OrphanedSgrMouseTail, } @@ -798,6 +868,23 @@ fn parse_host_color_scheme_report(buffer: &[u8]) -> Option<HostAppearance> { } } +/// Parses an XTWINOPS cell size report (`CSI 6 ; height ; width t`) into +/// `(width_px, height_px)`; note the reply orders height first. +fn parse_host_cell_size_report(buffer: &[u8]) -> Option<(u32, u32)> { + let body = buffer.strip_prefix(b"\x1b[")?.strip_suffix(b"t")?; + let text = std::str::from_utf8(body).ok()?; + let mut params = text.split(';'); + if params.next()? != "6" { + return None; + } + let height_px = params.next()?.parse::<u32>().ok()?; + let width_px = params.next()?.parse::<u32>().ok()?; + if params.next().is_some() || width_px == 0 || height_px == 0 { + return None; + } + Some((width_px, height_px)) +} + fn starts_with_incomplete_default_color_response(buffer: &[u8]) -> bool { matches!( control_string(buffer), @@ -814,6 +901,26 @@ fn starts_with_incomplete_host_color_scheme_report(buffer: &[u8]) -> bool { && buffer.len() < GHOSTTY_COLOR_SCHEME_DARK_REPORT.len() } +fn starts_with_incomplete_host_cell_size_report(buffer: &[u8]) -> bool { + let Some(body) = buffer.strip_prefix(b"\x1b[") else { + return false; + }; + if body.is_empty() || body.last() == Some(&b't') { + return false; + } + + let mut params = body.split(|byte| *byte == b';'); + if params.next() != Some(b"6".as_slice()) { + return false; + } + let height = params.next(); + let width = params.next(); + params.next().is_none() + && height.is_none_or(|value| value.iter().all(u8::is_ascii_digit)) + && width.is_none_or(|value| value.iter().all(u8::is_ascii_digit)) + && !(height.is_some_and(<[u8]>::is_empty) && width.is_some()) +} + fn control_string(buffer: &[u8]) -> Option<ControlString> { let family = match buffer.get(..2)? { b"\x1b]" => ControlStringFamily::Osc, @@ -959,6 +1066,29 @@ fn discard_or_buffer_orphaned_sgr_mouse_tail( } } +fn discard_host_reply_csi_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] { + 0x20..=0x3f => {} + 0x40..=0x7e => { + buffer.drain(..=index); + return true; + } + _ => { + buffer.drain(..index); + return true; + } + } + } + + buffer.drain(..inspected); + *discarded_tail_bytes = discarded_tail_bytes.saturating_add(inspected); + *discarded_tail_bytes >= MAX_DISCARDED_CONTROL_TAIL_BYTES +} + 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); @@ -1013,10 +1143,7 @@ fn control_string_terminator_for_family( match family { ControlStringFamily::Osc => osc_string_terminator(buffer), ControlStringFamily::StTerminated => st_string_terminator(buffer), - ControlStringFamily::HostColorSchemeCsi => buffer - .iter() - .position(|byte| *byte == b'n') - .map(|idx| idx + 1), + ControlStringFamily::HostReplyCsi => None, ControlStringFamily::OrphanedSgrMouseTail => buffer .iter() .position(|byte| matches!(*byte, b'M' | b'm')) @@ -1386,6 +1513,42 @@ mod tests { } #[test] + fn parses_host_cell_size_report() { + let events = parse_raw_input_bytes_sync(b"\x1b[6;21;10t"); + + assert_eq!(events.len(), 1); + assert!(matches!( + events[0], + RawInputEvent::HostCellSizeReport { + width_px: 10, + height_px: 21, + } + )); + } + + #[test] + fn host_cell_size_report_parser_is_exact() { + for bytes in [ + // Zero dimensions carry no usable cell size. + b"\x1b[6;0;10t".as_slice(), + b"\x1b[6;21;0t".as_slice(), + // Missing or extra parameters. + b"\x1b[6;21t".as_slice(), + b"\x1b[6;21;10;3t".as_slice(), + // Other XTWINOPS reports must not be mistaken for a cell size. + b"\x1b[4;1610;777t".as_slice(), + b"\x1b[8;37;161t".as_slice(), + // Non-numeric parameters. + b"\x1b[6;21;1-t".as_slice(), + ] { + assert!( + parse_host_cell_size_report(bytes).is_none(), + "bytes: {bytes:?}" + ); + } + } + + #[test] fn split_color_scheme_timeout_does_not_swallow_legacy_alt_bracket() { let mut framer = RawInputByteFramer::default(); @@ -2472,6 +2635,102 @@ mod tests { } #[test] + fn holds_lone_escape_and_stitches_split_host_cell_size_reply() { + let mut framer = RawInputByteFramer::default(); + framer.host_cell_size_query_sent(); + + // The XTWINOPS reply is split right at its ESC introducer. + assert!(framer.push(b"\x1b").is_empty()); + assert!(framer.flush_timeout().is_empty()); + + let chunks = framer.push(b"[6;21;10t"); + assert_eq!(chunks, vec![b"\x1b[6;21;10t".to_vec()]); + let (event, _) = extract_one_event(&chunks[0]).unwrap(); + assert!(matches!( + event, + RawInputEvent::HostCellSizeReport { + width_px: 10, + height_px: 21, + } + )); + } + + #[test] + fn timed_out_host_cell_size_reply_fragments_do_not_leak() { + for (prefix, tail) in [ + (b"\x1b[6".as_slice(), b";21;10t".as_slice()), + (b"\x1b[6;".as_slice(), b"21;10t".as_slice()), + (b"\x1b[6;21;".as_slice(), b"10t".as_slice()), + ] { + let mut framer = RawInputByteFramer::default(); + framer.host_cell_size_query_sent(); + + assert!(framer.push(prefix).is_empty(), "prefix: {prefix:?}"); + assert!(framer.flush_timeout().is_empty(), "prefix: {prefix:?}"); + assert!(framer.push(tail).is_empty(), "tail: {tail:?}"); + assert_eq!(framer.push(b"a"), vec![b"a".to_vec()]); + } + } + + #[test] + fn split_host_cell_size_reply_after_csi_intro_gets_one_more_flush() { + let mut framer = RawInputByteFramer::default(); + framer.host_cell_size_query_sent(); + + assert!(framer.push(b"\x1b[").is_empty()); + assert!(framer.flush_timeout().is_empty()); + assert_eq!(framer.push(b"6;21;10t"), vec![b"\x1b[6;21;10t".to_vec()]); + + let mut alt_bracket = RawInputByteFramer::default(); + alt_bracket.host_cell_size_query_sent(); + assert!(alt_bracket.push(b"\x1b[").is_empty()); + assert!(alt_bracket.flush_timeout().is_empty()); + assert_eq!(alt_bracket.flush_timeout(), vec![b"\x1b[".to_vec()]); + } + + #[test] + fn malformed_host_reply_tail_preserves_following_input() { + let mut framer = RawInputByteFramer::default(); + framer.host_cell_size_query_sent(); + + assert!(framer.push(b"\x1b[6;21").is_empty()); + assert!(framer.flush_timeout().is_empty()); + assert_eq!( + framer.push(b";10xabc"), + vec![b"a".to_vec(), b"b".to_vec(), b"c".to_vec()] + ); + } + + #[test] + fn host_reply_tail_discard_is_bounded_across_pushes() { + let mut framer = RawInputByteFramer::default(); + framer.host_cell_size_query_sent(); + + assert!(framer.push(b"\x1b[6;21").is_empty()); + assert!(framer.flush_timeout().is_empty()); + assert!(framer.push(&[b'1'; 64]).is_empty()); + assert_eq!( + framer.push(&[b'2'; 67]), + vec![b"2".to_vec(), b"2".to_vec(), b"2".to_vec()] + ); + } + + #[test] + fn stops_holding_lone_escape_after_host_cell_size_reply_completes() { + let mut framer = RawInputByteFramer::default(); + framer.host_cell_size_query_sent(); + + assert_eq!( + framer.push(b"\x1b[6;21;10t"), + vec![b"\x1b[6;21;10t".to_vec()] + ); + + // Window closed: a later lone Escape flushes immediately. + assert!(framer.push(b"\x1b").is_empty()); + assert_eq!(framer.flush_timeout(), vec![b"\x1b".to_vec()]); + } + + #[test] fn default_byte_framer_does_not_rearm_after_color_scheme_report() { let mut framer = RawInputByteFramer::default(); |
