sql_cli/
cursor_manager.rs

1/// Manages all cursor and navigation operations
2/// This extracts cursor logic from the monolithic `enhanced_tui.rs`
3pub struct CursorManager {
4    /// Current cursor position in the input (byte offset)
5    input_cursor_position: usize,
6
7    /// Visual cursor position for rendering (col, row)
8    _visual_cursor: (usize, usize),
9
10    /// Table navigation position (row, col)
11    table_cursor: (usize, usize),
12
13    /// Horizontal scroll offset for wide tables
14    horizontal_scroll: u16,
15
16    /// Vertical scroll offset for long results
17    vertical_scroll: usize,
18}
19
20impl Default for CursorManager {
21    fn default() -> Self {
22        Self::new()
23    }
24}
25
26impl CursorManager {
27    #[must_use]
28    pub fn new() -> Self {
29        Self {
30            input_cursor_position: 0,
31            _visual_cursor: (0, 0),
32            table_cursor: (0, 0),
33            horizontal_scroll: 0,
34            vertical_scroll: 0,
35        }
36    }
37
38    // ========== Input Cursor Methods ==========
39
40    /// Move cursor forward by one word
41    pub fn move_word_forward(&mut self, text: &str) -> usize {
42        let chars: Vec<char> = text.chars().collect();
43        let mut pos = self.input_cursor_position;
44
45        // Skip current word
46        while pos < chars.len() && !chars[pos].is_whitespace() {
47            pos += 1;
48        }
49
50        // Skip whitespace
51        while pos < chars.len() && chars[pos].is_whitespace() {
52            pos += 1;
53        }
54
55        self.input_cursor_position = pos;
56        pos
57    }
58
59    /// Move cursor backward by one word
60    pub fn move_word_backward(&mut self, text: &str) -> usize {
61        let chars: Vec<char> = text.chars().collect();
62        let mut pos = self.input_cursor_position;
63
64        if pos == 0 {
65            return 0;
66        }
67
68        pos -= 1;
69
70        // Skip whitespace
71        while pos > 0 && chars[pos].is_whitespace() {
72            pos -= 1;
73        }
74
75        // Skip to beginning of word
76        while pos > 0 && !chars[pos - 1].is_whitespace() {
77            pos -= 1;
78        }
79
80        self.input_cursor_position = pos;
81        pos
82    }
83
84    /// Move cursor to beginning of line
85    pub fn move_to_line_start(&mut self) -> usize {
86        self.input_cursor_position = 0;
87        0
88    }
89
90    /// Move cursor to end of line
91    pub fn move_to_line_end(&mut self, text: &str) -> usize {
92        self.input_cursor_position = text.len();
93        text.len()
94    }
95
96    /// Move cursor left by one character
97    pub fn move_left(&mut self) -> usize {
98        if self.input_cursor_position > 0 {
99            self.input_cursor_position -= 1;
100        }
101        self.input_cursor_position
102    }
103
104    /// Move cursor right by one character
105    pub fn move_right(&mut self, text: &str) -> usize {
106        if self.input_cursor_position < text.len() {
107            self.input_cursor_position += 1;
108        }
109        self.input_cursor_position
110    }
111
112    /// Set cursor position directly
113    pub fn set_position(&mut self, pos: usize) {
114        self.input_cursor_position = pos;
115    }
116
117    /// Get current cursor position
118    #[must_use]
119    pub fn position(&self) -> usize {
120        self.input_cursor_position
121    }
122
123    // ========== Table Navigation Methods ==========
124
125    /// Move selection up in table
126    pub fn move_table_up(&mut self) -> (usize, usize) {
127        if self.table_cursor.0 > 0 {
128            self.table_cursor.0 -= 1;
129        }
130        self.table_cursor
131    }
132
133    /// Move selection down in table
134    pub fn move_table_down(&mut self, max_rows: usize) -> (usize, usize) {
135        if self.table_cursor.0 < max_rows.saturating_sub(1) {
136            self.table_cursor.0 += 1;
137        }
138        self.table_cursor
139    }
140
141    /// Move selection left in table
142    pub fn move_table_left(&mut self) -> (usize, usize) {
143        if self.table_cursor.1 > 0 {
144            self.table_cursor.1 -= 1;
145        }
146        self.table_cursor
147    }
148
149    /// Move selection right in table
150    pub fn move_table_right(&mut self, max_cols: usize) -> (usize, usize) {
151        if self.table_cursor.1 < max_cols.saturating_sub(1) {
152            self.table_cursor.1 += 1;
153        }
154        self.table_cursor
155    }
156
157    /// Move to first row
158    pub fn move_table_home(&mut self) -> (usize, usize) {
159        self.table_cursor.0 = 0;
160        self.table_cursor
161    }
162
163    /// Move to last row
164    pub fn move_table_end(&mut self, max_rows: usize) -> (usize, usize) {
165        self.table_cursor.0 = max_rows.saturating_sub(1);
166        self.table_cursor
167    }
168
169    /// Page up in table
170    pub fn page_up(&mut self, page_size: usize) -> (usize, usize) {
171        self.table_cursor.0 = self.table_cursor.0.saturating_sub(page_size);
172        self.table_cursor
173    }
174
175    /// Page down in table
176    pub fn page_down(&mut self, page_size: usize, max_rows: usize) -> (usize, usize) {
177        self.table_cursor.0 = (self.table_cursor.0 + page_size).min(max_rows.saturating_sub(1));
178        self.table_cursor
179    }
180
181    /// Get current table cursor position
182    #[must_use]
183    pub fn table_position(&self) -> (usize, usize) {
184        self.table_cursor
185    }
186
187    /// Reset table cursor to origin
188    pub fn reset_table_cursor(&mut self) {
189        self.table_cursor = (0, 0);
190    }
191
192    // ========== Scroll Management Methods ==========
193
194    /// Update horizontal scroll based on cursor position and viewport
195    pub fn update_horizontal_scroll(&mut self, cursor_col: usize, viewport_width: u16) {
196        let cursor_x = cursor_col as u16;
197
198        // Scroll right if cursor is beyond viewport
199        if cursor_x >= self.horizontal_scroll + viewport_width {
200            self.horizontal_scroll = cursor_x.saturating_sub(viewport_width - 1);
201        }
202
203        // Scroll left if cursor is before viewport
204        if cursor_x < self.horizontal_scroll {
205            self.horizontal_scroll = cursor_x;
206        }
207    }
208
209    /// Update vertical scroll based on cursor position and viewport
210    pub fn update_vertical_scroll(&mut self, cursor_row: usize, viewport_height: usize) {
211        // Scroll down if cursor is below viewport
212        if cursor_row >= self.vertical_scroll + viewport_height {
213            self.vertical_scroll = cursor_row.saturating_sub(viewport_height - 1);
214        }
215
216        // Scroll up if cursor is above viewport
217        if cursor_row < self.vertical_scroll {
218            self.vertical_scroll = cursor_row;
219        }
220    }
221
222    /// Get current scroll offsets
223    #[must_use]
224    pub fn scroll_offsets(&self) -> (u16, usize) {
225        (self.horizontal_scroll, self.vertical_scroll)
226    }
227
228    /// Set scroll offsets directly
229    pub fn set_scroll_offsets(&mut self, horizontal: u16, vertical: usize) {
230        self.horizontal_scroll = horizontal;
231        self.vertical_scroll = vertical;
232    }
233
234    /// Reset horizontal scroll to zero (useful when switching queries)
235    pub fn reset_horizontal_scroll(&mut self) {
236        self.horizontal_scroll = 0;
237    }
238
239    // ========== Token/Word Utilities ==========
240
241    /// Find word boundaries at current position
242    #[must_use]
243    pub fn get_word_at_cursor(&self, text: &str) -> Option<(usize, usize, String)> {
244        if text.is_empty() || self.input_cursor_position > text.len() {
245            return None;
246        }
247
248        let chars: Vec<char> = text.chars().collect();
249        let mut start = self.input_cursor_position;
250        let mut end = self.input_cursor_position;
251
252        // If at whitespace, no word
253        if start < chars.len() && chars[start].is_whitespace() {
254            return None;
255        }
256
257        // Find word start
258        while start > 0 && !chars[start - 1].is_whitespace() {
259            start -= 1;
260        }
261
262        // Find word end
263        while end < chars.len() && !chars[end].is_whitespace() {
264            end += 1;
265        }
266
267        let word: String = chars[start..end].iter().collect();
268        Some((start, end, word))
269    }
270
271    /// Get partial word before cursor (for completion)
272    #[must_use]
273    pub fn get_partial_word_before_cursor(&self, text: &str) -> Option<String> {
274        if self.input_cursor_position == 0 {
275            return None;
276        }
277
278        let before_cursor = &text[..self.input_cursor_position];
279        let last_space = before_cursor.rfind(' ').map_or(0, |i| i + 1);
280
281        if last_space < self.input_cursor_position {
282            Some(before_cursor[last_space..].to_string())
283        } else {
284            None
285        }
286    }
287}
288
289/// Extension trait to integrate `CursorManager` with Buffer
290pub trait CursorBuffer {
291    fn cursor_manager(&self) -> &CursorManager;
292    fn cursor_manager_mut(&mut self) -> &mut CursorManager;
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    #[test]
300    fn test_word_navigation() {
301        let mut cm = CursorManager::new();
302        let text = "SELECT * FROM table WHERE id = 1";
303
304        // Start at beginning
305        assert_eq!(cm.position(), 0);
306
307        // Move forward by word
308        cm.move_word_forward(text);
309        assert_eq!(cm.position(), 7); // After "SELECT "
310
311        cm.move_word_forward(text);
312        assert_eq!(cm.position(), 9); // After "* "
313
314        // Move backward by word
315        cm.move_word_backward(text);
316        assert_eq!(cm.position(), 7); // Back to "*"
317
318        cm.move_word_backward(text);
319        assert_eq!(cm.position(), 0); // Back to "SELECT"
320    }
321
322    #[test]
323    fn test_table_navigation() {
324        let mut cm = CursorManager::new();
325
326        // Test movement within bounds
327        cm.move_table_down(10);
328        assert_eq!(cm.table_position(), (1, 0));
329
330        cm.move_table_right(5);
331        assert_eq!(cm.table_position(), (1, 1));
332
333        // Test boundary conditions
334        cm.move_table_end(10);
335        assert_eq!(cm.table_position(), (9, 1));
336
337        cm.move_table_home();
338        assert_eq!(cm.table_position(), (0, 1));
339    }
340
341    #[test]
342    fn test_scroll_management() {
343        let mut cm = CursorManager::new();
344
345        // Test horizontal scroll
346        cm.update_horizontal_scroll(100, 80);
347        assert_eq!(cm.scroll_offsets().0, 21); // 100 - 80 + 1
348
349        // Test vertical scroll
350        cm.update_vertical_scroll(50, 20);
351        assert_eq!(cm.scroll_offsets().1, 31); // 50 - 20 + 1
352    }
353
354    #[test]
355    fn test_word_extraction() {
356        let mut cm = CursorManager::new();
357        let text = "SELECT column FROM table";
358
359        // Position at "column"
360        cm.set_position(7);
361        let word = cm.get_word_at_cursor(text);
362        assert_eq!(word, Some((7, 13, "column".to_string())));
363
364        // Position at space
365        cm.set_position(6);
366        let word = cm.get_word_at_cursor(text);
367        assert_eq!(word, None);
368
369        // Partial word for completion
370        cm.set_position(10); // Middle of "column"
371        let partial = cm.get_partial_word_before_cursor(text);
372        assert_eq!(partial, Some("col".to_string()));
373    }
374}