Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/next/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
192 changes: 192 additions & 0 deletions src/app/input/copy_mode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
14 changes: 13 additions & 1 deletion src/app/input/lease.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ pub(crate) struct ForwardedInputLease {
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum ConsumedInputLease {
ReprocessRepeats(TerminalInputContext),
RepeatCopyNavigation,
SuppressRepeats,
}

Expand Down Expand Up @@ -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 => {}
Expand Down Expand Up @@ -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,
Expand Down
21 changes: 21 additions & 0 deletions src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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(
Expand Down
21 changes: 21 additions & 0 deletions src/app/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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(
Expand Down
Loading