Skip to main content

fresh/primitives/
text_edit.rs

1//! Reusable multiline text editing state
2//!
3//! This module provides `TextEdit`, a multiline text editor with:
4//! - Cursor navigation (arrows, home/end)
5//! - Selection support (Shift+arrows, Ctrl+A)
6//! - Insert/delete operations
7//! - Word navigation (Ctrl+arrows)
8//!
9//! Single-line editing is a special case (one line, newlines disallowed).
10
11use crate::primitives::word_navigation::{find_word_end_bytes, find_word_start_bytes};
12
13/// Compute the flat UTF-8 byte offset of `(row, col)` into the
14/// `\n`-joined view of `lines`. Used by both `flat_cursor_byte` and
15/// `selection_flat_range` so the two stay in lock-step.
16fn flat_offset(lines: &[String], row: usize, col: usize) -> usize {
17    let mut offset = 0;
18    for (i, line) in lines.iter().enumerate() {
19        if i == row {
20            return offset + col.min(line.len());
21        }
22        offset += line.len() + 1;
23    }
24    offset.saturating_sub(1)
25}
26
27/// Multiline text editing state
28#[derive(Debug, Clone)]
29pub struct TextEdit {
30    /// Lines of text
31    pub lines: Vec<String>,
32    /// Current cursor row (0-indexed)
33    pub cursor_row: usize,
34    /// Current cursor column (0-indexed, in bytes)
35    pub cursor_col: usize,
36    /// Selection anchor position (row, col) - for Shift+Arrow selection
37    pub selection_anchor: Option<(usize, usize)>,
38    /// Whether to allow multiline (newlines)
39    pub multiline: bool,
40}
41
42impl Default for TextEdit {
43    fn default() -> Self {
44        Self::new()
45    }
46}
47
48impl TextEdit {
49    /// Create a new empty text edit (multiline by default)
50    pub fn new() -> Self {
51        Self {
52            lines: vec![String::new()],
53            cursor_row: 0,
54            cursor_col: 0,
55            selection_anchor: None,
56            multiline: true,
57        }
58    }
59
60    /// Create a single-line text edit
61    pub fn single_line() -> Self {
62        Self {
63            lines: vec![String::new()],
64            cursor_row: 0,
65            cursor_col: 0,
66            selection_anchor: None,
67            multiline: false,
68        }
69    }
70
71    /// Create from initial text
72    pub fn with_text(text: &str) -> Self {
73        let lines: Vec<String> = text.lines().map(String::from).collect();
74        let lines = if lines.is_empty() {
75            vec![String::new()]
76        } else {
77            lines
78        };
79        Self {
80            lines,
81            cursor_row: 0,
82            cursor_col: 0,
83            selection_anchor: None,
84            multiline: true,
85        }
86    }
87
88    /// Create single-line from initial text (takes first line only)
89    pub fn single_line_with_text(text: &str) -> Self {
90        let first_line = text.lines().next().unwrap_or("").to_string();
91        Self {
92            lines: vec![first_line],
93            cursor_row: 0,
94            cursor_col: 0,
95            selection_anchor: None,
96            multiline: false,
97        }
98    }
99
100    /// Get the full text value
101    pub fn value(&self) -> String {
102        self.lines.join("\n")
103    }
104
105    /// Set the text value, resetting cursor to start
106    pub fn set_value(&mut self, text: &str) {
107        if self.multiline {
108            self.lines = text.lines().map(String::from).collect();
109            if self.lines.is_empty() {
110                self.lines.push(String::new());
111            }
112        } else {
113            self.lines = vec![text.lines().next().unwrap_or("").to_string()];
114        }
115        self.cursor_row = 0;
116        self.cursor_col = 0;
117        self.selection_anchor = None;
118    }
119
120    /// Get the current line
121    pub fn current_line(&self) -> &str {
122        self.lines
123            .get(self.cursor_row)
124            .map(|s| s.as_str())
125            .unwrap_or("")
126    }
127
128    /// Get number of lines
129    pub fn line_count(&self) -> usize {
130        self.lines.len()
131    }
132
133    /// Flat UTF-8 byte offset of the cursor in `value()`. Each newline
134    /// separator between lines counts as one byte. Useful as a bridge
135    /// to renderers / IPC payloads that speak in linear byte offsets.
136    pub fn flat_cursor_byte(&self) -> usize {
137        let mut offset = 0;
138        for (i, line) in self.lines.iter().enumerate() {
139            if i == self.cursor_row {
140                return offset + self.cursor_col.min(line.len());
141            }
142            offset += line.len() + 1; // +1 for the joining '\n'
143        }
144        // cursor_row past the end: clamp to value length.
145        self.value().len()
146    }
147
148    /// Move the cursor to the position designated by a flat UTF-8 byte
149    /// offset into `value()`. Clears any active selection (matching the
150    /// other non-selecting cursor-move methods). Clamps to the value's
151    /// length and snaps to the nearest preceding char boundary.
152    pub fn set_cursor_from_flat(&mut self, byte: usize) {
153        self.clear_selection();
154        let total = self.value().len();
155        let mut remaining = byte.min(total);
156        for (i, line) in self.lines.iter().enumerate() {
157            if remaining <= line.len() {
158                let mut col = remaining;
159                while col > 0 && !line.is_char_boundary(col) {
160                    col -= 1;
161                }
162                self.cursor_row = i;
163                self.cursor_col = col;
164                return;
165            }
166            remaining -= line.len() + 1; // step past the line and its trailing '\n'
167        }
168        self.cursor_row = self.lines.len().saturating_sub(1);
169        self.cursor_col = self.lines.last().map(|l| l.len()).unwrap_or(0);
170    }
171
172    /// Selection range expressed as `(start_flat_byte, end_flat_byte)`
173    /// into `value()`. Returns `None` when there's no active selection.
174    /// Useful for renderers that overlay a background highlight on a
175    /// flat-string view of the value.
176    pub fn selection_flat_range(&self) -> Option<(usize, usize)> {
177        let ((sr, sc), (er, ec)) = self.selection_range()?;
178        Some((
179            flat_offset(&self.lines, sr, sc),
180            flat_offset(&self.lines, er, ec),
181        ))
182    }
183
184    // ========================================================================
185    // Cursor movement (clears selection)
186    // ========================================================================
187
188    /// Move cursor left
189    pub fn move_left(&mut self) {
190        self.clear_selection();
191        self.move_left_internal();
192    }
193
194    fn move_left_internal(&mut self) {
195        if self.cursor_col > 0 {
196            // Move to previous char boundary
197            let line = &self.lines[self.cursor_row];
198            let mut new_col = self.cursor_col - 1;
199            while new_col > 0 && !line.is_char_boundary(new_col) {
200                new_col -= 1;
201            }
202            self.cursor_col = new_col;
203        } else if self.cursor_row > 0 && self.multiline {
204            self.cursor_row -= 1;
205            self.cursor_col = self.lines[self.cursor_row].len();
206        }
207    }
208
209    /// Move cursor right
210    pub fn move_right(&mut self) {
211        self.clear_selection();
212        self.move_right_internal();
213    }
214
215    fn move_right_internal(&mut self) {
216        let line_len = self
217            .lines
218            .get(self.cursor_row)
219            .map(|l| l.len())
220            .unwrap_or(0);
221        if self.cursor_col < line_len {
222            // Move to next char boundary
223            let line = &self.lines[self.cursor_row];
224            let mut new_col = self.cursor_col + 1;
225            while new_col < line.len() && !line.is_char_boundary(new_col) {
226                new_col += 1;
227            }
228            self.cursor_col = new_col;
229        } else if self.cursor_row + 1 < self.lines.len() && self.multiline {
230            self.cursor_row += 1;
231            self.cursor_col = 0;
232        }
233    }
234
235    /// Move cursor up
236    pub fn move_up(&mut self) {
237        self.clear_selection();
238        self.move_up_internal();
239    }
240
241    fn move_up_internal(&mut self) {
242        if self.cursor_row > 0 {
243            self.cursor_row -= 1;
244            let line_len = self.lines[self.cursor_row].len();
245            self.cursor_col = self.cursor_col.min(line_len);
246        }
247    }
248
249    /// Move cursor down
250    pub fn move_down(&mut self) {
251        self.clear_selection();
252        self.move_down_internal();
253    }
254
255    fn move_down_internal(&mut self) {
256        if self.cursor_row + 1 < self.lines.len() {
257            self.cursor_row += 1;
258            let line_len = self.lines[self.cursor_row].len();
259            self.cursor_col = self.cursor_col.min(line_len);
260        }
261    }
262
263    /// Move to start of line
264    pub fn move_home(&mut self) {
265        self.clear_selection();
266        self.cursor_col = 0;
267    }
268
269    /// Move to end of line
270    pub fn move_end(&mut self) {
271        self.clear_selection();
272        self.cursor_col = self
273            .lines
274            .get(self.cursor_row)
275            .map(|l| l.len())
276            .unwrap_or(0);
277    }
278
279    /// Move to start of previous word
280    pub fn move_word_left(&mut self) {
281        self.clear_selection();
282        self.move_word_left_internal();
283    }
284
285    fn move_word_left_internal(&mut self) {
286        let line = &self.lines[self.cursor_row];
287        if self.cursor_col > 0 {
288            let new_col = find_word_start_bytes(line.as_bytes(), self.cursor_col);
289            if new_col < self.cursor_col {
290                self.cursor_col = new_col;
291                return;
292            }
293        }
294        // At start of line, move to end of previous line
295        if self.cursor_row > 0 && self.multiline {
296            self.cursor_row -= 1;
297            self.cursor_col = self.lines[self.cursor_row].len();
298        }
299    }
300
301    /// Move to start of next word
302    pub fn move_word_right(&mut self) {
303        self.clear_selection();
304        self.move_word_right_internal();
305    }
306
307    fn move_word_right_internal(&mut self) {
308        let line = &self.lines[self.cursor_row];
309        if self.cursor_col < line.len() {
310            let new_col = find_word_end_bytes(line.as_bytes(), self.cursor_col);
311            if new_col > self.cursor_col {
312                self.cursor_col = new_col;
313                return;
314            }
315        }
316        // At end of line, move to start of next line
317        if self.cursor_row + 1 < self.lines.len() && self.multiline {
318            self.cursor_row += 1;
319            self.cursor_col = 0;
320        }
321    }
322
323    // ========================================================================
324    // Selection support
325    // ========================================================================
326
327    /// Check if there's an active selection
328    pub fn has_selection(&self) -> bool {
329        if let Some((anchor_row, anchor_col)) = self.selection_anchor {
330            anchor_row != self.cursor_row || anchor_col != self.cursor_col
331        } else {
332            false
333        }
334    }
335
336    /// Get selection range as ((start_row, start_col), (end_row, end_col))
337    /// where start is before end in document order
338    pub fn selection_range(&self) -> Option<((usize, usize), (usize, usize))> {
339        let (anchor_row, anchor_col) = self.selection_anchor?;
340        if anchor_row == self.cursor_row && anchor_col == self.cursor_col {
341            return None;
342        }
343
344        let (start, end) = if anchor_row < self.cursor_row
345            || (anchor_row == self.cursor_row && anchor_col < self.cursor_col)
346        {
347            ((anchor_row, anchor_col), (self.cursor_row, self.cursor_col))
348        } else {
349            ((self.cursor_row, self.cursor_col), (anchor_row, anchor_col))
350        };
351        Some((start, end))
352    }
353
354    /// Get selected text
355    pub fn selected_text(&self) -> Option<String> {
356        let ((start_row, start_col), (end_row, end_col)) = self.selection_range()?;
357
358        if start_row == end_row {
359            let line = &self.lines[start_row];
360            let end_col = end_col.min(line.len());
361            let start_col = start_col.min(end_col);
362            Some(line[start_col..end_col].to_string())
363        } else {
364            let mut result = String::new();
365            // First line from start_col to end
366            let first_line = &self.lines[start_row];
367            result.push_str(&first_line[start_col.min(first_line.len())..]);
368            result.push('\n');
369            // Middle lines (full)
370            for row in (start_row + 1)..end_row {
371                result.push_str(&self.lines[row]);
372                result.push('\n');
373            }
374            // Last line from start to end_col
375            let last_line = &self.lines[end_row];
376            result.push_str(&last_line[..end_col.min(last_line.len())]);
377            Some(result)
378        }
379    }
380
381    /// Delete selection and return the deleted text
382    pub fn delete_selection(&mut self) -> Option<String> {
383        let ((start_row, start_col), (end_row, end_col)) = self.selection_range()?;
384        let deleted = self.selected_text()?;
385
386        if start_row == end_row {
387            let line = &mut self.lines[start_row];
388            let end_col = end_col.min(line.len());
389            let start_col = start_col.min(end_col);
390            line.drain(start_col..end_col);
391        } else {
392            let end_col = end_col.min(self.lines[end_row].len());
393            let after_end = self.lines[end_row][end_col..].to_string();
394            self.lines[start_row].truncate(start_col);
395            self.lines[start_row].push_str(&after_end);
396            // Remove the lines in between
397            for _ in (start_row + 1)..=end_row {
398                self.lines.remove(start_row + 1);
399            }
400        }
401
402        self.cursor_row = start_row;
403        self.cursor_col = start_col;
404        self.selection_anchor = None;
405        Some(deleted)
406    }
407
408    /// Clear selection without deleting text
409    pub fn clear_selection(&mut self) {
410        self.selection_anchor = None;
411    }
412
413    /// Start or extend selection
414    fn ensure_anchor(&mut self) {
415        if self.selection_anchor.is_none() {
416            self.selection_anchor = Some((self.cursor_row, self.cursor_col));
417        }
418    }
419
420    /// Move cursor left with selection (Shift+Left)
421    pub fn move_left_selecting(&mut self) {
422        self.ensure_anchor();
423        self.move_left_internal();
424    }
425
426    /// Move cursor right with selection (Shift+Right)
427    pub fn move_right_selecting(&mut self) {
428        self.ensure_anchor();
429        self.move_right_internal();
430    }
431
432    /// Move cursor up with selection (Shift+Up)
433    pub fn move_up_selecting(&mut self) {
434        self.ensure_anchor();
435        self.move_up_internal();
436    }
437
438    /// Move cursor down with selection (Shift+Down)
439    pub fn move_down_selecting(&mut self) {
440        self.ensure_anchor();
441        self.move_down_internal();
442    }
443
444    /// Move to start of line with selection (Shift+Home)
445    pub fn move_home_selecting(&mut self) {
446        self.ensure_anchor();
447        self.cursor_col = 0;
448    }
449
450    /// Move to end of line with selection (Shift+End)
451    pub fn move_end_selecting(&mut self) {
452        self.ensure_anchor();
453        self.cursor_col = self
454            .lines
455            .get(self.cursor_row)
456            .map(|l| l.len())
457            .unwrap_or(0);
458    }
459
460    /// Move word left with selection (Ctrl+Shift+Left)
461    pub fn move_word_left_selecting(&mut self) {
462        self.ensure_anchor();
463        self.move_word_left_internal();
464    }
465
466    /// Move word right with selection (Ctrl+Shift+Right)
467    pub fn move_word_right_selecting(&mut self) {
468        self.ensure_anchor();
469        self.move_word_right_internal();
470    }
471
472    /// Select all text (Ctrl+A)
473    pub fn select_all(&mut self) {
474        self.selection_anchor = Some((0, 0));
475        self.cursor_row = self.lines.len().saturating_sub(1);
476        self.cursor_col = self.lines.last().map(|l| l.len()).unwrap_or(0);
477    }
478
479    // ========================================================================
480    // Editing operations
481    // ========================================================================
482
483    /// Insert a character at cursor position
484    pub fn insert_char(&mut self, c: char) {
485        // Delete selection first if any
486        if self.has_selection() {
487            self.delete_selection();
488        }
489
490        if c == '\n' && self.multiline {
491            // Split line at cursor
492            let current_line = &self.lines[self.cursor_row];
493            let col = self.cursor_col.min(current_line.len());
494            let (before, after) = current_line.split_at(col);
495            let before = before.to_string();
496            let after = after.to_string();
497            self.lines[self.cursor_row] = before;
498            self.lines.insert(self.cursor_row + 1, after);
499            self.cursor_row += 1;
500            self.cursor_col = 0;
501        } else if c != '\n' && self.cursor_row < self.lines.len() {
502            let line = &mut self.lines[self.cursor_row];
503            let col = self.cursor_col.min(line.len());
504            line.insert(col, c);
505            self.cursor_col = col + c.len_utf8();
506        }
507        // Ignore newline in single-line mode
508    }
509
510    /// Insert a string at cursor position.
511    ///
512    /// In single-line mode, embedded `\n` are flattened to a space
513    /// (`' '`) — pasting `"foo\nbar"` into a single-line input
514    /// yields `"foo bar"`. Dropping the newline silently would
515    /// concatenate the words (`"foobar"`), which is the wrong
516    /// thing for the common "paste a CSV line into a search
517    /// field" workflow. The space preserves token boundaries and
518    /// matches what every other GUI text input does on paste.
519    pub fn insert_str(&mut self, text: &str) {
520        if self.has_selection() {
521            self.delete_selection();
522        }
523        for c in text.chars() {
524            if c == '\n' && !self.multiline {
525                self.insert_char(' ');
526                continue;
527            }
528            self.insert_char(c);
529        }
530    }
531
532    /// Delete character before cursor (backspace)
533    pub fn backspace(&mut self) {
534        if self.has_selection() {
535            self.delete_selection();
536            return;
537        }
538
539        if self.cursor_col > 0 {
540            let line = &mut self.lines[self.cursor_row];
541            // Find previous char boundary
542            let mut del_start = self.cursor_col - 1;
543            while del_start > 0 && !line.is_char_boundary(del_start) {
544                del_start -= 1;
545            }
546            line.drain(del_start..self.cursor_col);
547            self.cursor_col = del_start;
548        } else if self.cursor_row > 0 && self.multiline {
549            // Join with previous line
550            let current_line = self.lines.remove(self.cursor_row);
551            self.cursor_row -= 1;
552            self.cursor_col = self.lines[self.cursor_row].len();
553            self.lines[self.cursor_row].push_str(&current_line);
554        }
555    }
556
557    /// Delete character at cursor (delete key)
558    pub fn delete(&mut self) {
559        if self.has_selection() {
560            self.delete_selection();
561            return;
562        }
563
564        let line_len = self
565            .lines
566            .get(self.cursor_row)
567            .map(|l| l.len())
568            .unwrap_or(0);
569        if self.cursor_col < line_len {
570            let line = &mut self.lines[self.cursor_row];
571            // Find next char boundary
572            let mut del_end = self.cursor_col + 1;
573            while del_end < line.len() && !line.is_char_boundary(del_end) {
574                del_end += 1;
575            }
576            line.drain(self.cursor_col..del_end);
577        } else if self.cursor_row + 1 < self.lines.len() && self.multiline {
578            // Join with next line
579            let next_line = self.lines.remove(self.cursor_row + 1);
580            self.lines[self.cursor_row].push_str(&next_line);
581        }
582    }
583
584    /// Delete from cursor to end of word (Ctrl+Delete)
585    pub fn delete_word_forward(&mut self) {
586        if self.has_selection() {
587            self.delete_selection();
588            return;
589        }
590
591        let line = &self.lines[self.cursor_row];
592        let word_end = find_word_end_bytes(line.as_bytes(), self.cursor_col);
593        if word_end > self.cursor_col {
594            let line = &mut self.lines[self.cursor_row];
595            line.drain(self.cursor_col..word_end);
596        } else if self.cursor_row + 1 < self.lines.len() && self.multiline {
597            // At end of line, join with next
598            let next_line = self.lines.remove(self.cursor_row + 1);
599            self.lines[self.cursor_row].push_str(&next_line);
600        }
601    }
602
603    /// Delete from start of word to cursor (Ctrl+Backspace)
604    pub fn delete_word_backward(&mut self) {
605        if self.has_selection() {
606            self.delete_selection();
607            return;
608        }
609
610        let line = &self.lines[self.cursor_row];
611        let word_start = find_word_start_bytes(line.as_bytes(), self.cursor_col);
612        if word_start < self.cursor_col {
613            let line = &mut self.lines[self.cursor_row];
614            line.drain(word_start..self.cursor_col);
615            self.cursor_col = word_start;
616        } else if self.cursor_row > 0 && self.multiline {
617            // At start of line, join with previous
618            let current_line = self.lines.remove(self.cursor_row);
619            self.cursor_row -= 1;
620            self.cursor_col = self.lines[self.cursor_row].len();
621            self.lines[self.cursor_row].push_str(&current_line);
622        }
623    }
624
625    /// Delete from cursor to end of line (Ctrl+K)
626    pub fn delete_to_end(&mut self) {
627        if self.has_selection() {
628            self.delete_selection();
629            return;
630        }
631
632        if let Some(line) = self.lines.get_mut(self.cursor_row) {
633            line.truncate(self.cursor_col);
634        }
635    }
636
637    /// Clear all text
638    pub fn clear(&mut self) {
639        self.lines = vec![String::new()];
640        self.cursor_row = 0;
641        self.cursor_col = 0;
642        self.selection_anchor = None;
643    }
644}
645
646#[cfg(test)]
647mod tests {
648    use super::*;
649
650    #[test]
651    fn test_single_line_basic() {
652        let mut edit = TextEdit::single_line();
653        edit.insert_str("hello world");
654        assert_eq!(edit.value(), "hello world");
655        assert_eq!(edit.cursor_col, 11);
656    }
657
658    #[test]
659    fn test_single_line_flattens_newlines_to_spaces() {
660        // Pasting a multi-line string into a single-line input
661        // should flatten newlines to spaces, not concatenate
662        // (which would lose token boundaries).
663        let mut edit = TextEdit::single_line();
664        edit.insert_str("hello\nworld");
665        assert_eq!(edit.value(), "hello world");
666        assert_eq!(edit.line_count(), 1);
667    }
668
669    #[test]
670    fn test_multiline_basic() {
671        let mut edit = TextEdit::new();
672        edit.insert_str("hello\nworld");
673        assert_eq!(edit.value(), "hello\nworld");
674        assert_eq!(edit.line_count(), 2);
675        assert_eq!(edit.cursor_row, 1);
676        assert_eq!(edit.cursor_col, 5);
677    }
678
679    #[test]
680    fn test_selection_single_line() {
681        let mut edit = TextEdit::single_line_with_text("hello world");
682        edit.cursor_col = 6; // After "hello "
683
684        edit.move_right_selecting();
685        edit.move_right_selecting();
686        edit.move_right_selecting();
687        edit.move_right_selecting();
688        edit.move_right_selecting();
689
690        assert!(edit.has_selection());
691        assert_eq!(edit.selected_text(), Some("world".to_string()));
692    }
693
694    #[test]
695    fn test_selection_multiline() {
696        let mut edit = TextEdit::with_text("line1\nline2\nline3");
697        edit.cursor_row = 0;
698        edit.cursor_col = 3; // After "lin"
699
700        // Select to middle of line 2
701        edit.move_down_selecting();
702        edit.move_right_selecting();
703        edit.move_right_selecting();
704
705        assert!(edit.has_selection());
706        let selected = edit.selected_text().unwrap();
707        assert_eq!(selected, "e1\nline2");
708    }
709
710    #[test]
711    fn test_delete_selection() {
712        let mut edit = TextEdit::with_text("hello world");
713        edit.cursor_col = 0;
714
715        // Select "hello "
716        for _ in 0..6 {
717            edit.move_right_selecting();
718        }
719
720        let deleted = edit.delete_selection();
721        assert_eq!(deleted, Some("hello ".to_string()));
722        assert_eq!(edit.value(), "world");
723        assert_eq!(edit.cursor_col, 0);
724    }
725
726    #[test]
727    fn test_backspace_with_selection() {
728        let mut edit = TextEdit::with_text("hello world");
729        edit.select_all();
730        edit.backspace();
731        assert_eq!(edit.value(), "");
732    }
733
734    #[test]
735    fn test_insert_replaces_selection() {
736        let mut edit = TextEdit::with_text("hello world");
737        edit.select_all();
738        edit.insert_str("goodbye");
739        assert_eq!(edit.value(), "goodbye");
740    }
741
742    #[test]
743    fn test_flat_cursor_byte_round_trips_multiline() {
744        let mut edit = TextEdit::with_text("ab\ncde\nf");
745        // Cursor on line 1 col 2 (between 'd' and 'e').
746        edit.cursor_row = 1;
747        edit.cursor_col = 2;
748        let flat = edit.flat_cursor_byte();
749        // "ab\n" = 3 bytes, plus 2 into line 1 = 5.
750        assert_eq!(flat, 5);
751        // Round-trip: set from flat 5 should land at (1, 2).
752        let mut edit2 = TextEdit::with_text("ab\ncde\nf");
753        edit2.set_cursor_from_flat(5);
754        assert_eq!((edit2.cursor_row, edit2.cursor_col), (1, 2));
755    }
756
757    #[test]
758    fn test_set_cursor_from_flat_clamps_past_end() {
759        let mut edit = TextEdit::with_text("abc\nde");
760        edit.set_cursor_from_flat(999);
761        assert_eq!((edit.cursor_row, edit.cursor_col), (1, 2));
762    }
763
764    #[test]
765    fn test_set_cursor_from_flat_snaps_to_char_boundary() {
766        // "é" is 2 bytes. Flat offset 1 lands mid-multibyte — snap to 0.
767        let mut edit = TextEdit::single_line_with_text("é");
768        edit.set_cursor_from_flat(1);
769        assert_eq!(edit.cursor_col, 0);
770    }
771
772    #[test]
773    fn test_selection_flat_range_spans_newline() {
774        let mut edit = TextEdit::with_text("ab\ncd");
775        edit.cursor_row = 0;
776        edit.cursor_col = 1;
777        edit.move_right_selecting(); // (0,2)
778        edit.move_right_selecting(); // (1,0) — crossed the newline
779        edit.move_right_selecting(); // (1,1)
780                                     // Anchor was at (0,1) → flat 1. Cursor at (1,1) → flat 4.
781        assert_eq!(edit.selection_flat_range(), Some((1, 4)));
782    }
783
784    #[test]
785    fn test_word_navigation() {
786        let mut edit = TextEdit::single_line_with_text("one two three");
787        edit.cursor_col = 0;
788
789        edit.move_word_right();
790        assert_eq!(edit.cursor_col, 3); // After "one"
791
792        edit.move_word_right();
793        assert_eq!(edit.cursor_col, 7); // After "two"
794
795        edit.move_word_left();
796        assert_eq!(edit.cursor_col, 4); // Start of "two"
797    }
798}