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 | |
| 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')
| -rw-r--r-- | src/app/input/clipboard.rs | 45 | ||||
| -rw-r--r-- | src/app/input/copy_mode.rs | 4 | ||||
| -rw-r--r-- | src/app/input/lease.rs | 375 | ||||
| -rw-r--r-- | src/app/input/mod.rs | 62 | ||||
| -rw-r--r-- | src/app/input/modal.rs | 14 | ||||
| -rw-r--r-- | src/app/input/navigate.rs | 75 | ||||
| -rw-r--r-- | src/app/input/terminal.rs | 390 |
7 files changed, 783 insertions, 182 deletions
diff --git a/src/app/input/clipboard.rs b/src/app/input/clipboard.rs index db3fa9f2..62ac1ca9 100644 --- a/src/app/input/clipboard.rs +++ b/src/app/input/clipboard.rs @@ -5,7 +5,9 @@ use crate::{ input::TerminalKey, }; -fn is_retained_selection_copy_key(key: TerminalKey) -> bool { +use super::{ConsumedInputLease, InputLeaseKey}; + +fn is_retained_selection_copy_key(key: &TerminalKey) -> bool { matches!(key.code, KeyCode::Char('c' | 'C')) && matches!(key.modifiers, KeyModifiers::CONTROL | KeyModifiers::SUPER) } @@ -31,7 +33,7 @@ impl App { key: TerminalKey, ) -> bool { if self.state.copy_on_select - || !is_retained_selection_copy_key(key) + || !is_retained_selection_copy_key(&key) || !self .state .selection @@ -46,7 +48,10 @@ impl App { return false; } - self.suppressed_repeat_keys.insert((source_id, key.code)); + self.input_leases.insert_consumed( + InputLeaseKey::new(source_id, &key), + ConsumedInputLease::SuppressRepeats, + ); true } } @@ -132,11 +137,19 @@ mod tests { assert_visible_selection(&app); assert!(app.event_rx.try_recv().is_err()); - let ctrl_c = TerminalKey::new(KeyCode::Char('c'), KeyModifiers::CONTROL); + let ctrl_c = TerminalKey::new(KeyCode::Char('c'), KeyModifiers::CONTROL) + .with_windows_record(crate::input::WindowsKeyRecord { + key_down: true, + repeat_count: 1, + virtual_key_code: 0x43, + virtual_scan_code: 0x2e, + unicode: 'c' as u16, + control_key_state: 0x0008, + }); let source_id = 41; app.route_client_events_from( source_id, - vec![crate::raw_input::RawInputEvent::Key(ctrl_c)], + vec![crate::raw_input::RawInputEvent::Key(ctrl_c.clone())], false, ); @@ -156,29 +169,39 @@ mod tests { app.route_client_events_from( source_id, - vec![crate::raw_input::RawInputEvent::Key( - ctrl_c.with_kind(KeyEventKind::Repeat), - )], + vec![ + crate::raw_input::RawInputEvent::Key(ctrl_c.clone()), + crate::raw_input::RawInputEvent::Key( + ctrl_c.clone().with_kind(KeyEventKind::Repeat), + ), + ], false, ); + assert_eq!(app.input_leases.len(), 1); assert!(app.event_rx.try_recv().is_err()); assert!(input_rx.try_recv().is_err()); app.route_client_events_from( source_id, vec![crate::raw_input::RawInputEvent::Key( - ctrl_c.with_kind(KeyEventKind::Release), + ctrl_c.clone().with_kind(KeyEventKind::Release), )], false, ); + assert!(app.input_leases.is_empty()); app.route_client_events_from( source_id, - vec![crate::raw_input::RawInputEvent::Key(ctrl_c)], + vec![crate::raw_input::RawInputEvent::Key(ctrl_c.clone())], false, ); + let expected = if cfg!(windows) { + b"\x1b[67;46;99;1;8;1_".as_slice() + } else { + b"\x03".as_slice() + }; assert_eq!( input_rx.try_recv().expect("forwarded Ctrl-C").as_ref(), - b"\x03" + expected ); assert!(app.event_rx.try_recv().is_err()); } diff --git a/src/app/input/copy_mode.rs b/src/app/input/copy_mode.rs index 6c967133..b744d2ce 100644 --- a/src/app/input/copy_mode.rs +++ b/src/app/input/copy_mode.rs @@ -16,7 +16,7 @@ impl App { return; } self.state.update_dismissed = true; - if self.state.is_prefix_key(key) { + if self.state.is_prefix_key(&key) { self.state.mode = Mode::Prefix; return; } @@ -80,7 +80,7 @@ impl AppState { terminal_runtimes: &TerminalRuntimeRegistry, key: TerminalKey, ) { - if self.handle_copy_mode_search_prompt_key(terminal_runtimes, key) { + if self.handle_copy_mode_search_prompt_key(terminal_runtimes, key.clone()) { return; } match key.code { 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) + ); + } +} diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs index 40bc82fb..f22c7994 100644 --- a/src/app/input/mod.rs +++ b/src/app/input/mod.rs @@ -38,6 +38,7 @@ fn modified_url_click_modifier_matches_terminal_mouse_reporting() { mod clipboard; mod copy_mode; +mod lease; mod modal; mod mouse; mod navigate; @@ -48,6 +49,7 @@ mod sidebar; mod terminal; pub(crate) use self::{ + lease::{ConsumedInputLease, ForwardedInputLease, InputLeaseKey, InputLeaseTable, RepeatPlan}, modal::{ handle_global_menu_key, handle_keybind_help_key, handle_navigator_key, insert_keybind_help_query_text, insert_navigator_search_text, insert_rename_input_text, @@ -121,6 +123,66 @@ impl App { None } + pub(crate) fn handle_text_commit_headless(&mut self, text: &str) { + if text.is_empty() { + return; + } + if self.state.popup_pane.is_some() { + if let Some(runtime) = self.popup_runtime() { + let _ = runtime.try_send_bytes(Bytes::copy_from_slice(text.as_bytes())); + } else { + self.close_popup_pane(); + } + return; + } + if self.state.mode != Mode::Terminal { + self.paste_into_active_text_input(text); + return; + } + + self.state.clear_selection(); + self.selection_autoscroll_deadline = None; + self.state.update_dismissed = true; + if let Some(ws_idx) = self.state.active { + if let Some(runtime) = self + .state + .focused_runtime_in_workspace(&self.terminal_runtimes, ws_idx) + { + let _ = runtime.try_send_bytes(Bytes::copy_from_slice(text.as_bytes())); + } + } + } + + pub(super) async fn handle_text_commit(&mut self, text: String) { + if text.is_empty() { + return; + } + if self.state.popup_pane.is_some() { + if let Some(runtime) = self.popup_runtime() { + let _ = runtime.send_bytes(Bytes::from(text)).await; + } else { + self.close_popup_pane(); + } + return; + } + if self.state.mode != Mode::Terminal { + self.paste_into_active_text_input(&text); + return; + } + + self.state.clear_selection(); + self.selection_autoscroll_deadline = None; + self.state.update_dismissed = true; + if let Some(ws_idx) = self.state.active { + if let Some(runtime) = self + .state + .focused_runtime_in_workspace(&self.terminal_runtimes, ws_idx) + { + let _ = runtime.send_bytes(Bytes::from(text)).await; + } + } + } + pub(super) async fn handle_paste(&mut self, text: String) { if self.state.popup_pane.is_some() { if let Some(runtime) = self.popup_runtime() { diff --git a/src/app/input/modal.rs b/src/app/input/modal.rs index d8a14602..0a5c13e5 100644 --- a/src/app/input/modal.rs +++ b/src/app/input/modal.rs @@ -309,7 +309,7 @@ pub(super) fn keybind_help_back(state: &mut AppState) { pub(crate) fn handle_keybind_help_key(state: &mut AppState, key: TerminalKey) { if state.keybind_help.search_focused { - let text_char = keybind_help_text_char(key); + let text_char = keybind_help_text_char(key.clone()); match key.code { KeyCode::Up => state.scroll_keybind_help(-1), KeyCode::Down => state.scroll_keybind_help(1), @@ -343,13 +343,13 @@ pub(crate) fn handle_keybind_help_key(state: &mut AppState, key: TerminalKey) { KeyCode::PageDown => state.scroll_keybind_help(8), KeyCode::Home => state.keybind_help.scroll = 0, KeyCode::End => state.keybind_help.scroll = state.keybind_help_max_scroll(), - _ if keybind_help_text_char(key) == Some('/') => { + _ if keybind_help_text_char(key.clone()) == Some('/') => { state.keybind_help.search_focused = true; state.keybind_help.scroll = 0; } KeyCode::Esc => keybind_help_back(state), KeyCode::Enter => leave_modal(state), - _ if keybind_help_text_char(key) == Some('?') => leave_modal(state), + _ if keybind_help_text_char(key.clone()) == Some('?') => leave_modal(state), _ => {} } } @@ -712,8 +712,8 @@ pub(crate) fn handle_resize_key(state: &mut AppState, raw_key: TerminalKey) { let key = raw_key.as_key_event(); if key.code == KeyCode::Esc || key.code == KeyCode::Enter - || state.keybinds.resize_mode.matches_prefix_key(raw_key) - || state.keybinds.resize_mode.matches_direct_key(raw_key) + || state.keybinds.resize_mode.matches_prefix_key(&raw_key) + || state.keybinds.resize_mode.matches_direct_key(&raw_key) { if state.active.is_some() { state.mode = Mode::Terminal; @@ -1128,8 +1128,8 @@ impl App { let key = raw_key.as_key_event(); if key.code == KeyCode::Esc || key.code == KeyCode::Enter - || self.state.keybinds.resize_mode.matches_prefix_key(raw_key) - || self.state.keybinds.resize_mode.matches_direct_key(raw_key) + || self.state.keybinds.resize_mode.matches_prefix_key(&raw_key) + || self.state.keybinds.resize_mode.matches_direct_key(&raw_key) { self.state.mode = if self.state.active.is_some() { Mode::Terminal diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs index 93dfe77d..6d0f7f22 100644 --- a/src/app/input/navigate.rs +++ b/src/app/input/navigate.rs @@ -31,14 +31,14 @@ pub(crate) fn terminal_direct_navigation_action( pub(crate) fn terminal_direct_non_indexed_navigation_action( state: &AppState, - key: TerminalKey, + key: &TerminalKey, ) -> Option<NavigateAction> { non_indexed_action_for_key(state, key, BindingDispatch::Direct) } pub(crate) fn terminal_direct_indexed_navigation_action( state: &AppState, - key: TerminalKey, + key: &TerminalKey, ) -> Option<NavigateAction> { indexed_navigation_action(state, key, BindingDispatch::Direct) } @@ -65,7 +65,7 @@ impl App { return; } - if self.state.is_prefix_key(raw_key) { + if self.state.is_prefix_key(&raw_key) { if self.state.copy_mode_pane_is_focused() { self.state.cancel_copy_mode(&self.terminal_runtimes); } @@ -81,20 +81,20 @@ impl App { } if let Some(action) = - non_indexed_action_for_key(&self.state, raw_key, BindingDispatch::Prefix) + non_indexed_action_for_key(&self.state, &raw_key, BindingDispatch::Prefix) { self.execute_prefix_key_action(action); return; } - if let Some(binding) = command_for_key(&self.state, raw_key, BindingDispatch::Prefix) { + if let Some(binding) = command_for_key(&self.state, &raw_key, BindingDispatch::Prefix) { self.cancel_copy_mode_if_active(); self.launch_custom_command(binding, ActionContext::Prefix); return; } if let Some(action) = - indexed_navigation_action(&self.state, raw_key, BindingDispatch::Prefix) + indexed_navigation_action(&self.state, &raw_key, BindingDispatch::Prefix) { self.execute_prefix_key_action(action); return; @@ -128,7 +128,7 @@ impl App { let key = raw_key.as_key_event(); self.state.update_dismissed = true; - if key.code == KeyCode::Esc || self.state.is_prefix_key(raw_key) { + if key.code == KeyCode::Esc || self.state.is_prefix_key(&raw_key) { leave_navigate_mode(&mut self.state); return; } @@ -138,7 +138,7 @@ impl App { .keybinds .navigate .workspace_up - .matches_direct_key(raw_key) + .matches_direct_key(&raw_key) { self.state.move_selected_workspace_by_visible_delta(-1); return; @@ -148,18 +148,18 @@ impl App { .keybinds .navigate .workspace_down - .matches_direct_key(raw_key) + .matches_direct_key(&raw_key) { self.state.move_selected_workspace_by_visible_delta(1); return; } - if let Some(action) = navigate_reserved_action_for_key(&self.state, raw_key) { + if let Some(action) = navigate_reserved_action_for_key(&self.state, &raw_key) { self.execute_tui_navigate_action(action, ActionContext::Navigate); return; } - if let Some(action) = navigate_mode_non_indexed_action_for_key(&self.state, raw_key) { + if let Some(action) = navigate_mode_non_indexed_action_for_key(&self.state, &raw_key) { if action == NavigateAction::EditScrollback { self.launch_focused_scrollback_editor(); } else { @@ -169,12 +169,12 @@ impl App { return; } - if let Some(binding) = command_for_key(&self.state, raw_key, BindingDispatch::Prefix) { + if let Some(binding) = command_for_key(&self.state, &raw_key, BindingDispatch::Prefix) { self.launch_custom_command(binding, ActionContext::Navigate); return; } - if let Some(action) = navigate_mode_indexed_action_for_key(&self.state, raw_key) { + if let Some(action) = navigate_mode_indexed_action_for_key(&self.state, &raw_key) { self.execute_tui_navigate_action(action, ActionContext::Navigate); self.selection_autoscroll_deadline = None; } @@ -773,7 +773,7 @@ impl App { return false; }; - let bytes = rt.encode_terminal_key(key); + let bytes = rt.encode_terminal_key(key.clone()); if bytes.is_empty() || rt.try_send_bytes(Bytes::from(bytes)).is_err() { return false; } @@ -1136,7 +1136,7 @@ pub(crate) enum BindingDispatch { pub(crate) fn command_for_key( state: &AppState, - key: TerminalKey, + key: &TerminalKey, dispatch: BindingDispatch, ) -> Option<crate::config::CustomCommandKeybind> { state @@ -1150,7 +1150,7 @@ pub(crate) fn command_for_key( .cloned() } -fn unmodified_digit_for_key(key: TerminalKey) -> Option<char> { +fn unmodified_digit_for_key(key: &TerminalKey) -> Option<char> { ('1'..='9').find(|digit| { crate::config::terminal_key_matches_combo( key, @@ -1164,7 +1164,7 @@ fn unmodified_digit_for_key(key: TerminalKey) -> Option<char> { #[cfg(test)] pub(super) fn handle_navigate_reserved_key(state: &mut AppState, key: TerminalKey) -> bool { - if let Some(c) = unmodified_digit_for_key(key) { + if let Some(c) = unmodified_digit_for_key(&key) { let idx = (c as usize) - ('1' as usize); if let Some(ws_idx) = state.workspace_at_visible_position(idx) { state.switch_workspace(ws_idx); @@ -1203,7 +1203,12 @@ pub(super) fn handle_navigate_reserved_key(state: &mut AppState, key: TerminalKe } } - if state.keybinds.navigate.workspace_up.matches_direct_key(key) { + if state + .keybinds + .navigate + .workspace_up + .matches_direct_key(&key) + { state.move_selected_workspace_by_visible_delta(-1); return true; } @@ -1211,24 +1216,24 @@ pub(super) fn handle_navigate_reserved_key(state: &mut AppState, key: TerminalKe .keybinds .navigate .workspace_down - .matches_direct_key(key) + .matches_direct_key(&key) { state.move_selected_workspace_by_visible_delta(1); return true; } - if state.keybinds.navigate.pane_left.matches_direct_key(key) { + if state.keybinds.navigate.pane_left.matches_direct_key(&key) { state.navigate_pane(NavDirection::Left); return true; } - if state.keybinds.navigate.pane_down.matches_direct_key(key) { + if state.keybinds.navigate.pane_down.matches_direct_key(&key) { state.navigate_pane(NavDirection::Down); return true; } - if state.keybinds.navigate.pane_up.matches_direct_key(key) { + if state.keybinds.navigate.pane_up.matches_direct_key(&key) { state.navigate_pane(NavDirection::Up); return true; } - if state.keybinds.navigate.pane_right.matches_direct_key(key) { + if state.keybinds.navigate.pane_right.matches_direct_key(&key) { state.navigate_pane(NavDirection::Right); return true; } @@ -1236,7 +1241,7 @@ pub(super) fn handle_navigate_reserved_key(state: &mut AppState, key: TerminalKe false } -fn navigate_reserved_action_for_key(state: &AppState, key: TerminalKey) -> Option<NavigateAction> { +fn navigate_reserved_action_for_key(state: &AppState, key: &TerminalKey) -> Option<NavigateAction> { if let Some(c) = unmodified_digit_for_key(key) { return Some(NavigateAction::SwitchWorkspace( (c as usize) - ('1' as usize), @@ -1303,12 +1308,12 @@ pub(crate) fn handle_navigate_key(state: &mut AppState, key: KeyEvent) { state.update_dismissed = true; let terminal_key = TerminalKey::from(key); - if state.is_prefix_key(terminal_key) || key.code == KeyCode::Esc { + if state.is_prefix_key(&terminal_key) || key.code == KeyCode::Esc { leave_navigate_mode(state); return; } - if handle_navigate_reserved_key(state, terminal_key) { + if handle_navigate_reserved_key(state, terminal_key.clone()) { return; } @@ -1396,7 +1401,7 @@ fn copy_mode_survives_prefix_action(action: NavigateAction) -> bool { fn indexed_navigation_action( state: &AppState, - key: TerminalKey, + key: &TerminalKey, dispatch: BindingDispatch, ) -> Option<NavigateAction> { let kb = &state.keybinds; @@ -1440,7 +1445,7 @@ fn indexed_navigation_action( fn action_matches( bindings: &crate::config::ActionKeybinds, - key: TerminalKey, + key: &TerminalKey, dispatch: BindingDispatch, ) -> bool { match dispatch { @@ -1455,13 +1460,13 @@ fn action_for_key( key: TerminalKey, dispatch: BindingDispatch, ) -> Option<NavigateAction> { - non_indexed_action_for_key(state, key, dispatch) - .or_else(|| indexed_navigation_action(state, key, dispatch)) + non_indexed_action_for_key(state, &key, dispatch) + .or_else(|| indexed_navigation_action(state, &key, dispatch)) } fn non_indexed_action_for_key( state: &AppState, - key: TerminalKey, + key: &TerminalKey, dispatch: BindingDispatch, ) -> Option<NavigateAction> { let kb = &state.keybinds; @@ -1536,7 +1541,7 @@ fn navigate_mode_action_for_key(state: &AppState, key: TerminalKey) -> Option<Na fn navigate_mode_non_indexed_action_for_key( state: &AppState, - key: TerminalKey, + key: &TerminalKey, ) -> Option<NavigateAction> { let action = non_indexed_action_for_key(state, key, BindingDispatch::Prefix)?; if matches!( @@ -1553,7 +1558,7 @@ fn navigate_mode_non_indexed_action_for_key( fn navigate_mode_indexed_action_for_key( state: &AppState, - key: TerminalKey, + key: &TerminalKey, ) -> Option<NavigateAction> { indexed_navigation_action(state, key, BindingDispatch::Prefix) } @@ -2719,9 +2724,9 @@ command = "echo literal" state.keybinds = config.keybinds(); let key = TerminalKey::new(KeyCode::Char('!'), KeyModifiers::empty()); - assert!(command_for_key(&state, key, BindingDispatch::Prefix).is_some()); + assert!(command_for_key(&state, &key, BindingDispatch::Prefix).is_some()); assert_eq!( - indexed_navigation_action(&state, key, BindingDispatch::Prefix), + indexed_navigation_action(&state, &key, BindingDispatch::Prefix), Some(NavigateAction::SwitchWorkspace(0)) ); } diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs index 3e99d0fc..f0e10d9c 100644 --- a/src/app/input/terminal.rs +++ b/src/app/input/terminal.rs @@ -41,7 +41,7 @@ impl App { source_id: InputSourceId, key: TerminalKey, ) -> Option<TerminalInputTarget> { - match self.prepare_popup_key_forward(key) { + match self.prepare_popup_key_forward(key.clone()) { PreparedPopupInput::NotOpen => {} PreparedPopupInput::Consumed => return None, PreparedPopupInput::Bytes { target, bytes } => { @@ -66,7 +66,7 @@ impl App { key: TerminalKey, ) -> Option<PreparedPaneInput> { let key_event = key.as_key_event(); - if self.try_copy_retained_selection(source_id, key) { + if self.try_copy_retained_selection(source_id, key.clone()) { return None; } @@ -74,7 +74,8 @@ impl App { self.selection_autoscroll_deadline = None; self.state.update_dismissed = true; - if let Some(action) = super::terminal_direct_non_indexed_navigation_action(&self.state, key) + if let Some(action) = + super::terminal_direct_non_indexed_navigation_action(&self.state, &key) { debug!( code = ?key_event.code, @@ -93,7 +94,7 @@ impl App { if let Some(binding) = super::navigate::command_for_key( &self.state, - key, + &key, super::navigate::BindingDispatch::Direct, ) { debug!( @@ -107,7 +108,7 @@ impl App { return None; } - if let Some(action) = super::terminal_direct_indexed_navigation_action(&self.state, key) { + if let Some(action) = super::terminal_direct_indexed_navigation_action(&self.state, &key) { debug!( code = ?key_event.code, modifiers = ?key_event.modifiers, @@ -119,7 +120,7 @@ impl App { return None; } - if self.state.is_prefix_key(key) { + if self.state.is_prefix_key(&key) { self.state.mode = Mode::Prefix; return None; } @@ -189,7 +190,7 @@ impl App { rt.scroll_reset(); let protocol = rt.keyboard_protocol(); - let bytes = rt.encode_terminal_key(key); + let bytes = rt.encode_terminal_key(key.clone()); if matches!(key_event.code, KeyCode::Esc) || key_event @@ -251,7 +252,7 @@ impl App { return PreparedPopupInput::Consumed; }; rt.scroll_reset(); - let bytes = rt.encode_terminal_key(key); + let bytes = rt.encode_terminal_key(key.clone()); self.state.mode = Mode::Terminal; if bytes.is_empty() { PreparedPopupInput::Consumed @@ -316,7 +317,7 @@ impl App { let Some(runtime) = self.terminal_input_runtime(target) else { return false; }; - let bytes = runtime.encode_terminal_key(key); + let bytes = runtime.encode_terminal_key(key.clone()); bytes.is_empty() || runtime.try_send_bytes(Bytes::from(bytes)).is_ok() } @@ -328,25 +329,24 @@ impl App { let Some(runtime) = self.terminal_input_runtime(target) else { return false; }; - let bytes = runtime.encode_terminal_key(key); + let bytes = runtime.encode_terminal_key(key.clone()); bytes.is_empty() || runtime.send_bytes(Bytes::from(bytes)).await.is_ok() } fn take_pressed_keys_for_source( &mut self, source_id: crate::app::InputSourceId, - ) -> Vec<crate::app::PressedTerminalKey> { - let pressed = self - .pressed_terminal_keys - .iter() - .filter(|((id, _), _)| *id == source_id) - .map(|(_, pressed)| pressed.clone()) - .collect(); - self.pressed_terminal_keys - .retain(|(id, _), _| *id != source_id); - self.suppressed_repeat_keys - .retain(|(id, _)| *id != source_id); - pressed + ) -> Vec<super::ForwardedInputLease> { + self.input_leases.remove_source(source_id) + } + + pub(crate) fn release_input_target_headless(&mut self, target: &TerminalInputTarget) { + for pressed in self.input_leases.remove_target(target) { + let release = pressed + .key + .with_kind(crossterm::event::KeyEventKind::Release); + let _ = self.forward_terminal_key_to_target_headless(&pressed.target, release); + } } pub(crate) fn release_input_source_headless(&mut self, source_id: crate::app::InputSourceId) { @@ -375,7 +375,7 @@ impl App { &mut self, key: TerminalKey, ) -> Option<TerminalInputTarget> { - match self.prepare_popup_key_forward(key) { + match self.prepare_popup_key_forward(key.clone()) { PreparedPopupInput::NotOpen => {} PreparedPopupInput::Consumed => return None, PreparedPopupInput::Bytes { target, bytes } => { @@ -1579,65 +1579,155 @@ mod tests { assert!(rx.try_recv().is_err()); } - #[tokio::test] - async fn page_up_scrolls_plain_shell_pane() { + fn app_with_plain_scrollback( + line_count: usize, + ) -> (App, crate::layout::PaneId, crate::layout::PaneInfo) { let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); - let info = pane_infos[0].clone(); - ws.tabs[0].runtimes.insert( + let mut workspace = Workspace::test_new("test"); + let pane_id = workspace.tabs[0].root_pane; + let pane_infos = workspace.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); + let pane_info = pane_infos[0].clone(); + workspace.tabs[0].runtimes.insert( pane_id, crate::terminal::TerminalRuntime::test_with_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, + pane_info.inner_rect.width, + pane_info.inner_rect.height, 16 * 1024, - &numbered_lines_bytes(64), + &numbered_lines_bytes(line_count), ), ); - - app.state.workspaces = vec![ws]; + app.state.workspaces = vec![workspace]; app.state.active = Some(0); app.state.selected = 0; app.state.mode = Mode::Terminal; app.state.view.pane_infos = pane_infos; + (app, pane_id, pane_info) + } - let start_metrics = app - .state + fn pane_scroll_offset(app: &App, pane_id: crate::layout::PaneId) -> usize { + app.state .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) .and_then(crate::terminal::TerminalRuntime::scroll_metrics) - .expect("initial scroll metrics"); - assert_eq!(start_metrics.offset_from_bottom, 0); + .expect("pane scroll metrics") + .offset_from_bottom + } + + fn physical_page_up(repeat_count: u16) -> TerminalKey { + TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty()).with_windows_record( + crate::input::WindowsKeyRecord { + key_down: true, + repeat_count, + virtual_key_code: 0x21, + virtual_scan_code: 0x49, + unicode: 0, + control_key_state: 0x0100, + }, + ) + } + + #[tokio::test] + async fn page_up_scrolls_plain_shell_pane() { + let (mut app, pane_id, pane_info) = app_with_plain_scrollback(64); + assert_eq!(pane_scroll_offset(&app, pane_id), 0); app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); - let end_metrics = app - .state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) - .and_then(crate::terminal::TerminalRuntime::scroll_metrics) - .expect("scroll metrics after PageUp"); assert_eq!( - end_metrics.offset_from_bottom, - info.inner_rect.height as usize + pane_scroll_offset(&app, pane_id), + pane_info.inner_rect.height as usize ); } #[tokio::test] - async fn page_down_returns_to_bottom_after_page_up() { + async fn consumed_page_up_preserves_separate_and_grouped_repeats() { + let (mut app, pane_id, pane_info) = app_with_plain_scrollback(256); + let page_up = physical_page_up(1); + app.route_client_events( + vec![ + crate::raw_input::RawInputEvent::Key(page_up.clone()), + crate::raw_input::RawInputEvent::Key( + page_up.clone().with_kind(KeyEventKind::Repeat), + ), + crate::raw_input::RawInputEvent::Key( + page_up.clone().with_kind(KeyEventKind::Release), + ), + ], + false, + ); + + assert_eq!( + pane_scroll_offset(&app, pane_id), + pane_info.inner_rect.height as usize * 2 + ); + + app.route_client_events( + vec![crate::raw_input::RawInputEvent::Key(physical_page_up(3))], + false, + ); + + assert_eq!( + pane_scroll_offset(&app, pane_id), + pane_info.inner_rect.height as usize * 5 + ); + } + + #[tokio::test] + async fn runtime_consumed_page_up_preserves_separate_and_grouped_repeats() { + let (mut app, pane_id, pane_info) = app_with_plain_scrollback(256); + let page_up = physical_page_up(1); + for key in [ + page_up.clone(), + page_up.clone().with_kind(KeyEventKind::Repeat), + page_up.clone().with_kind(KeyEventKind::Release), + ] { + app.handle_raw_input_event(crate::raw_input::RawInputEvent::Key(key)) + .await; + } + + assert_eq!( + pane_scroll_offset(&app, pane_id), + pane_info.inner_rect.height as usize * 2 + ); + + app.handle_raw_input_event(crate::raw_input::RawInputEvent::Key(physical_page_up(3))) + .await; + + assert_eq!( + pane_scroll_offset(&app, pane_id), + pane_info.inner_rect.height as usize * 5 + ); + } + + #[tokio::test] + async fn consumed_repeat_that_becomes_forwarded_acquires_pane_ownership() { let mut app = app_for_mouse_test(); let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; + let first_pane = ws.tabs[0].root_pane; + let second_pane = ws.test_split(ratatui::layout::Direction::Horizontal); let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); - let info = pane_infos[0].clone(); + let first_info = pane_infos + .iter() + .find(|info| info.id == first_pane) + .expect("first pane info"); ws.tabs[0].runtimes.insert( - pane_id, + first_pane, crate::terminal::TerminalRuntime::test_with_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, + first_info.inner_rect.width, + first_info.inner_rect.height, 16 * 1024, - &numbered_lines_bytes(64), + &numbered_lines_bytes(128), ), ); + let (second_runtime, mut second_rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 80, + 24, + 0, + b"\x1b[?1h\x1b[>15u", + 3, + ); + ws.tabs[0].runtimes.insert(second_pane, second_runtime); + ws.tabs[0].layout.focus_pane(first_pane); app.state.workspaces = vec![ws]; app.state.active = Some(0); @@ -1645,107 +1735,153 @@ mod tests { app.state.mode = Mode::Terminal; app.state.view.pane_infos = pane_infos; - app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); - let after_up = app - .state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) - .and_then(crate::terminal::TerminalRuntime::scroll_metrics) - .expect("scroll metrics after PageUp"); - assert!(after_up.offset_from_bottom > 0); + let page_up = physical_page_up(1); + app.route_client_events( + vec![crate::raw_input::RawInputEvent::Key(page_up.clone())], + false, + ); + assert!(app.state.focus_pane_in_workspace(0, second_pane)); + app.route_client_events( + vec![crate::raw_input::RawInputEvent::Key( + page_up.clone().with_kind(KeyEventKind::Repeat), + )], + false, + ); + assert!(app.state.focus_pane_in_workspace(0, first_pane)); + app.route_client_events( + vec![ + crate::raw_input::RawInputEvent::Key( + page_up.clone().with_kind(KeyEventKind::Repeat), + ), + crate::raw_input::RawInputEvent::Key(page_up.with_kind(KeyEventKind::Release)), + ], + false, + ); - app.handle_terminal_key_headless(TerminalKey::new( - KeyCode::PageDown, - KeyModifiers::empty(), - )); - let after_down = app - .state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) - .and_then(crate::terminal::TerminalRuntime::scroll_metrics) - .expect("scroll metrics after PageDown"); - assert_eq!(after_down.offset_from_bottom, 0); + let first_repeat = second_rx.try_recv().expect("first forwarded repeat"); + let second_repeat = second_rx.try_recv().expect("owned repeat"); + let release = second_rx.try_recv().expect("owned release"); + assert_eq!(first_repeat, second_repeat); + assert_ne!(second_repeat, release); + assert!(second_rx.try_recv().is_err()); + assert!(app.input_leases.is_empty()); } #[tokio::test] - async fn page_up_release_does_not_scroll_plain_shell_pane_again() { + async fn missing_popup_runtime_suppresses_grouped_and_later_repeats() { let mut app = app_for_mouse_test(); let mut ws = Workspace::test_new("test"); let pane_id = ws.tabs[0].root_pane; - let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); - let info = pane_infos[0].clone(); - ws.tabs[0].runtimes.insert( - pane_id, - crate::terminal::TerminalRuntime::test_with_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, - 16 * 1024, - &numbered_lines_bytes(64), - ), - ); - + let (runtime, mut input_rx) = crate::terminal::TerminalRuntime::test_with_channel(80, 24); + ws.tabs[0].runtimes.insert(pane_id, runtime); app.state.workspaces = vec![ws]; app.state.active = Some(0); app.state.selected = 0; app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; - app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); - let after_press = app + let (popup_runtime, _popup_rx) = + crate::terminal::TerminalRuntime::test_with_channel(40, 12); + app.install_test_popup_runtime(popup_runtime); + let popup_terminal_id = app .state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) - .and_then(crate::terminal::TerminalRuntime::scroll_metrics) - .expect("scroll metrics after PageUp press"); - assert_eq!( - after_press.offset_from_bottom, - info.inner_rect.height as usize + .popup_pane + .as_ref() + .expect("popup installed") + .terminal_id + .clone(); + app.terminal_runtimes.remove(&popup_terminal_id); + + let key = TerminalKey::new(KeyCode::Char('x'), KeyModifiers::empty()).with_repeat_count(3); + app.route_client_events( + vec![ + crate::raw_input::RawInputEvent::Key(key.clone()), + crate::raw_input::RawInputEvent::Key( + key.clone() + .with_kind(KeyEventKind::Repeat) + .with_repeat_count(1), + ), + crate::raw_input::RawInputEvent::Key(key.with_kind(KeyEventKind::Release)), + ], + false, ); + assert!(app.state.popup_pane.is_none()); + assert!(input_rx.try_recv().is_err()); + assert!(app.input_leases.is_empty()); + } + + #[tokio::test] + async fn consumed_repeat_stays_suppressed_after_context_returns() { + let (mut app, pane_id, _pane_info) = app_with_plain_scrollback(128); + let page_up = physical_page_up(1); + app.route_client_events( + vec![crate::raw_input::RawInputEvent::Key(page_up.clone())], + false, + ); + let after_press = pane_scroll_offset(&app, pane_id); + + let (popup_runtime, mut popup_rx) = + crate::terminal::TerminalRuntime::test_with_channel(40, 12); + app.install_test_popup_runtime(popup_runtime); + app.route_client_events( + vec![crate::raw_input::RawInputEvent::Key( + page_up.clone().with_kind(KeyEventKind::Repeat), + )], + false, + ); + app.close_popup_pane(); + app.route_client_events( + vec![ + crate::raw_input::RawInputEvent::Key( + page_up.clone().with_kind(KeyEventKind::Repeat), + ), + crate::raw_input::RawInputEvent::Key(page_up.with_kind(KeyEventKind::Release)), + ], + false, + ); + + assert_eq!(pane_scroll_offset(&app, pane_id), after_press); + assert!(popup_rx.try_recv().is_err()); + assert!(app.input_leases.is_empty()); + } + + #[tokio::test] + async fn page_down_returns_to_bottom_after_page_up() { + let (mut app, pane_id, _pane_info) = app_with_plain_scrollback(64); + + app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); + assert!(pane_scroll_offset(&app, pane_id) > 0); + + app.handle_terminal_key_headless(TerminalKey::new( + KeyCode::PageDown, + KeyModifiers::empty(), + )); + assert_eq!(pane_scroll_offset(&app, pane_id), 0); + } + + #[tokio::test] + async fn page_up_release_does_not_scroll_plain_shell_pane_again() { + let (mut app, pane_id, pane_info) = app_with_plain_scrollback(64); + + app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); + let after_press = pane_scroll_offset(&app, pane_id); + assert_eq!(after_press, pane_info.inner_rect.height as usize); + app.handle_terminal_key_headless( TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty()) .with_kind(KeyEventKind::Release), ); - let after_release = app - .state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) - .and_then(crate::terminal::TerminalRuntime::scroll_metrics) - .expect("scroll metrics after PageUp release"); - assert_eq!( - after_release.offset_from_bottom, - after_press.offset_from_bottom - ); + assert_eq!(pane_scroll_offset(&app, pane_id), after_press); } #[tokio::test] async fn modified_page_up_does_not_host_scroll_plain_shell_pane() { - let mut app = app_for_mouse_test(); - let mut ws = Workspace::test_new("test"); - let pane_id = ws.tabs[0].root_pane; - let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18)); - let info = pane_infos[0].clone(); - ws.tabs[0].runtimes.insert( - pane_id, - crate::terminal::TerminalRuntime::test_with_scrollback_bytes( - info.inner_rect.width, - info.inner_rect.height, - 16 * 1024, - &numbered_lines_bytes(64), - ), - ); - - app.state.workspaces = vec![ws]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - app.state.view.pane_infos = pane_infos; + let (mut app, pane_id, _pane_info) = app_with_plain_scrollback(64); app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::CONTROL)); - let metrics = app - .state - .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) - .and_then(crate::terminal::TerminalRuntime::scroll_metrics) - .expect("scroll metrics after modified PageUp"); - assert_eq!(metrics.offset_from_bottom, 0); + assert_eq!(pane_scroll_offset(&app, pane_id), 0); } #[tokio::test] |
