Files
herdr/src/raw_input.rs
T
Can Celik 898f733f92 fix: keep escape-prefixed terminal key bindings intact (#4759)
* fix: keep escape-prefixed terminal key bindings intact

refs #4751

* test: cover escape-prefixed option+backspace binding

refs #4751
2026-09-29 20:34:45 +03:00

3183 lines
110 KiB
Rust

use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
#[cfg(windows)]
use std::time::{Duration, Instant};
/// Parse raw terminal input bytes into a list of `RawInputEvent`s.
///
/// This directly extracts events without going through a channel, making it
/// suitable for synchronous use.
#[cfg(any(unix, test))]
pub fn parse_raw_input_bytes_sync(data: &[u8]) -> Vec<RawInputEvent> {
let mut framer = RawInputFramer::default();
let mut events = framer.push(data);
events.extend(framer.flush_timeout());
events
}
use crate::input::{parse_terminal_key_sequence, TerminalKey, TextCommit};
use crate::terminal_theme::{
parse_default_color_response, parse_palette_color_response, DefaultColorKind, HostAppearance,
RgbColor,
};
const ESC: u8 = 0x1b;
#[cfg(unix)]
pub(crate) const RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS: i32 = 10;
#[cfg(unix)]
pub(crate) const MOUSE_ACTIVE_ESCAPE_SEQUENCE_FLUSH_TIMEOUT_MS: i32 = 150;
/// Covers the 33 ms split in #4630 without gluing legacy Alt+[ to the next key.
#[cfg(unix)]
pub(crate) const MOUSE_ACTIVE_CSI_INTRODUCER_FLUSH_TIMEOUT_MS: i32 = 50;
/// Covers the 350 ms mouse tail delay in #3480. Other input ends it early (#4751).
#[cfg(unix)]
const DISAMBIGUATED_MOUSE_TAIL_FLUSH_TIMEOUT_MS: i32 = 500;
pub(crate) const GHOSTTY_COLOR_SCHEME_DARK_REPORT: &[u8] = b"\x1b[?997;1n";
pub(crate) const GHOSTTY_COLOR_SCHEME_LIGHT_REPORT: &[u8] = b"\x1b[?997;2n";
const BRACKETED_PASTE_START: &[u8] = b"\x1b[200~";
const BRACKETED_PASTE_END: &[u8] = b"\x1b[201~";
/// Returns the UTF-8 payload when `data` is exactly one complete bracketed paste.
pub(crate) fn complete_text_bracketed_paste(data: &[u8]) -> Option<&str> {
if !data.starts_with(BRACKETED_PASTE_START) {
return None;
}
let end = find_subsequence(data, BRACKETED_PASTE_END)?;
if end + BRACKETED_PASTE_END.len() != data.len() {
return None;
}
std::str::from_utf8(&data[BRACKETED_PASTE_START.len()..end]).ok()
}
/// Client transport uses this to distinguish recoverable oversized interactive
/// pastes from generic oversized input, which remains a protocol violation.
pub(crate) fn is_complete_text_bracketed_paste(data: &[u8]) -> bool {
complete_text_bracketed_paste(data).is_some()
}
#[derive(Debug)]
pub enum RawInputEvent {
Key(TerminalKey),
Text(TextCommit),
Paste(String),
Mouse(MouseEvent),
OuterFocusGained,
OuterFocusLost,
HostDefaultColor {
kind: DefaultColorKind,
color: RgbColor,
},
HostPaletteColors {
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,
}
#[derive(Default)]
pub(crate) struct RawInputFramer {
byte_framer: RawInputByteFramer,
}
impl RawInputFramer {
#[cfg(any(windows, test))]
pub(crate) fn for_host_input() -> Self {
Self {
byte_framer: RawInputByteFramer::for_host_input(),
}
}
#[cfg(windows)]
pub(crate) fn host_default_color_query_sent(&mut self, escape_grace: Duration) {
self.byte_framer.host_default_color_query_sent(escape_grace);
}
pub(crate) fn push(&mut self, data: &[u8]) -> Vec<RawInputEvent> {
Self::events_from_chunks(self.byte_framer.push(data))
}
#[cfg(any(windows, test))]
pub(crate) fn has_pending_input(&self) -> bool {
self.byte_framer.has_pending_input()
}
#[cfg(windows)]
pub(crate) fn holds_host_default_color_escape(&self) -> bool {
self.byte_framer.buffer.as_slice() == [ESC]
&& self.byte_framer.host_color_replies_awaited > 0
&& self
.byte_framer
.host_default_color_query_deadline
.is_some_and(|deadline| Instant::now() < deadline)
}
#[cfg(all(test, not(windows)))]
pub(crate) fn holds_host_default_color_escape(&self) -> bool {
false
}
#[cfg(any(windows, test))]
pub(crate) fn has_pending_bracketed_paste(&self) -> bool {
self.byte_framer.has_pending_bracketed_paste()
}
#[cfg(any(windows, test))]
pub(crate) fn has_pending_default_mouse_sequence(&self) -> bool {
starts_with_incomplete_default_mouse_sequence(&self.byte_framer.buffer)
}
pub(crate) fn flush_timeout(&mut self) -> Vec<RawInputEvent> {
Self::events_from_chunks(self.byte_framer.flush_timeout())
}
#[cfg(any(windows, test))]
pub(crate) fn flush_keyboard_escape(&mut self) -> Vec<RawInputEvent> {
if self.byte_framer.buffer.as_slice() == [ESC] {
self.byte_framer.lone_escape_recently_flushed = true;
Self::events_from_chunks(vec![std::mem::take(&mut self.byte_framer.buffer)])
} else {
self.flush_timeout()
}
}
/// Semantic input ends mouse recovery, unlike another idle interval.
#[cfg(any(windows, test))]
pub(crate) fn flush_interrupted(&mut self) -> Vec<RawInputEvent> {
#[cfg(windows)]
self.byte_framer.flush_lone_escape_on_interruption();
let mut chunks = self.byte_framer.flush_timeout();
self.byte_framer.timed_out_mouse_prefix = None;
chunks.extend(self.byte_framer.drain_available_chunks());
Self::events_from_chunks(chunks)
}
fn events_from_chunks(chunks: Vec<Vec<u8>>) -> Vec<RawInputEvent> {
chunks
.into_iter()
.filter_map(|chunk| {
if chunk.as_slice() == [ESC] {
return Some(RawInputEvent::Key(
TerminalKey::new(crossterm::event::KeyCode::Esc, KeyModifiers::empty())
.with_vt_bytes(chunk),
));
}
extract_one_event(&chunk).map(|(event, _consumed)| {
tracing::debug!(raw_bytes = ?chunk, event = ?event, "raw input event parsed");
event
})
})
.collect()
}
}
#[derive(Default)]
pub(crate) struct RawInputByteFramer {
buffer: Vec<u8>,
discard_until: Option<ControlStringFamily>,
discarded_tail_bytes: usize,
// Keep the discarded prefix separate from continuation bytes awaiting validation.
timed_out_mouse_prefix: Option<Vec<u8>>,
lone_escape_recently_flushed: bool,
host_color_replies_awaited: u16,
#[cfg(windows)]
host_default_color_query_issued: bool,
#[cfg(windows)]
host_default_color_query_deadline: Option<Instant>,
#[cfg(windows)]
host_color_tail_deadline: Option<Instant>,
#[cfg(windows)]
host_color_tail_split_st: bool,
host_cell_size_replies_awaited: u16,
host_appearance_reply_awaited: bool,
held_pending_host_reply_esc: bool,
host_color_scheme_change_tracking: bool,
host_appearance_query_on_focus: bool,
split_coalesced_escape: bool,
#[cfg(unix)]
host_escape_disambiguation_active: bool,
#[cfg(unix)]
awaiting_mouse_tail_after: Option<usize>,
}
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 {
pub(crate) fn for_host_input() -> Self {
Self::with_host_input_policy(
crate::platform::capabilities().preserve_legacy_doubled_escape_input,
)
}
fn with_host_input_policy(preserve_legacy_doubled_escape_input: bool) -> Self {
Self {
split_coalesced_escape: !preserve_legacy_doubled_escape_input,
..Self::default()
}
}
pub(crate) fn push(&mut self, data: &[u8]) -> Vec<Vec<u8>> {
self.buffer.extend_from_slice(data);
#[cfg(unix)]
if let Some(prefix_len) = self.awaiting_mouse_tail_after.take() {
if !continues_escape_sequence(&self.buffer) {
// The prefix already outlived keyboard timing; it was a key.
let mut chunks = vec![self.buffer.drain(..prefix_len).collect()];
chunks.extend(self.drain_available_chunks());
return chunks;
}
}
self.drain_available_chunks()
}
/// Hold a lone trailing ESC for one idle flush so an OSC 10/11 reply split
/// at its ESC introducer stitches back together instead of leaking (#549).
/// Overlapping queries each add their own replies to the window.
pub(crate) fn host_color_query_sent(&mut self) {
self.host_color_query_sent_with_replies(HOST_COLOR_QUERY_REPLIES);
}
#[cfg(windows)]
pub(crate) fn host_default_color_query_sent(&mut self, reply_window: Duration) {
self.host_color_query_sent_with_replies(2);
self.host_default_color_query_issued = true;
self.host_default_color_query_deadline = Some(Instant::now() + reply_window);
}
fn host_color_query_sent_with_replies(&mut self, replies: u16) {
self.host_color_replies_awaited = self.host_color_replies_awaited.saturating_add(replies);
self.held_pending_host_reply_esc = false;
}
#[cfg(windows)]
fn flush_lone_escape_on_interruption(&mut self) {
if self.buffer.as_slice() == [ESC] {
self.held_pending_host_reply_esc = true;
self.host_default_color_query_deadline = None;
}
}
fn host_appearance_query_sent(&mut self) {
self.host_appearance_reply_awaited = true;
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
|| self.host_appearance_reply_awaited
}
#[cfg(any(unix, test))]
pub(crate) fn enable_host_color_scheme_change_tracking(&mut self) {
self.host_color_scheme_change_tracking = true;
}
/// Arm the bounded host-reply window when focus gain will emit an appearance query.
/// If the write or reply fails, a lone Escape is delayed for only one extra flush.
#[cfg(any(not(windows), test))]
pub(crate) fn enable_host_appearance_query_on_focus(&mut self) {
self.host_appearance_query_on_focus = true;
}
pub(crate) fn has_pending_input(&self) -> bool {
!self.buffer.is_empty()
}
#[cfg(unix)]
pub(crate) fn set_host_escape_disambiguation_active(&mut self, active: bool) {
self.host_escape_disambiguation_active = active;
}
/// How long to keep holding input after a first idle flush held it.
#[cfg(unix)]
pub(crate) fn held_input_flush_timeout_ms(&self) -> i32 {
if self.awaiting_mouse_tail_after.is_some() {
DISAMBIGUATED_MOUSE_TAIL_FLUSH_TIMEOUT_MS
} else {
RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS
}
}
#[cfg(unix)]
pub(crate) fn has_pending_lone_escape(&self) -> bool {
self.buffer.as_slice() == [ESC]
}
#[cfg(unix)]
pub(crate) fn has_pending_csi_introducer(&self) -> bool {
self.buffer.as_slice() == b"\x1b["
}
#[cfg(unix)]
pub(crate) fn has_pending_incomplete_mouse_sequence(&self) -> bool {
starts_with_incomplete_sgr_mouse_sequence(&self.buffer)
|| starts_with_incomplete_default_mouse_sequence(&self.buffer)
}
#[cfg(any(windows, test))]
pub(crate) fn has_pending_bracketed_paste(&self) -> bool {
self.buffer.starts_with(BRACKETED_PASTE_START)
&& find_subsequence(&self.buffer, BRACKETED_PASTE_END).is_none()
}
pub(crate) fn flush_timeout(&mut self) -> Vec<Vec<u8>> {
let mut chunks = self.drain_available_chunks();
#[cfg(windows)]
let host_color_query_expired = self
.host_default_color_query_deadline
.is_some_and(|deadline| Instant::now() >= deadline);
#[cfg(windows)]
if host_color_query_expired {
self.host_default_color_query_deadline = None;
self.host_color_replies_awaited = 0;
}
// Idle is not evidence that a mouse report has ended. The continuation
// stays bounded and is released if it cannot complete a valid report.
if self.timed_out_mouse_prefix.is_some() {
return chunks;
}
if let Some(family) = self.discard_until {
if family == ControlStringFamily::HostReplyCsi {
return chunks;
}
#[cfg(windows)]
if self.host_color_tail_deadline.is_some() {
return chunks;
}
let keep_split_st = self.buffer.last() == Some(&ESC);
let keep_discarding = plausible_control_string_tail(family, &self.buffer);
self.discarded_tail_bytes = self.discarded_tail_bytes.saturating_add(self.buffer.len());
self.buffer.clear();
if keep_discarding && self.discarded_tail_bytes <= MAX_DISCARDED_CONTROL_TAIL_BYTES {
if keep_split_st {
self.buffer.push(ESC);
}
} else {
self.discard_until = None;
self.discarded_tail_bytes = 0;
}
return chunks;
}
if self.buffer.is_empty() {
return chunks;
}
// A confirmed host sends Escape as `CSI 27u`, so a mouse report prefix
// that outlives keyboard timing may still get a delayed tail (#3480).
// Keep it briefly; `push` releases it if other input follows.
// A finished mouse wait also counts as this prefix's one-flush host
// reply hold, so the two holds never stack.
#[cfg(unix)]
let mouse_wait_served = self.awaiting_mouse_tail_after.take().is_some();
#[cfg(not(unix))]
let mouse_wait_served = false;
#[cfg(unix)]
if !mouse_wait_served
&& self.host_escape_disambiguation_active
&& could_continue_as_mouse_report(&self.buffer)
{
self.awaiting_mouse_tail_after = Some(self.buffer.len());
return chunks;
}
if self.lone_escape_recently_flushed && self.buffer.starts_with(b"[<") {
tracing::debug!(
len = self.buffer.len(),
"discarding incomplete orphaned SGR mouse tail after input timeout"
);
let mut prefix = vec![ESC];
prefix.append(&mut self.buffer);
self.retain_timed_out_mouse_prefix(prefix);
self.lone_escape_recently_flushed = false;
return chunks;
}
if starts_with_incomplete_sgr_mouse_sequence(&self.buffer) {
tracing::debug!(
bytes = ?self.buffer,
"discarding incomplete SGR mouse sequence after input timeout"
);
let prefix = std::mem::take(&mut self.buffer);
self.retain_timed_out_mouse_prefix(prefix);
return chunks;
}
if self.buffer.starts_with(BRACKETED_PASTE_START)
&& find_subsequence(&self.buffer, BRACKETED_PASTE_END).is_none()
{
tracing::trace!(
len = self.buffer.len(),
"waiting for bracketed paste terminator"
);
return chunks;
}
#[cfg(windows)]
if self.host_default_color_query_issued
&& is_incomplete_host_default_color_prefix(&self.buffer)
{
if self.host_default_color_query_deadline.is_some()
&& self.buffer.len() <= MAX_DISCARDED_CONTROL_TAIL_BYTES
{
return chunks;
}
if host_color_query_expired && self.buffer.len() <= MAX_DISCARDED_CONTROL_TAIL_BYTES {
self.discard_until = Some(ControlStringFamily::Osc);
let split_st = self.buffer.last() == Some(&ESC);
self.discarded_tail_bytes = self.buffer.len() - usize::from(split_st);
self.host_color_tail_deadline =
Some(Instant::now() + HOST_COLOR_TAIL_RECOVERY_GRACE);
self.host_color_tail_split_st = split_st;
self.buffer.clear();
if split_st {
self.buffer.push(ESC);
}
return chunks;
}
self.buffer.clear();
return chunks;
}
if starts_with_incomplete_default_color_response(&self.buffer) {
tracing::trace!(
len = self.buffer.len(),
"waiting for host color response terminator"
);
return chunks;
}
if (self.host_cell_size_replies_awaited > 0 || self.host_appearance_reply_awaited)
&& self.buffer.as_slice() == b"\x1b["
{
if !self.held_pending_host_reply_esc && !mouse_wait_served {
self.held_pending_host_reply_esc = true;
tracing::trace!("holding incomplete host CSI reply one flush");
return chunks;
}
self.host_cell_size_replies_awaited = 0;
self.host_appearance_reply_awaited = false;
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) {
if self.host_appearance_reply_awaited && !self.held_pending_host_reply_esc {
self.held_pending_host_reply_esc = true;
tracing::trace!(
len = self.buffer.len(),
"holding incomplete host color scheme report one flush"
);
return chunks;
}
tracing::debug!(
len = self.buffer.len(),
"discarding incomplete host color scheme report after input timeout"
);
self.host_appearance_reply_awaited = false;
self.held_pending_host_reply_esc = false;
self.discard_until = Some(ControlStringFamily::HostReplyCsi);
self.discarded_tail_bytes = 0;
self.buffer.clear();
return chunks;
}
if let Some(ControlString::Incomplete { family }) = control_string(&self.buffer) {
tracing::debug!(
len = self.buffer.len(),
"discarding incomplete host control string after input timeout"
);
// This intentionally gives host control replies precedence over legacy
// Alt forms like Alt+] after timeout, so later reply tails cannot leak.
self.discard_until = Some(family);
self.discarded_tail_bytes = 0;
self.buffer.clear();
return chunks;
}
if self.buffer.as_slice() == [ESC] {
#[cfg(windows)]
if self.host_color_replies_awaited > 0
&& self.host_default_color_query_deadline.is_some()
{
// Native console reply fragments can arrive across several idle polls.
return chunks;
}
if self.awaiting_host_reply() && !self.held_pending_host_reply_esc && !mouse_wait_served
{
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;
#[cfg(windows)]
{
self.host_default_color_query_deadline = None;
}
self.host_cell_size_replies_awaited = 0;
self.host_appearance_reply_awaited = false;
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"
);
self.lone_escape_recently_flushed = true;
chunks.push(std::mem::take(&mut self.buffer));
return chunks;
}
if let Ok(text) = std::str::from_utf8(&self.buffer) {
if parse_terminal_key_sequence(text).is_some() {
chunks.push(std::mem::take(&mut self.buffer));
return chunks;
}
}
if starts_with_incomplete_utf8_char(&self.buffer) {
tracing::trace!(bytes = ?self.buffer, "waiting for UTF-8 continuation bytes");
return chunks;
}
if self.buffer.first() == Some(&ESC) && starts_with_incomplete_utf8_char(&self.buffer[1..])
{
tracing::trace!(bytes = ?self.buffer, "waiting for escaped UTF-8 continuation bytes");
return chunks;
}
tracing::debug!(bytes = ?self.buffer, "dropping incomplete raw input buffer after timeout");
self.lone_escape_recently_flushed = false;
self.buffer.clear();
chunks
}
fn retain_timed_out_mouse_prefix(&mut self, prefix: Vec<u8>) {
self.timed_out_mouse_prefix = (prefix.len() < MAX_DISCARDED_CONTROL_TAIL_BYTES
&& plausible_sgr_mouse_prefix(&prefix))
.then_some(prefix);
}
fn drain_available_chunks(&mut self) -> Vec<Vec<u8>> {
let mut chunks = Vec::new();
loop {
if let Some(prefix) = &self.timed_out_mouse_prefix {
match classify_sgr_mouse_continuation(prefix, &self.buffer) {
SgrMouseContinuation::Incomplete => break,
SgrMouseContinuation::Complete(len) => {
self.buffer.drain(..len);
}
SgrMouseContinuation::Invalid => {}
}
self.timed_out_mouse_prefix = None;
}
if self.lone_escape_recently_flushed {
if starts_with_incomplete_orphaned_sgr_mouse_tail(&self.buffer) {
break;
}
if discard_complete_orphaned_sgr_mouse_tail(&mut self.buffer) {
self.lone_escape_recently_flushed = false;
continue;
}
self.lone_escape_recently_flushed = false;
}
if let Some(family) = self.discard_until {
#[cfg(windows)]
if let Some(deadline) = self.host_color_tail_deadline {
if self.host_color_tail_split_st
&& self.buffer.len() > 1
&& self.buffer[1] != b'\\'
{
self.buffer.drain(..1);
self.end_host_color_tail_recovery();
continue;
}
let terminator_len = osc_string_terminator(&self.buffer);
if let Some(new_escape) = self
.buffer
.windows(2)
.position(|pair| pair[0] == ESC && pair[1] != b'\\')
{
if terminator_len.is_none_or(|len| new_escape < len) {
self.buffer.drain(..new_escape);
self.end_host_color_tail_recovery();
continue;
}
}
let body_len = terminator_len
.map(|len| len - if self.buffer[len - 1] == 0x07 { 1 } else { 2 })
.unwrap_or(self.buffer.len());
if let Some(invalid) = self.buffer[..body_len]
.iter()
.position(|byte| !plausible_host_color_tail_byte(*byte))
{
self.buffer.drain(..invalid);
self.end_host_color_tail_recovery();
continue;
}
if let Some(terminator_len) = terminator_len {
if self.discarded_tail_bytes + terminator_len
<= MAX_DISCARDED_CONTROL_TAIL_BYTES
{
self.buffer.drain(..terminator_len);
self.end_host_color_tail_recovery();
continue;
}
}
if Instant::now() >= deadline
|| self.discarded_tail_bytes + self.buffer.len()
>= MAX_DISCARDED_CONTROL_TAIL_BYTES
{
self.end_host_color_tail_recovery();
continue;
}
break;
}
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;
}
let Some(terminator_len) =
control_string_terminator_for_family(&self.buffer, family)
else {
break;
};
self.buffer.drain(..terminator_len);
self.discard_until = None;
self.discarded_tail_bytes = 0;
continue;
}
if self.split_coalesced_escape && self.buffer.starts_with(b"\x1b\x1b") {
chunks.push(vec![ESC]);
self.buffer.drain(..1);
continue;
}
let Some((event, consumed)) = extract_one_event(&self.buffer) else {
break;
};
if matches!(
event,
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_appearance_query_on_focus
&& matches!(event, RawInputEvent::OuterFocusGained)
{
self.host_appearance_query_sent();
} else if matches!(event, RawInputEvent::HostColorSchemeChanged(_)) {
self.host_appearance_reply_awaited = false;
if self.host_color_scheme_change_tracking {
self.host_color_query_sent();
}
}
self.held_pending_host_reply_esc = false;
chunks.push(self.buffer[..consumed].to_vec());
self.buffer.drain(..consumed);
}
chunks
}
#[cfg(windows)]
fn end_host_color_tail_recovery(&mut self) {
self.discard_until = None;
self.discarded_tail_bytes = 0;
self.host_color_tail_deadline = None;
self.host_color_tail_split_st = false;
}
}
const MAX_DISCARDED_CONTROL_TAIL_BYTES: usize = 128;
#[cfg(windows)]
const HOST_COLOR_TAIL_RECOVERY_GRACE: Duration = Duration::from_millis(100);
#[cfg(windows)]
fn plausible_host_color_tail_byte(byte: u8) -> bool {
byte.is_ascii_hexdigit()
|| matches!(
byte,
b';' | b':'
| b'/'
| b'#'
| b'?'
| b'.'
| b'_'
| b'-'
| b'+'
| b'r'
| b'g'
| b'b'
| b'R'
| b'G'
| b'B'
| ESC
)
}
fn plausible_control_string_tail(family: ControlStringFamily, buffer: &[u8]) -> bool {
match family {
ControlStringFamily::Osc => buffer.iter().all(|byte| {
byte.is_ascii_digit()
|| matches!(
*byte,
b';' | b':'
| b'/'
| b'#'
| b'?'
| b'.'
| b'_'
| b'-'
| b'+'
| b'r'
| b'g'
| b'b'
| b'R'
| b'G'
| b'B'
| ESC
)
}),
ControlStringFamily::StTerminated => buffer.last() == Some(&ESC),
ControlStringFamily::HostReplyCsi => false,
}
}
#[cfg(any(unix, test))]
pub(crate) fn events_require_host_surface_redraw(
events: &[RawInputEvent],
redraw_on_focus_gained: bool,
) -> bool {
redraw_on_focus_gained
&& events
.iter()
.any(|event| matches!(event, RawInputEvent::OuterFocusGained))
}
#[cfg(any(unix, test))]
pub(crate) fn events_require_host_mode_refresh(events: &[RawInputEvent]) -> bool {
events
.iter()
.any(|event| matches!(event, RawInputEvent::OuterFocusGained))
}
#[cfg(any(not(windows), test))]
pub(crate) fn events_require_host_terminal_appearance_query(events: &[RawInputEvent]) -> bool {
events
.iter()
.any(|event| matches!(event, RawInputEvent::OuterFocusGained))
}
#[cfg(any(not(windows), test))]
pub(crate) fn events_require_host_terminal_theme_query(events: &[RawInputEvent]) -> bool {
events
.iter()
.any(|event| matches!(event, RawInputEvent::HostColorSchemeChanged(_)))
}
fn extract_one_event(buffer: &[u8]) -> Option<(RawInputEvent, usize)> {
if buffer.is_empty() {
return None;
}
if buffer.starts_with(BRACKETED_PASTE_START) {
let end = find_subsequence(buffer, BRACKETED_PASTE_END)?;
let content = std::str::from_utf8(&buffer[BRACKETED_PASTE_START.len()..end]).ok()?;
return Some((
RawInputEvent::Paste(content.to_string()),
end + BRACKETED_PASTE_END.len(),
));
}
if buffer[0] == ESC {
let seq_len = complete_escape_sequence_len(buffer)?;
if buffer[..seq_len].starts_with(b"\x1b[M") {
let event = parse_default_mouse(&buffer[..seq_len])
.map(RawInputEvent::Mouse)
.unwrap_or(RawInputEvent::Unsupported);
return Some((event, seq_len));
}
let seq = std::str::from_utf8(&buffer[..seq_len]).ok()?;
if let Some((kind, color)) = parse_default_color_response(seq) {
return Some((RawInputEvent::HostDefaultColor { kind, color }, seq_len));
}
if let Some((index, color)) = parse_palette_color_response(seq) {
return Some((
RawInputEvent::HostPaletteColors {
colors: vec![(index, color)],
},
seq_len,
));
}
match seq {
"\x1b[I" => return Some((RawInputEvent::OuterFocusGained, seq_len)),
"\x1b[O" => return Some((RawInputEvent::OuterFocusLost, seq_len)),
_ => {}
}
if let Some(appearance) = parse_host_color_scheme_report(&buffer[..seq_len]) {
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));
}
if let Some(key) = parse_terminal_key_sequence(seq) {
return Some((
RawInputEvent::Key(key.with_vt_bytes(buffer[..seq_len].to_vec())),
seq_len,
));
}
tracing::debug!(sequence = ?seq, "dropping unsupported escape sequence");
return Some((RawInputEvent::Unsupported, seq_len));
}
let consumed = first_complete_utf8_char_len(buffer)?;
let text = std::str::from_utf8(&buffer[..consumed]).ok()?;
let key = parse_terminal_key_sequence(text)?
.with_text_commit()
.with_vt_bytes(buffer[..consumed].to_vec());
Some((RawInputEvent::Key(key), consumed))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ControlStringFamily {
Osc,
StTerminated,
HostReplyCsi,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ControlString {
Complete {
len: usize,
family: ControlStringFamily,
},
Incomplete {
family: ControlStringFamily,
},
}
fn parse_host_color_scheme_report(buffer: &[u8]) -> Option<HostAppearance> {
match buffer {
GHOSTTY_COLOR_SCHEME_DARK_REPORT => Some(HostAppearance::Dark),
GHOSTTY_COLOR_SCHEME_LIGHT_REPORT => Some(HostAppearance::Light),
_ => None,
}
}
/// 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),
Some(ControlString::Incomplete {
family: ControlStringFamily::Osc
})
) && matches!(buffer.get(..5), Some(b"\x1b]10;" | b"\x1b]11;"))
}
#[cfg(windows)]
fn is_incomplete_host_default_color_prefix(buffer: &[u8]) -> bool {
buffer.starts_with(b"\x1b]")
&& matches!(
control_string(buffer),
Some(ControlString::Incomplete {
family: ControlStringFamily::Osc
})
)
&& [b"\x1b]10;".as_slice(), b"\x1b]11;".as_slice()]
.iter()
.any(|prefix| prefix.starts_with(buffer) || buffer.starts_with(prefix))
}
fn starts_with_incomplete_host_color_scheme_report(buffer: &[u8]) -> bool {
buffer.starts_with(b"\x1b[?")
&& (GHOSTTY_COLOR_SCHEME_DARK_REPORT.starts_with(buffer)
|| GHOSTTY_COLOR_SCHEME_LIGHT_REPORT.starts_with(buffer))
&& 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,
b"\x1bP" | b"\x1b_" | b"\x1b^" | b"\x1bX" => ControlStringFamily::StTerminated,
_ => return None,
};
Some(match control_string_terminator_for_family(buffer, family) {
Some(len) => ControlString::Complete { len, family },
None => ControlString::Incomplete { family },
})
}
fn first_complete_utf8_char_len(buffer: &[u8]) -> Option<usize> {
let width = utf8_char_width(*buffer.first()?)?;
if buffer.len() < width {
return None;
}
std::str::from_utf8(&buffer[..width]).ok()?;
Some(width)
}
fn starts_with_incomplete_utf8_char(buffer: &[u8]) -> bool {
match std::str::from_utf8(buffer) {
Ok(_) => false,
Err(err) => err.valid_up_to() == 0 && err.error_len().is_none(),
}
}
fn utf8_char_width(first: u8) -> Option<usize> {
if first < 0x80 {
Some(1)
} else if first & 0b1110_0000 == 0b1100_0000 {
Some(2)
} else if first & 0b1111_0000 == 0b1110_0000 {
Some(3)
} else if first & 0b1111_1000 == 0b1111_0000 {
Some(4)
} else {
None
}
}
fn complete_escape_sequence_len(buffer: &[u8]) -> Option<usize> {
if buffer.len() == 1 {
return None;
}
if buffer.starts_with(b"\x1b\x1b[<") {
if let Some(mouse_len) = find_csi_final(&buffer[1..], b"Mm") {
let mouse_sequence = std::str::from_utf8(&buffer[1..1 + mouse_len]).ok()?;
if parse_sgr_mouse(mouse_sequence).is_some() {
return Some(1);
}
}
}
if buffer.len() >= 7
&& buffer.starts_with(b"\x1b\x1b[M")
&& parse_default_mouse(&buffer[1..7]).is_some()
{
return Some(1);
}
if buffer.starts_with(b"\x1b\x1b") {
return complete_escape_sequence_len(&buffer[1..]).map(|len| len + 1);
}
if buffer.starts_with(b"\x1b[") {
if buffer.starts_with(b"\x1b[<") {
return find_csi_final(buffer, b"Mm");
}
if buffer.starts_with(b"\x1b[M") {
return (buffer.len() >= 6).then_some(6);
}
return find_csi_final(
buffer,
b"@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~",
);
}
if let Some(control) = control_string(buffer) {
return match control {
ControlString::Complete { len, .. } => Some(len),
ControlString::Incomplete { .. } => None,
};
}
if buffer.starts_with(b"\x1bO") {
return (buffer.len() >= 3).then_some(3);
}
let escaped_char_width = utf8_char_width(buffer[1])?;
if buffer.len() < 1 + escaped_char_width {
return None;
}
std::str::from_utf8(&buffer[1..1 + escaped_char_width]).ok()?;
Some(1 + escaped_char_width)
}
fn starts_with_incomplete_sgr_mouse_sequence(buffer: &[u8]) -> bool {
buffer.starts_with(b"\x1b[<")
&& buffer[3..]
.iter()
.all(|byte| byte.is_ascii_digit() || *byte == b';')
}
/// Whether bytes after a held ESC or ESC[ still read as one escape sequence,
/// such as a mouse report, host reply, or bracketed paste, rather than a key.
#[cfg(unix)]
fn continues_escape_sequence(buffer: &[u8]) -> bool {
match buffer {
[ESC] | [ESC, b'['] => true,
// OSC, DCS and APC host replies can also split after their ESC.
[ESC, b']' | b'P' | b'_', ..] => true,
[ESC, b'[', next, ..] => *next == b'M' || matches!(*next, 0x20..=0x3f),
_ => false,
}
}
#[cfg(unix)]
fn could_continue_as_mouse_report(buffer: &[u8]) -> bool {
[b"\x1b[<".as_slice(), b"\x1b[M"]
.iter()
.any(|prefix| buffer.starts_with(prefix) || prefix.starts_with(buffer))
}
#[cfg(any(unix, windows, test))]
fn starts_with_incomplete_default_mouse_sequence(buffer: &[u8]) -> bool {
buffer.starts_with(b"\x1b[M") && buffer.len() < 6
}
fn starts_with_incomplete_orphaned_sgr_mouse_tail(buffer: &[u8]) -> bool {
if buffer.len() > MAX_ORPHANED_SGR_MOUSE_TAIL_BYTES {
return false;
}
buffer.len() < 3 && b"[<".starts_with(buffer)
|| buffer.starts_with(b"[<")
&& buffer[2..]
.iter()
.all(|byte| byte.is_ascii_digit() || *byte == b';')
}
fn discard_complete_orphaned_sgr_mouse_tail(buffer: &mut Vec<u8>) -> bool {
let Some(terminator_len) = buffer
.iter()
.position(|byte| matches!(*byte, b'M' | b'm'))
.map(|idx| idx + 1)
else {
return false;
};
if terminator_len > MAX_ORPHANED_SGR_MOUSE_TAIL_BYTES {
return false;
}
let mut sequence = Vec::with_capacity(terminator_len + 1);
sequence.push(ESC);
sequence.extend_from_slice(&buffer[..terminator_len]);
let Ok(sequence) = std::str::from_utf8(&sequence) else {
return false;
};
if parse_sgr_mouse(sequence).is_none() {
return false;
}
buffer.drain(..terminator_len);
true
}
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
}
enum SgrMouseContinuation {
Incomplete,
Complete(usize),
Invalid,
}
fn classify_sgr_mouse_continuation(prefix: &[u8], tail: &[u8]) -> SgrMouseContinuation {
let remaining = MAX_DISCARDED_CONTROL_TAIL_BYTES.saturating_sub(prefix.len());
let tail = &tail[..tail.len().min(remaining)];
let final_index = tail
.iter()
.position(|byte| !byte.is_ascii_digit() && *byte != b';');
let payload_len = final_index.unwrap_or(tail.len());
let mut report = prefix.to_vec();
report.extend_from_slice(&tail[..payload_len]);
if !plausible_sgr_mouse_prefix(&report) {
return SgrMouseContinuation::Invalid;
}
if let Some(index) = final_index {
report.push(tail[index]);
let valid = std::str::from_utf8(&report)
.ok()
.and_then(parse_sgr_mouse)
.is_some();
return if valid {
SgrMouseContinuation::Complete(index + 1)
} else {
SgrMouseContinuation::Invalid
};
}
if report.len() >= MAX_DISCARDED_CONTROL_TAIL_BYTES {
SgrMouseContinuation::Invalid
} else {
SgrMouseContinuation::Incomplete
}
}
// Reject impossible continuations early, without changing the general mouse
// parser. A partial last field (including zero) can still become valid.
fn plausible_sgr_mouse_prefix(report: &[u8]) -> bool {
let Some(body) = report.strip_prefix(b"\x1b[<") else {
return false;
};
let mut fields = body.split(|byte| *byte == b';').enumerate().peekable();
while let Some((field, digits)) = fields.next() {
if field > 2 {
return false;
}
if digits.is_empty() {
return fields.peek().is_none();
}
if !digits.iter().all(u8::is_ascii_digit) {
return false;
}
let Some(value) = std::str::from_utf8(digits)
.ok()
.and_then(|digits| digits.parse::<u16>().ok())
else {
return false;
};
if field == 0 && value > u16::from(u8::MAX) {
return false;
}
if fields.peek().is_some()
&& ((field == 0 && parse_mouse_cb(value as u8).is_none()) || (field == 1 && value == 0))
{
return false;
}
}
true
}
fn osc_string_terminator(buffer: &[u8]) -> Option<usize> {
let st = find_subsequence(buffer, b"\x1b\\").map(|idx| idx + 2);
let bel = buffer
.iter()
.position(|byte| *byte == b'\x07')
.map(|idx| idx + 1);
match (st, bel) {
(Some(st), Some(bel)) => Some(st.min(bel)),
(Some(st), None) => Some(st),
(None, Some(bel)) => Some(bel),
(None, None) => None,
}
}
fn st_string_terminator(buffer: &[u8]) -> Option<usize> {
find_subsequence(buffer, b"\x1b\\").map(|idx| idx + 2)
}
fn control_string_terminator_for_family(
buffer: &[u8],
family: ControlStringFamily,
) -> Option<usize> {
match family {
ControlStringFamily::Osc => osc_string_terminator(buffer),
ControlStringFamily::StTerminated => st_string_terminator(buffer),
ControlStringFamily::HostReplyCsi => None,
}
}
fn find_csi_final(buffer: &[u8], finals: &[u8]) -> Option<usize> {
for (idx, byte) in buffer.iter().enumerate().skip(2) {
if finals.contains(byte) {
return Some(idx + 1);
}
}
None
}
fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack
.windows(needle.len())
.position(|window| window == needle)
}
fn parse_default_mouse(sequence: &[u8]) -> Option<MouseEvent> {
let &[ESC, b'[', b'M', encoded_cb, encoded_column, encoded_row] = sequence else {
return None;
};
let cb = encoded_cb.checked_sub(32)?;
let column = u16::from(encoded_column).checked_sub(33)?;
let row = u16::from(encoded_row).checked_sub(33)?;
let (kind, modifiers) = parse_mouse_cb(cb)?;
Some(MouseEvent {
kind,
column,
row,
modifiers,
})
}
fn parse_sgr_mouse(sequence: &str) -> Option<MouseEvent> {
let body = sequence.strip_prefix("\x1b[<")?;
let final_char = body.chars().last()?;
if final_char != 'M' && final_char != 'm' {
return None;
}
let payload = &body[..body.len() - 1];
let mut parts = payload.split(';');
let cb = parts.next()?.parse::<u8>().ok()?;
let column = parts.next()?.parse::<u16>().ok()?.checked_sub(1)?;
let row = parts.next()?.parse::<u16>().ok()?.checked_sub(1)?;
let (kind, modifiers) = parse_mouse_cb(cb)?;
let kind = if final_char == 'm' {
match kind {
MouseEventKind::Down(button) => MouseEventKind::Up(button),
other => other,
}
} else {
kind
};
Some(MouseEvent {
kind,
column,
row,
modifiers,
})
}
fn parse_mouse_cb(cb: u8) -> Option<(MouseEventKind, KeyModifiers)> {
let button_number = (cb & 0b0000_0011) | ((cb & 0b1100_0000) >> 4);
let dragging = cb & 0b0010_0000 == 0b0010_0000;
let kind = match (button_number, dragging) {
(0, false) => MouseEventKind::Down(MouseButton::Left),
(1, false) => MouseEventKind::Down(MouseButton::Middle),
(2, false) => MouseEventKind::Down(MouseButton::Right),
(0, true) => MouseEventKind::Drag(MouseButton::Left),
(1, true) => MouseEventKind::Drag(MouseButton::Middle),
(2, true) => MouseEventKind::Drag(MouseButton::Right),
(3, false) => MouseEventKind::Up(MouseButton::Left),
// Crossterm cannot represent extended-button drags. Preserve their
// position as motion so a stuck host button cannot suppress hover.
(3, true) | (4, true) | (5, true) | (8, true) | (9, true) => MouseEventKind::Moved,
(4, false) => MouseEventKind::ScrollUp,
(5, false) => MouseEventKind::ScrollDown,
(6, false) => MouseEventKind::ScrollLeft,
(7, false) => MouseEventKind::ScrollRight,
_ => return None,
};
let mut modifiers = KeyModifiers::empty();
if cb & 0b0000_0100 != 0 {
modifiers |= KeyModifiers::SHIFT;
}
if cb & 0b0000_1000 != 0 {
modifiers |= KeyModifiers::ALT;
}
if cb & 0b0001_0000 != 0 {
modifiers |= KeyModifiers::CONTROL;
}
Some((kind, modifiers))
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{KeyCode, KeyEventKind};
fn assert_raw_key(event: RawInputEvent, code: KeyCode, modifiers: KeyModifiers) {
let RawInputEvent::Key(key) = event else {
panic!("expected key");
};
assert_eq!(key.code, code);
assert_eq!(key.modifiers, modifiers);
}
fn decode_hex(hex: &str) -> Vec<u8> {
let hex = hex.trim();
assert_eq!(hex.len() % 2, 0, "hex string must have even length");
(0..hex.len())
.step_by(2)
.map(|idx| u8::from_str_radix(&hex[idx..idx + 2], 16).unwrap())
.collect()
}
fn parse_fixture_key_code(value: &str) -> KeyCode {
match value {
"enter" => KeyCode::Enter,
"tab" => KeyCode::Tab,
"backspace" => KeyCode::Backspace,
"esc" => KeyCode::Esc,
"up" => KeyCode::Up,
"down" => KeyCode::Down,
"left" => KeyCode::Left,
"right" => KeyCode::Right,
"home" => KeyCode::Home,
"end" => KeyCode::End,
"pageup" => KeyCode::PageUp,
"pagedown" => KeyCode::PageDown,
"insert" => KeyCode::Insert,
"delete" => KeyCode::Delete,
value if value.starts_with("char:") => {
KeyCode::Char(value.trim_start_matches("char:").chars().next().unwrap())
}
other => panic!("unsupported fixture key code: {other}"),
}
}
fn parse_fixture_modifiers(value: &str) -> KeyModifiers {
if value == "-" || value.is_empty() {
return KeyModifiers::empty();
}
let mut modifiers = KeyModifiers::empty();
for part in value.split('+') {
match part {
"shift" => modifiers |= KeyModifiers::SHIFT,
"alt" => modifiers |= KeyModifiers::ALT,
"control" => modifiers |= KeyModifiers::CONTROL,
"super" => modifiers |= KeyModifiers::SUPER,
"hyper" => modifiers |= KeyModifiers::HYPER,
"meta" => modifiers |= KeyModifiers::META,
other => panic!("unsupported fixture modifier: {other}"),
}
}
modifiers
}
#[test]
fn parses_kitty_shift_letter_release() {
let (RawInputEvent::Key(key), consumed) = extract_one_event(b"\x1b[108:76;2:3u").unwrap()
else {
panic!("expected key");
};
assert_eq!(consumed, 13);
assert_eq!(key.code, KeyCode::Char('l'));
assert_eq!(key.modifiers, KeyModifiers::SHIFT);
assert_eq!(key.kind, KeyEventKind::Release);
assert_eq!(key.shifted_codepoint, Some('L' as u32));
}
#[test]
fn parses_bracketed_paste() {
let (RawInputEvent::Paste(text), consumed) =
extract_one_event(b"\x1b[200~hello\x1b[201~rest").unwrap()
else {
panic!("expected paste");
};
assert_eq!(text, "hello");
assert_eq!(consumed, 17);
}
#[test]
fn complete_text_bracketed_paste_requires_one_exact_utf8_sequence() {
assert_eq!(
complete_text_bracketed_paste(b"\x1b[200~hello\x1b[201~"),
Some("hello")
);
assert!(!is_complete_text_bracketed_paste(b"\x1b[200~hello"));
assert!(!is_complete_text_bracketed_paste(
b"\x1b[200~hello\x1b[201~rest"
));
assert!(!is_complete_text_bracketed_paste(
b"\x1b[200~one\x1b[201~\x1b[200~two\x1b[201~"
));
assert!(!is_complete_text_bracketed_paste(b"\x1b[200~\xff\x1b[201~"));
}
#[test]
fn parses_sgr_mouse() {
let (RawInputEvent::Mouse(mouse), consumed) = extract_one_event(b"\x1b[<0;20;10M").unwrap()
else {
panic!("expected mouse");
};
assert_eq!(consumed, 11);
assert_eq!(mouse.kind, MouseEventKind::Down(MouseButton::Left));
assert_eq!(mouse.column, 19);
assert_eq!(mouse.row, 9);
assert_eq!(mouse.modifiers, KeyModifiers::empty());
}
#[test]
fn parses_default_mouse_encoding() {
let mut framer = RawInputFramer::default();
let events = framer.push(b"\x1b[MCN1");
let [RawInputEvent::Mouse(mouse)] = events.as_slice() else {
panic!("expected one mouse event");
};
assert_eq!(mouse.kind, MouseEventKind::Moved);
assert_eq!((mouse.column, mouse.row), (45, 16));
assert_eq!(mouse.modifiers, KeyModifiers::empty());
}
#[test]
fn rejected_default_mouse_frame_preserves_trailing_input() {
let mut framer = RawInputFramer::default();
let events = framer.push(b"\x1b[M\x82AAx");
assert_eq!(events.len(), 2);
assert!(matches!(events[0], RawInputEvent::Unsupported));
assert_raw_key(
events.into_iter().nth(1).unwrap(),
KeyCode::Char('x'),
KeyModifiers::empty(),
);
}
#[test]
fn parses_extended_button_drag_as_mouse_motion() {
for input in [
b"\x1b[<160;20;10M".as_slice(),
b"\x1b[<161;20;10M".as_slice(),
] {
let (RawInputEvent::Mouse(mouse), _) = extract_one_event(input).unwrap() else {
panic!("expected mouse");
};
assert_eq!(mouse.kind, MouseEventKind::Moved);
assert_eq!((mouse.column, mouse.row), (19, 9));
}
}
#[test]
fn parses_sgr_mouse_observable_modifiers() {
let cases = [
(b"\x1b[<8;20;10M".as_slice(), KeyModifiers::ALT),
(b"\x1b[<16;20;10M".as_slice(), KeyModifiers::CONTROL),
(
b"\x1b[<24;20;10M".as_slice(),
KeyModifiers::ALT | KeyModifiers::CONTROL,
),
];
for (input, expected) in cases {
let (RawInputEvent::Mouse(mouse), _) = extract_one_event(input).unwrap() else {
panic!("expected mouse");
};
assert_eq!(mouse.modifiers, expected);
assert!(!mouse.modifiers.contains(KeyModifiers::SUPER));
}
}
#[test]
fn parses_host_default_color_response_with_st() {
let (RawInputEvent::HostDefaultColor { kind, color }, consumed) =
extract_one_event(b"\x1b]10;rgb:cccc/dddd/eeee\x1b\\").unwrap()
else {
panic!("expected host color response");
};
assert_eq!(consumed, 25);
assert_eq!(kind, DefaultColorKind::Foreground);
assert_eq!(
color,
RgbColor {
r: 0xcc,
g: 0xdd,
b: 0xee
}
);
}
#[test]
fn parses_host_default_color_response_with_bel() {
let (RawInputEvent::HostDefaultColor { kind, color }, consumed) =
extract_one_event(b"\x1b]11;#112233\x07").unwrap()
else {
panic!("expected host color response");
};
assert_eq!(consumed, 13);
assert_eq!(kind, DefaultColorKind::Background);
assert_eq!(
color,
RgbColor {
r: 0x11,
g: 0x22,
b: 0x33
}
);
}
#[test]
fn parses_host_palette_color_response() {
let (RawInputEvent::HostPaletteColors { colors }, consumed) =
extract_one_event(b"\x1b]4;7;rgb:1111/2222/3333\x1b\\").unwrap()
else {
panic!("expected host palette response");
};
assert_eq!(consumed, 26);
assert_eq!(
colors,
vec![(
7,
RgbColor {
r: 0x11,
g: 0x22,
b: 0x33,
}
)]
);
}
#[test]
fn parses_legacy_up_arrow() {
let (RawInputEvent::Key(key), consumed) = extract_one_event(b"\x1b[A").unwrap() else {
panic!("expected key");
};
assert_eq!(consumed, 3);
assert_eq!(key.code, KeyCode::Up);
}
#[test]
fn parses_outer_focus_events() {
let (event, consumed) = extract_one_event(b"\x1b[I").unwrap();
assert_eq!(consumed, 3);
assert!(matches!(event, RawInputEvent::OuterFocusGained));
let (event, consumed) = extract_one_event(b"\x1b[O").unwrap();
assert_eq!(consumed, 3);
assert!(matches!(event, RawInputEvent::OuterFocusLost));
}
#[test]
fn outer_focus_gained_requests_host_surface_redraw() {
let events = parse_raw_input_bytes_sync(b"\x1b[I");
assert!(events_require_host_surface_redraw(&events, true));
assert!(!events_require_host_surface_redraw(&events, false));
let events = parse_raw_input_bytes_sync(b"\x1b[O");
assert!(!events_require_host_surface_redraw(&events, true));
}
#[test]
fn outer_focus_gained_requests_host_mode_refresh() {
assert!(events_require_host_mode_refresh(
&parse_raw_input_bytes_sync(b"\x1b[I")
));
assert!(!events_require_host_mode_refresh(
&parse_raw_input_bytes_sync(b"\x1b[O")
));
}
#[test]
fn outer_focus_gained_requests_host_appearance_query() {
let gained = parse_raw_input_bytes_sync(b"\x1b[I");
let lost = parse_raw_input_bytes_sync(b"\x1b[O");
let scheme_report = parse_raw_input_bytes_sync(b"\x1b[?997;1n");
assert!(events_require_host_terminal_appearance_query(&gained));
assert!(!events_require_host_terminal_appearance_query(&lost));
assert!(!events_require_host_terminal_appearance_query(
&scheme_report
));
assert!(events_require_host_terminal_theme_query(&scheme_report));
}
#[test]
fn parses_ghostty_color_scheme_reports() {
for bytes in [
GHOSTTY_COLOR_SCHEME_DARK_REPORT,
GHOSTTY_COLOR_SCHEME_LIGHT_REPORT,
] {
let events = parse_raw_input_bytes_sync(bytes);
assert_eq!(events.len(), 1, "bytes: {bytes:?}");
assert!(matches!(
events[0],
RawInputEvent::HostColorSchemeChanged(HostAppearance::Dark | HostAppearance::Light)
));
assert!(events_require_host_terminal_theme_query(&events));
}
}
#[test]
fn ghostty_color_scheme_report_parser_is_exact() {
for bytes in [
b"\x1b[?997;0n".as_slice(),
b"\x1b[?997;3n".as_slice(),
b"\x1b[?998;1n".as_slice(),
] {
let events = parse_raw_input_bytes_sync(bytes);
assert_eq!(events.len(), 1, "bytes: {bytes:?}");
assert!(matches!(events[0], RawInputEvent::Unsupported));
assert!(!events_require_host_terminal_theme_query(&events));
}
}
#[test]
fn raw_input_framer_reassembles_split_color_scheme_report() {
let mut framer = RawInputFramer::default();
assert!(framer.push(b"\x1b[?997;").is_empty());
let events = framer.push(b"1n");
assert_eq!(events.len(), 1);
assert!(matches!(
events[0],
RawInputEvent::HostColorSchemeChanged(HostAppearance::Dark)
));
}
#[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();
assert!(framer.push(b"\x1b[").is_empty());
assert_eq!(framer.flush_timeout(), vec![b"\x1b[".to_vec()]);
}
#[test]
fn raw_input_byte_framer_discards_timed_out_split_color_scheme_report_tail() {
let mut framer = RawInputByteFramer::default();
assert!(framer.push(b"\x1b[?997;").is_empty());
assert!(framer.flush_timeout().is_empty());
assert!(framer.push(b"1n").is_empty());
assert_eq!(framer.push(b"a"), vec![b"a".to_vec()]);
assert!(framer.flush_timeout().is_empty());
}
#[test]
fn parses_xterm_alt_up_arrow() {
let (RawInputEvent::Key(key), consumed) = extract_one_event(b"\x1b[1;3A").unwrap() else {
panic!("expected key");
};
assert_eq!(consumed, 6);
assert_eq!(key.code, KeyCode::Up);
assert_eq!(key.modifiers, KeyModifiers::ALT);
}
#[test]
fn parses_legacy_alt_backspace() {
let (RawInputEvent::Key(key), consumed) = extract_one_event(b"\x1b\x7f").unwrap() else {
panic!("expected key");
};
assert_eq!(consumed, 2);
assert_eq!(key.code, KeyCode::Backspace);
assert_eq!(key.modifiers, KeyModifiers::ALT);
}
#[test]
fn parses_kitty_alt_backspace() {
let (RawInputEvent::Key(key), consumed) = extract_one_event(b"\x1b[127;3u").unwrap() else {
panic!("expected key");
};
assert_eq!(consumed, 8);
assert_eq!(key.code, KeyCode::Backspace);
assert_eq!(key.modifiers, KeyModifiers::ALT);
}
#[test]
fn parses_enhanced_pageup_press() {
let (RawInputEvent::Key(key), consumed) = extract_one_event(b"\x1b[5;1:1~").unwrap() else {
panic!("expected key");
};
assert_eq!(consumed, 8);
assert_eq!(key.code, KeyCode::PageUp);
assert_eq!(key.modifiers, KeyModifiers::empty());
assert_eq!(key.kind, KeyEventKind::Press);
}
#[test]
fn parses_enhanced_pagedown_release() {
let (RawInputEvent::Key(key), consumed) = extract_one_event(b"\x1b[6;1:3~").unwrap() else {
panic!("expected key");
};
assert_eq!(consumed, 8);
assert_eq!(key.code, KeyCode::PageDown);
assert_eq!(key.modifiers, KeyModifiers::empty());
assert_eq!(key.kind, KeyEventKind::Release);
}
#[test]
fn raw_input_family_matrix_is_covered() {
let cases: &[(&[u8], KeyCode, KeyModifiers)] = &[
(b"\x02", KeyCode::Char('b'), KeyModifiers::CONTROL),
(b"\r", KeyCode::Enter, KeyModifiers::empty()),
(b"\t", KeyCode::Tab, KeyModifiers::empty()),
(b"\x7f", KeyCode::Backspace, KeyModifiers::empty()),
(b"\x1b[A", KeyCode::Up, KeyModifiers::empty()),
(b"\x1b[1;3A", KeyCode::Up, KeyModifiers::ALT),
(b"\x1b\x7f", KeyCode::Backspace, KeyModifiers::ALT),
(b"\x1b[127;3u", KeyCode::Backspace, KeyModifiers::ALT),
(b"\x1b[57420;1u", KeyCode::Down, KeyModifiers::empty()),
(b"\x1b[57423;1u", KeyCode::Home, KeyModifiers::empty()),
(b"\x1bOq", KeyCode::Char('1'), KeyModifiers::empty()),
(b"\x1b[14~", KeyCode::F(4), KeyModifiers::empty()),
(b"\x1b[11;2~", KeyCode::F(1), KeyModifiers::SHIFT),
(b"\x1b[13;1:1~", KeyCode::F(3), KeyModifiers::empty()),
(b"\x1b[14;3~", KeyCode::F(4), KeyModifiers::ALT),
(b"\x1b[57364;1u", KeyCode::F(1), KeyModifiers::empty()),
(b"\x1b[57366;1u", KeyCode::F(3), KeyModifiers::empty()),
(b"\x1b[57366;2u", KeyCode::F(3), KeyModifiers::SHIFT),
(b"\x1b[57375;1u", KeyCode::F(12), KeyModifiers::empty()),
(b"\x1b[57376;1u", KeyCode::F(13), KeyModifiers::empty()),
(b"\x1b[49:33;2:1u", KeyCode::Char('1'), KeyModifiers::SHIFT),
];
for (bytes, code, modifiers) in cases {
let (event, consumed) = extract_one_event(bytes).unwrap();
assert_eq!(consumed, bytes.len());
assert_raw_key(event, *code, *modifiers);
}
}
#[test]
fn raw_framer_waits_for_application_keypad_sequence_final_byte() {
let mut framer = RawInputFramer::default();
assert!(framer.push(b"\x1bO").is_empty());
let events = framer.push(b"q");
assert_eq!(events.len(), 1);
assert_raw_key(
events.into_iter().next().unwrap(),
KeyCode::Char('1'),
KeyModifiers::empty(),
);
}
#[test]
fn unsupported_ss3_sequence_stays_unsupported() {
let (event, consumed) = extract_one_event(b"\x1bOz").unwrap();
assert_eq!(consumed, 3);
assert!(matches!(event, RawInputEvent::Unsupported));
}
#[test]
fn parses_modified_rxvt_f_key_alias() {
let (event, consumed) = extract_one_event(b"\x1b[14;3~").unwrap();
assert_eq!(consumed, 7);
assert_raw_key(event, KeyCode::F(4), KeyModifiers::ALT);
}
#[test]
fn flushes_lone_escape_after_timeout() {
let mut framer = RawInputFramer::default();
assert!(framer.push(&[ESC]).is_empty());
let events = framer.flush_timeout();
assert_eq!(events.len(), 1);
assert_raw_key(
events.into_iter().next().unwrap(),
KeyCode::Esc,
KeyModifiers::empty(),
);
}
#[test]
fn parses_raw_ctrl_b() {
let (RawInputEvent::Key(key), consumed) = extract_one_event(b"\x02").unwrap() else {
panic!("expected key");
};
assert_eq!(consumed, 1);
assert_eq!(key.code, KeyCode::Char('b'));
assert_eq!(key.modifiers, KeyModifiers::CONTROL);
}
#[test]
fn parses_raw_lf_as_ctrl_j() {
let (RawInputEvent::Key(key), consumed) = extract_one_event(b"\n").unwrap() else {
panic!("expected key");
};
assert_eq!(consumed, 1);
assert_eq!(key.code, KeyCode::Char('j'));
assert_eq!(key.modifiers, KeyModifiers::CONTROL);
}
fn assert_fixture_extracts_whole_events(corpus: &str, macos_layout: bool) {
for line in corpus.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let mut columns: Vec<_> = line.split('\t').collect();
if columns.len() == 5 {
columns.push("");
}
if macos_layout {
if columns.len() == 6 {
columns.push("");
}
assert_eq!(
columns.len(),
7,
"macOS fixture row must have 7 columns: {line}"
);
if columns[2].is_empty() {
continue;
}
let bytes = decode_hex(columns[2]);
let (event, consumed) = extract_one_event(&bytes).unwrap();
assert_eq!(
consumed,
bytes.len(),
"fixture should extract a whole event: {line}"
);
assert_raw_key(
event,
parse_fixture_key_code(columns[3]),
parse_fixture_modifiers(columns[4]),
);
} else {
if columns.len() == 5 {
columns.push("");
}
let (bytes_hex, code, modifiers) = match columns.len() {
6 => {
if columns[1].chars().all(|ch| ch.is_ascii_hexdigit()) {
(columns[1], columns[2], columns[3])
} else {
(columns[2], columns[3], columns[4])
}
}
7 => (columns[2], columns[3], columns[4]),
_ => panic!("fixture row must have 6 or 7 columns: {line}"),
};
assert!(
bytes_hex.chars().all(|ch| ch.is_ascii_hexdigit()),
"non-hex fixture bytes: {bytes_hex} in {line}"
);
let bytes = decode_hex(bytes_hex);
let (event, consumed) = extract_one_event(&bytes).unwrap();
assert_eq!(
consumed,
bytes.len(),
"fixture should extract a whole event: {line}"
);
assert_raw_key(
event,
parse_fixture_key_code(code),
parse_fixture_modifiers(modifiers),
);
}
}
}
#[test]
fn raw_input_corpus_fixture_extracts_whole_events() {
let corpus = include_str!("../tests/fixtures/keyboard_protocol_corpus.tsv");
assert_fixture_extracts_whole_events(corpus, false);
}
#[test]
fn raw_input_macos_terminal_variants_fixture_extracts_whole_events() {
let corpus = include_str!("../tests/fixtures/macos_terminal_variants.tsv");
assert_fixture_extracts_whole_events(corpus, true);
}
#[test]
fn raw_input_linux_terminal_variants_fixture_extracts_whole_events() {
let corpus = include_str!("../tests/fixtures/linux_terminal_variants.tsv");
assert_fixture_extracts_whole_events(corpus, false);
}
#[test]
fn chunked_legacy_arrow_waits_for_completion() {
let mut framer = RawInputFramer::default();
assert!(framer.push(b"\x1b").is_empty());
let events = framer.push(b"[A");
assert_eq!(events.len(), 1);
assert_raw_key(
events.into_iter().next().unwrap(),
KeyCode::Up,
KeyModifiers::empty(),
);
}
#[test]
fn lone_escape_is_buffered_until_timeout_flush() {
let mut framer = RawInputFramer::default();
assert!(framer.push(b"\x1b").is_empty());
let events = framer.flush_timeout();
assert_eq!(events.len(), 1);
assert_raw_key(
events.into_iter().next().unwrap(),
KeyCode::Esc,
KeyModifiers::empty(),
);
}
#[test]
fn escape_followed_by_arrow_before_flush_does_not_emit_escape() {
let mut framer = RawInputFramer::default();
assert!(framer.push(b"\x1b").is_empty());
let events = framer.push(b"[B");
assert_eq!(events.len(), 1);
assert_raw_key(
events.into_iter().next().unwrap(),
KeyCode::Down,
KeyModifiers::empty(),
);
}
#[test]
fn escape_followed_by_sgr_mouse_before_flush_does_not_emit_text() {
let mut framer = RawInputFramer::default();
assert!(framer.push(b"\x1b").is_empty());
let events = framer.push(b"[<65;43;26M");
assert_eq!(events.len(), 1);
assert!(matches!(
events[0],
RawInputEvent::Mouse(MouseEvent {
kind: MouseEventKind::ScrollDown,
column: 42,
row: 25,
..
})
));
}
#[test]
fn lone_escape_then_complete_sgr_mouse_report_emits_both_events() {
for report in [b"\x1b[<35;10;20M".as_slice(), b"\x1b[<35;10;20m".as_slice()] {
let mut framer = RawInputFramer::default();
assert!(framer.push(b"\x1b").is_empty());
let events = framer.push(report);
assert_eq!(events.len(), 2);
let mut events = events.into_iter();
assert_raw_key(events.next().unwrap(), KeyCode::Esc, KeyModifiers::empty());
assert!(matches!(
events.next().unwrap(),
RawInputEvent::Mouse(MouseEvent {
kind: MouseEventKind::Moved,
column: 9,
row: 19,
..
})
));
assert!(framer.flush_timeout().is_empty());
}
}
#[test]
fn lone_escape_then_default_mouse_report_emits_both_events() {
let mut framer = RawInputFramer::default();
assert!(framer.push(b"\x1b").is_empty());
let events = framer.push(b"\x1b[MCN1");
assert_eq!(events.len(), 2);
let mut events = events.into_iter();
assert_raw_key(events.next().unwrap(), KeyCode::Esc, KeyModifiers::empty());
assert!(matches!(
events.next().unwrap(),
RawInputEvent::Mouse(MouseEvent {
kind: MouseEventKind::Moved,
column: 45,
row: 16,
..
})
));
assert!(framer.flush_timeout().is_empty());
}
#[test]
fn legacy_doubled_escape_alt_arrow_remains_one_event() {
let mut framer = RawInputFramer::default();
let events = framer.push(b"\x1b\x1b[A");
assert_eq!(events.len(), 1);
assert_raw_key(
events.into_iter().next().unwrap(),
KeyCode::Up,
KeyModifiers::ALT,
);
assert!(framer.flush_timeout().is_empty());
}
#[cfg(not(target_os = "macos"))]
#[test]
fn non_macos_host_input_splits_lone_escape_from_arrow() {
let mut framer = RawInputByteFramer::for_host_input();
assert_eq!(
framer.push(b"\x1b\x1b[D"),
vec![b"\x1b".to_vec(), b"\x1b[D".to_vec()]
);
}
#[test]
fn macos_host_input_policy_preserves_legacy_doubled_escape_alt_arrow() {
let mut framer = RawInputByteFramer::with_host_input_policy(true);
assert_eq!(framer.push(b"\x1b\x1b[D"), vec![b"\x1b\x1b[D".to_vec()]);
}
#[test]
fn sgr_mouse_sequence_split_after_button_prefix_is_reassembled_before_timeout() {
let mut framer = RawInputFramer::default();
assert!(framer.push(b"\x1b[<3").is_empty());
let events = framer.push(b"5;58;30M");
assert_eq!(events.len(), 1);
assert!(matches!(
events[0],
RawInputEvent::Mouse(MouseEvent {
kind: MouseEventKind::Moved,
column: 57,
row: 29,
..
})
));
}
#[test]
fn timed_out_split_sgr_mouse_tail_is_discarded_and_following_input_is_preserved() {
let mut framer = RawInputFramer::default();
assert!(framer.push(b"\x1b[<3").is_empty());
assert!(framer.flush_timeout().is_empty());
let events = framer.push(b"5;58;30Mx");
assert_eq!(events.len(), 1);
assert_raw_key(
events.into_iter().next().unwrap(),
KeyCode::Char('x'),
KeyModifiers::empty(),
);
}
#[test]
fn captured_sgr_mouse_tail_after_second_idle_flush_is_discarded() {
let mut framer = RawInputByteFramer::for_host_input();
// Issue #3911, 2026-09-13 07:02:14 UTC: this prefix timed out,
// then its tail arrived 33 ms later. The Unix reader flushes again
// after 10 ms of continued idle following the first discard.
assert!(framer.push(b"\x1b[<3").is_empty());
assert!(framer.flush_timeout().is_empty());
assert!(framer.flush_timeout().is_empty());
assert_eq!(framer.push(b"5;28;31M"), Vec::<Vec<u8>>::new());
}
#[test]
fn timed_out_sgr_mouse_invalid_completion_is_preserved_after_idle() {
let mut framer = RawInputByteFramer::default();
assert!(framer.push(b"\x1b[<3").is_empty());
assert!(framer.flush_timeout().is_empty());
assert!(framer.flush_timeout().is_empty());
assert_eq!(framer.push(b"M"), vec![b"M".to_vec()]);
}
#[test]
fn timed_out_sgr_mouse_completion_survives_read_splits_and_idle() {
let tail = b"5;28;31M";
for split in 0..=tail.len() {
let mut framer = RawInputByteFramer::for_host_input();
assert!(framer.push(b"\x1b[<3").is_empty());
assert!(framer.flush_timeout().is_empty());
assert!(framer.flush_timeout().is_empty());
assert!(framer.push(&tail[..split]).is_empty());
assert!(framer.flush_timeout().is_empty());
assert!(framer.flush_timeout().is_empty());
let mut rest = tail[split..].to_vec();
rest.extend_from_slice(b"x\x1b[A");
assert_eq!(framer.push(&rest), vec![b"x".to_vec(), b"\x1b[A".to_vec()]);
assert!(framer.timed_out_mouse_prefix.is_none());
assert!(!framer.has_pending_input());
}
}
#[test]
fn timed_out_sgr_mouse_invalid_syntax_releases_continuation() {
for tail in [
b"5;0;31M".as_slice(), // zero coordinate
b"5;;31M", // empty field
b"5;28;31;1M", // extra field (the general parser is permissive)
b"999;28;31M", // button overflow
b"5;65536;31M", // coordinate overflow
] {
let mut framer = RawInputByteFramer::default();
assert!(framer.push(b"\x1b[<3").is_empty());
assert!(framer.flush_timeout().is_empty());
assert_eq!(framer.push(tail).concat(), tail);
assert!(framer.timed_out_mouse_prefix.is_none());
}
}
#[test]
fn timed_out_sgr_mouse_interruption_preserves_text_and_new_events() {
for suffix in [
b"x".as_slice(),
b"\x1b[A", // new key sequence
b"\x1b[200~paste\x1b[201~", // bracketed paste
"\u{4f60}".as_bytes(), // UTF-8 split across reads
] {
let mut framer = RawInputByteFramer::default();
assert!(framer.push(b"\x1b[<3").is_empty());
assert!(framer.flush_timeout().is_empty());
assert!(framer.push(b"5;28;").is_empty());
assert!(framer.flush_timeout().is_empty());
let mut chunks = Vec::new();
for byte in suffix {
chunks.extend(framer.push(&[*byte]));
}
let mut expected = b"5;28;".to_vec();
expected.extend_from_slice(suffix);
assert_eq!(chunks.concat(), expected);
assert!(framer.timed_out_mouse_prefix.is_none());
assert_eq!(framer.push(b"123M").concat(), b"123M");
}
}
#[test]
fn timed_out_sgr_mouse_budget_includes_prefix_and_preserves_overflow() {
let prefix = b"\x1b[<35;1;";
let remaining = MAX_DISCARDED_CONTROL_TAIL_BYTES - prefix.len();
let mut framer = RawInputByteFramer::default();
assert!(framer.push(prefix).is_empty());
assert!(framer.flush_timeout().is_empty());
let mut valid_tail = vec![b'0'; remaining - 2];
valid_tail.extend_from_slice(b"1M");
assert!(framer.push(&valid_tail).is_empty()); // complete exactly at limit
assert!(framer.timed_out_mouse_prefix.is_none());
for length in [remaining, remaining + 1024] {
let mut framer = RawInputByteFramer::default();
assert!(framer.push(prefix).is_empty());
assert!(framer.flush_timeout().is_empty());
let tail = vec![b'0'; length];
assert_eq!(framer.push(&tail).concat(), tail);
assert!(framer.timed_out_mouse_prefix.is_none());
assert_eq!(framer.push(b"1Mtext").concat(), b"1Mtext");
}
let mut framer = RawInputByteFramer::default();
assert!(framer.push(prefix).is_empty());
assert!(framer.flush_timeout().is_empty());
let mut tail = vec![b'0'; remaining - 1];
assert!(framer.push(&tail).is_empty());
assert!(framer.flush_timeout().is_empty());
tail.extend_from_slice(b"01M");
assert_eq!(framer.push(b"01M").concat(), tail);
assert!(framer.timed_out_mouse_prefix.is_none());
}
#[test]
fn sgr_mouse_tail_after_lone_escape_timeout_is_discarded() {
let mut framer = RawInputFramer::default();
assert!(framer.push(b"\x1b").is_empty());
let timeout_events = framer.flush_timeout();
assert_eq!(timeout_events.len(), 1);
assert_raw_key(
timeout_events.into_iter().next().unwrap(),
KeyCode::Esc,
KeyModifiers::empty(),
);
assert!(framer.push(b"[<65;43;26M").is_empty());
}
#[test]
fn input_after_discarded_complete_sgr_mouse_tail_is_preserved() {
let mut framer = RawInputFramer::default();
assert!(framer.push(b"\x1b").is_empty());
assert_eq!(framer.flush_timeout().len(), 1);
let events = framer.push(b"[<65;43;26Mx");
assert_eq!(events.len(), 1);
assert_raw_key(
events.into_iter().next().unwrap(),
KeyCode::Char('x'),
KeyModifiers::empty(),
);
}
#[test]
fn invalid_orphaned_sgr_mouse_tail_after_escape_timeout_is_preserved() {
let mut framer = RawInputFramer::default();
assert!(framer.push(b"\x1b").is_empty());
assert_eq!(framer.flush_timeout().len(), 1);
let events = framer.push(b"[<x");
assert_eq!(events.len(), 3);
assert_raw_key(
events.into_iter().last().unwrap(),
KeyCode::Char('x'),
KeyModifiers::empty(),
);
}
#[test]
fn double_split_sgr_mouse_tail_after_lone_escape_timeout_is_discarded() {
let mut framer = RawInputFramer::default();
assert!(framer.push(b"\x1b").is_empty());
assert_eq!(framer.flush_timeout().len(), 1);
assert!(framer.push(b"[<65;4").is_empty());
assert!(framer.flush_timeout().is_empty());
assert!(framer.flush_timeout().is_empty());
let events = framer.push(b"3;26Mx");
assert_eq!(events.len(), 1);
assert_raw_key(
events.into_iter().next().unwrap(),
KeyCode::Char('x'),
KeyModifiers::empty(),
);
}
#[test]
fn escape_followed_by_alt_char_before_flush_becomes_alt_key() {
let mut framer = RawInputFramer::default();
assert!(framer.push(b"\x1b").is_empty());
let events = framer.push(b"b");
assert_eq!(events.len(), 1);
assert_raw_key(
events.into_iter().next().unwrap(),
KeyCode::Char('b'),
KeyModifiers::ALT,
);
}
#[test]
fn chunked_kitty_sequence_waits_for_completion() {
let mut framer = RawInputFramer::default();
assert!(framer.push(b"\x1b[49:33;2:").is_empty());
let events = framer.push(b"1u");
assert_eq!(events.len(), 1);
assert_raw_key(
events.into_iter().next().unwrap(),
KeyCode::Char('1'),
KeyModifiers::SHIFT,
);
}
#[test]
fn chunked_bracketed_paste_waits_for_terminator() {
let mut framer = RawInputFramer::default();
assert!(framer.push(b"\x1b[200~hello").is_empty());
let events = framer.push(b"\x1b[201~");
assert_eq!(events.len(), 1);
let RawInputEvent::Paste(text) = &events[0] else {
panic!("expected paste");
};
assert_eq!(text, "hello");
}
#[test]
fn incomplete_bracketed_paste_is_not_flushed_on_timeout() {
let mut framer = RawInputFramer::default();
assert!(framer.push(b"\x1b[200~hello\nworld").is_empty());
assert!(framer.flush_timeout().is_empty());
let events = framer.push(b"\x1b[201~");
assert_eq!(events.len(), 1);
let RawInputEvent::Paste(text) = &events[0] else {
panic!("expected paste");
};
assert_eq!(text, "hello\nworld");
}
#[test]
fn complete_utf8_char_before_incomplete_char_is_drained() {
let mut framer = RawInputByteFramer::default();
let mut input = "你".as_bytes().to_vec();
input.push("好".as_bytes()[0]);
assert_eq!(framer.push(&input), vec!["你".as_bytes().to_vec()]);
assert_eq!(framer.push(&[]), Vec::<Vec<u8>>::new());
}
#[test]
fn incomplete_utf8_prefix_is_not_flushed_on_timeout() {
let mut framer = RawInputByteFramer::default();
let prefix = &"好".as_bytes()[..1];
assert!(framer.push(prefix).is_empty());
assert!(framer.flush_timeout().is_empty());
assert_eq!(
framer.push(&"好".as_bytes()[1..]),
vec!["好".as_bytes().to_vec()]
);
}
#[test]
fn invalid_utf8_lead_byte_is_flushed_instead_of_buffered_forever() {
let mut framer = RawInputByteFramer::default();
assert!(framer.push(&[0xC0]).is_empty());
assert!(framer.flush_timeout().is_empty());
assert!(!framer.has_pending_input());
}
#[test]
fn complete_utf8_char_before_incomplete_char_survives_timeout_and_next_chunk() {
let mut framer = RawInputByteFramer::default();
let mut input = "你".as_bytes().to_vec();
input.push("好".as_bytes()[0]);
assert_eq!(framer.push(&input), vec!["你".as_bytes().to_vec()]);
assert!(framer.flush_timeout().is_empty());
assert_eq!(
framer.push(&"好".as_bytes()[1..]),
vec!["好".as_bytes().to_vec()]
);
}
#[test]
fn alt_utf8_char_drains_as_one_event_before_following_input() {
let events = parse_raw_input_bytes_sync("\x1béx".as_bytes());
assert_eq!(events.len(), 2);
assert_raw_key(
events.into_iter().next().unwrap(),
KeyCode::Char('é'),
KeyModifiers::ALT,
);
}
#[test]
fn chunked_alt_utf8_waits_for_continuation_byte_after_escape() {
let mut framer = RawInputFramer::default();
let bytes = "\x1bé".as_bytes();
assert!(framer.push(&bytes[..2]).is_empty());
assert!(framer.flush_timeout().is_empty());
let events = framer.push(&bytes[2..]);
assert_eq!(events.len(), 1);
assert_raw_key(
events.into_iter().next().unwrap(),
KeyCode::Char('é'),
KeyModifiers::ALT,
);
}
#[test]
fn chunked_utf8_waits_for_continuation_byte() {
let mut framer = RawInputFramer::default();
assert!(framer.push(&"é".as_bytes()[..1]).is_empty());
let events = framer.push(&"é".as_bytes()[1..]);
assert_eq!(events.len(), 1);
assert_raw_key(
events.into_iter().next().unwrap(),
KeyCode::Char('é'),
KeyModifiers::empty(),
);
}
#[test]
fn chunked_cjk_utf8_waits_for_all_continuation_bytes() {
let mut framer = RawInputFramer::default();
let bytes = "好".as_bytes();
assert!(framer.push(&bytes[..1]).is_empty());
assert!(framer.flush_timeout().is_empty());
assert!(framer.push(&bytes[1..2]).is_empty());
assert!(framer.flush_timeout().is_empty());
let events = framer.push(&bytes[2..]);
assert_eq!(events.len(), 1);
assert_raw_key(
events.into_iter().next().unwrap(),
KeyCode::Char('好'),
KeyModifiers::empty(),
);
}
#[test]
fn chunked_four_byte_utf8_waits_for_all_continuation_bytes() {
let mut framer = RawInputFramer::default();
let bytes = "🙂".as_bytes();
for split in 1..bytes.len() {
assert!(framer.push(&bytes[split - 1..split]).is_empty());
assert!(framer.flush_timeout().is_empty());
}
let events = framer.push(&bytes[bytes.len() - 1..]);
assert_eq!(events.len(), 1);
assert_raw_key(
events.into_iter().next().unwrap(),
KeyCode::Char('🙂'),
KeyModifiers::empty(),
);
}
#[test]
fn long_multilingual_voice_like_burst_drains_without_truncation() {
let text = "你好,今天我们测试一段比较长的语音输入。こんにちは。안녕하세요.🙂".repeat(128);
assert!(
text.len() > 4096,
"test input should exceed the client read buffer"
);
let mut framer = RawInputByteFramer::default();
let chunks = framer.push(text.as_bytes());
let rebuilt: Vec<u8> = chunks.into_iter().flatten().collect();
assert!(!framer.has_pending_input());
assert_eq!(rebuilt, text.as_bytes());
}
#[test]
fn long_multilingual_burst_survives_one_byte_chunks_and_timeouts() {
let text = "中文かなカナ한글🙂,。".repeat(64);
let mut framer = RawInputByteFramer::default();
let mut rebuilt = Vec::new();
for byte in text.as_bytes() {
rebuilt.extend(
framer
.push(std::slice::from_ref(byte))
.into_iter()
.flatten(),
);
if framer.has_pending_input() {
assert!(framer.flush_timeout().is_empty());
}
}
rebuilt.extend(framer.flush_timeout().into_iter().flatten());
assert!(!framer.has_pending_input());
assert_eq!(rebuilt, text.as_bytes());
}
#[test]
fn parses_ghostty_default_background_response() {
let events = parse_raw_input_bytes_sync(b"\x1b]11;rgb:2828/2a2a/3636\x07");
assert_eq!(events.len(), 1);
assert!(matches!(
events[0],
RawInputEvent::HostDefaultColor {
kind: DefaultColorKind::Background,
color: RgbColor {
r: 0x28,
g: 0x2a,
b: 0x36
}
}
));
}
#[test]
fn raw_input_framer_reassembles_split_default_background_response() {
let mut framer = RawInputFramer::default();
assert!(framer.push(b"\x1b]").is_empty());
let events = framer.push(b"11;#123456\x07");
assert_eq!(events.len(), 1);
assert!(matches!(
events[0],
RawInputEvent::HostDefaultColor {
kind: DefaultColorKind::Background,
color: RgbColor {
r: 0x12,
g: 0x34,
b: 0x56,
}
}
));
}
#[test]
fn raw_input_byte_framer_discards_split_control_string_after_timeout() {
let mut framer = RawInputByteFramer::default();
assert!(framer.push(b"\x1b]").is_empty());
assert!(framer.flush_timeout().is_empty());
assert!(framer.push(b"11;#123456\x07").is_empty());
assert_eq!(framer.push(b"a"), vec![b"a".to_vec()]);
}
#[test]
fn raw_input_byte_framer_keeps_discarding_tail_across_timeout() {
let mut framer = RawInputByteFramer::default();
assert!(framer.push(b"\x1b]").is_empty());
assert!(framer.flush_timeout().is_empty());
assert!(framer.push(b"1").is_empty());
assert!(framer.flush_timeout().is_empty());
assert!(framer.push(b"1;#123456\x07").is_empty());
assert_eq!(framer.push(b"a"), vec![b"a".to_vec()]);
}
#[test]
fn raw_input_byte_framer_releases_discard_on_implausible_tail() {
let mut framer = RawInputByteFramer::default();
assert!(framer.push(b"\x1b]").is_empty());
assert!(framer.flush_timeout().is_empty());
assert!(framer.push(b"a").is_empty());
assert!(framer.flush_timeout().is_empty());
assert_eq!(framer.push(b"b"), vec![b"b".to_vec()]);
}
#[test]
fn parse_raw_input_bytes_sync_does_not_parse_incomplete_strings_as_alt_keys() {
for bytes in [
b"\x1b]".as_slice(),
b"\x1bP".as_slice(),
b"\x1b_".as_slice(),
b"\x1b^".as_slice(),
b"\x1bX".as_slice(),
] {
let events = parse_raw_input_bytes_sync(bytes);
assert!(events.is_empty(), "parsed {bytes:?} as {events:?}");
}
}
#[test]
fn non_osc_control_strings_ignore_bel_and_complete_at_st() {
let bytes = b"\x1bPabc\x07def\x1b\\x";
let (event, consumed) = extract_one_event(bytes).unwrap();
assert!(matches!(event, RawInputEvent::Unsupported));
assert_eq!(consumed, b"\x1bPabc\x07def\x1b\\".len());
}
#[test]
fn non_osc_default_color_text_remains_key_input() {
let events = parse_raw_input_bytes_sync(b"11;rgb:2828/2a2a/3636\x07");
assert_eq!(events.len(), 22);
assert_raw_key(
events.into_iter().next().unwrap(),
KeyCode::Char('1'),
KeyModifiers::empty(),
);
}
#[test]
fn byte_framer_does_not_hold_non_osc_default_color_text() {
let mut framer = RawInputByteFramer::default();
let chunks = framer.push(b"11;rgb:2828");
assert_eq!(chunks.len(), 11);
assert!(framer.flush_timeout().is_empty());
}
#[cfg(windows)]
#[test]
fn windows_host_color_prefix_respects_query_deadline() {
let mut framer = RawInputFramer::for_host_input();
framer.host_default_color_query_sent(Duration::from_secs(1));
assert!(framer.push(b"\x1b]10").is_empty());
for _ in 0..3 {
assert!(framer.flush_timeout().is_empty());
}
assert!(framer.push(b";rgb:aaaa/").is_empty());
for _ in 0..3 {
assert!(framer.flush_timeout().is_empty());
}
assert!(matches!(
framer.push(b"bbbb/cccc\x1b\\").as_slice(),
[RawInputEvent::HostDefaultColor {
kind: DefaultColorKind::Foreground,
..
}]
));
let mut expired = RawInputFramer::for_host_input();
expired.host_default_color_query_sent(Duration::ZERO);
assert!(expired.push(b"\x1b]11;").is_empty());
assert!(expired.flush_timeout().is_empty());
assert!(matches!(
expired.push(b"x").as_slice(),
[RawInputEvent::Key(key)] if key.code == KeyCode::Char('x')
));
assert!(matches!(
expired.push(b"\x1b]11;rgb:1111/2222/3333\x1b\\").as_slice(),
[RawInputEvent::HostDefaultColor {
kind: DefaultColorKind::Background,
..
}]
));
}
#[cfg(windows)]
#[test]
fn windows_expired_host_color_reply_tail_does_not_reach_pane() {
let mut framer = RawInputFramer::for_host_input();
framer.host_default_color_query_sent(Duration::ZERO);
assert!(framer.push(b"\x1b]11;rgb:1111/").is_empty());
assert!(framer.flush_timeout().is_empty());
assert!(framer.push(b"2222/").is_empty());
for _ in 0..3 {
assert!(framer.flush_timeout().is_empty());
}
assert!(matches!(
framer.push(b"3333\x1b\\x").as_slice(),
[RawInputEvent::Key(key)] if key.code == KeyCode::Char('x')
));
let mut expired = RawInputFramer::for_host_input();
expired.host_default_color_query_sent(Duration::ZERO);
assert!(expired.push(b"\x1b]10;rgb:aaaa/").is_empty());
assert!(expired.flush_timeout().is_empty());
expired.byte_framer.host_color_tail_deadline = Some(Instant::now());
assert!(expired.flush_timeout().is_empty());
assert!(matches!(
expired.push(b"y").as_slice(),
[RawInputEvent::Key(key)] if key.code == KeyCode::Char('y')
));
let mut raw_keyboard = RawInputFramer::for_host_input();
raw_keyboard.host_default_color_query_sent(Duration::ZERO);
assert!(raw_keyboard.push(b"\x1b]11;rgb:1111/").is_empty());
assert!(raw_keyboard.flush_timeout().is_empty());
assert!(raw_keyboard.push(b"a").is_empty());
raw_keyboard.byte_framer.host_color_tail_deadline = Some(Instant::now());
assert!(matches!(
raw_keyboard.flush_timeout().as_slice(),
[RawInputEvent::Key(key)] if key.code == KeyCode::Char('a')
));
let mut split_st = RawInputFramer::for_host_input();
split_st.host_default_color_query_sent(Duration::ZERO);
assert!(split_st.push(b"\x1b]11;rgb:1111/2222/3333\x1b").is_empty());
assert!(split_st.flush_timeout().is_empty());
assert!(matches!(
split_st.push(b"\\x").as_slice(),
[RawInputEvent::Key(key)] if key.code == KeyCode::Char('x')
));
let mut abandoned_st = RawInputFramer::for_host_input();
abandoned_st.host_default_color_query_sent(Duration::ZERO);
assert!(abandoned_st
.push(b"\x1b]11;rgb:1111/2222/3333\x1b")
.is_empty());
assert!(abandoned_st.flush_timeout().is_empty());
assert!(matches!(
abandoned_st
.push(b"\x1b]10;rgb:aaaa/bbbb/cccc\x1b\\")
.as_slice(),
[RawInputEvent::HostDefaultColor {
kind: DefaultColorKind::Foreground,
..
}]
));
let mut next_reply = RawInputFramer::for_host_input();
next_reply.host_default_color_query_sent(Duration::ZERO);
assert!(next_reply.push(b"\x1b]11;rgb:1111/").is_empty());
assert!(next_reply.flush_timeout().is_empty());
assert!(next_reply.push(b"\x1b").is_empty());
assert!(matches!(
next_reply.push(b"]10;rgb:aaaa/bbbb/cccc\x1b\\").as_slice(),
[RawInputEvent::HostDefaultColor {
kind: DefaultColorKind::Foreground,
..
}]
));
}
#[test]
fn holds_lone_escape_and_stitches_split_host_color_reply() {
let mut framer = RawInputByteFramer::default();
framer.host_color_query_sent();
// The reply is split right at its ESC introducer.
assert!(framer.push(b"\x1b").is_empty());
// The idle flush must not release the ESC as an Escape key while a host
// color reply is still outstanding.
assert!(framer.flush_timeout().is_empty());
// The rest of the OSC 11 reply arrives and stitches back together
// instead of leaking its payload into the focused pane.
let chunks = framer.push(b"]11;rgb:2424/2727/3a3a\x1b\\");
assert_eq!(chunks.len(), 1);
let (event, _) = extract_one_event(&chunks[0]).unwrap();
assert!(matches!(
event,
RawInputEvent::HostDefaultColor {
kind: DefaultColorKind::Background,
..
}
));
}
#[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();
assert_eq!(
framer.push(GHOSTTY_COLOR_SCHEME_DARK_REPORT),
vec![GHOSTTY_COLOR_SCHEME_DARK_REPORT.to_vec()]
);
assert!(framer.push(b"\x1b").is_empty());
assert_eq!(framer.flush_timeout(), vec![b"\x1b".to_vec()]);
}
#[test]
fn opt_in_does_not_delay_plain_escape_without_color_scheme_report() {
let mut framer = RawInputByteFramer::default();
framer.enable_host_color_scheme_change_tracking();
assert!(framer.push(b"\x1b").is_empty());
assert_eq!(framer.flush_timeout(), vec![b"\x1b".to_vec()]);
}
#[test]
fn opted_in_byte_framer_rearms_after_outer_focus_gained() {
let mut framer = RawInputByteFramer::default();
framer.enable_host_color_scheme_change_tracking();
framer.enable_host_appearance_query_on_focus();
assert_eq!(framer.push(b"\x1b[I"), vec![b"\x1b[I".to_vec()]);
assert!(framer.push(b"\x1b").is_empty());
assert!(framer.flush_timeout().is_empty());
assert_eq!(
framer.push(b"[?997;2n"),
vec![GHOSTTY_COLOR_SCHEME_LIGHT_REPORT.to_vec()]
);
}
#[test]
fn opted_in_byte_framer_reassembles_appearance_reply_split_after_csi() {
let mut framer = RawInputByteFramer::default();
framer.enable_host_color_scheme_change_tracking();
framer.enable_host_appearance_query_on_focus();
assert_eq!(framer.push(b"\x1b[I"), vec![b"\x1b[I".to_vec()]);
assert!(framer.push(b"\x1b[").is_empty());
assert!(framer.flush_timeout().is_empty());
assert_eq!(
framer.push(b"?997;2n"),
vec![GHOSTTY_COLOR_SCHEME_LIGHT_REPORT.to_vec()]
);
}
#[test]
fn opted_in_byte_framer_reassembles_delayed_appearance_reply() {
let mut framer = RawInputByteFramer::default();
framer.enable_host_color_scheme_change_tracking();
framer.enable_host_appearance_query_on_focus();
assert_eq!(framer.push(b"\x1b[I"), vec![b"\x1b[I".to_vec()]);
assert!(framer.push(b"\x1b[?997;").is_empty());
assert!(framer.flush_timeout().is_empty());
assert_eq!(
framer.push(b"2n"),
vec![GHOSTTY_COLOR_SCHEME_LIGHT_REPORT.to_vec()]
);
}
#[test]
fn timed_out_appearance_reply_preserves_pending_color_reply_window() {
let mut framer = RawInputByteFramer::default();
framer.host_color_query_sent();
framer.enable_host_appearance_query_on_focus();
assert_eq!(framer.push(b"\x1b[I"), vec![b"\x1b[I".to_vec()]);
assert!(framer.push(b"\x1b[?997;").is_empty());
assert!(framer.flush_timeout().is_empty());
assert!(framer.flush_timeout().is_empty());
assert!(framer.push(b"2n").is_empty());
assert!(framer.push(b"\x1b").is_empty());
assert!(framer.flush_timeout().is_empty());
assert_eq!(
framer.push(b"]10;rgb:aaaa/bbbb/cccc\x1b\\"),
vec![b"\x1b]10;rgb:aaaa/bbbb/cccc\x1b\\".to_vec()]
);
}
#[test]
fn disabled_focus_query_does_not_rearm_byte_framer() {
let mut framer = RawInputByteFramer::default();
framer.enable_host_color_scheme_change_tracking();
assert_eq!(framer.push(b"\x1b[I"), vec![b"\x1b[I".to_vec()]);
assert!(framer.push(b"\x1b").is_empty());
assert_eq!(framer.flush_timeout(), vec![b"\x1b".to_vec()]);
}
#[test]
fn focus_query_policy_does_not_delay_plain_escape_without_focus() {
let mut framer = RawInputByteFramer::default();
framer.enable_host_appearance_query_on_focus();
assert!(framer.push(b"\x1b").is_empty());
assert_eq!(framer.flush_timeout(), vec![b"\x1b".to_vec()]);
}
#[test]
fn focus_query_without_reply_holds_escape_for_only_one_flush() {
let mut framer = RawInputByteFramer::default();
framer.enable_host_appearance_query_on_focus();
assert_eq!(framer.push(b"\x1b[I"), vec![b"\x1b[I".to_vec()]);
assert!(framer.push(b"\x1b").is_empty());
assert!(framer.flush_timeout().is_empty());
assert_eq!(framer.flush_timeout(), vec![b"\x1b".to_vec()]);
}
#[test]
fn opted_in_byte_framer_rearms_after_color_scheme_report() {
let mut framer = RawInputByteFramer::default();
framer.enable_host_color_scheme_change_tracking();
assert_eq!(
framer.push(GHOSTTY_COLOR_SCHEME_DARK_REPORT),
vec![GHOSTTY_COLOR_SCHEME_DARK_REPORT.to_vec()]
);
assert!(framer.push(b"\x1b").is_empty());
assert!(framer.flush_timeout().is_empty());
let chunks = framer.push(b"]10;#abcdef\x07");
assert_eq!(chunks.len(), 1);
let (event, _) = extract_one_event(&chunks[0]).unwrap();
assert!(matches!(
event,
RawInputEvent::HostDefaultColor {
kind: DefaultColorKind::Foreground,
color: RgbColor {
r: 0xab,
g: 0xcd,
b: 0xef
}
}
));
assert!(framer.push(b"\x1b").is_empty());
assert!(framer.flush_timeout().is_empty());
let chunks = framer.push(b"]11;#123456\x07");
assert_eq!(chunks.len(), 1);
let (event, _) = extract_one_event(&chunks[0]).unwrap();
assert!(matches!(
event,
RawInputEvent::HostDefaultColor {
kind: DefaultColorKind::Background,
color: RgbColor {
r: 0x12,
g: 0x34,
b: 0x56
}
}
));
assert!(framer.push(b"\x1b").is_empty());
assert!(framer.flush_timeout().is_empty());
assert_eq!(framer.flush_timeout(), vec![b"\x1b".to_vec()]);
}
#[test]
fn flushes_lone_escape_when_not_awaiting_host_color_reply() {
let mut framer = RawInputByteFramer::default();
assert!(framer.push(b"\x1b").is_empty());
assert_eq!(framer.flush_timeout(), vec![b"\x1b".to_vec()]);
}
#[test]
fn gives_up_holding_lone_escape_after_one_idle_flush() {
let mut framer = RawInputByteFramer::default();
framer.host_color_query_sent();
assert!(framer.push(b"\x1b").is_empty());
// First idle flush holds the escape.
assert!(framer.flush_timeout().is_empty());
// No continuation arrived; the second idle flush releases it as Escape.
assert_eq!(framer.flush_timeout(), vec![b"\x1b".to_vec()]);
}
#[test]
fn stops_holding_lone_escape_after_host_color_reply_completes() {
use std::fmt::Write as _;
let mut framer = RawInputByteFramer::default();
framer.host_color_query_sent();
let mut replies =
String::from("\x1b]10;rgb:6565/7b7b/8383\x1b\\\x1b]11;rgb:2424/2727/3a3a\x1b\\");
for index in 0..=u8::MAX {
let _ = write!(replies, "\x1b]4;{index};rgb:1111/2222/3333\x1b\\");
}
let chunks = framer.push(replies.as_bytes());
assert_eq!(chunks.len(), 258);
// 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 overlapping_host_color_queries_keep_holding_split_reply_escape() {
use std::fmt::Write as _;
let mut framer = RawInputByteFramer::default();
framer.host_color_query_sent();
framer.host_color_query_sent();
let mut first_batch =
String::from("\x1b]10;rgb:6565/7b7b/8383\x1b\\\x1b]11;rgb:2424/2727/3a3a\x1b\\");
for index in 0..=u8::MAX {
let _ = write!(first_batch, "\x1b]4;{index};rgb:1111/2222/3333\x1b\\");
}
assert_eq!(framer.push(first_batch.as_bytes()).len(), 258);
// The second batch is still outstanding, so its first reply split at ESC
// must not leak into the pane as an Escape key.
assert!(framer.push(b"\x1b").is_empty());
assert!(framer.flush_timeout().is_empty());
let chunks = framer.push(b"]10;rgb:eeee/eeee/eeee\x1b\\");
assert_eq!(chunks.len(), 1);
let (event, _) = extract_one_event(&chunks[0]).unwrap();
assert!(matches!(
event,
RawInputEvent::HostDefaultColor {
kind: DefaultColorKind::Foreground,
..
}
));
}
}