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 | |
| parent | 0de503ccd9a8fedeeb6cbcc1446ab2bc644fdbb6 (diff) | |
feat: add copy mode search and tmux word motions
refs #1230
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/api/panes.rs | 2 | ||||
| -rw-r--r-- | src/app/input/copy_mode.rs | 917 | ||||
| -rw-r--r-- | src/app/input/mod.rs | 18 | ||||
| -rw-r--r-- | src/app/state.rs | 25 | ||||
| -rw-r--r-- | src/ghostty/mod.rs | 93 | ||||
| -rw-r--r-- | src/pane.rs | 33 | ||||
| -rw-r--r-- | src/pane/terminal.rs | 764 | ||||
| -rw-r--r-- | src/terminal/runtime.rs | 28 | ||||
| -rw-r--r-- | src/ui.rs | 2 | ||||
| -rw-r--r-- | src/ui/menus.rs | 67 | ||||
| -rw-r--r-- | src/ui/panes.rs | 112 |
11 files changed, 1873 insertions, 188 deletions
diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index cf7b594d..9f64dd51 100644 --- a/src/app/api/panes.rs +++ b/src/app/api/panes.rs @@ -2500,6 +2500,7 @@ mod tests { cursor_col: 0, entry_offset_from_bottom: 0, selection: None, + search: Default::default(), }); let source_public = app.public_pane_id(0, source).unwrap(); let target_public = app.public_pane_id(0, target).unwrap(); @@ -3136,6 +3137,7 @@ mod tests { cursor_col: 0, entry_offset_from_bottom: 0, selection: None, + search: Default::default(), }); let source_public = app.public_pane_id(0, source).unwrap(); diff --git a/src/app/input/copy_mode.rs b/src/app/input/copy_mode.rs index 6ced6409..9e6b3384 100644 --- a/src/app/input/copy_mode.rs +++ b/src/app/input/copy_mode.rs @@ -3,7 +3,7 @@ use unicode_width::UnicodeWidthChar; use crate::{ app::{ - state::{CopyModeSelection, CopyModeState}, + state::{CopyModeSearchDirection, CopyModeSearchPrompt, CopyModeSelection, CopyModeState}, App, AppState, Mode, }, input::TerminalKey, @@ -76,6 +76,10 @@ impl AppState { cursor_col: cursor.1.min(info.inner_rect.width.saturating_sub(1)), entry_offset_from_bottom, selection: None, + search: crate::app::state::CopyModeSearchState { + geometry: Some((info.inner_rect.width, info.inner_rect.height)), + ..Default::default() + }, }); self.mode = Mode::Copy; } @@ -85,8 +89,32 @@ impl AppState { terminal_runtimes: &TerminalRuntimeRegistry, key: TerminalKey, ) { + if self.handle_copy_mode_search_prompt_key(terminal_runtimes, key) { + return; + } match key.code { KeyCode::Esc => { + let should_clear = self.copy_mode.as_ref().is_some_and(|copy_mode| { + copy_mode.selection.is_some() + || !copy_mode.search.query.is_empty() + || !copy_mode.search.matches.is_empty() + || copy_mode.search.direction.is_some() + }); + if should_clear { + self.clear_copy_mode_selection(); + if let Some(search) = self + .copy_mode + .as_mut() + .map(|copy_mode| &mut copy_mode.search) + { + let geometry = search.geometry; + *search = crate::app::state::CopyModeSearchState { + geometry, + ..Default::default() + }; + } + return; + } self.exit_copy_mode(terminal_runtimes, false); return; } @@ -162,6 +190,10 @@ impl AppState { '0' => self.copy_mode_line_edge(terminal_runtimes, false), '$' => self.copy_mode_line_edge(terminal_runtimes, true), '^' => self.copy_mode_first_non_blank(terminal_runtimes), + '/' => self.open_copy_mode_search(CopyModeSearchDirection::Forward), + '?' => self.open_copy_mode_search(CopyModeSearchDirection::Backward), + 'n' => self.repeat_copy_mode_search(terminal_runtimes, false), + 'N' => self.repeat_copy_mode_search(terminal_runtimes, true), '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), @@ -171,6 +203,185 @@ impl AppState { } } + fn handle_copy_mode_search_prompt_key( + &mut self, + terminal_runtimes: &TerminalRuntimeRegistry, + key: TerminalKey, + ) -> bool { + let Some(copy_mode) = self.copy_mode.as_mut() else { + return false; + }; + let Some(prompt) = copy_mode.search.prompt.as_mut() else { + return false; + }; + match key.code { + KeyCode::Esc => { + copy_mode.search.prompt = None; + } + KeyCode::Enter => { + let direction = prompt.direction; + let query = std::mem::take(&mut prompt.query); + copy_mode.search.prompt = None; + self.submit_copy_mode_search(terminal_runtimes, query, direction, false); + } + KeyCode::Backspace => { + prompt.query.pop(); + } + KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { + prompt.query.clear(); + } + _ => { + if let Some(ch) = copy_mode_command_char(key) { + prompt.query.push(ch); + } + } + } + true + } + + fn open_copy_mode_search(&mut self, direction: CopyModeSearchDirection) { + let Some(copy_mode) = self.copy_mode.as_mut() else { + return; + }; + copy_mode.search.prompt = Some(CopyModeSearchPrompt { + direction, + query: String::new(), + }); + } + + fn repeat_copy_mode_search( + &mut self, + terminal_runtimes: &TerminalRuntimeRegistry, + reverse: bool, + ) { + let Some(copy_mode) = self.copy_mode.as_ref() else { + return; + }; + if copy_mode.search.query.is_empty() { + return; + } + let Some(mut direction) = copy_mode.search.direction else { + return; + }; + if reverse { + direction = direction.reversed(); + } + self.submit_copy_mode_search( + terminal_runtimes, + copy_mode.search.query.clone(), + direction, + true, + ); + } + + fn submit_copy_mode_search( + &mut self, + terminal_runtimes: &TerminalRuntimeRegistry, + query: String, + direction: CopyModeSearchDirection, + repeat: bool, + ) { + if query.is_empty() { + return; + } + let Some(copy_mode) = self.copy_mode.as_ref() else { + return; + }; + let pane_id = copy_mode.pane_id; + let Some(ws_idx) = self.active else { + return; + }; + let Some(runtime) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id) + else { + return; + }; + let Some(metrics) = runtime.scroll_metrics() else { + return; + }; + let cursor = crate::pane::TerminalTextPoint { + row: viewport_top_row(metrics).saturating_add(u32::from(copy_mode.cursor_row)), + col: copy_mode.cursor_col, + }; + let previous_match = repeat + .then(|| { + copy_mode + .search + .current + .and_then(|index| copy_mode.search.matches.get(index).copied()) + }) + .flatten() + .filter(|text_match| { + text_match.start == cursor && runtime.text_match_is_current(*text_match) + }); + let matches = runtime.search_text_matches(&query, query.chars().any(char::is_uppercase)); + let current = search_match_index(&matches, direction, cursor, previous_match); + + if let Some(copy_mode) = self.copy_mode.as_mut() { + copy_mode.search.query = query; + if !repeat { + copy_mode.search.direction = Some(direction); + } + copy_mode.search.matches = matches; + copy_mode.search.current = current; + } + + let Some(target) = current.and_then(|index| { + self.copy_mode + .as_ref() + .and_then(|copy_mode| copy_mode.search.matches.get(index).copied()) + }) else { + return; + }; + self.move_copy_cursor_to_absolute(terminal_runtimes, target.start, true); + } + + fn move_copy_cursor_to_absolute( + &mut self, + terminal_runtimes: &TerminalRuntimeRegistry, + target: crate::pane::TerminalTextPoint, + reserve_overlay_row: bool, + ) { + let Some(copy_mode) = self.copy_mode.as_ref() else { + return; + }; + let pane_id = copy_mode.pane_id; + let Some(info) = self.pane_info_by_id(pane_id).cloned() else { + return; + }; + let Some(metrics) = self.pane_scroll_metrics(terminal_runtimes, pane_id) else { + return; + }; + let current_top = viewport_top_row(metrics); + let max_cursor_row = info + .inner_rect + .height + .saturating_sub(if reserve_overlay_row { 2 } else { 1 }); + let desired_top = if target.row < current_top { + target.row + } else if target.row > current_top.saturating_add(u32::from(max_cursor_row)) { + target.row.saturating_sub(u32::from(max_cursor_row)) + } else { + current_top + }; + let desired_offset = metrics + .max_offset_from_bottom + .saturating_sub(desired_top as usize); + self.set_pane_scroll_offset(terminal_runtimes, pane_id, desired_offset); + let Some(updated_metrics) = self.pane_scroll_metrics(terminal_runtimes, pane_id) else { + return; + }; + let updated_top = viewport_top_row(updated_metrics); + if let Some(copy_mode) = self.copy_mode.as_mut() { + copy_mode.cursor_row = target + .row + .saturating_sub(updated_top) + .min(u32::from(info.inner_rect.height.saturating_sub(1))) + as u16; + copy_mode.cursor_col = target.col.min(info.inner_rect.width.saturating_sub(1)); + } + self.sync_copy_mode_selection(terminal_runtimes); + } + pub(crate) fn cancel_copy_mode(&mut self, terminal_runtimes: &TerminalRuntimeRegistry) { self.exit_copy_mode(terminal_runtimes, false); } @@ -178,6 +389,7 @@ impl AppState { fn exit_copy_mode(&mut self, terminal_runtimes: &TerminalRuntimeRegistry, copy: bool) { let restore_scroll = self .copy_mode + .as_ref() .map(|copy_mode| (copy_mode.pane_id, copy_mode.entry_offset_from_bottom)); if copy { self.copy_selection(terminal_runtimes); @@ -196,7 +408,7 @@ impl AppState { } fn begin_copy_mode_selection(&mut self, terminal_runtimes: &TerminalRuntimeRegistry) { - let Some(copy_mode) = self.copy_mode else { + let Some(copy_mode) = self.copy_mode.as_ref() else { return; }; let Some(info) = self.pane_info_by_id(copy_mode.pane_id).cloned() else { @@ -221,23 +433,23 @@ impl AppState { } fn select_copy_mode_line(&mut self, terminal_runtimes: &TerminalRuntimeRegistry) { - let Some(mut copy_mode) = self.copy_mode else { + let Some(copy_mode) = self.copy_mode.as_ref() else { return; }; - let Some(info) = self.pane_info_by_id(copy_mode.pane_id) else { + let pane_id = copy_mode.pane_id; + let cursor_row = copy_mode.cursor_row; + let Some(info) = self.pane_info_by_id(pane_id) else { return; }; let end_col = info.inner_rect.width.saturating_sub(1); - let metrics = self.pane_scroll_metrics(terminal_runtimes, copy_mode.pane_id); - let anchor_row = Selection::absolute_row_for_viewport(copy_mode.cursor_row, metrics); + let metrics = self.pane_scroll_metrics(terminal_runtimes, pane_id); + let anchor_row = Selection::absolute_row_for_viewport(cursor_row, metrics); self.selection = Some(Selection::line_range( - copy_mode.pane_id, - anchor_row, - anchor_row, - end_col, + pane_id, anchor_row, anchor_row, end_col, )); - copy_mode.selection = Some(CopyModeSelection::Linewise { anchor_row }); - self.copy_mode = Some(copy_mode); + if let Some(copy_mode) = self.copy_mode.as_mut() { + copy_mode.selection = Some(CopyModeSelection::Linewise { anchor_row }); + } } fn move_copy_cursor( @@ -246,45 +458,48 @@ impl AppState { row_delta: i16, col_delta: i16, ) { - let Some(mut copy_mode) = self.copy_mode else { + let Some(copy_mode) = self.copy_mode.as_ref() else { return; }; - let Some(info) = self.pane_info_by_id(copy_mode.pane_id).cloned() else { + let pane_id = copy_mode.pane_id; + let mut cursor_row = copy_mode.cursor_row; + let mut cursor_col = copy_mode.cursor_col; + let Some(info) = self.pane_info_by_id(pane_id).cloned() else { self.exit_copy_mode(terminal_runtimes, false); return; }; if col_delta < 0 { - copy_mode.cursor_col = copy_mode - .cursor_col - .saturating_sub(col_delta.unsigned_abs()); + cursor_col = cursor_col.saturating_sub(col_delta.unsigned_abs()); } else if col_delta > 0 { - copy_mode.cursor_col = copy_mode - .cursor_col + cursor_col = cursor_col .saturating_add(col_delta as u16) .min(info.inner_rect.width.saturating_sub(1)); } if row_delta < 0 { let delta = row_delta.unsigned_abs(); - if copy_mode.cursor_row >= delta { - copy_mode.cursor_row -= delta; + if cursor_row >= delta { + cursor_row -= delta; } else { - self.scroll_pane_up(terminal_runtimes, copy_mode.pane_id, usize::from(delta)); - copy_mode.cursor_row = 0; + self.scroll_pane_up(terminal_runtimes, pane_id, usize::from(delta)); + cursor_row = 0; } } else if row_delta > 0 { let delta = row_delta as u16; let bottom = info.inner_rect.height.saturating_sub(1); - if copy_mode.cursor_row.saturating_add(delta) <= bottom { - copy_mode.cursor_row += delta; + if cursor_row.saturating_add(delta) <= bottom { + cursor_row += delta; } else { - self.scroll_pane_down(terminal_runtimes, copy_mode.pane_id, usize::from(delta)); - copy_mode.cursor_row = bottom; + self.scroll_pane_down(terminal_runtimes, pane_id, usize::from(delta)); + cursor_row = bottom; } } - self.copy_mode = Some(copy_mode); + if let Some(copy_mode) = self.copy_mode.as_mut() { + copy_mode.cursor_row = cursor_row; + copy_mode.cursor_col = cursor_col; + } self.sync_copy_mode_selection(terminal_runtimes); } @@ -294,15 +509,17 @@ impl AppState { direction: i16, half_page: bool, ) { - let Some(mut copy_mode) = self.copy_mode else { + let Some(copy_mode) = self.copy_mode.as_ref() else { return; }; - let Some(info) = self.pane_info_by_id(copy_mode.pane_id).cloned() else { + let pane_id = copy_mode.pane_id; + let mut cursor_row = copy_mode.cursor_row; + let Some(info) = self.pane_info_by_id(pane_id).cloned() else { self.exit_copy_mode(terminal_runtimes, false); return; }; let lines = copy_mode_page_lines(info.inner_rect.height, half_page); - if let Some(metrics) = self.pane_scroll_metrics(terminal_runtimes, copy_mode.pane_id) { + if let Some(metrics) = self.pane_scroll_metrics(terminal_runtimes, pane_id) { if direction < 0 { let next_offset = metrics.offset_from_bottom.saturating_add(lines); if next_offset > metrics.max_offset_from_bottom { @@ -312,96 +529,101 @@ impl AppState { let cursor_lines = lines.saturating_sub(scrolled_lines); self.set_pane_scroll_offset( terminal_runtimes, - copy_mode.pane_id, + pane_id, metrics.max_offset_from_bottom, ); - copy_mode.cursor_row = copy_mode - .cursor_row - .saturating_sub(cursor_lines.min(u16::MAX as usize) as u16); + cursor_row = + cursor_row.saturating_sub(cursor_lines.min(u16::MAX as usize) as u16); } else { - self.set_pane_scroll_offset(terminal_runtimes, copy_mode.pane_id, next_offset); + self.set_pane_scroll_offset(terminal_runtimes, pane_id, next_offset); } } else if metrics.offset_from_bottom < lines { let cursor_lines = lines.saturating_sub(metrics.offset_from_bottom); - self.set_pane_scroll_offset(terminal_runtimes, copy_mode.pane_id, 0); - copy_mode.cursor_row = copy_mode - .cursor_row + self.set_pane_scroll_offset(terminal_runtimes, pane_id, 0); + cursor_row = cursor_row .saturating_add(cursor_lines.min(u16::MAX as usize) as u16) .min(info.inner_rect.height.saturating_sub(1)); } else { self.set_pane_scroll_offset( terminal_runtimes, - copy_mode.pane_id, + pane_id, metrics.offset_from_bottom - lines, ); } } else if direction < 0 { - self.scroll_pane_up(terminal_runtimes, copy_mode.pane_id, lines); + self.scroll_pane_up(terminal_runtimes, pane_id, lines); } else { - self.scroll_pane_down(terminal_runtimes, copy_mode.pane_id, lines); + self.scroll_pane_down(terminal_runtimes, pane_id, lines); + } + if let Some(copy_mode) = self.copy_mode.as_mut() { + copy_mode.cursor_row = cursor_row; } - self.copy_mode = Some(copy_mode); self.sync_copy_mode_selection(terminal_runtimes); } fn copy_mode_history_top(&mut self, terminal_runtimes: &TerminalRuntimeRegistry) { - let Some(mut copy_mode) = self.copy_mode else { + let Some(copy_mode) = self.copy_mode.as_ref() else { return; }; - let Some(metrics) = self.pane_scroll_metrics(terminal_runtimes, copy_mode.pane_id) else { + let pane_id = copy_mode.pane_id; + let Some(metrics) = self.pane_scroll_metrics(terminal_runtimes, pane_id) else { return; }; - self.set_pane_scroll_offset( - terminal_runtimes, - copy_mode.pane_id, - metrics.max_offset_from_bottom, - ); - copy_mode.cursor_row = 0; - self.copy_mode = Some(copy_mode); + self.set_pane_scroll_offset(terminal_runtimes, pane_id, metrics.max_offset_from_bottom); + if let Some(copy_mode) = self.copy_mode.as_mut() { + copy_mode.cursor_row = 0; + } self.sync_copy_mode_selection(terminal_runtimes); } fn copy_mode_history_bottom(&mut self, terminal_runtimes: &TerminalRuntimeRegistry) { - let Some(mut copy_mode) = self.copy_mode else { + let Some(copy_mode) = self.copy_mode.as_ref() else { return; }; - let Some(info) = self.pane_info_by_id(copy_mode.pane_id) else { + let pane_id = copy_mode.pane_id; + let Some(info) = self.pane_info_by_id(pane_id) else { self.exit_copy_mode(terminal_runtimes, false); return; }; - self.set_pane_scroll_offset(terminal_runtimes, copy_mode.pane_id, 0); - copy_mode.cursor_row = info.inner_rect.height.saturating_sub(1); - self.copy_mode = Some(copy_mode); + let cursor_row = info.inner_rect.height.saturating_sub(1); + self.set_pane_scroll_offset(terminal_runtimes, pane_id, 0); + if let Some(copy_mode) = self.copy_mode.as_mut() { + copy_mode.cursor_row = cursor_row; + } self.sync_copy_mode_selection(terminal_runtimes); } fn copy_mode_line_edge(&mut self, terminal_runtimes: &TerminalRuntimeRegistry, end: bool) { - let Some(mut copy_mode) = self.copy_mode else { + let Some(copy_mode) = self.copy_mode.as_ref() else { return; }; - let Some(info) = self.pane_info_by_id(copy_mode.pane_id) else { + let pane_id = copy_mode.pane_id; + let Some(info) = self.pane_info_by_id(pane_id) else { self.exit_copy_mode(terminal_runtimes, false); return; }; - copy_mode.cursor_col = if end { + let cursor_col = if end { info.inner_rect.width.saturating_sub(1) } else { 0 }; - self.copy_mode = Some(copy_mode); + if let Some(copy_mode) = self.copy_mode.as_mut() { + copy_mode.cursor_col = cursor_col; + } self.sync_copy_mode_selection(terminal_runtimes); } fn copy_mode_first_non_blank(&mut self, terminal_runtimes: &TerminalRuntimeRegistry) { - let Some(mut copy_mode) = self.copy_mode else { + let Some(copy_mode) = self.copy_mode.as_ref() else { return; }; - let Some(text) = self.copy_mode_visible_row_text(terminal_runtimes, copy_mode.cursor_row) - else { + let cursor_row = copy_mode.cursor_row; + let Some(text) = self.copy_mode_visible_row_text(terminal_runtimes, cursor_row) else { return; }; - copy_mode.cursor_col = first_non_blank_col(&text).unwrap_or(0); - self.copy_mode = Some(copy_mode); + if let Some(copy_mode) = self.copy_mode.as_mut() { + copy_mode.cursor_col = first_non_blank_col(&text).unwrap_or(0); + } self.sync_copy_mode_selection(terminal_runtimes); } @@ -410,51 +632,67 @@ impl AppState { terminal_runtimes: &TerminalRuntimeRegistry, motion: WordMotion, ) { - let Some(mut copy_mode) = self.copy_mode else { + let Some(copy_mode) = self.copy_mode.as_ref() else { return; }; - let Some(info) = self.pane_info_by_id(copy_mode.pane_id) else { - self.exit_copy_mode(terminal_runtimes, false); + let Some(metrics) = self.pane_scroll_metrics(terminal_runtimes, copy_mode.pane_id) else { return; }; - let Some(text) = self.copy_mode_visible_row_text(terminal_runtimes, copy_mode.cursor_row) + let Some(ws_idx) = self.active else { + return; + }; + let Some(runtime) = + self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, copy_mode.pane_id) else { return; }; - let Some(col) = word_motion_target(&text, copy_mode.cursor_col, motion) else { + let absolute_row = + viewport_top_row(metrics).saturating_add(u32::from(copy_mode.cursor_row)); + let motion = match motion { + WordMotion::NextStart => crate::pane::TerminalWordMotion::NextStart, + WordMotion::PreviousStart => crate::pane::TerminalWordMotion::PreviousStart, + WordMotion::NextEnd => crate::pane::TerminalWordMotion::NextEnd, + }; + let Some(target) = runtime.word_motion_target(absolute_row, copy_mode.cursor_col, motion) + else { return; }; - copy_mode.cursor_col = col.min(info.inner_rect.width.saturating_sub(1)); - self.copy_mode = Some(copy_mode); - self.sync_copy_mode_selection(terminal_runtimes); + self.move_copy_cursor_to_absolute(terminal_runtimes, target, false); } fn copy_mode_paragraph(&mut self, terminal_runtimes: &TerminalRuntimeRegistry, direction: i16) { - let Some(copy_mode) = self.copy_mode else { + let Some(copy_mode) = self.copy_mode.as_ref() else { return; }; + let pane_id = copy_mode.pane_id; let Some(pane_height) = self - .pane_info_by_id(copy_mode.pane_id) + .pane_info_by_id(pane_id) .map(|info| info.inner_rect.height) else { self.exit_copy_mode(terminal_runtimes, false); return; }; let limit = self - .pane_scroll_metrics(terminal_runtimes, copy_mode.pane_id) + .pane_scroll_metrics(terminal_runtimes, pane_id) .map(|metrics| metrics.max_offset_from_bottom + metrics.viewport_rows) .unwrap_or(pane_height as usize) .clamp(1, 1000); for _ in 0..limit { - let before = self.copy_mode; + let before = self.copy_mode.as_ref().map(|copy_mode| { + ( + copy_mode.cursor_row, + copy_mode.cursor_col, + copy_mode.selection, + ) + }); let before_offset = self - .pane_scroll_metrics(terminal_runtimes, copy_mode.pane_id) + .pane_scroll_metrics(terminal_runtimes, pane_id) .map(|metrics| metrics.offset_from_bottom); self.move_copy_cursor(terminal_runtimes, direction, 0); - let Some(after) = self.copy_mode else { + let Some(after) = self.copy_mode.as_ref() else { return; }; if self @@ -468,8 +706,15 @@ impl AppState { else { continue; }; - let did_not_move = - before == self.copy_mode && before_offset == Some(after_metrics.offset_from_bottom); + let did_not_move = before + == self.copy_mode.as_ref().map(|copy_mode| { + ( + copy_mode.cursor_row, + copy_mode.cursor_col, + copy_mode.selection, + ) + }) + && before_offset == Some(after_metrics.offset_from_bottom); let at_top = direction < 0 && after.cursor_row == 0 && after_metrics.offset_from_bottom == after_metrics.max_offset_from_bottom; @@ -487,7 +732,7 @@ impl AppState { terminal_runtimes: &TerminalRuntimeRegistry, viewport_row: u16, ) -> Option<String> { - let copy_mode = self.copy_mode?; + let copy_mode = self.copy_mode.as_ref()?; let ws_idx = self.active?; let info = self.pane_info_by_id(copy_mode.pane_id)?; if viewport_row >= info.inner_rect.height || info.inner_rect.width == 0 { @@ -506,7 +751,7 @@ impl AppState { } pub(crate) fn copy_mode_pane_is_focused(&self) -> bool { - let Some(copy_mode) = self.copy_mode else { + let Some(copy_mode) = self.copy_mode.as_ref() else { return false; }; self.current_pane_focus_target() @@ -539,6 +784,26 @@ impl AppState { self.sync_copy_mode_with_focus(); } + pub(crate) fn sync_copy_mode_search_geometry(&mut self) { + let geometry = self.copy_mode.as_ref().and_then(|copy_mode| { + self.view + .pane_infos + .iter() + .find(|info| info.id == copy_mode.pane_id) + .map(|info| (info.inner_rect.width, info.inner_rect.height)) + }); + let Some(copy_mode) = self.copy_mode.as_mut() else { + return; + }; + if let Some(geometry) = geometry { + if copy_mode.search.geometry.is_some() && copy_mode.search.geometry != Some(geometry) { + copy_mode.search.matches.clear(); + copy_mode.search.current = None; + } + copy_mode.search.geometry = Some(geometry); + } + } + pub(crate) fn clear_copy_mode_selection(&mut self) { self.clear_selection(); if let Some(copy_mode) = self.copy_mode.as_mut() { @@ -550,7 +815,7 @@ impl AppState { &mut self, pane_ids: impl IntoIterator<Item = crate::layout::PaneId>, ) { - let Some(copy_mode) = self.copy_mode else { + let Some(copy_mode) = self.copy_mode.as_ref() else { return; }; if pane_ids @@ -570,7 +835,7 @@ impl AppState { } fn sync_copy_mode_selection(&mut self, terminal_runtimes: &TerminalRuntimeRegistry) { - let Some(copy_mode) = self.copy_mode else { + let Some(copy_mode) = self.copy_mode.as_ref() else { return; }; let Some(selection) = copy_mode.selection else { @@ -605,6 +870,49 @@ impl AppState { } } +impl CopyModeSearchDirection { + fn reversed(self) -> Self { + match self { + Self::Forward => Self::Backward, + Self::Backward => Self::Forward, + } + } +} + +fn viewport_top_row(metrics: crate::pane::ScrollMetrics) -> u32 { + metrics + .max_offset_from_bottom + .saturating_sub(metrics.offset_from_bottom) + .min(u32::MAX as usize) as u32 +} + +fn search_match_index( + matches: &[crate::pane::TerminalTextMatch], + direction: CopyModeSearchDirection, + cursor: crate::pane::TerminalTextPoint, + previous: Option<crate::pane::TerminalTextMatch>, +) -> Option<usize> { + if matches.is_empty() { + return None; + } + match direction { + CopyModeSearchDirection::Forward => { + let origin = previous.map_or(cursor, |text_match| text_match.end); + matches + .iter() + .position(|text_match| text_match.start > origin) + .or(Some(0)) + } + CopyModeSearchDirection::Backward => { + let origin = previous.map_or(cursor, |text_match| text_match.start); + matches + .iter() + .rposition(|text_match| text_match.end < origin) + .or_else(|| matches.len().checked_sub(1)) + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum WordMotion { NextStart, @@ -612,12 +920,6 @@ enum WordMotion { NextEnd, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct WordSpan { - start: u16, - end: u16, -} - fn first_non_blank_col(text: &str) -> Option<u16> { let mut col = 0u16; for ch in text.chars() { @@ -629,62 +931,6 @@ fn first_non_blank_col(text: &str) -> Option<u16> { None } -fn word_motion_target(text: &str, cursor_col: u16, motion: WordMotion) -> Option<u16> { - let spans = word_spans(text); - match motion { - WordMotion::NextStart => spans.iter().enumerate().find_map(|(idx, span)| { - if cursor_col < span.start { - Some(span.start) - } else if cursor_col >= span.start && cursor_col <= span.end { - spans.get(idx + 1).map(|next| next.start) - } else { - None - } - }), - WordMotion::PreviousStart => spans - .iter() - .rev() - .find(|span| span.start < cursor_col) - .map(|span| span.start), - WordMotion::NextEnd => spans.iter().find_map(|span| { - if cursor_col < span.end { - Some(span.end) - } else { - None - } - }), - } -} - -fn word_spans(text: &str) -> Vec<WordSpan> { - let mut spans = Vec::new(); - let mut col = 0u16; - let mut start = None; - - for ch in text.chars() { - let width = char_cell_width(ch); - if ch.is_whitespace() { - if let Some(start_col) = start.take() { - spans.push(WordSpan { - start: start_col, - end: col.saturating_sub(1), - }); - } - } else if start.is_none() { - start = Some(col); - } - col = col.saturating_add(width); - } - - if let Some(start_col) = start { - spans.push(WordSpan { - start: start_col, - end: col.saturating_sub(1), - }); - } - spans -} - fn char_cell_width(ch: char) -> u16 { UnicodeWidthChar::width(ch).unwrap_or(1).max(1) as u16 } @@ -904,7 +1150,10 @@ mod tests { let (mut app, pane_id) = app_with_copy_screen(b"alpha\nbeta\n"); app.state.enter_copy_mode(&app.terminal_runtimes); assert_eq!(app.state.mode, Mode::Copy); - assert_eq!(app.state.copy_mode.expect("copy mode").pane_id, pane_id); + assert_eq!( + app.state.copy_mode.as_ref().expect("copy mode").pane_id, + pane_id + ); } #[tokio::test] @@ -914,7 +1163,7 @@ mod tests { 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.expect("copy mode").cursor_row + 1; + let height = app.state.copy_mode.as_ref().expect("copy mode").cursor_row + 1; let expected_lines = copy_mode_page_lines(height, false); app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('b'), KeyModifiers::CONTROL)); @@ -944,7 +1193,7 @@ mod tests { async fn copy_mode_prefix_escape_returns_to_copy_mode() { let (mut app, _) = app_with_copy_screen(b"alpha\nbeta\n"); app.state.enter_copy_mode(&app.terminal_runtimes); - let copy_mode = app.state.copy_mode.expect("copy mode"); + let copy_mode = app.state.copy_mode.as_ref().expect("copy mode").clone(); app.handle_key(TerminalKey::new( app.state.prefix_code, @@ -962,7 +1211,7 @@ mod tests { async fn copy_mode_prefix_focus_keeps_copy_mode_on_source_pane() { let (mut app, first_pane, second_pane) = app_with_split_copy_screen(b"alpha\nbeta\n"); app.state.enter_copy_mode(&app.terminal_runtimes); - let copy_mode = app.state.copy_mode.expect("copy mode"); + let copy_mode = app.state.copy_mode.as_ref().expect("copy mode").clone(); assert_eq!(copy_mode.pane_id, first_pane); app.handle_key(TerminalKey::new( @@ -974,7 +1223,7 @@ mod tests { .await; assert_eq!(app.state.mode, Mode::Terminal); - assert_eq!(app.state.copy_mode, Some(copy_mode)); + assert_eq!(app.state.copy_mode, Some(copy_mode.clone())); assert_eq!( app.state.workspaces[0].tabs[0].layout.focused(), second_pane @@ -1054,7 +1303,10 @@ mod tests { crate::terminal::TerminalState::new(survivor_terminal, "/tmp".into()), ); app.state.enter_copy_mode(&app.terminal_runtimes); - assert_eq!(app.state.copy_mode.expect("copy mode").pane_id, first_pane); + assert_eq!( + app.state.copy_mode.as_ref().expect("copy mode").pane_id, + first_pane + ); app.handle_key(TerminalKey::new( app.state.prefix_code, @@ -1078,7 +1330,7 @@ mod tests { let bytes = numbered_lines_bytes(64); let (mut app, pane_id) = app_with_copy_scrollback(&bytes); app.state.enter_copy_mode(&app.terminal_runtimes); - let height = app.state.copy_mode.expect("copy mode").cursor_row + 1; + let height = app.state.copy_mode.as_ref().expect("copy mode").cursor_row + 1; let page_lines = copy_mode_page_lines(height, false); app.state .set_pane_scroll_offset(&app.terminal_runtimes, pane_id, page_lines); @@ -1099,13 +1351,332 @@ mod tests { } app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('w'), KeyModifiers::empty())); - assert_eq!(app.state.copy_mode.expect("copy mode").cursor_col, 4); + assert_eq!( + app.state.copy_mode.as_ref().expect("copy mode").cursor_col, + 4 + ); app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('e'), KeyModifiers::empty())); - assert_eq!(app.state.copy_mode.expect("copy mode").cursor_col, 6); + assert_eq!( + app.state.copy_mode.as_ref().expect("copy mode").cursor_col, + 6 + ); app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('b'), KeyModifiers::empty())); - assert_eq!(app.state.copy_mode.expect("copy mode").cursor_col, 4); + assert_eq!( + app.state.copy_mode.as_ref().expect("copy mode").cursor_col, + 4 + ); + } + + fn submit_copy_search(app: &mut App, marker: char, query: &str) { + app.handle_copy_mode_key(TerminalKey::new( + KeyCode::Char(marker), + KeyModifiers::empty(), + )); + for ch in query.chars() { + app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char(ch), KeyModifiers::empty())); + } + app.handle_copy_mode_key(TerminalKey::new(KeyCode::Enter, KeyModifiers::empty())); + } + + #[tokio::test] + async fn copy_mode_search_wraps_and_repeats_in_both_directions() { + let (mut app, pane_id) = app_with_copy_screen(b"alpha needle\r\nbeta needle\r\ngamma\r\n"); + app.state.enter_copy_mode(&app.terminal_runtimes); + + submit_copy_search(&mut app, '/', "needle"); + + let copy_mode = app.state.copy_mode.as_ref().expect("copy mode"); + assert_eq!(copy_mode.search.matches.len(), 2); + assert_eq!(copy_mode.search.current, Some(0)); + assert_eq!( + copy_mode_viewport_top_row(&app, pane_id) + usize::from(copy_mode.cursor_row), + 0 + ); + assert_eq!(copy_mode.cursor_col, 6); + + app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('n'), KeyModifiers::empty())); + let copy_mode = app.state.copy_mode.as_ref().expect("copy mode"); + assert_eq!(copy_mode.search.current, Some(1)); + assert_eq!( + copy_mode_viewport_top_row(&app, pane_id) + usize::from(copy_mode.cursor_row), + 1 + ); + assert_eq!(copy_mode.cursor_col, 5); + + app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('N'), KeyModifiers::SHIFT)); + let copy_mode = app.state.copy_mode.as_ref().expect("copy mode"); + assert_eq!(copy_mode.search.current, Some(0)); + assert_eq!( + copy_mode_viewport_top_row(&app, pane_id) + usize::from(copy_mode.cursor_row), + 0 + ); + + app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('n'), KeyModifiers::empty())); + assert_eq!( + app.state + .copy_mode + .as_ref() + .expect("copy mode") + .search + .current, + Some(1) + ); + } + + #[tokio::test] + async fn copy_mode_backward_search_uses_last_match_before_cursor() { + let (mut app, pane_id) = app_with_copy_screen(b"alpha needle\r\nbeta needle\r\ngamma\r\n"); + app.state.enter_copy_mode(&app.terminal_runtimes); + + submit_copy_search(&mut app, '?', "needle"); + + let copy_mode = app.state.copy_mode.as_ref().expect("copy mode"); + assert_eq!(copy_mode.search.current, Some(1)); + assert_eq!( + copy_mode_viewport_top_row(&app, pane_id) + usize::from(copy_mode.cursor_row), + 1 + ); + } + + #[tokio::test] + async fn copy_mode_shifted_slash_opens_backward_search() { + let (mut app, _) = app_with_copy_screen(b"alpha needle\r\n"); + app.state.enter_copy_mode(&app.terminal_runtimes); + + app.handle_copy_mode_key( + TerminalKey::new(KeyCode::Char('/'), KeyModifiers::SHIFT) + .with_shifted_codepoint('?' as u32), + ); + + assert_eq!( + app.state + .copy_mode + .as_ref() + .and_then(|copy_mode| copy_mode.search.prompt.as_ref()) + .map(|prompt| prompt.direction), + Some(CopyModeSearchDirection::Backward) + ); + } + + #[tokio::test] + async fn copy_mode_search_extends_an_active_selection() { + let (mut app, _) = app_with_copy_screen(b"alpha\r\nbeta needle\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())); + + submit_copy_search(&mut app, '/', "needle"); + app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('y'), KeyModifiers::empty())); + + assert_eq!(copy_mode_clipboard_text(&mut app), "alpha\nbeta n"); + } + + #[tokio::test] + async fn copy_mode_search_prompt_escape_and_failure_preserve_copy_mode() { + let (mut app, pane_id) = app_with_copy_screen(b"alpha\r\nbeta\r\n"); + app.state.enter_copy_mode(&app.terminal_runtimes); + let before = ( + copy_mode_viewport_top_row(&app, pane_id), + app.state.copy_mode.as_ref().expect("copy mode").cursor_row, + app.state.copy_mode.as_ref().expect("copy mode").cursor_col, + ); + + app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('/'), KeyModifiers::empty())); + app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('x'), KeyModifiers::empty())); + app.handle_copy_mode_key(TerminalKey::new(KeyCode::Esc, KeyModifiers::empty())); + assert_eq!(app.state.mode, Mode::Copy); + assert!(app + .state + .copy_mode + .as_ref() + .expect("copy mode") + .search + .prompt + .is_none()); + + submit_copy_search(&mut app, '/', "missing"); + let copy_mode = app.state.copy_mode.as_ref().expect("copy mode"); + assert_eq!(copy_mode.search.query, "missing"); + assert!(copy_mode.search.matches.is_empty()); + assert_eq!( + ( + copy_mode_viewport_top_row(&app, pane_id), + copy_mode.cursor_row, + copy_mode.cursor_col, + ), + before + ); + } + + #[tokio::test] + async fn copy_mode_escape_clears_search_before_exiting() { + let (mut app, _) = app_with_copy_screen(b"alpha needle\r\n"); + app.state.enter_copy_mode(&app.terminal_runtimes); + submit_copy_search(&mut app, '/', "needle"); + + app.handle_copy_mode_key(TerminalKey::new(KeyCode::Esc, KeyModifiers::empty())); + + let search = &app.state.copy_mode.as_ref().expect("copy mode").search; + assert!(search.query.is_empty()); + assert!(search.matches.is_empty()); + assert!(search.current.is_none()); + assert_eq!(app.state.mode, Mode::Copy); + + app.handle_copy_mode_key(TerminalKey::new(KeyCode::Esc, KeyModifiers::empty())); + assert_eq!(app.state.mode, Mode::Terminal); + assert!(app.state.copy_mode.is_none()); + } + + #[tokio::test] + async fn copy_mode_escape_clears_selection_before_exiting() { + let (mut app, _) = app_with_copy_screen(b"alpha\r\n"); + app.state.enter_copy_mode(&app.terminal_runtimes); + app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('v'), KeyModifiers::empty())); + assert!(app.state.selection.is_some()); + + app.handle_copy_mode_key(TerminalKey::new(KeyCode::Esc, KeyModifiers::empty())); + + assert_eq!(app.state.mode, Mode::Copy); + assert!(app.state.selection.is_none()); + assert!(app + .state + .copy_mode + .as_ref() + .expect("copy mode") + .selection + .is_none()); + + app.handle_copy_mode_key(TerminalKey::new(KeyCode::Esc, KeyModifiers::empty())); + assert_eq!(app.state.mode, Mode::Terminal); + } + + #[tokio::test] + async fn copy_mode_search_prompt_accepts_paste_without_exiting() { + let (mut app, _) = app_with_copy_screen(b"alpha needle\r\n"); + app.state.enter_copy_mode(&app.terminal_runtimes); + app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('/'), KeyModifiers::empty())); + + assert!(app.paste_into_active_text_input("needle")); + assert_eq!( + app.state + .copy_mode + .as_ref() + .and_then(|copy_mode| copy_mode.search.prompt.as_ref()) + .map(|prompt| prompt.query.as_str()), + Some("needle") + ); + assert_eq!(app.state.mode, Mode::Copy); + } + + #[tokio::test] + async fn copy_mode_word_motions_cross_rows_and_respect_separators() { + let (mut app, pane_id) = app_with_copy_screen(b"foo.bar_baz\r\nqux\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 [3, 4] { + 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, + expected_col + ); + } + app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('w'), KeyModifiers::empty())); + 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::empty())); + 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, 4); + } + + #[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); + app.state.enter_copy_mode(&app.terminal_runtimes); + submit_copy_search(&mut app, '/', "missing"); + assert_eq!(copy_mode_offset_from_bottom(&app, pane_id), 0); + + app.state + .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) + .expect("runtime") + .test_process_pty_bytes(b"live output\r\n"); + assert_eq!(copy_mode_offset_from_bottom(&app, pane_id), 0); + + submit_copy_search(&mut app, '?', "000000"); + assert!(copy_mode_offset_from_bottom(&app, pane_id) > 0); + let runtime = app + .state + .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) + .expect("runtime"); + let visible_before = runtime.visible_text(); + runtime.test_process_pty_bytes(b"more output\r\n"); + assert_eq!(runtime.visible_text(), visible_before); + } + + #[tokio::test] + async fn copy_mode_resize_clears_matches_but_keeps_query() { + let (mut app, _) = app_with_copy_screen(b"alpha needle\r\n"); + app.state.enter_copy_mode(&app.terminal_runtimes); + submit_copy_search(&mut app, '/', "needle"); + assert!(!app + .state + .copy_mode + .as_ref() + .expect("copy mode") + .search + .matches + .is_empty()); + + app.state.view.pane_infos[0].inner_rect.width = app.state.view.pane_infos[0] + .inner_rect + .width + .saturating_sub(1); + app.state.sync_copy_mode_search_geometry(); + + let search = &app.state.copy_mode.as_ref().expect("copy mode").search; + assert_eq!(search.query, "needle"); + assert!(search.matches.is_empty()); + assert!(search.current.is_none()); + } + + #[tokio::test] + async fn copy_mode_hidden_source_does_not_look_like_a_resize() { + let (mut app, _) = app_with_copy_screen(b"alpha needle\r\n"); + app.state.enter_copy_mode(&app.terminal_runtimes); + submit_copy_search(&mut app, '/', "needle"); + let before = app + .state + .copy_mode + .as_ref() + .expect("copy mode") + .search + .clone(); + + app.state.view.pane_infos.clear(); + app.state.sync_copy_mode_search_geometry(); + + assert_eq!( + app.state.copy_mode.as_ref().expect("copy mode").search, + before + ); } #[tokio::test] @@ -1221,7 +1792,7 @@ mod tests { let bytes = numbered_lines_bytes(64); let (mut app, pane_id) = app_with_copy_scrollback(&bytes); app.state.enter_copy_mode(&app.terminal_runtimes); - let height = app.state.copy_mode.expect("copy mode").cursor_row + 1; + let height = app.state.copy_mode.as_ref().expect("copy mode").cursor_row + 1; let expected_lines = copy_mode_page_lines(height, false); app.handle_copy_mode_key(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())); @@ -1234,7 +1805,7 @@ mod tests { let bytes = numbered_lines_bytes(64); let (mut app, pane_id) = app_with_copy_scrollback(&bytes); app.state.enter_copy_mode(&app.terminal_runtimes); - let bottom = app.state.copy_mode.expect("copy mode").cursor_row; + let bottom = app.state.copy_mode.as_ref().expect("copy mode").cursor_row; let lines = copy_mode_page_lines(bottom + 1, true); let metrics = copy_mode_scroll_metrics(&app, pane_id); assert!(metrics.max_offset_from_bottom >= lines); @@ -1249,7 +1820,7 @@ mod tests { app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); - let copy_mode = app.state.copy_mode.expect("copy mode"); + let copy_mode = app.state.copy_mode.as_ref().expect("copy mode"); let expected_cursor_delta = 1; assert_eq!( copy_mode_offset_from_bottom(&app, pane_id), @@ -1266,7 +1837,7 @@ mod tests { let bytes = numbered_lines_bytes(64); let (mut app, pane_id) = app_with_copy_scrollback(&bytes); app.state.enter_copy_mode(&app.terminal_runtimes); - let bottom = app.state.copy_mode.expect("copy mode").cursor_row; + let bottom = app.state.copy_mode.as_ref().expect("copy mode").cursor_row; let lines = copy_mode_page_lines(bottom + 1, true); assert!(lines > 1); app.state @@ -1277,7 +1848,7 @@ mod tests { app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('d'), KeyModifiers::CONTROL)); - let copy_mode = app.state.copy_mode.expect("copy mode"); + let copy_mode = app.state.copy_mode.as_ref().expect("copy mode"); assert_eq!(copy_mode_offset_from_bottom(&app, pane_id), 0); assert_eq!(copy_mode.cursor_row, 1); } @@ -1328,19 +1899,31 @@ mod tests { } app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('6'), KeyModifiers::SHIFT)); - assert_eq!(app.state.copy_mode.expect("copy mode").cursor_col, 0); + assert_eq!( + app.state.copy_mode.as_ref().expect("copy mode").cursor_col, + 0 + ); app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char(']'), KeyModifiers::SHIFT)); - assert_eq!(app.state.copy_mode.expect("copy mode").cursor_row, 3); + assert_eq!( + app.state.copy_mode.as_ref().expect("copy mode").cursor_row, + 3 + ); app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('['), KeyModifiers::SHIFT)); - assert_eq!(app.state.copy_mode.expect("copy mode").cursor_row, 1); + assert_eq!( + app.state.copy_mode.as_ref().expect("copy mode").cursor_row, + 1 + ); app.handle_copy_mode_key( TerminalKey::new(KeyCode::Char(']'), KeyModifiers::SHIFT) .with_shifted_codepoint('}' as u32), ); - assert_eq!(app.state.copy_mode.expect("copy mode").cursor_row, 3); + assert_eq!( + app.state.copy_mode.as_ref().expect("copy mode").cursor_row, + 3 + ); } #[tokio::test] diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs index 450afab8..e4da8bc9 100644 --- a/src/app/input/mod.rs +++ b/src/app/input/mod.rs @@ -155,6 +155,20 @@ impl App { insert_navigator_search_text(&mut self.state, &self.terminal_runtimes, text); true } + Mode::Copy => { + let Some(prompt) = self + .state + .copy_mode + .as_mut() + .and_then(|copy_mode| copy_mode.search.prompt.as_mut()) + else { + return false; + }; + prompt + .query + .extend(text.chars().filter(|ch| !ch.is_control())); + true + } _ => false, } } @@ -513,6 +527,10 @@ pub(crate) fn modal_paste_target_active(state: &AppState) -> bool { .as_ref() .is_some_and(|open| open.search_focused), Mode::Navigator => state.navigator.search_focused, + Mode::Copy => state + .copy_mode + .as_ref() + .is_some_and(|copy_mode| copy_mode.search.prompt.is_some()), _ => false, } } diff --git a/src/app/state.rs b/src/app/state.rs index eee61566..338c9ca3 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -844,13 +844,14 @@ pub(crate) struct NavigatorState { pub expanded_workspaces: std::collections::HashSet<String>, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct CopyModeState { pub pane_id: PaneId, pub cursor_row: u16, pub cursor_col: u16, pub entry_offset_from_bottom: usize, pub selection: Option<CopyModeSelection>, + pub search: CopyModeSearchState, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -859,6 +860,28 @@ pub(crate) enum CopyModeSelection { Linewise { anchor_row: u32 }, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CopyModeSearchDirection { + Forward, + Backward, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CopyModeSearchPrompt { + pub direction: CopyModeSearchDirection, + pub query: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct CopyModeSearchState { + pub prompt: Option<CopyModeSearchPrompt>, + pub query: String, + pub direction: Option<CopyModeSearchDirection>, + pub matches: Vec<crate::pane::TerminalTextMatch>, + pub current: Option<usize>, + pub geometry: Option<(u16, u16)>, +} + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub enum AgentPanelSort { #[default] diff --git a/src/ghostty/mod.rs b/src/ghostty/mod.rs index 97b22f59..a65272c6 100644 --- a/src/ghostty/mod.rs +++ b/src/ghostty/mod.rs @@ -417,6 +417,19 @@ pub enum CellWide { SpacerHead, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ScreenTextCell { + pub wide: CellWide, + pub graphemes: Vec<u32>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ScreenTextRow { + pub cells: Vec<ScreenTextCell>, + pub soft_wrapped: bool, + pub wrap_continuation: bool, +} + impl CellWide { fn from_raw(value: ffi::GhosttyCellWide) -> Self { match value { @@ -768,6 +781,43 @@ impl Terminal { Ok((wide, graphemes)) } + pub(crate) fn screen_text_rows(&self) -> Result<Vec<ScreenTextRow>, Error> { + self.screen_text_rows_range(0, usize::MAX) + } + + pub(crate) fn screen_text_rows_range( + &self, + start_row: usize, + end_row_exclusive: usize, + ) -> Result<Vec<ScreenTextRow>, Error> { + let total_rows = self.total_rows()?; + let start_row = start_row.min(total_rows); + let end_row_exclusive = end_row_exclusive.min(total_rows).max(start_row); + let cols = self.cols()?; + let mut rows = Vec::with_capacity(end_row_exclusive.saturating_sub(start_row)); + for y in start_row..end_row_exclusive { + let Some(y) = u32::try_from(y).ok() else { + break; + }; + let mut grid_ref = self.grid_ref(ghostty_screen_point(0, y))?; + let (soft_wrapped, wrap_continuation) = grid_ref_wrap_state(&grid_ref)?; + let mut cells = Vec::with_capacity(usize::from(cols)); + for x in 0..cols { + grid_ref.x = x; + cells.push(ScreenTextCell { + wide: grid_ref_wide(&grid_ref)?, + graphemes: grid_ref_graphemes(&grid_ref)?, + }); + } + rows.push(ScreenTextRow { + cells, + soft_wrapped, + wrap_continuation, + }); + } + Ok(rows) + } + fn viewport_graphemes_and_style(&self, x: u16, y: u32) -> Result<(Vec<u32>, CellStyle), Error> { let grid_ref = self.grid_ref(ghostty_viewport_point(x, y))?; let graphemes = grid_ref_graphemes(&grid_ref)?; @@ -1531,6 +1581,30 @@ fn grid_ref_wide(grid_ref: &ffi::GhosttyGridRef) -> Result<CellWide, Error> { Ok(CellWide::from_raw(wide)) } +fn grid_ref_wrap_state(grid_ref: &ffi::GhosttyGridRef) -> Result<(bool, bool), Error> { + let mut row = 0; + unsafe { + ffi::ghostty_grid_ref_row(grid_ref, &mut row).into_result()?; + } + let mut soft_wrapped = false; + let mut wrap_continuation = false; + unsafe { + ffi::ghostty_row_get( + row, + ffi::GhosttyRowData_GHOSTTY_ROW_DATA_WRAP, + (&mut soft_wrapped as *mut bool).cast(), + ) + .into_result()?; + ffi::ghostty_row_get( + row, + ffi::GhosttyRowData_GHOSTTY_ROW_DATA_WRAP_CONTINUATION, + (&mut wrap_continuation as *mut bool).cast(), + ) + .into_result()?; + } + Ok((soft_wrapped, wrap_continuation)) +} + fn kitty_placement_u32( iterator: ffi::GhosttyKittyGraphicsPlacementIterator, data: ffi::GhosttyKittyGraphicsPlacementData, @@ -3240,6 +3314,25 @@ mod tests { } #[test] + fn screen_text_rows_preserve_wrap_and_grapheme_cells() { + let mut terminal = Terminal::new(5, 3, 100).unwrap(); + terminal.write("abcdef\r\n界e\u{301}".as_bytes()); + + let rows = terminal.screen_text_rows().unwrap(); + + assert_eq!(rows.len(), 3); + assert!(rows[0].soft_wrapped); + assert!(!rows[0].wrap_continuation); + assert!(!rows[1].soft_wrapped); + assert!(rows[1].wrap_continuation); + assert!(!rows[2].wrap_continuation); + assert_eq!(rows[2].cells[0].wide, CellWide::Wide); + assert_eq!(rows[2].cells[0].graphemes, vec!['界' as u32]); + assert_eq!(rows[2].cells[1].wide, CellWide::SpacerTail); + assert_eq!(rows[2].cells[2].graphemes, vec!['e' as u32, 0x301]); + } + + #[test] fn render_state_row_dirty_can_be_cleared_independently() { let mut terminal = Terminal::new(8, 3, 100).unwrap(); let mut render_state = RenderState::new().unwrap(); diff --git a/src/pane.rs b/src/pane.rs index a6edaaa7..19d356dc 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -40,7 +40,10 @@ use self::agent_detection::{ AGENT_PENDING_IDLE_RECHECK, AGENT_STARTUP_GRACE_WINDOW, }; use self::terminal::{GhosttyPaneTerminal, PaneTerminal}; -pub(crate) use self::terminal::{TerminalDirtyPatch, TerminalDirtyPatchOutcome}; +pub(crate) use self::terminal::{ + TerminalDirtyPatch, TerminalDirtyPatchOutcome, TerminalTextMatch, TerminalTextPoint, + TerminalWordMotion, +}; pub use self::{ state::PaneState, terminal::{InputState, ScrollMetrics, TerminalCursorState}, @@ -2383,6 +2386,34 @@ impl PaneRuntime { self.terminal.scroll_metrics() } + pub(crate) fn search_text_matches( + &self, + query: &str, + case_sensitive: bool, + ) -> Vec<crate::pane::TerminalTextMatch> { + self.terminal.search_text_matches(query, case_sensitive) + } + + pub(crate) fn text_match_is_current(&self, text_match: crate::pane::TerminalTextMatch) -> bool { + self.terminal.text_match_is_current(text_match) + } + + pub(crate) fn text_matches_are_current( + &self, + text_matches: &[crate::pane::TerminalTextMatch], + ) -> Vec<bool> { + self.terminal.text_matches_are_current(text_matches) + } + + pub(crate) fn word_motion_target( + &self, + row: u32, + col: u16, + motion: crate::pane::TerminalWordMotion, + ) -> Option<crate::pane::TerminalTextPoint> { + self.terminal.word_motion_target(row, col, motion) + } + pub fn input_state(&self) -> Option<InputState> { self.terminal.input_state() } 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 { diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs index 5e4f778e..1ff799b8 100644 --- a/src/terminal/runtime.rs +++ b/src/terminal/runtime.rs @@ -251,6 +251,34 @@ impl TerminalRuntime { self.0.scroll_metrics() } + pub(crate) fn search_text_matches( + &self, + query: &str, + case_sensitive: bool, + ) -> Vec<crate::pane::TerminalTextMatch> { + self.0.search_text_matches(query, case_sensitive) + } + + pub(crate) fn text_match_is_current(&self, text_match: crate::pane::TerminalTextMatch) -> bool { + self.0.text_match_is_current(text_match) + } + + pub(crate) fn text_matches_are_current( + &self, + text_matches: &[crate::pane::TerminalTextMatch], + ) -> Vec<bool> { + self.0.text_matches_are_current(text_matches) + } + + pub(crate) fn word_motion_target( + &self, + row: u32, + col: u16, + motion: crate::pane::TerminalWordMotion, + ) -> Option<crate::pane::TerminalTextPoint> { + self.0.word_motion_target(row, col, motion) + } + pub fn input_state(&self) -> Option<crate::pane::InputState> { self.0.input_state() } @@ -317,6 +317,7 @@ fn compute_view_internal( pane_infos, split_borders, }; + app.sync_copy_mode_search_geometry(); } fn compute_mobile_view( @@ -387,6 +388,7 @@ fn compute_mobile_view( pane_infos, split_borders, }; + app.sync_copy_mode_search_geometry(); } /// Render the UI — reads AppState but does not mutate it. diff --git a/src/ui/menus.rs b/src/ui/menus.rs index 42b4ec70..763c0ea8 100644 --- a/src/ui/menus.rs +++ b/src/ui/menus.rs @@ -70,26 +70,57 @@ pub(super) fn render_copy_mode_overlay(app: &AppState, frame: &mut Frame, area: .bg(app.palette.accent) .add_modifier(Modifier::BOLD); - let select = if app - .copy_mode - .is_some_and(|copy_mode| copy_mode.selection.is_some()) - { - "selecting" + let Some(copy_mode) = app.copy_mode.as_ref() else { + return; + }; + let line = if let Some(prompt) = copy_mode.search.prompt.as_ref() { + let marker = match prompt.direction { + crate::app::state::CopyModeSearchDirection::Forward => "/", + crate::app::state::CopyModeSearchDirection::Backward => "?", + }; + Line::from(vec![ + Span::styled(" COPY ", mode_style), + Span::raw(" "), + Span::styled(marker, key), + Span::styled(prompt.query.clone(), Style::default().fg(app.palette.text)), + Span::styled("█", key), + Span::styled(" enter search esc cancel", dim), + ]) } else { - "select" + let select = if copy_mode.selection.is_some() { + "selecting" + } else { + "select" + }; + let match_status = copy_mode + .search + .current + .map(|current| format!(" {}/{}", current + 1, copy_mode.search.matches.len())) + .or_else(|| (!copy_mode.search.query.is_empty()).then(|| " 0/0".to_string())) + .unwrap_or_default(); + let (exit_keys, exit_label) = + if copy_mode.search.query.is_empty() && copy_mode.selection.is_none() { + ("q/esc", " exit") + } else { + ("esc", " clear q exit") + }; + Line::from(vec![ + Span::styled(" COPY ", mode_style), + Span::raw(" "), + Span::styled("h/j/k/l w/b/e { }", key), + Span::styled(" move ", dim), + Span::styled("/ ?", key), + Span::styled(" search ", dim), + Span::styled("n/N", key), + Span::styled(format!(" repeat{match_status} "), dim), + Span::styled("v/space", key), + Span::styled(format!(" {select} "), dim), + Span::styled("y/enter", key), + Span::styled(" copy ", dim), + Span::styled(exit_keys, key), + Span::styled(exit_label, dim), + ]) }; - let line = Line::from(vec![ - Span::styled(" COPY ", mode_style), - Span::raw(" "), - Span::styled("h/j/k/l w/b/e { }", key), - Span::styled(" move ", dim), - Span::styled("v/space", key), - Span::styled(format!(" {select} "), dim), - Span::styled("y/enter", key), - Span::styled(" copy ", dim), - Span::styled("q/esc", key), - Span::styled(" exit", dim), - ]); let overlay_y = area.y + area.height.saturating_sub(1); let overlay_area = Rect::new(area.x, overlay_y, area.width, 1); diff --git a/src/ui/panes.rs b/src/ui/panes.rs index c071a010..3241de12 100644 --- a/src/ui/panes.rs +++ b/src/ui/panes.rs @@ -331,6 +331,17 @@ pub(super) fn render_panes( } } + let (copy_search_top, copy_search_bottom, copy_search_matches) = + validated_copy_mode_search_matches(app, info, rt); + render_copy_mode_search_highlights( + app, + frame, + info, + copy_search_top, + copy_search_bottom, + ©_search_matches, + false, + ); render_selection_highlight( &app.selection, frame, @@ -340,6 +351,15 @@ pub(super) fn render_panes( &app.palette, app.host_terminal_theme, ); + render_copy_mode_search_highlights( + app, + frame, + info, + copy_search_top, + copy_search_bottom, + ©_search_matches, + true, + ); render_copy_mode_cursor(app, frame, info); } } @@ -598,7 +618,7 @@ fn render_copy_mode_cursor(app: &AppState, frame: &mut Frame, info: &PaneInfo) { if app.mode != Mode::Copy { return; } - let Some(copy_mode) = app.copy_mode else { + let Some(copy_mode) = app.copy_mode.as_ref() else { return; }; if copy_mode.pane_id != info.id @@ -619,6 +639,96 @@ fn render_copy_mode_cursor(app: &AppState, frame: &mut Frame, info: &PaneInfo) { ); } +fn validated_copy_mode_search_matches( + app: &AppState, + info: &PaneInfo, + rt: &crate::terminal::TerminalRuntime, +) -> (u32, u32, Vec<(usize, crate::pane::TerminalTextMatch)>) { + let Some(copy_mode) = app.copy_mode.as_ref() else { + return (0, 0, Vec::new()); + }; + if copy_mode.pane_id != info.id { + return (0, 0, Vec::new()); + } + let Some(metrics) = rt.scroll_metrics() else { + return (0, 0, Vec::new()); + }; + let top = metrics + .max_offset_from_bottom + .saturating_sub(metrics.offset_from_bottom) + .min(u32::MAX as usize) as u32; + let bottom = top.saturating_add(u32::from(info.inner_rect.height.saturating_sub(1))); + let first_visible = copy_mode + .search + .matches + .partition_point(|text_match| text_match.end.row < top); + let visible = ©_mode.search.matches[first_visible..]; + let visible_len = visible.partition_point(|text_match| text_match.start.row <= bottom); + let candidates = visible[..visible_len].to_vec(); + let validity = rt.text_matches_are_current(&candidates); + + let matches = candidates + .into_iter() + .zip(validity) + .enumerate() + .filter_map(|(offset, (text_match, is_current))| { + is_current.then_some((first_visible + offset, text_match)) + }) + .collect(); + (top, bottom, matches) +} + +fn render_copy_mode_search_highlights( + app: &AppState, + frame: &mut Frame, + info: &PaneInfo, + top: u32, + bottom: u32, + matches: &[(usize, crate::pane::TerminalTextMatch)], + current_only: bool, +) { + let Some(copy_mode) = app.copy_mode.as_ref() else { + return; + }; + let current = copy_mode.search.current; + let style = if current_only { + Style::default() + .fg(panel_contrast_fg(&app.palette)) + .bg(app.palette.accent) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + .fg(app.palette.text) + .bg(app.palette.surface1) + }; + + for &(index, text_match) in matches { + if (current == Some(index)) != current_only { + continue; + } + let start_row = text_match.start.row.max(top); + let end_row = text_match.end.row.min(bottom); + for absolute_row in start_row..=end_row { + let viewport_row = absolute_row.saturating_sub(top) as u16; + let start_col = if absolute_row == text_match.start.row { + text_match.start.col + } else { + 0 + }; + let end_col = if absolute_row == text_match.end.row { + text_match.end.col + } else { + info.inner_rect.width.saturating_sub(1) + }; + for col in start_col..=end_col.min(info.inner_rect.width.saturating_sub(1)) { + let x = info.inner_rect.x.saturating_add(col); + let y = info.inner_rect.y.saturating_add(viewport_row); + frame.buffer_mut()[(x, y)].set_style(style); + } + } + } +} + fn render_selection_highlight( selection: &Option<crate::selection::Selection>, frame: &mut Frame, |
