aboutsummaryrefslogtreecommitdiff
path: root/src/client/shell/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/client/shell/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/client/shell/copy_mode.rs')
-rw-r--r--src/client/shell/copy_mode.rs871
1 files changed, 871 insertions, 0 deletions
diff --git a/src/client/shell/copy_mode.rs b/src/client/shell/copy_mode.rs
new file mode 100644
index 00000000..c39a7507
--- /dev/null
+++ b/src/client/shell/copy_mode.rs
@@ -0,0 +1,871 @@
+use super::*;
+use crossterm::event::{KeyCode, KeyModifiers};
+
+impl ClientShellState {
+ pub(super) fn reset_copy_pipeline(&mut self) {
+ self.copy_session_generation = self.copy_session_generation.saturating_add(1);
+ self.copy_operation_in_flight = false;
+ self.copy_operation_queue.clear();
+ self.copy_input_queue.clear();
+ }
+
+ pub(super) fn enter_copy_mode(&mut self, outcome: &mut ClientShellInput) -> bool {
+ let pane_id = match self.focused_pane_id() {
+ Some(pane_id) => pane_id,
+ None => return false,
+ };
+ if self
+ .copy_mode
+ .as_ref()
+ .is_some_and(|copy_mode| copy_mode.pane_id == pane_id)
+ {
+ self.mode = ClientShellMode::Copy;
+ return true;
+ }
+ if self.copy_mode.is_some() {
+ self.exit_copy_mode(false, outcome);
+ }
+ let Some(hit) = self
+ .hits
+ .panes
+ .iter()
+ .find(|hit| hit.pane_id == pane_id)
+ .cloned()
+ else {
+ return false;
+ };
+ let Some(metrics) = hit.scroll else {
+ return false;
+ };
+ let viewport_top = metrics
+ .max_offset_from_bottom
+ .saturating_sub(metrics.offset_from_bottom)
+ .min(u32::MAX as usize) as u32;
+ let cursor = self
+ .pane_surface
+ .as_ref()
+ .and_then(|surface| {
+ let pane = surface.panes.iter().find(|pane| pane.pane_id == pane_id)?;
+ let cursor = surface
+ .frame
+ .cursor
+ .as_ref()
+ .filter(|cursor| cursor.visible)?;
+ let inner = pane.inner_rect;
+ (cursor.x >= inner.x
+ && cursor.x < inner.x.saturating_add(inner.width)
+ && cursor.y >= inner.y
+ && cursor.y < inner.y.saturating_add(inner.height))
+ .then_some(crate::api::schema::PaneTextPoint {
+ row: viewport_top.saturating_add(u32::from(cursor.y - inner.y)),
+ col: cursor.x - inner.x,
+ })
+ })
+ .unwrap_or(crate::api::schema::PaneTextPoint {
+ row: viewport_top
+ .saturating_add(u32::from(hit.inner_rect.height.saturating_sub(1))),
+ col: 0,
+ });
+ self.selection = None;
+ self.stop_selection_autoscroll();
+ self.selection_highlight_clear_deadline = None;
+ self.reset_copy_pipeline();
+ let content_revision = self
+ .pane_surface
+ .as_ref()
+ .and_then(|surface| surface.panes.iter().find(|pane| pane.pane_id == pane_id))
+ .map_or(0, |pane| pane.content_revision);
+ self.copy_mode = Some(ClientCopyModeState {
+ pane_id,
+ content_revision,
+ geometry: (hit.inner_rect.width, hit.inner_rect.height),
+ cursor,
+ offset_from_bottom: metrics.offset_from_bottom,
+ max_offset_from_bottom: metrics.max_offset_from_bottom,
+ entry_offset_from_bottom: metrics.offset_from_bottom,
+ selection: None,
+ search_prompt: None,
+ search_query: String::new(),
+ search_direction: None,
+ search_matches: Vec::new(),
+ search_total: 0,
+ search_current: None,
+ search_current_global: None,
+ search_generation: 0,
+ copy_after_search: false,
+ });
+ self.mode = ClientShellMode::Copy;
+ true
+ }
+
+ pub(super) fn route_copy_mode_key(
+ &mut self,
+ key: &crate::input::TerminalKey,
+ outcome: &mut ClientShellInput,
+ ) {
+ if self.route_copy_search_prompt_key(key, outcome) {
+ 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 {
+ if let Some(copy_mode) = self.copy_mode.as_mut() {
+ copy_mode.selection = None;
+ copy_mode.search_query.clear();
+ copy_mode.search_direction = None;
+ copy_mode.search_matches.clear();
+ copy_mode.search_total = 0;
+ copy_mode.search_current = None;
+ copy_mode.search_current_global = None;
+ copy_mode.search_generation = copy_mode.search_generation.saturating_add(1);
+ copy_mode.copy_after_search = false;
+ }
+ self.selection = None;
+ } else {
+ self.exit_copy_mode(false, outcome);
+ }
+ outcome.repaint = true;
+ return;
+ }
+ KeyCode::Enter => {
+ if !self.defer_copy_until_search_result() {
+ self.exit_copy_mode(true, outcome);
+ }
+ return;
+ }
+ KeyCode::Left => {
+ self.move_copy_cursor(0, -1, outcome);
+ return;
+ }
+ KeyCode::Down => {
+ self.move_copy_cursor(1, 0, outcome);
+ return;
+ }
+ KeyCode::Up => {
+ self.move_copy_cursor(-1, 0, outcome);
+ return;
+ }
+ KeyCode::Right => {
+ self.move_copy_cursor(0, 1, outcome);
+ return;
+ }
+ KeyCode::PageUp => {
+ self.move_copy_page(-1, false, outcome);
+ return;
+ }
+ KeyCode::PageDown => {
+ self.move_copy_page(1, false, outcome);
+ return;
+ }
+ KeyCode::Home => {
+ self.set_copy_cursor_col(0);
+ self.sync_copy_selection();
+ outcome.repaint = true;
+ return;
+ }
+ KeyCode::End => {
+ self.request_copy_motion(crate::api::schema::PaneCopyMotion::LineEnd, outcome);
+ return;
+ }
+ _ => {}
+ }
+
+ match (key.code, key.modifiers) {
+ (KeyCode::Char('b'), modifiers) if modifiers.contains(KeyModifiers::CONTROL) => {
+ self.move_copy_page(-1, false, outcome);
+ return;
+ }
+ (KeyCode::Char('f'), modifiers) if modifiers.contains(KeyModifiers::CONTROL) => {
+ self.move_copy_page(1, false, outcome);
+ return;
+ }
+ (KeyCode::Char('u'), modifiers) if modifiers.contains(KeyModifiers::CONTROL) => {
+ self.move_copy_page(-1, true, outcome);
+ return;
+ }
+ (KeyCode::Char('d'), modifiers) if modifiers.contains(KeyModifiers::CONTROL) => {
+ self.move_copy_page(1, true, outcome);
+ return;
+ }
+ _ => {}
+ }
+
+ let Some(command) = crate::copy_mode::copy_mode_command_char(key.clone()) else {
+ return;
+ };
+ match command {
+ 'q' => self.exit_copy_mode(false, outcome),
+ 'y' => {
+ if !self.defer_copy_until_search_result() {
+ self.exit_copy_mode(true, outcome);
+ }
+ }
+ 'v' | ' ' => self.begin_copy_selection(false),
+ 'V' => self.begin_copy_selection(true),
+ 'h' => self.move_copy_cursor(0, -1, outcome),
+ 'j' => self.move_copy_cursor(1, 0, outcome),
+ 'k' => self.move_copy_cursor(-1, 0, outcome),
+ 'l' => self.move_copy_cursor(0, 1, outcome),
+ 'g' => self.move_copy_history(true, outcome),
+ 'G' => self.move_copy_history(false, outcome),
+ '0' => {
+ self.set_copy_cursor_col(0);
+ self.sync_copy_selection();
+ outcome.repaint = true;
+ }
+ '$' => self.request_copy_motion(crate::api::schema::PaneCopyMotion::LineEnd, outcome),
+ '^' => {
+ self.request_copy_motion(crate::api::schema::PaneCopyMotion::FirstNonBlank, outcome)
+ }
+ '/' => self.open_copy_search(crate::api::schema::PaneCopySearchDirection::Forward),
+ '?' => self.open_copy_search(crate::api::schema::PaneCopySearchDirection::Backward),
+ 'n' => self.repeat_copy_search(false, outcome),
+ 'N' => self.repeat_copy_search(true, outcome),
+ 'w' => {
+ self.request_copy_motion(crate::api::schema::PaneCopyMotion::NextWordStart, outcome)
+ }
+ 'b' => self.request_copy_motion(
+ crate::api::schema::PaneCopyMotion::PreviousWordStart,
+ outcome,
+ ),
+ 'e' => {
+ self.request_copy_motion(crate::api::schema::PaneCopyMotion::NextWordEnd, outcome)
+ }
+ 'W' => self.request_copy_motion(
+ crate::api::schema::PaneCopyMotion::NextBigWordStart,
+ outcome,
+ ),
+ 'B' => self.request_copy_motion(
+ crate::api::schema::PaneCopyMotion::PreviousBigWordStart,
+ outcome,
+ ),
+ 'E' => self
+ .request_copy_motion(crate::api::schema::PaneCopyMotion::NextBigWordEnd, outcome),
+ '{' => self.request_copy_motion(
+ crate::api::schema::PaneCopyMotion::PreviousParagraph,
+ outcome,
+ ),
+ '}' => {
+ self.request_copy_motion(crate::api::schema::PaneCopyMotion::NextParagraph, outcome)
+ }
+ _ => return,
+ }
+ outcome.repaint = true;
+ }
+
+ fn route_copy_search_prompt_key(
+ &mut self,
+ key: &crate::input::TerminalKey,
+ outcome: &mut ClientShellInput,
+ ) -> bool {
+ let Some(prompt) = self
+ .copy_mode
+ .as_ref()
+ .and_then(|copy_mode| copy_mode.search_prompt.as_ref())
+ else {
+ return false;
+ };
+ let mut submit = None;
+ match key.code {
+ KeyCode::Esc => {
+ if let Some(copy_mode) = self.copy_mode.as_mut() {
+ copy_mode.search_prompt = None;
+ }
+ }
+ KeyCode::Enter => {
+ submit = Some((prompt.query.clone(), prompt.direction));
+ if let Some(copy_mode) = self.copy_mode.as_mut() {
+ copy_mode.search_prompt = None;
+ }
+ }
+ KeyCode::Backspace => {
+ if let Some(prompt) = self
+ .copy_mode
+ .as_mut()
+ .and_then(|copy_mode| copy_mode.search_prompt.as_mut())
+ {
+ prompt.query.pop();
+ }
+ }
+ KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
+ if let Some(prompt) = self
+ .copy_mode
+ .as_mut()
+ .and_then(|copy_mode| copy_mode.search_prompt.as_mut())
+ {
+ prompt.query.clear();
+ }
+ }
+ _ => {
+ if let Some(ch) = crate::copy_mode::copy_mode_command_char(key.clone()) {
+ if let Some(prompt) = self
+ .copy_mode
+ .as_mut()
+ .and_then(|copy_mode| copy_mode.search_prompt.as_mut())
+ {
+ prompt.query.push(ch);
+ }
+ }
+ }
+ }
+ if let Some((query, direction)) = submit {
+ self.request_copy_search(query, direction, false, outcome);
+ }
+ outcome.repaint = true;
+ true
+ }
+
+ pub(super) fn insert_copy_search_text(&mut self, text: &str) -> bool {
+ let Some(prompt) = self
+ .copy_mode
+ .as_mut()
+ .and_then(|copy_mode| copy_mode.search_prompt.as_mut())
+ else {
+ return false;
+ };
+ prompt
+ .query
+ .extend(text.chars().filter(|character| !character.is_control()));
+ true
+ }
+
+ fn open_copy_search(&mut self, direction: crate::api::schema::PaneCopySearchDirection) {
+ let Some(copy_mode) = self.copy_mode.as_mut() else {
+ return;
+ };
+ copy_mode.search_prompt = Some(ClientCopySearchPrompt {
+ direction,
+ query: String::new(),
+ });
+ }
+
+ fn repeat_copy_search(&mut self, reverse: bool, outcome: &mut ClientShellInput) {
+ let Some(copy_mode) = self.copy_mode.as_ref() else {
+ return;
+ };
+ if copy_mode.search_query.is_empty() {
+ return;
+ }
+ let Some(direction) = copy_mode.search_direction else {
+ return;
+ };
+ let direction = if reverse {
+ match direction {
+ crate::api::schema::PaneCopySearchDirection::Forward => {
+ crate::api::schema::PaneCopySearchDirection::Backward
+ }
+ crate::api::schema::PaneCopySearchDirection::Backward => {
+ crate::api::schema::PaneCopySearchDirection::Forward
+ }
+ }
+ } else {
+ direction
+ };
+ self.request_copy_search(copy_mode.search_query.clone(), direction, true, outcome);
+ }
+
+ fn defer_copy_until_search_result(&mut self) -> bool {
+ let Some(copy_mode) = self.copy_mode.as_ref() else {
+ return false;
+ };
+ let pane_id = copy_mode.pane_id.clone();
+ let generation = copy_mode.search_generation;
+ let pending = self.pending_requests.values().any(|pending| {
+ matches!(
+ &pending.kind,
+ PendingEndpointKind::CopySearch {
+ pane_id: pending_pane,
+ generation: pending_generation,
+ ..
+ } if pending_pane == &pane_id && *pending_generation == generation
+ )
+ }) || self
+ .copy_operation_queue
+ .iter()
+ .any(|operation| matches!(operation, ClientCopyOperation::Search { .. }));
+ if pending {
+ if let Some(copy_mode) = self.copy_mode.as_mut() {
+ copy_mode.copy_after_search = true;
+ }
+ }
+ pending
+ }
+
+ fn request_copy_search(
+ &mut self,
+ query: String,
+ direction: crate::api::schema::PaneCopySearchDirection,
+ repeat: bool,
+ outcome: &mut ClientShellInput,
+ ) {
+ if query.is_empty() || self.copy_mode.is_none() {
+ return;
+ }
+ self.copy_operation_queue
+ .push_back(ClientCopyOperation::Search {
+ query,
+ direction,
+ repeat,
+ });
+ self.dispatch_next_copy_operation(outcome);
+ }
+
+ pub(super) fn apply_copy_search_result(
+ &mut self,
+ pane_id: &str,
+ origin: crate::api::schema::PaneTextPoint,
+ query: String,
+ direction: crate::api::schema::PaneCopySearchDirection,
+ repeat: bool,
+ generation: u64,
+ result: ClientCopySearchResult,
+ outcome: &mut ClientShellInput,
+ ) -> bool {
+ let search_queued = self
+ .copy_operation_queue
+ .iter()
+ .any(|operation| matches!(operation, ClientCopyOperation::Search { .. }));
+ let Some(copy_mode) = self.copy_mode.as_mut() else {
+ return false;
+ };
+ if copy_mode.pane_id != pane_id
+ || copy_mode.cursor != origin
+ || copy_mode.content_revision != result.content_revision
+ || copy_mode.search_generation != generation
+ {
+ return false;
+ }
+ let current = result.current.filter(|index| *index < result.matches.len());
+ copy_mode.search_query = query;
+ if !repeat {
+ copy_mode.search_direction = Some(direction);
+ }
+ copy_mode.search_matches = result.matches;
+ copy_mode.search_total = result.total;
+ copy_mode.search_current = current;
+ copy_mode.search_current_global = result.current_global;
+ let target = current.and_then(|index| copy_mode.search_matches.get(index).copied());
+ let copy_after_search = if search_queued {
+ false
+ } else {
+ std::mem::take(&mut copy_mode.copy_after_search)
+ };
+ if let Some(target) = target {
+ copy_mode.cursor = target.start;
+ self.reveal_copy_cursor(outcome, true);
+ self.sync_copy_selection();
+ }
+ if copy_after_search {
+ self.exit_copy_mode(true, outcome);
+ }
+ outcome.repaint = true;
+ true
+ }
+
+ pub(super) fn complete_copy_operation(
+ &mut self,
+ session_generation: u64,
+ continue_queue: bool,
+ outcome: &mut ClientShellInput,
+ ) {
+ if self.copy_session_generation != session_generation {
+ return;
+ }
+ self.copy_operation_in_flight = false;
+ if continue_queue && self.copy_mode.is_some() {
+ self.dispatch_next_copy_operation(outcome);
+ self.dispatch_queued_copy_input(outcome);
+ } else {
+ self.copy_operation_queue.clear();
+ self.copy_input_queue.clear();
+ }
+ }
+
+ fn dispatch_queued_copy_input(&mut self, outcome: &mut ClientShellInput) {
+ while !self.copy_operation_in_flight {
+ let Some(key) = self.copy_input_queue.pop_front() else {
+ return;
+ };
+ self.handle_key(key, outcome);
+ }
+ }
+
+ pub(super) fn cancel_deferred_copy_after_search(&mut self, generation: u64) {
+ if let Some(copy_mode) = self
+ .copy_mode
+ .as_mut()
+ .filter(|copy_mode| copy_mode.search_generation == generation)
+ {
+ copy_mode.copy_after_search = false;
+ }
+ }
+
+ fn copy_hit(&self) -> Option<PaneHit> {
+ let pane_id = self.copy_mode.as_ref()?.pane_id.as_str();
+ self.hits
+ .panes
+ .iter()
+ .find(|hit| hit.pane_id == pane_id)
+ .cloned()
+ }
+
+ fn move_copy_cursor(&mut self, row_delta: i16, col_delta: i16, outcome: &mut ClientShellInput) {
+ let Some(hit) = self.copy_hit() else {
+ self.exit_copy_mode(false, outcome);
+ return;
+ };
+ let Some(copy_mode) = self.copy_mode.as_mut() else {
+ return;
+ };
+ if col_delta < 0 {
+ copy_mode.cursor.col = copy_mode
+ .cursor
+ .col
+ .saturating_sub(col_delta.unsigned_abs());
+ } else if col_delta > 0 {
+ copy_mode.cursor.col = copy_mode
+ .cursor
+ .col
+ .saturating_add(col_delta as u16)
+ .min(hit.inner_rect.width.saturating_sub(1));
+ }
+ let total_rows = copy_mode
+ .max_offset_from_bottom
+ .saturating_add(hit.inner_rect.height as usize)
+ .max(1);
+ if row_delta < 0 {
+ copy_mode.cursor.row = copy_mode
+ .cursor
+ .row
+ .saturating_sub(u32::from(row_delta.unsigned_abs()));
+ } else if row_delta > 0 {
+ copy_mode.cursor.row = copy_mode
+ .cursor
+ .row
+ .saturating_add(u32::from(row_delta as u16))
+ .min(total_rows.saturating_sub(1).min(u32::MAX as usize) as u32);
+ }
+ self.reveal_copy_cursor(outcome, false);
+ self.sync_copy_selection();
+ outcome.repaint = true;
+ }
+
+ fn move_copy_page(&mut self, direction: i8, half_page: bool, outcome: &mut ClientShellInput) {
+ let Some(hit) = self.copy_hit() else {
+ return;
+ };
+ let lines = crate::copy_mode::copy_mode_page_lines(hit.inner_rect.height, half_page);
+ let Some((pane_id, next_offset)) = self.copy_mode.as_mut().map(|copy_mode| {
+ if direction < 0 {
+ copy_mode.cursor.row = copy_mode.cursor.row.saturating_sub(lines as u32);
+ copy_mode.offset_from_bottom = copy_mode
+ .offset_from_bottom
+ .saturating_add(lines)
+ .min(copy_mode.max_offset_from_bottom);
+ } else {
+ let last_row = copy_mode
+ .max_offset_from_bottom
+ .saturating_add(hit.inner_rect.height as usize)
+ .saturating_sub(1)
+ .min(u32::MAX as usize) as u32;
+ copy_mode.cursor.row = copy_mode
+ .cursor
+ .row
+ .saturating_add(lines as u32)
+ .min(last_row);
+ copy_mode.offset_from_bottom = copy_mode.offset_from_bottom.saturating_sub(lines);
+ }
+ (copy_mode.pane_id.clone(), copy_mode.offset_from_bottom)
+ }) else {
+ return;
+ };
+ self.push_pane_scroll_offset(pane_id, next_offset, outcome);
+ self.sync_copy_selection();
+ outcome.repaint = true;
+ }
+
+ fn move_copy_history(&mut self, top: bool, outcome: &mut ClientShellInput) {
+ let Some(hit) = self.copy_hit() else {
+ return;
+ };
+ let Some((pane_id, offset_from_bottom)) = self.copy_mode.as_mut().map(|copy_mode| {
+ if top {
+ copy_mode.cursor.row = 0;
+ copy_mode.offset_from_bottom = copy_mode.max_offset_from_bottom;
+ } else {
+ copy_mode.cursor.row = copy_mode
+ .max_offset_from_bottom
+ .saturating_add(hit.inner_rect.height as usize)
+ .saturating_sub(1)
+ .min(u32::MAX as usize) as u32;
+ copy_mode.offset_from_bottom = 0;
+ }
+ (copy_mode.pane_id.clone(), copy_mode.offset_from_bottom)
+ }) else {
+ return;
+ };
+ self.push_pane_scroll_offset(pane_id, offset_from_bottom, outcome);
+ self.sync_copy_selection();
+ outcome.repaint = true;
+ }
+
+ fn set_copy_cursor_col(&mut self, col: u16) {
+ if let Some(copy_mode) = self.copy_mode.as_mut() {
+ copy_mode.cursor.col = col;
+ }
+ }
+
+ fn reveal_copy_cursor(&mut self, outcome: &mut ClientShellInput, reserve_mode_bar_row: bool) {
+ let Some(hit) = self.copy_hit() else {
+ return;
+ };
+ let request = self.copy_mode.as_mut().and_then(|copy_mode| {
+ let current_top = copy_mode
+ .max_offset_from_bottom
+ .saturating_sub(copy_mode.offset_from_bottom) as u32;
+ let max_cursor_row = hit
+ .inner_rect
+ .height
+ .saturating_sub(if reserve_mode_bar_row { 2 } else { 1 });
+ let bottom = current_top.saturating_add(u32::from(max_cursor_row));
+ let desired_top = if copy_mode.cursor.row < current_top {
+ copy_mode.cursor.row
+ } else if copy_mode.cursor.row > bottom {
+ copy_mode
+ .cursor
+ .row
+ .saturating_sub(u32::from(max_cursor_row))
+ } else {
+ current_top
+ };
+ let offset = copy_mode
+ .max_offset_from_bottom
+ .saturating_sub(desired_top as usize);
+ if offset == copy_mode.offset_from_bottom {
+ return None;
+ }
+ copy_mode.offset_from_bottom = offset;
+ Some((copy_mode.pane_id.clone(), offset))
+ });
+ if let Some((pane_id, offset)) = request {
+ self.push_pane_scroll_offset(pane_id, offset, outcome);
+ }
+ }
+
+ fn begin_copy_selection(&mut self, linewise: bool) {
+ let end_col = self
+ .copy_hit()
+ .map_or(0, |hit| hit.inner_rect.width.saturating_sub(1));
+ let Some(copy_mode) = self.copy_mode.as_mut() else {
+ return;
+ };
+ if linewise {
+ copy_mode.selection = Some(ClientCopySelection::Linewise {
+ anchor_row: copy_mode.cursor.row,
+ });
+ self.selection = Some(crate::selection::Selection::line_range(
+ copy_mode.pane_id.clone(),
+ copy_mode.cursor.row,
+ copy_mode.cursor.row,
+ end_col,
+ ));
+ } else {
+ copy_mode.selection = Some(ClientCopySelection::Character {
+ anchor: copy_mode.cursor,
+ });
+ self.selection = Some(crate::selection::Selection::absolute_anchor(
+ copy_mode.pane_id.clone(),
+ (copy_mode.cursor.row, copy_mode.cursor.col),
+ ));
+ }
+ }
+
+ pub(super) fn sync_copy_selection(&mut self) {
+ let Some(copy_mode) = self.copy_mode.as_ref() else {
+ return;
+ };
+ let Some(selection) = copy_mode.selection else {
+ return;
+ };
+ self.selection = Some(match selection {
+ ClientCopySelection::Character { anchor } => {
+ crate::selection::Selection::absolute_range(
+ copy_mode.pane_id.clone(),
+ (anchor.row, anchor.col),
+ (copy_mode.cursor.row, copy_mode.cursor.col),
+ )
+ }
+ ClientCopySelection::Linewise { anchor_row } => {
+ crate::selection::Selection::line_range(
+ copy_mode.pane_id.clone(),
+ anchor_row,
+ copy_mode.cursor.row,
+ self.copy_hit()
+ .map_or(0, |hit| hit.inner_rect.width.saturating_sub(1)),
+ )
+ }
+ });
+ }
+
+ fn request_copy_motion(
+ &mut self,
+ motion: crate::api::schema::PaneCopyMotion,
+ outcome: &mut ClientShellInput,
+ ) {
+ if self.copy_mode.is_none() {
+ return;
+ }
+ self.copy_operation_queue
+ .push_back(ClientCopyOperation::Motion(motion));
+ self.dispatch_next_copy_operation(outcome);
+ }
+
+ pub(super) fn dispatch_next_copy_operation(&mut self, outcome: &mut ClientShellInput) {
+ if self.copy_operation_in_flight {
+ return;
+ }
+ while let Some(operation) = self.copy_operation_queue.pop_front() {
+ let Some(copy_mode) = self.copy_mode.as_mut() else {
+ self.copy_operation_queue.clear();
+ self.copy_input_queue.clear();
+ return;
+ };
+ let session_generation = self.copy_session_generation;
+ let pane_id = copy_mode.pane_id.clone();
+ let origin = copy_mode.cursor;
+ let (method, kind) = match operation {
+ ClientCopyOperation::Motion(motion) => (
+ crate::api::schema::Method::PaneCopyMotion(
+ crate::api::schema::PaneCopyMotionParams {
+ pane_id: pane_id.clone(),
+ cursor: origin,
+ motion,
+ content_revision: Some(copy_mode.content_revision),
+ },
+ ),
+ PendingEndpointKind::CopyMotion {
+ pane_id,
+ origin,
+ session_generation,
+ },
+ ),
+ ClientCopyOperation::Search {
+ query,
+ direction,
+ repeat,
+ } => {
+ if query.is_empty() {
+ continue;
+ }
+ copy_mode.search_generation = copy_mode.search_generation.saturating_add(1);
+ let generation = copy_mode.search_generation;
+ let previous = repeat
+ .then(|| {
+ copy_mode
+ .search_current
+ .and_then(|index| copy_mode.search_matches.get(index).copied())
+ .filter(|text_match| text_match.start == copy_mode.cursor)
+ })
+ .flatten();
+ (
+ crate::api::schema::Method::PaneCopySearch(
+ crate::api::schema::PaneCopySearchParams {
+ pane_id: pane_id.clone(),
+ query: query.clone(),
+ direction,
+ cursor: origin,
+ content_revision: copy_mode.content_revision,
+ previous,
+ },
+ ),
+ PendingEndpointKind::CopySearch {
+ pane_id,
+ origin,
+ query,
+ direction,
+ repeat,
+ generation,
+ session_generation,
+ },
+ )
+ }
+ };
+ self.copy_operation_in_flight = true;
+ self.push_endpoint_method_with_kind(method, kind, outcome);
+ return;
+ }
+ }
+
+ pub(super) fn apply_copy_motion_target(
+ &mut self,
+ pane_id: &str,
+ origin: crate::api::schema::PaneTextPoint,
+ cursor: crate::api::schema::PaneTextPoint,
+ content_revision: u64,
+ outcome: &mut ClientShellInput,
+ ) -> bool {
+ let Some(copy_mode) = self.copy_mode.as_mut() else {
+ return false;
+ };
+ if copy_mode.pane_id != pane_id
+ || copy_mode.cursor != origin
+ || copy_mode.content_revision != content_revision
+ {
+ return false;
+ }
+ copy_mode.cursor = cursor;
+ self.reveal_copy_cursor(outcome, false);
+ self.sync_copy_selection();
+ outcome.repaint = true;
+ true
+ }
+
+ pub(super) fn exit_copy_mode(&mut self, copy: bool, outcome: &mut ClientShellInput) {
+ if copy
+ && !self
+ .selection
+ .as_ref()
+ .is_some_and(crate::selection::Selection::is_visible)
+ {
+ if let Some((pane_id, text_match)) = self.copy_mode.as_ref().and_then(|copy_mode| {
+ copy_mode
+ .search_current
+ .and_then(|index| copy_mode.search_matches.get(index).copied())
+ .map(|text_match| (copy_mode.pane_id.clone(), text_match))
+ }) {
+ self.selection = Some(crate::selection::Selection::absolute_range(
+ pane_id,
+ (text_match.start.row, text_match.start.col),
+ (text_match.end.row, text_match.end.col),
+ ));
+ }
+ }
+ let Some(copy_mode) = self.copy_mode.take() else {
+ return;
+ };
+ self.reset_copy_pipeline();
+ if copy
+ && self
+ .selection
+ .as_ref()
+ .is_some_and(crate::selection::Selection::is_visible)
+ {
+ self.request_selection_copy(outcome);
+ }
+ self.selection = None;
+ self.selection_highlight_clear_deadline = None;
+ self.push_pane_scroll_offset(
+ copy_mode.pane_id,
+ copy_mode.entry_offset_from_bottom,
+ outcome,
+ );
+ self.mode = ClientShellMode::Terminal;
+ outcome.repaint = true;
+ }
+}