Skip to main content

saorsa_core/
cursor.rs

1//! Cursor position and selection types for text editing.
2//!
3//! Provides [`CursorPosition`], [`Selection`], and [`CursorState`] types
4//! used by the [`crate::widget::TextArea`] widget for tracking the editing
5//! cursor, text selection, and movement operations.
6
7use crate::text_buffer::TextBuffer;
8
9/// A position within a text buffer, expressed as a line and column.
10///
11/// Both `line` and `col` are zero-based.
12#[derive(Clone, Copy, Debug, PartialEq, Eq)]
13pub struct CursorPosition {
14    /// Zero-based line index.
15    pub line: usize,
16    /// Zero-based column index (character offset within the line).
17    pub col: usize,
18}
19
20impl CursorPosition {
21    /// Create a new cursor position.
22    pub fn new(line: usize, col: usize) -> Self {
23        Self { line, col }
24    }
25
26    /// Create a cursor position at the beginning of the buffer (0, 0).
27    pub fn beginning() -> Self {
28        Self { line: 0, col: 0 }
29    }
30}
31
32impl PartialOrd for CursorPosition {
33    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
34        Some(self.cmp(other))
35    }
36}
37
38impl Ord for CursorPosition {
39    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
40        self.line.cmp(&other.line).then(self.col.cmp(&other.col))
41    }
42}
43
44/// A text selection defined by an anchor and a head position.
45///
46/// The anchor is where the selection started and the head is where
47/// the cursor currently is. The anchor may come before or after the
48/// head — use [`Selection::ordered`] to get (start, end).
49#[derive(Clone, Copy, Debug, PartialEq, Eq)]
50pub struct Selection {
51    /// The position where the selection started.
52    pub anchor: CursorPosition,
53    /// The current cursor position (moving end of the selection).
54    pub head: CursorPosition,
55}
56
57impl Selection {
58    /// Create a new selection.
59    pub fn new(anchor: CursorPosition, head: CursorPosition) -> Self {
60        Self { anchor, head }
61    }
62
63    /// Returns `true` if the selection is empty (anchor equals head).
64    pub fn is_empty(&self) -> bool {
65        self.anchor == self.head
66    }
67
68    /// Return the selection as `(start, end)` in document order.
69    pub fn ordered(&self) -> (CursorPosition, CursorPosition) {
70        if self.anchor <= self.head {
71            (self.anchor, self.head)
72        } else {
73            (self.head, self.anchor)
74        }
75    }
76
77    /// Check if a position is contained within the selection.
78    pub fn contains(&self, pos: CursorPosition) -> bool {
79        let (start, end) = self.ordered();
80        pos >= start && pos < end
81    }
82
83    /// Return the range of lines spanned by the selection.
84    pub fn line_range(&self) -> (usize, usize) {
85        let (start, end) = self.ordered();
86        (start.line, end.line)
87    }
88}
89
90/// The full cursor state for a text editing session.
91///
92/// Tracks the cursor position, optional selection, and the preferred
93/// column for vertical movement (so moving up/down through short lines
94/// returns to the original column).
95#[derive(Clone, Debug)]
96pub struct CursorState {
97    /// Current cursor position.
98    pub position: CursorPosition,
99    /// Active selection, if any.
100    pub selection: Option<Selection>,
101    /// Preferred column for vertical movement.
102    pub preferred_col: Option<usize>,
103}
104
105impl CursorState {
106    /// Create a new cursor state at the given position.
107    pub fn new(line: usize, col: usize) -> Self {
108        Self {
109            position: CursorPosition::new(line, col),
110            selection: None,
111            preferred_col: None,
112        }
113    }
114
115    /// Move cursor left by one character, wrapping to the previous line.
116    pub fn move_left(&mut self, buffer: &TextBuffer) {
117        self.clear_selection();
118        if self.position.col > 0 {
119            self.position.col -= 1;
120        } else if self.position.line > 0 {
121            self.position.line -= 1;
122            self.position.col = buffer.line_len(self.position.line).unwrap_or(0);
123        }
124        self.preferred_col = None;
125    }
126
127    /// Move cursor right by one character, wrapping to the next line.
128    pub fn move_right(&mut self, buffer: &TextBuffer) {
129        self.clear_selection();
130        let line_len = buffer.line_len(self.position.line).unwrap_or(0);
131        if self.position.col < line_len {
132            self.position.col += 1;
133        } else if self.position.line + 1 < buffer.line_count() {
134            self.position.line += 1;
135            self.position.col = 0;
136        }
137        self.preferred_col = None;
138    }
139
140    /// Move cursor up by one line, preserving the preferred column.
141    pub fn move_up(&mut self, buffer: &TextBuffer) {
142        self.clear_selection();
143        if self.position.line > 0 {
144            let target_col = self.preferred_col.unwrap_or(self.position.col);
145            self.preferred_col = Some(target_col);
146            self.position.line -= 1;
147            let line_len = buffer.line_len(self.position.line).unwrap_or(0);
148            self.position.col = target_col.min(line_len);
149        }
150    }
151
152    /// Move cursor down by one line, preserving the preferred column.
153    pub fn move_down(&mut self, buffer: &TextBuffer) {
154        self.clear_selection();
155        if self.position.line + 1 < buffer.line_count() {
156            let target_col = self.preferred_col.unwrap_or(self.position.col);
157            self.preferred_col = Some(target_col);
158            self.position.line += 1;
159            let line_len = buffer.line_len(self.position.line).unwrap_or(0);
160            self.position.col = target_col.min(line_len);
161        }
162    }
163
164    /// Move cursor to the start of the current line.
165    pub fn move_to_line_start(&mut self) {
166        self.clear_selection();
167        self.position.col = 0;
168        self.preferred_col = None;
169    }
170
171    /// Move cursor to the end of the current line.
172    pub fn move_to_line_end(&mut self, buffer: &TextBuffer) {
173        self.clear_selection();
174        self.position.col = buffer.line_len(self.position.line).unwrap_or(0);
175        self.preferred_col = None;
176    }
177
178    /// Move cursor to the beginning of the buffer.
179    pub fn move_to_buffer_start(&mut self) {
180        self.clear_selection();
181        self.position = CursorPosition::beginning();
182        self.preferred_col = None;
183    }
184
185    /// Move cursor to the end of the buffer.
186    pub fn move_to_buffer_end(&mut self, buffer: &TextBuffer) {
187        self.clear_selection();
188        let last_line = buffer.line_count().saturating_sub(1);
189        self.position.line = last_line;
190        self.position.col = buffer.line_len(last_line).unwrap_or(0);
191        self.preferred_col = None;
192    }
193
194    /// Start a selection at the current cursor position.
195    pub fn start_selection(&mut self) {
196        self.selection = Some(Selection::new(self.position, self.position));
197    }
198
199    /// Extend the selection to the current cursor position.
200    ///
201    /// If no selection exists, this starts one from the current position.
202    pub fn extend_selection(&mut self) {
203        match &mut self.selection {
204            Some(sel) => sel.head = self.position,
205            None => self.start_selection(),
206        }
207    }
208
209    /// Clear the current selection.
210    pub fn clear_selection(&mut self) {
211        self.selection = None;
212    }
213
214    /// Get the text currently selected in the buffer.
215    ///
216    /// Returns `None` if there is no selection or the selection is empty.
217    pub fn selected_text(&self, buffer: &TextBuffer) -> Option<String> {
218        let sel = self.selection.as_ref()?;
219        if sel.is_empty() {
220            return None;
221        }
222        let (start, end) = sel.ordered();
223
224        let mut result = String::new();
225        for line_idx in start.line..=end.line {
226            if let Some(line_text) = buffer.line(line_idx) {
227                let line_start = if line_idx == start.line { start.col } else { 0 };
228                let line_end = if line_idx == end.line {
229                    end.col.min(line_text.chars().count())
230                } else {
231                    line_text.chars().count()
232                };
233
234                let chars: String = line_text
235                    .chars()
236                    .skip(line_start)
237                    .take(line_end.saturating_sub(line_start))
238                    .collect();
239                result.push_str(&chars);
240
241                // Add newline between lines (but not after the last)
242                if line_idx < end.line {
243                    result.push('\n');
244                }
245            }
246        }
247
248        if result.is_empty() {
249            None
250        } else {
251            Some(result)
252        }
253    }
254}
255
256#[cfg(test)]
257#[allow(clippy::unwrap_used)]
258mod tests {
259    use super::*;
260
261    fn buf(text: &str) -> TextBuffer {
262        TextBuffer::from_text(text)
263    }
264
265    // --- CursorPosition ---
266
267    #[test]
268    fn cursor_position_new() {
269        let p = CursorPosition::new(3, 5);
270        assert!(p.line == 3);
271        assert!(p.col == 5);
272    }
273
274    #[test]
275    fn cursor_position_beginning() {
276        let p = CursorPosition::beginning();
277        assert!(p.line == 0);
278        assert!(p.col == 0);
279    }
280
281    #[test]
282    fn cursor_position_ordering() {
283        let a = CursorPosition::new(0, 5);
284        let b = CursorPosition::new(1, 0);
285        let c = CursorPosition::new(0, 10);
286        assert!(a < b);
287        assert!(a < c);
288        assert!(b > c);
289    }
290
291    // --- Selection ---
292
293    #[test]
294    fn selection_empty() {
295        let p = CursorPosition::new(0, 0);
296        let sel = Selection::new(p, p);
297        assert!(sel.is_empty());
298    }
299
300    #[test]
301    fn selection_ordered_forward() {
302        let a = CursorPosition::new(0, 0);
303        let b = CursorPosition::new(1, 5);
304        let sel = Selection::new(a, b);
305        let (start, end) = sel.ordered();
306        assert!(start == a);
307        assert!(end == b);
308    }
309
310    #[test]
311    fn selection_ordered_backward() {
312        let a = CursorPosition::new(1, 5);
313        let b = CursorPosition::new(0, 0);
314        let sel = Selection::new(a, b);
315        let (start, end) = sel.ordered();
316        assert!(start == b);
317        assert!(end == a);
318    }
319
320    #[test]
321    fn selection_contains() {
322        let sel = Selection::new(CursorPosition::new(0, 2), CursorPosition::new(0, 8));
323        assert!(sel.contains(CursorPosition::new(0, 5)));
324        assert!(!sel.contains(CursorPosition::new(0, 8))); // end is exclusive
325        assert!(!sel.contains(CursorPosition::new(0, 1)));
326    }
327
328    #[test]
329    fn selection_line_range() {
330        let sel = Selection::new(CursorPosition::new(2, 0), CursorPosition::new(5, 3));
331        assert!(sel.line_range() == (2, 5));
332    }
333
334    // --- CursorState movement ---
335
336    #[test]
337    fn move_left_within_line() {
338        let b = buf("hello");
339        let mut c = CursorState::new(0, 3);
340        c.move_left(&b);
341        assert!(c.position.col == 2);
342    }
343
344    #[test]
345    fn move_left_wraps_to_prev_line() {
346        let b = buf("hello\nworld");
347        let mut c = CursorState::new(1, 0);
348        c.move_left(&b);
349        assert!(c.position.line == 0);
350        assert!(c.position.col == 5);
351    }
352
353    #[test]
354    fn move_left_at_beginning_stays() {
355        let b = buf("hello");
356        let mut c = CursorState::new(0, 0);
357        c.move_left(&b);
358        assert!(c.position == CursorPosition::beginning());
359    }
360
361    #[test]
362    fn move_right_within_line() {
363        let b = buf("hello");
364        let mut c = CursorState::new(0, 2);
365        c.move_right(&b);
366        assert!(c.position.col == 3);
367    }
368
369    #[test]
370    fn move_right_wraps_to_next_line() {
371        let b = buf("hello\nworld");
372        let mut c = CursorState::new(0, 5);
373        c.move_right(&b);
374        assert!(c.position.line == 1);
375        assert!(c.position.col == 0);
376    }
377
378    #[test]
379    fn move_right_at_end_stays() {
380        let b = buf("hello");
381        let mut c = CursorState::new(0, 5);
382        c.move_right(&b);
383        assert!(c.position.line == 0);
384        assert!(c.position.col == 5);
385    }
386
387    #[test]
388    fn move_up_preserves_preferred_col() {
389        let b = buf("long line here\nhi\nanother long line");
390        let mut c = CursorState::new(0, 10);
391        c.move_down(&b); // line 1 "hi" → col clamped to 2
392        assert!(c.position.col == 2);
393        c.move_down(&b); // line 2 "another long line" → col restored to 10
394        assert!(c.position.col == 10);
395    }
396
397    #[test]
398    fn move_up_at_top_stays() {
399        let b = buf("hello");
400        let mut c = CursorState::new(0, 3);
401        c.move_up(&b);
402        assert!(c.position.line == 0);
403    }
404
405    #[test]
406    fn move_down_at_bottom_stays() {
407        let b = buf("hello");
408        let mut c = CursorState::new(0, 3);
409        c.move_down(&b);
410        assert!(c.position.line == 0);
411    }
412
413    #[test]
414    fn move_to_line_start_and_end() {
415        let b = buf("hello");
416        let mut c = CursorState::new(0, 3);
417        c.move_to_line_start();
418        assert!(c.position.col == 0);
419        c.move_to_line_end(&b);
420        assert!(c.position.col == 5);
421    }
422
423    #[test]
424    fn move_to_buffer_start_and_end() {
425        let b = buf("hello\nworld\nfoo");
426        let mut c = CursorState::new(1, 3);
427        c.move_to_buffer_start();
428        assert!(c.position == CursorPosition::beginning());
429        c.move_to_buffer_end(&b);
430        assert!(c.position.line == 2);
431        assert!(c.position.col == 3);
432    }
433
434    // --- Selection operations ---
435
436    #[test]
437    fn start_and_extend_selection() {
438        let mut c = CursorState::new(0, 5);
439        c.start_selection();
440        assert!(c.selection.is_some());
441        c.position.col = 10;
442        c.extend_selection();
443        match &c.selection {
444            Some(sel) => {
445                assert!(sel.anchor.col == 5);
446                assert!(sel.head.col == 10);
447            }
448            None => unreachable!("expected selection"),
449        }
450    }
451
452    #[test]
453    fn clear_selection() {
454        let mut c = CursorState::new(0, 0);
455        c.start_selection();
456        assert!(c.selection.is_some());
457        c.clear_selection();
458        assert!(c.selection.is_none());
459    }
460
461    #[test]
462    fn selected_text_single_line() {
463        let b = buf("hello world");
464        let mut c = CursorState::new(0, 0);
465        c.selection = Some(Selection::new(
466            CursorPosition::new(0, 6),
467            CursorPosition::new(0, 11),
468        ));
469        match c.selected_text(&b) {
470            Some(ref s) if s == "world" => {}
471            other => unreachable!("expected 'world', got {other:?}"),
472        }
473    }
474
475    #[test]
476    fn selected_text_multi_line() {
477        let b = buf("hello\nworld\nfoo");
478        let mut c = CursorState::new(0, 0);
479        c.selection = Some(Selection::new(
480            CursorPosition::new(0, 3),
481            CursorPosition::new(1, 3),
482        ));
483        match c.selected_text(&b) {
484            Some(ref s) if s == "lo\nwor" => {}
485            other => unreachable!("expected 'lo\\nwor', got {other:?}"),
486        }
487    }
488
489    #[test]
490    fn selected_text_empty_selection_returns_none() {
491        let b = buf("hello");
492        let mut c = CursorState::new(0, 3);
493        c.selection = Some(Selection::new(
494            CursorPosition::new(0, 3),
495            CursorPosition::new(0, 3),
496        ));
497        assert!(c.selected_text(&b).is_none());
498    }
499
500    #[test]
501    fn movement_clears_selection() {
502        let b = buf("hello");
503        let mut c = CursorState::new(0, 3);
504        c.start_selection();
505        c.move_right(&b);
506        assert!(c.selection.is_none());
507    }
508}