aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-07-10 04:22:10 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-07-10 04:22:10 +0300
commitc8850a2e3e284033142db0f8117fa5998c68bd59 (patch)
tree487530d94265c096ceeb3ded7c60a94f14a6f7fd /src
parent0de503ccd9a8fedeeb6cbcc1446ab2bc644fdbb6 (diff)
feat: add copy mode search and tmux word motions
refs #1230
Diffstat (limited to 'src')
-rw-r--r--src/app/api/panes.rs2
-rw-r--r--src/app/input/copy_mode.rs917
-rw-r--r--src/app/input/mod.rs18
-rw-r--r--src/app/state.rs25
-rw-r--r--src/ghostty/mod.rs93
-rw-r--r--src/pane.rs33
-rw-r--r--src/pane/terminal.rs764
-rw-r--r--src/terminal/runtime.rs28
-rw-r--r--src/ui.rs2
-rw-r--r--src/ui/menus.rs67
-rw-r--r--src/ui/panes.rs112
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(&regex::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()
}
diff --git a/src/ui.rs b/src/ui.rs
index 12acc8b6..129b4e67 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -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,
+ &copy_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,
+ &copy_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 = &copy_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,