diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-08-01 04:27:50 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-08-01 04:27:50 +0300 |
| commit | b76adc155d979705a353fd6dc87c702f9dfc6c22 (patch) | |
| tree | f613254e0cfbfd2b4450a6a5f3ee7c0ba8136976 /src/app/input/lease.rs | |
| parent | 8d190eddda2c74afa3dc043f8ec8020ffd7b5a94 (diff) | |
fix(input): preserve native key lifecycle across routing (#2142)
refs #2077
Co-authored-by: Jonathan Liebig <jonathan.liebig@gmail.com>
Diffstat (limited to 'src/app/input/lease.rs')
| -rw-r--r-- | src/app/input/lease.rs | 375 |
1 files changed, 375 insertions, 0 deletions
diff --git a/src/app/input/lease.rs b/src/app/input/lease.rs new file mode 100644 index 00000000..de118334 --- /dev/null +++ b/src/app/input/lease.rs @@ -0,0 +1,375 @@ +use std::collections::HashMap; + +use crate::app::{InputSourceId, TerminalInputContext, TerminalInputTarget}; +use crate::input::{KeyIdentity, TerminalKey}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) struct InputLeaseKey { + source_id: InputSourceId, + identity: KeyIdentity, +} + +impl InputLeaseKey { + pub(crate) fn new(source_id: InputSourceId, key: &TerminalKey) -> Self { + Self { + source_id, + identity: key.identity(), + } + } +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct ForwardedInputLease { + pub(crate) target: TerminalInputTarget, + pub(crate) key: TerminalKey, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum ConsumedInputLease { + ReprocessRepeats(TerminalInputContext), + SuppressRepeats, +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum InputLease { + Forwarded(ForwardedInputLease), + Consumed(ConsumedInputLease), +} + +pub(crate) enum RepeatPlan { + Forwarded(TerminalInputTarget), + Reprocess { + context: TerminalInputContext, + repetitions: u16, + tracked: bool, + }, + Ignore, +} + +#[derive(Default)] +pub(crate) struct InputLeaseTable { + leases: HashMap<InputLeaseKey, InputLease>, +} + +impl InputLeaseTable { + pub(crate) fn normalize_press( + &mut self, + lease_key: &InputLeaseKey, + key: TerminalKey, + ) -> TerminalKey { + if key.kind != crossterm::event::KeyEventKind::Press || key.generated_text.is_some() { + return key; + } + if key.has_physical_identity() && self.leases.contains_key(lease_key) { + key.with_kind(crossterm::event::KeyEventKind::Repeat) + } else { + self.leases.remove(lease_key); + key + } + } + + pub(crate) fn complete_press( + &mut self, + lease_key: InputLeaseKey, + key: &TerminalKey, + initial_context: Option<&TerminalInputContext>, + resulting_context: Option<&TerminalInputContext>, + target: Option<TerminalInputTarget>, + ) -> RepeatPlan { + if key.generated_text.is_some() { + return RepeatPlan::Ignore; + } + if let Some(target) = target { + self.insert_forwarded(lease_key, target, key.clone()); + return RepeatPlan::Ignore; + } + if !self.leases.contains_key(&lease_key) { + let disposition = match (initial_context, resulting_context) { + (Some(initial), Some(resulting)) if initial == resulting => { + ConsumedInputLease::ReprocessRepeats(initial.clone()) + } + _ => ConsumedInputLease::SuppressRepeats, + }; + self.insert_consumed(lease_key, disposition); + } + match self.leases.get(&lease_key) { + Some(InputLease::Consumed(ConsumedInputLease::ReprocessRepeats(context))) + if key.repeat_count > 1 => + { + RepeatPlan::Reprocess { + context: context.clone(), + repetitions: key.repeat_count - 1, + tracked: true, + } + } + _ => RepeatPlan::Ignore, + } + } + + pub(crate) fn plan_repeat( + &mut self, + lease_key: InputLeaseKey, + key: &TerminalKey, + current_context: Option<&TerminalInputContext>, + ) -> RepeatPlan { + match self.leases.get(&lease_key) { + Some(InputLease::Forwarded(lease)) => { + return RepeatPlan::Forwarded(lease.target.clone()); + } + Some(InputLease::Consumed(ConsumedInputLease::ReprocessRepeats(context))) + if current_context == Some(context) => + { + return RepeatPlan::Reprocess { + context: context.clone(), + repetitions: key.repeat_count, + tracked: true, + }; + } + Some(InputLease::Consumed(ConsumedInputLease::ReprocessRepeats(_))) => { + self.insert_consumed(lease_key, ConsumedInputLease::SuppressRepeats); + return RepeatPlan::Ignore; + } + Some(InputLease::Consumed(ConsumedInputLease::SuppressRepeats)) => { + return RepeatPlan::Ignore; + } + None => {} + } + match current_context { + Some(context) => RepeatPlan::Reprocess { + context: context.clone(), + repetitions: key.repeat_count, + tracked: false, + }, + None => RepeatPlan::Ignore, + } + } + + pub(crate) fn reprocess_allowed( + &mut self, + lease_key: InputLeaseKey, + expected_context: &TerminalInputContext, + current_context: Option<&TerminalInputContext>, + tracked: bool, + ) -> bool { + let allowed = current_context == Some(expected_context); + if tracked && !allowed { + self.insert_consumed(lease_key, ConsumedInputLease::SuppressRepeats); + } + allowed + } + + pub(crate) fn remove_forwarded(&mut self, key: &InputLeaseKey) -> Option<ForwardedInputLease> { + match self.leases.remove(key) { + Some(InputLease::Forwarded(lease)) => Some(lease), + Some(InputLease::Consumed(_)) | None => None, + } + } + + #[cfg(test)] + pub(crate) fn contains(&self, key: &InputLeaseKey) -> bool { + self.leases.contains_key(key) + } + + pub(crate) fn insert_forwarded( + &mut self, + key: InputLeaseKey, + target: TerminalInputTarget, + original: TerminalKey, + ) { + self.leases.insert( + key, + InputLease::Forwarded(ForwardedInputLease { + target, + key: original, + }), + ); + } + + pub(crate) fn insert_consumed(&mut self, key: InputLeaseKey, disposition: ConsumedInputLease) { + self.leases.insert(key, InputLease::Consumed(disposition)); + } + + pub(crate) fn remove(&mut self, key: &InputLeaseKey) -> Option<InputLease> { + self.leases.remove(key) + } + + pub(crate) fn remove_source(&mut self, source_id: InputSourceId) -> Vec<ForwardedInputLease> { + let keys = self + .leases + .keys() + .filter(|key| key.source_id == source_id) + .copied() + .collect::<Vec<_>>(); + self.remove_keys(keys) + } + + pub(crate) fn remove_target( + &mut self, + target: &TerminalInputTarget, + ) -> Vec<ForwardedInputLease> { + let keys = self + .leases + .iter() + .filter_map(|(key, lease)| match lease { + InputLease::Forwarded(lease) if &lease.target == target => Some(*key), + InputLease::Forwarded(_) | InputLease::Consumed(_) => None, + }) + .collect::<Vec<_>>(); + self.remove_keys(keys) + } + + fn remove_keys( + &mut self, + keys: impl IntoIterator<Item = InputLeaseKey>, + ) -> Vec<ForwardedInputLease> { + keys.into_iter() + .filter_map(|key| match self.leases.remove(&key) { + Some(InputLease::Forwarded(lease)) => Some(lease), + Some(InputLease::Consumed(_)) | None => None, + }) + .collect() + } + + #[cfg(test)] + pub(crate) fn is_empty(&self) -> bool { + self.leases.is_empty() + } + + #[cfg(test)] + pub(crate) fn len(&self) -> usize { + self.leases.len() + } +} + +#[cfg(test)] +mod tests { + use crossterm::event::{KeyCode, KeyModifiers}; + + use super::*; + + fn target() -> TerminalInputTarget { + TerminalInputTarget { + terminal_id: crate::terminal::TerminalId::alloc(), + } + } + + #[test] + fn remove_source_returns_forwarded_and_discards_consumed_leases() { + let key = TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()); + let forwarded = InputLeaseKey::new(7, &key); + let consumed = InputLeaseKey::new( + 7, + &TerminalKey::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + ); + let other_source = InputLeaseKey::new(8, &key); + let first_target = target(); + let mut leases = InputLeaseTable::default(); + leases.insert_forwarded(forwarded, first_target.clone(), key.clone()); + leases.insert_consumed(consumed, ConsumedInputLease::SuppressRepeats); + leases.insert_forwarded(other_source, target(), key.clone()); + + assert_eq!( + leases.remove_source(7), + vec![ForwardedInputLease { + target: first_target, + key, + }] + ); + assert_eq!(leases.len(), 1); + assert!(leases.contains(&other_source)); + } + + #[test] + fn remove_target_closes_only_forwarded_leases_for_that_terminal() { + let key = TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()); + let removed_key = InputLeaseKey::new(7, &key); + let retained_key = InputLeaseKey::new(8, &key); + let removed_target = target(); + let retained_target = target(); + let mut leases = InputLeaseTable::default(); + leases.insert_forwarded(removed_key, removed_target.clone(), key.clone()); + leases.insert_forwarded(retained_key, retained_target, key.clone()); + + assert_eq!( + leases.remove_target(&removed_target), + vec![ForwardedInputLease { + target: removed_target, + key, + }] + ); + assert_eq!(leases.len(), 1); + assert!(leases.contains(&retained_key)); + } + + #[test] + fn duplicate_physical_press_normalizes_for_forwarded_and_consumed_leases() { + let record = crate::input::WindowsKeyRecord { + key_down: true, + repeat_count: 1, + virtual_key_code: 65, + virtual_scan_code: 30, + unicode: 97, + control_key_state: 0, + }; + let physical = + TerminalKey::new(KeyCode::Char('a'), KeyModifiers::empty()).with_windows_record(record); + let lease_key = InputLeaseKey::new(7, &physical); + let mut leases = InputLeaseTable::default(); + + assert_eq!( + leases.normalize_press(&lease_key, physical.clone()).kind, + crossterm::event::KeyEventKind::Press + ); + leases.insert_consumed(lease_key, ConsumedInputLease::SuppressRepeats); + assert_eq!( + leases.normalize_press(&lease_key, physical.clone()).kind, + crossterm::event::KeyEventKind::Repeat + ); + leases.insert_forwarded(lease_key, target(), physical.clone()); + assert_eq!( + leases.normalize_press(&lease_key, physical.clone()).kind, + crossterm::event::KeyEventKind::Repeat + ); + } + + #[test] + fn new_semantic_press_recomputes_consumed_repeat_disposition() { + let key = TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()).with_repeat_count(3); + let lease_key = InputLeaseKey::new(7, &key); + let context = TerminalInputContext::Pane; + let mut leases = InputLeaseTable::default(); + leases.insert_consumed(lease_key, ConsumedInputLease::SuppressRepeats); + + let key = leases.normalize_press(&lease_key, key); + let plan = leases.complete_press(lease_key, &key, Some(&context), Some(&context), None); + + assert!(matches!( + plan, + RepeatPlan::Reprocess { + context: TerminalInputContext::Pane, + repetitions: 2, + tracked: true, + } + )); + } + + #[test] + fn physical_and_semantic_identities_do_not_collide() { + let record = crate::input::WindowsKeyRecord { + key_down: true, + repeat_count: 1, + virtual_key_code: 65, + virtual_scan_code: 30, + unicode: 97, + control_key_state: 0, + }; + let physical = + TerminalKey::new(KeyCode::Char('a'), KeyModifiers::empty()).with_windows_record(record); + let semantic = TerminalKey::new(KeyCode::Char('a'), KeyModifiers::empty()); + + assert_ne!( + InputLeaseKey::new(7, &physical), + InputLeaseKey::new(7, &semantic) + ); + } +} |
