diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index 72d3b0f52a..36d48127d3 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -14,6 +14,7 @@ - Experimental pane graphics now support bounded named layers, acknowledged full-RGBA primary-layer direct file frames on audited local terminals, owned BGRA fallback, exact pixel mouse input, and placement-only resize replay. ### Fixed +- Copy-mode cursor and scroll navigation keys now repeat when held in terminals that report explicit key-repeat events, while confirm, exit, prefix, and other actions remain single-shot. - Fish `Ctrl+Alt` keybindings now work in panes after legacy Alt-prefixed control bytes are decoded with both modifiers. (#2514) - `herdr config check` now reports unknown built-in theme names instead of silently accepting them. (#2452) - macOS `herdr --remote` clients now keep the accepted bridge socket blocking, preventing an immediate disconnect after the protocol handshake. (#2478, thanks @mathijshenquet) diff --git a/src/app/input/copy_mode.rs b/src/app/input/copy_mode.rs index 8bdbfd387a..386f971988 100644 --- a/src/app/input/copy_mode.rs +++ b/src/app/input/copy_mode.rs @@ -27,6 +27,17 @@ impl App { } impl AppState { + pub(crate) fn allows_copy_navigation_repeat(&self, key: &TerminalKey) -> bool { + self.mode == Mode::Copy + && self + .copy_mode + .as_ref() + .is_some_and(|copy_mode| copy_mode.search.prompt.is_none()) + && !self.is_prefix_key(key) + && key.generated_text.is_none() + && is_repeatable_navigation_key(key) + } + pub(crate) fn enter_copy_mode(&mut self, terminal_runtimes: &TerminalRuntimeRegistry) { let Some(ws_idx) = self.active else { return; @@ -964,6 +975,46 @@ fn copy_mode_page_lines(height: u16, half_page: bool) -> usize { } } +fn is_repeatable_navigation_key(key: &TerminalKey) -> bool { + matches!( + key.code, + KeyCode::Left + | KeyCode::Down + | KeyCode::Up + | KeyCode::Right + | KeyCode::PageUp + | KeyCode::PageDown + | KeyCode::Home + | KeyCode::End + ) || matches!( + (key.code, key.modifiers), + (KeyCode::Char('b' | 'f' | 'u' | 'd'), modifiers) + if modifiers.contains(KeyModifiers::CONTROL) + ) || copy_mode_command_char(key.clone()).is_some_and(|ch| { + matches!( + ch, + 'h' | 'j' + | 'k' + | 'l' + | 'g' + | 'G' + | '0' + | '$' + | '^' + | 'n' + | 'N' + | 'w' + | 'b' + | 'e' + | 'W' + | 'B' + | 'E' + | '{' + | '}' + ) + }) +} + fn copy_mode_command_char(key: TerminalKey) -> Option { if !key.modifiers.difference(KeyModifiers::SHIFT).is_empty() { return None; @@ -1175,6 +1226,147 @@ mod tests { ); } + #[test] + fn copy_mode_repeat_allowlist_covers_navigation_but_not_actions() { + for code in [ + KeyCode::Left, + KeyCode::Down, + KeyCode::Up, + KeyCode::Right, + KeyCode::PageUp, + KeyCode::PageDown, + KeyCode::Home, + KeyCode::End, + ] { + assert!(is_repeatable_navigation_key(&TerminalKey::new( + code, + KeyModifiers::empty() + ))); + } + + for ch in [ + 'h', 'j', 'k', 'l', 'g', 'G', '0', '$', '^', 'n', 'N', 'w', 'b', 'e', 'W', 'B', 'E', + '{', '}', + ] { + assert!(is_repeatable_navigation_key(&TerminalKey::new( + KeyCode::Char(ch), + KeyModifiers::empty() + ))); + } + + for ch in ['b', 'f', 'u', 'd'] { + assert!(is_repeatable_navigation_key(&TerminalKey::new( + KeyCode::Char(ch), + KeyModifiers::CONTROL + ))); + } + + for code in [ + KeyCode::Enter, + KeyCode::Esc, + KeyCode::Char('q'), + KeyCode::Char('y'), + KeyCode::Char('v'), + KeyCode::Char('V'), + KeyCode::Char(' '), + KeyCode::Char('/'), + KeyCode::Char('?'), + ] { + assert!(!is_repeatable_navigation_key(&TerminalKey::new( + code, + KeyModifiers::empty() + ))); + } + } + + #[tokio::test] + async fn copy_mode_does_not_repeat_navigation_while_searching_or_for_the_prefix() { + let (mut app, _) = app_with_copy_screen(b"alpha\nbeta\n"); + app.state.enter_copy_mode(&app.terminal_runtimes); + let navigation = TerminalKey::new(KeyCode::Char('j'), KeyModifiers::empty()); + assert!(app.state.allows_copy_navigation_repeat(&navigation)); + + app.state + .open_copy_mode_search(CopyModeSearchDirection::Forward); + assert!(!app.state.allows_copy_navigation_repeat(&navigation)); + + app.state + .copy_mode + .as_mut() + .expect("copy mode") + .search + .prompt = None; + app.state.prefix_code = KeyCode::Char('b'); + app.state.prefix_mods = KeyModifiers::CONTROL; + let prefix = TerminalKey::new(KeyCode::Char('b'), KeyModifiers::CONTROL); + assert!(!app.state.allows_copy_navigation_repeat(&prefix)); + } + + #[tokio::test] + async fn copy_mode_repeats_ghostty_enhanced_arrow_navigation() { + let (mut app, _) = app_with_copy_screen(b"alpha\nbeta\n"); + app.state.enter_copy_mode(&app.terminal_runtimes); + app.state.copy_mode.as_mut().expect("copy mode").cursor_col = 3; + + app.route_client_input(b"\x1b[1;1:1D\x1b[1;1:2D\x1b[1;1:2D\x1b[1;1:3D".to_vec()); + + assert_eq!( + app.state.copy_mode.as_ref().expect("copy mode").cursor_col, + 0 + ); + } + + #[tokio::test] + async fn copy_mode_repeats_windows_character_navigation() { + let (mut app, _) = app_with_copy_screen(b"zero\none\ntwo\n"); + app.state.enter_copy_mode(&app.terminal_runtimes); + app.state.copy_mode.as_mut().expect("copy mode").cursor_row = 0; + let record = crate::input::WindowsKeyRecord { + key_down: true, + repeat_count: 1, + virtual_key_code: 0x4a, + virtual_scan_code: 0x24, + unicode: u16::from(b'j'), + control_key_state: 0, + }; + let key = + TerminalKey::new(KeyCode::Char('j'), KeyModifiers::empty()).with_windows_record(record); + + app.handle_raw_input_event(crate::raw_input::RawInputEvent::Key(key.clone())) + .await; + app.handle_raw_input_event(crate::raw_input::RawInputEvent::Key(key)) + .await; + + assert_eq!( + app.state.copy_mode.as_ref().expect("copy mode").cursor_row, + 2 + ); + } + + #[tokio::test] + async fn copy_mode_repeats_ctrl_navigation() { + let bytes = numbered_lines_bytes(64); + let (mut app, pane_id) = app_with_copy_scrollback(&bytes); + app.state.prefix_code = KeyCode::Char('a'); + app.state.prefix_mods = KeyModifiers::CONTROL; + app.state.enter_copy_mode(&app.terminal_runtimes); + let height = app.state.copy_mode.as_ref().expect("copy mode").cursor_row + 1; + let expected_lines = copy_mode_page_lines(height, false) * 2; + let key = TerminalKey::new(KeyCode::Char('b'), KeyModifiers::CONTROL); + + app.route_client_events( + vec![ + crate::raw_input::RawInputEvent::Key(key.clone()), + crate::raw_input::RawInputEvent::Key( + key.with_kind(crossterm::event::KeyEventKind::Repeat), + ), + ], + false, + ); + + assert_eq!(copy_mode_offset_from_bottom(&app, pane_id), expected_lines); + } + #[tokio::test] async fn copy_mode_ctrl_b_uses_page_up() { let bytes = numbered_lines_bytes(64); diff --git a/src/app/input/lease.rs b/src/app/input/lease.rs index de118334d4..9375d5512e 100644 --- a/src/app/input/lease.rs +++ b/src/app/input/lease.rs @@ -27,6 +27,7 @@ pub(crate) struct ForwardedInputLease { #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum ConsumedInputLease { ReprocessRepeats(TerminalInputContext), + RepeatCopyNavigation, SuppressRepeats, } @@ -129,7 +130,9 @@ impl InputLeaseTable { self.insert_consumed(lease_key, ConsumedInputLease::SuppressRepeats); return RepeatPlan::Ignore; } - Some(InputLease::Consumed(ConsumedInputLease::SuppressRepeats)) => { + Some(InputLease::Consumed( + ConsumedInputLease::RepeatCopyNavigation | ConsumedInputLease::SuppressRepeats, + )) => { return RepeatPlan::Ignore; } None => {} @@ -170,6 +173,15 @@ impl InputLeaseTable { self.leases.contains_key(key) } + pub(crate) fn repeats_copy_navigation(&self, key: &InputLeaseKey) -> bool { + matches!( + self.leases.get(key), + Some(InputLease::Consumed( + ConsumedInputLease::RepeatCopyNavigation + )) + ) + } + pub(crate) fn insert_forwarded( &mut self, key: InputLeaseKey, diff --git a/src/app/mod.rs b/src/app/mod.rs index 15f352af8f..c3cb67daa8 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1722,6 +1722,8 @@ impl App { let key = self.input_leases.normalize_press(&lease_key, key); match key.kind { crossterm::event::KeyEventKind::Press => { + let repeat_copy_navigation = + self.state.allows_copy_navigation_repeat(&key); let initial_context = self.terminal_input_context(); let target = if initial_context.is_some() { self.handle_terminal_key_headless_from(source_id, key.clone()) @@ -1737,8 +1739,27 @@ impl App { resulting_context.as_ref(), target, ); + if repeat_copy_navigation && self.state.mode == Mode::Copy { + self.input_leases.insert_consumed( + lease_key, + input::ConsumedInputLease::RepeatCopyNavigation, + ); + } self.execute_repeat_plan_headless(source_id, lease_key, key, plan); } + crossterm::event::KeyEventKind::Repeat + if self.state.allows_copy_navigation_repeat(&key) + && self.input_leases.repeats_copy_navigation(&lease_key) => + { + let repetitions = key.repeat_count; + let key = key.with_repeat_count(1); + for _ in 0..repetitions { + if self.state.mode != Mode::Copy { + break; + } + self.handle_non_terminal_key_headless(key.clone()); + } + } crossterm::event::KeyEventKind::Repeat => { let current_context = self.terminal_input_context(); let plan = self.input_leases.plan_repeat( diff --git a/src/app/runtime.rs b/src/app/runtime.rs index 31426f874d..e14ae9831f 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -189,6 +189,7 @@ impl App { let key = self.input_leases.normalize_press(&lease_key, key); match key.kind { crossterm::event::KeyEventKind::Press => { + let repeat_copy_navigation = self.state.allows_copy_navigation_repeat(&key); let initial_context = self.terminal_input_context(); let target = self.handle_key(key.clone()).await; let resulting_context = self.terminal_input_context(); @@ -199,9 +200,29 @@ impl App { resulting_context.as_ref(), target, ); + if repeat_copy_navigation && self.state.mode == crate::app::Mode::Copy { + self.input_leases.insert_consumed( + lease_key, + super::input::ConsumedInputLease::RepeatCopyNavigation, + ); + } self.execute_repeat_plan(lease_key, key, plan).await; true } + crossterm::event::KeyEventKind::Repeat + if self.state.allows_copy_navigation_repeat(&key) + && self.input_leases.repeats_copy_navigation(&lease_key) => + { + let repetitions = key.repeat_count; + let key = key.with_repeat_count(1); + for _ in 0..repetitions { + if self.state.mode != crate::app::Mode::Copy { + break; + } + self.handle_key(key.clone()).await; + } + true + } crossterm::event::KeyEventKind::Repeat => { let current_context = self.terminal_input_context(); let plan = self.input_leases.plan_repeat(