aboutsummaryrefslogtreecommitdiff
path: root/src/raw_input.rs
diff options
context:
space:
mode:
authorWakaTaira <50697207+WakaTaira@users.noreply.github.com>2026-08-02 22:53:08 +0900
committerGitHub <noreply@github.com>2026-08-02 16:53:08 +0300
commit890113943999bd0332f535864b982190907449c4 (patch)
tree602fa09cf67a48ef19eab38ba3cb0b67a9f17189 /src/raw_input.rs
parentd742e51530a78b77e1debe23a72ea7178810f3fd (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.rs291
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();