aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorJP Lew <462836+jplew@users.noreply.github.com>2026-08-06 17:00:23 +0530
committerGitHub <noreply@github.com>2026-08-06 14:30:23 +0300
commitfd0e4ff43aed7751c4cf5a4247e6581dbcfaa3b8 (patch)
tree96f542526664565f86431f53606cbe244cc87f33 /src
parente9222d1882effd62ddae9c4d81bc2d9390da7cac (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>
Diffstat (limited to 'src')
-rw-r--r--src/app/input/copy_mode.rs107
-rw-r--r--src/pane/terminal.rs180
2 files changed, 285 insertions, 2 deletions
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();