Skip to main content

tui_lipan/widgets/terminal/
copy_mode.rs

1//! Keyboard copy-mode state for terminal grids.
2//!
3//! This module owns navigation state only. It does not own a terminal screen, clipboard,
4//! scrollback application, runtime updates, or copy feedback. Applications compose it with a
5//! [`crate::widgets::TerminalScreen`] and [`crate::widgets::TerminalRenderSnapshot`].
6
7use 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/// The terminal grid data needed to handle one copy-mode key.
17///
18/// Cursor and selection coordinates (`cols`, `cursor`, and `anchor`) are **display columns**.
19/// `cursor_row_text` is the text row under the cursor; word/line motions bridge its character
20/// columns to display columns internally. `prompt_lines` contains absolute retained-line indices
21/// for semantic prompt jumps, in ascending order.
22#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23pub struct CopyModeGrid<'a> {
24    /// Number of visible rows.
25    pub rows: usize,
26    /// Number of visible display columns.
27    pub cols: usize,
28    /// Maximum scrollback offset (the number of retained history rows).
29    pub total_scrollback_rows: usize,
30    /// Text of the row under the cursor, used as the source for character-column motion adapters.
31    pub cursor_row_text: &'a str,
32    /// Absolute retained-line positions of semantic prompts, oldest first.
33    pub prompt_lines: &'a [usize],
34}
35
36/// Result of handling a copy-mode key.
37#[derive(Clone, Copy, Debug, PartialEq, Eq)]
38pub enum CopyModeAction {
39    /// The key is not a copy-mode key; the application may route it elsewhere.
40    Ignored,
41    /// The cursor or scrollback offset moved without changing an active selection.
42    Moved,
43    /// The selection anchor or an anchored cursor position changed.
44    SelectionChanged,
45    /// The application should copy the current selection.
46    RequestCopy,
47    /// The application should leave copy mode without copying.
48    Cancel,
49}
50
51/// Stateful keyboard navigation for terminal copy mode.
52///
53/// Cursor coordinates are viewport-relative while the anchor is an absolute retained-line position.
54#[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    /// Create copy mode at a viewport cursor and scrollback offset.
64    ///
65    /// `cursor_col` is a display-column coordinate.
66    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    /// Handle a copy-mode key against the current terminal grid.
76    ///
77    /// Navigation keys use vim's character-column motions. `[` and `]` jump to the previous and
78    /// next absolute prompt line in `grid.prompt_lines`; all other unlisted keys return
79    /// [`CopyModeAction::Ignored`].
80    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    /// Return the viewport cursor position. The returned column is a display-column coordinate.
234    pub fn cursor(&self) -> (usize, usize) {
235        (self.cursor_row, self.cursor_col)
236    }
237
238    /// Return the current selection endpoints, if an anchor is active.
239    ///
240    /// Both columns in the returned point are display-column coordinates.
241    pub fn anchor(&self) -> Option<TerminalPos> {
242        self.anchor
243    }
244
245    /// Return the current scrollback offset.
246    pub fn scrollback_offset(&self) -> usize {
247        self.scrollback_offset
248    }
249
250    /// Return the selection from the anchor to the cursor.
251    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    /// Move the copy cursor and scrollback offset to an application-selected location.
263    ///
264    /// `col` is a display-column coordinate.
265    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    /// Toggle the selection anchor at the current cursor position.
272    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
307/// Apply a character-column motion while keeping the copy cursor in display columns.
308fn 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        // 界 occupies display columns 0..2, then the space is column 2; `w` lands on `f` at 3.
514        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        // history - offset + row = 5, so ] selects line 7 and parks at offset 3.
630        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}