1use super::{TerminalPos, TerminalSelection};
8use crate::core::event::{KeyCode, KeyEvent, KeyMods};
9use crate::text_motion::{
10 byte_to_char_col, cell_big_word_backward_start, cell_big_word_end, cell_big_word_forward_start,
11 cell_line_first_nonblank, cell_line_last, cell_word_backward_start, cell_word_end,
12 cell_word_forward_start, char_col_to_byte,
13};
14use crate::utils::spans::{byte_at_display_column, display_column};
15
16#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23pub struct CopyModeGrid<'a> {
24 pub rows: usize,
26 pub cols: usize,
28 pub total_scrollback_rows: usize,
30 pub cursor_row_text: &'a str,
32 pub prompt_lines: &'a [usize],
34}
35
36#[derive(Clone, Copy, Debug, PartialEq, Eq)]
38pub enum CopyModeAction {
39 Ignored,
41 Moved,
43 SelectionChanged,
45 RequestCopy,
47 Cancel,
49}
50
51#[derive(Clone, Debug, PartialEq, Eq)]
55pub struct TerminalCopyMode {
56 cursor_row: usize,
57 cursor_col: usize,
58 anchor: Option<TerminalPos>,
59 scrollback_offset: usize,
60}
61
62impl TerminalCopyMode {
63 pub fn new(cursor_row: usize, cursor_col: usize, scrollback_offset: usize) -> Self {
67 Self {
68 cursor_row,
69 cursor_col,
70 anchor: None,
71 scrollback_offset,
72 }
73 }
74
75 pub fn handle_key(&mut self, key: KeyEvent, grid: CopyModeGrid<'_>) -> CopyModeAction {
81 let rows = grid.rows.max(1);
82 let cols = grid.cols.max(1);
83 self.cursor_row = self.cursor_row.min(rows - 1);
84 self.cursor_col = self.cursor_col.min(cols - 1);
85 self.scrollback_offset = self.scrollback_offset.min(grid.total_scrollback_rows);
86 if let Some(anchor) = self.anchor.as_mut() {
87 anchor.line = anchor
88 .line
89 .min(grid.total_scrollback_rows.saturating_add(rows - 1));
90 anchor.col = anchor.col.min(cols - 1);
91 }
92
93 let shifted_upper =
94 key.mods == KeyMods::SHIFT && matches!(key.code, KeyCode::Char('G' | 'W' | 'B' | 'E'));
95 let is_ctrl_page =
96 key.mods == KeyMods::CTRL && matches!(key.code, KeyCode::Char('u' | 'd'));
97 if !key.mods.is_empty() && !shifted_upper && !is_ctrl_page {
98 return CopyModeAction::Ignored;
99 }
100
101 if key.mods.is_empty() && (key.is(KeyCode::Esc) || key.is(KeyCode::Char('q'))) {
102 return CopyModeAction::Cancel;
103 }
104 if key.mods.is_empty() && (key.is(KeyCode::Char('y')) || key.is(KeyCode::Enter)) {
105 return CopyModeAction::RequestCopy;
106 }
107 if key.mods.is_empty() && (key.is(KeyCode::Char('v')) || key.is(KeyCode::Char(' '))) {
108 self.toggle_anchor(grid.total_scrollback_rows);
109 return CopyModeAction::SelectionChanged;
110 }
111
112 if key.mods.is_empty() && key.is(KeyCode::Char('[')) {
113 return self.jump_prompt(false, grid);
114 }
115 if key.mods.is_empty() && key.is(KeyCode::Char(']')) {
116 return self.jump_prompt(true, grid);
117 }
118
119 let before = (self.cursor_row, self.cursor_col, self.scrollback_offset);
120 let half_page = (rows / 2).max(1);
121 let handled = match key.code {
122 KeyCode::Char('h') | KeyCode::Left => {
123 self.cursor_col = self.cursor_col.saturating_sub(1);
124 true
125 }
126 KeyCode::Char('l') | KeyCode::Right => {
127 self.cursor_col = (self.cursor_col + 1).min(cols - 1);
128 true
129 }
130 KeyCode::Char('k') | KeyCode::Up => {
131 move_up(self, 1, grid.total_scrollback_rows);
132 true
133 }
134 KeyCode::Char('j') | KeyCode::Down => {
135 move_down(self, 1, rows);
136 true
137 }
138 KeyCode::Char('u') if key.mods == KeyMods::CTRL => {
139 move_up(self, half_page, grid.total_scrollback_rows);
140 true
141 }
142 KeyCode::Char('d') if key.mods == KeyMods::CTRL => {
143 move_down(self, half_page, rows);
144 true
145 }
146 KeyCode::Char('g') => {
147 self.scrollback_offset = grid.total_scrollback_rows;
148 self.cursor_row = 0;
149 true
150 }
151 KeyCode::Char('G') => {
152 self.scrollback_offset = 0;
153 self.cursor_row = rows - 1;
154 true
155 }
156 KeyCode::Char('w') => {
157 self.cursor_col = display_motion(
158 grid.cursor_row_text,
159 self.cursor_col,
160 cell_word_forward_start,
161 );
162 true
163 }
164 KeyCode::Char('b') => {
165 self.cursor_col = display_motion(
166 grid.cursor_row_text,
167 self.cursor_col,
168 cell_word_backward_start,
169 );
170 true
171 }
172 KeyCode::Char('e') => {
173 self.cursor_col =
174 display_motion(grid.cursor_row_text, self.cursor_col, cell_word_end);
175 true
176 }
177 KeyCode::Char('W') => {
178 self.cursor_col = display_motion(
179 grid.cursor_row_text,
180 self.cursor_col,
181 cell_big_word_forward_start,
182 );
183 true
184 }
185 KeyCode::Char('B') => {
186 self.cursor_col = display_motion(
187 grid.cursor_row_text,
188 self.cursor_col,
189 cell_big_word_backward_start,
190 );
191 true
192 }
193 KeyCode::Char('E') => {
194 self.cursor_col =
195 display_motion(grid.cursor_row_text, self.cursor_col, cell_big_word_end);
196 true
197 }
198 KeyCode::Char('0') => {
199 self.cursor_col = 0;
200 true
201 }
202 KeyCode::Char('^') => {
203 self.cursor_col =
204 display_motion(grid.cursor_row_text, self.cursor_col, |row, _| {
205 cell_line_first_nonblank(row)
206 });
207 true
208 }
209 KeyCode::Char('$') => {
210 self.cursor_col =
211 display_motion(grid.cursor_row_text, self.cursor_col, |row, _| {
212 cell_line_last(row)
213 });
214 true
215 }
216 _ => false,
217 };
218
219 if !handled {
220 return CopyModeAction::Ignored;
221 }
222 if before == (self.cursor_row, self.cursor_col, self.scrollback_offset) {
223 return CopyModeAction::Ignored;
224 }
225
226 if self.anchor.is_some() {
227 CopyModeAction::SelectionChanged
228 } else {
229 CopyModeAction::Moved
230 }
231 }
232
233 pub fn cursor(&self) -> (usize, usize) {
235 (self.cursor_row, self.cursor_col)
236 }
237
238 pub fn anchor(&self) -> Option<TerminalPos> {
242 self.anchor
243 }
244
245 pub fn scrollback_offset(&self) -> usize {
247 self.scrollback_offset
248 }
249
250 pub fn selection(&self, total_scrollback_rows: usize) -> Option<TerminalSelection> {
252 let anchor = self.anchor?;
253 Some(TerminalSelection {
254 anchor,
255 cursor: TerminalPos {
256 line: current_absolute_line(self, total_scrollback_rows),
257 col: self.cursor_col,
258 },
259 })
260 }
261
262 pub fn goto(&mut self, row: usize, col: usize, scrollback_offset: usize) {
266 self.cursor_row = row;
267 self.cursor_col = col;
268 self.scrollback_offset = scrollback_offset;
269 }
270
271 fn toggle_anchor(&mut self, total_scrollback_rows: usize) {
273 self.anchor = match self.anchor {
274 Some(_) => None,
275 None => Some(TerminalPos {
276 line: current_absolute_line(self, total_scrollback_rows),
277 col: self.cursor_col,
278 }),
279 };
280 }
281
282 fn jump_prompt(&mut self, forward: bool, grid: CopyModeGrid<'_>) -> CopyModeAction {
283 let Some(&line) = prompt_target(
284 grid.prompt_lines,
285 current_absolute_line(self, grid.total_scrollback_rows),
286 forward,
287 ) else {
288 return CopyModeAction::Ignored;
289 };
290
291 if line < grid.total_scrollback_rows {
292 self.scrollback_offset = grid.total_scrollback_rows - line;
293 self.cursor_row = 0;
294 } else {
295 self.scrollback_offset = 0;
296 self.cursor_row = (line - grid.total_scrollback_rows).min(grid.rows.max(1) - 1);
297 }
298 self.cursor_col = 0;
299 if self.anchor.is_some() {
300 CopyModeAction::SelectionChanged
301 } else {
302 CopyModeAction::Moved
303 }
304 }
305}
306
307fn display_motion(row: &str, display_col: usize, motion: fn(&str, usize) -> usize) -> usize {
309 let byte = byte_at_display_column(row, display_col);
310 let char_col = byte_to_char_col(row, byte);
311 let next_char_col = motion(row, char_col);
312 display_column(row, char_col_to_byte(row, next_char_col))
313}
314
315fn current_absolute_line(mode: &TerminalCopyMode, total_scrollback_rows: usize) -> usize {
316 total_scrollback_rows
317 .saturating_sub(mode.scrollback_offset)
318 .saturating_add(mode.cursor_row)
319}
320
321fn prompt_target(lines: &[usize], current: usize, forward: bool) -> Option<&usize> {
322 if forward {
323 lines.iter().find(|line| **line > current)
324 } else {
325 lines.iter().rfind(|line| **line < current)
326 }
327}
328
329fn move_up(mode: &mut TerminalCopyMode, steps: usize, total_scrollback_rows: usize) {
330 for _ in 0..steps {
331 if mode.cursor_row > 0 {
332 mode.cursor_row -= 1;
333 } else if mode.scrollback_offset < total_scrollback_rows {
334 mode.scrollback_offset += 1;
335 } else {
336 break;
337 }
338 }
339}
340
341fn move_down(mode: &mut TerminalCopyMode, steps: usize, rows: usize) {
342 let bottom = rows.max(1) - 1;
343 for _ in 0..steps {
344 if mode.cursor_row < bottom {
345 mode.cursor_row += 1;
346 } else if mode.scrollback_offset > 0 {
347 mode.scrollback_offset -= 1;
348 } else {
349 break;
350 }
351 }
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357
358 const GRID: CopyModeGrid<'static> = CopyModeGrid {
359 rows: 4,
360 cols: 20,
361 total_scrollback_rows: 10,
362 cursor_row_text: "one two three",
363 prompt_lines: &[],
364 };
365
366 fn key(code: KeyCode) -> KeyEvent {
367 KeyEvent {
368 code,
369 mods: Default::default(),
370 }
371 }
372
373 #[test]
374 fn movement_scrolls_at_viewport_edges() {
375 let mut mode = TerminalCopyMode::new(2, 0, 0);
376 assert_eq!(
377 mode.handle_key(key(KeyCode::Up), GRID),
378 CopyModeAction::Moved
379 );
380 assert_eq!(mode.cursor(), (1, 0));
381 assert_eq!(
382 mode.handle_key(key(KeyCode::Up), GRID),
383 CopyModeAction::Moved
384 );
385 assert_eq!(mode.cursor(), (0, 0));
386 assert_eq!(
387 mode.handle_key(key(KeyCode::Up), GRID),
388 CopyModeAction::Moved
389 );
390 assert_eq!(mode.scrollback_offset(), 1);
391
392 mode.goto(3, 0, 2);
393 assert_eq!(
394 mode.handle_key(key(KeyCode::Down), GRID),
395 CopyModeAction::Moved
396 );
397 assert_eq!(mode.scrollback_offset(), 1);
398 assert_eq!(mode.cursor(), (3, 0));
399 }
400
401 #[test]
402 fn anchor_stays_absolute_while_scrolling_at_viewport_edges() {
403 let mut mode = TerminalCopyMode::new(0, 2, 0);
404 assert_eq!(
405 mode.handle_key(key(KeyCode::Char('v')), GRID),
406 CopyModeAction::SelectionChanged
407 );
408 let anchor = mode.anchor().expect("anchor");
409 assert_eq!(anchor, TerminalPos { line: 10, col: 2 });
410 assert_eq!(
411 mode.handle_key(key(KeyCode::Up), GRID),
412 CopyModeAction::SelectionChanged
413 );
414 assert_eq!(mode.scrollback_offset(), 1);
415 assert_eq!(mode.anchor(), Some(anchor));
416 assert_eq!(
417 mode.selection(GRID.total_scrollback_rows)
418 .expect("selection")
419 .cursor
420 .line,
421 9
422 );
423 }
424
425 #[test]
426 fn movement_at_a_boundary_is_ignored() {
427 let mut mode = TerminalCopyMode::new(0, 0, GRID.total_scrollback_rows);
428 assert_eq!(
429 mode.handle_key(key(KeyCode::Left), GRID),
430 CopyModeAction::Ignored
431 );
432 assert_eq!(
433 mode.handle_key(key(KeyCode::Up), GRID),
434 CopyModeAction::Ignored
435 );
436
437 mode.goto(GRID.rows - 1, GRID.cols - 1, 0);
438 assert_eq!(
439 mode.handle_key(key(KeyCode::Right), GRID),
440 CopyModeAction::Ignored
441 );
442 assert_eq!(
443 mode.handle_key(key(KeyCode::Down), GRID),
444 CopyModeAction::Ignored
445 );
446 }
447
448 #[test]
449 fn ctrl_page_motions_and_g_commands_use_copy_mode_bounds() {
450 let mut mode = TerminalCopyMode::new(2, 0, 0);
451 let ctrl_u = KeyEvent {
452 code: KeyCode::Char('u'),
453 mods: crate::core::event::KeyMods::CTRL,
454 };
455 assert_eq!(mode.handle_key(ctrl_u, GRID), CopyModeAction::Moved);
456 assert_eq!(mode.cursor(), (0, 0));
457
458 assert_eq!(
459 mode.handle_key(key(KeyCode::Char('g')), GRID),
460 CopyModeAction::Moved
461 );
462 assert_eq!((mode.cursor(), mode.scrollback_offset()), ((0, 0), 10));
463 assert_eq!(
464 mode.handle_key(key(KeyCode::Char('G')), GRID),
465 CopyModeAction::Moved
466 );
467 assert_eq!((mode.cursor(), mode.scrollback_offset()), ((3, 0), 0));
468 }
469
470 #[test]
471 fn vim_motions_and_anchor_report_selection_changes() {
472 let mut mode = TerminalCopyMode::new(0, 0, 0);
473 assert_eq!(
474 mode.handle_key(key(KeyCode::Char('w')), GRID),
475 CopyModeAction::Moved
476 );
477 assert_eq!(mode.cursor().1, 4);
478 assert_eq!(
479 mode.handle_key(key(KeyCode::Char('v')), GRID),
480 CopyModeAction::SelectionChanged
481 );
482 assert_eq!(
483 mode.handle_key(key(KeyCode::Char('e')), GRID),
484 CopyModeAction::SelectionChanged
485 );
486 assert_eq!(mode.anchor(), Some(TerminalPos { line: 10, col: 4 }));
487 assert_eq!(
488 mode.selection(GRID.total_scrollback_rows)
489 .map(|s| (s.anchor.col, s.cursor.col)),
490 Some((4, 6))
491 );
492 assert_eq!(
493 mode.handle_key(key(KeyCode::Char(' ')), GRID),
494 CopyModeAction::SelectionChanged
495 );
496 assert_eq!(mode.selection(GRID.total_scrollback_rows), None);
497 }
498
499 #[test]
500 fn text_motions_keep_cursor_and_selection_in_display_columns() {
501 let grid = CopyModeGrid {
502 rows: 2,
503 cols: 20,
504 total_scrollback_rows: 0,
505 cursor_row_text: "界 foo",
506 prompt_lines: &[],
507 };
508 let mut mode = TerminalCopyMode::new(0, 0, 0);
509 assert_eq!(
510 mode.handle_key(key(KeyCode::Char('w')), grid),
511 CopyModeAction::Moved
512 );
513 assert_eq!(mode.cursor(), (0, 3));
515 assert_eq!(
516 mode.handle_key(key(KeyCode::Char('e')), grid),
517 CopyModeAction::Moved
518 );
519 assert_eq!(mode.cursor(), (0, 5));
520
521 mode.goto(0, 1, 0);
522 assert_eq!(
523 mode.handle_key(key(KeyCode::Char('v')), grid),
524 CopyModeAction::SelectionChanged
525 );
526 assert_eq!(
527 mode.handle_key(key(KeyCode::Char('w')), grid),
528 CopyModeAction::SelectionChanged
529 );
530 let selection = mode
531 .selection(grid.total_scrollback_rows)
532 .expect("anchor should create a selection");
533 assert_eq!((selection.anchor.col, selection.cursor.col), (1, 3));
534 mode.handle_key(key(KeyCode::Char(' ')), grid);
535
536 let combining = CopyModeGrid {
537 cursor_row_text: " e\u{301} foo",
538 ..grid
539 };
540 mode.goto(0, 0, 0);
541 assert_eq!(
542 mode.handle_key(key(KeyCode::Char('^')), combining),
543 CopyModeAction::Moved
544 );
545 assert_eq!(mode.cursor().1, 2);
546 assert_eq!(
547 mode.handle_key(key(KeyCode::Char('$')), combining),
548 CopyModeAction::Moved
549 );
550 assert_eq!(mode.cursor().1, 6);
551 }
552
553 #[test]
554 fn ordinary_keys_require_no_modifiers() {
555 let grid = GRID;
556 let mut mode = TerminalCopyMode::new(1, 2, 0);
557 for mods in [
558 KeyMods::CTRL,
559 KeyMods::SHIFT,
560 KeyMods {
561 alt: true,
562 ..KeyMods::NONE
563 },
564 ] {
565 let key = KeyEvent {
566 code: KeyCode::Char('h'),
567 mods,
568 };
569 assert_eq!(mode.handle_key(key, grid), CopyModeAction::Ignored);
570 assert_eq!(mode.cursor(), (1, 2));
571 }
572
573 let ctrl_shift_u = KeyEvent {
574 code: KeyCode::Char('u'),
575 mods: KeyMods {
576 ctrl: true,
577 shift: true,
578 ..KeyMods::NONE
579 },
580 };
581 assert_eq!(mode.handle_key(ctrl_shift_u, grid), CopyModeAction::Ignored);
582
583 let ctrl_u = KeyEvent {
584 code: KeyCode::Char('u'),
585 mods: KeyMods::CTRL,
586 };
587 assert_eq!(mode.handle_key(ctrl_u, grid), CopyModeAction::Moved);
588 assert_eq!(mode.cursor(), (0, 2));
589
590 let ctrl_d = KeyEvent {
591 code: KeyCode::Char('d'),
592 mods: KeyMods::CTRL,
593 };
594 assert_eq!(mode.handle_key(ctrl_d, grid), CopyModeAction::Moved);
595 assert_eq!(mode.cursor(), (2, 2));
596
597 let shifted_g = KeyEvent {
598 code: KeyCode::Char('G'),
599 mods: KeyMods::SHIFT,
600 };
601 assert_eq!(mode.handle_key(shifted_g, grid), CopyModeAction::Moved);
602 assert_eq!(mode.cursor(), (3, 2));
603 }
604
605 #[test]
606 fn copy_cancel_and_unknown_keys_are_distinct() {
607 let mut mode = TerminalCopyMode::new(0, 0, 0);
608 assert_eq!(
609 mode.handle_key(key(KeyCode::Char('x')), GRID),
610 CopyModeAction::Ignored
611 );
612 assert_eq!(
613 mode.handle_key(key(KeyCode::Char('q')), GRID),
614 CopyModeAction::Cancel
615 );
616 assert_eq!(
617 mode.handle_key(key(KeyCode::Enter), GRID),
618 CopyModeAction::RequestCopy
619 );
620 }
621
622 #[test]
623 fn prompt_jumps_use_absolute_line_math() {
624 let grid = CopyModeGrid {
625 prompt_lines: &[2, 7, 13],
626 ..GRID
627 };
628 let mut mode = TerminalCopyMode::new(0, 4, 5);
629 assert_eq!(
631 mode.handle_key(key(KeyCode::Char(']')), grid),
632 CopyModeAction::Moved
633 );
634 assert_eq!((mode.cursor(), mode.scrollback_offset()), ((0, 0), 3));
635 assert_eq!(
636 mode.handle_key(key(KeyCode::Char('[')), grid),
637 CopyModeAction::Moved
638 );
639 assert_eq!((mode.cursor(), mode.scrollback_offset()), ((0, 0), 8));
640
641 mode.goto(0, 0, 10);
642 assert_eq!(
643 mode.handle_key(key(KeyCode::Char('[')), grid),
644 CopyModeAction::Ignored
645 );
646 mode.goto(3, 0, 0);
647 assert_eq!(
648 mode.handle_key(key(KeyCode::Char(']')), grid),
649 CopyModeAction::Ignored
650 );
651 }
652}