diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-10 04:22:10 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-10 04:22:10 +0300 |
| commit | c8850a2e3e284033142db0f8117fa5998c68bd59 (patch) | |
| tree | 487530d94265c096ceeb3ded7c60a94f14a6f7fd /src/pane | |
| parent | 0de503ccd9a8fedeeb6cbcc1446ab2bc644fdbb6 (diff) | |
feat: add copy mode search and tmux word motions
refs #1230
Diffstat (limited to 'src/pane')
| -rw-r--r-- | src/pane/terminal.rs | 764 |
1 files changed, 764 insertions, 0 deletions
diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index 02e53352..5c7dfcb9 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -1,4 +1,6 @@ use std::borrow::Cow; +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; @@ -49,6 +51,30 @@ pub struct ScrollMetrics { pub viewport_rows: usize, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct TerminalTextPoint { + pub row: u32, + pub col: u16, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct TerminalTextMatch { + pub start: TerminalTextPoint, + pub end: TerminalTextPoint, + pub source_fingerprint: u64, + pub scan_cols: u16, + pub scan_screen: crate::ghostty::ActiveScreen, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum TerminalWordMotion { + NextStart, + PreviousStart, + NextEnd, +} + +const COPY_MODE_WORD_SEPARATORS: &str = "!\"#$%&'()*+,-./:;<=>?@[\\]^`{|}~"; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct TerminalCursorState { pub x: u16, @@ -196,6 +222,141 @@ impl PaneTerminal { self.ghostty.scroll_metrics() } + pub(crate) fn search_text_matches( + &self, + query: &str, + case_sensitive: bool, + ) -> Vec<TerminalTextMatch> { + let Some((buffer, active_screen)) = self.retained_text_buffer() else { + return Vec::new(); + }; + buffer.search(query, case_sensitive, active_screen) + } + + pub(crate) fn text_match_is_current(&self, text_match: TerminalTextMatch) -> bool { + self.text_matches_are_current(&[text_match]) + .first() + .copied() + .unwrap_or(false) + } + + pub(crate) fn text_matches_are_current(&self, text_matches: &[TerminalTextMatch]) -> Vec<bool> { + if text_matches.is_empty() { + return Vec::new(); + } + let Ok(core) = self.ghostty.core.lock() else { + return vec![false; text_matches.len()]; + }; + let Some(cols) = core.terminal.cols().ok() else { + return vec![false; text_matches.len()]; + }; + let Some(active_screen) = core.terminal.active_screen().ok() else { + return vec![false; text_matches.len()]; + }; + let row_range = text_matches + .iter() + .filter(|text_match| { + text_match.scan_cols == cols && text_match.scan_screen == active_screen + }) + .fold(None::<(u32, u32)>, |range, text_match| { + Some(match range { + Some((start_row, end_row)) => ( + start_row.min(text_match.start.row), + end_row.max(text_match.end.row), + ), + None => (text_match.start.row, text_match.end.row), + }) + }); + let Some((start_row, end_row)) = row_range else { + return vec![false; text_matches.len()]; + }; + let Ok(rows) = core + .terminal + .screen_text_rows_range(start_row as usize, end_row.saturating_add(1) as usize) + else { + return vec![false; text_matches.len()]; + }; + let buffer = RetainedTextBuffer::new_search(cols, rows, start_row); + text_matches + .iter() + .map(|text_match| { + text_match.scan_cols == cols + && text_match.scan_screen == active_screen + && buffer.contains_match(*text_match) + }) + .collect() + } + + pub(crate) fn word_motion_target( + &self, + row: u32, + col: u16, + motion: TerminalWordMotion, + ) -> Option<TerminalTextPoint> { + let core = self.ghostty.core.lock().ok()?; + let cols = core.terminal.cols().ok()?; + let total_rows = core.terminal.total_rows().ok()?; + let row = usize::try_from(row).ok()?; + if row >= total_rows { + return None; + } + + let mut window_rows = 64usize; + loop { + let (start_row, end_row) = match motion { + TerminalWordMotion::PreviousStart => { + (row.saturating_sub(window_rows.saturating_sub(1)), row + 1) + } + TerminalWordMotion::NextStart | TerminalWordMotion::NextEnd => { + (row, row.saturating_add(window_rows).min(total_rows)) + } + }; + let rows = core + .terminal + .screen_text_rows_range(start_row, end_row) + .ok()?; + let starts_in_continuation = rows + .first() + .is_some_and(|row| row.wrap_continuation && start_row > 0); + let ends_in_continuation = rows + .last() + .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 + && target + .is_some_and(|target| starts_in_continuation && target.row == start_row as u32); + let needs_more_future = motion == TerminalWordMotion::NextEnd + && ends_in_continuation + && target.is_some_and(|target| buffer.point_is_final_atom(target)); + if target.is_some() && !needs_more_history && !needs_more_future { + return target; + } + + let reached_edge = match motion { + TerminalWordMotion::PreviousStart => start_row == 0, + TerminalWordMotion::NextStart | TerminalWordMotion::NextEnd => { + end_row == total_rows + } + }; + if reached_edge { + return target; + } + window_rows = window_rows.saturating_mul(2).min(total_rows); + } + } + + fn retained_text_buffer(&self) -> Option<(RetainedTextBuffer, crate::ghostty::ActiveScreen)> { + let (cols, rows, active_screen) = { + let core = self.ghostty.core.lock().ok()?; + let cols = core.terminal.cols().ok()?; + let rows = core.terminal.screen_text_rows().ok()?; + let active_screen = core.terminal.active_screen().ok()?; + (cols, rows, active_screen) + }; + Some((RetainedTextBuffer::new_search(cols, rows, 0), active_screen)) + } + pub fn input_state(&self) -> Option<InputState> { self.ghostty.input_state() } @@ -355,6 +516,361 @@ impl PaneTerminal { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TextClass { + Whitespace, + Separator, + Word, +} + +#[derive(Debug)] +struct TextAtom { + point: Option<TerminalTextPoint>, + end_col: u16, + class: TextClass, +} + +#[derive(Debug)] +struct TextSpan { + byte_start: usize, + byte_end: usize, + start: TerminalTextPoint, + end: TerminalTextPoint, +} + +#[derive(Debug, Default)] +struct LogicalTextLine { + text: String, + spans: Vec<TextSpan>, +} + +#[derive(Debug)] +struct RetainedTextBuffer { + cols: u16, + lines: Vec<LogicalTextLine>, + atoms: Vec<TextAtom>, +} + +impl RetainedTextBuffer { + #[cfg(test)] + fn new(cols: u16, rows: Vec<crate::ghostty::ScreenTextRow>) -> Self { + Self::build(cols, rows, 0, true, true) + } + + fn new_search(cols: u16, rows: Vec<crate::ghostty::ScreenTextRow>, row_offset: u32) -> Self { + Self::build(cols, rows, row_offset, true, false) + } + + fn new_words(cols: u16, rows: Vec<crate::ghostty::ScreenTextRow>, row_offset: u32) -> Self { + Self::build(cols, rows, row_offset, false, true) + } + + fn build( + cols: u16, + rows: Vec<crate::ghostty::ScreenTextRow>, + row_offset: u32, + build_lines: bool, + build_atoms: bool, + ) -> Self { + let mut lines = Vec::new(); + let mut line = LogicalTextLine::default(); + let mut atoms: Vec<TextAtom> = Vec::new(); + + for (row_idx, row) in rows.into_iter().enumerate() { + let Some(row_idx) = u32::try_from(row_idx).ok() else { + break; + }; + let row_idx = row_offset.saturating_add(row_idx); + for (col, cell) in row.cells.into_iter().enumerate() { + let Ok(col) = u16::try_from(col) else { + break; + }; + if cell.wide == crate::ghostty::CellWide::SpacerTail { + continue; + } + if cell.wide == crate::ghostty::CellWide::SpacerHead { + if build_atoms { + atoms.push(TextAtom { + point: Some(TerminalTextPoint { row: row_idx, col }), + end_col: col, + class: atoms + .last() + .map_or(TextClass::Whitespace, |atom| atom.class), + }); + } + continue; + } + let width = if cell.wide == crate::ghostty::CellWide::Wide { + 2 + } else { + 1 + }; + let text = terminal_cell_text(&cell.graphemes); + let start = TerminalTextPoint { row: row_idx, col }; + let end = TerminalTextPoint { + row: row_idx, + col: col.saturating_add(width - 1), + }; + if build_lines { + let byte_start = line.text.len(); + line.text.push_str(&text); + let byte_end = line.text.len(); + line.spans.push(TextSpan { + byte_start, + byte_end, + start, + end, + }); + } + if build_atoms { + atoms.push(TextAtom { + point: Some(start), + end_col: end.col, + class: text_class(&text), + }); + } + } + + if row.soft_wrapped { + continue; + } + + if build_lines { + let trimmed_len = line.text.trim_end().len(); + while line + .spans + .last() + .is_some_and(|span| span.byte_start >= trimmed_len) + { + line.spans.pop(); + } + line.text.truncate(trimmed_len); + lines.push(std::mem::take(&mut line)); + } + if build_atoms { + atoms.push(TextAtom { + point: None, + end_col: 0, + class: TextClass::Whitespace, + }); + } + } + + if build_lines && (!line.text.is_empty() || !line.spans.is_empty()) { + lines.push(line); + } + + Self { cols, lines, atoms } + } + + fn search( + &self, + query: &str, + case_sensitive: bool, + active_screen: crate::ghostty::ActiveScreen, + ) -> Vec<TerminalTextMatch> { + if query.is_empty() { + return Vec::new(); + } + let Ok(regex) = regex::RegexBuilder::new(®ex::escape(query)) + .case_insensitive(!case_sensitive) + .build() + else { + return Vec::new(); + }; + let mut matches = Vec::new(); + for line in &self.lines { + for found in regex.find_iter(&line.text) { + let Ok(start_index) = line + .spans + .binary_search_by_key(&found.start(), |span| span.byte_start) + else { + continue; + }; + let Ok(end_index) = line + .spans + .binary_search_by_key(&found.end(), |span| span.byte_end) + else { + continue; + }; + let start_span = &line.spans[start_index]; + let end_span = &line.spans[end_index]; + matches.push(TerminalTextMatch { + start: start_span.start, + end: end_span.end, + source_fingerprint: text_fingerprint(found.as_str()), + scan_cols: self.cols, + scan_screen: active_screen, + }); + } + } + matches + } + + fn contains_match(&self, text_match: TerminalTextMatch) -> bool { + self.lines.iter().any(|line| { + let Ok(start_index) = line + .spans + .binary_search_by_key(&text_match.start, |span| span.start) + else { + return false; + }; + let Ok(end_index) = line + .spans + .binary_search_by_key(&text_match.end, |span| span.end) + else { + return false; + }; + let start_span = &line.spans[start_index]; + let end_span = &line.spans[end_index]; + start_span.byte_start <= end_span.byte_end + && text_fingerprint(&line.text[start_span.byte_start..end_span.byte_end]) + == text_match.source_fingerprint + }) + } + + fn word_motion( + &self, + row: u32, + col: u16, + motion: TerminalWordMotion, + ) -> Option<TerminalTextPoint> { + let current = self.atoms.iter().position(|atom| { + atom.point + .is_some_and(|point| point.row == row && col >= point.col && col <= atom.end_col) + })?; + match motion { + TerminalWordMotion::NextStart => self.next_word_start(current), + TerminalWordMotion::PreviousStart => self.previous_word_start(current), + TerminalWordMotion::NextEnd => self.next_word_end(current), + } + } + + fn next_word_start(&self, current: usize) -> Option<TerminalTextPoint> { + let current_class = self.atoms.get(current)?.class; + let mut next = current.saturating_add(1); + if current_class != TextClass::Whitespace { + while self + .atoms + .get(next) + .is_some_and(|atom| atom.class == current_class) + { + next += 1; + } + } + while self + .atoms + .get(next) + .is_some_and(|atom| atom.class == TextClass::Whitespace) + { + next += 1; + } + self.next_point(next) + } + + fn previous_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)?; + } + let class = self.atoms.get(previous)?.class; + while previous > 0 + && self + .atoms + .get(previous - 1) + .is_some_and(|atom| atom.class == class) + { + previous -= 1; + } + self.previous_point(previous) + } + + fn next_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; + } + let class = self.atoms.get(next)?.class; + while self + .atoms + .get(next + 1) + .is_some_and(|atom| atom.class == class) + { + 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 { + return Some(point); + } + index += 1; + } + None + } + + fn previous_point(&self, mut index: usize) -> Option<TerminalTextPoint> { + loop { + if let Some(point) = self.atoms.get(index)?.point { + return Some(point); + } + index = index.checked_sub(1)?; + } + } + + fn point_is_final_atom(&self, point: TerminalTextPoint) -> bool { + self.atoms + .iter() + .rev() + .find(|atom| atom.point.is_some()) + .is_some_and(|atom| { + atom.point + .is_some_and(|start| start.row == point.row && atom.end_col == point.col) + }) + } +} + +fn terminal_cell_text(graphemes: &[u32]) -> String { + if graphemes.is_empty() + || graphemes.first().copied() == Some(crate::ghostty::KITTY_UNICODE_PLACEHOLDER) + { + return " ".to_string(); + } + graphemes + .iter() + .map(|codepoint| char::from_u32(*codepoint).unwrap_or(char::REPLACEMENT_CHARACTER)) + .collect() +} + +fn text_class(text: &str) -> TextClass { + let Some(ch) = text.chars().next() else { + return TextClass::Whitespace; + }; + if ch.is_whitespace() { + TextClass::Whitespace + } else if ch.is_ascii() && COPY_MODE_WORD_SEPARATORS.contains(ch) { + TextClass::Separator + } else { + TextClass::Word + } +} + +fn text_fingerprint(text: &str) -> u64 { + let mut hasher = DefaultHasher::new(); + text.hash(&mut hasher); + hasher.finish() +} + impl GhosttyPaneTerminal { pub fn new( mut terminal: crate::ghostty::Terminal, @@ -2327,6 +2843,45 @@ mod tests { use ratatui::{layout::Rect, style::Color}; use tokio::sync::mpsc; + fn text_cell(text: &str) -> crate::ghostty::ScreenTextCell { + crate::ghostty::ScreenTextCell { + wide: crate::ghostty::CellWide::Narrow, + graphemes: text.chars().map(u32::from).collect(), + } + } + + fn wide_text_cells(text: &str) -> [crate::ghostty::ScreenTextCell; 2] { + [ + crate::ghostty::ScreenTextCell { + wide: crate::ghostty::CellWide::Wide, + graphemes: text.chars().map(u32::from).collect(), + }, + crate::ghostty::ScreenTextCell { + wide: crate::ghostty::CellWide::SpacerTail, + graphemes: Vec::new(), + }, + ] + } + + fn text_row( + cells: impl IntoIterator<Item = crate::ghostty::ScreenTextCell>, + soft_wrapped: bool, + ) -> crate::ghostty::ScreenTextRow { + crate::ghostty::ScreenTextRow { + cells: cells.into_iter().collect(), + soft_wrapped, + wrap_continuation: false, + } + } + + fn search_primary( + buffer: &RetainedTextBuffer, + query: &str, + case_sensitive: bool, + ) -> Vec<TerminalTextMatch> { + buffer.search(query, case_sensitive, crate::ghostty::ActiveScreen::Primary) + } + fn write_numbered_lines(terminal: &mut crate::ghostty::Terminal, count: usize) { for i in 0..count { terminal.write(format!("{i:06}\r\n").as_bytes()); @@ -2340,6 +2895,215 @@ mod tests { terminal.write(b"END"); } + #[test] + fn retained_text_search_crosses_soft_wraps_but_not_hard_lines() { + let buffer = RetainedTextBuffer::new( + 5, + vec![ + text_row("abcde".chars().map(|ch| text_cell(&ch.to_string())), true), + text_row("fgh ".chars().map(|ch| text_cell(&ch.to_string())), false), + text_row("abc ".chars().map(|ch| text_cell(&ch.to_string())), false), + ], + ); + + let matches = search_primary(&buffer, "def", true); + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].start, TerminalTextPoint { row: 0, col: 3 }); + assert_eq!(matches[0].end, TerminalTextPoint { row: 1, col: 0 }); + assert!(search_primary(&buffer, "hab", true).is_empty()); + } + + #[test] + fn retained_text_search_maps_wide_and_combining_graphemes_to_cells() { + let mut cells = vec![text_cell("A")]; + cells.extend(wide_text_cells("界")); + cells.push(text_cell("e\u{301}")); + cells.push(text_cell("Z")); + let buffer = RetainedTextBuffer::new(5, vec![text_row(cells, false)]); + + let matches = search_primary(&buffer, "界e\u{301}", true); + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].start, TerminalTextPoint { row: 0, col: 1 }); + assert_eq!(matches[0].end, TerminalTextPoint { row: 0, col: 3 }); + assert!(search_primary(&buffer, "\u{301}", true).is_empty()); + } + + #[test] + fn retained_text_search_skips_wide_spacer_heads_at_soft_wraps() { + let mut first = "abcd" + .chars() + .map(|ch| text_cell(&ch.to_string())) + .collect::<Vec<_>>(); + first.push(crate::ghostty::ScreenTextCell { + wide: crate::ghostty::CellWide::SpacerHead, + graphemes: Vec::new(), + }); + let mut second = wide_text_cells("界").to_vec(); + second.extend("xyz".chars().map(|ch| text_cell(&ch.to_string()))); + let buffer = + RetainedTextBuffer::new(5, vec![text_row(first, true), text_row(second, false)]); + + let matches = search_primary(&buffer, "d界", true); + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].start, TerminalTextPoint { row: 0, col: 3 }); + assert_eq!(matches[0].end, TerminalTextPoint { row: 1, col: 1 }); + } + + #[test] + fn retained_text_word_motion_does_not_split_at_a_wide_spacer_head() { + let mut first = "abcd" + .chars() + .map(|ch| text_cell(&ch.to_string())) + .collect::<Vec<_>>(); + first.push(crate::ghostty::ScreenTextCell { + wide: crate::ghostty::CellWide::SpacerHead, + graphemes: Vec::new(), + }); + let mut second = wide_text_cells("界").to_vec(); + second.extend("xyz".chars().map(|ch| text_cell(&ch.to_string()))); + let buffer = + RetainedTextBuffer::new(5, vec![text_row(first, true), text_row(second, false)]); + + assert_eq!( + buffer.word_motion(0, 0, TerminalWordMotion::NextStart), + None + ); + assert_eq!( + buffer.word_motion(0, 0, TerminalWordMotion::NextEnd), + Some(TerminalTextPoint { row: 1, col: 4 }) + ); + } + + #[test] + fn retained_text_search_is_literal_and_unicode_case_aware() { + let buffer = RetainedTextBuffer::new( + 12, + vec![text_row( + "CAFÉ a.b ".chars().map(|ch| text_cell(&ch.to_string())), + false, + )], + ); + + assert_eq!(search_primary(&buffer, "café", false).len(), 1); + assert!(search_primary(&buffer, "café", true).is_empty()); + assert_eq!(search_primary(&buffer, "a.b", true).len(), 1); + assert!(search_primary(&buffer, "a?b", true).is_empty()); + } + + #[test] + fn retained_text_word_motions_use_tmux_separators_across_rows() { + let buffer = RetainedTextBuffer::new( + 6, + vec![ + text_row("a_b.c ".chars().map(|ch| text_cell(&ch.to_string())), false), + text_row("—d ".chars().map(|ch| text_cell(&ch.to_string())), false), + ], + ); + + 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::NextStart), + Some(TerminalTextPoint { row: 1, col: 0 }) + ); + assert_eq!( + buffer.word_motion(1, 1, TerminalWordMotion::PreviousStart), + Some(TerminalTextPoint { row: 1, col: 0 }) + ); + } + + #[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(); + terminal.write(b"alpha needle"); + let pane = PaneTerminal::new(GhosttyPaneTerminal::new(terminal, tx).unwrap()); + + let text_match = pane.search_text_matches("needle", true)[0]; + assert!(pane.text_match_is_current(text_match)); + pane.ghostty + .core + .lock() + .unwrap() + .terminal + .write(b"\r\x1b[2Kalpha changed"); + assert!(!pane.text_match_is_current(text_match)); + } + + #[test] + fn live_terminal_match_validation_handles_soft_wrapped_matches() { + let (tx, _rx) = mpsc::channel(4); + let mut terminal = crate::ghostty::Terminal::new(5, 3, 100).unwrap(); + terminal.write(b"abcdef"); + let pane = PaneTerminal::new(GhosttyPaneTerminal::new(terminal, tx).unwrap()); + + let text_match = pane.search_text_matches("def", true)[0]; + + assert_eq!(text_match.start, TerminalTextPoint { row: 0, col: 3 }); + assert_eq!(text_match.end, TerminalTextPoint { row: 1, col: 0 }); + assert!(pane.text_match_is_current(text_match)); + } + + #[test] + fn live_terminal_match_validation_rejects_an_active_screen_change() { + let (tx, _rx) = mpsc::channel(4); + let mut terminal = crate::ghostty::Terminal::new(20, 3, 100).unwrap(); + terminal.write(b"alpha needle"); + let pane = PaneTerminal::new(GhosttyPaneTerminal::new(terminal, tx).unwrap()); + + let text_match = pane.search_text_matches("needle", true)[0]; + pane.ghostty + .core + .lock() + .unwrap() + .terminal + .write(b"\x1b[?1049hneedle"); + + assert!(!pane.text_match_is_current(text_match)); + } + + #[test] + fn live_terminal_word_motion_expands_across_long_blank_history() { + let (tx, _rx) = mpsc::channel(4); + let mut terminal = crate::ghostty::Terminal::new(10, 3, 200).unwrap(); + terminal.write(b"origin\r\n"); + for _ in 0..80 { + terminal.write(b"\r\n"); + } + let last_row = terminal.total_rows().unwrap().saturating_sub(1) as u32; + let pane = PaneTerminal::new(GhosttyPaneTerminal::new(terminal, tx).unwrap()); + + assert_eq!( + pane.word_motion_target(last_row, 0, TerminalWordMotion::PreviousStart), + Some(TerminalTextPoint { row: 0, col: 0 }) + ); + } + + #[test] + fn live_terminal_word_end_expands_through_a_long_soft_wrap() { + let (tx, _rx) = mpsc::channel(4); + let mut terminal = crate::ghostty::Terminal::new(2, 3, 200).unwrap(); + let word = "a".repeat(132); + terminal.write(word.as_bytes()); + let pane = PaneTerminal::new(GhosttyPaneTerminal::new(terminal, tx).unwrap()); + let text_match = pane.search_text_matches(&word, true)[0]; + + assert_eq!( + pane.word_motion_target( + text_match.start.row, + text_match.start.col, + TerminalWordMotion::NextEnd, + ), + Some(text_match.end) + ); + } + fn current_palette_color(pane: &GhosttyPaneTerminal, index: u8) -> crate::ghostty::RgbColor { let mut core = pane.core.lock().unwrap(); let GhosttyPaneCore { |
