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/mod.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/mod.rs')
| -rw-r--r-- | src/app/mod.rs | 447 |
1 files changed, 388 insertions, 59 deletions
diff --git a/src/app/mod.rs b/src/app/mod.rs index 442df528..2a996685 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -136,9 +136,7 @@ pub struct App { pub(crate) detached_custom_command_children: Vec<std::process::Child>, pub(crate) persist_pane_history: bool, pub(crate) last_render_at: Option<Instant>, - pub(crate) pressed_terminal_keys: - HashMap<(InputSourceId, crossterm::event::KeyCode), PressedTerminalKey>, - pub(crate) suppressed_repeat_keys: HashSet<(InputSourceId, crossterm::event::KeyCode)>, + pub(crate) input_leases: input::InputLeaseTable, pub render_notify: Arc<Notify>, pub(crate) render_dirty: Arc<crate::render_signal::RenderSignal>, pub(crate) full_redraw_pending: bool, @@ -205,21 +203,14 @@ pub(crate) struct TerminalInputTarget { } #[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct PressedTerminalKey { - target: TerminalInputTarget, - key: crate::input::TerminalKey, +pub(crate) enum TerminalInputContext { + Pane, + Popup(crate::terminal::TerminalId), } pub(crate) type InputSourceId = u64; const LOCAL_INPUT_SOURCE: InputSourceId = 0; -fn pressed_key_identity( - source_id: InputSourceId, - key: &crate::input::TerminalKey, -) -> (InputSourceId, crossterm::event::KeyCode) { - (source_id, key.code) -} - fn auto_updates_enabled(no_session: bool) -> bool { !no_session && !cfg!(debug_assertions) } @@ -765,8 +756,7 @@ impl App { selection_highlight_clear_deadline: None, persist_pane_history: config.experimental.pane_history, last_render_at: None, - pressed_terminal_keys: HashMap::new(), - suppressed_repeat_keys: HashSet::new(), + input_leases: input::InputLeaseTable::default(), api_rx, event_hub, last_focus, @@ -1587,6 +1577,73 @@ impl App { // --------------------------------------------------------------------------- impl App { + pub(crate) fn terminal_input_context(&self) -> Option<TerminalInputContext> { + if let Some(popup) = &self.state.popup_pane { + Some(TerminalInputContext::Popup(popup.terminal_id.clone())) + } else if self.state.mode == Mode::Terminal { + Some(TerminalInputContext::Pane) + } else { + None + } + } + + fn execute_repeat_plan_headless( + &mut self, + source_id: InputSourceId, + lease_key: input::InputLeaseKey, + key: crate::input::TerminalKey, + plan: input::RepeatPlan, + ) { + match plan { + input::RepeatPlan::Forwarded(target) => { + if !self.forward_terminal_key_to_target_headless(&target, key) { + self.input_leases.remove(&lease_key); + } + } + input::RepeatPlan::Reprocess { + context, + repetitions, + tracked, + } => { + let key = key + .with_kind(crossterm::event::KeyEventKind::Repeat) + .with_repeat_count(1); + let mut forwarded_target = None; + for _ in 0..repetitions { + if let Some(target) = &forwarded_target { + if !self.forward_terminal_key_to_target_headless(target, key.clone()) { + self.input_leases.remove(&lease_key); + break; + } + continue; + } + let current_context = self.terminal_input_context(); + if !self.input_leases.reprocess_allowed( + lease_key, + &context, + current_context.as_ref(), + tracked, + ) { + break; + } + if let Some(target) = + self.handle_terminal_key_headless_from(source_id, key.clone()) + { + if tracked { + self.input_leases.insert_forwarded( + lease_key, + target.clone(), + key.clone(), + ); + forwarded_target = Some(target); + } + } + } + } + input::RepeatPlan::Ignore => {} + } + } + /// Routes raw input bytes from a client through the existing input pipeline. /// /// The input bytes are parsed into `RawInputEvent`s and then processed. @@ -1618,57 +1675,47 @@ impl App { let previous_mode = self.state.mode; match event { crate::raw_input::RawInputEvent::Key(key) => { - let pressed_key_id = pressed_key_identity(source_id, &key); + let lease_key = input::InputLeaseKey::new(source_id, &key); + let key = self.input_leases.normalize_press(&lease_key, key); match key.kind { crossterm::event::KeyEventKind::Press => { - if self.state.popup_pane.is_some() || self.state.mode == Mode::Terminal - { - self.suppressed_repeat_keys.remove(&pressed_key_id); - if let Some(target) = - self.handle_terminal_key_headless_from(source_id, key) - { - if !key.is_text_commit { - self.pressed_terminal_keys.insert( - pressed_key_id, - PressedTerminalKey { target, key }, - ); - } - } else { - self.pressed_terminal_keys.remove(&pressed_key_id); - } + let initial_context = self.terminal_input_context(); + let target = if initial_context.is_some() { + self.handle_terminal_key_headless_from(source_id, key.clone()) } else { - self.pressed_terminal_keys.remove(&pressed_key_id); - self.suppressed_repeat_keys.insert(pressed_key_id); - self.handle_non_terminal_key_headless(key); - } + self.handle_non_terminal_key_headless(key.clone()); + None + }; + let resulting_context = self.terminal_input_context(); + let plan = self.input_leases.complete_press( + lease_key, + &key, + initial_context.as_ref(), + resulting_context.as_ref(), + target, + ); + self.execute_repeat_plan_headless(source_id, lease_key, key, plan); } crossterm::event::KeyEventKind::Repeat => { - if let Some(pressed) = - self.pressed_terminal_keys.get(&pressed_key_id).cloned() - { - if !self - .forward_terminal_key_to_target_headless(&pressed.target, key) - { - self.pressed_terminal_keys.remove(&pressed_key_id); - } - } else if (self.state.popup_pane.is_some() - || self.state.mode == Mode::Terminal) - && !self.suppressed_repeat_keys.contains(&pressed_key_id) - { - let _ = self.handle_terminal_key_headless_from(source_id, key); - } + let current_context = self.terminal_input_context(); + let plan = self.input_leases.plan_repeat( + lease_key, + &key, + current_context.as_ref(), + ); + self.execute_repeat_plan_headless(source_id, lease_key, key, plan); } crossterm::event::KeyEventKind::Release => { - self.suppressed_repeat_keys.remove(&pressed_key_id); - if let Some(pressed) = - self.pressed_terminal_keys.remove(&pressed_key_id) - { + if let Some(lease) = self.input_leases.remove_forwarded(&lease_key) { let _ = self - .forward_terminal_key_to_target_headless(&pressed.target, key); + .forward_terminal_key_to_target_headless(&lease.target, key); } } } } + crate::raw_input::RawInputEvent::Text(text) => { + self.handle_text_commit_headless(text.as_str()); + } crate::raw_input::RawInputEvent::Mouse(mouse) => { if self.state.popup_pane.is_some() || self.state.mouse_capture { self.handle_mouse_event_headless(source_id, mouse); @@ -1838,6 +1885,94 @@ mod tests { ) } + #[cfg(windows)] + #[tokio::test] + async fn native_repeats_and_releases_follow_the_pressed_pane() { + let record = crate::input::WindowsKeyRecord { + key_down: true, + repeat_count: 1, + virtual_key_code: 27, + virtual_scan_code: 1, + unicode: 27, + control_key_state: 0, + }; + let key = crate::input::TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()) + .with_windows_record(record); + let enhanced = crate::input::TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()) + .with_windows_record(crate::input::WindowsKeyRecord { + control_key_state: 0x0100, + ..record + }); + assert_ne!( + input::InputLeaseKey::new(7, &key), + input::InputLeaseKey::new(7, &enhanced) + ); + + let mut app = test_app(); + let mut workspace = Workspace::test_new("test"); + let pressed_pane = workspace.focused_pane_id().unwrap(); + let other_pane = workspace.test_split(ratatui::layout::Direction::Horizontal); + workspace.tabs[0].layout.focus_pane(pressed_pane); + let (pressed_runtime, mut pressed_rx) = + TerminalRuntime::test_with_channel_capacity(80, 24, 6); + let (other_runtime, mut other_rx) = TerminalRuntime::test_with_channel_capacity(80, 24, 6); + workspace.tabs[0] + .runtimes + .insert(pressed_pane, pressed_runtime); + workspace.tabs[0].runtimes.insert(other_pane, other_runtime); + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + + let release = crate::input::TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()) + .with_windows_record(crate::input::WindowsKeyRecord { + key_down: false, + repeat_count: 1, + unicode: 0, + ..record + }) + .with_kind(KeyEventKind::Release); + app.route_client_events( + vec![crate::raw_input::RawInputEvent::Key(key.clone())], + false, + ); + assert!(app.state.focus_pane_in_workspace(0, other_pane)); + app.route_client_events( + vec![ + crate::raw_input::RawInputEvent::Key(key.clone()), + crate::raw_input::RawInputEvent::Key(key.clone().with_kind(KeyEventKind::Repeat)), + crate::raw_input::RawInputEvent::Key(release), + crate::raw_input::RawInputEvent::Key(key.clone()), + crate::raw_input::RawInputEvent::OuterFocusLost, + ], + false, + ); + + for _ in 0..3 { + assert_eq!( + pressed_rx.try_recv().unwrap(), + bytes::Bytes::from_static(b"\x1b[27;1;27;1;0;1_") + ); + } + assert_eq!( + pressed_rx.try_recv().unwrap(), + bytes::Bytes::from_static(b"\x1b[27;1;0;0;0;1_") + ); + assert!(pressed_rx.try_recv().is_err()); + + assert_eq!( + other_rx.try_recv().unwrap(), + bytes::Bytes::from_static(b"\x1b[27;1;27;1;0;1_") + ); + assert_eq!( + other_rx.try_recv().unwrap(), + bytes::Bytes::from_static(b"\x1b[27;1;27;0;0;1_") + ); + assert!(other_rx.try_recv().is_err()); + assert!(app.input_leases.is_empty()); + } + fn release_notes_state() -> state::ReleaseNotesState { state::ReleaseNotesState { version: "0.1.0".into(), @@ -4877,6 +5012,27 @@ mod tests { } #[test] + fn explicit_text_commit_does_not_trigger_navigate_binding() { + let mut app = test_app(); + app.state.workspaces = vec![Workspace::test_new("test")]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.detach_exits = false; + app.state.mode = Mode::Navigate; + + app.route_client_events( + vec![crate::raw_input::RawInputEvent::Text( + crate::input::TextCommit::new("q"), + )], + false, + ); + + assert!(!app.state.detach_requested); + assert_eq!(app.state.mode, Mode::Navigate); + assert!(app.input_leases.is_empty()); + } + + #[test] fn route_client_input_q_detaches_in_persistence_mode() { let mut app = test_app(); app.state.workspaces = vec![Workspace::test_new("test")]; @@ -5105,6 +5261,31 @@ last_pane = "prefix+tab" } #[tokio::test] + async fn explicit_text_commit_bypasses_bindings_leases_and_key_encoding() { + let mut app = test_app(); + let mut workspace = Workspace::test_new("test"); + let focused = workspace.focused_pane_id().unwrap(); + let (runtime, mut rx) = + TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 2); + workspace.tabs[0].runtimes.insert(focused, runtime); + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + + app.route_client_events( + vec![crate::raw_input::RawInputEvent::Text( + crate::input::TextCommit::new("你🙂"), + )], + false, + ); + + assert_eq!(rx.recv().await.unwrap().as_ref(), "你🙂".as_bytes()); + assert!(rx.try_recv().is_err()); + assert!(app.input_leases.is_empty()); + } + + #[tokio::test] async fn committed_ime_text_bypasses_report_all_key_encoding() { let mut app = test_app(); let mut workspace = Workspace::test_new("test"); @@ -5123,7 +5304,7 @@ last_pane = "prefix+tab" rx.recv().await.unwrap(), bytes::Bytes::from_static("你".as_bytes()) ); - assert!(app.pressed_terminal_keys.is_empty()); + assert!(app.input_leases.is_empty()); } #[tokio::test] @@ -5142,7 +5323,7 @@ last_pane = "prefix+tab" app.route_client_input(b"A".to_vec()); assert_eq!(rx.recv().await.unwrap(), bytes::Bytes::from_static(b"A")); - assert!(app.pressed_terminal_keys.is_empty()); + assert!(app.input_leases.is_empty()); } #[tokio::test] @@ -5174,7 +5355,7 @@ last_pane = "prefix+tab" rx.try_recv().expect("physical release"), bytes::Bytes::from_static(b"\x1b[106;1:3u") ); - assert!(app.pressed_terminal_keys.is_empty()); + assert!(app.input_leases.is_empty()); } #[tokio::test] @@ -5210,7 +5391,7 @@ last_pane = "prefix+tab" rx.try_recv().expect("synthetic release on focus loss"), bytes::Bytes::from_static(b"\x1b[106;1:3u") ); - assert!(app.pressed_terminal_keys.is_empty()); + assert!(app.input_leases.is_empty()); } #[tokio::test] @@ -5245,7 +5426,155 @@ last_pane = "prefix+tab" rx.try_recv().expect("synthetic release on disconnect"), bytes::Bytes::from_static(b"\x1b[106;1:3u") ); - assert!(app.pressed_terminal_keys.is_empty()); + assert!(app.input_leases.is_empty()); + } + + #[tokio::test] + async fn physical_count_one_repeats_and_release_keep_the_pressed_pane() { + let mut app = test_app(); + let mut workspace = Workspace::test_new("test"); + let pressed_pane = workspace.focused_pane_id().unwrap(); + let other_pane = workspace.test_split(ratatui::layout::Direction::Horizontal); + workspace.tabs[0].layout.focus_pane(pressed_pane); + let (pressed_runtime, mut pressed_rx) = + TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 5); + let (other_runtime, mut other_rx) = + TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 2); + workspace.tabs[0] + .runtimes + .insert(pressed_pane, pressed_runtime); + workspace.tabs[0].runtimes.insert(other_pane, other_runtime); + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + + 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 roundtrip = |events: Vec<crate::protocol::ClientInputEvent>| { + let message = crate::protocol::ClientMessage::InputEvents { events }; + let encoded = + bincode::serde::encode_to_vec(&message, bincode::config::standard()).unwrap(); + let (decoded, _): (crate::protocol::ClientMessage, _) = + bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); + let crate::protocol::ClientMessage::InputEvents { events } = decoded else { + panic!("expected structured input events"); + }; + events + .into_iter() + .map(|event| event.to_raw_input_event()) + .collect() + }; + let event = |kind, record| crate::protocol::ClientInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('a'), + modifiers: 0, + kind, + repeat_count: 1, + generated_text: None, + source: crate::protocol::ClientKeySource::WindowsConsole { record }, + }; + + app.route_client_events( + roundtrip(vec![event(crate::protocol::ClientKeyKind::Press, record)]), + false, + ); + assert!(app.state.focus_pane_in_workspace(0, other_pane)); + app.route_client_events( + roundtrip(vec![ + event(crate::protocol::ClientKeyKind::Press, record), + event(crate::protocol::ClientKeyKind::Repeat, record), + event( + crate::protocol::ClientKeyKind::Release, + crate::input::WindowsKeyRecord { + key_down: false, + unicode: 0, + ..record + }, + ), + ]), + false, + ); + + for expected in [ + b"\x1b[97;1:1u".as_slice(), + b"\x1b[97;1:2u".as_slice(), + b"\x1b[97;1:2u".as_slice(), + b"\x1b[97;1:3u".as_slice(), + ] { + assert_eq!(pressed_rx.try_recv().unwrap().as_ref(), expected); + } + assert!(pressed_rx.try_recv().is_err()); + assert!(other_rx.try_recv().is_err()); + assert!(app.input_leases.is_empty()); + } + + #[tokio::test] + async fn grouped_physical_press_and_runtime_loss_preserve_count_then_close_the_lease() { + let mut app = test_app(); + let workspace = Workspace::test_new("test"); + let pane_id = workspace.focused_pane_id().unwrap(); + let terminal_id = workspace.tabs[0].terminal_id(pane_id).unwrap().clone(); + app.state.workspaces = vec![workspace]; + app.state.ensure_test_terminals(); + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + let (runtime, mut rx) = + TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 2); + app.terminal_runtimes.insert(terminal_id.clone(), runtime); + + let record = crate::input::WindowsKeyRecord { + key_down: true, + repeat_count: 3, + virtual_key_code: 65, + virtual_scan_code: 30, + unicode: 97, + control_key_state: 0, + }; + let message = crate::protocol::ClientMessage::InputEvents { + events: vec![crate::protocol::ClientInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('a'), + modifiers: 0, + kind: crate::protocol::ClientKeyKind::Press, + repeat_count: 3, + generated_text: None, + source: crate::protocol::ClientKeySource::WindowsConsole { record }, + }], + }; + let encoded = bincode::serde::encode_to_vec(&message, bincode::config::standard()).unwrap(); + let (decoded, _): (crate::protocol::ClientMessage, _) = + bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap(); + let crate::protocol::ClientMessage::InputEvents { events } = decoded else { + panic!("expected structured input events"); + }; + + app.route_client_events( + events + .into_iter() + .map(|event| event.to_raw_input_event()) + .collect(), + false, + ); + app.shutdown_terminal_runtime(terminal_id.clone()); + + assert_eq!( + rx.try_recv().expect("grouped press"), + bytes::Bytes::from_static(b"\x1b[97;1:1u\x1b[97;1:2u\x1b[97;1:2u") + ); + assert_eq!( + rx.try_recv() + .expect("synthetic release before runtime shutdown"), + bytes::Bytes::from_static(b"\x1b[97;1:3u") + ); + assert!(rx.try_recv().is_err()); + assert!(app.input_leases.is_empty()); + assert!(app.terminal_runtimes.get(&terminal_id).is_none()); } #[tokio::test] |
