aboutsummaryrefslogtreecommitdiff
path: root/src/input/lease.rs
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-01 15:48:22 +0300
committerGitHub <noreply@github.com>2026-09-01 15:48:22 +0300
commit207be3c771d281baae6e5fa0fb74be9a056e97a2 (patch)
treed7b037ecdf629224a048c841d11659efdc23507d /src/input/lease.rs
parent8a6d697308162874a236c84aac0b0f8c7ac01f83 (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.rs452
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)
+ );
+ }
+}