Skip to main content

scarab_plugin_api/copy_mode/
mod.rs

1//! Copy Mode & Advanced Selection
2//!
3//! This module provides vim-like keyboard navigation and selection in terminal scrollback.
4//! Users can enter copy mode, navigate with hjkl keys, select text with visual mode,
5//! and yank to clipboard.
6
7use serde::{Deserialize, Serialize};
8
9/// Copy mode cursor with support for scrollback navigation
10#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
11pub struct CopyModeCursor {
12    /// Horizontal position (column)
13    pub x: u16,
14    /// Vertical position (row), can be negative for scrollback
15    pub y: i32,
16}
17
18impl CopyModeCursor {
19    /// Create a new cursor at the specified position
20    pub fn new(x: u16, y: i32) -> Self {
21        Self { x, y }
22    }
23
24    /// Create a cursor at the origin (0, 0)
25    pub fn origin() -> Self {
26        Self { x: 0, y: 0 }
27    }
28}
29
30/// Selection region with anchor and active cursor positions
31#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
32pub struct Selection {
33    /// The fixed anchor point where selection started
34    pub anchor: CopyModeCursor,
35    /// The active cursor that moves with navigation
36    pub active: CopyModeCursor,
37}
38
39impl Selection {
40    /// Create a new selection with the given anchor and active positions
41    pub fn new(anchor: CopyModeCursor, active: CopyModeCursor) -> Self {
42        Self { anchor, active }
43    }
44
45    /// Create a selection anchored at a single point
46    pub fn at_point(cursor: CopyModeCursor) -> Self {
47        Self {
48            anchor: cursor,
49            active: cursor,
50        }
51    }
52}
53
54/// Selection mode for different types of text selection
55#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
56pub enum SelectionMode {
57    /// No active selection
58    #[default]
59    None,
60    /// Character-by-character selection (vim visual mode)
61    Cell,
62    /// Whole line selection (vim V mode)
63    Line,
64    /// Rectangular block selection (vim Ctrl+V mode)
65    Block,
66    /// Word-by-word semantic selection
67    Word,
68}
69
70/// Copy mode state tracking active mode, cursor, and selection
71#[derive(Clone, Debug, Default, Serialize, Deserialize)]
72pub struct CopyModeState {
73    /// Whether copy mode is currently active
74    pub active: bool,
75    /// Current cursor position
76    pub cursor: CopyModeCursor,
77    /// Active selection, if any
78    pub selection: Option<Selection>,
79    /// Current selection mode
80    pub selection_mode: SelectionMode,
81    /// Viewport offset (lines scrolled from bottom)
82    pub viewport_offset: i32,
83}
84
85impl CopyModeState {
86    /// Create a new inactive copy mode state
87    pub fn new() -> Self {
88        Self::default()
89    }
90
91    /// Activate copy mode at the current cursor position
92    pub fn activate(&mut self, cursor: CopyModeCursor) {
93        self.active = true;
94        self.cursor = cursor;
95        self.selection = None;
96        self.selection_mode = SelectionMode::None;
97    }
98
99    /// Deactivate copy mode and clear selection
100    pub fn deactivate(&mut self) {
101        self.active = false;
102        self.selection = None;
103        self.selection_mode = SelectionMode::None;
104    }
105
106    /// Start a selection at the current cursor position
107    pub fn start_selection(&mut self, mode: SelectionMode) {
108        self.selection_mode = mode;
109        self.selection = Some(Selection::at_point(self.cursor));
110    }
111
112    /// Clear the current selection
113    pub fn clear_selection(&mut self) {
114        self.selection = None;
115        self.selection_mode = SelectionMode::None;
116    }
117
118    /// Update the active end of the selection to the current cursor
119    pub fn update_selection(&mut self) {
120        if let Some(ref mut selection) = self.selection {
121            selection.active = self.cursor;
122        }
123    }
124
125    /// Toggle selection mode on/off
126    pub fn toggle_selection(&mut self, mode: SelectionMode) {
127        if self.selection_mode == mode {
128            self.clear_selection();
129        } else {
130            self.start_selection(mode);
131        }
132    }
133
134    /// Swap the anchor and active ends of the selection
135    pub fn swap_selection_ends(&mut self) {
136        if let Some(ref mut selection) = self.selection {
137            std::mem::swap(&mut selection.anchor, &mut selection.active);
138            self.cursor = selection.active;
139        }
140    }
141
142    // Cursor movement methods
143
144    /// Move cursor left, clamping at column 0
145    pub fn move_left(&mut self) {
146        if self.cursor.x > 0 {
147            self.cursor.x -= 1;
148        }
149    }
150
151    /// Move cursor right, clamping at max_cols
152    pub fn move_right(&mut self, max_cols: u16) {
153        if self.cursor.x < max_cols.saturating_sub(1) {
154            self.cursor.x += 1;
155        }
156    }
157
158    /// Move cursor up, clamping at scrollback top
159    pub fn move_up(&mut self, min_y: i32) {
160        if self.cursor.y > min_y {
161            self.cursor.y -= 1;
162        }
163    }
164
165    /// Move cursor down, clamping at screen bottom
166    pub fn move_down(&mut self, max_y: i32) {
167        if self.cursor.y < max_y {
168            self.cursor.y += 1;
169        }
170    }
171
172    /// Move cursor to column 0 (start of line)
173    pub fn move_to_line_start(&mut self) {
174        self.cursor.x = 0;
175    }
176
177    /// Move cursor to end of line
178    pub fn move_to_line_end(&mut self, line_length: u16) {
179        self.cursor.x = line_length.saturating_sub(1);
180    }
181
182    /// Move cursor to top of scrollback
183    pub fn move_to_top(&mut self, min_y: i32) {
184        self.cursor.y = min_y;
185    }
186
187    /// Move cursor to bottom of screen
188    pub fn move_to_bottom(&mut self, max_y: i32) {
189        self.cursor.y = max_y;
190    }
191
192    // Selection methods
193
194    /// Toggle cell selection mode
195    pub fn toggle_cell_selection(&mut self) {
196        self.toggle_selection(SelectionMode::Cell);
197    }
198
199    /// Toggle line selection mode
200    pub fn toggle_line_selection(&mut self) {
201        self.toggle_selection(SelectionMode::Line);
202    }
203
204    /// Toggle block selection mode
205    pub fn toggle_block_selection(&mut self) {
206        self.toggle_selection(SelectionMode::Block);
207    }
208
209    /// Toggle word selection mode
210    pub fn toggle_word_selection(&mut self) {
211        self.toggle_selection(SelectionMode::Word);
212    }
213
214    /// Get the selected text using a callback to retrieve line content
215    ///
216    /// The callback function should take a line number (y coordinate) and return
217    /// the line content as a String, or None if the line doesn't exist.
218    pub fn get_selection_text<F>(&self, get_line: F) -> Option<String>
219    where
220        F: Fn(i32) -> Option<String>,
221    {
222        let selection = self.selection.as_ref()?;
223        let (start, end) = normalize_selection(selection);
224
225        let mut result = String::new();
226
227        match self.selection_mode {
228            SelectionMode::None => return None,
229            SelectionMode::Cell => {
230                // Character-by-character selection
231                if start.y == end.y {
232                    // Single line selection
233                    if let Some(line) = get_line(start.y) {
234                        let start_x = start.x as usize;
235                        let end_x = (end.x as usize + 1).min(line.len());
236                        if start_x < line.len() {
237                            result.push_str(&line[start_x..end_x]);
238                        }
239                    }
240                } else {
241                    // Multi-line selection
242                    for y in start.y..=end.y {
243                        if let Some(line) = get_line(y) {
244                            if y == start.y {
245                                // First line: from start.x to end
246                                let start_x = start.x as usize;
247                                if start_x < line.len() {
248                                    result.push_str(&line[start_x..]);
249                                }
250                            } else if y == end.y {
251                                // Last line: from beginning to end.x
252                                let end_x = (end.x as usize + 1).min(line.len());
253                                result.push_str(&line[..end_x]);
254                            } else {
255                                // Middle lines: entire line
256                                result.push_str(&line);
257                            }
258                            // Add newline except for last line
259                            if y < end.y {
260                                result.push('\n');
261                            }
262                        }
263                    }
264                }
265            }
266            SelectionMode::Line => {
267                // Whole line selection
268                for y in start.y..=end.y {
269                    if let Some(line) = get_line(y) {
270                        result.push_str(&line);
271                        if y < end.y {
272                            result.push('\n');
273                        }
274                    }
275                }
276            }
277            SelectionMode::Block => {
278                // Rectangular block selection
279                let min_x = start.x.min(end.x);
280                let max_x = start.x.max(end.x);
281
282                for y in start.y..=end.y {
283                    if let Some(line) = get_line(y) {
284                        let start_x = min_x as usize;
285                        let end_x = (max_x as usize + 1).min(line.len());
286                        if start_x < line.len() {
287                            result.push_str(&line[start_x..end_x]);
288                        }
289                        if y < end.y {
290                            result.push('\n');
291                        }
292                    }
293                }
294            }
295            SelectionMode::Word => {
296                // Word selection (basic implementation - just use cell mode for now)
297                if start.y == end.y {
298                    if let Some(line) = get_line(start.y) {
299                        let start_x = start.x as usize;
300                        let end_x = (end.x as usize + 1).min(line.len());
301                        if start_x < line.len() {
302                            result.push_str(&line[start_x..end_x]);
303                        }
304                    }
305                }
306            }
307        }
308
309        if result.is_empty() {
310            None
311        } else {
312            Some(result)
313        }
314    }
315}
316
317/// Search state for copy mode search functionality
318#[derive(Clone, Debug, Default, Serialize, Deserialize)]
319pub struct SearchState {
320    /// Whether search is currently active
321    pub active: bool,
322    /// Current search query
323    pub query: String,
324    /// Search direction
325    pub direction: SearchDirection,
326    /// List of all matches found
327    pub matches: Vec<SearchMatch>,
328    /// Index of the currently selected match
329    pub current_match: Option<usize>,
330}
331
332impl SearchState {
333    /// Create a new inactive search state
334    pub fn new() -> Self {
335        Self::default()
336    }
337
338    /// Start a new search in the given direction
339    pub fn start_search(&mut self, direction: SearchDirection) {
340        self.active = true;
341        self.direction = direction;
342        self.query.clear();
343        self.matches.clear();
344        self.current_match = None;
345    }
346
347    /// Update the search query and matches
348    pub fn update_query(&mut self, query: String, matches: Vec<SearchMatch>) {
349        self.query = query;
350        let is_empty = matches.is_empty();
351        self.matches = matches;
352        self.current_match = if is_empty { None } else { Some(0) };
353    }
354
355    /// Navigate to the next match
356    pub fn next_match(&mut self) {
357        if let Some(current) = self.current_match {
358            if !self.matches.is_empty() {
359                self.current_match = Some((current + 1) % self.matches.len());
360            }
361        }
362    }
363
364    /// Navigate to the previous match
365    pub fn prev_match(&mut self) {
366        if let Some(current) = self.current_match {
367            if !self.matches.is_empty() {
368                self.current_match = Some(if current == 0 {
369                    self.matches.len() - 1
370                } else {
371                    current - 1
372                });
373            }
374        }
375    }
376
377    /// Get the currently selected match
378    pub fn current(&self) -> Option<&SearchMatch> {
379        self.current_match.and_then(|idx| self.matches.get(idx))
380    }
381
382    /// Deactivate search
383    pub fn deactivate(&mut self) {
384        self.active = false;
385        self.query.clear();
386        self.matches.clear();
387        self.current_match = None;
388    }
389}
390
391/// Search direction
392#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
393pub enum SearchDirection {
394    /// Search forward from cursor
395    #[default]
396    Forward,
397    /// Search backward from cursor
398    Backward,
399}
400
401/// A match found during search
402#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
403pub struct SearchMatch {
404    /// Starting cursor position of the match
405    pub start: CopyModeCursor,
406    /// Ending cursor position of the match
407    pub end: CopyModeCursor,
408}
409
410impl SearchMatch {
411    /// Create a new search match
412    pub fn new(start: CopyModeCursor, end: CopyModeCursor) -> Self {
413        Self { start, end }
414    }
415}
416
417/// Find all matches of a query string in the scrollback buffer
418///
419/// This function searches through the entire scrollback buffer for occurrences
420/// of the query string and returns a list of matches with their positions.
421///
422/// # Arguments
423/// * `query` - The search string (case-insensitive)
424/// * `get_line` - Callback to retrieve line content by y coordinate
425/// * `min_y` - Minimum y coordinate (top of scrollback)
426/// * `max_y` - Maximum y coordinate (bottom of screen)
427///
428/// # Returns
429/// Vector of SearchMatch structs representing each occurrence found
430pub fn find_matches<F>(query: &str, get_line: F, min_y: i32, max_y: i32) -> Vec<SearchMatch>
431where
432    F: Fn(i32) -> Option<String>,
433{
434    let mut matches = Vec::new();
435
436    if query.is_empty() {
437        return matches;
438    }
439
440    let query_lower = query.to_lowercase();
441
442    // Search through all lines in the buffer
443    for y in min_y..=max_y {
444        if let Some(line) = get_line(y) {
445            let line_lower = line.to_lowercase();
446
447            // Find all occurrences in this line
448            let mut start_pos = 0;
449            while let Some(match_pos) = line_lower[start_pos..].find(&query_lower) {
450                let absolute_pos = start_pos + match_pos;
451                let start = CopyModeCursor::new(absolute_pos as u16, y);
452                let end = CopyModeCursor::new((absolute_pos + query.len() - 1) as u16, y);
453                matches.push(SearchMatch::new(start, end));
454
455                // Move past this match to find the next one
456                start_pos = absolute_pos + 1;
457            }
458        }
459    }
460
461    matches
462}
463
464/// Normalize a selection so that start comes before end
465///
466/// Returns (start, end) where start.y < end.y, or if start.y == end.y, then start.x <= end.x
467pub fn normalize_selection(selection: &Selection) -> (CopyModeCursor, CopyModeCursor) {
468    let a = selection.anchor;
469    let b = selection.active;
470
471    if a.y < b.y || (a.y == b.y && a.x <= b.x) {
472        (a, b)
473    } else {
474        (b, a)
475    }
476}
477
478/// Get the bounding box of a selection
479///
480/// Returns (min_x, min_y, max_x, max_y) representing the rectangular bounds
481pub fn get_selection_bounds(selection: &Selection) -> (u16, i32, u16, i32) {
482    let (start, end) = normalize_selection(selection);
483
484    // For cell selection, the bounds are tight around the start and end
485    let min_x = start.x.min(end.x);
486    let max_x = start.x.max(end.x);
487    let min_y = start.y.min(end.y);
488    let max_y = start.y.max(end.y);
489
490    (min_x, min_y, max_x, max_y)
491}
492
493/// Find word boundaries at the given position in a line
494///
495/// Returns (start_x, end_x) representing the word boundaries.
496/// A word is defined as a sequence of alphanumeric characters or underscores.
497///
498/// # Arguments
499/// * `x` - The column position to search from
500/// * `line` - The line content
501///
502/// # Returns
503/// Tuple of (start_x, end_x) representing the word boundaries
504pub fn find_word_bounds(x: u16, line: &str) -> (u16, u16) {
505    let x_pos = x as usize;
506
507    if line.is_empty() || x_pos >= line.len() {
508        return (x, x);
509    }
510
511    let chars: Vec<char> = line.chars().collect();
512
513    // Check if current position is a word character
514    let is_word_char = |c: char| c.is_alphanumeric() || c == '_';
515
516    if x_pos >= chars.len() {
517        return (x, x);
518    }
519
520    let current_char = chars[x_pos];
521
522    // If not on a word character, just return the current position
523    if !is_word_char(current_char) {
524        return (x, x);
525    }
526
527    // Find start of word (move left)
528    let mut start = x_pos;
529    while start > 0 && is_word_char(chars[start - 1]) {
530        start -= 1;
531    }
532
533    // Find end of word (move right)
534    let mut end = x_pos;
535    while end < chars.len() - 1 && is_word_char(chars[end + 1]) {
536        end += 1;
537    }
538
539    (start as u16, end as u16)
540}
541
542// Re-export status bar types for use in indicator functions
543use crate::status_bar::{Color, RenderItem};
544
545/// Generate mode indicator render items for status bar integration
546///
547/// Creates a styled indicator showing the current copy mode state.
548/// Returns an empty vector if copy mode is not active.
549///
550/// # Arguments
551/// * `state` - The current copy mode state
552/// * `search_active` - Whether search is currently active
553///
554/// # Returns
555/// Vector of RenderItem elements for the status bar
556///
557/// # Example
558/// ```rust
559/// use scarab_plugin_api::copy_mode::{CopyModeState, copy_mode_indicator};
560///
561/// let mut state = CopyModeState::new();
562/// state.active = true;
563/// let items = copy_mode_indicator(&state, false);
564/// // Returns: [Background(orange), Foreground(dark), Bold, Text(" COPY "), ResetAttributes]
565/// ```
566pub fn copy_mode_indicator(state: &CopyModeState, search_active: bool) -> Vec<RenderItem> {
567    if !state.active {
568        return vec![];
569    }
570
571    let mode_text = if search_active {
572        "SEARCH"
573    } else {
574        match state.selection {
575            None => "COPY",
576            Some(_) => match state.selection_mode {
577                SelectionMode::None => "COPY",
578                SelectionMode::Cell => "VISUAL",
579                SelectionMode::Line => "V-LINE",
580                SelectionMode::Block => "V-BLOCK",
581                SelectionMode::Word => "V-WORD",
582            },
583        }
584    };
585
586    vec![
587        RenderItem::Background(Color::Rgb(255, 158, 100)), // Orange
588        RenderItem::Foreground(Color::Rgb(26, 27, 38)),    // Dark text
589        RenderItem::Bold,
590        RenderItem::Text(format!(" {} ", mode_text)),
591        RenderItem::ResetAttributes,
592    ]
593}
594
595/// Generate position indicator render items for status bar
596///
597/// Shows the current cursor position in "L{line},C{column}" format.
598/// Line and column numbers are 1-indexed for user display.
599///
600/// # Arguments
601/// * `state` - The current copy mode state
602///
603/// # Returns
604/// Vector of RenderItem elements for the status bar
605///
606/// # Example
607/// ```rust
608/// use scarab_plugin_api::copy_mode::{CopyModeState, CopyModeCursor, copy_mode_position_indicator};
609///
610/// let mut state = CopyModeState::new();
611/// state.active = true;
612/// state.cursor = CopyModeCursor::new(5, 10);
613/// let items = copy_mode_position_indicator(&state);
614/// // Returns: [Text(" L11,C6 ")]
615/// ```
616pub fn copy_mode_position_indicator(state: &CopyModeState) -> Vec<RenderItem> {
617    if !state.active {
618        return vec![];
619    }
620
621    vec![RenderItem::Text(format!(
622        " L{},C{} ",
623        state.cursor.y + 1,
624        state.cursor.x + 1
625    ))]
626}
627
628/// Generate search match count indicator for status bar
629///
630/// Shows the current match number and total count in "{current}/{total}" format.
631/// Returns empty vector if search is not active or there are no matches.
632///
633/// # Arguments
634/// * `search` - The current search state
635///
636/// # Returns
637/// Vector of RenderItem elements for the status bar
638///
639/// # Example
640/// ```rust
641/// use scarab_plugin_api::copy_mode::{SearchState, SearchMatch, CopyModeCursor, search_match_indicator};
642///
643/// let mut search = SearchState::new();
644/// search.active = true;
645/// search.matches = vec![
646///     SearchMatch::new(CopyModeCursor::new(0, 0), CopyModeCursor::new(5, 0)),
647///     SearchMatch::new(CopyModeCursor::new(0, 1), CopyModeCursor::new(5, 1)),
648/// ];
649/// search.current_match = Some(0);
650/// let items = search_match_indicator(&search);
651/// // Returns: [Text(" 1/2 ")]
652/// ```
653pub fn search_match_indicator(search: &SearchState) -> Vec<RenderItem> {
654    if !search.active || search.matches.is_empty() {
655        return vec![];
656    }
657
658    let current = search.current_match.map(|i| i + 1).unwrap_or(0);
659    vec![RenderItem::Text(format!(
660        " {}/{} ",
661        current,
662        search.matches.len()
663    ))]
664}
665
666#[cfg(test)]
667mod tests {
668    use super::*;
669
670    #[test]
671    fn test_cursor_creation() {
672        let cursor = CopyModeCursor::new(10, 5);
673        assert_eq!(cursor.x, 10);
674        assert_eq!(cursor.y, 5);
675    }
676
677    #[test]
678    fn test_cursor_origin() {
679        let cursor = CopyModeCursor::origin();
680        assert_eq!(cursor.x, 0);
681        assert_eq!(cursor.y, 0);
682    }
683
684    #[test]
685    fn test_cursor_negative_y() {
686        // Test scrollback support
687        let cursor = CopyModeCursor::new(5, -100);
688        assert_eq!(cursor.y, -100);
689    }
690
691    #[test]
692    fn test_selection_normalization() {
693        // Test forward selection (already normalized)
694        let sel = Selection {
695            anchor: CopyModeCursor::new(0, 0),
696            active: CopyModeCursor::new(10, 5),
697        };
698        let (start, end) = normalize_selection(&sel);
699        assert_eq!(start.x, 0);
700        assert_eq!(start.y, 0);
701        assert_eq!(end.x, 10);
702        assert_eq!(end.y, 5);
703
704        // Test backward selection (needs normalization)
705        let sel = Selection {
706            anchor: CopyModeCursor::new(10, 5),
707            active: CopyModeCursor::new(5, 3),
708        };
709        let (start, end) = normalize_selection(&sel);
710        assert_eq!(start.x, 5);
711        assert_eq!(start.y, 3);
712        assert_eq!(end.x, 10);
713        assert_eq!(end.y, 5);
714
715        // Test same line, different columns
716        let sel = Selection {
717            anchor: CopyModeCursor::new(10, 5),
718            active: CopyModeCursor::new(3, 5),
719        };
720        let (start, end) = normalize_selection(&sel);
721        assert_eq!(start.x, 3);
722        assert_eq!(start.y, 5);
723        assert_eq!(end.x, 10);
724        assert_eq!(end.y, 5);
725    }
726
727    #[test]
728    fn test_selection_bounds() {
729        let sel = Selection {
730            anchor: CopyModeCursor::new(5, 3),
731            active: CopyModeCursor::new(10, 7),
732        };
733        let (min_x, min_y, max_x, max_y) = get_selection_bounds(&sel);
734        assert_eq!(min_x, 5);
735        assert_eq!(min_y, 3);
736        assert_eq!(max_x, 10);
737        assert_eq!(max_y, 7);
738    }
739
740    #[test]
741    fn test_selection_bounds_reversed() {
742        let sel = Selection {
743            anchor: CopyModeCursor::new(10, 7),
744            active: CopyModeCursor::new(5, 3),
745        };
746        let (min_x, min_y, max_x, max_y) = get_selection_bounds(&sel);
747        assert_eq!(min_x, 5);
748        assert_eq!(min_y, 3);
749        assert_eq!(max_x, 10);
750        assert_eq!(max_y, 7);
751    }
752
753    #[test]
754    fn test_selection_bounds_single_line() {
755        let sel = Selection {
756            anchor: CopyModeCursor::new(3, 5),
757            active: CopyModeCursor::new(10, 5),
758        };
759        let (min_x, min_y, max_x, max_y) = get_selection_bounds(&sel);
760        assert_eq!(min_x, 3);
761        assert_eq!(min_y, 5);
762        assert_eq!(max_x, 10);
763        assert_eq!(max_y, 5);
764    }
765
766    #[test]
767    fn test_copy_mode_state_activation() {
768        let mut state = CopyModeState::new();
769        assert!(!state.active);
770
771        state.activate(CopyModeCursor::new(5, 10));
772        assert!(state.active);
773        assert_eq!(state.cursor.x, 5);
774        assert_eq!(state.cursor.y, 10);
775
776        state.deactivate();
777        assert!(!state.active);
778    }
779
780    #[test]
781    fn test_copy_mode_selection_toggle() {
782        let mut state = CopyModeState::new();
783        state.cursor = CopyModeCursor::new(5, 5);
784
785        // Start cell selection
786        state.toggle_selection(SelectionMode::Cell);
787        assert_eq!(state.selection_mode, SelectionMode::Cell);
788        assert!(state.selection.is_some());
789
790        // Toggle off
791        state.toggle_selection(SelectionMode::Cell);
792        assert_eq!(state.selection_mode, SelectionMode::None);
793        assert!(state.selection.is_none());
794    }
795
796    #[test]
797    fn test_copy_mode_swap_selection_ends() {
798        let mut state = CopyModeState::new();
799        state.cursor = CopyModeCursor::new(5, 5);
800        state.start_selection(SelectionMode::Cell);
801
802        // Move cursor to create selection
803        state.cursor = CopyModeCursor::new(10, 10);
804        state.update_selection();
805
806        let selection = state.selection.as_ref().unwrap();
807        assert_eq!(selection.anchor, CopyModeCursor::new(5, 5));
808        assert_eq!(selection.active, CopyModeCursor::new(10, 10));
809
810        // Swap ends
811        state.swap_selection_ends();
812        let selection = state.selection.as_ref().unwrap();
813        assert_eq!(selection.anchor, CopyModeCursor::new(10, 10));
814        assert_eq!(selection.active, CopyModeCursor::new(5, 5));
815        assert_eq!(state.cursor, CopyModeCursor::new(5, 5));
816    }
817
818    #[test]
819    fn test_search_state_navigation() {
820        let mut search = SearchState::new();
821        let matches = vec![
822            SearchMatch::new(CopyModeCursor::new(0, 0), CopyModeCursor::new(5, 0)),
823            SearchMatch::new(CopyModeCursor::new(10, 0), CopyModeCursor::new(15, 0)),
824            SearchMatch::new(CopyModeCursor::new(0, 1), CopyModeCursor::new(5, 1)),
825        ];
826
827        search.update_query("test".to_string(), matches);
828        assert_eq!(search.current_match, Some(0));
829
830        // Navigate forward
831        search.next_match();
832        assert_eq!(search.current_match, Some(1));
833
834        search.next_match();
835        assert_eq!(search.current_match, Some(2));
836
837        // Wrap around
838        search.next_match();
839        assert_eq!(search.current_match, Some(0));
840
841        // Navigate backward
842        search.prev_match();
843        assert_eq!(search.current_match, Some(2));
844    }
845
846    #[test]
847    fn test_search_state_no_matches() {
848        let mut search = SearchState::new();
849        search.update_query("notfound".to_string(), vec![]);
850        assert_eq!(search.current_match, None);
851
852        // Navigation should do nothing
853        search.next_match();
854        assert_eq!(search.current_match, None);
855
856        search.prev_match();
857        assert_eq!(search.current_match, None);
858    }
859
860    #[test]
861    fn test_find_matches() {
862        let get_line = |y: i32| match y {
863            0 => Some("Hello world, hello Rust!".to_string()),
864            1 => Some("Another HELLO here".to_string()),
865            2 => Some("No match on this line".to_string()),
866            _ => None,
867        };
868
869        let matches = find_matches("hello", get_line, 0, 2);
870
871        // Should find "Hello" (case-insensitive) and "hello" on line 0, and "HELLO" on line 1
872        assert_eq!(matches.len(), 3);
873
874        // First match: "Hello" at start of line 0
875        assert_eq!(matches[0].start, CopyModeCursor::new(0, 0));
876        assert_eq!(matches[0].end, CopyModeCursor::new(4, 0));
877
878        // Second match: "hello" at position 13 on line 0
879        assert_eq!(matches[1].start, CopyModeCursor::new(13, 0));
880        assert_eq!(matches[1].end, CopyModeCursor::new(17, 0));
881
882        // Third match: "HELLO" on line 1
883        assert_eq!(matches[2].start, CopyModeCursor::new(8, 1));
884        assert_eq!(matches[2].end, CopyModeCursor::new(12, 1));
885    }
886
887    #[test]
888    fn test_find_matches_empty_query() {
889        let get_line = |_y: i32| Some("Some text".to_string());
890        let matches = find_matches("", get_line, 0, 5);
891        assert_eq!(matches.len(), 0);
892    }
893
894    #[test]
895    fn test_find_matches_no_matches() {
896        let get_line = |_y: i32| Some("No matches here".to_string());
897        let matches = find_matches("xyz", get_line, 0, 5);
898        assert_eq!(matches.len(), 0);
899    }
900
901    #[test]
902    fn test_selection_mode_default() {
903        let mode = SelectionMode::default();
904        assert_eq!(mode, SelectionMode::None);
905    }
906
907    #[test]
908    fn test_search_direction_default() {
909        let direction = SearchDirection::default();
910        assert_eq!(direction, SearchDirection::Forward);
911    }
912
913    #[test]
914    fn test_move_left() {
915        let mut state = CopyModeState::new();
916        state.cursor = CopyModeCursor::new(5, 0);
917
918        state.move_left();
919        assert_eq!(state.cursor.x, 4);
920
921        state.move_left();
922        state.move_left();
923        state.move_left();
924        state.move_left();
925        assert_eq!(state.cursor.x, 0);
926
927        // Should clamp at 0
928        state.move_left();
929        assert_eq!(state.cursor.x, 0);
930    }
931
932    #[test]
933    fn test_move_right() {
934        let mut state = CopyModeState::new();
935        state.cursor = CopyModeCursor::new(5, 0);
936
937        state.move_right(80);
938        assert_eq!(state.cursor.x, 6);
939
940        // Should clamp at max_cols - 1
941        state.cursor.x = 79;
942        state.move_right(80);
943        assert_eq!(state.cursor.x, 79);
944    }
945
946    #[test]
947    fn test_move_up() {
948        let mut state = CopyModeState::new();
949        state.cursor = CopyModeCursor::new(5, 10);
950
951        state.move_up(0);
952        assert_eq!(state.cursor.y, 9);
953
954        // Move to min_y
955        for _ in 0..10 {
956            state.move_up(0);
957        }
958        assert_eq!(state.cursor.y, 0);
959
960        // Should clamp at min_y
961        state.move_up(0);
962        assert_eq!(state.cursor.y, 0);
963    }
964
965    #[test]
966    fn test_move_down() {
967        let mut state = CopyModeState::new();
968        state.cursor = CopyModeCursor::new(5, 0);
969
970        state.move_down(24);
971        assert_eq!(state.cursor.y, 1);
972
973        // Move to max_y
974        state.cursor.y = 23;
975        state.move_down(24);
976        assert_eq!(state.cursor.y, 24);
977
978        // Should clamp at max_y
979        state.move_down(24);
980        assert_eq!(state.cursor.y, 24);
981    }
982
983    #[test]
984    fn test_move_up_with_scrollback() {
985        let mut state = CopyModeState::new();
986        state.cursor = CopyModeCursor::new(5, 0);
987
988        // Move into scrollback (negative y)
989        state.move_up(-100);
990        assert_eq!(state.cursor.y, -1);
991
992        state.cursor.y = -50;
993        state.move_up(-100);
994        assert_eq!(state.cursor.y, -51);
995
996        // Clamp at scrollback top
997        state.cursor.y = -100;
998        state.move_up(-100);
999        assert_eq!(state.cursor.y, -100);
1000    }
1001
1002    #[test]
1003    fn test_move_to_line_start() {
1004        let mut state = CopyModeState::new();
1005        state.cursor = CopyModeCursor::new(42, 5);
1006
1007        state.move_to_line_start();
1008        assert_eq!(state.cursor.x, 0);
1009        assert_eq!(state.cursor.y, 5); // y unchanged
1010    }
1011
1012    #[test]
1013    fn test_move_to_line_end() {
1014        let mut state = CopyModeState::new();
1015        state.cursor = CopyModeCursor::new(5, 3);
1016
1017        state.move_to_line_end(80);
1018        assert_eq!(state.cursor.x, 79);
1019        assert_eq!(state.cursor.y, 3); // y unchanged
1020    }
1021
1022    #[test]
1023    fn test_move_to_top() {
1024        let mut state = CopyModeState::new();
1025        state.cursor = CopyModeCursor::new(5, 10);
1026
1027        state.move_to_top(-100);
1028        assert_eq!(state.cursor.x, 5); // x unchanged
1029        assert_eq!(state.cursor.y, -100);
1030    }
1031
1032    #[test]
1033    fn test_move_to_bottom() {
1034        let mut state = CopyModeState::new();
1035        state.cursor = CopyModeCursor::new(5, -50);
1036
1037        state.move_to_bottom(24);
1038        assert_eq!(state.cursor.x, 5); // x unchanged
1039        assert_eq!(state.cursor.y, 24);
1040    }
1041
1042    #[test]
1043    fn test_toggle_cell_selection() {
1044        let mut state = CopyModeState::new();
1045        state.cursor = CopyModeCursor::new(5, 5);
1046
1047        state.toggle_cell_selection();
1048        assert_eq!(state.selection_mode, SelectionMode::Cell);
1049        assert!(state.selection.is_some());
1050
1051        state.toggle_cell_selection();
1052        assert_eq!(state.selection_mode, SelectionMode::None);
1053        assert!(state.selection.is_none());
1054    }
1055
1056    #[test]
1057    fn test_toggle_line_selection() {
1058        let mut state = CopyModeState::new();
1059        state.cursor = CopyModeCursor::new(5, 5);
1060
1061        state.toggle_line_selection();
1062        assert_eq!(state.selection_mode, SelectionMode::Line);
1063        assert!(state.selection.is_some());
1064
1065        state.toggle_line_selection();
1066        assert_eq!(state.selection_mode, SelectionMode::None);
1067        assert!(state.selection.is_none());
1068    }
1069
1070    #[test]
1071    fn test_toggle_block_selection() {
1072        let mut state = CopyModeState::new();
1073        state.cursor = CopyModeCursor::new(5, 5);
1074
1075        state.toggle_block_selection();
1076        assert_eq!(state.selection_mode, SelectionMode::Block);
1077        assert!(state.selection.is_some());
1078
1079        state.toggle_block_selection();
1080        assert_eq!(state.selection_mode, SelectionMode::None);
1081        assert!(state.selection.is_none());
1082    }
1083
1084    #[test]
1085    fn test_toggle_word_selection() {
1086        let mut state = CopyModeState::new();
1087        state.cursor = CopyModeCursor::new(5, 5);
1088
1089        state.toggle_word_selection();
1090        assert_eq!(state.selection_mode, SelectionMode::Word);
1091        assert!(state.selection.is_some());
1092
1093        state.toggle_word_selection();
1094        assert_eq!(state.selection_mode, SelectionMode::None);
1095        assert!(state.selection.is_none());
1096    }
1097
1098    #[test]
1099    fn test_get_selection_text_cell_single_line() {
1100        let mut state = CopyModeState::new();
1101        state.cursor = CopyModeCursor::new(5, 0);
1102        state.start_selection(SelectionMode::Cell);
1103        state.cursor = CopyModeCursor::new(9, 0);
1104        state.update_selection();
1105
1106        let get_line = |y: i32| {
1107            if y == 0 {
1108                Some("Hello, World!".to_string())
1109            } else {
1110                None
1111            }
1112        };
1113
1114        let text = state.get_selection_text(get_line);
1115        assert_eq!(text, Some(", Wor".to_string()));
1116    }
1117
1118    #[test]
1119    fn test_get_selection_text_cell_multi_line() {
1120        let mut state = CopyModeState::new();
1121        state.cursor = CopyModeCursor::new(5, 0);
1122        state.start_selection(SelectionMode::Cell);
1123        state.cursor = CopyModeCursor::new(5, 2);
1124        state.update_selection();
1125
1126        let get_line = |y: i32| match y {
1127            0 => Some("Line 0".to_string()),
1128            1 => Some("Line 1".to_string()),
1129            2 => Some("Line 2".to_string()),
1130            _ => None,
1131        };
1132
1133        let text = state.get_selection_text(get_line);
1134        assert_eq!(text, Some("0\nLine 1\nLine 2".to_string()));
1135    }
1136
1137    #[test]
1138    fn test_get_selection_text_line_mode() {
1139        let mut state = CopyModeState::new();
1140        state.cursor = CopyModeCursor::new(5, 0);
1141        state.start_selection(SelectionMode::Line);
1142        state.cursor = CopyModeCursor::new(10, 2);
1143        state.update_selection();
1144
1145        let get_line = |y: i32| match y {
1146            0 => Some("First line".to_string()),
1147            1 => Some("Second line".to_string()),
1148            2 => Some("Third line".to_string()),
1149            _ => None,
1150        };
1151
1152        let text = state.get_selection_text(get_line);
1153        assert_eq!(
1154            text,
1155            Some("First line\nSecond line\nThird line".to_string())
1156        );
1157    }
1158
1159    #[test]
1160    fn test_get_selection_text_block_mode() {
1161        let mut state = CopyModeState::new();
1162        state.cursor = CopyModeCursor::new(2, 0);
1163        state.start_selection(SelectionMode::Block);
1164        state.cursor = CopyModeCursor::new(5, 2);
1165        state.update_selection();
1166
1167        let get_line = |y: i32| match y {
1168            0 => Some("ABCDEFGH".to_string()),
1169            1 => Some("12345678".to_string()),
1170            2 => Some("abcdefgh".to_string()),
1171            _ => None,
1172        };
1173
1174        let text = state.get_selection_text(get_line);
1175        assert_eq!(text, Some("CDEF\n3456\ncdef".to_string()));
1176    }
1177
1178    #[test]
1179    fn test_get_selection_text_no_selection() {
1180        let state = CopyModeState::new();
1181
1182        let get_line = |_y: i32| Some("Line".to_string());
1183
1184        let text = state.get_selection_text(get_line);
1185        assert_eq!(text, None);
1186    }
1187
1188    #[test]
1189    fn test_movement_with_selection_update() {
1190        let mut state = CopyModeState::new();
1191        state.cursor = CopyModeCursor::new(5, 5);
1192        state.start_selection(SelectionMode::Cell);
1193
1194        // Move and update selection
1195        state.move_right(80);
1196        state.update_selection();
1197
1198        let selection = state.selection.as_ref().unwrap();
1199        assert_eq!(selection.anchor, CopyModeCursor::new(5, 5));
1200        assert_eq!(selection.active, CopyModeCursor::new(6, 5));
1201
1202        // Move down and update
1203        state.move_down(24);
1204        state.update_selection();
1205
1206        let selection = state.selection.as_ref().unwrap();
1207        assert_eq!(selection.anchor, CopyModeCursor::new(5, 5));
1208        assert_eq!(selection.active, CopyModeCursor::new(6, 6));
1209    }
1210
1211    #[test]
1212    fn test_find_word_bounds() {
1213        let line = "Hello world, this is a test";
1214
1215        // Test word "Hello"
1216        let (start, end) = find_word_bounds(2, line);
1217        assert_eq!(start, 0);
1218        assert_eq!(end, 4);
1219
1220        // Test word "world"
1221        let (start, end) = find_word_bounds(6, line);
1222        assert_eq!(start, 6);
1223        assert_eq!(end, 10);
1224
1225        // Test word "test"
1226        let (start, end) = find_word_bounds(24, line);
1227        assert_eq!(start, 23);
1228        assert_eq!(end, 26);
1229
1230        // Test on space (non-word character)
1231        let (start, end) = find_word_bounds(5, line);
1232        assert_eq!(start, 5);
1233        assert_eq!(end, 5);
1234    }
1235
1236    #[test]
1237    fn test_find_word_bounds_empty_line() {
1238        let line = "";
1239        let (start, end) = find_word_bounds(0, line);
1240        assert_eq!(start, 0);
1241        assert_eq!(end, 0);
1242    }
1243
1244    #[test]
1245    fn test_find_word_bounds_with_underscores() {
1246        let line = "hello_world test_case";
1247
1248        // Test word with underscore
1249        let (start, end) = find_word_bounds(6, line);
1250        assert_eq!(start, 0);
1251        assert_eq!(end, 10); // "hello_world"
1252    }
1253
1254    #[test]
1255    fn test_copy_mode_indicator() {
1256        let mut state = CopyModeState::new();
1257
1258        // Inactive state
1259        let items = copy_mode_indicator(&state, false);
1260        assert_eq!(items.len(), 0);
1261
1262        // Active copy mode
1263        state.active = true;
1264        let items = copy_mode_indicator(&state, false);
1265        assert!(items.len() > 0);
1266        match &items[3] {
1267            RenderItem::Text(s) => assert_eq!(s, " COPY "),
1268            _ => panic!("Expected text item"),
1269        }
1270
1271        // Visual mode
1272        state.selection = Some(Selection::at_point(CopyModeCursor::new(0, 0)));
1273        state.selection_mode = SelectionMode::Cell;
1274        let items = copy_mode_indicator(&state, false);
1275        match &items[3] {
1276            RenderItem::Text(s) => assert_eq!(s, " VISUAL "),
1277            _ => panic!("Expected text item"),
1278        }
1279
1280        // Line mode
1281        state.selection_mode = SelectionMode::Line;
1282        let items = copy_mode_indicator(&state, false);
1283        match &items[3] {
1284            RenderItem::Text(s) => assert_eq!(s, " V-LINE "),
1285            _ => panic!("Expected text item"),
1286        }
1287
1288        // Search mode
1289        let items = copy_mode_indicator(&state, true);
1290        match &items[3] {
1291            RenderItem::Text(s) => assert_eq!(s, " SEARCH "),
1292            _ => panic!("Expected text item"),
1293        }
1294    }
1295
1296    #[test]
1297    fn test_copy_mode_position_indicator() {
1298        let mut state = CopyModeState::new();
1299
1300        // Inactive state
1301        let items = copy_mode_position_indicator(&state);
1302        assert_eq!(items.len(), 0);
1303
1304        // Active with cursor position
1305        state.active = true;
1306        state.cursor = CopyModeCursor::new(5, 10);
1307        let items = copy_mode_position_indicator(&state);
1308        assert_eq!(items.len(), 1);
1309        match &items[0] {
1310            RenderItem::Text(s) => assert_eq!(s, " L11,C6 "),
1311            _ => panic!("Expected text item"),
1312        }
1313    }
1314
1315    #[test]
1316    fn test_search_match_indicator() {
1317        let mut search = SearchState::new();
1318
1319        // Inactive search
1320        let items = search_match_indicator(&search);
1321        assert_eq!(items.len(), 0);
1322
1323        // Active search with no matches
1324        search.active = true;
1325        let items = search_match_indicator(&search);
1326        assert_eq!(items.len(), 0);
1327
1328        // Active search with matches
1329        search.matches = vec![
1330            SearchMatch::new(CopyModeCursor::new(0, 0), CopyModeCursor::new(5, 0)),
1331            SearchMatch::new(CopyModeCursor::new(0, 1), CopyModeCursor::new(5, 1)),
1332            SearchMatch::new(CopyModeCursor::new(0, 2), CopyModeCursor::new(5, 2)),
1333        ];
1334        search.current_match = Some(1);
1335
1336        let items = search_match_indicator(&search);
1337        assert_eq!(items.len(), 1);
1338        match &items[0] {
1339            RenderItem::Text(s) => assert_eq!(s, " 2/3 "),
1340            _ => panic!("Expected text item"),
1341        }
1342    }
1343}