diff --git a/src/app/input/lease.rs b/src/app/input/lease.rs index 6724b80429..53c4d054c4 100644 --- a/src/app/input/lease.rs +++ b/src/app/input/lease.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; -use crate::app::{InputSourceId, TerminalInputContext, TerminalInputTarget}; +use crate::app::{InputContext, InputSourceId, TerminalInputTarget}; use crate::input::{KeyIdentity, TerminalKey}; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -26,7 +26,7 @@ pub(crate) struct ForwardedInputLease { #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum ConsumedInputLease { - ReprocessRepeats(TerminalInputContext), + ReprocessRepeats(InputContext), SuppressRepeats, } @@ -39,7 +39,7 @@ pub(crate) enum InputLease { pub(crate) enum RepeatPlan { Forwarded(TerminalInputTarget), Reprocess { - context: TerminalInputContext, + context: InputContext, repetitions: u16, tracked: bool, }, @@ -76,8 +76,8 @@ impl InputLeaseTable { &mut self, lease_key: InputLeaseKey, key: &TerminalKey, - initial_context: Option<&TerminalInputContext>, - resulting_context: Option<&TerminalInputContext>, + initial_context: Option<&InputContext>, + resulting_context: Option<&InputContext>, target: Option, ) -> RepeatPlan { if key.generated_text.is_some() && !key.has_physical_identity() { @@ -114,7 +114,7 @@ impl InputLeaseTable { &mut self, lease_key: InputLeaseKey, key: &TerminalKey, - current_context: Option<&TerminalInputContext>, + current_context: Option<&InputContext>, ) -> RepeatPlan { match self.leases.get(&lease_key) { Some(InputLease::Forwarded(lease)) => { @@ -151,8 +151,8 @@ impl InputLeaseTable { pub(crate) fn reprocess_allowed( &mut self, lease_key: InputLeaseKey, - expected_context: &TerminalInputContext, - current_context: Option<&TerminalInputContext>, + expected_context: &InputContext, + current_context: Option<&InputContext>, tracked: bool, ) -> bool { let allowed = current_context == Some(expected_context); @@ -353,7 +353,7 @@ mod tests { fn physical_generated_text_keeps_native_repeat_lifecycle() { let key = physical_generated_slash(3); let lease_key = InputLeaseKey::new(7, &key); - let context = TerminalInputContext::Pane; + let context = InputContext::Pane; let forwarded_target = target(); let mut leases = InputLeaseTable::default(); @@ -387,13 +387,13 @@ mod tests { fn consumed_grouped_physical_generated_text_reprocesses_repeats() { let key = physical_generated_slash(3); let lease_key = InputLeaseKey::new(7, &key); - let context = TerminalInputContext::Pane; + let context = InputContext::Pane; let mut leases = InputLeaseTable::default(); assert!(matches!( leases.complete_press(lease_key, &key, Some(&context), Some(&context), None), RepeatPlan::Reprocess { - context: TerminalInputContext::Pane, + context: InputContext::Pane, repetitions: 2, tracked: true, } @@ -406,7 +406,7 @@ mod tests { .with_generated_text(Some("/".to_owned())) .with_repeat_count(3); let lease_key = InputLeaseKey::new(7, &key); - let context = TerminalInputContext::Pane; + let context = InputContext::Pane; let mut leases = InputLeaseTable::default(); assert!(matches!( @@ -426,7 +426,7 @@ mod tests { 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 context = InputContext::Pane; let mut leases = InputLeaseTable::default(); leases.insert_consumed(lease_key, ConsumedInputLease::SuppressRepeats); @@ -436,7 +436,7 @@ mod tests { assert!(matches!( plan, RepeatPlan::Reprocess { - context: TerminalInputContext::Pane, + context: InputContext::Pane, repetitions: 2, tracked: true, } diff --git a/src/app/mod.rs b/src/app/mod.rs index c28f62da1e..36664a98c0 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -220,9 +220,18 @@ pub(crate) struct TerminalInputTarget { } #[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) enum TerminalInputContext { +pub(crate) enum InputContext { Pane, Popup(crate::terminal::TerminalId), + NonTerminal(NonTerminalInputContext), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum NonTerminalInputContext { + Copy, + Navigator, + Navigate, + KeybindHelp, } pub(crate) type InputSourceId = u64; @@ -1666,11 +1675,23 @@ impl App { // --------------------------------------------------------------------------- impl App { - pub(crate) fn terminal_input_context(&self) -> Option { + pub(crate) fn input_context(&self) -> Option { if let Some(popup) = &self.state.popup_pane { - Some(TerminalInputContext::Popup(popup.terminal_id.clone())) + Some(InputContext::Popup(popup.terminal_id.clone())) } else if self.state.mode == Mode::Terminal { - Some(TerminalInputContext::Pane) + Some(InputContext::Pane) + } else if self.state.mode == Mode::Copy { + Some(InputContext::NonTerminal(NonTerminalInputContext::Copy)) + } else if self.state.mode == Mode::Navigator { + Some(InputContext::NonTerminal( + NonTerminalInputContext::Navigator, + )) + } else if self.state.mode == Mode::Navigate { + Some(InputContext::NonTerminal(NonTerminalInputContext::Navigate)) + } else if self.state.mode == Mode::KeybindHelp { + Some(InputContext::NonTerminal( + NonTerminalInputContext::KeybindHelp, + )) } else { None } @@ -1706,7 +1727,7 @@ impl App { } continue; } - let current_context = self.terminal_input_context(); + let current_context = self.input_context(); if !self.input_leases.reprocess_allowed( lease_key, &context, @@ -1715,6 +1736,10 @@ impl App { ) { break; } + if matches!(context, InputContext::NonTerminal(_)) { + self.handle_non_terminal_key_headless(key.clone()); + continue; + } if let Some(target) = self.handle_terminal_key_headless_from(source_id, key.clone()) { @@ -1793,14 +1818,17 @@ impl App { let key = self.input_leases.normalize_press(&lease_key, key); match key.kind { crossterm::event::KeyEventKind::Press => { - let initial_context = self.terminal_input_context(); - let target = if initial_context.is_some() { + let initial_context = self.input_context(); + let target = if matches!( + initial_context, + Some(InputContext::Pane | InputContext::Popup(_)) + ) { self.handle_terminal_key_headless_from(source_id, key.clone()) } else { self.handle_non_terminal_key_headless(key.clone()); None }; - let resulting_context = self.terminal_input_context(); + let resulting_context = self.input_context(); let plan = self.input_leases.complete_press( lease_key, &key, @@ -1811,7 +1839,7 @@ impl App { self.execute_repeat_plan_headless(source_id, lease_key, key, plan); } crossterm::event::KeyEventKind::Repeat => { - let current_context = self.terminal_input_context(); + let current_context = self.input_context(); let plan = self.input_leases.plan_repeat( lease_key, &key, @@ -2093,6 +2121,89 @@ mod tests { assert!(app.input_leases.is_empty()); } + #[tokio::test] + async fn copy_mode_press_moves_cursor_and_forwards_nothing() { + let (mut app, _pane_id, mut rx) = app_with_copy_mode(); + + app.route_client_events( + vec![raw_key( + KeyCode::Down, + KeyModifiers::empty(), + KeyEventKind::Press, + )], + false, + ); + + assert_eq!(app.state.mode, Mode::Copy); + assert_eq!( + app.state.copy_mode.as_ref().expect("copy mode").cursor_row, + 1 + ); + assert!(rx.try_recv().is_err()); + } + + #[tokio::test] + async fn copy_mode_repeat_after_press_moves_cursor_again() { + let (mut app, _pane_id, mut rx) = app_with_copy_mode(); + + app.route_client_events( + vec![ + raw_key(KeyCode::Down, KeyModifiers::empty(), KeyEventKind::Press), + raw_key(KeyCode::Down, KeyModifiers::empty(), KeyEventKind::Repeat), + ], + false, + ); + + assert_eq!(app.state.mode, Mode::Copy); + assert_eq!( + app.state.copy_mode.as_ref().expect("copy mode").cursor_row, + 2, + "repeat should move the copy-mode cursor a second time" + ); + assert!(rx.try_recv().is_err()); + } + + #[tokio::test] + async fn copy_mode_repeat_with_repeat_count_moves_cursor_by_count() { + let (mut app, _pane_id, mut rx) = app_with_copy_mode(); + + app.route_client_events( + vec![ + raw_key(KeyCode::Down, KeyModifiers::empty(), KeyEventKind::Press), + crate::raw_input::RawInputEvent::Key( + crate::input::TerminalKey::new(KeyCode::Down, KeyModifiers::empty()) + .with_kind(KeyEventKind::Repeat) + .with_repeat_count(2), + ), + ], + false, + ); + + assert_eq!( + app.state.copy_mode.as_ref().expect("copy mode").cursor_row, + 3, + "a single repeat_count=2 event should move the cursor two more times" + ); + assert!(rx.try_recv().is_err()); + } + + #[tokio::test] + async fn copy_mode_esc_repeat_does_not_leak_after_exit() { + let (mut app, _pane_id, mut rx) = app_with_copy_mode(); + + app.route_client_events( + vec![ + raw_key(KeyCode::Esc, KeyModifiers::empty(), KeyEventKind::Press), + raw_key(KeyCode::Esc, KeyModifiers::empty(), KeyEventKind::Repeat), + ], + false, + ); + + assert_eq!(app.state.mode, Mode::Terminal); + assert!(app.state.copy_mode.is_none()); + assert!(rx.try_recv().is_err()); + } + fn release_notes_state() -> state::ReleaseNotesState { state::ReleaseNotesState { version: "0.1.0".into(), @@ -2113,6 +2224,41 @@ mod tests { ) } + /// Puts a fresh app into copy mode with a channel-backed pane runtime + /// showing several lines of text, and pins the copy-mode cursor to the + /// top row so vertical cursor movement is unambiguous. + fn app_with_copy_mode() -> ( + App, + crate::layout::PaneId, + tokio::sync::mpsc::Receiver, + ) { + let mut app = test_app(); + 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(0, 0, 20, 5)); + let info = pane_infos[0].clone(); + let (runtime, rx) = TerminalRuntime::test_with_channel_and_scrollback_bytes( + info.inner_rect.width, + info.inner_rect.height, + 0, + b"line-0\r\nline-1\r\nline-2\r\nline-3\r\nline-4\r\n", + 8, + ); + 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.state.enter_copy_mode(&app.terminal_runtimes); + app.state + .copy_mode + .as_mut() + .expect("copy mode entered") + .cursor_row = 0; + (app, pane_id, rx) + } + fn unique_temp_path(name: &str) -> std::path::PathBuf { let stamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -4051,6 +4197,54 @@ mod tests { assert!(next_press_handled); } + #[tokio::test] + async fn monolithic_copy_mode_repeat_after_press_moves_cursor_again() { + let (mut app, _pane_id, mut rx) = app_with_copy_mode(); + + app.handle_raw_input_event(raw_key( + KeyCode::Down, + KeyModifiers::empty(), + KeyEventKind::Press, + )) + .await; + app.handle_raw_input_event(raw_key( + KeyCode::Down, + KeyModifiers::empty(), + KeyEventKind::Repeat, + )) + .await; + + assert_eq!(app.state.mode, Mode::Copy); + assert_eq!( + app.state.copy_mode.as_ref().expect("copy mode").cursor_row, + 2, + "repeat should move the copy-mode cursor a second time" + ); + assert!(rx.try_recv().is_err()); + } + + #[tokio::test] + async fn monolithic_copy_mode_esc_repeat_does_not_leak_after_exit() { + let (mut app, _pane_id, mut rx) = app_with_copy_mode(); + + app.handle_raw_input_event(raw_key( + KeyCode::Esc, + KeyModifiers::empty(), + KeyEventKind::Press, + )) + .await; + app.handle_raw_input_event(raw_key( + KeyCode::Esc, + KeyModifiers::empty(), + KeyEventKind::Repeat, + )) + .await; + + assert_eq!(app.state.mode, Mode::Terminal); + assert!(app.state.copy_mode.is_none()); + assert!(rx.try_recv().is_err()); + } + #[test] fn read_only_api_requests_do_not_force_rerender() { let read_only = crate::api::schema::Request { diff --git a/src/app/runtime.rs b/src/app/runtime.rs index 8367a22350..17dba9ddf7 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -152,7 +152,7 @@ impl App { } continue; } - let current_context = self.terminal_input_context(); + let current_context = self.input_context(); if !self.input_leases.reprocess_allowed( lease_key, &context, @@ -189,9 +189,9 @@ impl App { let key = self.input_leases.normalize_press(&lease_key, key); match key.kind { crossterm::event::KeyEventKind::Press => { - let initial_context = self.terminal_input_context(); + let initial_context = self.input_context(); let target = self.handle_key(key.clone()).await; - let resulting_context = self.terminal_input_context(); + let resulting_context = self.input_context(); let plan = self.input_leases.complete_press( lease_key, &key, @@ -203,7 +203,7 @@ impl App { true } crossterm::event::KeyEventKind::Repeat => { - let current_context = self.terminal_input_context(); + let current_context = self.input_context(); let plan = self.input_leases.plan_repeat( lease_key, &key,