mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-22 08:01:06 +00:00
+61
-4
@@ -365,7 +365,7 @@ fn windows_crossterm_reader_loop(
|
||||
Err(_) => break,
|
||||
};
|
||||
|
||||
let raw_sequence_pending = framer.has_pending_input();
|
||||
let raw_sequence_pending = framer.requires_raw_continuation();
|
||||
if let Some(bytes) = windows_key_raw_bytes(&event, raw_sequence_pending) {
|
||||
tracing::debug!(
|
||||
bytes = ?bytes,
|
||||
@@ -521,6 +521,8 @@ fn windows_client_input_event_from_raw(
|
||||
let source = if let Some(bytes) = key.vt_bytes() {
|
||||
crate::protocol::ClientKeySource::Vt {
|
||||
bytes: bytes.to_vec(),
|
||||
shifted_codepoint: key.shifted_codepoint,
|
||||
base_layout_codepoint: key.base_layout_codepoint,
|
||||
}
|
||||
} else if let Some(record) = key.windows_record() {
|
||||
crate::protocol::ClientKeySource::WindowsConsole { record }
|
||||
@@ -748,6 +750,25 @@ mod windows_tests {
|
||||
use super::*;
|
||||
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
#[test]
|
||||
fn windows_oversized_csi_final_is_routed_through_discard_state() {
|
||||
let mut framer = crate::raw_input::RawInputFramer::default();
|
||||
let mut oversized = b"\x1b[".to_vec();
|
||||
oversized.extend(std::iter::repeat_n(b'1', 4096));
|
||||
|
||||
assert!(framer.push(&oversized).is_empty());
|
||||
assert!(framer.requires_raw_continuation());
|
||||
|
||||
let final_key = Event::Key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::empty()));
|
||||
let final_bytes = windows_key_raw_bytes(&final_key, framer.requires_raw_continuation())
|
||||
.expect("discard continuation routes through raw framer");
|
||||
assert!(framer.push(&final_bytes).is_empty());
|
||||
assert!(!framer.requires_raw_continuation());
|
||||
|
||||
let following_key = Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::empty()));
|
||||
assert_eq!(windows_key_raw_bytes(&following_key, false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_control_chars_are_reframed_as_raw_bytes() {
|
||||
let escape = Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()));
|
||||
@@ -883,7 +904,11 @@ mod windows_tests {
|
||||
kind: crate::protocol::ClientKeyKind::Press,
|
||||
repeat_count: 1,
|
||||
generated_text: None,
|
||||
source: crate::protocol::ClientKeySource::Vt { bytes: vec![4] },
|
||||
source: crate::protocol::ClientKeySource::Vt {
|
||||
bytes: vec![4],
|
||||
shifted_codepoint: None,
|
||||
base_layout_codepoint: None,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -907,12 +932,40 @@ mod windows_tests {
|
||||
repeat_count: 1,
|
||||
generated_text: None,
|
||||
source: crate::protocol::ClientKeySource::Vt {
|
||||
bytes: b"\x1b[A".to_vec()
|
||||
bytes: b"\x1b[A".to_vec(),
|
||||
shifted_codepoint: None,
|
||||
base_layout_codepoint: None,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_raw_kitty_input_preserves_alternate_codepoints() {
|
||||
let mut framer = crate::raw_input::RawInputFramer::default();
|
||||
let mut events = framer.push(b"\x1b[97:65:113;;65:769u");
|
||||
assert_eq!(events.len(), 1);
|
||||
|
||||
let event =
|
||||
windows_client_input_event_from_raw(events.remove(0)).expect("raw key converts");
|
||||
let crate::protocol::ClientInputEvent::Key {
|
||||
generated_text,
|
||||
source:
|
||||
crate::protocol::ClientKeySource::Vt {
|
||||
shifted_codepoint,
|
||||
base_layout_codepoint,
|
||||
..
|
||||
},
|
||||
..
|
||||
} = event
|
||||
else {
|
||||
panic!("expected VT key event");
|
||||
};
|
||||
assert_eq!(generated_text.as_deref(), Some("A\u{301}"));
|
||||
assert_eq!(shifted_codepoint, Some('A' as u32));
|
||||
assert_eq!(base_layout_codepoint, Some('q' as u32));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_bare_escape_flushes_to_semantic_escape() {
|
||||
let mut framer = crate::raw_input::RawInputFramer::default();
|
||||
@@ -930,7 +983,11 @@ mod windows_tests {
|
||||
kind: crate::protocol::ClientKeyKind::Press,
|
||||
repeat_count: 1,
|
||||
generated_text: None,
|
||||
source: crate::protocol::ClientKeySource::Vt { bytes: vec![0x1b] },
|
||||
source: crate::protocol::ClientKeySource::Vt {
|
||||
bytes: vec![0x1b],
|
||||
shifted_codepoint: None,
|
||||
base_layout_codepoint: None,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1467,7 +1467,11 @@ mod tests {
|
||||
|
||||
repeat_count: 1,
|
||||
generated_text: None,
|
||||
source: crate::protocol::ClientKeySource::Vt { bytes: vec![0x1b] },
|
||||
source: crate::protocol::ClientKeySource::Vt {
|
||||
bytes: vec![0x1b],
|
||||
shifted_codepoint: None,
|
||||
base_layout_codepoint: None,
|
||||
},
|
||||
}]
|
||||
);
|
||||
}
|
||||
@@ -1487,7 +1491,11 @@ mod tests {
|
||||
|
||||
repeat_count: 1,
|
||||
generated_text: None,
|
||||
source: crate::protocol::ClientKeySource::Vt { bytes: vec![0x1b] },
|
||||
source: crate::protocol::ClientKeySource::Vt {
|
||||
bytes: vec![0x1b],
|
||||
shifted_codepoint: None,
|
||||
base_layout_codepoint: None,
|
||||
},
|
||||
}]
|
||||
);
|
||||
}
|
||||
@@ -2201,7 +2209,11 @@ mod tests {
|
||||
|
||||
repeat_count: 1,
|
||||
generated_text: None,
|
||||
source: crate::protocol::ClientKeySource::Vt { bytes: vec![0x1b] },
|
||||
source: crate::protocol::ClientKeySource::Vt {
|
||||
bytes: vec![0x1b],
|
||||
shifted_codepoint: None,
|
||||
base_layout_codepoint: None,
|
||||
},
|
||||
}]
|
||||
);
|
||||
}
|
||||
@@ -2220,7 +2232,11 @@ mod tests {
|
||||
|
||||
repeat_count: 1,
|
||||
generated_text: None,
|
||||
source: crate::protocol::ClientKeySource::Vt { bytes: vec![0x1b] },
|
||||
source: crate::protocol::ClientKeySource::Vt {
|
||||
bytes: vec![0x1b],
|
||||
shifted_codepoint: None,
|
||||
base_layout_codepoint: None,
|
||||
},
|
||||
},
|
||||
crate::protocol::ClientInputEvent::Key {
|
||||
code: crate::protocol::ClientKeyCode::Enter,
|
||||
|
||||
+31
-18
@@ -218,26 +218,27 @@ fn try_encode_csi_u(key: &TerminalKey, flags: u16) -> Option<Vec<u8>> {
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let (codepoint, alternate_shifted) = match key.code {
|
||||
let (codepoint, alternate_shifted, alternate_base_layout) = match key.code {
|
||||
KeyCode::Char(c) => {
|
||||
let base = canonical_kitty_char(c, mods);
|
||||
let shifted = alternate_shifted_codepoint(key, flags);
|
||||
(base as u32, shifted)
|
||||
let base_layout = alternate_base_layout_codepoint(key, flags);
|
||||
(base as u32, shifted, base_layout)
|
||||
}
|
||||
KeyCode::Enter => (13, None),
|
||||
KeyCode::Tab => (9, None),
|
||||
KeyCode::Backspace => (127, None),
|
||||
KeyCode::Esc => (27, None),
|
||||
KeyCode::Left => (57417, None),
|
||||
KeyCode::Right => (57418, None),
|
||||
KeyCode::Up => (57419, None),
|
||||
KeyCode::Down => (57420, None),
|
||||
KeyCode::PageUp => (57421, None),
|
||||
KeyCode::PageDown => (57422, None),
|
||||
KeyCode::Home => (57423, None),
|
||||
KeyCode::End => (57424, None),
|
||||
KeyCode::Insert => (57425, None),
|
||||
KeyCode::Delete => (57426, None),
|
||||
KeyCode::Enter => (13, None, None),
|
||||
KeyCode::Tab => (9, None, None),
|
||||
KeyCode::Backspace => (127, None, None),
|
||||
KeyCode::Esc => (27, None, None),
|
||||
KeyCode::Left => (57417, None, None),
|
||||
KeyCode::Right => (57418, None, None),
|
||||
KeyCode::Up => (57419, None, None),
|
||||
KeyCode::Down => (57420, None, None),
|
||||
KeyCode::PageUp => (57421, None, None),
|
||||
KeyCode::PageDown => (57422, None, None),
|
||||
KeyCode::Home => (57423, None, None),
|
||||
KeyCode::End => (57424, None, None),
|
||||
KeyCode::Insert => (57425, None, None),
|
||||
KeyCode::Delete => (57426, None, None),
|
||||
_ => return None, // fall back to legacy for unhandled keys
|
||||
};
|
||||
|
||||
@@ -246,8 +247,14 @@ fn try_encode_csi_u(key: &TerminalKey, flags: u16) -> Option<Vec<u8>> {
|
||||
let mut sequence = String::with_capacity(32);
|
||||
sequence.push_str("\x1b[");
|
||||
write!(&mut sequence, "{codepoint}").ok()?;
|
||||
if let Some(shifted) = alternate_shifted {
|
||||
write!(&mut sequence, ":{shifted}").ok()?;
|
||||
if alternate_shifted.is_some() || alternate_base_layout.is_some() {
|
||||
sequence.push(':');
|
||||
if let Some(shifted) = alternate_shifted {
|
||||
write!(&mut sequence, "{shifted}").ok()?;
|
||||
}
|
||||
if let Some(base_layout) = alternate_base_layout {
|
||||
write!(&mut sequence, ":{base_layout}").ok()?;
|
||||
}
|
||||
}
|
||||
write!(&mut sequence, ";{modifier}").ok()?;
|
||||
if let Some(event) = event_suffix {
|
||||
@@ -466,6 +473,12 @@ fn alternate_shifted_codepoint(key: &TerminalKey, flags: u16) -> Option<u32> {
|
||||
}
|
||||
}
|
||||
|
||||
fn alternate_base_layout_codepoint(key: &TerminalKey, flags: u16) -> Option<u32> {
|
||||
(flags & KITTY_FLAG_REPORT_ALTERNATE_KEYS != 0)
|
||||
.then_some(key.base_layout_codepoint)
|
||||
.flatten()
|
||||
}
|
||||
|
||||
fn kitty_event_suffix(key: &TerminalKey, flags: u16) -> Option<u8> {
|
||||
if flags & KITTY_FLAG_REPORT_EVENT_TYPES == 0 {
|
||||
return None;
|
||||
|
||||
+17
-1
@@ -72,6 +72,7 @@ pub struct TerminalKey {
|
||||
pub kind: crossterm::event::KeyEventKind,
|
||||
pub repeat_count: u16,
|
||||
pub shifted_codepoint: Option<u32>,
|
||||
pub base_layout_codepoint: Option<u32>,
|
||||
pub generated_text: Option<String>,
|
||||
source: KeySource,
|
||||
}
|
||||
@@ -84,6 +85,7 @@ impl TerminalKey {
|
||||
kind: crossterm::event::KeyEventKind::Press,
|
||||
repeat_count: 1,
|
||||
shifted_codepoint: None,
|
||||
base_layout_codepoint: None,
|
||||
generated_text: None,
|
||||
source: KeySource::Synthesized,
|
||||
}
|
||||
@@ -112,12 +114,26 @@ impl TerminalKey {
|
||||
self
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // Reserved for the upcoming raw input parser to preserve shifted/base key pairs.
|
||||
pub fn with_shifted_codepoint(mut self, shifted_codepoint: u32) -> Self {
|
||||
self.shifted_codepoint = Some(shifted_codepoint);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_base_layout_codepoint(mut self, base_layout_codepoint: u32) -> Self {
|
||||
self.base_layout_codepoint = Some(base_layout_codepoint);
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_alternate_codepoints(
|
||||
mut self,
|
||||
shifted_codepoint: Option<u32>,
|
||||
base_layout_codepoint: Option<u32>,
|
||||
) -> Self {
|
||||
self.shifted_codepoint = shifted_codepoint;
|
||||
self.base_layout_codepoint = base_layout_codepoint;
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_generated_text(mut self, text: Option<String>) -> Self {
|
||||
self.generated_text = if self.kind == crossterm::event::KeyEventKind::Release {
|
||||
None
|
||||
|
||||
+86
-21
@@ -2,6 +2,8 @@ use crossterm::event::{KeyCode, KeyModifiers, MediaKeyCode, ModifierKeyCode};
|
||||
|
||||
use super::TerminalKey;
|
||||
|
||||
const MAX_KITTY_ASSOCIATED_TEXT_CODEPOINTS: usize = 64;
|
||||
|
||||
#[allow(dead_code)] // Next step: raw stdin parser will feed TerminalKey directly through this path.
|
||||
pub fn parse_terminal_key_sequence(data: &str) -> Option<TerminalKey> {
|
||||
parse_kitty_key_sequence(data)
|
||||
@@ -16,35 +18,38 @@ fn parse_kitty_key_sequence(data: &str) -> Option<TerminalKey> {
|
||||
let mut fields = body.split(';');
|
||||
let key_part = fields.next()?;
|
||||
let modifier_part = fields.next().unwrap_or("1");
|
||||
let associated_text = fields.next();
|
||||
let associated_text = fields.next().filter(|field| !field.is_empty());
|
||||
if fields.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let modifier_part = if modifier_part.is_empty() {
|
||||
"1"
|
||||
} else {
|
||||
modifier_part
|
||||
};
|
||||
let (modifier_text, event_type) = split_modifier_and_event(modifier_part);
|
||||
let modifier = modifier_text.parse::<u8>().ok()?.checked_sub(1)?;
|
||||
|
||||
let mut key_fields = key_part.split(':');
|
||||
let codepoint = key_fields.next()?.parse::<u32>().ok()?;
|
||||
let shifted_codepoint = key_fields
|
||||
.next()
|
||||
.filter(|field| !field.is_empty())
|
||||
.and_then(|field| field.parse::<u32>().ok());
|
||||
|
||||
if let Some(text) = associated_text {
|
||||
if text.parse::<u32>().ok()? != codepoint {
|
||||
return None;
|
||||
}
|
||||
let shifted_codepoint = parse_optional_kitty_codepoint(key_fields.next())?;
|
||||
let base_layout_codepoint = parse_optional_kitty_codepoint(key_fields.next())?;
|
||||
if key_fields.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let generated_text = match associated_text {
|
||||
Some(text) => Some(parse_kitty_associated_text(text)?),
|
||||
None => None,
|
||||
};
|
||||
let code = kitty_codepoint_to_keycode(codepoint)?;
|
||||
let kind = parse_kitty_event_type(event_type)?;
|
||||
let mut modifiers = key_modifiers_from_u8(modifier);
|
||||
// Kitty permits the shifted alternate only while Shift is active. Normalize
|
||||
// contradictory reports here so they cannot dispatch an unshifted command.
|
||||
if matches!(code, KeyCode::Char(_))
|
||||
&& shifted_codepoint
|
||||
.is_some_and(|shifted| shifted != codepoint && char::from_u32(shifted).is_some())
|
||||
&& shifted_codepoint.is_some_and(|shifted| shifted != codepoint)
|
||||
{
|
||||
modifiers |= KeyModifiers::SHIFT;
|
||||
}
|
||||
@@ -53,7 +58,33 @@ fn parse_kitty_key_sequence(data: &str) -> Option<TerminalKey> {
|
||||
if let Some(shifted_codepoint) = shifted_codepoint {
|
||||
key = key.with_shifted_codepoint(shifted_codepoint);
|
||||
}
|
||||
Some(key)
|
||||
if let Some(base_layout_codepoint) = base_layout_codepoint {
|
||||
key = key.with_base_layout_codepoint(base_layout_codepoint);
|
||||
}
|
||||
Some(key.with_generated_text(generated_text))
|
||||
}
|
||||
|
||||
fn parse_optional_kitty_codepoint(field: Option<&str>) -> Option<Option<u32>> {
|
||||
let Some(field) = field.filter(|field| !field.is_empty()) else {
|
||||
return Some(None);
|
||||
};
|
||||
let codepoint = field.parse::<u32>().ok()?;
|
||||
char::from_u32(codepoint).map(|_| Some(codepoint))
|
||||
}
|
||||
|
||||
fn parse_kitty_associated_text(text: &str) -> Option<String> {
|
||||
let mut generated = String::new();
|
||||
for (index, field) in text.split(':').enumerate() {
|
||||
if index >= MAX_KITTY_ASSOCIATED_TEXT_CODEPOINTS {
|
||||
return None;
|
||||
}
|
||||
let ch = field.parse::<u32>().ok().and_then(char::from_u32)?;
|
||||
if ch.is_control() {
|
||||
return None;
|
||||
}
|
||||
generated.push(ch);
|
||||
}
|
||||
Some(generated)
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // Reserved for the upcoming raw stdin parser.
|
||||
@@ -662,8 +693,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_kitty_sequence_with_associated_emoji_text() {
|
||||
let key = parse_terminal_key_sequence("\x1b[128512;1;128512u").unwrap();
|
||||
fn parse_kitty_sequence_preserves_associated_text() {
|
||||
let key = parse_terminal_key_sequence("\x1b[128512;1;128512:65039u").unwrap();
|
||||
assert_terminal_key_eq(
|
||||
key.clone(),
|
||||
KeyCode::Char('😀'),
|
||||
@@ -671,15 +702,44 @@ mod tests {
|
||||
crossterm::event::KeyEventKind::Press,
|
||||
None,
|
||||
);
|
||||
assert_eq!(key.generated_text.as_deref(), Some("😀\u{fe0f}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_unmodeled_kitty_associated_text() {
|
||||
assert_eq!(parse_terminal_key_sequence("\x1b[128512;1;128513u"), None);
|
||||
assert_eq!(
|
||||
parse_terminal_key_sequence("\x1b[128512;1;128512:65039u"),
|
||||
None
|
||||
);
|
||||
fn parse_kitty_sequence_preserves_full_alternates_and_omitted_modifiers() {
|
||||
let key = parse_terminal_key_sequence("\x1b[97:65:113;;65:769u").unwrap();
|
||||
|
||||
assert_eq!(key.code, KeyCode::Char('a'));
|
||||
assert_eq!(key.modifiers, KeyModifiers::SHIFT);
|
||||
assert_eq!(key.shifted_codepoint, Some('A' as u32));
|
||||
assert_eq!(key.base_layout_codepoint, Some('q' as u32));
|
||||
assert_eq!(key.generated_text.as_deref(), Some("A\u{301}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_kitty_sequence_discards_associated_text_on_release() {
|
||||
let key = parse_terminal_key_sequence("\x1b[97:65;2:3;65u").unwrap();
|
||||
|
||||
assert_eq!(key.kind, crossterm::event::KeyEventKind::Release);
|
||||
assert_eq!(key.generated_text, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_malformed_kitty_alternates_and_associated_text() {
|
||||
for sequence in [
|
||||
"\x1b[97:65:113:120;1u",
|
||||
"\x1b[97;1;1114112u",
|
||||
"\x1b[97;1;65::66u",
|
||||
"\x1b[97;1;3u",
|
||||
"\x1b[97;1;27u",
|
||||
"\x1b[97;1;133u",
|
||||
] {
|
||||
assert_eq!(parse_terminal_key_sequence(sequence), None, "{sequence:?}");
|
||||
}
|
||||
|
||||
let associated = vec!["97"; MAX_KITTY_ASSOCIATED_TEXT_CODEPOINTS + 1].join(":");
|
||||
let oversized = format!("\x1b[97;1;{associated}u");
|
||||
assert_eq!(parse_terminal_key_sequence(&oversized), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -976,6 +1036,11 @@ mod tests {
|
||||
"{} shifted codepoint",
|
||||
case.family
|
||||
);
|
||||
assert_eq!(
|
||||
parsed.base_layout_codepoint, case.base_layout_codepoint,
|
||||
"{} base layout codepoint",
|
||||
case.family
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ pub(crate) struct KeyboardCorpusCase<'a> {
|
||||
pub modifiers: KeyModifiers,
|
||||
pub kind: KeyEventKind,
|
||||
pub shifted_codepoint: Option<u32>,
|
||||
pub base_layout_codepoint: Option<u32>,
|
||||
pub generated_text: Option<String>,
|
||||
pub pane_profile: &'a str,
|
||||
pub expected_pane_hex: &'a str,
|
||||
@@ -23,10 +24,9 @@ pub(crate) fn keyboard_corpus_cases(corpus: &str) -> Vec<KeyboardCorpusCase<'_>>
|
||||
}
|
||||
|
||||
let columns: Vec<_> = line.split('\t').collect();
|
||||
assert_eq!(
|
||||
columns.len(),
|
||||
9,
|
||||
"keyboard corpus row must have 9 columns: {line}"
|
||||
assert!(
|
||||
matches!(columns.len(), 9 | 10),
|
||||
"keyboard corpus row must have 9 or 10 columns: {line}"
|
||||
);
|
||||
|
||||
Some(KeyboardCorpusCase {
|
||||
@@ -37,6 +37,10 @@ pub(crate) fn keyboard_corpus_cases(corpus: &str) -> Vec<KeyboardCorpusCase<'_>>
|
||||
kind: parse_kind(columns[4]),
|
||||
shifted_codepoint: (!columns[5].is_empty())
|
||||
.then(|| columns[5].parse::<u32>().expect("shifted codepoint")),
|
||||
base_layout_codepoint: columns
|
||||
.get(9)
|
||||
.filter(|field| !field.is_empty())
|
||||
.map(|field| field.parse::<u32>().expect("base layout codepoint")),
|
||||
generated_text: (columns[6] != "-").then(|| {
|
||||
String::from_utf8(decode_hex(columns[6])).expect("generated text must be UTF-8")
|
||||
}),
|
||||
|
||||
@@ -3323,6 +3323,11 @@ mod tests {
|
||||
"{} shifted codepoint",
|
||||
case.family
|
||||
);
|
||||
assert_eq!(
|
||||
key.base_layout_codepoint, case.base_layout_codepoint,
|
||||
"{} base layout codepoint",
|
||||
case.family
|
||||
);
|
||||
assert_eq!(
|
||||
key.generated_text, case.generated_text,
|
||||
"{} generated text",
|
||||
@@ -3350,6 +3355,7 @@ mod tests {
|
||||
"kitty_5" => b"\x1b[>5u".as_slice(),
|
||||
"kitty_7" => b"\x1b[>7u".as_slice(),
|
||||
"kitty_11" => b"\x1b[>11u".as_slice(),
|
||||
"kitty_13" => b"\x1b[>13u".as_slice(),
|
||||
"kitty_15" => b"\x1b[>15u".as_slice(),
|
||||
"kitty_25" => b"\x1b[>25u".as_slice(),
|
||||
"kitty_31" => b"\x1b[>31u".as_slice(),
|
||||
@@ -3491,6 +3497,8 @@ mod tests {
|
||||
| "kitty_text_policy"
|
||||
| "equivalent_press_suffix"
|
||||
| "missing_modifier"
|
||||
| "adapter_generated_text_loss"
|
||||
| "adapter_base_layout_loss"
|
||||
),
|
||||
"unknown difference category: {}",
|
||||
columns[2]
|
||||
|
||||
+42
-8
@@ -140,6 +140,8 @@ pub enum ClientKeySource {
|
||||
Synthesized,
|
||||
Vt {
|
||||
bytes: Vec<u8>,
|
||||
shifted_codepoint: Option<u32>,
|
||||
base_layout_codepoint: Option<u32>,
|
||||
},
|
||||
WindowsConsole {
|
||||
record: crate::input::WindowsKeyRecord,
|
||||
@@ -309,7 +311,13 @@ impl ClientInputEvent {
|
||||
.with_generated_text(generated_text.clone());
|
||||
key = match source {
|
||||
ClientKeySource::Synthesized => key,
|
||||
ClientKeySource::Vt { bytes } => key.with_vt_bytes(bytes.clone()),
|
||||
ClientKeySource::Vt {
|
||||
bytes,
|
||||
shifted_codepoint,
|
||||
base_layout_codepoint,
|
||||
} => key
|
||||
.with_vt_bytes(bytes.clone())
|
||||
.with_alternate_codepoints(*shifted_codepoint, *base_layout_codepoint),
|
||||
ClientKeySource::WindowsConsole { record } => key.with_windows_record(*record),
|
||||
};
|
||||
key = key
|
||||
@@ -1148,13 +1156,15 @@ mod tests {
|
||||
source: crate::protocol::ClientKeySource::Synthesized,
|
||||
},
|
||||
ClientInputEvent::Key {
|
||||
code: ClientKeyCode::Backspace,
|
||||
modifiers: 0,
|
||||
code: ClientKeyCode::Char('l'),
|
||||
modifiers: crossterm::event::KeyModifiers::SHIFT.bits(),
|
||||
kind: ClientKeyKind::Press,
|
||||
repeat_count: 3,
|
||||
generated_text: None,
|
||||
generated_text: Some("L".to_owned()),
|
||||
source: crate::protocol::ClientKeySource::Vt {
|
||||
bytes: b"\x1b[127;1u".to_vec(),
|
||||
bytes: b"\x1b[108:76:113;2;76u".to_vec(),
|
||||
shifted_codepoint: Some('L' as u32),
|
||||
base_layout_codepoint: Some('q' as u32),
|
||||
},
|
||||
},
|
||||
ClientInputEvent::Key {
|
||||
@@ -1188,9 +1198,10 @@ mod tests {
|
||||
assert_eq!(
|
||||
encoded,
|
||||
vec![
|
||||
7, 5, 0, 15, 78, 1, 0, 1, 0, 0, 0, 0, 0, 0, 3, 0, 1, 8, 27, 91, 49, 50, 55, 59, 49,
|
||||
117, 0, 14, 0, 2, 1, 0, 2, 0, 1, 27, 1, 27, 0, 1, 7, 228, 189, 160, 240, 159, 153,
|
||||
130, 2, 0, 0, 3, 4, 0,
|
||||
7, 5, 0, 15, 78, 1, 0, 1, 0, 0, 0, 15, 108, 1, 0, 3, 1, 1, 76, 1, 18, 27, 91, 49,
|
||||
48, 56, 58, 55, 54, 58, 49, 49, 51, 59, 50, 59, 55, 54, 117, 1, 76, 1, 113, 0, 14,
|
||||
0, 2, 1, 0, 2, 0, 1, 27, 1, 27, 0, 1, 7, 228, 189, 160, 240, 159, 153, 130, 2, 0,
|
||||
0, 3, 4, 0,
|
||||
]
|
||||
);
|
||||
let (decoded, _): (ClientMessage, _) =
|
||||
@@ -1219,6 +1230,29 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vt_client_input_preserves_alternate_codepoints() {
|
||||
let event = ClientInputEvent::Key {
|
||||
code: ClientKeyCode::Char('a'),
|
||||
modifiers: crossterm::event::KeyModifiers::SHIFT.bits(),
|
||||
kind: ClientKeyKind::Press,
|
||||
repeat_count: 1,
|
||||
generated_text: Some("A\u{301}".to_owned()),
|
||||
source: ClientKeySource::Vt {
|
||||
bytes: b"\x1b[97:65:113;;65:769u".to_vec(),
|
||||
shifted_codepoint: Some('A' as u32),
|
||||
base_layout_codepoint: Some('q' as u32),
|
||||
},
|
||||
};
|
||||
|
||||
let crate::raw_input::RawInputEvent::Key(key) = event.to_raw_input_event() else {
|
||||
panic!("expected key event");
|
||||
};
|
||||
assert_eq!(key.shifted_codepoint, Some('A' as u32));
|
||||
assert_eq!(key.base_layout_codepoint, Some('q' as u32));
|
||||
assert_eq!(key.generated_text.as_deref(), Some("A\u{301}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_input_events_convert_to_raw_keys() {
|
||||
let record = crate::input::WindowsKeyRecord {
|
||||
|
||||
+174
-16
@@ -181,6 +181,11 @@ impl RawInputFramer {
|
||||
self.byte_framer.has_pending_input()
|
||||
}
|
||||
|
||||
#[cfg(any(windows, test))]
|
||||
pub(crate) fn requires_raw_continuation(&self) -> bool {
|
||||
self.byte_framer.has_pending_input() || self.byte_framer.discard_until.is_some()
|
||||
}
|
||||
|
||||
pub(crate) fn has_pending_incomplete_mouse_sequence(&self) -> bool {
|
||||
self.byte_framer.has_pending_incomplete_mouse_sequence()
|
||||
}
|
||||
@@ -232,6 +237,9 @@ 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;
|
||||
const MAX_CSI_SEQUENCE_BYTES: usize = 4096;
|
||||
const MAX_LEGACY_ESCAPE_PREFIXES: usize = 64;
|
||||
const CSI_FINAL_BYTES: &[u8] = b"@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~";
|
||||
|
||||
impl RawInputByteFramer {
|
||||
pub(crate) fn for_host_input() -> Self {
|
||||
@@ -313,7 +321,10 @@ impl RawInputByteFramer {
|
||||
let mut chunks = self.drain_available_chunks();
|
||||
|
||||
if let Some(family) = self.discard_until {
|
||||
if family == ControlStringFamily::HostReplyCsi {
|
||||
if matches!(
|
||||
family,
|
||||
ControlStringFamily::CsiTail | ControlStringFamily::OversizedCsi
|
||||
) {
|
||||
return chunks;
|
||||
}
|
||||
if family == ControlStringFamily::OrphanedSgrMouseTail {
|
||||
@@ -408,7 +419,7 @@ impl RawInputByteFramer {
|
||||
);
|
||||
self.host_cell_size_replies_awaited = 0;
|
||||
self.held_pending_host_reply_esc = false;
|
||||
self.discard_until = Some(ControlStringFamily::HostReplyCsi);
|
||||
self.discard_until = Some(ControlStringFamily::CsiTail);
|
||||
self.discarded_tail_bytes = 0;
|
||||
self.buffer.clear();
|
||||
return chunks;
|
||||
@@ -429,7 +440,7 @@ impl RawInputByteFramer {
|
||||
);
|
||||
self.host_appearance_reply_awaited = false;
|
||||
self.held_pending_host_reply_esc = false;
|
||||
self.discard_until = Some(ControlStringFamily::HostReplyCsi);
|
||||
self.discard_until = Some(ControlStringFamily::CsiTail);
|
||||
self.discarded_tail_bytes = 0;
|
||||
self.buffer.clear();
|
||||
return chunks;
|
||||
@@ -496,6 +507,19 @@ impl RawInputByteFramer {
|
||||
let mut chunks = Vec::new();
|
||||
|
||||
loop {
|
||||
let escape_count = self.buffer.iter().take_while(|byte| **byte == ESC).count();
|
||||
if self.discard_until.is_none() && escape_count > MAX_LEGACY_ESCAPE_PREFIXES {
|
||||
let excess = escape_count - MAX_LEGACY_ESCAPE_PREFIXES;
|
||||
tracing::warn!(excess, "splitting excessive legacy escape prefixes");
|
||||
chunks.extend((0..excess).map(|_| vec![ESC]));
|
||||
self.buffer.drain(..excess);
|
||||
continue;
|
||||
}
|
||||
|
||||
if self.discard_oversized_csi_prefix() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if self.lone_escape_recently_flushed {
|
||||
if starts_with_incomplete_orphaned_sgr_mouse_tail(&self.buffer) {
|
||||
break;
|
||||
@@ -508,15 +532,21 @@ impl RawInputByteFramer {
|
||||
}
|
||||
|
||||
if let Some(family) = self.discard_until {
|
||||
if family == ControlStringFamily::HostReplyCsi {
|
||||
if discard_host_reply_csi_tail(&mut self.buffer, &mut self.discarded_tail_bytes)
|
||||
{
|
||||
if family == ControlStringFamily::CsiTail {
|
||||
if discard_csi_tail(&mut self.buffer, &mut self.discarded_tail_bytes) {
|
||||
self.discard_until = None;
|
||||
self.discarded_tail_bytes = 0;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if family == ControlStringFamily::OversizedCsi {
|
||||
if discard_oversized_csi_tail(&mut self.buffer) {
|
||||
self.discard_until = None;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if family == ControlStringFamily::OrphanedSgrMouseTail {
|
||||
if discard_orphaned_sgr_mouse_tail(
|
||||
&mut self.buffer,
|
||||
@@ -574,6 +604,36 @@ impl RawInputByteFramer {
|
||||
|
||||
chunks
|
||||
}
|
||||
|
||||
fn discard_oversized_csi_prefix(&mut self) -> bool {
|
||||
if self.discard_until.is_some() {
|
||||
return false;
|
||||
}
|
||||
let Some(offset) = csi_sequence_offset(&self.buffer) else {
|
||||
return false;
|
||||
};
|
||||
let csi = &self.buffer[offset..];
|
||||
|
||||
match find_csi_final(csi, CSI_FINAL_BYTES) {
|
||||
Some(len) if len > MAX_CSI_SEQUENCE_BYTES => {
|
||||
let consumed = offset + len;
|
||||
tracing::warn!(len = consumed, "discarding oversized CSI input sequence");
|
||||
self.buffer.drain(..consumed);
|
||||
true
|
||||
}
|
||||
None if csi.len() > MAX_CSI_SEQUENCE_BYTES => {
|
||||
tracing::warn!(
|
||||
len = self.buffer.len(),
|
||||
"discarding oversized incomplete CSI input sequence"
|
||||
);
|
||||
self.buffer.clear();
|
||||
self.discard_until = Some(ControlStringFamily::OversizedCsi);
|
||||
self.discarded_tail_bytes = 0;
|
||||
true
|
||||
}
|
||||
Some(_) | None => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_DISCARDED_CONTROL_TAIL_BYTES: usize = 128;
|
||||
@@ -602,7 +662,7 @@ fn plausible_control_string_tail(family: ControlStringFamily, buffer: &[u8]) ->
|
||||
)
|
||||
}),
|
||||
ControlStringFamily::StTerminated => buffer.last() == Some(&ESC),
|
||||
ControlStringFamily::HostReplyCsi => false,
|
||||
ControlStringFamily::CsiTail | ControlStringFamily::OversizedCsi => false,
|
||||
ControlStringFamily::OrphanedSgrMouseTail => buffer
|
||||
.iter()
|
||||
.all(|byte| byte.is_ascii_digit() || matches!(*byte, b';' | b'M' | b'm')),
|
||||
@@ -901,7 +961,8 @@ fn extract_one_event(buffer: &[u8]) -> Option<(RawInputEvent, usize)> {
|
||||
enum ControlStringFamily {
|
||||
Osc,
|
||||
StTerminated,
|
||||
HostReplyCsi,
|
||||
CsiTail,
|
||||
OversizedCsi,
|
||||
OrphanedSgrMouseTail,
|
||||
}
|
||||
|
||||
@@ -1022,6 +1083,11 @@ fn utf8_char_width(first: u8) -> Option<usize> {
|
||||
}
|
||||
}
|
||||
|
||||
fn csi_sequence_offset(buffer: &[u8]) -> Option<usize> {
|
||||
let escape_count = buffer.iter().take_while(|byte| **byte == ESC).count();
|
||||
(escape_count > 0 && buffer.get(escape_count) == Some(&b'[')).then(|| escape_count - 1)
|
||||
}
|
||||
|
||||
fn complete_escape_sequence_len(buffer: &[u8]) -> Option<usize> {
|
||||
if buffer.len() == 1 {
|
||||
return None;
|
||||
@@ -1043,8 +1109,18 @@ fn complete_escape_sequence_len(buffer: &[u8]) -> Option<usize> {
|
||||
return Some(1);
|
||||
}
|
||||
|
||||
if buffer.starts_with(b"\x1b\x1b") {
|
||||
return complete_escape_sequence_len(&buffer[1..]).map(|len| len + 1);
|
||||
let escape_count = buffer.iter().take_while(|byte| **byte == ESC).count();
|
||||
if escape_count > MAX_LEGACY_ESCAPE_PREFIXES {
|
||||
return Some(1);
|
||||
}
|
||||
let escape_offset = escape_count.saturating_sub(1);
|
||||
let sequence = &buffer[escape_offset..];
|
||||
complete_single_escape_sequence_len(sequence).map(|len| escape_offset + len)
|
||||
}
|
||||
|
||||
fn complete_single_escape_sequence_len(buffer: &[u8]) -> Option<usize> {
|
||||
if buffer.len() == 1 {
|
||||
return None;
|
||||
}
|
||||
|
||||
if buffer.starts_with(b"\x1b[") {
|
||||
@@ -1054,10 +1130,7 @@ fn complete_escape_sequence_len(buffer: &[u8]) -> Option<usize> {
|
||||
if buffer.starts_with(b"\x1b[M") {
|
||||
return (buffer.len() >= 6).then_some(6);
|
||||
}
|
||||
return find_csi_final(
|
||||
buffer,
|
||||
b"@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~",
|
||||
);
|
||||
return find_csi_final(buffer, CSI_FINAL_BYTES);
|
||||
}
|
||||
|
||||
if let Some(control) = control_string(buffer) {
|
||||
@@ -1136,7 +1209,22 @@ fn discard_or_buffer_orphaned_sgr_mouse_tail(
|
||||
}
|
||||
}
|
||||
|
||||
fn discard_host_reply_csi_tail(buffer: &mut Vec<u8>, discarded_tail_bytes: &mut usize) -> bool {
|
||||
fn discard_oversized_csi_tail(buffer: &mut Vec<u8>) -> bool {
|
||||
for (index, byte) in buffer.iter().copied().enumerate() {
|
||||
match byte {
|
||||
0x20..=0x3f => {}
|
||||
_ => {
|
||||
buffer.drain(..=index);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buffer.clear();
|
||||
false
|
||||
}
|
||||
|
||||
fn discard_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);
|
||||
|
||||
@@ -1213,7 +1301,7 @@ fn control_string_terminator_for_family(
|
||||
match family {
|
||||
ControlStringFamily::Osc => osc_string_terminator(buffer),
|
||||
ControlStringFamily::StTerminated => st_string_terminator(buffer),
|
||||
ControlStringFamily::HostReplyCsi => None,
|
||||
ControlStringFamily::CsiTail | ControlStringFamily::OversizedCsi => None,
|
||||
ControlStringFamily::OrphanedSgrMouseTail => buffer
|
||||
.iter()
|
||||
.position(|byte| matches!(*byte, b'M' | b'm'))
|
||||
@@ -1945,6 +2033,11 @@ mod tests {
|
||||
"{} shifted codepoint",
|
||||
case.family
|
||||
);
|
||||
assert_eq!(
|
||||
key.base_layout_codepoint, case.base_layout_codepoint,
|
||||
"{} base layout codepoint",
|
||||
case.family
|
||||
);
|
||||
assert_eq!(
|
||||
key.generated_text, case.generated_text,
|
||||
"{} generated text",
|
||||
@@ -2311,6 +2404,71 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_csi_input_is_discarded_without_losing_following_keys() {
|
||||
let mut incomplete = RawInputByteFramer::default();
|
||||
let mut oversized = b"\x1b[".to_vec();
|
||||
oversized.extend(std::iter::repeat_n(b'1', MAX_CSI_SEQUENCE_BYTES));
|
||||
assert!(incomplete.push(&oversized).is_empty());
|
||||
assert!(!incomplete.has_pending_input());
|
||||
assert!(incomplete
|
||||
.push(&[b'2'; MAX_DISCARDED_CONTROL_TAIL_BYTES + 1])
|
||||
.is_empty());
|
||||
assert!(!incomplete.has_pending_input());
|
||||
assert!(incomplete.push(b"u").is_empty());
|
||||
assert_eq!(incomplete.push(b"x"), vec![b"x".to_vec()]);
|
||||
|
||||
let mut complete = RawInputByteFramer::default();
|
||||
oversized.extend_from_slice(b"uy");
|
||||
assert_eq!(complete.push(&oversized), vec![b"y".to_vec()]);
|
||||
assert!(!complete.has_pending_input());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_csi_discard_precedes_excess_escape_batching() {
|
||||
let mut framer = RawInputByteFramer::default();
|
||||
let mut oversized = b"\x1b[".to_vec();
|
||||
oversized.extend(std::iter::repeat_n(b'1', MAX_CSI_SEQUENCE_BYTES));
|
||||
assert!(framer.push(&oversized).is_empty());
|
||||
|
||||
let mut continuation = vec![ESC; MAX_LEGACY_ESCAPE_PREFIXES + 1];
|
||||
continuation.push(b'x');
|
||||
let rebuilt = framer
|
||||
.push(&continuation)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(rebuilt, continuation[1..]);
|
||||
assert!(!framer.has_pending_input());
|
||||
assert!(framer.discard_until.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doubled_escape_cannot_bypass_oversized_csi_limit() {
|
||||
let mut framer = RawInputByteFramer::with_host_input_policy(true);
|
||||
let mut oversized = b"\x1b\x1b[".to_vec();
|
||||
oversized.extend(std::iter::repeat_n(b'1', MAX_CSI_SEQUENCE_BYTES));
|
||||
|
||||
assert!(framer.push(&oversized).is_empty());
|
||||
assert!(!framer.has_pending_input());
|
||||
assert!(framer.push(b"u").is_empty());
|
||||
assert_eq!(framer.push(b"x"), vec![b"x".to_vec()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_escape_prefixes_are_drained_iteratively() {
|
||||
let mut framer = RawInputByteFramer::with_host_input_policy(true);
|
||||
let mut input = vec![ESC; MAX_LEGACY_ESCAPE_PREFIXES * 1024];
|
||||
input.extend_from_slice(b"[A");
|
||||
|
||||
let chunks = framer.push(&input);
|
||||
let rebuilt = chunks.into_iter().flatten().collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(rebuilt, input);
|
||||
assert!(!framer.has_pending_input());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chunked_bracketed_paste_waits_for_terminator() {
|
||||
let (tx, mut rx) = mpsc::channel(8);
|
||||
|
||||
@@ -451,7 +451,7 @@ fn input_event_limit(events: &[ClientInputEvent]) -> InputEventLimit {
|
||||
.saturating_mul(usize::from((*repeat_count).max(1))),
|
||||
);
|
||||
}
|
||||
if let crate::protocol::ClientKeySource::Vt { bytes } = source {
|
||||
if let crate::protocol::ClientKeySource::Vt { bytes, .. } = source {
|
||||
input_bytes = input_bytes.saturating_add(bytes.len());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,3 +17,5 @@ ghostty kitty_shift_letter_kitty_31 equivalent_press_suffix 1b5b3130383a37363b32
|
||||
ghostty kitty_super_a_kitty_31 equivalent_press_suffix 1b5b39373b3975
|
||||
ghostty kitty_hyper_a_kitty_31 missing_modifier 1b5b39373b3b393775
|
||||
ghostty kitty_meta_a_kitty_31 missing_modifier 1b5b39373b3b393775
|
||||
ghostty kitty_full_alternates_associated_text adapter_generated_text_loss 41
|
||||
ghostty kitty_full_alternates_kitty_13 adapter_base_layout_loss 1b5b39373a36353b3275
|
||||
|
||||
|
+3
-1
@@ -1,4 +1,4 @@
|
||||
# family bytes_hex code modifiers kind shifted_codepoint generated_text_hex pane_profile pane_bytes_hex
|
||||
# family bytes_hex code modifiers kind shifted_codepoint generated_text_hex pane_profile pane_bytes_hex base_layout_codepoint(optional)
|
||||
legacy_ctrl_space 00 char: control press - legacy 00
|
||||
legacy_ctrl_a 01 char:a control press - legacy 01
|
||||
legacy_ctrl_b 02 char:b control press - legacy 02
|
||||
@@ -120,3 +120,5 @@ kitty_repeat_a_kitty_11 1b5b39373b313a3275 char:a - repeat - kitty_11 1b5b39373
|
||||
kitty_super_a_kitty_31 1b5b39373b3975 char:a super press - kitty_31 1b5b39373b393a3175
|
||||
kitty_hyper_a_kitty_31 1b5b39373b313775 char:a hyper press - kitty_31 1b5b39373b31373a3175
|
||||
kitty_meta_a_kitty_31 1b5b39373b333375 char:a meta press - kitty_31 1b5b39373b33333a3175
|
||||
kitty_full_alternates_kitty_13 1b5b39373a36353a3131333b3275 char:a shift press 65 - kitty_13 1b5b39373a36353a3131333b3275 113
|
||||
kitty_full_alternates_associated_text 1b5b39373a36353a3131333b3b36353a37363975 char:a shift press 65 41cc81 legacy 41cc81 113
|
||||
|
||||
|
Reference in New Issue
Block a user