mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-22 08:01:06 +00:00
* feat: add opt-in client-rendered shell * feat: add client-owned shell mouse controls * feat: complete client-owned shell chrome * feat: add client-owned shell settings and notifications * feat: run client shell custom commands * feat: render popup terminals in client shell * feat: add client-owned scrollback and copy mode * fix: preserve client worktree trust defaults * feat: render startup diagnostics in client shell * feat: render onboarding in client shell * feat: render product announcements in client shell * feat: render release notes in client shell * feat: render mobile switcher in client shell * feat: render kitty graphics in client shell * fix: preserve client shell lifecycle coherence * refactor: route client shell commands through endpoint connections * refactor: make the client-rendered shell the default * refactor: remove monolithic launch mode * refactor: complete client-rendered shell cutover * perf: retain client pane surface updates * perf: scale retained pane surface updates * perf: remove remote pane input latency * test: make client shell checks platform-independent * fix: prevent client timer starvation * test: fix client shell platform gates * test: accept retained surfaces in cross-area checks * test: make client mode assertions platform-neutral * fix: badge only outdated integrations
2575 lines
86 KiB
Rust
2575 lines
86 KiB
Rust
use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
|
|
|
|
/// 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(any(unix, test))]
|
|
pub(crate) const RAW_INPUT_IDLE_FLUSH_TIMEOUT_MS: i32 = 10;
|
|
#[cfg(any(unix, test))]
|
|
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 {
|
|
#[cfg(any(windows, test))]
|
|
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))
|
|
}
|
|
|
|
#[cfg(any(windows, all(test, not(target_os = "macos"))))]
|
|
pub(crate) fn has_pending_input(&self) -> bool {
|
|
self.byte_framer.has_pending_input()
|
|
}
|
|
|
|
#[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
|
|
}
|
|
|
|
#[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(any(not(windows), test))]
|
|
pub(crate) fn has_pending_lone_escape(&self) -> bool {
|
|
self.buffer.as_slice() == [ESC]
|
|
}
|
|
|
|
#[cfg(any(unix, test))]
|
|
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')),
|
|
}
|
|
}
|
|
|
|
#[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(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,
|
|
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';')
|
|
}
|
|
|
|
#[cfg(any(unix, 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) =
|
|
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
|
|
}
|
|
|
|
#[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_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 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 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 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());
|
|
}
|
|
|
|
#[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()]);
|
|
}
|
|
}
|