diff options
Diffstat (limited to 'src/input')
| -rw-r--r-- | src/input/encode.rs | 18 | ||||
| -rw-r--r-- | src/input/mod.rs | 4 | ||||
| -rw-r--r-- | src/input/model.rs | 248 | ||||
| -rw-r--r-- | src/input/parse.rs | 16 |
4 files changed, 261 insertions, 25 deletions
diff --git a/src/input/encode.rs b/src/input/encode.rs index cd59b0f4..c475e0da 100644 --- a/src/input/encode.rs +++ b/src/input/encode.rs @@ -16,8 +16,10 @@ pub fn encode_key(key: KeyEvent, protocol: KeyboardProtocol) -> Vec<u8> { } pub fn encode_terminal_key(key: TerminalKey, protocol: KeyboardProtocol) -> Vec<u8> { - if key.is_text_commit { - return encode_text_input(&key).unwrap_or_default(); + if key.kind != crossterm::event::KeyEventKind::Release { + if let Some(text) = &key.generated_text { + return text.as_bytes().to_vec(); + } } // A release event only produces bytes when the pane protocol reports event @@ -282,10 +284,7 @@ fn encode_legacy(key: TerminalKey) -> Vec<u8> { // Alt modifier on character keys: prefix with ESC if mods.contains(KeyModifiers::ALT) { - let inner = TerminalKey { - modifiers: mods.difference(KeyModifiers::ALT), - ..key - }; + let inner = key.with_modifiers(mods.difference(KeyModifiers::ALT)); let mut bytes = vec![0x1b]; bytes.extend(encode_legacy_inner(inner)); return bytes; @@ -950,6 +949,13 @@ mod tests { encode_key(release, KeyboardProtocol::Kitty { flags: 3 }), b"\x1b[106;1:3u" ); + + let mut malformed_release = TerminalKey::from(release); + malformed_release.generated_text = Some("j".to_owned()); + assert_eq!( + encode_terminal_key(malformed_release, KeyboardProtocol::Kitty { flags: 3 }), + b"\x1b[106;1:3u" + ); } #[test] diff --git a/src/input/mod.rs b/src/input/mod.rs index 3882b035..0a716f9c 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -9,7 +9,7 @@ pub use encode::{ #[cfg(not(windows))] pub use model::ime_compatible_keyboard_enhancement_flags; pub use model::{ - host_modify_other_keys_mode, KeyboardProtocol, MouseProtocolEncoding, MouseProtocolMode, - TerminalKey, + host_modify_other_keys_mode, KeyIdentity, KeyboardProtocol, MouseProtocolEncoding, + MouseProtocolMode, TerminalKey, TextCommit, WindowsKeyRecord, }; pub use parse::parse_terminal_key_sequence; diff --git a/src/input/model.rs b/src/input/model.rs index f6072bc6..9c3c14e7 100644 --- a/src/input/model.rs +++ b/src/input/model.rs @@ -3,13 +3,77 @@ use crossterm::event::KeyboardEnhancementFlags; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct WindowsKeyRecord { + pub key_down: bool, + pub repeat_count: u16, + pub virtual_key_code: u16, + pub virtual_scan_code: u16, + pub unicode: u16, + pub control_key_state: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TextCommit { + text: String, +} + +impl TextCommit { + pub fn new(text: impl Into<String>) -> Self { + Self { text: text.into() } + } + + pub fn as_str(&self) -> &str { + &self.text + } + + pub(crate) fn into_string(self) -> String { + self.text + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct PhysicalKeyId(u32); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum KeyIdentity { + Physical(PhysicalKeyId), + Semantic(KeyCode), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum KeySource { + Synthesized, + Vt { + bytes: Vec<u8>, + }, + WindowsConsole { + record: WindowsKeyRecord, + physical_key: Option<PhysicalKeyId>, + }, +} + +impl WindowsKeyRecord { + fn physical_key_id(self) -> Option<PhysicalKeyId> { + const ENHANCED_KEY: u32 = 0x0100; + (self.virtual_scan_code != 0).then(|| { + PhysicalKeyId( + u32::from(self.virtual_scan_code) + | (u32::from(self.control_key_state & ENHANCED_KEY != 0) << 16), + ) + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] pub struct TerminalKey { pub code: KeyCode, pub modifiers: KeyModifiers, pub kind: crossterm::event::KeyEventKind, + pub repeat_count: u16, pub shifted_codepoint: Option<u32>, - pub is_text_commit: bool, + pub generated_text: Option<String>, + source: KeySource, } impl TerminalKey { @@ -18,36 +82,127 @@ impl TerminalKey { code, modifiers, kind: crossterm::event::KeyEventKind::Press, + repeat_count: 1, shifted_codepoint: None, - is_text_commit: false, + generated_text: None, + source: KeySource::Synthesized, } } pub fn with_kind(mut self, kind: crossterm::event::KeyEventKind) -> Self { + if kind == crossterm::event::KeyEventKind::Release { + self.repeat_count = 1; + self.generated_text = None; + } self.kind = kind; self } + pub fn with_repeat_count(mut self, repeat_count: u16) -> Self { + self.repeat_count = if self.kind == crossterm::event::KeyEventKind::Release { + 1 + } else { + repeat_count.max(1) + }; + self + } + + pub(crate) fn with_modifiers(mut self, modifiers: KeyModifiers) -> Self { + self.modifiers = modifiers; + 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 as_text_commit(mut self) -> Self { + pub(crate) fn with_generated_text(mut self, text: Option<String>) -> Self { + self.generated_text = if self.kind == crossterm::event::KeyEventKind::Release { + None + } else { + text + }; + self + } + + pub(crate) fn with_vt_bytes(mut self, bytes: Vec<u8>) -> Self { + self.source = KeySource::Vt { bytes }; + self + } + + pub fn with_windows_record(mut self, record: WindowsKeyRecord) -> Self { + self.repeat_count = if self.kind == crossterm::event::KeyEventKind::Release { + 1 + } else { + record.repeat_count.max(1) + }; + self.source = KeySource::WindowsConsole { + physical_key: record.physical_key_id(), + record, + }; + self + } + + #[cfg(any(windows, test))] + pub(crate) fn vt_bytes(&self) -> Option<&[u8]> { + match &self.source { + KeySource::Vt { bytes } => Some(bytes), + KeySource::Synthesized | KeySource::WindowsConsole { .. } => None, + } + } + + #[cfg(any(windows, test))] + pub(crate) fn windows_record(&self) -> Option<WindowsKeyRecord> { + match self.source { + KeySource::WindowsConsole { record, .. } => Some(record), + KeySource::Synthesized | KeySource::Vt { .. } => None, + } + } + + pub(crate) fn identity(&self) -> KeyIdentity { + match self.source { + KeySource::WindowsConsole { + physical_key: Some(physical_key), + .. + } => KeyIdentity::Physical(physical_key), + KeySource::WindowsConsole { + physical_key: None, .. + } + | KeySource::Synthesized + | KeySource::Vt { .. } => KeyIdentity::Semantic(self.code), + } + } + + pub(crate) fn has_physical_identity(&self) -> bool { + matches!( + self.source, + KeySource::WindowsConsole { + physical_key: Some(_), + .. + } + ) + } + + pub fn with_text_commit(mut self) -> Self { let has_text_only_modifiers = match self.code { - KeyCode::Char(ch) if ch.is_ascii_uppercase() => { + KeyCode::Char(ch) if ch.is_uppercase() => { self.modifiers == KeyModifiers::SHIFT || self.modifiers.is_empty() } KeyCode::Char(_) => self.modifiers.is_empty(), _ => false, }; - self.is_text_commit = - has_text_only_modifiers && self.kind == crossterm::event::KeyEventKind::Press; + if has_text_only_modifiers && self.kind == crossterm::event::KeyEventKind::Press { + self.generated_text = match self.code { + KeyCode::Char(ch) => Some(ch.to_string()), + _ => None, + }; + } self } - pub fn as_key_event(self) -> KeyEvent { + pub fn as_key_event(&self) -> KeyEvent { KeyEvent::new_with_kind(self.code, self.modifiers, self.kind) } } @@ -169,6 +324,83 @@ mod tests { use super::*; #[test] + fn native_source_exposes_typed_identity_without_changing_semantics() { + let record = WindowsKeyRecord { + key_down: true, + repeat_count: 1, + virtual_key_code: 27, + virtual_scan_code: 1, + unicode: 27, + control_key_state: 0, + }; + let key = TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()).with_windows_record(record); + let enhanced = TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()).with_windows_record( + WindowsKeyRecord { + control_key_state: 0x0100, + ..record + }, + ); + + assert!(matches!(key.identity(), KeyIdentity::Physical(_))); + assert_ne!(key.identity(), enhanced.identity()); + assert_eq!(key.code, KeyCode::Esc); + } + + #[test] + fn semantic_source_uses_semantic_identity() { + let key = TerminalKey::new(KeyCode::Char('x'), KeyModifiers::CONTROL); + + assert_eq!(key.identity(), KeyIdentity::Semantic(KeyCode::Char('x'))); + assert!(!key.has_physical_identity()); + assert_eq!(key.windows_record(), None); + } + + #[test] + fn native_source_remains_immutable_when_canonical_phase_changes() { + let key = TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()) + .with_windows_record(WindowsKeyRecord { + key_down: true, + repeat_count: 1, + virtual_key_code: 27, + virtual_scan_code: 1, + unicode: 27, + control_key_state: 0, + }) + .with_kind(crossterm::event::KeyEventKind::Release); + + assert_eq!(key.kind, crossterm::event::KeyEventKind::Release); + assert_eq!( + key.windows_record().map(|record| record.key_down), + Some(true) + ); + assert_eq!(key.repeat_count, 1); + } + + #[test] + fn release_clears_generated_text_and_grouped_repeat_count() { + let release = TerminalKey::new(KeyCode::Char('a'), KeyModifiers::empty()) + .with_generated_text(Some("a".to_owned())) + .with_repeat_count(4) + .with_kind(crossterm::event::KeyEventKind::Release); + let regrouped_release = release + .clone() + .with_repeat_count(4) + .with_generated_text(Some("ignored".to_owned())); + + assert_eq!(release.generated_text, None); + assert_eq!(release.repeat_count, 1); + assert_eq!(regrouped_release.generated_text, None); + assert_eq!(regrouped_release.repeat_count, 1); + } + + #[test] + fn non_ascii_uppercase_with_shift_is_committed_text() { + let key = TerminalKey::new(KeyCode::Char('É'), KeyModifiers::SHIFT).with_text_commit(); + + assert_eq!(key.generated_text.as_deref(), Some("É")); + } + + #[test] fn protocol_from_zero_flags_is_legacy() { assert_eq!( KeyboardProtocol::from_kitty_flags(0), diff --git a/src/input/parse.rs b/src/input/parse.rs index e4c554f8..b0420816 100644 --- a/src/input/parse.rs +++ b/src/input/parse.rs @@ -40,13 +40,11 @@ fn parse_kitty_key_sequence(data: &str) -> Option<TerminalKey> { let code = kitty_codepoint_to_keycode(codepoint)?; let kind = parse_kitty_event_type(event_type)?; - Some(TerminalKey { - code, - modifiers: key_modifiers_from_u8(modifier), - kind, - shifted_codepoint, - is_text_commit: false, - }) + let mut key = TerminalKey::new(code, key_modifiers_from_u8(modifier)).with_kind(kind); + if let Some(shifted_codepoint) = shifted_codepoint { + key = key.with_shifted_codepoint(shifted_codepoint); + } + Some(key) } #[allow(dead_code)] // Reserved for the upcoming raw stdin parser. @@ -533,7 +531,7 @@ mod tests { fn parse_legacy_alt_shift_letter_preserves_shift() { let key = parse_terminal_key_sequence("\x1bA").expect("alt-shift letter should parse"); assert_terminal_key_eq( - key, + key.clone(), KeyCode::Char('A'), KeyModifiers::ALT | KeyModifiers::SHIFT, crossterm::event::KeyEventKind::Press, @@ -620,7 +618,7 @@ mod tests { fn parse_kitty_sequence_with_associated_emoji_text() { let key = parse_terminal_key_sequence("\x1b[128512;1;128512u").unwrap(); assert_terminal_key_eq( - key, + key.clone(), KeyCode::Char('😀'), KeyModifiers::empty(), crossterm::event::KeyEventKind::Press, |
