fix: repeat copy-mode navigation keys

refs #2371
This commit is contained in:
Ogulcan Celik
2026-08-09 05:26:09 +03:00
parent d277d2f83b
commit 7cce478e4e
5 changed files with 248 additions and 1 deletions
+1
View File
@@ -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)
+192
View File
@@ -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<char> {
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);
+13 -1
View File
@@ -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,
+21
View File
@@ -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(
+21
View File
@@ -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(