mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-22 16:01:07 +00:00
@@ -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)
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user