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