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 | |
| parent | 0de503ccd9a8fedeeb6cbcc1446ab2bc644fdbb6 (diff) | |
feat: add copy mode search and tmux word motions
refs #1230
| -rw-r--r-- | docs/next/CHANGELOG.md | 3 | ||||
| -rw-r--r-- | docs/next/README.md | 2 | ||||
| -rw-r--r-- | docs/next/website/src/content/docs/ja/keyboard.mdx | 2 | ||||
| -rw-r--r-- | docs/next/website/src/content/docs/keyboard.mdx | 2 | ||||
| -rw-r--r-- | docs/next/website/src/content/docs/zh-cn/keyboard.mdx | 2 | ||||
| -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 |
16 files changed, 1880 insertions, 192 deletions
diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index 35c8e60f..009a8862 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +### Added +- Copy mode now supports literal smart-case search with `/` and `?`, repeating with `n` and `N`, match highlighting, and tmux-style cross-line `w`/`b`/`e` word motions. (#1230) + ### Fixed - `herdr --remote` now installs remote helper binaries without routing the binary stream through a multiline `/bin/sh -c` command, fixing installs for non-POSIX login shells such as xonsh. (#1203, thanks @nhumrich) diff --git a/docs/next/README.md b/docs/next/README.md index 80a13838..9211a4de 100644 --- a/docs/next/README.md +++ b/docs/next/README.md @@ -65,7 +65,7 @@ Press `ctrl+b q` to detach the client. The server and pane processes keep runnin **Workspaces, tabs, panes.** A workspace is the project-level container. Tabs group panes inside a workspace. Panes are real terminal processes, not rewritten agent views. -**Copy.** Herdr copies pane text, not the sidebar. Drag-select inside a pane, double-click a word or token, or press `prefix+[` for keyboard copy mode. In copy mode, move with `h/j/k/l`, `w/b/e`, and `{`/`}`, start selection with `v` or Space, copy with `y` or Enter, and leave with `q` or Esc. In PuTTY and some SSH terminals, hold `Shift` while dragging to use the terminal's own selection, and `Shift` + right click to paste. +**Copy.** Herdr copies pane text, not the sidebar. Drag-select inside a pane, double-click a word or token, or press `prefix+[` for keyboard copy mode. In copy mode, move with `h/j/k/l`, `w/b/e`, and `{`/`}`, search forward or backward with `/` or `?`, repeat with `n` or `N`, start selection with `v` or Space, copy with `y` or Enter, and leave with `q` or Esc. Esc clears an active selection or search before exiting. In PuTTY and some SSH terminals, hold `Shift` while dragging to use the terminal's own selection, and `Shift` + right click to paste. **Update and restore.** `herdr update` installs a new binary, but a running server keeps using the old process until it is stopped or handed off. Stop the old server to use the new version. Stopping exits pane processes. Run `herdr server stop`, then run `herdr` again for the default session. For a named session, run `herdr session stop <name>`, then run `herdr session attach <name>` again. `herdr update --handoff` is experimental and tries to move live panes, including foreground processes such as dev servers, from the old server to the new one. With current official integrations installed, supported agent panes can restart from their native agent sessions after a server restart or update. diff --git a/docs/next/website/src/content/docs/ja/keyboard.mdx b/docs/next/website/src/content/docs/ja/keyboard.mdx index cb6a3f0d..f9bb4ac8 100644 --- a/docs/next/website/src/content/docs/ja/keyboard.mdx +++ b/docs/next/website/src/content/docs/ja/keyboard.mdx @@ -64,7 +64,7 @@ Herdr はマウスネイティブです。キーバインドをひとつも覚ã� ## コピーモード -`prefix+[` を押すと、フォーカス中のペインでコピーモードに入ります。`h/j/k/l`、`w/b/e`、`{`/`}` で移動し、`v` または Space で選択を開始し、`y` または Enter でコピーし、`q` または Esc でコピーせずに抜けます。マウスのドラッグ選択なら、コピーモードに入らずにそのままコピーできます。 +`prefix+[` を押すと、フォーカス中のペインでコピーモードに入ります。`h/j/k/l`、tmux 形式の `w/b/e`、`{`/`}` で移動します。`/` または `?` で前方または後方のリテラル検索を開始し、`n` または `N` で同じ方向または逆方向に繰り返します。クエリに大文字が含まれる場合だけ大文字と小文字を区別します。`v` または Space で選択を開始し、`y` または Enter でコピーし、`q` または Esc でコピーせずに抜けます。Esc は終了する前に、アクティブな選択または検索を消します。コピーモードはペインのプロセスを停止しません。最下部では出力を追従し、履歴へ移動するとその位置を保ちます。マウスのドラッグ選択なら、コピーモードに入らずにそのままコピーできます。 ## 何でも変更できる diff --git a/docs/next/website/src/content/docs/keyboard.mdx b/docs/next/website/src/content/docs/keyboard.mdx index d102802f..599bea3b 100644 --- a/docs/next/website/src/content/docs/keyboard.mdx +++ b/docs/next/website/src/content/docs/keyboard.mdx @@ -64,7 +64,7 @@ The full keymap and the binding syntax live in the [keybinding reference](/docs/ ## Copy mode -Press `prefix+[` to enter copy mode for the focused pane. Use `h/j/k/l`, `w/b/e`, `{`/`}`, `PageUp`/`PageDown`, `ctrl+b`/`ctrl+f`, and `ctrl+u`/`ctrl+d` to move, `v` or Space to start a selection, `y` or Enter to copy it, and `q` or Esc to leave without copying. The configured prefix keeps its normal meaning in copy mode; with the default prefix, `ctrl+b` enters prefix mode instead of paging up, so use a different prefix if you want `ctrl+b` for copy-mode page-up. Mouse drag-select copies without entering copy mode at all. +Press `prefix+[` to enter copy mode for the focused pane. Use `h/j/k/l`, tmux-style `w/b/e`, `{`/`}`, `PageUp`/`PageDown`, `ctrl+b`/`ctrl+f`, and `ctrl+u`/`ctrl+d` to move. Press `/` or `?` for forward or backward literal search, then `n` or `N` to repeat in the same or opposite direction. Search is case-insensitive unless the query contains an uppercase letter. Use `v` or Space to start a selection, `y` or Enter to copy it, and `q` or Esc to leave without copying. Esc clears an active selection or search before exiting. Copy mode does not pause the pane process: output remains live, follows at the bottom, and stays pinned when you navigate into history. The configured prefix keeps its normal meaning in copy mode; with the default prefix, `ctrl+b` enters prefix mode instead of paging up, so use a different prefix if you want `ctrl+b` for copy-mode page-up. Mouse drag-select copies without entering copy mode at all. ## Change anything diff --git a/docs/next/website/src/content/docs/zh-cn/keyboard.mdx b/docs/next/website/src/content/docs/zh-cn/keyboard.mdx index 21ae60bd..0dfea960 100644 --- a/docs/next/website/src/content/docs/zh-cn/keyboard.mdx +++ b/docs/next/website/src/content/docs/zh-cn/keyboard.mdx @@ -64,7 +64,7 @@ Herdr 是鼠标原生的。你可以点击窗格、标签页、工作区和智èƒ ## 复制模式 -按 `prefix+[` 让聚焦的窗格进入复制模式。用 `h/j/k/l`、`w/b/e` 和 `{`/`}` 移动,用 `v` 或空格开始选择,用 `y` 或回车复制,用 `q` 或 Esc 不复制直接退出。鼠标拖选可以直接复制,完全不用进入复制模式。 +按 `prefix+[` 让聚焦的窗格进入复制模式。用 `h/j/k/l`、tmux 风格的 `w/b/e` 和 `{`/`}` 移动。按 `/` 或 `?` 向前或向后进行文本搜索,再用 `n` 或 `N` 按相同或相反方向重复搜索。查询包含大写字母时区分大小写,否则不区分。用 `v` 或空格开始选择,用 `y` 或回车复制,用 `q` 或 Esc 不复制直接退出。Esc 会先清除当前选择或搜索,然后才退出。复制模式不会暂停窗格进程:停留在底部时继续跟随输出,进入历史记录后保持当前位置。鼠标拖选可以直接复制,完全不用进入复制模式。 ## 一切都可以改 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, |
