aboutsummaryrefslogtreecommitdiff
path: root/src/input
diff options
context:
space:
mode:
Diffstat (limited to 'src/input')
-rw-r--r--src/input/encode.rs18
-rw-r--r--src/input/mod.rs4
-rw-r--r--src/input/model.rs248
-rw-r--r--src/input/parse.rs16
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,