Files
herdr/src/raw_input.rs
T

3119 lines
103 KiB
Rust

use std::io::Read;
use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
/// Parse raw terminal input bytes into a list of `RawInputEvent`s.
///
/// This is used by the headless server to route client input through the
/// same parsing pipeline that the monolithic binary uses for stdin.
/// Incomplete sequences at the end of the buffer are flushed as best-effort
/// (same logic as the live input reader).
#[allow(dead_code)]
pub fn parse_raw_input_bytes(data: &[u8]) -> Vec<RawInputEvent> {
// Delegate to the sync version which actually works.
parse_raw_input_bytes_sync(data)
}
/// A raw input event paired with the byte range it consumed from the original buffer.
#[cfg(test)]
#[derive(Debug)]
pub struct RawInputEventWithRange {
/// The parsed event.
pub event: RawInputEvent,
/// Byte offset where this event starts in the original buffer.
pub start: usize,
/// Number of bytes this event consumed from the original buffer.
/// For events generated from flushed incomplete bytes, `len` may be 0
/// (synthetic events that don't map to original bytes).
pub len: usize,
}
/// Parse raw terminal input bytes into a list of `RawInputEventWithRange`s (synchronous version).
///
/// Unlike `parse_raw_input_bytes_sync`, this preserves the byte offset for each
/// event, allowing callers to write only the specific bytes for each event
/// instead of the entire input buffer.
#[cfg(test)]
pub fn parse_raw_input_bytes_with_ranges(data: &[u8]) -> Vec<RawInputEventWithRange> {
let mut buffer = data.to_vec();
let mut events = Vec::new();
let mut offset = 0usize;
while let Some((event, consumed)) = extract_one_event(&buffer) {
buffer.drain(..consumed);
events.push(RawInputEventWithRange {
event,
start: offset,
len: consumed,
});
offset += consumed;
}
// Flush remaining incomplete bytes.
if !buffer.is_empty() {
if buffer.as_slice() == [ESC] {
events.push(RawInputEventWithRange {
event: RawInputEvent::Key(
TerminalKey::new(crossterm::event::KeyCode::Esc, KeyModifiers::empty())
.with_vt_bytes(vec![ESC]),
),
start: offset,
len: 1,
});
} else if matches!(
control_string(&buffer),
Some(ControlString::Incomplete { .. })
) {
return events;
} else if let Ok(text) = std::str::from_utf8(&buffer) {
if let Some(key) = parse_terminal_key_sequence(text) {
events.push(RawInputEventWithRange {
event: RawInputEvent::Key(key.with_text_commit().with_vt_bytes(buffer.clone())),
start: offset,
len: buffer.len(),
});
}
}
}
events
}
/// Parse raw terminal input bytes into a list of `RawInputEvent`s (synchronous version).
///
/// Unlike `parse_raw_input_bytes`, this directly extracts events without
/// going through a channel, making it suitable for synchronous use.
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
}
#[cfg(unix)]
use std::os::fd::AsRawFd;
use tokio::sync::mpsc;
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;
pub(crate) const RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS: i32 = 10;
pub(crate) const MOUSE_ACTIVE_ESCAPE_SEQUENCE_FLUSH_TIMEOUT_MS: i32 = 150;
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 {
pub(crate) fn for_host_input() -> Self {
Self {
byte_framer: RawInputByteFramer::for_host_input(),
}
}
pub(crate) fn push(&mut self, data: &[u8]) -> Vec<RawInputEvent> {
Self::events_from_chunks(self.byte_framer.push(data))
}
pub(crate) fn host_color_query_sent(&mut self) {
self.byte_framer.host_color_query_sent();
}
pub(crate) fn enable_host_color_scheme_change_tracking(&mut self) {
self.byte_framer.enable_host_color_scheme_change_tracking();
}
#[cfg(any(not(windows), test))]
pub(crate) fn enable_host_appearance_query_on_focus(&mut self) {
self.byte_framer.enable_host_appearance_query_on_focus();
}
pub(crate) fn has_pending_input(&self) -> bool {
self.byte_framer.has_pending_input()
}
pub(crate) fn has_pending_incomplete_mouse_sequence(&self) -> bool {
self.byte_framer.has_pending_incomplete_mouse_sequence()
}
#[cfg(any(windows, test))]
pub(crate) fn has_pending_bracketed_paste(&self) -> bool {
self.byte_framer.has_pending_bracketed_paste()
}
pub(crate) fn flush_timeout(&mut self) -> Vec<RawInputEvent> {
Self::events_from_chunks(self.byte_framer.flush_timeout())
}
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,
lone_escape_recently_flushed: bool,
host_color_replies_awaited: u16,
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,
}
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);
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).
pub(crate) fn host_color_query_sent(&mut self) {
self.host_color_replies_awaited = HOST_COLOR_QUERY_REPLIES;
self.held_pending_host_reply_esc = false;
}
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
}
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(any(not(windows), test))]
pub(crate) fn has_pending_lone_escape(&self) -> bool {
self.buffer.as_slice() == [ESC]
}
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();
if let Some(family) = self.discard_until {
if family == ControlStringFamily::HostReplyCsi {
return chunks;
}
if family == ControlStringFamily::OrphanedSgrMouseTail {
self.buffer.clear();
self.discard_until = None;
self.discarded_tail_bytes = 0;
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;
}
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"
);
discard_or_buffer_orphaned_sgr_mouse_tail(
&mut self.buffer,
&mut self.discard_until,
&mut self.discarded_tail_bytes,
);
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"
);
self.discarded_tail_bytes = self.buffer.len();
self.discard_until = (self.discarded_tail_bytes <= MAX_DISCARDED_CONTROL_TAIL_BYTES)
.then_some(ControlStringFamily::OrphanedSgrMouseTail);
self.buffer.clear();
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;
}
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 {
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] {
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.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 drain_available_chunks(&mut self) -> Vec<Vec<u8>> {
let mut chunks = Vec::new();
loop {
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 {
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,
&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
}
}
const MAX_DISCARDED_CONTROL_TAIL_BYTES: usize = 128;
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,
ControlStringFamily::OrphanedSgrMouseTail => buffer
.iter()
.all(|byte| byte.is_ascii_digit() || matches!(*byte, b';' | b'M' | b'm')),
}
}
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(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 input_flush_timeout_ms(framer: &RawInputFramer) -> i32 {
if framer.has_pending_incomplete_mouse_sequence() {
MOUSE_ACTIVE_ESCAPE_SEQUENCE_FLUSH_TIMEOUT_MS
} else {
RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS
}
}
pub fn spawn_input_reader() -> mpsc::Receiver<RawInputEvent> {
let (tx, rx) = mpsc::channel(256);
std::thread::spawn(move || {
let stdin = std::io::stdin();
let mut reader = stdin.lock();
let mut scratch = [0u8; 1024];
let mut framer = RawInputFramer::for_host_input();
framer.host_color_query_sent();
framer.enable_host_color_scheme_change_tracking();
#[cfg(not(windows))]
framer.enable_host_appearance_query_on_focus();
let mut pending_palette = Vec::new();
loop {
match reader.read(&mut scratch) {
Ok(0) => break,
Ok(n) => {
send_raw_input_events(framer.push(&scratch[..n]), &tx, &mut pending_palette);
if stdin_read_ready(&reader, input_flush_timeout_ms(&framer)) == Some(false) {
let had_pending = framer.has_pending_input();
let events = framer.flush_timeout();
let held_escape = had_pending && events.is_empty();
send_raw_input_events(events, &tx, &mut pending_palette);
flush_host_palette_events(&tx, &mut pending_palette);
if held_escape
&& stdin_read_ready(&reader, RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS)
== Some(false)
{
send_raw_input_events(
framer.flush_timeout(),
&tx,
&mut pending_palette,
);
flush_host_palette_events(&tx, &mut pending_palette);
}
}
}
Err(_) => break,
}
}
});
rx
}
fn send_raw_input_events(
events: Vec<RawInputEvent>,
tx: &mpsc::Sender<RawInputEvent>,
pending_palette: &mut Vec<(u8, RgbColor)>,
) {
for event in events {
match event {
RawInputEvent::HostPaletteColors { colors } => {
pending_palette.extend(colors);
if pending_palette.len() == 256 {
flush_host_palette_events(tx, pending_palette);
}
}
event @ RawInputEvent::HostDefaultColor { .. } => {
let _ = tx.blocking_send(event);
}
event => {
flush_host_palette_events(tx, pending_palette);
let _ = tx.blocking_send(event);
}
}
}
}
fn flush_host_palette_events(
tx: &mpsc::Sender<RawInputEvent>,
pending_palette: &mut Vec<(u8, RgbColor)>,
) {
if pending_palette.is_empty() {
return;
}
let colors = std::mem::take(pending_palette);
let _ = tx.blocking_send(RawInputEvent::HostPaletteColors { colors });
}
#[cfg(test)]
fn drain_buffer(buffer: &mut Vec<u8>, tx: &mpsc::Sender<RawInputEvent>) {
for bytes in drain_complete_input_bytes(buffer) {
let Some((event, _consumed)) = extract_one_event(&bytes) else {
continue;
};
tracing::debug!(raw_bytes = ?bytes, event = ?event, "raw input event parsed");
let _ = tx.blocking_send(event);
}
}
#[cfg(test)]
pub(crate) fn drain_complete_input_bytes(buffer: &mut Vec<u8>) -> Vec<Vec<u8>> {
let mut chunks = Vec::new();
while let Some((_event, consumed)) = extract_one_event(buffer) {
chunks.push(buffer[..consumed].to_vec());
buffer.drain(..consumed);
}
chunks
}
#[cfg(test)]
fn flush_incomplete_buffer(buffer: &mut Vec<u8>, tx: &mpsc::Sender<RawInputEvent>) {
if let Some(bytes) = flush_incomplete_input_bytes(buffer) {
if bytes.as_slice() == [ESC] {
let _ = tx.blocking_send(RawInputEvent::Key(
TerminalKey::new(crossterm::event::KeyCode::Esc, KeyModifiers::empty())
.with_vt_bytes(bytes),
));
return;
}
let Some((event, _consumed)) = extract_one_event(&bytes) else {
return;
};
let _ = tx.blocking_send(event);
}
}
#[cfg(test)]
pub(crate) fn flush_incomplete_input_bytes(buffer: &mut Vec<u8>) -> Option<Vec<u8>> {
let mut framer = RawInputByteFramer {
buffer: std::mem::take(buffer),
..Default::default()
};
let mut chunks = framer.flush_timeout();
*buffer = framer.buffer;
chunks.pop()
}
#[cfg(unix)]
fn stdin_read_ready<R: AsRawFd>(_reader: &R, _timeout_ms: i32) -> Option<bool> {
#[cfg(unix)]
{
let fd = _reader.as_raw_fd();
poll_read_ready(fd, _timeout_ms)
}
}
#[cfg(not(unix))]
fn stdin_read_ready<R>(_reader: &R, _timeout_ms: i32) -> Option<bool> {
None
}
#[cfg(unix)]
fn poll_read_ready(fd: i32, timeout_ms: i32) -> Option<bool> {
#[repr(C)]
struct PollFd {
fd: i32,
events: i16,
revents: i16,
}
unsafe extern "C" {
fn poll(fds: *mut PollFd, nfds: usize, timeout: i32) -> i32;
}
const POLLIN: i16 = 0x0001;
let mut pfd = PollFd {
fd,
events: POLLIN,
revents: 0,
};
let result = unsafe { poll(&mut pfd as *mut PollFd, 1, timeout_ms) };
if result < 0 {
None
} else {
Some(result > 0)
}
}
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,
OrphanedSgrMouseTail,
}
#[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;"))
}
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';')
}
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) =
control_string_terminator_for_family(buffer, ControlStringFamily::OrphanedSgrMouseTail)
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_or_buffer_orphaned_sgr_mouse_tail(
buffer: &mut Vec<u8>,
discard_until: &mut Option<ControlStringFamily>,
discarded_tail_bytes: &mut usize,
) {
if !discard_complete_orphaned_sgr_mouse_tail(buffer) {
*discarded_tail_bytes = buffer.len();
*discard_until = (*discarded_tail_bytes <= MAX_DISCARDED_CONTROL_TAIL_BYTES)
.then_some(ControlStringFamily::OrphanedSgrMouseTail);
buffer.clear();
}
}
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);
for index in 0..inspected {
match buffer[index] {
b'0'..=b'9' | b';' => {}
b'M' | b'm' => {
buffer.drain(..=index);
return true;
}
_ => {
buffer.drain(..index);
return true;
}
}
}
*discarded_tail_bytes = discarded_tail_bytes.saturating_add(inspected);
if buffer.len() > inspected {
buffer.clear();
return true;
}
buffer.clear();
false
}
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,
ControlStringFamily::OrphanedSgrMouseTail => buffer
.iter()
.position(|byte| matches!(*byte, b'M' | b'm'))
.map(|idx| idx + 1),
}
}
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
}
fn collect_events(rx: &mut mpsc::Receiver<RawInputEvent>) -> Vec<RawInputEvent> {
let mut events = Vec::new();
while let Ok(event) = rx.try_recv() {
events.push(event);
}
events
}
fn drain_chunk(buffer: &mut Vec<u8>, tx: &mpsc::Sender<RawInputEvent>, chunk: &[u8]) {
buffer.extend_from_slice(chunk);
drain_buffer(buffer, tx);
}
#[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 events = parse_raw_input_bytes_sync(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 events = parse_raw_input_bytes_with_ranges(b"\x1b[M\x82AAx");
assert_eq!(events.len(), 2);
assert!(matches!(events[0].event, RawInputEvent::Unsupported));
assert_eq!((events[0].start, events[0].len), (0, 6));
assert!(matches!(events[1].event, RawInputEvent::Key(_)));
assert_eq!((events[1].start, events[1].len), (6, 1));
}
#[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_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[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 modified_rxvt_f_key_alias_stays_unsupported() {
let (event, consumed) = extract_one_event(b"\x1b[14;3~").unwrap();
assert_eq!(consumed, 7);
assert!(matches!(event, RawInputEvent::Unsupported));
}
#[test]
fn flushes_lone_escape_after_timeout() {
let (tx, mut rx) = mpsc::channel(4);
let mut buffer = vec![ESC];
flush_incomplete_buffer(&mut buffer, &tx);
assert!(buffer.is_empty());
let event = rx.try_recv().unwrap();
let RawInputEvent::Key(key) = event else {
panic!("expected key");
};
assert_eq!(key.code, KeyCode::Esc);
}
#[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 (tx, mut rx) = mpsc::channel(8);
let mut buffer = Vec::new();
drain_chunk(&mut buffer, &tx, b"\x1b");
assert_eq!(buffer, b"\x1b");
assert!(collect_events(&mut rx).is_empty());
drain_chunk(&mut buffer, &tx, b"[A");
assert!(buffer.is_empty());
let events = collect_events(&mut rx);
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 (tx, mut rx) = mpsc::channel(8);
let mut buffer = Vec::new();
drain_chunk(&mut buffer, &tx, b"\x1b");
assert_eq!(buffer, b"\x1b");
assert!(collect_events(&mut rx).is_empty());
flush_incomplete_buffer(&mut buffer, &tx);
assert!(buffer.is_empty());
let events = collect_events(&mut rx);
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 (tx, mut rx) = mpsc::channel(8);
let mut buffer = Vec::new();
drain_chunk(&mut buffer, &tx, b"\x1b");
assert_eq!(buffer, b"\x1b");
assert!(collect_events(&mut rx).is_empty());
drain_chunk(&mut buffer, &tx, b"[B");
assert!(buffer.is_empty());
let events = collect_events(&mut rx);
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 legacy_reader_extends_incomplete_mouse_timeouts() {
let mut sgr_mouse = RawInputFramer::default();
assert!(sgr_mouse.push(b"\x1b[<3").is_empty());
assert_eq!(
input_flush_timeout_ms(&sgr_mouse),
MOUSE_ACTIVE_ESCAPE_SEQUENCE_FLUSH_TIMEOUT_MS
);
let report = b"\x1b[MCN1";
for split in 3..report.len() {
let mut default_mouse = RawInputFramer::default();
assert!(default_mouse.push(&report[..split]).is_empty());
assert_eq!(
input_flush_timeout_ms(&default_mouse),
MOUSE_ACTIVE_ESCAPE_SEQUENCE_FLUSH_TIMEOUT_MS
);
assert!(matches!(
default_mouse.push(&report[split..]).as_slice(),
[RawInputEvent::Mouse(_)]
));
}
let mut escape = RawInputFramer::default();
assert!(escape.push(b"\x1b").is_empty());
assert_eq!(
input_flush_timeout_ms(&escape),
RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS
);
}
#[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 timed_out_sgr_mouse_discard_state_clears_at_quiescence() {
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 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());
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 (tx, mut rx) = mpsc::channel(8);
let mut buffer = Vec::new();
drain_chunk(&mut buffer, &tx, b"\x1b");
assert_eq!(buffer, b"\x1b");
assert!(collect_events(&mut rx).is_empty());
drain_chunk(&mut buffer, &tx, b"b");
assert!(buffer.is_empty());
let events = collect_events(&mut rx);
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 (tx, mut rx) = mpsc::channel(8);
let mut buffer = Vec::new();
drain_chunk(&mut buffer, &tx, b"\x1b[49:33;2:");
assert_eq!(buffer, b"\x1b[49:33;2:");
assert!(collect_events(&mut rx).is_empty());
drain_chunk(&mut buffer, &tx, b"1u");
assert!(buffer.is_empty());
let events = collect_events(&mut rx);
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 (tx, mut rx) = mpsc::channel(8);
let mut buffer = Vec::new();
drain_chunk(&mut buffer, &tx, b"\x1b[200~hello");
assert_eq!(buffer, b"\x1b[200~hello");
assert!(collect_events(&mut rx).is_empty());
drain_chunk(&mut buffer, &tx, b"\x1b[201~");
assert!(buffer.is_empty());
let events = collect_events(&mut rx);
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 (tx, mut rx) = mpsc::channel(8);
let mut buffer = Vec::new();
drain_chunk(&mut buffer, &tx, b"\x1b[200~hello\nworld");
assert_eq!(buffer, b"\x1b[200~hello\nworld");
assert!(collect_events(&mut rx).is_empty());
flush_incomplete_buffer(&mut buffer, &tx);
assert_eq!(buffer, b"\x1b[200~hello\nworld");
assert!(collect_events(&mut rx).is_empty());
drain_chunk(&mut buffer, &tx, b"\x1b[201~");
assert!(buffer.is_empty());
let events = collect_events(&mut rx);
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 buffer = "你".as_bytes().to_vec();
buffer.push("好".as_bytes()[0]);
let chunks = drain_complete_input_bytes(&mut buffer);
assert_eq!(chunks, vec!["你".as_bytes().to_vec()]);
assert_eq!(buffer, vec!["好".as_bytes()[0]]);
}
#[test]
fn incomplete_utf8_prefix_is_not_flushed_on_timeout() {
let mut buffer = vec!["好".as_bytes()[0]];
assert_eq!(flush_incomplete_input_bytes(&mut buffer), None);
assert_eq!(buffer, vec!["好".as_bytes()[0]]);
}
#[test]
fn invalid_utf8_lead_byte_is_flushed_instead_of_buffered_forever() {
let mut buffer = vec![0xC0];
assert_eq!(flush_incomplete_input_bytes(&mut buffer), None);
assert!(buffer.is_empty());
}
#[test]
fn complete_utf8_char_before_incomplete_char_survives_timeout_and_next_chunk() {
let mut buffer = "你".as_bytes().to_vec();
buffer.push("好".as_bytes()[0]);
let chunks = drain_complete_input_bytes(&mut buffer);
assert_eq!(chunks, vec!["你".as_bytes().to_vec()]);
assert_eq!(flush_incomplete_input_bytes(&mut buffer), None);
assert_eq!(buffer, vec!["好".as_bytes()[0]]);
buffer.extend_from_slice(&"好".as_bytes()[1..]);
let chunks = drain_complete_input_bytes(&mut buffer);
assert_eq!(chunks, vec!["好".as_bytes().to_vec()]);
assert!(buffer.is_empty());
}
#[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 (tx, mut rx) = mpsc::channel(8);
let mut buffer = Vec::new();
let bytes = "\x1bé".as_bytes();
drain_chunk(&mut buffer, &tx, &bytes[..2]);
assert_eq!(buffer, bytes[..2]);
assert!(collect_events(&mut rx).is_empty());
flush_incomplete_buffer(&mut buffer, &tx);
assert_eq!(buffer, bytes[..2]);
assert!(collect_events(&mut rx).is_empty());
drain_chunk(&mut buffer, &tx, &bytes[2..]);
assert!(buffer.is_empty());
let events = collect_events(&mut rx);
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 (tx, mut rx) = mpsc::channel(8);
let mut buffer = Vec::new();
drain_chunk(&mut buffer, &tx, "é".as_bytes().get(..1).unwrap());
assert_eq!(buffer, vec![0xC3]);
assert!(collect_events(&mut rx).is_empty());
drain_chunk(&mut buffer, &tx, "é".as_bytes().get(1..).unwrap());
assert!(buffer.is_empty());
let events = collect_events(&mut rx);
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 (tx, mut rx) = mpsc::channel(8);
let mut buffer = Vec::new();
let bytes = "好".as_bytes();
drain_chunk(&mut buffer, &tx, &bytes[..1]);
assert_eq!(buffer, bytes[..1]);
assert!(collect_events(&mut rx).is_empty());
flush_incomplete_buffer(&mut buffer, &tx);
assert_eq!(buffer, bytes[..1]);
assert!(collect_events(&mut rx).is_empty());
drain_chunk(&mut buffer, &tx, &bytes[1..2]);
assert_eq!(buffer, bytes[..2]);
assert!(collect_events(&mut rx).is_empty());
flush_incomplete_buffer(&mut buffer, &tx);
assert_eq!(buffer, bytes[..2]);
assert!(collect_events(&mut rx).is_empty());
drain_chunk(&mut buffer, &tx, &bytes[2..]);
assert!(buffer.is_empty());
let events = collect_events(&mut rx);
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 (tx, mut rx) = mpsc::channel(8);
let mut buffer = Vec::new();
let bytes = "🙂".as_bytes();
for split in 1..bytes.len() {
drain_chunk(&mut buffer, &tx, &bytes[split - 1..split]);
assert_eq!(buffer, bytes[..split]);
assert!(collect_events(&mut rx).is_empty());
flush_incomplete_buffer(&mut buffer, &tx);
assert_eq!(buffer, bytes[..split]);
assert!(collect_events(&mut rx).is_empty());
}
drain_chunk(&mut buffer, &tx, &bytes[bytes.len() - 1..]);
assert!(buffer.is_empty());
let events = collect_events(&mut rx);
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 buffer = text.as_bytes().to_vec();
let chunks = drain_complete_input_bytes(&mut buffer);
let rebuilt: Vec<u8> = chunks.into_iter().flatten().collect();
assert!(buffer.is_empty());
assert_eq!(rebuilt, text.as_bytes());
}
#[test]
fn long_multilingual_burst_survives_one_byte_chunks_and_timeouts() {
let text = "中文かなカナ한글🙂,。".repeat(64);
let mut buffer = Vec::new();
let mut rebuilt = Vec::new();
for byte in text.as_bytes() {
buffer.push(*byte);
for chunk in drain_complete_input_bytes(&mut buffer) {
rebuilt.extend(chunk);
}
if !buffer.is_empty() {
assert_eq!(flush_incomplete_input_bytes(&mut buffer), None);
}
}
for chunk in drain_complete_input_bytes(&mut buffer) {
rebuilt.extend(chunk);
}
assert!(buffer.is_empty());
assert_eq!(rebuilt, text.as_bytes());
}
#[test]
fn parse_with_ranges_tracks_byte_offsets() {
use super::parse_raw_input_bytes_with_ranges;
// Input: Up arrow (3 bytes) + 'a' (1 byte) + Down arrow (3 bytes)
let input = b"\x1b[Aa\x1b[B".to_vec();
let ranges = parse_raw_input_bytes_with_ranges(&input);
assert_eq!(ranges.len(), 3, "should parse three events");
// Up arrow: \x1b[A at offset 0, length 3
assert_eq!(ranges[0].start, 0);
assert_eq!(ranges[0].len, 3);
assert!(matches!(
&ranges[0].event,
RawInputEvent::Key(k) if k.code == KeyCode::Up
));
// 'a' at offset 3, length 1
assert_eq!(ranges[1].start, 3);
assert_eq!(ranges[1].len, 1);
assert!(matches!(
&ranges[1].event,
RawInputEvent::Key(k) if k.code == KeyCode::Char('a')
));
// Down arrow: \x1b[B at offset 4, length 3
assert_eq!(ranges[2].start, 4);
assert_eq!(ranges[2].len, 3);
assert!(matches!(
&ranges[2].event,
RawInputEvent::Key(k) if k.code == KeyCode::Down
));
// Verify the raw bytes for each event slice correctly.
assert_eq!(
&input[ranges[0].start..ranges[0].start + ranges[0].len],
b"\x1b[A"
);
assert_eq!(
&input[ranges[1].start..ranges[1].start + ranges[1].len],
b"a"
);
assert_eq!(
&input[ranges[2].start..ranges[2].start + ranges[2].len],
b"\x1b[B"
);
}
#[test]
fn parse_with_ranges_handles_single_event() {
use super::parse_raw_input_bytes_with_ranges;
let input = b"a".to_vec();
let ranges = parse_raw_input_bytes_with_ranges(&input);
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0].start, 0);
assert_eq!(ranges[0].len, 1);
}
#[test]
fn parse_with_ranges_handles_mouse_event() {
use super::parse_raw_input_bytes_with_ranges;
let input = b"\x1b[<0;20;10M".to_vec();
let ranges = parse_raw_input_bytes_with_ranges(&input);
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0].start, 0);
assert_eq!(ranges[0].len, input.len());
assert!(matches!(&ranges[0].event, RawInputEvent::Mouse(_)));
}
#[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 drain_complete_input_bytes_keeps_split_default_background_response_buffered() {
let mut buffer = b"\x1b]11;rgb:2828".to_vec();
let chunks = drain_complete_input_bytes(&mut buffer);
assert!(chunks.is_empty());
assert_eq!(buffer, b"\x1b]11;rgb:2828");
}
#[test]
fn flush_incomplete_input_bytes_keeps_split_default_background_response_buffered() {
let mut buffer = b"\x1b]11;rgb:2828".to_vec();
let flushed = flush_incomplete_input_bytes(&mut buffer);
assert!(flushed.is_none());
assert_eq!(buffer, b"\x1b]11;rgb:2828");
}
#[test]
fn flush_incomplete_input_bytes_keeps_default_background_response_split_after_command() {
let mut buffer = b"\x1b]11;".to_vec();
let flushed = flush_incomplete_input_bytes(&mut buffer);
assert!(flushed.is_none());
assert_eq!(buffer, b"\x1b]11;");
}
#[test]
fn flush_incomplete_input_bytes_keeps_default_background_response_split_inside_st() {
let mut buffer = b"\x1b]11;rgb:2828/2a2a/3636\x1b".to_vec();
let flushed = flush_incomplete_input_bytes(&mut buffer);
assert!(flushed.is_none());
assert_eq!(buffer, b"\x1b]11;rgb:2828/2a2a/3636\x1b");
}
#[test]
fn drain_complete_input_bytes_keeps_bare_osc_introducer_buffered() {
let mut buffer = b"\x1b]".to_vec();
let chunks = drain_complete_input_bytes(&mut buffer);
assert!(chunks.is_empty());
assert_eq!(buffer, b"\x1b]");
}
#[test]
fn flush_incomplete_input_bytes_drops_bare_osc_introducer_after_timeout() {
let mut buffer = b"\x1b]".to_vec();
let flushed = flush_incomplete_input_bytes(&mut buffer);
assert!(flushed.is_none());
assert!(buffer.is_empty());
}
#[test]
fn flush_incomplete_input_bytes_drops_string_introducers_after_timeout() {
for bytes in [
b"\x1b]".as_slice(),
b"\x1bP".as_slice(),
b"\x1b_".as_slice(),
b"\x1b^".as_slice(),
b"\x1bX".as_slice(),
] {
let mut buffer = bytes.to_vec();
let flushed = flush_incomplete_input_bytes(&mut buffer);
assert!(flushed.is_none(), "flushed {bytes:?}");
assert!(buffer.is_empty(), "kept {bytes:?}");
}
}
#[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 flush_incomplete_input_bytes_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());
}
#[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()]);
}
}