Skip to main content

astrelis_text/
editor.rs

1//! Text editor with selection and cursor management.
2//!
3//! This module provides text editing capabilities for text input widgets:
4//! - Cursor positioning and movement
5//! - Text selection
6//! - Insert and delete operations
7//! - Hit testing (screen position to cursor position)
8//! - Selection rectangle generation
9//!
10//! # Example
11//!
12//! ```ignore
13//! use astrelis_text::*;
14//!
15//! let mut editor = TextEditor::new("Hello, World!");
16//!
17//! // Move cursor
18//! editor.move_cursor_end();
19//!
20//! // Insert text at cursor
21//! editor.insert_char('!');
22//!
23//! // Select text
24//! editor.select(0, 5); // Select "Hello"
25//!
26//! // Delete selection
27//! editor.delete_selection();
28//! ```
29
30use astrelis_core::math::Vec2;
31
32/// Text cursor position and state.
33#[derive(Debug, Clone, Copy, PartialEq)]
34pub struct TextCursor {
35    /// Byte offset in the string (UTF-8 byte index)
36    pub position: usize,
37    /// Visual X coordinate on screen (for vertical movement)
38    pub visual_x: f32,
39    /// Line number (0-indexed)
40    pub line: usize,
41    /// Column in the line (character index, not byte)
42    pub column: usize,
43}
44
45impl TextCursor {
46    /// Create a new cursor at position 0.
47    pub fn new() -> Self {
48        Self {
49            position: 0,
50            visual_x: 0.0,
51            line: 0,
52            column: 0,
53        }
54    }
55
56    /// Create a cursor at a specific byte position.
57    pub fn at_position(position: usize) -> Self {
58        Self {
59            position,
60            visual_x: 0.0,
61            line: 0,
62            column: 0,
63        }
64    }
65}
66
67impl Default for TextCursor {
68    fn default() -> Self {
69        Self::new()
70    }
71}
72
73/// Text selection range.
74#[derive(Debug, Clone, Copy, PartialEq)]
75pub struct TextSelection {
76    /// Selection start (byte offset)
77    pub start: usize,
78    /// Selection end (byte offset)
79    pub end: usize,
80}
81
82impl TextSelection {
83    /// Create a new selection.
84    pub fn new(start: usize, end: usize) -> Self {
85        Self { start, end }
86    }
87
88    /// Get the selection as (min, max) to ensure start <= end.
89    pub fn range(&self) -> (usize, usize) {
90        if self.start <= self.end {
91            (self.start, self.end)
92        } else {
93            (self.end, self.start)
94        }
95    }
96
97    /// Get the selection length in bytes.
98    pub fn len(&self) -> usize {
99        let (min, max) = self.range();
100        max - min
101    }
102
103    /// Check if the selection is empty.
104    pub fn is_empty(&self) -> bool {
105        self.start == self.end
106    }
107
108    /// Check if the selection contains a byte position.
109    pub fn contains(&self, position: usize) -> bool {
110        let (min, max) = self.range();
111        position >= min && position < max
112    }
113}
114
115/// Text editor with cursor and selection management.
116pub struct TextEditor {
117    /// Text content
118    text: String,
119    /// Cursor position
120    cursor: TextCursor,
121    /// Optional selection
122    selection: Option<TextSelection>,
123    /// Undo/redo history (simple version)
124    history: Vec<String>,
125    /// Current position in history
126    history_position: usize,
127}
128
129impl TextEditor {
130    /// Create a new text editor with initial text.
131    pub fn new(text: impl Into<String>) -> Self {
132        let text = text.into();
133        Self {
134            text: text.clone(),
135            cursor: TextCursor::new(),
136            selection: None,
137            history: vec![text],
138            history_position: 0,
139        }
140    }
141
142    /// Get the current text content.
143    pub fn text(&self) -> &str {
144        &self.text
145    }
146
147    /// Get the cursor position.
148    pub fn cursor(&self) -> &TextCursor {
149        &self.cursor
150    }
151
152    /// Get the selection, if any.
153    pub fn selection(&self) -> Option<&TextSelection> {
154        self.selection.as_ref()
155    }
156
157    /// Check if there's an active selection.
158    pub fn has_selection(&self) -> bool {
159        self.selection.as_ref().is_some_and(|sel| !sel.is_empty())
160    }
161
162    /// Set the cursor position (byte offset).
163    pub fn set_cursor(&mut self, position: usize) {
164        self.cursor.position = position.min(self.text.len());
165        self.clear_selection();
166        self.update_cursor_position();
167    }
168
169    /// Move cursor to start of text.
170    pub fn move_cursor_start(&mut self) {
171        self.set_cursor(0);
172    }
173
174    /// Move cursor to end of text.
175    pub fn move_cursor_end(&mut self) {
176        self.set_cursor(self.text.len());
177    }
178
179    /// Move cursor left by one character.
180    pub fn move_cursor_left(&mut self) {
181        if self.cursor.position > 0 {
182            // Move to previous UTF-8 character boundary
183            let mut pos = self.cursor.position - 1;
184            while pos > 0 && !self.text.is_char_boundary(pos) {
185                pos -= 1;
186            }
187            self.set_cursor(pos);
188        }
189    }
190
191    /// Move cursor right by one character.
192    pub fn move_cursor_right(&mut self) {
193        if self.cursor.position < self.text.len() {
194            // Move to next UTF-8 character boundary
195            let mut pos = self.cursor.position + 1;
196            while pos < self.text.len() && !self.text.is_char_boundary(pos) {
197                pos += 1;
198            }
199            self.set_cursor(pos);
200        }
201    }
202
203    /// Select text range.
204    pub fn select(&mut self, start: usize, end: usize) {
205        self.selection = Some(TextSelection::new(
206            start.min(self.text.len()),
207            end.min(self.text.len()),
208        ));
209        self.cursor.position = end.min(self.text.len());
210    }
211
212    /// Select all text.
213    pub fn select_all(&mut self) {
214        self.select(0, self.text.len());
215    }
216
217    /// Clear selection.
218    pub fn clear_selection(&mut self) {
219        self.selection = None;
220    }
221
222    /// Insert a character at the cursor position.
223    pub fn insert_char(&mut self, c: char) {
224        // Delete selection first if any
225        if self.has_selection() {
226            self.delete_selection();
227        }
228
229        // Insert character
230        self.text.insert(self.cursor.position, c);
231        self.cursor.position += c.len_utf8();
232        self.update_cursor_position();
233        self.push_history();
234    }
235
236    /// Insert a string at the cursor position.
237    pub fn insert_str(&mut self, s: &str) {
238        // Delete selection first if any
239        if self.has_selection() {
240            self.delete_selection();
241        }
242
243        // Insert string
244        self.text.insert_str(self.cursor.position, s);
245        self.cursor.position += s.len();
246        self.update_cursor_position();
247        self.push_history();
248    }
249
250    /// Delete character before cursor (backspace).
251    pub fn delete_char(&mut self) {
252        if self.has_selection() {
253            self.delete_selection();
254        } else if self.cursor.position > 0 {
255            // Find previous character boundary
256            let mut pos = self.cursor.position - 1;
257            while pos > 0 && !self.text.is_char_boundary(pos) {
258                pos -= 1;
259            }
260
261            self.text.drain(pos..self.cursor.position);
262            self.cursor.position = pos;
263            self.update_cursor_position();
264            self.push_history();
265        }
266    }
267
268    /// Delete character after cursor (delete key).
269    pub fn delete_char_forward(&mut self) {
270        if self.has_selection() {
271            self.delete_selection();
272        } else if self.cursor.position < self.text.len() {
273            // Find next character boundary
274            let mut pos = self.cursor.position + 1;
275            while pos < self.text.len() && !self.text.is_char_boundary(pos) {
276                pos += 1;
277            }
278
279            self.text.drain(self.cursor.position..pos);
280            self.update_cursor_position();
281            self.push_history();
282        }
283    }
284
285    /// Delete the current selection.
286    pub fn delete_selection(&mut self) {
287        if let Some(sel) = self.selection {
288            let (start, end) = sel.range();
289            self.text.drain(start..end);
290            self.cursor.position = start;
291            self.clear_selection();
292            self.update_cursor_position();
293            self.push_history();
294        }
295    }
296
297    /// Get selected text.
298    pub fn selected_text(&self) -> Option<&str> {
299        self.selection.as_ref().map(|sel| {
300            let (start, end) = sel.range();
301            &self.text[start..end]
302        })
303    }
304
305    /// Replace selected text with new text.
306    pub fn replace_selection(&mut self, text: &str) {
307        if self.has_selection() {
308            self.delete_selection();
309        }
310        self.insert_str(text);
311    }
312
313    /// Undo last operation.
314    pub fn undo(&mut self) -> bool {
315        if self.history_position > 0 {
316            self.history_position -= 1;
317            self.text = self.history[self.history_position].clone();
318            self.cursor.position = self.cursor.position.min(self.text.len());
319            self.clear_selection();
320            self.update_cursor_position();
321            true
322        } else {
323            false
324        }
325    }
326
327    /// Redo last undone operation.
328    pub fn redo(&mut self) -> bool {
329        if self.history_position < self.history.len() - 1 {
330            self.history_position += 1;
331            self.text = self.history[self.history_position].clone();
332            self.cursor.position = self.cursor.position.min(self.text.len());
333            self.clear_selection();
334            self.update_cursor_position();
335            true
336        } else {
337            false
338        }
339    }
340
341    /// Hit test to find cursor position from screen coordinates.
342    ///
343    /// This is a simplified version that assumes monospace font.
344    /// For real usage, you'd need glyph position information from the text shaper.
345    pub fn hit_test(&self, _pos: Vec2, _char_width: f32) -> usize {
346        // Simplified: just return current cursor position
347        // In a real implementation, this would:
348        // 1. Find the line at the Y coordinate
349        // 2. Find the character at the X coordinate within that line
350        // 3. Return the byte offset of that character
351        self.cursor.position
352    }
353
354    /// Get selection rectangles for rendering.
355    ///
356    /// This is a simplified version that returns a single rectangle.
357    /// For real usage with multi-line selections, you'd need line layout information.
358    pub fn selection_rects(
359        &self,
360        _line_height: f32,
361        _char_width: f32,
362    ) -> Vec<(f32, f32, f32, f32)> {
363        if let Some(sel) = self.selection {
364            if !sel.is_empty() {
365                let (start, end) = sel.range();
366                // Simplified: single rectangle
367                // In a real implementation, this would generate rectangles per line
368                vec![(
369                    start as f32 * _char_width,
370                    0.0,
371                    (end - start) as f32 * _char_width,
372                    _line_height,
373                )]
374            } else {
375                vec![]
376            }
377        } else {
378            vec![]
379        }
380    }
381
382    // Private helper methods
383
384    fn update_cursor_position(&mut self) {
385        // Update line and column based on cursor position
386        let text_before = &self.text[..self.cursor.position];
387        self.cursor.line = text_before.matches('\n').count();
388
389        // Find column (character index in current line)
390        if let Some(line_start) = text_before.rfind('\n') {
391            let line_text = &text_before[line_start + 1..];
392            self.cursor.column = line_text.chars().count();
393        } else {
394            self.cursor.column = text_before.chars().count();
395        }
396    }
397
398    fn push_history(&mut self) {
399        // Truncate history if we've undone and then made changes
400        self.history.truncate(self.history_position + 1);
401
402        // Add current state to history
403        self.history.push(self.text.clone());
404        self.history_position = self.history.len() - 1;
405
406        // Limit history size
407        const MAX_HISTORY: usize = 100;
408        if self.history.len() > MAX_HISTORY {
409            self.history.remove(0);
410            self.history_position -= 1;
411        }
412    }
413}
414
415#[cfg(test)]
416mod tests {
417    use super::*;
418
419    #[test]
420    fn test_text_cursor_default() {
421        let cursor = TextCursor::default();
422        assert_eq!(cursor.position, 0);
423        assert_eq!(cursor.line, 0);
424        assert_eq!(cursor.column, 0);
425    }
426
427    #[test]
428    fn test_text_selection_range() {
429        let sel = TextSelection::new(5, 10);
430        assert_eq!(sel.range(), (5, 10));
431        assert_eq!(sel.len(), 5);
432        assert!(!sel.is_empty());
433
434        // Reversed selection
435        let sel = TextSelection::new(10, 5);
436        assert_eq!(sel.range(), (5, 10));
437        assert_eq!(sel.len(), 5);
438    }
439
440    #[test]
441    fn test_text_selection_contains() {
442        let sel = TextSelection::new(5, 10);
443        assert!(sel.contains(5));
444        assert!(sel.contains(7));
445        assert!(!sel.contains(10)); // End is exclusive
446        assert!(!sel.contains(3));
447    }
448
449    #[test]
450    fn test_editor_new() {
451        let editor = TextEditor::new("Hello");
452        assert_eq!(editor.text(), "Hello");
453        assert_eq!(editor.cursor().position, 0);
454        assert!(!editor.has_selection());
455    }
456
457    #[test]
458    fn test_editor_insert_char() {
459        let mut editor = TextEditor::new("Hello");
460        editor.move_cursor_end();
461        editor.insert_char('!');
462        assert_eq!(editor.text(), "Hello!");
463        assert_eq!(editor.cursor().position, 6);
464    }
465
466    #[test]
467    fn test_editor_insert_str() {
468        let mut editor = TextEditor::new("Hello");
469        editor.move_cursor_end();
470        editor.insert_str(", World");
471        assert_eq!(editor.text(), "Hello, World");
472    }
473
474    #[test]
475    fn test_editor_delete_char() {
476        let mut editor = TextEditor::new("Hello");
477        editor.move_cursor_end();
478        editor.delete_char();
479        assert_eq!(editor.text(), "Hell");
480        assert_eq!(editor.cursor().position, 4);
481    }
482
483    #[test]
484    fn test_editor_delete_char_forward() {
485        let mut editor = TextEditor::new("Hello");
486        editor.set_cursor(0);
487        editor.delete_char_forward();
488        assert_eq!(editor.text(), "ello");
489        assert_eq!(editor.cursor().position, 0);
490    }
491
492    #[test]
493    fn test_editor_selection() {
494        let mut editor = TextEditor::new("Hello, World");
495        editor.select(0, 5);
496        assert!(editor.has_selection());
497        assert_eq!(editor.selected_text(), Some("Hello"));
498    }
499
500    #[test]
501    fn test_editor_delete_selection() {
502        let mut editor = TextEditor::new("Hello, World");
503        editor.select(0, 5);
504        editor.delete_selection();
505        assert_eq!(editor.text(), ", World");
506        assert!(!editor.has_selection());
507    }
508
509    #[test]
510    fn test_editor_replace_selection() {
511        let mut editor = TextEditor::new("Hello, World");
512        editor.select(7, 12);
513        editor.replace_selection("Universe");
514        assert_eq!(editor.text(), "Hello, Universe");
515    }
516
517    #[test]
518    fn test_editor_select_all() {
519        let mut editor = TextEditor::new("Hello");
520        editor.select_all();
521        assert_eq!(editor.selected_text(), Some("Hello"));
522    }
523
524    #[test]
525    fn test_editor_cursor_movement() {
526        let mut editor = TextEditor::new("Hello");
527
528        editor.move_cursor_end();
529        assert_eq!(editor.cursor().position, 5);
530
531        editor.move_cursor_left();
532        assert_eq!(editor.cursor().position, 4);
533
534        editor.move_cursor_right();
535        assert_eq!(editor.cursor().position, 5);
536
537        editor.move_cursor_start();
538        assert_eq!(editor.cursor().position, 0);
539    }
540
541    #[test]
542    fn test_editor_undo_redo() {
543        let mut editor = TextEditor::new("Hello");
544        editor.move_cursor_end();
545        editor.insert_char('!');
546        assert_eq!(editor.text(), "Hello!");
547
548        editor.undo();
549        assert_eq!(editor.text(), "Hello");
550
551        editor.redo();
552        assert_eq!(editor.text(), "Hello!");
553    }
554
555    #[test]
556    fn test_editor_utf8() {
557        let mut editor = TextEditor::new("Hello 世界");
558        editor.move_cursor_end();
559        editor.insert_char('!');
560        assert_eq!(editor.text(), "Hello 世界!");
561
562        editor.delete_char();
563        assert_eq!(editor.text(), "Hello 世界");
564
565        editor.delete_char();
566        assert_eq!(editor.text(), "Hello 世");
567    }
568
569    #[test]
570    fn test_cursor_position_multiline() {
571        let mut editor = TextEditor::new("Hello\nWorld");
572        editor.set_cursor(6); // After newline
573        assert_eq!(editor.cursor().line, 1);
574        assert_eq!(editor.cursor().column, 0);
575
576        editor.move_cursor_end();
577        assert_eq!(editor.cursor().line, 1);
578        assert_eq!(editor.cursor().column, 5);
579    }
580}