aboutsummaryrefslogtreecommitdiff
path: root/src/app/input
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/app/input
parent0de503ccd9a8fedeeb6cbcc1446ab2bc644fdbb6 (diff)
feat: add copy mode search and tmux word motions
refs #1230
Diffstat (limited to 'src/app/input')
-rw-r--r--src/app/input/copy_mode.rs917
-rw-r--r--src/app/input/mod.rs18
2 files changed, 768 insertions, 167 deletions
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,
}
}