diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-01 15:48:22 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-01 15:48:22 +0300 |
| commit | 207be3c771d281baae6e5fa0fb74be9a056e97a2 (patch) | |
| tree | d7b037ecdf629224a048c841d11659efdc23507d /src/input/lease.rs | |
| parent | 8a6d697308162874a236c84aac0b0f8c7ac01f83 (diff) | |
refactor: render the shell in the client (#3487)
* feat: add opt-in client-rendered shell
* feat: add client-owned shell mouse controls
* feat: complete client-owned shell chrome
* feat: add client-owned shell settings and notifications
* feat: run client shell custom commands
* feat: render popup terminals in client shell
* feat: add client-owned scrollback and copy mode
* fix: preserve client worktree trust defaults
* feat: render startup diagnostics in client shell
* feat: render onboarding in client shell
* feat: render product announcements in client shell
* feat: render release notes in client shell
* feat: render mobile switcher in client shell
* feat: render kitty graphics in client shell
* fix: preserve client shell lifecycle coherence
* refactor: route client shell commands through endpoint connections
* refactor: make the client-rendered shell the default
* refactor: remove monolithic launch mode
* refactor: complete client-rendered shell cutover
* perf: retain client pane surface updates
* perf: scale retained pane surface updates
* perf: remove remote pane input latency
* test: make client shell checks platform-independent
* fix: prevent client timer starvation
* test: fix client shell platform gates
* test: accept retained surfaces in cross-area checks
* test: make client mode assertions platform-neutral
* fix: badge only outdated integrations
Diffstat (limited to 'src/input/lease.rs')
| -rw-r--r-- | src/input/lease.rs | 452 |
1 files changed, 452 insertions, 0 deletions
diff --git a/src/input/lease.rs b/src/input/lease.rs new file mode 100644 index 00000000..0352df42 --- /dev/null +++ b/src/input/lease.rs @@ -0,0 +1,452 @@ +use std::collections::HashMap; +use std::hash::Hash; + +use super::{KeyIdentity, TerminalKey}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) struct InputLeaseKey<Source> { + source: Source, + identity: KeyIdentity, +} + +impl<Source> InputLeaseKey<Source> { + pub(crate) fn new(source: Source, key: &TerminalKey) -> Self { + Self { + source, + identity: key.identity(), + } + } +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct ForwardedInputLease<Target> { + pub(crate) target: Target, + pub(crate) key: TerminalKey, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum ConsumedInputLease<Context> { + ReprocessRepeats(Context), + SuppressRepeats, +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum InputLease<Context, Target> { + Forwarded(ForwardedInputLease<Target>), + Consumed(ConsumedInputLease<Context>), +} + +pub(crate) enum RepeatPlan<Context, Target> { + Forwarded(Target), + Reprocess { + context: Context, + repetitions: u16, + tracked: bool, + }, + Ignore, +} + +pub(crate) struct InputLeaseTable<Source, Context, Target> { + leases: HashMap<InputLeaseKey<Source>, InputLease<Context, Target>>, +} + +impl<Source, Context, Target> Default for InputLeaseTable<Source, Context, Target> { + fn default() -> Self { + Self { + leases: HashMap::new(), + } + } +} + +impl<Source, Context, Target> InputLeaseTable<Source, Context, Target> +where + Source: Copy + Eq + Hash, + Context: Clone + Eq, + Target: Clone + Eq, +{ + pub(crate) fn normalize_press( + &mut self, + lease_key: &InputLeaseKey<Source>, + key: TerminalKey, + ) -> TerminalKey { + if key.kind != crossterm::event::KeyEventKind::Press + || (key.generated_text.is_some() && !key.has_physical_identity()) + { + 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<Source>, + key: &TerminalKey, + initial_context: Option<&Context>, + resulting_context: Option<&Context>, + target: Option<Target>, + ) -> RepeatPlan<Context, Target> { + if key.generated_text.is_some() && !key.has_physical_identity() { + 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<Source>, + key: &TerminalKey, + current_context: Option<&Context>, + ) -> RepeatPlan<Context, Target> { + 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<Source>, + expected_context: &Context, + current_context: Option<&Context>, + 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<Source>, + ) -> Option<ForwardedInputLease<Target>> { + 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<Source>) -> bool { + self.leases.contains_key(key) + } + + pub(crate) fn insert_forwarded( + &mut self, + key: InputLeaseKey<Source>, + target: Target, + original: TerminalKey, + ) { + self.leases.insert( + key, + InputLease::Forwarded(ForwardedInputLease { + target, + key: original, + }), + ); + } + + pub(crate) fn insert_consumed( + &mut self, + key: InputLeaseKey<Source>, + disposition: ConsumedInputLease<Context>, + ) { + self.leases.insert(key, InputLease::Consumed(disposition)); + } + + pub(crate) fn remove( + &mut self, + key: &InputLeaseKey<Source>, + ) -> Option<InputLease<Context, Target>> { + self.leases.remove(key) + } + + pub(crate) fn remove_source(&mut self, source: Source) -> Vec<ForwardedInputLease<Target>> { + let keys = self + .leases + .keys() + .filter(|key| key.source == source) + .copied() + .collect::<Vec<_>>(); + self.remove_keys(keys) + } + + pub(crate) fn remove_target(&mut self, target: &Target) -> Vec<ForwardedInputLease<Target>> { + 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<Source>>, + ) -> Vec<ForwardedInputLease<Target>> { + 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::*; + + #[derive(Clone, Debug, PartialEq, Eq)] + enum Context { + Pane, + } + + type Leases = InputLeaseTable<u64, Context, u64>; + + fn physical_generated_slash(repeat_count: u16) -> TerminalKey { + TerminalKey::new(KeyCode::Char('/'), KeyModifiers::SHIFT) + .with_generated_text(Some("/".to_owned())) + .with_windows_record(crate::input::WindowsKeyRecord { + key_down: true, + repeat_count, + virtual_key_code: 0x37, + virtual_scan_code: 0x08, + unicode: u16::from(b'/'), + control_key_state: 0x0010, + }) + } + + #[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 mut leases = Leases::default(); + leases.insert_forwarded(forwarded, 10, key.clone()); + leases.insert_consumed(consumed, ConsumedInputLease::SuppressRepeats); + leases.insert_forwarded(other_source, 11, key.clone()); + + assert_eq!( + leases.remove_source(7), + vec![ForwardedInputLease { target: 10, key }] + ); + assert_eq!(leases.len(), 1); + assert!(leases.contains(&other_source)); + } + + #[test] + fn remove_target_closes_only_forwarded_leases_for_that_target() { + let key = TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()); + let removed_key = InputLeaseKey::new(7, &key); + let retained_key = InputLeaseKey::new(8, &key); + let mut leases = Leases::default(); + leases.insert_forwarded(removed_key, 10, key.clone()); + leases.insert_forwarded(retained_key, 11, key.clone()); + + assert_eq!( + leases.remove_target(&10), + vec![ForwardedInputLease { target: 10, 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 = Leases::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, 10, physical.clone()); + assert_eq!( + leases.normalize_press(&lease_key, physical).kind, + crossterm::event::KeyEventKind::Repeat + ); + } + + #[test] + fn physical_generated_text_keeps_native_repeat_lifecycle() { + let key = physical_generated_slash(3); + let lease_key = InputLeaseKey::new(7, &key); + let context = Context::Pane; + let mut leases = Leases::default(); + + assert!(matches!( + leases.complete_press(lease_key, &key, Some(&context), Some(&context), Some(10),), + RepeatPlan::Ignore + )); + let repeated = leases.normalize_press(&lease_key, key.with_repeat_count(1)); + assert_eq!(repeated.kind, crossterm::event::KeyEventKind::Repeat); + assert!(matches!( + leases.plan_repeat(lease_key, &repeated, Some(&context)), + RepeatPlan::Forwarded(10) + )); + assert!(leases.remove_forwarded(&lease_key).is_some()); + } + + #[test] + fn consumed_grouped_physical_generated_text_reprocesses_repeats() { + let key = physical_generated_slash(3); + let lease_key = InputLeaseKey::new(7, &key); + let context = Context::Pane; + let mut leases = Leases::default(); + + assert!(matches!( + leases.complete_press(lease_key, &key, Some(&context), Some(&context), None), + RepeatPlan::Reprocess { + context: Context::Pane, + repetitions: 2, + tracked: true, + } + )); + } + + #[test] + fn forwarded_semantic_generated_text_has_no_release_lease() { + let key = TerminalKey::new(KeyCode::Char('/'), KeyModifiers::SHIFT) + .with_generated_text(Some("/".to_owned())) + .with_repeat_count(3); + let lease_key = InputLeaseKey::new(7, &key); + let context = Context::Pane; + let mut leases = Leases::default(); + + assert!(matches!( + leases.complete_press(lease_key, &key, Some(&context), Some(&context), Some(10)), + RepeatPlan::Ignore + )); + assert_eq!(leases.remove_forwarded(&lease_key), None); + } + + #[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 = Context::Pane; + let mut leases = Leases::default(); + leases.insert_consumed(lease_key, ConsumedInputLease::SuppressRepeats); + + let key = leases.normalize_press(&lease_key, key); + assert!(matches!( + leases.complete_press(lease_key, &key, Some(&context), Some(&context), None), + RepeatPlan::Reprocess { + context: Context::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) + ); + } +} |
