aboutsummaryrefslogtreecommitdiff
path: root/src/app/input
diff options
context:
space:
mode:
Diffstat (limited to 'src/app/input')
-rw-r--r--src/app/input/clipboard.rs45
-rw-r--r--src/app/input/copy_mode.rs4
-rw-r--r--src/app/input/lease.rs375
-rw-r--r--src/app/input/mod.rs62
-rw-r--r--src/app/input/modal.rs14
-rw-r--r--src/app/input/navigate.rs75
-rw-r--r--src/app/input/terminal.rs390
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]