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 | |
| parent | d742e51530a78b77e1debe23a72ea7178810f3fd (diff) | |
fix(client): query host cell size when the ioctl reports no pixels (#2160)
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/mod.rs | 1 | ||||
| -rw-r--r-- | src/app/runtime.rs | 2 | ||||
| -rw-r--r-- | src/client/input.rs | 13 | ||||
| -rw-r--r-- | src/client/mod.rs | 178 | ||||
| -rw-r--r-- | src/raw_input.rs | 291 |
5 files changed, 449 insertions, 36 deletions
diff --git a/src/app/mod.rs b/src/app/mod.rs index 5d070afa..d049c0f2 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1768,6 +1768,7 @@ impl App { self.set_host_terminal_appearance(appearance, true); } } + crate::raw_input::RawInputEvent::HostCellSizeReport { .. } => {} crate::raw_input::RawInputEvent::Unsupported => {} } self.sync_prefix_input_source(previous_mode); diff --git a/src/app/runtime.rs b/src/app/runtime.rs index ef0d1224..e05b9599 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -257,6 +257,8 @@ impl App { self.query_host_terminal_theme(); self.set_host_terminal_appearance(appearance, true) } + // Cell size reports are consumed by the thin client, not the runtime. + crate::raw_input::RawInputEvent::HostCellSizeReport { .. } => false, crate::raw_input::RawInputEvent::Unsupported => false, }; self.sync_prefix_input_source(previous_mode); diff --git a/src/client/input.rs b/src/client/input.rs index 09e0eb92..fd374491 100644 --- a/src/client/input.rs +++ b/src/client/input.rs @@ -39,11 +39,16 @@ pub fn stdin_reader_loop( event_tx: mpsc::Sender<ClientLoopEvent>, should_quit: &Arc<AtomicBool>, host_color_query_sent: bool, + host_cell_size_query_sent: bool, host_mouse_capture_active: Arc<AtomicBool>, ) { #[cfg(windows)] { - let _ = (host_color_query_sent, host_mouse_capture_active); + let _ = ( + host_color_query_sent, + host_cell_size_query_sent, + host_mouse_capture_active, + ); windows_stdin_reader_loop(event_tx, should_quit); } @@ -52,6 +57,7 @@ pub fn stdin_reader_loop( event_tx, should_quit, host_color_query_sent, + host_cell_size_query_sent, host_mouse_capture_active, ); } @@ -61,6 +67,7 @@ fn unix_stdin_reader_loop( event_tx: mpsc::Sender<ClientLoopEvent>, should_quit: &Arc<AtomicBool>, host_color_query_sent: bool, + host_cell_size_query_sent: bool, host_mouse_capture_active: Arc<AtomicBool>, ) { let stdin = io::stdin(); @@ -71,6 +78,9 @@ fn unix_stdin_reader_loop( framer.host_color_query_sent(); framer.enable_host_color_scheme_change_tracking(); } + if host_cell_size_query_sent { + framer.host_cell_size_query_sent(); + } let mut pending_palette = Vec::new(); while !should_quit.load(Ordering::Acquire) { @@ -422,6 +432,7 @@ fn windows_client_input_event_from_raw( crate::raw_input::RawInputEvent::HostDefaultColor { .. } | crate::raw_input::RawInputEvent::HostPaletteColors { .. } | crate::raw_input::RawInputEvent::HostColorSchemeChanged(_) + | crate::raw_input::RawInputEvent::HostCellSizeReport { .. } | crate::raw_input::RawInputEvent::Unsupported => None, } } diff --git a/src/client/mod.rs b/src/client/mod.rs index d7fcdf71..c0fe73d3 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -16,7 +16,7 @@ mod input; use std::collections::HashSet; use std::io::{self, BufRead, Write as _}; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; use std::time::Duration; @@ -1158,7 +1158,7 @@ fn run_client_with_mode( // Get the terminal geometry before handshake (before raw mode). let (cols, rows, cell_width_px, cell_height_px) = - current_terminal_geometry(kitty_graphics_enabled); + initial_terminal_geometry(kitty_graphics_enabled); // Perform handshake while the stream is still in blocking mode. let negotiated_encoding = match do_handshake( @@ -1239,6 +1239,8 @@ fn run_client_with_mode( stream, cols, rows, + cell_width_px, + cell_height_px, should_quit, loop_config, negotiated_encoding, @@ -1283,6 +1285,8 @@ async fn run_client_loop( stream: LocalStream, cols: u16, rows: u16, + initial_cell_width_px: u32, + initial_cell_height_px: u32, should_quit: Arc<AtomicBool>, config: ClientLoopConfig, negotiated_encoding: RenderEncoding, @@ -1312,6 +1316,9 @@ async fn run_client_loop( }; debug!(?negotiated_encoding, "client render encoding active"); let host_mouse_capture_active = Arc::new(AtomicBool::new(state.mouse_capture_active)); + // Cell size reported by the host terminal, packed as width<<32 | height. + // Zero means the host has not reported one. + let reported_cell_size = Arc::new(AtomicU64::new(0)); // Channel for events from the stdin, resize, and server reader threads. let (event_tx, mut event_rx) = tokio::sync::mpsc::channel::<ClientLoopEvent>(256); @@ -1319,6 +1326,10 @@ async fn run_client_loop( // Spawn the stdin reader thread. let will_query_host_terminal_theme = state.attach_escape.is_none() && should_query_host_terminal_theme(); + // Terminals behind ConPTY report no pixel size through the ioctl, so ask the + // host terminal directly instead of falling back to an assumed cell size. + let will_query_host_cell_size = state.attach_escape.is_none() + && host_cell_size_query_required(state.kitty_graphics_enabled); let stdin_quit = should_quit.clone(); let stdin_tx = event_tx.clone(); let stdin_mouse_capture_active = host_mouse_capture_active.clone(); @@ -1327,6 +1338,7 @@ async fn run_client_loop( stdin_tx, &stdin_quit, will_query_host_terminal_theme, + will_query_host_cell_size, stdin_mouse_capture_active, ); }); @@ -1335,12 +1347,26 @@ async fn run_client_loop( query_host_terminal_theme(); } + if will_query_host_cell_size { + query_host_cell_size(); + } + // Spawn the resize poller thread. let resize_quit = should_quit.clone(); let resize_tx = event_tx.clone(); + let resize_cell_size = reported_cell_size.clone(); let kitty_graphics_enabled = state.kitty_graphics_enabled; std::thread::spawn(move || { - resize_poll_loop(resize_tx, cols, rows, kitty_graphics_enabled, &resize_quit); + resize_poll_loop( + resize_tx, + cols, + rows, + initial_cell_width_px, + initial_cell_height_px, + kitty_graphics_enabled, + &resize_cell_size, + &resize_quit, + ); }); // Spawn the server reader thread (blocking reads from the socket). @@ -1429,6 +1455,9 @@ async fn run_client_loop( if crate::raw_input::events_require_host_terminal_theme_query(&events) { query_host_terminal_theme(); } + if let Some((width_px, height_px)) = reported_cell_size_from_events(&events) { + store_reported_cell_size(&reported_cell_size, width_px, height_px); + } data }; if should_bridge_clipboard_image_paste( @@ -2085,23 +2114,56 @@ fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> { // Resize polling // --------------------------------------------------------------------------- -fn current_terminal_geometry(kitty_graphics_enabled: bool) -> (u16, u16, u32, u32) { - let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24)); - if !kitty_graphics_enabled { - return (cols, rows, 0, 0); - } - let Ok(size) = crossterm::terminal::window_size() else { - return (cols, rows, 8, 16); - }; +/// Cell size assumed when neither the terminal size ioctl nor the host +/// terminal reports pixel dimensions. +const DEFAULT_CELL_WIDTH_PX: u32 = 8; +const DEFAULT_CELL_HEIGHT_PX: u32 = 16; + +/// Cell size derived from the terminal size ioctl, if it reports pixels. +fn ioctl_cell_size() -> Option<(u32, u32)> { + let size = crossterm::terminal::window_size().ok()?; if size.columns == 0 || size.rows == 0 || size.width == 0 || size.height == 0 { - return (cols, rows, 8, 16); + return None; } - ( - cols, - rows, + Some(( (size.width as u32 / size.columns as u32).max(1), (size.height as u32 / size.rows as u32).max(1), - ) + )) +} + +/// Cell size used when the ioctl reports no pixels. +fn cell_size_fallback(reported: u64) -> (u32, u32) { + unpack_cell_size(reported).unwrap_or((DEFAULT_CELL_WIDTH_PX, DEFAULT_CELL_HEIGHT_PX)) +} + +#[cfg(any(unix, test))] +fn pack_cell_size(width_px: u32, height_px: u32) -> u64 { + (u64::from(width_px) << 32) | u64::from(height_px) +} + +fn unpack_cell_size(packed: u64) -> Option<(u32, u32)> { + let width_px = (packed >> 32) as u32; + let height_px = (packed & u64::from(u32::MAX)) as u32; + (width_px > 0 && height_px > 0).then_some((width_px, height_px)) +} + +fn current_terminal_geometry( + kitty_graphics_enabled: bool, + reported_cell_size: &AtomicU64, +) -> (u16, u16, u32, u32) { + let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24)); + if !kitty_graphics_enabled { + return (cols, rows, 0, 0); + } + let (cell_width_px, cell_height_px) = ioctl_cell_size() + .unwrap_or_else(|| cell_size_fallback(reported_cell_size.load(Ordering::Acquire))); + (cols, rows, cell_width_px, cell_height_px) +} + +/// Reads the terminal geometry before the handshake, before any host cell +/// size report can exist. +fn initial_terminal_geometry(kitty_graphics_enabled: bool) -> (u16, u16, u32, u32) { + current_terminal_geometry(kitty_graphics_enabled, &AtomicU64::new(0)) } /// Reports polled changes and signalled resizes that return to the same size. @@ -2114,16 +2176,21 @@ fn resize_report_required( } /// Watches the terminal size and sends resize events when it changes. +/// +/// The baseline cell size must match what the handshake sent to the server: +/// reading a fresh one here would race the host cell size reply and could +/// swallow the first change. fn resize_poll_loop( resize_tx: tokio::sync::mpsc::Sender<ClientLoopEvent>, initial_cols: u16, initial_rows: u16, + initial_cell_width: u32, + initial_cell_height: u32, kitty_graphics_enabled: bool, + reported_cell_size: &AtomicU64, should_quit: &Arc<AtomicBool>, ) { crate::platform::watch_terminal_resize_signal(); - let (_, _, initial_cell_width, initial_cell_height) = - current_terminal_geometry(kitty_graphics_enabled); let mut last_size = ( initial_cols, initial_rows, @@ -2133,7 +2200,7 @@ fn resize_poll_loop( while !should_quit.load(Ordering::Acquire) { std::thread::sleep(Duration::from_millis(100)); let signalled = crate::platform::take_terminal_resize_signal(); - let new_size = current_terminal_geometry(kitty_graphics_enabled); + let new_size = current_terminal_geometry(kitty_graphics_enabled, reported_cell_size); if resize_report_required(signalled, new_size, last_size) { last_size = new_size; if resize_tx @@ -2167,6 +2234,49 @@ fn write_host_terminal_theme_query(mut writer: impl io::Write) -> io::Result<()> writer.flush() } +/// XTWINOPS request for the host terminal cell size in pixels. +const HOST_CELL_SIZE_QUERY: &[u8] = b"\x1b[16t"; + +fn query_host_cell_size() { + let _ = write_host_cell_size_query(io::stdout()); +} + +fn should_query_host_cell_size() -> bool { + !cfg!(windows) +} + +/// Only pane graphics need pixel dimensions, and only when the ioctl cannot +/// provide them. +fn host_cell_size_query_required(kitty_graphics_enabled: bool) -> bool { + kitty_graphics_enabled && should_query_host_cell_size() && ioctl_cell_size().is_none() +} + +fn write_host_cell_size_query(mut writer: impl io::Write) -> io::Result<()> { + writer.write_all(HOST_CELL_SIZE_QUERY)?; + writer.flush() +} + +#[cfg(any(unix, test))] +fn store_reported_cell_size(reported_cell_size: &AtomicU64, width_px: u32, height_px: u32) { + let packed = pack_cell_size(width_px, height_px); + if reported_cell_size.swap(packed, Ordering::AcqRel) != packed { + debug!(width_px, height_px, "host terminal reported cell size"); + } +} + +#[cfg(any(unix, test))] +fn reported_cell_size_from_events( + events: &[crate::raw_input::RawInputEvent], +) -> Option<(u32, u32)> { + events.iter().rev().find_map(|event| match event { + crate::raw_input::RawInputEvent::HostCellSizeReport { + width_px, + height_px, + } => Some((*width_px, *height_px)), + _ => None, + }) +} + fn init_logging() { crate::logging::init_file_logging("herdr-client.log"); } @@ -2484,6 +2594,36 @@ mod tests { } #[test] + fn write_host_cell_size_query_emits_xtwinops_request() { + let mut output = Vec::new(); + write_host_cell_size_query(&mut output).unwrap(); + + assert_eq!(output, b"\x1b[16t"); + } + + #[test] + fn host_cell_size_query_is_disabled_on_windows() { + assert_eq!(should_query_host_cell_size(), !cfg!(windows)); + } + + #[test] + fn cell_size_fallback_prefers_reported_size_over_default() { + assert_eq!(cell_size_fallback(0), (8, 16)); + assert_eq!(cell_size_fallback(pack_cell_size(10, 21)), (10, 21)); + assert_eq!(cell_size_fallback(pack_cell_size(10, 0)), (8, 16)); + assert_eq!(cell_size_fallback(pack_cell_size(0, 21)), (8, 16)); + } + + #[test] + fn reported_cell_size_is_taken_from_host_cell_size_events() { + let events = crate::raw_input::parse_raw_input_bytes_sync(b"\x1b[?997;1n"); + assert_eq!(reported_cell_size_from_events(&events), None); + + let events = crate::raw_input::parse_raw_input_bytes_sync(b"\x1b[6;21;10t\x1b[6;18;9t"); + assert_eq!(reported_cell_size_from_events(&events), Some((9, 18))); + } + + #[test] fn color_scheme_reports_are_enabled_only_for_full_clients() { assert_eq!( should_enable_host_color_scheme_reports(true), 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(); |
