diff options
| author | JP Lew <462836+jplew@users.noreply.github.com> | 2026-08-06 17:00:23 +0530 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-08-06 14:30:23 +0300 |
| commit | fd0e4ff43aed7751c4cf5a4247e6581dbcfaa3b8 (patch) | |
| tree | 96f542526664565f86431f53606cbe244cc87f33 | |
| parent | e9222d1882effd62ddae9c4d81bc2d9390da7cac (diff) | |
feat(copy-mode): add big-word motions (#2270)
Co-authored-by: Ubuntu <ubuntu@ip-172-31-252-234.ca-west-1.compute.internal>
Co-authored-by: Can Celik <ogulcancelik@gmail.com>
| -rw-r--r-- | docs/next/website/src/content/docs/keyboard.mdx | 2 | ||||
| -rw-r--r-- | src/app/input/copy_mode.rs | 107 | ||||
| -rw-r--r-- | src/pane/terminal.rs | 180 |
3 files changed, 286 insertions, 3 deletions
diff --git a/docs/next/website/src/content/docs/keyboard.mdx b/docs/next/website/src/content/docs/keyboard.mdx index 22283518..d159b2c8 100644 --- a/docs/next/website/src/content/docs/keyboard.mdx +++ b/docs/next/website/src/content/docs/keyboard.mdx @@ -64,7 +64,7 @@ The full keymap and the binding syntax live in the [keybinding reference](/docs/ ## Copy mode -Press `prefix+[` to enter copy mode for the focused pane. Use `h/j/k/l`, tmux-style `w/b/e`, `{`/`}`, `PageUp`/`PageDown`, `ctrl+b`/`ctrl+f`, and `ctrl+u`/`ctrl+d` to move. Press `/` or `?` for forward or backward literal search, then `n` or `N` to repeat in the same or opposite direction. Search is case-insensitive unless the query contains an uppercase letter. Use `v` or Space to start a selection, `y` or Enter to copy it, and `q` or Esc to leave without copying. Esc clears an active selection or search before exiting. Copy mode does not pause the pane process: output remains live, follows at the bottom, and stays pinned when you navigate into history. The configured prefix keeps its normal meaning in copy mode; with the default prefix, `ctrl+b` enters prefix mode instead of paging up, so use a different prefix if you want `ctrl+b` for copy-mode page-up. Mouse drag-select copies without entering copy mode at all. +Press `prefix+[` to enter copy mode for the focused pane. Use `h/j/k/l`, tmux-style `w/b/e` and big-word `W/B/E`, `{`/`}`, `PageUp`/`PageDown`, `ctrl+b`/`ctrl+f`, and `ctrl+u`/`ctrl+d` to move. Press `/` or `?` for forward or backward literal search, then `n` or `N` to repeat in the same or opposite direction. Search is case-insensitive unless the query contains an uppercase letter. Use `v` or Space to start a selection, `y` or Enter to copy it, and `q` or Esc to leave without copying. Esc clears an active selection or search before exiting. Copy mode does not pause the pane process: output remains live, follows at the bottom, and stays pinned when you navigate into history. The configured prefix keeps its normal meaning in copy mode; with the default prefix, `ctrl+b` enters prefix mode instead of paging up, so use a different prefix if you want `ctrl+b` for copy-mode page-up. Mouse drag-select copies without entering copy mode at all. ## Change anything diff --git a/src/app/input/copy_mode.rs b/src/app/input/copy_mode.rs index b744d2ce..8bdbfd38 100644 --- a/src/app/input/copy_mode.rs +++ b/src/app/input/copy_mode.rs @@ -188,6 +188,9 @@ impl AppState { 'w' => self.copy_mode_word_motion(terminal_runtimes, WordMotion::NextStart), 'b' => self.copy_mode_word_motion(terminal_runtimes, WordMotion::PreviousStart), 'e' => self.copy_mode_word_motion(terminal_runtimes, WordMotion::NextEnd), + 'W' => self.copy_mode_word_motion(terminal_runtimes, WordMotion::NextBigStart), + 'B' => self.copy_mode_word_motion(terminal_runtimes, WordMotion::PreviousBigStart), + 'E' => self.copy_mode_word_motion(terminal_runtimes, WordMotion::NextBigEnd), '{' => self.copy_mode_paragraph(terminal_runtimes, -1), '}' => self.copy_mode_paragraph(terminal_runtimes, 1), _ => {} @@ -649,6 +652,9 @@ impl AppState { WordMotion::NextStart => crate::pane::TerminalWordMotion::NextStart, WordMotion::PreviousStart => crate::pane::TerminalWordMotion::PreviousStart, WordMotion::NextEnd => crate::pane::TerminalWordMotion::NextEnd, + WordMotion::NextBigStart => crate::pane::TerminalWordMotion::NextBigStart, + WordMotion::PreviousBigStart => crate::pane::TerminalWordMotion::PreviousBigStart, + WordMotion::NextBigEnd => crate::pane::TerminalWordMotion::NextBigEnd, }; let Some(target) = runtime.word_motion_target(absolute_row, copy_mode.cursor_col, motion) else { @@ -915,6 +921,9 @@ enum WordMotion { NextStart, PreviousStart, NextEnd, + NextBigStart, + PreviousBigStart, + NextBigEnd, } fn first_non_blank_col(text: &str) -> Option<u16> { @@ -1679,6 +1688,104 @@ mod tests { } #[tokio::test] + async fn copy_mode_big_word_motions_skip_punctuation_runs() { + let (mut app, _) = app_with_copy_screen(b"foo.bar baz qux\r\n"); + app.state.enter_copy_mode(&app.terminal_runtimes); + if let Some(copy_mode) = app.state.copy_mode.as_mut() { + copy_mode.cursor_row = 0; + copy_mode.cursor_col = 0; + } + + for expected_col in [8, 12] { + app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('w'), KeyModifiers::SHIFT)); + assert_eq!( + app.state.copy_mode.as_ref().expect("copy mode").cursor_col, + expected_col + ); + } + app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('e'), KeyModifiers::SHIFT)); + assert_eq!( + app.state.copy_mode.as_ref().expect("copy mode").cursor_col, + 14 + ); + for expected_col in [12, 8, 0] { + app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('b'), KeyModifiers::SHIFT)); + assert_eq!( + app.state.copy_mode.as_ref().expect("copy mode").cursor_col, + expected_col + ); + } + + // Lowercase motions keep their punctuation-aware behavior. + app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('w'), KeyModifiers::empty())); + assert_eq!( + app.state.copy_mode.as_ref().expect("copy mode").cursor_col, + 3 + ); + app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('w'), KeyModifiers::empty())); + assert_eq!( + app.state.copy_mode.as_ref().expect("copy mode").cursor_col, + 4 + ); + } + + #[tokio::test] + async fn copy_mode_big_word_motions_accept_shifted_codepoints_and_cross_rows() { + let (mut app, pane_id) = app_with_copy_screen(b"foo.bar baz\r\nqux/quux\r\n"); + app.state.enter_copy_mode(&app.terminal_runtimes); + if let Some(copy_mode) = app.state.copy_mode.as_mut() { + copy_mode.cursor_row = 0; + copy_mode.cursor_col = 0; + } + + app.handle_copy_mode_key( + TerminalKey::new(KeyCode::Char('W'), KeyModifiers::SHIFT) + .with_shifted_codepoint('W' as u32), + ); + let copy_mode = app.state.copy_mode.as_ref().expect("copy mode"); + assert_eq!( + copy_mode_viewport_top_row(&app, pane_id) + usize::from(copy_mode.cursor_row), + 0 + ); + assert_eq!(copy_mode.cursor_col, 8); + + app.handle_copy_mode_key( + TerminalKey::new(KeyCode::Char('W'), KeyModifiers::SHIFT) + .with_shifted_codepoint('W' as u32), + ); + let copy_mode = app.state.copy_mode.as_ref().expect("copy mode"); + assert_eq!( + copy_mode_viewport_top_row(&app, pane_id) + usize::from(copy_mode.cursor_row), + 1 + ); + assert_eq!(copy_mode.cursor_col, 0); + + app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('b'), KeyModifiers::SHIFT)); + let copy_mode = app.state.copy_mode.as_ref().expect("copy mode"); + assert_eq!( + copy_mode_viewport_top_row(&app, pane_id) + usize::from(copy_mode.cursor_row), + 0 + ); + assert_eq!(copy_mode.cursor_col, 8); + } + + #[tokio::test] + async fn copy_mode_big_word_motions_extend_an_active_selection() { + let (mut app, _) = app_with_copy_screen(b"foo.bar baz qux\r\n"); + app.state.enter_copy_mode(&app.terminal_runtimes); + if let Some(copy_mode) = app.state.copy_mode.as_mut() { + copy_mode.cursor_row = 0; + copy_mode.cursor_col = 0; + } + + app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('v'), KeyModifiers::empty())); + app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('w'), KeyModifiers::SHIFT)); + app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('y'), KeyModifiers::empty())); + + assert_eq!(copy_mode_clipboard_text(&mut app), "foo.bar b"); + } + + #[tokio::test] async fn copy_mode_search_does_not_change_live_follow_behavior() { let bytes = numbered_lines_bytes(32); let (mut app, pane_id) = app_with_copy_scrollback(&bytes); diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index 3e43cf06..d1dddb55 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -71,6 +71,9 @@ pub(crate) enum TerminalWordMotion { NextStart, PreviousStart, NextEnd, + NextBigStart, + PreviousBigStart, + NextBigEnd, } const COPY_MODE_WORD_SEPARATORS: &str = "!\"#$%&'()*+,-./:;<=>?@[\\]^`{|}~"; @@ -320,6 +323,12 @@ impl PaneTerminal { TerminalWordMotion::NextStart | TerminalWordMotion::NextEnd => { (row, row.saturating_add(window_rows).min(total_rows)) } + TerminalWordMotion::PreviousBigStart => { + (row.saturating_sub(window_rows.saturating_sub(1)), row + 1) + } + TerminalWordMotion::NextBigStart | TerminalWordMotion::NextBigEnd => { + (row, row.saturating_add(window_rows).min(total_rows)) + } }; let rows = core .terminal @@ -333,10 +342,12 @@ impl PaneTerminal { .is_some_and(|row| row.soft_wrapped && end_row < total_rows); let buffer = RetainedTextBuffer::new_words(cols, rows, u32::try_from(start_row).ok()?); let target = buffer.word_motion(u32::try_from(row).ok()?, col, motion); - let needs_more_history = motion == TerminalWordMotion::PreviousStart + let needs_more_history = (motion == TerminalWordMotion::PreviousStart + || motion == TerminalWordMotion::PreviousBigStart) && target .is_some_and(|target| starts_in_continuation && target.row == start_row as u32); - let needs_more_future = motion == TerminalWordMotion::NextEnd + let needs_more_future = (motion == TerminalWordMotion::NextEnd + || motion == TerminalWordMotion::NextBigEnd) && ends_in_continuation && target.is_some_and(|target| buffer.point_is_final_atom(target)); if target.is_some() && !needs_more_history && !needs_more_future { @@ -348,6 +359,10 @@ impl PaneTerminal { TerminalWordMotion::NextStart | TerminalWordMotion::NextEnd => { end_row == total_rows } + TerminalWordMotion::PreviousBigStart => start_row == 0, + TerminalWordMotion::NextBigStart | TerminalWordMotion::NextBigEnd => { + end_row == total_rows + } }; if reached_edge { return target; @@ -782,6 +797,9 @@ impl RetainedTextBuffer { TerminalWordMotion::NextStart => self.next_word_start(current), TerminalWordMotion::PreviousStart => self.previous_word_start(current), TerminalWordMotion::NextEnd => self.next_word_end(current), + TerminalWordMotion::NextBigStart => self.next_big_word_start(current), + TerminalWordMotion::PreviousBigStart => self.previous_big_word_start(current), + TerminalWordMotion::NextBigEnd => self.next_big_word_end(current), } } @@ -848,6 +866,71 @@ impl RetainedTextBuffer { self.previous_point(next) } + fn next_big_word_start(&self, current: usize) -> Option<TerminalTextPoint> { + let mut next = current.saturating_add(1); + if self + .atoms + .get(current) + .is_some_and(|atom| atom.class != TextClass::Whitespace) + { + while self + .atoms + .get(next) + .is_some_and(|atom| atom.class != TextClass::Whitespace) + { + next += 1; + } + } + while self + .atoms + .get(next) + .is_some_and(|atom| atom.class == TextClass::Whitespace) + { + next += 1; + } + self.next_point(next) + } + + fn previous_big_word_start(&self, current: usize) -> Option<TerminalTextPoint> { + let mut previous = current.checked_sub(1)?; + while self + .atoms + .get(previous) + .is_some_and(|atom| atom.class == TextClass::Whitespace) + { + previous = previous.checked_sub(1)?; + } + while previous > 0 + && self + .atoms + .get(previous - 1) + .is_some_and(|atom| atom.class != TextClass::Whitespace) + { + previous -= 1; + } + self.previous_point(previous) + } + + fn next_big_word_end(&self, current: usize) -> Option<TerminalTextPoint> { + let mut next = current.saturating_add(1); + while self + .atoms + .get(next) + .is_some_and(|atom| atom.class == TextClass::Whitespace) + { + next += 1; + } + self.atoms.get(next)?; + while self + .atoms + .get(next + 1) + .is_some_and(|atom| atom.class != TextClass::Whitespace) + { + next += 1; + } + self.previous_point(next) + } + fn next_point(&self, mut index: usize) -> Option<TerminalTextPoint> { while let Some(atom) = self.atoms.get(index) { if let Some(point) = atom.point { @@ -3386,6 +3469,99 @@ mod tests { } #[test] + fn retained_text_big_word_motions_treat_only_whitespace_as_separators() { + let buffer = RetainedTextBuffer::new( + 20, + vec![text_row( + "foo.bar baz qux/quux" + .chars() + .map(|ch| text_cell(&ch.to_string())), + false, + )], + ); + + // `W` skips punctuation-separated segments and lands on the next + // whitespace-delimited run. + assert_eq!( + buffer.word_motion(0, 0, TerminalWordMotion::NextBigStart), + Some(TerminalTextPoint { row: 0, col: 8 }) + ); + assert_eq!( + buffer.word_motion(0, 8, TerminalWordMotion::NextBigStart), + Some(TerminalTextPoint { row: 0, col: 12 }) + ); + // `E` lands on the last character of the current/next run. + assert_eq!( + buffer.word_motion(0, 0, TerminalWordMotion::NextBigEnd), + Some(TerminalTextPoint { row: 0, col: 6 }) + ); + assert_eq!( + buffer.word_motion(0, 6, TerminalWordMotion::NextBigEnd), + Some(TerminalTextPoint { row: 0, col: 10 }) + ); + assert_eq!( + buffer.word_motion(0, 12, TerminalWordMotion::NextBigEnd), + Some(TerminalTextPoint { row: 0, col: 19 }) + ); + // `B` returns to the beginning of the previous run. + assert_eq!( + buffer.word_motion(0, 19, TerminalWordMotion::PreviousBigStart), + Some(TerminalTextPoint { row: 0, col: 12 }) + ); + assert_eq!( + buffer.word_motion(0, 12, TerminalWordMotion::PreviousBigStart), + Some(TerminalTextPoint { row: 0, col: 8 }) + ); + assert_eq!( + buffer.word_motion(0, 8, TerminalWordMotion::PreviousBigStart), + Some(TerminalTextPoint { row: 0, col: 0 }) + ); + + // Lowercase motions keep their punctuation-aware behavior. + assert_eq!( + buffer.word_motion(0, 0, TerminalWordMotion::NextStart), + Some(TerminalTextPoint { row: 0, col: 3 }) + ); + assert_eq!( + buffer.word_motion(0, 3, TerminalWordMotion::NextStart), + Some(TerminalTextPoint { row: 0, col: 4 }) + ); + assert_eq!( + buffer.word_motion(0, 4, TerminalWordMotion::PreviousStart), + Some(TerminalTextPoint { row: 0, col: 3 }) + ); + } + + #[test] + fn retained_text_big_word_motions_cross_rows_and_blank_lines() { + let buffer = RetainedTextBuffer::new( + 6, + vec![ + text_row("a.b-c ".chars().map(|ch| text_cell(&ch.to_string())), false), + text_row(" ".chars().map(|ch| text_cell(&ch.to_string())), false), + text_row("d_e ".chars().map(|ch| text_cell(&ch.to_string())), false), + ], + ); + + assert_eq!( + buffer.word_motion(0, 0, TerminalWordMotion::NextBigStart), + Some(TerminalTextPoint { row: 2, col: 0 }) + ); + assert_eq!( + buffer.word_motion(2, 0, TerminalWordMotion::PreviousBigStart), + Some(TerminalTextPoint { row: 0, col: 0 }) + ); + assert_eq!( + buffer.word_motion(0, 0, TerminalWordMotion::NextBigEnd), + Some(TerminalTextPoint { row: 0, col: 4 }) + ); + assert_eq!( + buffer.word_motion(0, 4, TerminalWordMotion::NextBigEnd), + Some(TerminalTextPoint { row: 2, col: 2 }) + ); + } + + #[test] fn live_terminal_match_validation_rejects_overwritten_text() { let (tx, _rx) = mpsc::channel(4); let mut terminal = crate::ghostty::Terminal::new(20, 3, 100).unwrap(); |
