aboutsummaryrefslogtreecommitdiff
path: root/src/input/model.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/input/model.rs')
-rw-r--r--src/input/model.rs248
1 files changed, 240 insertions, 8 deletions
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),