aboutsummaryrefslogtreecommitdiff
path: root/src/app/input/copy_mode.rs
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-01 15:48:22 +0300
committerGitHub <noreply@github.com>2026-09-01 15:48:22 +0300
commit207be3c771d281baae6e5fa0fb74be9a056e97a2 (patch)
treed7b037ecdf629224a048c841d11659efdc23507d /src/app/input/copy_mode.rs
parent8a6d697308162874a236c84aac0b0f8c7ac01f83 (diff)
refactor: render the shell in the client (#3487)
* feat: add opt-in client-rendered shell * feat: add client-owned shell mouse controls * feat: complete client-owned shell chrome * feat: add client-owned shell settings and notifications * feat: run client shell custom commands * feat: render popup terminals in client shell * feat: add client-owned scrollback and copy mode * fix: preserve client worktree trust defaults * feat: render startup diagnostics in client shell * feat: render onboarding in client shell * feat: render product announcements in client shell * feat: render release notes in client shell * feat: render mobile switcher in client shell * feat: render kitty graphics in client shell * fix: preserve client shell lifecycle coherence * refactor: route client shell commands through endpoint connections * refactor: make the client-rendered shell the default * refactor: remove monolithic launch mode * refactor: complete client-rendered shell cutover * perf: retain client pane surface updates * perf: scale retained pane surface updates * perf: remove remote pane input latency * test: make client shell checks platform-independent * fix: prevent client timer starvation * test: fix client shell platform gates * test: accept retained surfaces in cross-area checks * test: make client mode assertions platform-neutral * fix: badge only outdated integrations
Diffstat (limited to 'src/app/input/copy_mode.rs')
-rw-r--r--src/app/input/copy_mode.rs2153
1 files changed, 0 insertions, 2153 deletions
diff --git a/src/app/input/copy_mode.rs b/src/app/input/copy_mode.rs
deleted file mode 100644
index 8bdbfd38..00000000
--- a/src/app/input/copy_mode.rs
+++ /dev/null
@@ -1,2153 +0,0 @@
-use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
-
-use crate::{
- app::{
- state::{CopyModeSearchDirection, CopyModeSearchPrompt, CopyModeSelection, CopyModeState},
- App, AppState, Mode,
- },
- input::TerminalKey,
- selection::Selection,
- terminal::TerminalRuntimeRegistry,
-};
-
-impl App {
- pub(crate) fn handle_copy_mode_key(&mut self, key: TerminalKey) {
- if key.kind == KeyEventKind::Release {
- return;
- }
- self.state.update_dismissed = true;
- if self.state.is_prefix_key(&key) {
- self.state.mode = Mode::Prefix;
- return;
- }
- self.state
- .handle_copy_mode_key(&self.terminal_runtimes, key);
- self.dispatch_pending_clipboard_write();
- }
-}
-
-impl AppState {
- pub(crate) fn enter_copy_mode(&mut self, terminal_runtimes: &TerminalRuntimeRegistry) {
- let Some(ws_idx) = self.active else {
- return;
- };
- let Some(pane_id) = self
- .workspaces
- .get(ws_idx)
- .and_then(|ws| ws.focused_pane_id())
- else {
- return;
- };
- let Some(info) = self.pane_info_by_id(pane_id).cloned() else {
- return;
- };
- if info.inner_rect.width == 0 || info.inner_rect.height == 0 {
- return;
- }
-
- let cursor = self
- .runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id)
- .and_then(|rt| rt.cursor_state(info.inner_rect, true))
- .filter(|cursor| cursor.visible)
- .map(|cursor| {
- (
- cursor.y.saturating_sub(info.inner_rect.y),
- cursor.x.saturating_sub(info.inner_rect.x),
- )
- })
- .unwrap_or_else(|| (info.inner_rect.height.saturating_sub(1), 0));
- let entry_offset_from_bottom = self
- .pane_scroll_metrics(terminal_runtimes, pane_id)
- .map_or(0, |metrics| metrics.offset_from_bottom);
-
- self.clear_selection();
- self.copy_mode = Some(CopyModeState {
- pane_id,
- cursor_row: cursor.0.min(info.inner_rect.height.saturating_sub(1)),
- 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;
- }
-
- pub(crate) fn handle_copy_mode_key(
- &mut self,
- terminal_runtimes: &TerminalRuntimeRegistry,
- key: TerminalKey,
- ) {
- if self.handle_copy_mode_search_prompt_key(terminal_runtimes, key.clone()) {
- 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;
- }
- KeyCode::Enter => {
- self.exit_copy_mode(terminal_runtimes, true);
- return;
- }
- KeyCode::Left => {
- self.move_copy_cursor(terminal_runtimes, 0, -1);
- return;
- }
- KeyCode::Down => {
- self.move_copy_cursor(terminal_runtimes, 1, 0);
- return;
- }
- KeyCode::Up => {
- self.move_copy_cursor(terminal_runtimes, -1, 0);
- return;
- }
- KeyCode::Right => {
- self.move_copy_cursor(terminal_runtimes, 0, 1);
- return;
- }
- KeyCode::PageUp => {
- self.scroll_copy_mode_page(terminal_runtimes, -1, false);
- return;
- }
- KeyCode::PageDown => {
- self.scroll_copy_mode_page(terminal_runtimes, 1, false);
- return;
- }
- KeyCode::Home => {
- self.copy_mode_line_edge(terminal_runtimes, false);
- return;
- }
- KeyCode::End => {
- self.copy_mode_line_edge(terminal_runtimes, true);
- return;
- }
- _ => {}
- }
-
- match (key.code, key.modifiers) {
- (KeyCode::Char('b'), mods) if mods.contains(KeyModifiers::CONTROL) => {
- self.scroll_copy_mode_page(terminal_runtimes, -1, false)
- }
- (KeyCode::Char('f'), mods) if mods.contains(KeyModifiers::CONTROL) => {
- self.scroll_copy_mode_page(terminal_runtimes, 1, false)
- }
- (KeyCode::Char('u'), mods) if mods.contains(KeyModifiers::CONTROL) => {
- self.scroll_copy_mode_page(terminal_runtimes, -1, true)
- }
- (KeyCode::Char('d'), mods) if mods.contains(KeyModifiers::CONTROL) => {
- self.scroll_copy_mode_page(terminal_runtimes, 1, true)
- }
- _ => {}
- }
-
- let Some(ch) = copy_mode_command_char(key) else {
- return;
- };
- match ch {
- 'q' => self.exit_copy_mode(terminal_runtimes, false),
- 'y' => self.exit_copy_mode(terminal_runtimes, true),
- 'v' | ' ' => self.begin_copy_mode_selection(terminal_runtimes),
- 'V' => self.select_copy_mode_line(terminal_runtimes),
- 'h' => self.move_copy_cursor(terminal_runtimes, 0, -1),
- 'j' => self.move_copy_cursor(terminal_runtimes, 1, 0),
- 'k' => self.move_copy_cursor(terminal_runtimes, -1, 0),
- 'l' => self.move_copy_cursor(terminal_runtimes, 0, 1),
- 'g' => self.copy_mode_history_top(terminal_runtimes),
- 'G' => self.copy_mode_history_bottom(terminal_runtimes),
- '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),
- 'W' => self.copy_mode_word_motion(terminal_runtimes, WordMotion::NextBigStart),
- 'B' => self.copy_mode_word_motion(terminal_runtimes, WordMotion::PreviousBigStart),
- 'E' => self.copy_mode_word_motion(terminal_runtimes, WordMotion::NextBigEnd),
- '{' => self.copy_mode_paragraph(terminal_runtimes, -1),
- '}' => self.copy_mode_paragraph(terminal_runtimes, 1),
- _ => {}
- }
- }
-
- 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);
- }
-
- 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);
- } else {
- self.clear_selection();
- }
- if let Some((pane_id, offset_from_bottom)) = restore_scroll {
- self.set_pane_scroll_offset(terminal_runtimes, pane_id, offset_from_bottom);
- }
- self.copy_mode = None;
- self.mode = if self.active.is_some() {
- Mode::Terminal
- } else {
- Mode::Navigate
- };
- }
-
- fn begin_copy_mode_selection(&mut self, terminal_runtimes: &TerminalRuntimeRegistry) {
- 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 {
- return;
- };
- if copy_mode.cursor_row >= info.inner_rect.height
- || copy_mode.cursor_col >= info.inner_rect.width
- {
- return;
- }
-
- let metrics = self.pane_scroll_metrics(terminal_runtimes, copy_mode.pane_id);
- self.selection = Some(Selection::anchor(
- copy_mode.pane_id,
- copy_mode.cursor_row,
- copy_mode.cursor_col,
- metrics,
- ));
- if let Some(copy_mode) = self.copy_mode.as_mut() {
- copy_mode.selection = Some(CopyModeSelection::Character);
- }
- }
-
- fn select_copy_mode_line(&mut self, terminal_runtimes: &TerminalRuntimeRegistry) {
- let Some(copy_mode) = self.copy_mode.as_ref() else {
- return;
- };
- 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, pane_id);
- let anchor_row = Selection::absolute_row_for_viewport(cursor_row, metrics);
- self.selection = Some(Selection::line_range(
- pane_id, anchor_row, anchor_row, end_col,
- ));
- if let Some(copy_mode) = self.copy_mode.as_mut() {
- copy_mode.selection = Some(CopyModeSelection::Linewise { anchor_row });
- }
- }
-
- fn move_copy_cursor(
- &mut self,
- terminal_runtimes: &TerminalRuntimeRegistry,
- row_delta: i16,
- col_delta: i16,
- ) {
- let Some(copy_mode) = self.copy_mode.as_ref() else {
- return;
- };
- 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 {
- cursor_col = cursor_col.saturating_sub(col_delta.unsigned_abs());
- } else if col_delta > 0 {
- 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 cursor_row >= delta {
- cursor_row -= delta;
- } else {
- 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 cursor_row.saturating_add(delta) <= bottom {
- cursor_row += delta;
- } else {
- self.scroll_pane_down(terminal_runtimes, pane_id, usize::from(delta));
- cursor_row = bottom;
- }
- }
-
- 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);
- }
-
- fn scroll_copy_mode_page(
- &mut self,
- terminal_runtimes: &TerminalRuntimeRegistry,
- direction: i16,
- half_page: bool,
- ) {
- let Some(copy_mode) = self.copy_mode.as_ref() else {
- return;
- };
- 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, pane_id) {
- if direction < 0 {
- let next_offset = metrics.offset_from_bottom.saturating_add(lines);
- if next_offset > metrics.max_offset_from_bottom {
- let scrolled_lines = metrics
- .max_offset_from_bottom
- .saturating_sub(metrics.offset_from_bottom);
- let cursor_lines = lines.saturating_sub(scrolled_lines);
- self.set_pane_scroll_offset(
- terminal_runtimes,
- pane_id,
- metrics.max_offset_from_bottom,
- );
- cursor_row =
- cursor_row.saturating_sub(cursor_lines.min(u16::MAX as usize) as u16);
- } else {
- 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, 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,
- pane_id,
- metrics.offset_from_bottom - lines,
- );
- }
- } else if direction < 0 {
- self.scroll_pane_up(terminal_runtimes, pane_id, lines);
- } else {
- 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.sync_copy_mode_selection(terminal_runtimes);
- }
-
- fn copy_mode_history_top(&mut self, terminal_runtimes: &TerminalRuntimeRegistry) {
- let Some(copy_mode) = self.copy_mode.as_ref() else {
- return;
- };
- 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, 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(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) else {
- self.exit_copy_mode(terminal_runtimes, false);
- return;
- };
- 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(copy_mode) = self.copy_mode.as_ref() else {
- return;
- };
- 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 {
- self.exit_copy_mode(terminal_runtimes, false);
- return;
- };
- let cursor_col = if end {
- let Some(text) = self.copy_mode_visible_row_text(terminal_runtimes, cursor_row) else {
- return;
- };
- last_character_col(&text)
- .unwrap_or(0)
- .min(info.inner_rect.width.saturating_sub(1))
- } else {
- 0
- };
- 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(copy_mode) = self.copy_mode.as_ref() else {
- return;
- };
- let cursor_row = copy_mode.cursor_row;
- let Some(text) = self.copy_mode_visible_row_text(terminal_runtimes, cursor_row) else {
- return;
- };
- 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);
- }
-
- fn copy_mode_word_motion(
- &mut self,
- terminal_runtimes: &TerminalRuntimeRegistry,
- motion: WordMotion,
- ) {
- 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 {
- return;
- };
- 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 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,
- WordMotion::NextBigStart => crate::pane::TerminalWordMotion::NextBigStart,
- WordMotion::PreviousBigStart => crate::pane::TerminalWordMotion::PreviousBigStart,
- WordMotion::NextBigEnd => crate::pane::TerminalWordMotion::NextBigEnd,
- };
- let Some(target) = runtime.word_motion_target(absolute_row, copy_mode.cursor_col, motion)
- else {
- return;
- };
- 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.as_ref() else {
- return;
- };
- let pane_id = copy_mode.pane_id;
- let Some(pane_height) = self
- .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, 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.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, pane_id)
- .map(|metrics| metrics.offset_from_bottom);
-
- self.move_copy_cursor(terminal_runtimes, direction, 0);
-
- let Some(after) = self.copy_mode.as_ref() else {
- return;
- };
- if self
- .copy_mode_visible_row_text(terminal_runtimes, after.cursor_row)
- .is_some_and(|text| text.trim().is_empty())
- {
- return;
- }
-
- let Some(after_metrics) = self.pane_scroll_metrics(terminal_runtimes, after.pane_id)
- else {
- continue;
- };
- 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;
- let at_bottom = direction > 0
- && after.cursor_row + 1 >= pane_height
- && after_metrics.offset_from_bottom == 0;
- if did_not_move || at_top || at_bottom {
- return;
- }
- }
- }
-
- fn copy_mode_visible_row_text(
- &self,
- terminal_runtimes: &TerminalRuntimeRegistry,
- viewport_row: u16,
- ) -> Option<String> {
- 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 {
- return None;
- }
- let metrics = self.pane_scroll_metrics(terminal_runtimes, copy_mode.pane_id);
- let row_selection = Selection::range(
- copy_mode.pane_id,
- viewport_row,
- 0,
- info.inner_rect.width.saturating_sub(1),
- metrics,
- );
- self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, copy_mode.pane_id)?
- .extract_selection(&row_selection)
- }
-
- pub(crate) fn copy_mode_pane_is_focused(&self) -> bool {
- let Some(copy_mode) = self.copy_mode.as_ref() else {
- return false;
- };
- self.current_pane_focus_target()
- .is_some_and(|target| target.pane_id == copy_mode.pane_id)
- }
-
- pub(crate) fn sync_copy_mode_with_focus(&mut self) {
- if self.copy_mode.is_none() {
- return;
- }
- if !matches!(
- self.mode,
- Mode::Copy | Mode::Terminal | Mode::Navigate | Mode::Prefix
- ) {
- return;
- }
- if self.copy_mode_pane_is_focused() {
- self.mode = Mode::Copy;
- } else if self.active.is_some() {
- self.clear_copy_mode_selection();
- self.mode = Mode::Terminal;
- } else {
- self.clear_copy_mode_selection();
- self.mode = Mode::Navigate;
- }
- }
-
- pub(crate) fn settle_terminal_mode_after_focus(&mut self) {
- self.mode = Mode::Terminal;
- 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() {
- copy_mode.selection = None;
- }
- }
-
- pub(crate) fn clear_copy_mode_for_removed_panes(
- &mut self,
- pane_ids: impl IntoIterator<Item = crate::layout::PaneId>,
- ) {
- let Some(copy_mode) = self.copy_mode.as_ref() else {
- return;
- };
- if pane_ids
- .into_iter()
- .any(|pane_id| pane_id == copy_mode.pane_id)
- {
- self.clear_selection();
- self.copy_mode = None;
- if self.mode == Mode::Copy {
- self.mode = if self.active.is_some() {
- Mode::Terminal
- } else {
- Mode::Navigate
- };
- }
- }
- }
-
- fn sync_copy_mode_selection(&mut self, terminal_runtimes: &TerminalRuntimeRegistry) {
- let Some(copy_mode) = self.copy_mode.as_ref() else {
- return;
- };
- let Some(selection) = copy_mode.selection else {
- return;
- };
- let Some(info) = self.pane_info_by_id(copy_mode.pane_id).cloned() else {
- return;
- };
- match selection {
- CopyModeSelection::Character => {
- let screen_col = info.inner_rect.x.saturating_add(copy_mode.cursor_col);
- let screen_row = info.inner_rect.y.saturating_add(copy_mode.cursor_row);
- self.update_selection_cursor(
- terminal_runtimes,
- copy_mode.pane_id,
- screen_col,
- screen_row,
- );
- }
- CopyModeSelection::Linewise { anchor_row } => {
- let metrics = self.pane_scroll_metrics(terminal_runtimes, copy_mode.pane_id);
- let cursor_row =
- Selection::absolute_row_for_viewport(copy_mode.cursor_row, metrics);
- self.selection = Some(Selection::line_range(
- copy_mode.pane_id,
- anchor_row,
- cursor_row,
- info.inner_rect.width.saturating_sub(1),
- ));
- }
- }
- }
-}
-
-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,
- PreviousStart,
- NextEnd,
- NextBigStart,
- PreviousBigStart,
- NextBigEnd,
-}
-
-fn first_non_blank_col(text: &str) -> Option<u16> {
- let mut col = 0u16;
- for ch in text.chars() {
- if !ch.is_whitespace() {
- return Some(col);
- }
- col = col.saturating_add(char_cell_width(ch));
- }
- None
-}
-
-fn last_character_col(text: &str) -> Option<u16> {
- let mut col = 0u16;
- let mut last_col = None;
- for ch in text.chars() {
- let width = u16::from(crate::ghostty::unicode_codepoint_width(ch as u32));
- if width > 0 {
- last_col = Some(col);
- col = col.saturating_add(width);
- }
- }
- last_col
-}
-
-fn char_cell_width(ch: char) -> u16 {
- u16::from(crate::ghostty::unicode_codepoint_width(ch as u32)).max(1)
-}
-
-fn copy_mode_page_lines(height: u16, half_page: bool) -> usize {
- if height <= 2 {
- 1
- } else if half_page {
- usize::from(height / 2)
- } else {
- usize::from(height - 2)
- }
-}
-
-fn copy_mode_command_char(key: TerminalKey) -> Option<char> {
- if !key.modifiers.difference(KeyModifiers::SHIFT).is_empty() {
- return None;
- }
-
- if let Some(ch) = key.shifted_codepoint.and_then(char::from_u32) {
- return Some(ch);
- }
-
- let KeyCode::Char(ch) = key.code else {
- return None;
- };
- if key.modifiers.contains(KeyModifiers::SHIFT) {
- Some(shifted_ascii_char(ch).unwrap_or(ch))
- } else {
- Some(ch)
- }
-}
-
-fn shifted_ascii_char(ch: char) -> Option<char> {
- match ch {
- 'a'..='z' => Some(ch.to_ascii_uppercase()),
- '1' => Some('!'),
- '2' => Some('@'),
- '3' => Some('#'),
- '4' => Some('$'),
- '5' => Some('%'),
- '6' => Some('^'),
- '7' => Some('&'),
- '8' => Some('*'),
- '9' => Some('('),
- '0' => Some(')'),
- '-' => Some('_'),
- '=' => Some('+'),
- '[' => Some('{'),
- ']' => Some('}'),
- '\\' => Some('|'),
- ';' => Some(':'),
- '\'' => Some('"'),
- ',' => Some('<'),
- '.' => Some('>'),
- '/' => Some('?'),
- '`' => Some('~'),
- _ => None,
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::super::{app_for_mouse_test, numbered_lines_bytes};
- use super::*;
- use crate::{events::AppEvent, workspace::Workspace};
- use ratatui::layout::Rect;
-
- fn app_with_copy_runtime(
- runtime: impl FnOnce(u16, u16) -> crate::terminal::TerminalRuntime,
- ) -> (App, crate::layout::PaneId) {
- let mut app = app_for_mouse_test();
- let mut ws = Workspace::test_new("test");
- let pane_id = ws.tabs[0].root_pane;
- let pane_infos = ws.tabs[0].layout.panes(Rect::new(0, 0, 20, 5));
- let info = pane_infos[0].clone();
- ws.tabs[0].runtimes.insert(
- pane_id,
- runtime(info.inner_rect.width, info.inner_rect.height),
- );
- app.state.workspaces = vec![ws];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
- app.state.view.pane_infos = pane_infos;
- (app, pane_id)
- }
-
- fn app_with_copy_screen(bytes: &[u8]) -> (App, crate::layout::PaneId) {
- app_with_copy_runtime(|cols, rows| {
- crate::terminal::TerminalRuntime::test_with_screen_bytes(cols, rows, bytes)
- })
- }
-
- fn app_with_copy_scrollback(bytes: &[u8]) -> (App, crate::layout::PaneId) {
- app_with_copy_runtime(|cols, rows| {
- crate::terminal::TerminalRuntime::test_with_scrollback_bytes(
- cols,
- rows,
- 16 * 1024,
- bytes,
- )
- })
- }
-
- fn app_with_split_copy_runtime(
- bytes: &[u8],
- first_runtime: impl FnOnce(u16, u16, &[u8]) -> crate::terminal::TerminalRuntime,
- ) -> (App, crate::layout::PaneId, crate::layout::PaneId) {
- let mut app = app_for_mouse_test();
- let mut ws = Workspace::test_new("test");
- let first_pane = ws.tabs[0].root_pane;
- let second_pane = ws.test_split(ratatui::layout::Direction::Horizontal);
- let pane_infos = ws.tabs[0].layout.panes(Rect::new(0, 0, 40, 5));
- let first_info = pane_infos
- .iter()
- .find(|info| info.id == first_pane)
- .expect("first pane info");
- let second_info = pane_infos
- .iter()
- .find(|info| info.id == second_pane)
- .expect("second pane info");
- ws.tabs[0].runtimes.insert(
- first_pane,
- first_runtime(
- first_info.inner_rect.width,
- first_info.inner_rect.height,
- bytes,
- ),
- );
- ws.tabs[0].runtimes.insert(
- second_pane,
- crate::terminal::TerminalRuntime::test_with_screen_bytes(
- second_info.inner_rect.width,
- second_info.inner_rect.height,
- b"",
- ),
- );
- ws.tabs[0].layout.focus_pane(first_pane);
- app.state.workspaces = vec![ws];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
- app.state.view.pane_infos = pane_infos;
- (app, first_pane, second_pane)
- }
-
- fn app_with_split_copy_screen(
- bytes: &[u8],
- ) -> (App, crate::layout::PaneId, crate::layout::PaneId) {
- app_with_split_copy_runtime(bytes, |cols, rows, bytes| {
- crate::terminal::TerminalRuntime::test_with_screen_bytes(cols, rows, bytes)
- })
- }
-
- fn app_with_split_copy_scrollback(
- bytes: &[u8],
- ) -> (App, crate::layout::PaneId, crate::layout::PaneId) {
- app_with_split_copy_runtime(bytes, |cols, rows, bytes| {
- crate::terminal::TerminalRuntime::test_with_scrollback_bytes(
- cols,
- rows,
- 16 * 1024,
- bytes,
- )
- })
- }
-
- fn copy_mode_clipboard_text(app: &mut App) -> String {
- match app.event_rx.try_recv().expect("clipboard event") {
- AppEvent::ClipboardWrite { content } => {
- String::from_utf8(content).expect("utf8 clipboard")
- }
- other => panic!("unexpected event: {other:?}"),
- }
- }
-
- fn copy_mode_viewport_top_row(app: &App, pane_id: crate::layout::PaneId) -> usize {
- let metrics = app
- .state
- .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id)
- .and_then(crate::terminal::TerminalRuntime::scroll_metrics)
- .expect("copy mode scroll metrics");
- metrics
- .max_offset_from_bottom
- .saturating_sub(metrics.offset_from_bottom)
- }
-
- fn copy_mode_offset_from_bottom(app: &App, pane_id: crate::layout::PaneId) -> usize {
- app.state
- .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id)
- .and_then(crate::terminal::TerminalRuntime::scroll_metrics)
- .expect("copy mode scroll metrics")
- .offset_from_bottom
- }
-
- fn copy_mode_scroll_metrics(
- app: &App,
- pane_id: crate::layout::PaneId,
- ) -> crate::pane::ScrollMetrics {
- app.state
- .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id)
- .and_then(crate::terminal::TerminalRuntime::scroll_metrics)
- .expect("copy mode scroll metrics")
- }
-
- fn refresh_split_pane_infos(app: &mut App) {
- app.state.view.pane_infos = app.state.workspaces[0]
- .active_tab()
- .expect("active tab")
- .layout
- .panes(Rect::new(0, 0, 40, 5));
- }
-
- #[tokio::test]
- async fn enter_copy_mode_tracks_focused_pane() {
- 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.as_ref().expect("copy mode").pane_id,
- pane_id
- );
- }
-
- #[tokio::test]
- async fn copy_mode_ctrl_b_uses_page_up() {
- let bytes = numbered_lines_bytes(64);
- let (mut app, pane_id) = app_with_copy_scrollback(&bytes);
- 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.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));
-
- assert_eq!(app.state.mode, Mode::Copy);
- assert_eq!(copy_mode_offset_from_bottom(&app, pane_id), expected_lines);
- }
-
- #[tokio::test]
- async fn copy_mode_prefix_takes_priority_over_ctrl_b_page_up() {
- let bytes = numbered_lines_bytes(64);
- let (mut app, pane_id) = app_with_copy_scrollback(&bytes);
- app.state.enter_copy_mode(&app.terminal_runtimes);
-
- app.handle_key(TerminalKey::new(
- app.state.prefix_code,
- app.state.prefix_mods,
- ))
- .await;
-
- assert_eq!(app.state.mode, Mode::Prefix);
- assert_eq!(copy_mode_offset_from_bottom(&app, pane_id), 0);
- assert!(app.state.copy_mode.is_some());
- }
-
- #[tokio::test]
- 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.as_ref().expect("copy mode").clone();
-
- app.handle_key(TerminalKey::new(
- app.state.prefix_code,
- app.state.prefix_mods,
- ))
- .await;
- app.handle_key(TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()))
- .await;
-
- assert_eq!(app.state.mode, Mode::Copy);
- assert_eq!(app.state.copy_mode, Some(copy_mode));
- }
-
- #[tokio::test]
- 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.as_ref().expect("copy mode").clone();
- assert_eq!(copy_mode.pane_id, first_pane);
-
- app.handle_key(TerminalKey::new(
- app.state.prefix_code,
- app.state.prefix_mods,
- ))
- .await;
- app.handle_key(TerminalKey::new(KeyCode::Char('l'), KeyModifiers::empty()))
- .await;
-
- assert_eq!(app.state.mode, Mode::Terminal);
- assert_eq!(app.state.copy_mode, Some(copy_mode.clone()));
- assert_eq!(
- app.state.workspaces[0].tabs[0].layout.focused(),
- second_pane
- );
-
- refresh_split_pane_infos(&mut app);
- app.handle_key(TerminalKey::new(
- app.state.prefix_code,
- app.state.prefix_mods,
- ))
- .await;
- app.handle_key(TerminalKey::new(KeyCode::Char('h'), KeyModifiers::empty()))
- .await;
-
- assert_eq!(app.state.mode, Mode::Copy);
- assert_eq!(app.state.copy_mode, Some(copy_mode));
- assert_eq!(app.state.workspaces[0].tabs[0].layout.focused(), first_pane);
- }
-
- #[tokio::test]
- async fn copy_mode_focus_away_preserves_scrollback_position() {
- let bytes = numbered_lines_bytes(64);
- let (mut app, first_pane, second_pane) = app_with_split_copy_scrollback(&bytes);
- app.state.enter_copy_mode(&app.terminal_runtimes);
- app.handle_copy_mode_key(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty()));
- let scrolled_offset = copy_mode_offset_from_bottom(&app, first_pane);
- assert!(scrolled_offset > 0);
-
- app.handle_key(TerminalKey::new(
- app.state.prefix_code,
- app.state.prefix_mods,
- ))
- .await;
- app.handle_key(TerminalKey::new(KeyCode::Char('l'), KeyModifiers::empty()))
- .await;
-
- assert_eq!(app.state.mode, Mode::Terminal);
- assert_eq!(
- app.state.workspaces[0].tabs[0].layout.focused(),
- second_pane
- );
- assert_eq!(
- copy_mode_offset_from_bottom(&app, first_pane),
- scrolled_offset
- );
- }
-
- #[tokio::test]
- async fn copy_mode_cancel_restores_scroll_after_workspace_switch() {
- let bytes = numbered_lines_bytes(64);
- let (mut app, pane_id) = app_with_copy_scrollback(&bytes);
- app.state
- .workspaces
- .push(crate::workspace::Workspace::test_new("other"));
- app.state.enter_copy_mode(&app.terminal_runtimes);
- app.handle_copy_mode_key(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty()));
- assert!(copy_mode_offset_from_bottom(&app, pane_id) > 0);
-
- app.state.switch_workspace(1);
- app.state.cancel_copy_mode(&app.terminal_runtimes);
-
- assert_eq!(copy_mode_offset_from_bottom(&app, pane_id), 0);
- assert!(app.state.copy_mode.is_none());
- assert_eq!(app.state.mode, Mode::Terminal);
- }
-
- #[tokio::test]
- async fn copy_mode_clears_when_source_tab_closes_after_focus_away() {
- let (mut app, first_pane, _) = app_with_split_copy_screen(b"alpha\nbeta\n");
- let survivor_tab = app.state.workspaces[0].test_add_tab(Some("survivor"));
- let survivor_pane = app.state.workspaces[0].tabs[survivor_tab].root_pane;
- let survivor_terminal = app.state.workspaces[0].tabs[survivor_tab].panes[&survivor_pane]
- .attached_terminal_id
- .clone();
- app.state.terminals.insert(
- survivor_terminal.clone(),
- crate::terminal::TerminalState::new(survivor_terminal, "/tmp".into()),
- );
- app.state.enter_copy_mode(&app.terminal_runtimes);
- assert_eq!(
- app.state.copy_mode.as_ref().expect("copy mode").pane_id,
- first_pane
- );
-
- app.handle_key(TerminalKey::new(
- app.state.prefix_code,
- app.state.prefix_mods,
- ))
- .await;
- app.handle_key(TerminalKey::new(KeyCode::Char('l'), KeyModifiers::empty()))
- .await;
- assert_eq!(app.state.mode, Mode::Terminal);
- assert!(app.state.copy_mode.is_some());
-
- assert!(!app.state.close_tab());
-
- assert_eq!(app.state.mode, Mode::Terminal);
- assert!(app.state.copy_mode.is_none());
- app.state.assert_invariants_for_test();
- }
-
- #[tokio::test]
- async fn copy_mode_ctrl_f_uses_page_down() {
- 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.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);
-
- app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('f'), KeyModifiers::CONTROL));
-
- assert_eq!(app.state.mode, Mode::Copy);
- assert_eq!(copy_mode_offset_from_bottom(&app, pane_id), 0);
- }
-
- #[tokio::test]
- async fn copy_mode_line_end_stops_at_last_character() {
- let (mut app, _) = app_with_copy_screen(b"hello\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('$'), KeyModifiers::empty()));
-
- assert_eq!(
- app.state.copy_mode.as_ref().expect("copy mode").cursor_col,
- 4
- );
-
- if let Some(copy_mode) = app.state.copy_mode.as_mut() {
- copy_mode.cursor_col = 0;
- }
- app.handle_copy_mode_key(TerminalKey::new(KeyCode::End, KeyModifiers::empty()));
- assert_eq!(
- app.state.copy_mode.as_ref().expect("copy mode").cursor_col,
- 4
- );
-
- let (mut empty_app, _) = app_with_copy_screen(b"\r\n");
- empty_app
- .state
- .enter_copy_mode(&empty_app.terminal_runtimes);
- if let Some(copy_mode) = empty_app.state.copy_mode.as_mut() {
- copy_mode.cursor_row = 0;
- copy_mode.cursor_col = 7;
- }
- empty_app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('$'), KeyModifiers::empty()));
- assert_eq!(
- empty_app
- .state
- .copy_mode
- .as_ref()
- .expect("copy mode")
- .cursor_col,
- 0
- );
-
- let (mut wide_app, _) = app_with_copy_screen("a界\r\n".as_bytes());
- wide_app.state.enter_copy_mode(&wide_app.terminal_runtimes);
- if let Some(copy_mode) = wide_app.state.copy_mode.as_mut() {
- copy_mode.cursor_row = 0;
- copy_mode.cursor_col = 0;
- }
- wide_app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('$'), KeyModifiers::empty()));
- assert_eq!(
- wide_app
- .state
- .copy_mode
- .as_ref()
- .expect("copy mode")
- .cursor_col,
- 1
- );
- }
-
- #[tokio::test]
- async fn copy_mode_word_motions_use_visible_row_words() {
- let (mut app, _) = app_with_copy_screen(b"foo bar baz\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('w'), KeyModifiers::empty()));
- 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.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.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_big_word_motions_skip_punctuation_runs() {
- let (mut app, _) = app_with_copy_screen(b"foo.bar baz qux\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 [8, 12] {
- app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('w'), KeyModifiers::SHIFT));
- assert_eq!(
- app.state.copy_mode.as_ref().expect("copy mode").cursor_col,
- expected_col
- );
- }
- app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('e'), KeyModifiers::SHIFT));
- assert_eq!(
- app.state.copy_mode.as_ref().expect("copy mode").cursor_col,
- 14
- );
- for expected_col in [12, 8, 0] {
- app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('b'), KeyModifiers::SHIFT));
- assert_eq!(
- app.state.copy_mode.as_ref().expect("copy mode").cursor_col,
- expected_col
- );
- }
-
- // Lowercase motions keep their punctuation-aware behavior.
- 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,
- 3
- );
- 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,
- 4
- );
- }
-
- #[tokio::test]
- async fn copy_mode_big_word_motions_accept_shifted_codepoints_and_cross_rows() {
- let (mut app, pane_id) = app_with_copy_screen(b"foo.bar baz\r\nqux/quux\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('W'), KeyModifiers::SHIFT)
- .with_shifted_codepoint('W' as u32),
- );
- 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, 8);
-
- app.handle_copy_mode_key(
- TerminalKey::new(KeyCode::Char('W'), KeyModifiers::SHIFT)
- .with_shifted_codepoint('W' as u32),
- );
- 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::SHIFT));
- 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, 8);
- }
-
- #[tokio::test]
- async fn copy_mode_big_word_motions_extend_an_active_selection() {
- let (mut app, _) = app_with_copy_screen(b"foo.bar baz qux\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()));
- app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('w'), KeyModifiers::SHIFT));
- app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('y'), KeyModifiers::empty()));
-
- assert_eq!(copy_mode_clipboard_text(&mut app), "foo.bar b");
- }
-
- #[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 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
- );
- 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]
- async fn copy_mode_shift_v_y_copies_visible_line() {
- let (mut app, _) = app_with_copy_screen(b"alpha\r\nbeta\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 = 1;
- copy_mode.cursor_col = 2;
- }
-
- app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('v'), KeyModifiers::SHIFT));
- app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('y'), KeyModifiers::empty()));
-
- assert_eq!(copy_mode_clipboard_text(&mut app), "beta");
- assert_eq!(app.state.mode, Mode::Terminal);
- }
-
- #[tokio::test]
- async fn copy_mode_shift_v_extends_linewise_down() {
- let (mut app, _) = app_with_copy_screen(b"alpha\r\nbeta\r\ngamma\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 = 2;
- }
-
- app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('v'), KeyModifiers::SHIFT));
- app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('j'), KeyModifiers::empty()));
- app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('y'), KeyModifiers::empty()));
-
- assert_eq!(copy_mode_clipboard_text(&mut app), "alpha\nbeta");
- }
-
- #[tokio::test]
- async fn copy_mode_shift_v_extends_linewise_up() {
- let (mut app, _) = app_with_copy_screen(b"alpha\r\nbeta\r\ngamma\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 = 1;
- copy_mode.cursor_col = 2;
- }
-
- app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('v'), KeyModifiers::SHIFT));
- app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('k'), KeyModifiers::empty()));
- app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('y'), KeyModifiers::empty()));
-
- assert_eq!(copy_mode_clipboard_text(&mut app), "alpha\nbeta");
- }
-
- #[tokio::test]
- async fn copy_mode_shift_v_reverses_without_character_tail() {
- let (mut app, _) = app_with_copy_screen(b"alpha\r\nbeta\r\ngamma\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 = 1;
- copy_mode.cursor_col = 2;
- }
-
- app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('v'), KeyModifiers::SHIFT));
- app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('j'), KeyModifiers::empty()));
- app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('k'), KeyModifiers::empty()));
- app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('k'), KeyModifiers::empty()));
- app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('y'), KeyModifiers::empty()));
-
- assert_eq!(copy_mode_clipboard_text(&mut app), "alpha\nbeta");
- }
-
- #[tokio::test]
- async fn copy_mode_shift_v_horizontal_motion_keeps_linewise_selection() {
- let (mut app, _) = app_with_copy_screen(b"alpha\r\nbeta\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 = 1;
- copy_mode.cursor_col = 2;
- }
-
- app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('v'), KeyModifiers::SHIFT));
- app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('h'), KeyModifiers::empty()));
- app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('l'), KeyModifiers::empty()));
- app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('y'), KeyModifiers::empty()));
-
- assert_eq!(copy_mode_clipboard_text(&mut app), "beta");
- }
-
- #[tokio::test]
- async fn copy_mode_shift_v_page_up_keeps_linewise_scrollback_selection() {
- let bytes = numbered_lines_bytes(64);
- let (mut app, pane_id) = app_with_copy_scrollback(&bytes);
- 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 = 2;
- }
-
- let anchor_row = copy_mode_viewport_top_row(&app, pane_id);
- app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('v'), KeyModifiers::SHIFT));
- app.handle_copy_mode_key(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty()));
- let cursor_row = copy_mode_viewport_top_row(&app, pane_id);
- app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('y'), KeyModifiers::empty()));
-
- assert!(cursor_row < anchor_row);
- let expected = (cursor_row..=anchor_row)
- .map(|row| format!("{row:06}"))
- .collect::<Vec<_>>()
- .join("\n");
- assert_eq!(copy_mode_clipboard_text(&mut app), expected);
- assert_eq!(copy_mode_offset_from_bottom(&app, pane_id), 0);
- }
-
- #[tokio::test]
- async fn copy_mode_page_up_uses_tmux_page_size() {
- 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.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()));
-
- assert_eq!(copy_mode_offset_from_bottom(&app, pane_id), expected_lines);
- }
-
- #[tokio::test]
- async fn copy_mode_ctrl_u_moves_cursor_when_history_top_clamps() {
- 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.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);
- app.state.set_pane_scroll_offset(
- &app.terminal_runtimes,
- pane_id,
- metrics.max_offset_from_bottom - lines + 1,
- );
- if let Some(copy_mode) = app.state.copy_mode.as_mut() {
- copy_mode.cursor_row = bottom;
- }
-
- app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('u'), KeyModifiers::CONTROL));
-
- 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),
- metrics.max_offset_from_bottom
- );
- assert_eq!(
- copy_mode.cursor_row,
- bottom.saturating_sub(expected_cursor_delta as u16)
- );
- }
-
- #[tokio::test]
- async fn copy_mode_ctrl_d_moves_cursor_when_live_bottom_clamps() {
- 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.as_ref().expect("copy mode").cursor_row;
- let lines = copy_mode_page_lines(bottom + 1, true);
- assert!(lines > 1);
- app.state
- .set_pane_scroll_offset(&app.terminal_runtimes, pane_id, lines - 1);
- if let Some(copy_mode) = app.state.copy_mode.as_mut() {
- copy_mode.cursor_row = 0;
- }
-
- app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('d'), KeyModifiers::CONTROL));
-
- 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);
- }
-
- #[tokio::test]
- async fn copy_mode_q_exits_and_returns_to_bottom_after_scrollback() {
- let bytes = numbered_lines_bytes(64);
- let (mut app, pane_id) = app_with_copy_scrollback(&bytes);
- app.state.enter_copy_mode(&app.terminal_runtimes);
-
- app.handle_copy_mode_key(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty()));
- assert!(copy_mode_offset_from_bottom(&app, pane_id) > 0);
-
- app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('q'), KeyModifiers::empty()));
-
- assert_eq!(app.state.mode, Mode::Terminal);
- assert!(app.state.copy_mode.is_none());
- assert_eq!(copy_mode_offset_from_bottom(&app, pane_id), 0);
- }
-
- #[tokio::test]
- async fn copy_mode_q_restores_entry_scrollback_offset() {
- let bytes = numbered_lines_bytes(64);
- let (mut app, pane_id) = app_with_copy_scrollback(&bytes);
- let entry_offset = 3;
- app.state
- .set_pane_scroll_offset(&app.terminal_runtimes, pane_id, entry_offset);
- assert_eq!(copy_mode_offset_from_bottom(&app, pane_id), entry_offset);
-
- app.state.enter_copy_mode(&app.terminal_runtimes);
- app.handle_copy_mode_key(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty()));
- assert!(copy_mode_offset_from_bottom(&app, pane_id) > entry_offset);
-
- app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('q'), KeyModifiers::empty()));
-
- assert_eq!(app.state.mode, Mode::Terminal);
- assert!(app.state.copy_mode.is_none());
- assert_eq!(copy_mode_offset_from_bottom(&app, pane_id), entry_offset);
- }
-
- #[tokio::test]
- async fn shifted_punctuation_keys_work_with_enhanced_key_reporting() {
- let (mut app, _) = app_with_copy_screen(b"foo\r\n\r\nbar\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 = 2;
- copy_mode.cursor_col = 2;
- }
-
- app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('6'), KeyModifiers::SHIFT));
- 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.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.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.as_ref().expect("copy mode").cursor_row,
- 3
- );
- }
-
- #[tokio::test]
- async fn copy_mode_v_y_copies_selection_and_exits() {
- let (mut app, _) = app_with_copy_screen(b"alpha\nbeta\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()));
- app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('l'), KeyModifiers::empty()));
- app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('l'), KeyModifiers::empty()));
- app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('y'), KeyModifiers::empty()));
-
- match app.event_rx.try_recv().expect("clipboard event") {
- AppEvent::ClipboardWrite { content } => assert_eq!(content, b"alp"),
- other => panic!("unexpected event: {other:?}"),
- }
- assert_eq!(app.state.mode, Mode::Terminal);
- assert!(app.state.copy_mode.is_none());
- }
-
- #[tokio::test]
- async fn copy_mode_same_tab_switch_preserves_selection() {
- let (mut app, _) = app_with_copy_screen(b"alpha\nbeta\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()));
- app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('l'), KeyModifiers::empty()));
- app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('l'), KeyModifiers::empty()));
-
- assert!(app.state.switch_workspace_tab(0, 0));
-
- app.handle_copy_mode_key(TerminalKey::new(KeyCode::Char('y'), KeyModifiers::empty()));
- assert_eq!(copy_mode_clipboard_text(&mut app), "alp");
- assert_eq!(app.state.mode, Mode::Terminal);
- assert!(app.state.copy_mode.is_none());
- }
-}