Skip to main content

par_term/
copy_mode.rs

1//! Vi-style Copy Mode state machine.
2//!
3//! Copy Mode provides keyboard-driven text selection and navigation,
4//! matching iTerm2's Copy Mode. When active, all keyboard input navigates
5//! an independent cursor through the terminal buffer (including scrollback).
6
7use crate::selection::{Selection, SelectionMode};
8use crate::smart_selection::is_word_char;
9use std::collections::HashMap;
10
11/// Visual selection mode in copy mode
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum VisualMode {
14    /// No visual selection active
15    None,
16    /// Character-wise selection (v)
17    Char,
18    /// Line-wise selection (V)
19    Line,
20    /// Block/rectangular selection (Ctrl+V)
21    Block,
22}
23
24/// Pending operator waiting for a motion
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum PendingOperator {
27    /// Yank (copy) operator
28    Yank,
29}
30
31/// Search direction
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum SearchDirection {
34    Forward,
35    Backward,
36}
37
38/// A named mark position
39#[derive(Debug, Clone, Copy)]
40pub struct Mark {
41    pub col: usize,
42    pub absolute_line: usize,
43}
44
45/// Copy mode state machine.
46///
47/// Uses absolute line indexing:
48/// - Line 0 = oldest scrollback line
49/// - Line `scrollback_len - 1` = newest scrollback line
50/// - Line `scrollback_len` = top of visible screen (at scroll_offset=0)
51/// - Line `scrollback_len + rows - 1` = bottom of visible screen
52pub struct CopyModeState {
53    /// Whether copy mode is active
54    pub active: bool,
55    /// Cursor column position
56    pub cursor_col: usize,
57    /// Cursor absolute line position
58    pub cursor_absolute_line: usize,
59    /// Current visual selection mode
60    pub visual_mode: VisualMode,
61    /// Selection anchor point (absolute_line, col) - set when entering visual mode
62    pub selection_anchor: Option<(usize, usize)>,
63    /// Count prefix for motions (e.g., 5j moves down 5 lines)
64    pub count: Option<usize>,
65    /// Pending operator waiting for a motion
66    pub pending_operator: Option<PendingOperator>,
67    /// Named marks (a-z)
68    pub marks: HashMap<char, Mark>,
69    /// Terminal columns
70    pub cols: usize,
71    /// Terminal rows
72    pub rows: usize,
73    /// Scrollback buffer length
74    pub scrollback_len: usize,
75    /// Current search query
76    pub search_query: String,
77    /// Search direction
78    pub search_direction: SearchDirection,
79    /// Whether search input mode is active
80    pub is_searching: bool,
81    /// Waiting for second 'g' in 'gg'
82    pub(crate) pending_g: bool,
83    /// Waiting for mark name after 'm'
84    pub(crate) pending_mark_set: bool,
85    /// Waiting for mark name after "'"
86    pub(crate) pending_mark_goto: bool,
87}
88
89impl Default for CopyModeState {
90    fn default() -> Self {
91        Self::new()
92    }
93}
94
95impl CopyModeState {
96    /// Create a new inactive copy mode state
97    pub fn new() -> Self {
98        Self {
99            active: false,
100            cursor_col: 0,
101            cursor_absolute_line: 0,
102            visual_mode: VisualMode::None,
103            selection_anchor: None,
104            count: None,
105            pending_operator: None,
106            marks: HashMap::new(),
107            cols: 80,
108            rows: 24,
109            scrollback_len: 0,
110            search_query: String::new(),
111            search_direction: SearchDirection::Forward,
112            is_searching: false,
113            pending_g: false,
114            pending_mark_set: false,
115            pending_mark_goto: false,
116        }
117    }
118
119    /// Enter copy mode at the given cursor position
120    pub fn enter(
121        &mut self,
122        cursor_col: usize,
123        cursor_row: usize,
124        cols: usize,
125        rows: usize,
126        scrollback_len: usize,
127    ) {
128        self.active = true;
129        self.cols = cols;
130        self.rows = rows;
131        self.scrollback_len = scrollback_len;
132        // Convert screen row to absolute line
133        self.cursor_absolute_line = scrollback_len + cursor_row;
134        self.cursor_col = cursor_col.min(cols.saturating_sub(1));
135        self.visual_mode = VisualMode::None;
136        self.selection_anchor = None;
137        self.count = None;
138        self.pending_operator = None;
139        self.search_query.clear();
140        self.is_searching = false;
141        self.pending_g = false;
142        self.pending_mark_set = false;
143        self.pending_mark_goto = false;
144    }
145
146    /// Exit copy mode, clearing all state
147    pub fn exit(&mut self) {
148        self.active = false;
149        self.visual_mode = VisualMode::None;
150        self.selection_anchor = None;
151        self.count = None;
152        self.pending_operator = None;
153        self.is_searching = false;
154        self.pending_g = false;
155        self.pending_mark_set = false;
156        self.pending_mark_goto = false;
157    }
158
159    // ========================================================================
160    // Count prefix
161    // ========================================================================
162
163    /// Push a digit to the count prefix
164    pub fn push_count_digit(&mut self, digit: u8) {
165        let current = self.count.unwrap_or(0);
166        self.count = Some(current * 10 + digit as usize);
167    }
168
169    /// Get the effective count (defaults to 1 if no count set)
170    pub fn effective_count(&mut self) -> usize {
171        let c = self.count.unwrap_or(1);
172        self.count = None;
173        c
174    }
175
176    /// Total number of lines (scrollback + screen)
177    fn total_lines(&self) -> usize {
178        self.scrollback_len + self.rows
179    }
180
181    /// Maximum valid absolute line index
182    fn max_line(&self) -> usize {
183        self.total_lines().saturating_sub(1)
184    }
185
186    // ========================================================================
187    // Basic motions
188    // ========================================================================
189
190    /// Move cursor left by count
191    pub fn move_left(&mut self) {
192        let count = self.effective_count();
193        self.cursor_col = self.cursor_col.saturating_sub(count);
194    }
195
196    /// Move cursor right by count
197    pub fn move_right(&mut self) {
198        let count = self.effective_count();
199        self.cursor_col = (self.cursor_col + count).min(self.cols.saturating_sub(1));
200    }
201
202    /// Move cursor up by count
203    pub fn move_up(&mut self) {
204        let count = self.effective_count();
205        self.cursor_absolute_line = self.cursor_absolute_line.saturating_sub(count);
206    }
207
208    /// Move cursor down by count
209    pub fn move_down(&mut self) {
210        let count = self.effective_count();
211        self.cursor_absolute_line = (self.cursor_absolute_line + count).min(self.max_line());
212    }
213
214    /// Move cursor to start of line
215    pub fn move_to_line_start(&mut self) {
216        self.cursor_col = 0;
217    }
218
219    /// Move cursor to end of line
220    pub fn move_to_line_end(&mut self) {
221        self.cursor_col = self.cols.saturating_sub(1);
222    }
223
224    /// Move cursor to first non-blank character on the line
225    pub fn move_to_first_non_blank(&mut self, line_text: &str) {
226        let first_non_blank = line_text
227            .chars()
228            .position(|c| !c.is_whitespace())
229            .unwrap_or(0);
230        self.cursor_col = first_non_blank.min(self.cols.saturating_sub(1));
231    }
232
233    // ========================================================================
234    // Word motions
235    // ========================================================================
236
237    /// Move forward to start of next word
238    pub fn move_word_forward(&mut self, line_text: &str, word_chars: &str) {
239        let count = self.effective_count();
240        let chars: Vec<char> = line_text.chars().collect();
241        let mut col = self.cursor_col;
242
243        for _ in 0..count {
244            if col >= chars.len() {
245                break;
246            }
247            // Skip current word characters
248            while col < chars.len() && is_word_char(chars[col], word_chars) {
249                col += 1;
250            }
251            // Skip non-word characters (whitespace/punctuation)
252            while col < chars.len() && !is_word_char(chars[col], word_chars) {
253                col += 1;
254            }
255        }
256
257        self.cursor_col = col.min(self.cols.saturating_sub(1));
258    }
259
260    /// Move backward to start of previous word
261    pub fn move_word_backward(&mut self, line_text: &str, word_chars: &str) {
262        let count = self.effective_count();
263        let chars: Vec<char> = line_text.chars().collect();
264        let mut col = self.cursor_col;
265
266        for _ in 0..count {
267            if col == 0 {
268                break;
269            }
270            col = col.saturating_sub(1);
271            // Skip non-word characters backward
272            while col > 0 && !is_word_char(chars[col], word_chars) {
273                col -= 1;
274            }
275            // Skip word characters backward to find start
276            while col > 0 && is_word_char(chars[col - 1], word_chars) {
277                col -= 1;
278            }
279        }
280
281        self.cursor_col = col;
282    }
283
284    /// Move forward to end of current/next word
285    pub fn move_word_end(&mut self, line_text: &str, word_chars: &str) {
286        let count = self.effective_count();
287        let chars: Vec<char> = line_text.chars().collect();
288        let mut col = self.cursor_col;
289
290        for _ in 0..count {
291            if col >= chars.len().saturating_sub(1) {
292                break;
293            }
294            col += 1;
295            // Skip non-word characters
296            while col < chars.len() && !is_word_char(chars[col], word_chars) {
297                col += 1;
298            }
299            // Move to end of word
300            while col < chars.len().saturating_sub(1) && is_word_char(chars[col + 1], word_chars) {
301                col += 1;
302            }
303        }
304
305        self.cursor_col = col.min(self.cols.saturating_sub(1));
306    }
307
308    /// Move forward to start of next WORD (whitespace-delimited)
309    pub fn move_big_word_forward(&mut self, line_text: &str) {
310        let count = self.effective_count();
311        let chars: Vec<char> = line_text.chars().collect();
312        let mut col = self.cursor_col;
313
314        for _ in 0..count {
315            // Skip non-whitespace
316            while col < chars.len() && !chars[col].is_whitespace() {
317                col += 1;
318            }
319            // Skip whitespace
320            while col < chars.len() && chars[col].is_whitespace() {
321                col += 1;
322            }
323        }
324
325        self.cursor_col = col.min(self.cols.saturating_sub(1));
326    }
327
328    /// Move backward to start of previous WORD (whitespace-delimited)
329    pub fn move_big_word_backward(&mut self, line_text: &str) {
330        let count = self.effective_count();
331        let chars: Vec<char> = line_text.chars().collect();
332        let mut col = self.cursor_col;
333
334        for _ in 0..count {
335            if col == 0 {
336                break;
337            }
338            col = col.saturating_sub(1);
339            // Skip whitespace backward
340            while col > 0 && chars[col].is_whitespace() {
341                col -= 1;
342            }
343            // Skip non-whitespace backward
344            while col > 0 && !chars[col - 1].is_whitespace() {
345                col -= 1;
346            }
347        }
348
349        self.cursor_col = col;
350    }
351
352    /// Move forward to end of current/next WORD (whitespace-delimited)
353    pub fn move_big_word_end(&mut self, line_text: &str) {
354        let count = self.effective_count();
355        let chars: Vec<char> = line_text.chars().collect();
356        let mut col = self.cursor_col;
357
358        for _ in 0..count {
359            if col >= chars.len().saturating_sub(1) {
360                break;
361            }
362            col += 1;
363            // Skip whitespace
364            while col < chars.len() && chars[col].is_whitespace() {
365                col += 1;
366            }
367            // Move to end of WORD
368            while col < chars.len().saturating_sub(1) && !chars[col + 1].is_whitespace() {
369                col += 1;
370            }
371        }
372
373        self.cursor_col = col.min(self.cols.saturating_sub(1));
374    }
375
376    // ========================================================================
377    // Page motions
378    // ========================================================================
379
380    /// Move half page up
381    pub fn half_page_up(&mut self) {
382        let half = self.rows / 2;
383        let count = self.effective_count();
384        self.cursor_absolute_line = self.cursor_absolute_line.saturating_sub(half * count);
385    }
386
387    /// Move half page down
388    pub fn half_page_down(&mut self) {
389        let half = self.rows / 2;
390        let count = self.effective_count();
391        self.cursor_absolute_line = (self.cursor_absolute_line + half * count).min(self.max_line());
392    }
393
394    /// Move full page up
395    pub fn page_up(&mut self) {
396        let count = self.effective_count();
397        self.cursor_absolute_line = self.cursor_absolute_line.saturating_sub(self.rows * count);
398    }
399
400    /// Move full page down
401    pub fn page_down(&mut self) {
402        let count = self.effective_count();
403        self.cursor_absolute_line =
404            (self.cursor_absolute_line + self.rows * count).min(self.max_line());
405    }
406
407    /// Go to top of buffer (line 0)
408    pub fn goto_top(&mut self) {
409        self.cursor_absolute_line = 0;
410    }
411
412    /// Go to bottom of buffer (last line)
413    pub fn goto_bottom(&mut self) {
414        self.cursor_absolute_line = self.max_line();
415    }
416
417    /// Go to specific absolute line (for count+G)
418    pub fn goto_line(&mut self, line: usize) {
419        self.cursor_absolute_line = line.min(self.max_line());
420    }
421
422    // ========================================================================
423    // Visual mode
424    // ========================================================================
425
426    /// Toggle character-wise visual mode
427    pub fn toggle_visual_char(&mut self) {
428        if self.visual_mode == VisualMode::Char {
429            self.visual_mode = VisualMode::None;
430            self.selection_anchor = None;
431        } else {
432            self.visual_mode = VisualMode::Char;
433            self.selection_anchor = Some((self.cursor_absolute_line, self.cursor_col));
434        }
435    }
436
437    /// Toggle line-wise visual mode
438    pub fn toggle_visual_line(&mut self) {
439        if self.visual_mode == VisualMode::Line {
440            self.visual_mode = VisualMode::None;
441            self.selection_anchor = None;
442        } else {
443            self.visual_mode = VisualMode::Line;
444            self.selection_anchor = Some((self.cursor_absolute_line, self.cursor_col));
445        }
446    }
447
448    /// Toggle block/rectangular visual mode
449    pub fn toggle_visual_block(&mut self) {
450        if self.visual_mode == VisualMode::Block {
451            self.visual_mode = VisualMode::None;
452            self.selection_anchor = None;
453        } else {
454            self.visual_mode = VisualMode::Block;
455            self.selection_anchor = Some((self.cursor_absolute_line, self.cursor_col));
456        }
457    }
458
459    /// Compute a `Selection` from the current visual mode state.
460    ///
461    /// The selection coordinates are in screen-relative terms
462    /// (row = line - viewport_top) for rendering.
463    /// `scroll_offset` is the current viewport scroll position.
464    pub fn compute_selection(&self, scroll_offset: usize) -> Option<Selection> {
465        if self.visual_mode == VisualMode::None {
466            return None;
467        }
468
469        let (anchor_line, anchor_col) = self.selection_anchor?;
470
471        // Convert absolute lines to viewport-relative rows
472        // viewport_top = scrollback_len - scroll_offset (absolute line at top of screen)
473        let viewport_top = self.scrollback_len.saturating_sub(scroll_offset);
474
475        // Both anchor and cursor must produce valid viewport rows for rendering
476        // We allow negative (above screen) and beyond-screen positions
477        // by clamping to 0..rows-1 for rendering purposes
478        let anchor_row = anchor_line.saturating_sub(viewport_top);
479        let cursor_row = self.cursor_absolute_line.saturating_sub(viewport_top);
480
481        let mode = match self.visual_mode {
482            VisualMode::None => return None,
483            VisualMode::Char => SelectionMode::Normal,
484            VisualMode::Line => SelectionMode::Line,
485            VisualMode::Block => SelectionMode::Rectangular,
486        };
487
488        let start = (anchor_col, anchor_row);
489        let end = (self.cursor_col, cursor_row);
490
491        Some(Selection::new(start, end, mode))
492    }
493
494    // ========================================================================
495    // Marks
496    // ========================================================================
497
498    /// Set a named mark at the current cursor position
499    pub fn set_mark(&mut self, name: char) {
500        self.marks.insert(
501            name,
502            Mark {
503                col: self.cursor_col,
504                absolute_line: self.cursor_absolute_line,
505            },
506        );
507    }
508
509    /// Jump to a named mark, returning true if the mark exists
510    pub fn goto_mark(&mut self, name: char) -> bool {
511        if let Some(mark) = self.marks.get(&name) {
512            self.cursor_col = mark.col;
513            self.cursor_absolute_line = mark.absolute_line;
514            true
515        } else {
516            false
517        }
518    }
519
520    // ========================================================================
521    // Search
522    // ========================================================================
523
524    /// Start search input mode
525    pub fn start_search(&mut self, direction: SearchDirection) {
526        self.is_searching = true;
527        self.search_direction = direction;
528        self.search_query.clear();
529    }
530
531    /// Add a character to the search query
532    pub fn search_input(&mut self, ch: char) {
533        self.search_query.push(ch);
534    }
535
536    /// Remove the last character from the search query
537    pub fn search_backspace(&mut self) {
538        self.search_query.pop();
539    }
540
541    /// Cancel search mode without executing
542    pub fn cancel_search(&mut self) {
543        self.is_searching = false;
544        self.search_query.clear();
545    }
546
547    // ========================================================================
548    // Viewport
549    // ========================================================================
550
551    /// Get the cursor position in screen coordinates, if visible.
552    ///
553    /// Returns `(col, row)` where row is relative to the viewport top.
554    /// Returns `None` if the cursor is outside the visible viewport.
555    pub fn screen_cursor_pos(&self, scroll_offset: usize) -> Option<(usize, usize)> {
556        let viewport_top = self.scrollback_len.saturating_sub(scroll_offset);
557        let viewport_bottom = viewport_top + self.rows;
558
559        if self.cursor_absolute_line >= viewport_top && self.cursor_absolute_line < viewport_bottom
560        {
561            let screen_row = self.cursor_absolute_line - viewport_top;
562            Some((self.cursor_col, screen_row))
563        } else {
564            None
565        }
566    }
567
568    /// Calculate the scroll offset needed to make the cursor visible.
569    ///
570    /// Returns `Some(new_offset)` if scrolling is needed, `None` if cursor is already visible.
571    pub fn required_scroll_offset(&self, current_offset: usize) -> Option<usize> {
572        let viewport_top = self.scrollback_len.saturating_sub(current_offset);
573        let viewport_bottom = viewport_top + self.rows;
574
575        if self.cursor_absolute_line < viewport_top {
576            // Cursor is above viewport — scroll up
577            let new_offset = self
578                .scrollback_len
579                .saturating_sub(self.cursor_absolute_line);
580            Some(new_offset)
581        } else if self.cursor_absolute_line >= viewport_bottom {
582            // Cursor is below viewport — scroll down
583            let lines_below = self.cursor_absolute_line - viewport_top;
584            let needed_offset = current_offset
585                .saturating_sub(lines_below.saturating_sub(self.rows.saturating_sub(1)));
586            // Alternatively: the viewport top should be cursor_line - (rows - 1)
587            let target_viewport_top = self
588                .cursor_absolute_line
589                .saturating_sub(self.rows.saturating_sub(1));
590            let new_offset = self.scrollback_len.saturating_sub(target_viewport_top);
591            // Clamp to valid range
592            let _ = needed_offset; // suppress unused
593            Some(new_offset.min(self.scrollback_len))
594        } else {
595            None
596        }
597    }
598
599    /// Update the scrollback length (call when terminal state changes)
600    pub fn update_dimensions(&mut self, cols: usize, rows: usize, scrollback_len: usize) {
601        self.cols = cols;
602        self.rows = rows;
603        self.scrollback_len = scrollback_len;
604        // Clamp cursor to valid range
605        self.cursor_col = self.cursor_col.min(cols.saturating_sub(1));
606        self.cursor_absolute_line = self.cursor_absolute_line.min(self.max_line());
607    }
608
609    /// Get a status line description of the current mode
610    pub fn status_text(&self) -> String {
611        if self.is_searching {
612            let dir = match self.search_direction {
613                SearchDirection::Forward => '/',
614                SearchDirection::Backward => '?',
615            };
616            format!("{}{}", dir, self.search_query)
617        } else {
618            let mode = match self.visual_mode {
619                VisualMode::None => "COPY",
620                VisualMode::Char => "VISUAL",
621                VisualMode::Line => "VISUAL LINE",
622                VisualMode::Block => "VISUAL BLOCK",
623            };
624            let pos = format!(
625                "{}:{} (abs {})",
626                self.cursor_absolute_line
627                    .saturating_sub(self.scrollback_len),
628                self.cursor_col,
629                self.cursor_absolute_line,
630            );
631            format!("-- {} -- {}", mode, pos)
632        }
633    }
634}
635
636#[cfg(test)]
637mod tests {
638    use super::*;
639
640    #[test]
641    fn test_enter_exit() {
642        let mut cm = CopyModeState::new();
643        assert!(!cm.active);
644
645        cm.enter(5, 10, 80, 24, 100);
646        assert!(cm.active);
647        assert_eq!(cm.cursor_col, 5);
648        assert_eq!(cm.cursor_absolute_line, 110); // scrollback(100) + row(10)
649        assert_eq!(cm.cols, 80);
650        assert_eq!(cm.rows, 24);
651
652        cm.exit();
653        assert!(!cm.active);
654    }
655
656    #[test]
657    fn test_basic_motions() {
658        let mut cm = CopyModeState::new();
659        cm.enter(10, 5, 80, 24, 100);
660
661        cm.move_left();
662        assert_eq!(cm.cursor_col, 9);
663
664        cm.move_right();
665        assert_eq!(cm.cursor_col, 10);
666
667        cm.move_up();
668        assert_eq!(cm.cursor_absolute_line, 104);
669
670        cm.move_down();
671        assert_eq!(cm.cursor_absolute_line, 105);
672
673        cm.move_to_line_start();
674        assert_eq!(cm.cursor_col, 0);
675
676        cm.move_to_line_end();
677        assert_eq!(cm.cursor_col, 79);
678    }
679
680    #[test]
681    fn test_count_prefix() {
682        let mut cm = CopyModeState::new();
683        cm.enter(10, 12, 80, 24, 100);
684
685        cm.push_count_digit(5);
686        cm.move_down();
687        assert_eq!(cm.cursor_absolute_line, 117);
688    }
689
690    #[test]
691    fn test_boundary_clamping() {
692        let mut cm = CopyModeState::new();
693        cm.enter(0, 0, 80, 24, 0);
694
695        // Can't go above line 0
696        cm.move_up();
697        assert_eq!(cm.cursor_absolute_line, 0);
698
699        // Can't go left of col 0
700        cm.move_left();
701        assert_eq!(cm.cursor_col, 0);
702
703        // Can't go past max line
704        cm.goto_bottom();
705        assert_eq!(cm.cursor_absolute_line, 23);
706        cm.move_down();
707        assert_eq!(cm.cursor_absolute_line, 23);
708    }
709
710    #[test]
711    fn test_visual_modes() {
712        let mut cm = CopyModeState::new();
713        cm.enter(5, 5, 80, 24, 100);
714
715        // Enter char visual
716        cm.toggle_visual_char();
717        assert_eq!(cm.visual_mode, VisualMode::Char);
718        assert!(cm.selection_anchor.is_some());
719
720        // Toggle off
721        cm.toggle_visual_char();
722        assert_eq!(cm.visual_mode, VisualMode::None);
723        assert!(cm.selection_anchor.is_none());
724
725        // Enter line visual
726        cm.toggle_visual_line();
727        assert_eq!(cm.visual_mode, VisualMode::Line);
728
729        // Switch to block visual
730        cm.toggle_visual_block();
731        assert_eq!(cm.visual_mode, VisualMode::Block);
732    }
733
734    #[test]
735    fn test_screen_cursor_pos() {
736        let mut cm = CopyModeState::new();
737        cm.enter(5, 10, 80, 24, 100);
738        // scroll_offset=0 means viewport starts at line 100
739
740        // Cursor at absolute line 110, viewport top at 100
741        assert_eq!(cm.screen_cursor_pos(0), Some((5, 10)));
742
743        // Cursor above viewport
744        cm.cursor_absolute_line = 50;
745        assert_eq!(cm.screen_cursor_pos(0), None);
746
747        // Scroll up to make it visible
748        assert_eq!(cm.screen_cursor_pos(50), Some((5, 0)));
749    }
750
751    #[test]
752    fn test_compute_selection() {
753        let mut cm = CopyModeState::new();
754        cm.enter(5, 5, 80, 24, 100);
755
756        // No selection without visual mode
757        assert!(cm.compute_selection(0).is_none());
758
759        // Enter visual char mode
760        cm.toggle_visual_char();
761        cm.move_right();
762        cm.move_right();
763        cm.move_down();
764
765        let sel = cm.compute_selection(0).unwrap();
766        assert_eq!(sel.mode, SelectionMode::Normal);
767        // Anchor at (5, 5), cursor at (7, 6)
768        assert_eq!(sel.start, (5, 5));
769        assert_eq!(sel.end, (7, 6));
770    }
771
772    #[test]
773    fn test_marks() {
774        let mut cm = CopyModeState::new();
775        cm.enter(10, 5, 80, 24, 100);
776
777        cm.set_mark('a');
778        cm.move_down();
779        cm.move_right();
780
781        assert!(cm.goto_mark('a'));
782        assert_eq!(cm.cursor_col, 10);
783        assert_eq!(cm.cursor_absolute_line, 105);
784
785        assert!(!cm.goto_mark('b')); // non-existent mark
786    }
787
788    #[test]
789    fn test_word_motions() {
790        let mut cm = CopyModeState::new();
791        cm.enter(0, 0, 80, 24, 0);
792
793        let line = "hello world foo";
794        cm.move_word_forward(line, "");
795        assert_eq!(cm.cursor_col, 6); // start of "world"
796
797        cm.move_word_end(line, "");
798        assert_eq!(cm.cursor_col, 10); // end of "world"
799
800        cm.move_word_backward(line, "");
801        assert_eq!(cm.cursor_col, 6); // back to start of "world"
802    }
803
804    #[test]
805    fn test_page_motions() {
806        let mut cm = CopyModeState::new();
807        cm.enter(0, 12, 80, 24, 200);
808        // Absolute line = 212
809
810        cm.half_page_up();
811        assert_eq!(cm.cursor_absolute_line, 200); // 212 - 12
812
813        cm.page_down();
814        assert_eq!(cm.cursor_absolute_line, 223); // max_line = 200+24-1 = 223
815
816        cm.goto_top();
817        assert_eq!(cm.cursor_absolute_line, 0);
818
819        cm.goto_bottom();
820        assert_eq!(cm.cursor_absolute_line, 223);
821    }
822
823    #[test]
824    fn test_search_state() {
825        let mut cm = CopyModeState::new();
826        cm.enter(0, 0, 80, 24, 0);
827
828        cm.start_search(SearchDirection::Forward);
829        assert!(cm.is_searching);
830
831        cm.search_input('h');
832        cm.search_input('e');
833        assert_eq!(cm.search_query, "he");
834
835        cm.search_backspace();
836        assert_eq!(cm.search_query, "h");
837
838        cm.cancel_search();
839        assert!(!cm.is_searching);
840        assert!(cm.search_query.is_empty());
841    }
842
843    #[test]
844    fn test_required_scroll_offset() {
845        let mut cm = CopyModeState::new();
846        cm.enter(0, 12, 80, 24, 100);
847        // Cursor at line 112, viewport top at line 100 (offset=0)
848
849        // Cursor is visible, no scroll needed
850        assert_eq!(cm.required_scroll_offset(0), None);
851
852        // Move cursor above viewport
853        cm.cursor_absolute_line = 50;
854        let offset = cm.required_scroll_offset(0).unwrap();
855        assert_eq!(offset, 50); // scrollback_len - cursor_line = 100 - 50
856    }
857}