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