ratatui_toolkit/primitives/termtui/
copy_mode.rs

1//! Copy mode with frozen screen snapshot (mprocs pattern)
2//!
3//! When entering copy mode, we take a snapshot of the current screen state.
4//! This allows stable text selection even while the terminal continues
5//! receiving output in the background.
6
7use crate::primitives::termtui::screen::Screen;
8
9/// Cursor position in copy mode
10#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11pub struct CopyPos {
12    pub x: i32,
13    pub y: i32,
14}
15
16impl CopyPos {
17    pub fn new(x: i32, y: i32) -> Self {
18        Self { x, y }
19    }
20
21    /// Get the low and high positions for selection
22    pub fn to_low_high(start: &CopyPos, end: &CopyPos) -> (CopyPos, CopyPos) {
23        if start.y < end.y || (start.y == end.y && start.x <= end.x) {
24            (*start, *end)
25        } else {
26            (*end, *start)
27        }
28    }
29}
30
31/// Movement direction for copy mode navigation
32#[derive(Clone, Copy, Debug)]
33pub enum CopyMoveDir {
34    Up,
35    Down,
36    Left,
37    Right,
38    LineStart,
39    LineEnd,
40    PageUp,
41    PageDown,
42    Top,
43    Bottom,
44    WordLeft,
45    WordRight,
46}
47
48impl CopyMoveDir {
49    /// Get delta (dx, dy) for movement
50    pub fn delta(&self) -> (i32, i32) {
51        match self {
52            CopyMoveDir::Up => (0, -1),
53            CopyMoveDir::Down => (0, 1),
54            CopyMoveDir::Left => (-1, 0),
55            CopyMoveDir::Right => (1, 0),
56            _ => (0, 0), // Special movements handled separately
57        }
58    }
59}
60
61/// Copy mode state
62#[derive(Clone, Default)]
63pub enum CopyMode {
64    /// Not in copy mode
65    #[default]
66    None,
67    /// Active copy mode with frozen screen
68    Active {
69        /// Frozen screen snapshot
70        frozen_screen: Box<Screen>,
71        /// Current cursor position
72        cursor: CopyPos,
73        /// Selection anchor (start of selection)
74        anchor: Option<CopyPos>,
75        /// Screen height for bounds
76        screen_height: i32,
77        /// Screen width for bounds
78        screen_width: i32,
79        /// Scrollback available
80        scrollback_available: i32,
81    },
82}
83
84impl CopyMode {
85    /// Check if copy mode is active
86    pub fn is_active(&self) -> bool {
87        matches!(self, CopyMode::Active { .. })
88    }
89
90    /// Enter copy mode with a screen snapshot
91    pub fn enter(screen: Screen, start: CopyPos) -> Self {
92        let size = screen.size();
93        let scrollback = screen.primary_grid().scrollback_available() as i32;
94
95        CopyMode::Active {
96            frozen_screen: Box::new(screen),
97            cursor: start,
98            anchor: None,
99            screen_height: size.rows as i32,
100            screen_width: size.cols as i32,
101            scrollback_available: scrollback,
102        }
103    }
104
105    /// Move cursor in copy mode
106    pub fn move_cursor(&mut self, dx: i32, dy: i32) {
107        if let CopyMode::Active {
108            cursor,
109            screen_width,
110            screen_height,
111            scrollback_available,
112            ..
113        } = self
114        {
115            let new_x = (cursor.x + dx).clamp(0, *screen_width - 1);
116            let new_y = (cursor.y + dy).clamp(-*scrollback_available, *screen_height - 1);
117
118            cursor.x = new_x;
119            cursor.y = new_y;
120        }
121    }
122
123    /// Move cursor by direction
124    pub fn move_dir(&mut self, dir: CopyMoveDir) {
125        if let CopyMode::Active {
126            cursor,
127            screen_width,
128            screen_height,
129            scrollback_available,
130            ..
131        } = self
132        {
133            match dir {
134                CopyMoveDir::Up | CopyMoveDir::Down | CopyMoveDir::Left | CopyMoveDir::Right => {
135                    let (dx, dy) = dir.delta();
136                    self.move_cursor(dx, dy);
137                }
138                CopyMoveDir::LineStart => {
139                    cursor.x = 0;
140                }
141                CopyMoveDir::LineEnd => {
142                    cursor.x = *screen_width - 1;
143                }
144                CopyMoveDir::PageUp => {
145                    let page = *screen_height / 2;
146                    cursor.y = (cursor.y - page).max(-*scrollback_available);
147                }
148                CopyMoveDir::PageDown => {
149                    let page = *screen_height / 2;
150                    cursor.y = (cursor.y + page).min(*screen_height - 1);
151                }
152                CopyMoveDir::Top => {
153                    cursor.y = -*scrollback_available;
154                    cursor.x = 0;
155                }
156                CopyMoveDir::Bottom => {
157                    cursor.y = *screen_height - 1;
158                    cursor.x = *screen_width - 1;
159                }
160                CopyMoveDir::WordLeft | CopyMoveDir::WordRight => {
161                    // Simplified: just move by 5 characters
162                    let delta = if matches!(dir, CopyMoveDir::WordLeft) {
163                        -5
164                    } else {
165                        5
166                    };
167                    cursor.x = (cursor.x + delta).clamp(0, *screen_width - 1);
168                }
169            }
170        }
171    }
172
173    /// Set selection anchor at current cursor position
174    pub fn set_anchor(&mut self) {
175        if let CopyMode::Active { cursor, anchor, .. } = self {
176            if anchor.is_some() {
177                // Toggle anchor off
178                *anchor = None;
179            } else {
180                // Set anchor at current position
181                *anchor = Some(*cursor);
182            }
183        }
184    }
185
186    /// Set end position (for mouse selection)
187    pub fn set_end(&mut self) {
188        if let CopyMode::Active { cursor, anchor, .. } = self {
189            if anchor.is_none() {
190                // If no anchor, set it first
191                *anchor = Some(*cursor);
192            }
193        }
194    }
195
196    /// Get the current selection range
197    pub fn get_selection(&self) -> Option<(CopyPos, CopyPos)> {
198        if let CopyMode::Active { cursor, anchor, .. } = self {
199            anchor.map(|a| (a, *cursor))
200        } else {
201            None
202        }
203    }
204
205    /// Get selected text from frozen screen
206    pub fn get_selected_text(&self) -> Option<String> {
207        if let CopyMode::Active {
208            frozen_screen,
209            cursor,
210            anchor,
211            ..
212        } = self
213        {
214            let anchor = (*anchor)?;
215            let (low, high) = CopyPos::to_low_high(&anchor, cursor);
216
217            Some(frozen_screen.get_selected_text(low.x, low.y, high.x, high.y))
218        } else {
219            None
220        }
221    }
222
223    /// Get the frozen screen (for rendering in copy mode)
224    pub fn frozen_screen(&self) -> Option<&Screen> {
225        if let CopyMode::Active { frozen_screen, .. } = self {
226            Some(frozen_screen)
227        } else {
228            None
229        }
230    }
231
232    /// Get cursor position in copy mode
233    pub fn cursor(&self) -> Option<CopyPos> {
234        if let CopyMode::Active { cursor, .. } = self {
235            Some(*cursor)
236        } else {
237            None
238        }
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    fn make_test_screen() -> Screen {
247        Screen::new(24, 80, 1000)
248    }
249
250    #[test]
251    fn test_copy_mode_enter() {
252        let screen = make_test_screen();
253        let mode = CopyMode::enter(screen, CopyPos::new(0, 0));
254
255        assert!(mode.is_active());
256    }
257
258    #[test]
259    fn test_copy_mode_move_cursor() {
260        let screen = make_test_screen();
261        let mut mode = CopyMode::enter(screen, CopyPos::new(10, 10));
262
263        mode.move_cursor(5, 2);
264
265        if let CopyMode::Active { cursor, .. } = mode {
266            assert_eq!(cursor.x, 15);
267            assert_eq!(cursor.y, 12);
268        } else {
269            panic!("Expected active mode");
270        }
271    }
272
273    #[test]
274    fn test_copy_mode_bounds() {
275        let screen = make_test_screen();
276        let mut mode = CopyMode::enter(screen, CopyPos::new(0, 0));
277
278        // Try to move out of bounds
279        mode.move_cursor(-10, -10);
280
281        if let CopyMode::Active { cursor, .. } = mode {
282            assert_eq!(cursor.x, 0);
283            assert!(cursor.y <= 0);
284        } else {
285            panic!("Expected active mode");
286        }
287    }
288
289    #[test]
290    fn test_copy_mode_selection() {
291        let screen = make_test_screen();
292        let mut mode = CopyMode::enter(screen, CopyPos::new(5, 5));
293
294        // No selection initially
295        assert!(mode.get_selection().is_none());
296
297        // Set anchor
298        mode.set_anchor();
299
300        // Move cursor
301        mode.move_cursor(10, 0);
302
303        // Now we have selection
304        let selection = mode.get_selection();
305        assert!(selection.is_some());
306
307        let (start, end) = selection.unwrap();
308        assert_eq!(start.x, 5);
309        assert_eq!(end.x, 15);
310    }
311
312    #[test]
313    fn test_copy_pos_to_low_high() {
314        let a = CopyPos::new(10, 5);
315        let b = CopyPos::new(5, 10);
316
317        let (low, high) = CopyPos::to_low_high(&a, &b);
318        assert_eq!(low.y, 5);
319        assert_eq!(high.y, 10);
320    }
321}