Skip to main content

ply_engine/
text_input.rs

1use crate::color::Color;
2
3/// Identifies what kind of edit an undo entry was, for grouping consecutive similar edits.
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub enum UndoActionKind {
6    /// Inserting a single character (consecutive inserts are grouped).
7    InsertChar,
8    /// Inserting text from paste (not grouped).
9    Paste,
10    /// Deleting via Backspace (consecutive deletes are grouped).
11    Backspace,
12    /// Deleting via Delete key (consecutive deletes are grouped).
13    Delete,
14    /// Deleting a word (not grouped).
15    DeleteWord,
16    /// Cutting selected text (not grouped).
17    Cut,
18    /// Any other discrete change (not grouped).
19    Other,
20}
21
22/// A snapshot of text state for undo/redo.
23#[derive(Debug, Clone)]
24pub struct UndoEntry {
25    pub text: String,
26    pub cursor_pos: usize,
27    pub selection_anchor: Option<usize>,
28    pub action_kind: UndoActionKind,
29}
30
31/// Maximum number of undo entries to keep.
32const MAX_UNDO_STACK: usize = 200;
33
34/// Persistent text editing state per text input element.
35/// Keyed by element `u32` ID in `PlyContext::text_edit_states`.
36#[derive(Debug, Clone)]
37pub struct TextEditState {
38    /// The current text content.
39    pub text: String,
40    /// Character index of the cursor (0 = before first char, text.chars().count() = after last).
41    pub cursor_pos: usize,
42    /// When `Some`, defines the anchor of a selection range (anchor..cursor_pos or cursor_pos..anchor).
43    pub selection_anchor: Option<usize>,
44    /// Horizontal scroll offset (pixels) when text overflows the bounding box.
45    pub scroll_offset: f32,
46    /// Vertical scroll offset (pixels) for multiline text inputs.
47    pub scroll_offset_y: f32,
48    /// Timer for cursor blink animation (seconds).
49    pub cursor_blink_timer: f64,
50    /// Timestamp of last click (for double-click detection).
51    pub last_click_time: f64,
52    /// Element ID of last click (for double-click detection).
53    pub last_click_element: u32,
54    /// Saved visual column for vertical (up/down) navigation.
55    /// When set, up/down arrows try to return to this column.
56    pub preferred_col: Option<usize>,
57    /// When true, cursor movement skips structural style positions (`}` and empty content markers).
58    /// Set from `TextInputConfig::no_styles_movement`.
59    pub no_styles_movement: bool,
60    /// Undo stack: previous states (newest at end).
61    pub undo_stack: Vec<UndoEntry>,
62    /// Redo stack: states undone (newest at end).
63    pub redo_stack: Vec<UndoEntry>,
64}
65
66impl Default for TextEditState {
67    fn default() -> Self {
68        Self {
69            text: String::new(),
70            cursor_pos: 0,
71            selection_anchor: None,
72            scroll_offset: 0.0,
73            scroll_offset_y: 0.0,
74            preferred_col: None,
75            no_styles_movement: false,
76            cursor_blink_timer: 0.0,
77            last_click_time: 0.0,
78            last_click_element: 0,
79            undo_stack: Vec::new(),
80            redo_stack: Vec::new(),
81        }
82    }
83}
84
85impl TextEditState {
86    /// Returns the ordered selection range `(start, end)` if a selection is active.
87    pub fn selection_range(&self) -> Option<(usize, usize)> {
88        self.selection_anchor.map(|anchor| {
89            let start = anchor.min(self.cursor_pos);
90            let end = anchor.max(self.cursor_pos);
91            (start, end)
92        })
93    }
94
95    /// Returns the selected text, or empty string if no selection.
96    pub fn selected_text(&self) -> &str {
97        if let Some((start, end)) = self.selection_range() {
98            let byte_start = char_index_to_byte(&self.text, start);
99            let byte_end = char_index_to_byte(&self.text, end);
100            &self.text[byte_start..byte_end]
101        } else {
102            ""
103        }
104    }
105
106    /// Delete the current selection and place cursor at the start.
107    /// Returns true if a selection was deleted.
108    pub fn delete_selection(&mut self) -> bool {
109        if let Some((start, end)) = self.selection_range() {
110            let byte_start = char_index_to_byte(&self.text, start);
111            let byte_end = char_index_to_byte(&self.text, end);
112            self.text.drain(byte_start..byte_end);
113            self.cursor_pos = start;
114            self.selection_anchor = None;
115            true
116        } else {
117            false
118        }
119    }
120
121    /// Insert text at the current cursor position, replacing any selection.
122    /// Respects max_length if provided.
123    pub fn insert_text(&mut self, s: &str, max_length: Option<usize>) {
124        self.delete_selection();
125        let char_count = self.text.chars().count();
126        let insert_count = s.chars().count();
127        let allowed = if let Some(max) = max_length {
128            if char_count >= max {
129                0
130            } else {
131                insert_count.min(max - char_count)
132            }
133        } else {
134            insert_count
135        };
136        if allowed == 0 {
137            return;
138        }
139        let insert_str: String = s.chars().take(allowed).collect();
140        let byte_pos = char_index_to_byte(&self.text, self.cursor_pos);
141        self.text.insert_str(byte_pos, &insert_str);
142        self.cursor_pos += allowed;
143        self.reset_blink();
144    }
145
146    /// Move cursor left by one character.
147    pub fn move_left(&mut self, shift: bool) {
148        if !shift {
149            // If there's a selection and no shift, collapse to start
150            if let Some((start, _end)) = self.selection_range() {
151                self.cursor_pos = start;
152                self.selection_anchor = None;
153                self.reset_blink();
154                return;
155            }
156        }
157        if self.cursor_pos > 0 {
158            if shift && self.selection_anchor.is_none() {
159                self.selection_anchor = Some(self.cursor_pos);
160            }
161            self.cursor_pos -= 1;
162            if shift {
163                // If anchor equals cursor, clear selection
164                if self.selection_anchor == Some(self.cursor_pos) {
165                    self.selection_anchor = None;
166                }
167            }
168        }
169        if !shift {
170            self.selection_anchor = None;
171        }
172        self.reset_blink();
173    }
174
175    /// Move cursor right by one character.
176    pub fn move_right(&mut self, shift: bool) {
177        let len = self.text.chars().count();
178        if !shift {
179            // If there's a selection and no shift, collapse to end
180            if let Some((_start, end)) = self.selection_range() {
181                self.cursor_pos = end;
182                self.selection_anchor = None;
183                self.reset_blink();
184                return;
185            }
186        }
187        if self.cursor_pos < len {
188            if shift && self.selection_anchor.is_none() {
189                self.selection_anchor = Some(self.cursor_pos);
190            }
191            self.cursor_pos += 1;
192            if shift {
193                if self.selection_anchor == Some(self.cursor_pos) {
194                    self.selection_anchor = None;
195                }
196            }
197        }
198        if !shift {
199            self.selection_anchor = None;
200        }
201        self.reset_blink();
202    }
203
204    /// Move cursor to the start of the previous word.
205    pub fn move_word_left(&mut self, shift: bool) {
206        if shift && self.selection_anchor.is_none() {
207            self.selection_anchor = Some(self.cursor_pos);
208        }
209        self.cursor_pos = find_word_boundary_left(&self.text, self.cursor_pos);
210        if !shift {
211            self.selection_anchor = None;
212        } else if self.selection_anchor == Some(self.cursor_pos) {
213            self.selection_anchor = None;
214        }
215        self.reset_blink();
216    }
217
218    /// Move cursor to the end of the next word.
219    pub fn move_word_right(&mut self, shift: bool) {
220        if shift && self.selection_anchor.is_none() {
221            self.selection_anchor = Some(self.cursor_pos);
222        }
223        self.cursor_pos = find_word_boundary_right(&self.text, self.cursor_pos);
224        if !shift {
225            self.selection_anchor = None;
226        } else if self.selection_anchor == Some(self.cursor_pos) {
227            self.selection_anchor = None;
228        }
229        self.reset_blink();
230    }
231
232    /// Move cursor to start of line.
233    pub fn move_home(&mut self, shift: bool) {
234        if shift && self.selection_anchor.is_none() {
235            self.selection_anchor = Some(self.cursor_pos);
236        }
237        self.cursor_pos = 0;
238        if !shift {
239            self.selection_anchor = None;
240        } else if self.selection_anchor == Some(0) {
241            self.selection_anchor = None;
242        }
243        self.reset_blink();
244    }
245
246    /// Move cursor to end of line.
247    pub fn move_end(&mut self, shift: bool) {
248        let len = self.text.chars().count();
249        if shift && self.selection_anchor.is_none() {
250            self.selection_anchor = Some(self.cursor_pos);
251        }
252        self.cursor_pos = len;
253        if !shift {
254            self.selection_anchor = None;
255        } else if self.selection_anchor == Some(len) {
256            self.selection_anchor = None;
257        }
258        self.reset_blink();
259    }
260
261    /// Select all text.
262    pub fn select_all(&mut self) {
263        let len = self.text.chars().count();
264        if len > 0 {
265            self.selection_anchor = Some(0);
266            self.cursor_pos = len;
267        }
268        self.reset_blink();
269    }
270
271    /// Delete character before cursor (Backspace).
272    pub fn backspace(&mut self) {
273        if self.delete_selection() {
274            return;
275        }
276        if self.cursor_pos > 0 {
277            self.cursor_pos -= 1;
278            let byte_pos = char_index_to_byte(&self.text, self.cursor_pos);
279            let next_byte = char_index_to_byte(&self.text, self.cursor_pos + 1);
280            self.text.drain(byte_pos..next_byte);
281        }
282        self.reset_blink();
283    }
284
285    /// Delete character after cursor (Delete key).
286    pub fn delete_forward(&mut self) {
287        if self.delete_selection() {
288            return;
289        }
290        let len = self.text.chars().count();
291        if self.cursor_pos < len {
292            let byte_pos = char_index_to_byte(&self.text, self.cursor_pos);
293            let next_byte = char_index_to_byte(&self.text, self.cursor_pos + 1);
294            self.text.drain(byte_pos..next_byte);
295        }
296        self.reset_blink();
297    }
298
299    /// Delete the word before the cursor (Ctrl+Backspace).
300    pub fn backspace_word(&mut self) {
301        if self.delete_selection() {
302            return;
303        }
304        let target = find_word_boundary_left(&self.text, self.cursor_pos);
305        let byte_start = char_index_to_byte(&self.text, target);
306        let byte_end = char_index_to_byte(&self.text, self.cursor_pos);
307        self.text.drain(byte_start..byte_end);
308        self.cursor_pos = target;
309        self.reset_blink();
310    }
311
312    /// Delete the word after the cursor (Ctrl+Delete).
313    pub fn delete_word_forward(&mut self) {
314        if self.delete_selection() {
315            return;
316        }
317        let target = find_word_delete_boundary_right(&self.text, self.cursor_pos);
318        let byte_start = char_index_to_byte(&self.text, self.cursor_pos);
319        let byte_end = char_index_to_byte(&self.text, target);
320        self.text.drain(byte_start..byte_end);
321        self.reset_blink();
322    }
323
324    /// Set cursor position from a click at pixel x within the element.
325    /// `char_x_positions` should be a sorted list of x-positions for each character boundary
326    /// (index 0 = left edge of first char, index n = right edge of last char).
327    pub fn click_to_cursor(&mut self, click_x: f32, char_x_positions: &[f32], shift: bool) {
328        let new_pos = find_nearest_char_boundary(click_x, char_x_positions);
329        if shift {
330            if self.selection_anchor.is_none() {
331                self.selection_anchor = Some(self.cursor_pos);
332            }
333        } else {
334            self.selection_anchor = None;
335        }
336        self.cursor_pos = new_pos;
337        if shift {
338            if self.selection_anchor == Some(self.cursor_pos) {
339                self.selection_anchor = None;
340            }
341        }
342        self.reset_blink();
343    }
344
345    /// Select the word at the given character position (for double-click).
346    pub fn select_word_at(&mut self, char_pos: usize) {
347        let (start, end) = find_word_at(&self.text, char_pos);
348        if start != end {
349            self.selection_anchor = Some(start);
350            self.cursor_pos = end;
351        }
352        self.reset_blink();
353    }
354
355    /// Reset blink timer so cursor is immediately visible.
356    pub fn reset_blink(&mut self) {
357        self.cursor_blink_timer = 0.0;
358    }
359
360    /// Returns whether the cursor should be visible based on blink timer.
361    pub fn cursor_visible(&self) -> bool {
362        (self.cursor_blink_timer % 1.06) < 0.53
363    }
364
365    /// Update scroll offset to ensure cursor is visible within `visible_width`.
366    /// `cursor_x` is the pixel x-position of the cursor relative to text start.
367    pub fn ensure_cursor_visible(&mut self, cursor_x: f32, visible_width: f32) {
368        if cursor_x - self.scroll_offset > visible_width {
369            self.scroll_offset = cursor_x - visible_width;
370        }
371        if cursor_x - self.scroll_offset < 0.0 {
372            self.scroll_offset = cursor_x;
373        }
374        // Clamp scroll_offset to valid range
375        if self.scroll_offset < 0.0 {
376            self.scroll_offset = 0.0;
377        }
378    }
379
380    /// Update vertical scroll offset to keep cursor visible in multiline mode.
381    /// `cursor_line` is the 0-based line index the cursor is on.
382    /// `line_height` is pixel height per line. `visible_height` is the element height.
383    pub fn ensure_cursor_visible_vertical(&mut self, cursor_line: usize, line_height: f32, visible_height: f32) {
384        let cursor_y = cursor_line as f32 * line_height;
385        let cursor_bottom = cursor_y + line_height;
386        if cursor_bottom - self.scroll_offset_y > visible_height {
387            self.scroll_offset_y = cursor_bottom - visible_height;
388        }
389        if cursor_y - self.scroll_offset_y < 0.0 {
390            self.scroll_offset_y = cursor_y;
391        }
392        if self.scroll_offset_y < 0.0 {
393            self.scroll_offset_y = 0.0;
394        }
395    }
396
397    /// Push the current state onto the undo stack before an edit.
398    /// `kind` controls grouping: consecutive edits of the same kind are merged
399    /// (only InsertChar, Backspace, and Delete are grouped).
400    pub fn push_undo(&mut self, kind: UndoActionKind) {
401        // Grouping: if the last undo entry has the same kind and it's a groupable kind,
402        // don't push a new entry (the original pre-group state is already saved).
403        let should_group = matches!(kind, UndoActionKind::InsertChar | UndoActionKind::Backspace | UndoActionKind::Delete);
404        if should_group {
405            if let Some(last) = self.undo_stack.last() {
406                if last.action_kind == kind {
407                    // Same groupable action — skip push, keep the original entry
408                    // Clear redo stack since we're making a new edit
409                    self.redo_stack.clear();
410                    return;
411                }
412            }
413        }
414
415        self.undo_stack.push(UndoEntry {
416            text: self.text.clone(),
417            cursor_pos: self.cursor_pos,
418            selection_anchor: self.selection_anchor,
419            action_kind: kind,
420        });
421        // Limit stack size
422        if self.undo_stack.len() > MAX_UNDO_STACK {
423            self.undo_stack.remove(0);
424        }
425        // Any new edit clears the redo stack
426        self.redo_stack.clear();
427    }
428
429    /// Undo the last edit. Returns true if undo was performed.
430    pub fn undo(&mut self) -> bool {
431        if let Some(entry) = self.undo_stack.pop() {
432            // Save current state to redo stack
433            self.redo_stack.push(UndoEntry {
434                text: self.text.clone(),
435                cursor_pos: self.cursor_pos,
436                selection_anchor: self.selection_anchor,
437                action_kind: entry.action_kind,
438            });
439            // Restore
440            self.text = entry.text;
441            self.cursor_pos = entry.cursor_pos;
442            self.selection_anchor = entry.selection_anchor;
443            self.reset_blink();
444            true
445        } else {
446            false
447        }
448    }
449
450    /// Redo the last undone edit. Returns true if redo was performed.
451    pub fn redo(&mut self) -> bool {
452        if let Some(entry) = self.redo_stack.pop() {
453            // Save current state to undo stack
454            self.undo_stack.push(UndoEntry {
455                text: self.text.clone(),
456                cursor_pos: self.cursor_pos,
457                selection_anchor: self.selection_anchor,
458                action_kind: entry.action_kind,
459            });
460            // Restore
461            self.text = entry.text;
462            self.cursor_pos = entry.cursor_pos;
463            self.selection_anchor = entry.selection_anchor;
464            self.reset_blink();
465            true
466        } else {
467            false
468        }
469    }
470
471    /// Move cursor to the start of the current line (Home in multiline mode).
472    pub fn move_line_home(&mut self, shift: bool) {
473        if shift && self.selection_anchor.is_none() {
474            self.selection_anchor = Some(self.cursor_pos);
475        }
476        let target = line_start_char_pos(&self.text, self.cursor_pos);
477        self.cursor_pos = target;
478        if !shift {
479            self.selection_anchor = None;
480        } else if self.selection_anchor == Some(self.cursor_pos) {
481            self.selection_anchor = None;
482        }
483        self.reset_blink();
484    }
485
486    /// Move cursor to the end of the current line (End in multiline mode).
487    pub fn move_line_end(&mut self, shift: bool) {
488        if shift && self.selection_anchor.is_none() {
489            self.selection_anchor = Some(self.cursor_pos);
490        }
491        let target = line_end_char_pos(&self.text, self.cursor_pos);
492        self.cursor_pos = target;
493        if !shift {
494            self.selection_anchor = None;
495        } else if self.selection_anchor == Some(self.cursor_pos) {
496            self.selection_anchor = None;
497        }
498        self.reset_blink();
499    }
500
501    /// Move cursor up one line (multiline only).
502    pub fn move_up(&mut self, shift: bool) {
503        let (line, col) = line_and_column(&self.text, self.cursor_pos);
504        if line == 0 {
505            // Already on first line — move to start
506            if shift && self.selection_anchor.is_none() {
507                self.selection_anchor = Some(self.cursor_pos);
508            }
509            self.cursor_pos = 0;
510            if !shift {
511                self.selection_anchor = None;
512            } else if self.selection_anchor == Some(self.cursor_pos) {
513                self.selection_anchor = None;
514            }
515            self.reset_blink();
516            return;
517        }
518        if shift && self.selection_anchor.is_none() {
519            self.selection_anchor = Some(self.cursor_pos);
520        }
521        self.cursor_pos = char_pos_from_line_col(&self.text, line - 1, col);
522        if !shift {
523            self.selection_anchor = None;
524        } else if self.selection_anchor == Some(self.cursor_pos) {
525            self.selection_anchor = None;
526        }
527        self.reset_blink();
528    }
529
530    /// Move cursor down one line (multiline only).
531    pub fn move_down(&mut self, shift: bool) {
532        let (line, col) = line_and_column(&self.text, self.cursor_pos);
533        let line_count = self.text.chars().filter(|&c| c == '\n').count() + 1;
534        if line >= line_count - 1 {
535            // Already on last line — move to end
536            if shift && self.selection_anchor.is_none() {
537                self.selection_anchor = Some(self.cursor_pos);
538            }
539            self.cursor_pos = self.text.chars().count();
540            if !shift {
541                self.selection_anchor = None;
542            } else if self.selection_anchor == Some(self.cursor_pos) {
543                self.selection_anchor = None;
544            }
545            self.reset_blink();
546            return;
547        }
548        if shift && self.selection_anchor.is_none() {
549            self.selection_anchor = Some(self.cursor_pos);
550        }
551        self.cursor_pos = char_pos_from_line_col(&self.text, line + 1, col);
552        if !shift {
553            self.selection_anchor = None;
554        } else if self.selection_anchor == Some(self.cursor_pos) {
555            self.selection_anchor = None;
556        }
557        self.reset_blink();
558    }
559}
560
561/// When the `text-styling` feature is enabled, these methods operate on `TextEditState`
562/// using visual (display) cursor positions. The internal `text` field contains raw markup,
563/// but `cursor_pos` and `selection_anchor` always represent visual positions.
564#[cfg(feature = "text-styling")]
565impl TextEditState {
566    /// Get the visual length of the text (ignoring markup).
567    fn cursor_len_styled(&self) -> usize {
568        styling::cursor_len(&self.text)
569    }
570
571    /// Get the selected text in visual space, returning the visible chars.
572    pub fn selected_text_styled(&self) -> String {
573        if let Some((start, end)) = self.selection_range() {
574            let stripped = styling::strip_styling(&self.text);
575            let byte_start = char_index_to_byte(&stripped, start);
576            let byte_end = char_index_to_byte(&stripped, end);
577            stripped[byte_start..byte_end].to_string()
578        } else {
579            String::new()
580        }
581    }
582
583    /// Delete selection in styled mode. Returns true if a selection was deleted.
584    pub fn delete_selection_styled(&mut self) -> bool {
585        if let Some((start, end)) = self.selection_range() {
586            if self.no_styles_movement {
587                let start_cp = styling::cursor_to_content(&self.text, start);
588                let end_cp = styling::cursor_to_content(&self.text, end);
589                if start_cp < end_cp {
590                    self.text = styling::delete_content_range(&self.text, start_cp, end_cp);
591                }
592                self.cursor_pos = styling::content_to_cursor(&self.text, start_cp, true);
593            } else {
594                self.text = styling::delete_visual_range(&self.text, start, end);
595                self.cursor_pos = start;
596            }
597            self.selection_anchor = None;
598            // Cleanup empty styles (cursor is at start)
599            let (cleaned, new_pos) = styling::cleanup_empty_styles(&self.text, self.cursor_pos);
600            self.text = cleaned;
601            self.cursor_pos = new_pos;
602            self.snap_to_content_pos();
603            // If no visible content remains after deletion, clear entirely
604            if styling::strip_styling(&self.text).is_empty() {
605                self.text = String::new();
606                self.cursor_pos = 0;
607            }
608            true
609        } else {
610            false
611        }
612    }
613
614    /// Insert text at visual cursor position in styled mode, with escaping.
615    /// The input `s` should already be escaped if needed.
616    pub fn insert_text_styled(&mut self, s: &str, max_length: Option<usize>) {
617        self.delete_selection_styled();
618        let visual_count = self.cursor_len_styled();
619        let insert_cursor_len = styling::cursor_len(s);
620        let allowed = if let Some(max) = max_length {
621            if visual_count >= max {
622                0
623            } else {
624                insert_cursor_len.min(max - visual_count)
625            }
626        } else {
627            insert_cursor_len
628        };
629        if allowed == 0 {
630            return;
631        }
632        // If we need to truncate the insertion, work on the visual chars
633        let insert_str = if allowed < insert_cursor_len {
634            // Build truncated escaped string
635            let stripped = styling::strip_styling(s);
636            let truncated: String = stripped.chars().take(allowed).collect();
637            styling::escape_str(&truncated)
638        } else {
639            s.to_string()
640        };
641        let (new_text, new_cursor) = styling::insert_at_visual(&self.text, self.cursor_pos, &insert_str);
642        self.text = new_text;
643        self.cursor_pos = new_cursor;
644        // Clean up any empty style tags the cursor has passed
645        self.cleanup_after_move();
646        self.reset_blink();
647    }
648
649    /// Insert a single typed character in styled mode (auto-escapes).
650    pub fn insert_char_styled(&mut self, ch: char, max_length: Option<usize>) {
651        let escaped = styling::escape_char(ch);
652        self.insert_text_styled(&escaped, max_length);
653    }
654
655    /// Backspace in styled mode: delete visual char before cursor.
656    pub fn backspace_styled(&mut self) {
657        if self.delete_selection_styled() {
658            return;
659        }
660        if self.no_styles_movement {
661            let cp = styling::cursor_to_content(&self.text, self.cursor_pos);
662            if cp > 0 {
663                self.text = styling::delete_content_range(&self.text, cp - 1, cp);
664                self.cursor_pos = styling::content_to_cursor(&self.text, cp - 1, true);
665                let (cleaned, new_pos) = styling::cleanup_empty_styles(&self.text, self.cursor_pos);
666                self.text = cleaned;
667                self.cursor_pos = new_pos;
668                self.snap_to_content_pos();
669            }
670        } else if self.cursor_pos > 0 {
671            self.text = styling::delete_visual_range(&self.text, self.cursor_pos - 1, self.cursor_pos);
672            self.cursor_pos -= 1;
673            let (cleaned, new_pos) = styling::cleanup_empty_styles(&self.text, self.cursor_pos);
674            self.text = cleaned;
675            self.cursor_pos = new_pos;
676        }
677        self.preferred_col = None;
678        self.reset_blink();
679    }
680
681    /// Delete forward in styled mode: delete visual char after cursor.
682    pub fn delete_forward_styled(&mut self) {
683        if self.delete_selection_styled() {
684            return;
685        }
686        if self.no_styles_movement {
687            let cp = styling::cursor_to_content(&self.text, self.cursor_pos);
688            let content_len = styling::strip_styling(&self.text).chars().count();
689            if cp < content_len {
690                self.text = styling::delete_content_range(&self.text, cp, cp + 1);
691                self.cursor_pos = styling::content_to_cursor(&self.text, cp, true);
692                let (cleaned, new_pos) = styling::cleanup_empty_styles(&self.text, self.cursor_pos);
693                self.text = cleaned;
694                self.cursor_pos = new_pos;
695                self.snap_to_content_pos();
696            }
697        } else {
698            let vis_len = self.cursor_len_styled();
699            if self.cursor_pos < vis_len {
700                self.text = styling::delete_visual_range(&self.text, self.cursor_pos, self.cursor_pos + 1);
701                let (cleaned, new_pos) = styling::cleanup_empty_styles(&self.text, self.cursor_pos);
702                self.text = cleaned;
703                self.cursor_pos = new_pos;
704            }
705        }
706        self.preferred_col = None;
707        self.reset_blink();
708    }
709
710    /// Backspace word in styled mode.
711    pub fn backspace_word_styled(&mut self) {
712        if self.delete_selection_styled() {
713            return;
714        }
715        if self.no_styles_movement {
716            let cp = styling::cursor_to_content(&self.text, self.cursor_pos);
717            let stripped = styling::strip_styling(&self.text);
718            let target_cp = find_word_boundary_left(&stripped, cp);
719            if target_cp < cp {
720                self.text = styling::delete_content_range(&self.text, target_cp, cp);
721                self.cursor_pos = styling::content_to_cursor(&self.text, target_cp, true);
722                let (cleaned, new_pos) = styling::cleanup_empty_styles(&self.text, self.cursor_pos);
723                self.text = cleaned;
724                self.cursor_pos = new_pos;
725                self.snap_to_content_pos();
726            }
727        } else {
728            let target = styling::find_word_boundary_left_visual(&self.text, self.cursor_pos);
729            self.text = styling::delete_visual_range(&self.text, target, self.cursor_pos);
730            self.cursor_pos = target;
731            let (cleaned, new_pos) = styling::cleanup_empty_styles(&self.text, self.cursor_pos);
732            self.text = cleaned;
733            self.cursor_pos = new_pos;
734        }
735        self.preferred_col = None;
736        self.reset_blink();
737    }
738
739    /// Delete word forward in styled mode.
740    pub fn delete_word_forward_styled(&mut self) {
741        if self.delete_selection_styled() {
742            return;
743        }
744        if self.no_styles_movement {
745            let cp = styling::cursor_to_content(&self.text, self.cursor_pos);
746            let stripped = styling::strip_styling(&self.text);
747            let target_cp = find_word_delete_boundary_right(&stripped, cp);
748            if target_cp > cp {
749                self.text = styling::delete_content_range(&self.text, cp, target_cp);
750                self.cursor_pos = styling::content_to_cursor(&self.text, cp, true);
751                let (cleaned, new_pos) = styling::cleanup_empty_styles(&self.text, self.cursor_pos);
752                self.text = cleaned;
753                self.cursor_pos = new_pos;
754                self.snap_to_content_pos();
755            }
756        } else {
757            let target = styling::find_word_delete_boundary_right_visual(&self.text, self.cursor_pos);
758            self.text = styling::delete_visual_range(&self.text, self.cursor_pos, target);
759            let (cleaned, new_pos) = styling::cleanup_empty_styles(&self.text, self.cursor_pos);
760            self.text = cleaned;
761            self.cursor_pos = new_pos;
762        }
763        self.preferred_col = None;
764        self.reset_blink();
765    }
766
767    /// Move left in styled mode (visual space).
768    pub fn move_left_styled(&mut self, shift: bool) {
769        if self.no_styles_movement {
770            return self.move_left_content(shift);
771        }
772        if !shift {
773            if let Some((start, _end)) = self.selection_range() {
774                self.cursor_pos = start;
775                self.selection_anchor = None;
776                self.cleanup_after_move();
777                return;
778            }
779        }
780        if self.cursor_pos > 0 {
781            if shift && self.selection_anchor.is_none() {
782                self.selection_anchor = Some(self.cursor_pos);
783            }
784            self.cursor_pos -= 1;
785            if shift {
786                if self.selection_anchor == Some(self.cursor_pos) {
787                    self.selection_anchor = None;
788                }
789            }
790        }
791        if !shift {
792            self.selection_anchor = None;
793        }
794        self.cleanup_after_move();
795    }
796
797    /// Content-based left movement for `no_styles_movement` mode.
798    /// Decrements in content space so the cursor skips structural positions.
799    fn move_left_content(&mut self, shift: bool) {
800        let cp = styling::cursor_to_content(&self.text, self.cursor_pos);
801        if !shift {
802            if let Some((start, _end)) = self.selection_range() {
803                let sc = styling::cursor_to_content(&self.text, start);
804                self.cursor_pos = styling::content_to_cursor(&self.text, sc, true);
805                self.selection_anchor = None;
806                self.cleanup_after_move();
807                return;
808            }
809        }
810        if cp > 0 {
811            if shift && self.selection_anchor.is_none() {
812                self.selection_anchor = Some(self.cursor_pos);
813            }
814            self.cursor_pos = styling::content_to_cursor(&self.text, cp - 1, true);
815            if shift {
816                if self.selection_anchor == Some(self.cursor_pos) {
817                    self.selection_anchor = None;
818                }
819            }
820        }
821        if !shift {
822            self.selection_anchor = None;
823        }
824        self.cleanup_after_move();
825    }
826
827    /// Move right in styled mode (visual space).
828    pub fn move_right_styled(&mut self, shift: bool) {
829        let vis_len = self.cursor_len_styled();
830        if !shift {
831            if let Some((_start, end)) = self.selection_range() {
832                self.cursor_pos = end;
833                self.selection_anchor = None;
834                self.cleanup_after_move();
835                return;
836            }
837        }
838        if self.cursor_pos < vis_len {
839            if shift && self.selection_anchor.is_none() {
840                self.selection_anchor = Some(self.cursor_pos);
841            }
842            self.cursor_pos += 1;
843            if shift {
844                if self.selection_anchor == Some(self.cursor_pos) {
845                    self.selection_anchor = None;
846                }
847            }
848        }
849        if !shift {
850            self.selection_anchor = None;
851        }
852        self.cleanup_after_move();
853    }
854
855    /// Move word left in styled mode.
856    pub fn move_word_left_styled(&mut self, shift: bool) {
857        if shift && self.selection_anchor.is_none() {
858            self.selection_anchor = Some(self.cursor_pos);
859        }
860        self.cursor_pos = styling::find_word_boundary_left_visual(&self.text, self.cursor_pos);
861        if !shift {
862            self.selection_anchor = None;
863        } else if self.selection_anchor == Some(self.cursor_pos) {
864            self.selection_anchor = None;
865        }
866        self.cleanup_after_move();
867    }
868
869    /// Move word right in styled mode.
870    pub fn move_word_right_styled(&mut self, shift: bool) {
871        if shift && self.selection_anchor.is_none() {
872            self.selection_anchor = Some(self.cursor_pos);
873        }
874        self.cursor_pos = styling::find_word_boundary_right_visual(&self.text, self.cursor_pos);
875        if !shift {
876            self.selection_anchor = None;
877        } else if self.selection_anchor == Some(self.cursor_pos) {
878            self.selection_anchor = None;
879        }
880        self.cleanup_after_move();
881    }
882
883    /// Move to start in styled mode.
884    pub fn move_home_styled(&mut self, shift: bool) {
885        if shift && self.selection_anchor.is_none() {
886            self.selection_anchor = Some(self.cursor_pos);
887        }
888        self.cursor_pos = 0;
889        if !shift {
890            self.selection_anchor = None;
891        } else if self.selection_anchor == Some(0) {
892            self.selection_anchor = None;
893        }
894        self.cleanup_after_move();
895    }
896
897    /// Move to end in styled mode.
898    pub fn move_end_styled(&mut self, shift: bool) {
899        let vis_len = self.cursor_len_styled();
900        if shift && self.selection_anchor.is_none() {
901            self.selection_anchor = Some(self.cursor_pos);
902        }
903        self.cursor_pos = vis_len;
904        if !shift {
905            self.selection_anchor = None;
906        } else if self.selection_anchor == Some(vis_len) {
907            self.selection_anchor = None;
908        }
909        self.cleanup_after_move();
910    }
911
912    /// Move cursor up one visual line in styled mode.
913    /// If `visual_lines` is provided (multiline with wrapping), uses them for navigation.
914    /// Otherwise falls back to simple line-based movement.
915    pub fn move_up_styled(&mut self, shift: bool, visual_lines: Option<&[VisualLine]>) {
916        if shift && self.selection_anchor.is_none() {
917            self.selection_anchor = Some(self.cursor_pos);
918        }
919
920        let raw_cursor = styling::cursor_to_raw_for_insertion(&self.text, self.cursor_pos);
921
922        if let Some(vl) = visual_lines {
923            let (line_idx, _raw_col) = cursor_to_visual_pos(vl, raw_cursor);
924
925            // Compute column in content space (visible characters only)
926            // so structural chars like `}` don't offset the column.
927            let line_start_visual = styling::raw_to_cursor(&self.text, vl[line_idx].global_char_start);
928            let content_start = styling::cursor_to_content(&self.text, line_start_visual);
929            let content_current = styling::cursor_to_content(&self.text, self.cursor_pos);
930            let current_col = content_current.saturating_sub(content_start);
931            let col = self.preferred_col.unwrap_or(current_col);
932
933            if line_idx == 0 {
934                // Already on first line — move to start
935                self.cursor_pos = 0;
936            } else {
937                let target = &vl[line_idx - 1];
938                let target_start_visual = styling::raw_to_cursor(&self.text, target.global_char_start);
939                let target_end_visual = styling::raw_to_cursor(
940                    &self.text,
941                    target.global_char_start + target.char_count,
942                );
943                let target_content_start = styling::cursor_to_content(&self.text, target_start_visual);
944                let target_content_end = styling::cursor_to_content(&self.text, target_end_visual);
945                let target_content_len = target_content_end - target_content_start;
946                let target_col = col.min(target_content_len);
947                self.cursor_pos = styling::content_to_cursor(&self.text, target_content_start + target_col, false);
948            }
949
950            self.preferred_col = Some(col);
951        } else {
952            // Simple line-based movement for non-multiline
953            let (line, _col) = styling::line_and_column_styled(&self.text, self.cursor_pos);
954            let col = self.preferred_col.unwrap_or({
955                let line_start = styling::line_start_visual_styled(&self.text, line);
956                let content_start = styling::cursor_to_content(&self.text, line_start);
957                let content_current = styling::cursor_to_content(&self.text, self.cursor_pos);
958                content_current.saturating_sub(content_start)
959            });
960
961            if line == 0 {
962                self.cursor_pos = 0;
963            } else {
964                let target_start = styling::line_start_visual_styled(&self.text, line - 1);
965                let target_end = styling::line_end_visual_styled(&self.text, line - 1);
966                let target_content_start = styling::cursor_to_content(&self.text, target_start);
967                let target_content_end = styling::cursor_to_content(&self.text, target_end);
968                let target_content_len = target_content_end - target_content_start;
969                let target_col = col.min(target_content_len);
970                self.cursor_pos = styling::content_to_cursor(&self.text, target_content_start + target_col, false);
971            }
972
973            self.preferred_col = Some(col);
974        }
975
976        if !shift {
977            self.selection_anchor = None;
978        } else if self.selection_anchor == Some(self.cursor_pos) {
979            self.selection_anchor = None;
980        }
981        self.reset_blink();
982    }
983
984    /// Move cursor down one visual line in styled mode.
985    pub fn move_down_styled(&mut self, shift: bool, visual_lines: Option<&[VisualLine]>) {
986        if shift && self.selection_anchor.is_none() {
987            self.selection_anchor = Some(self.cursor_pos);
988        }
989
990        let vis_len = self.cursor_len_styled();
991        let raw_cursor = styling::cursor_to_raw_for_insertion(&self.text, self.cursor_pos);
992
993        if let Some(vl) = visual_lines {
994            let (line_idx, _raw_col) = cursor_to_visual_pos(vl, raw_cursor);
995
996            // Compute column in content space (visible characters only)
997            let line_start_visual = styling::raw_to_cursor(&self.text, vl[line_idx].global_char_start);
998            let content_start = styling::cursor_to_content(&self.text, line_start_visual);
999            let content_current = styling::cursor_to_content(&self.text, self.cursor_pos);
1000            let current_col = content_current.saturating_sub(content_start);
1001            let col = self.preferred_col.unwrap_or(current_col);
1002
1003            if line_idx >= vl.len() - 1 {
1004                // Already on last line — move to end
1005                self.cursor_pos = vis_len;
1006            } else {
1007                let target = &vl[line_idx + 1];
1008                let target_start_visual = styling::raw_to_cursor(&self.text, target.global_char_start);
1009                let target_end_visual = styling::raw_to_cursor(
1010                    &self.text,
1011                    target.global_char_start + target.char_count,
1012                );
1013                let target_content_start = styling::cursor_to_content(&self.text, target_start_visual);
1014                let target_content_end = styling::cursor_to_content(&self.text, target_end_visual);
1015                let target_content_len = target_content_end - target_content_start;
1016                let target_col = col.min(target_content_len);
1017                self.cursor_pos = styling::content_to_cursor(&self.text, target_content_start + target_col, false);
1018            }
1019
1020            self.preferred_col = Some(col);
1021        } else {
1022            // Simple line-based movement
1023            let (line, _col) = styling::line_and_column_styled(&self.text, self.cursor_pos);
1024            let line_count = styling::styled_line_count(&self.text);
1025            let col = self.preferred_col.unwrap_or({
1026                let line_start = styling::line_start_visual_styled(&self.text, line);
1027                let content_start = styling::cursor_to_content(&self.text, line_start);
1028                let content_current = styling::cursor_to_content(&self.text, self.cursor_pos);
1029                content_current.saturating_sub(content_start)
1030            });
1031
1032            if line >= line_count - 1 {
1033                self.cursor_pos = vis_len;
1034            } else {
1035                let target_start = styling::line_start_visual_styled(&self.text, line + 1);
1036                let target_end = styling::line_end_visual_styled(&self.text, line + 1);
1037                let target_content_start = styling::cursor_to_content(&self.text, target_start);
1038                let target_content_end = styling::cursor_to_content(&self.text, target_end);
1039                let target_content_len = target_content_end - target_content_start;
1040                let target_col = col.min(target_content_len);
1041                self.cursor_pos = styling::content_to_cursor(&self.text, target_content_start + target_col, false);
1042            }
1043
1044            self.preferred_col = Some(col);
1045        }
1046
1047        if !shift {
1048            self.selection_anchor = None;
1049        } else if self.selection_anchor == Some(self.cursor_pos) {
1050            self.selection_anchor = None;
1051        }
1052        self.reset_blink();
1053    }
1054
1055    /// Select all in styled mode.
1056    pub fn select_all_styled(&mut self) {
1057        let vis_len = self.cursor_len_styled();
1058        if vis_len > 0 {
1059            self.selection_anchor = Some(0);
1060            self.cursor_pos = vis_len;
1061            self.snap_to_content_pos();
1062        }
1063        self.reset_blink();
1064    }
1065
1066    /// Click to cursor in styled mode.
1067    /// `click_visual_pos` is the visual character position determined from click x.
1068    pub fn click_to_cursor_styled(&mut self, click_visual_pos: usize, shift: bool) {
1069        if shift {
1070            if self.selection_anchor.is_none() {
1071                self.selection_anchor = Some(self.cursor_pos);
1072            }
1073        } else {
1074            self.selection_anchor = None;
1075        }
1076        self.cursor_pos = click_visual_pos;
1077        if shift {
1078            if self.selection_anchor == Some(self.cursor_pos) {
1079                self.selection_anchor = None;
1080            }
1081        }
1082        self.cleanup_after_move();
1083    }
1084
1085    /// Select word at visual position in styled mode.
1086    pub fn select_word_at_styled(&mut self, visual_pos: usize) {
1087        let (start, end) = styling::find_word_at_visual(&self.text, visual_pos);
1088        if start != end {
1089            self.selection_anchor = Some(start);
1090            self.cursor_pos = end;
1091            self.snap_to_content_pos();
1092        }
1093        self.reset_blink();
1094    }
1095
1096    /// Snap `cursor_pos` (and `selection_anchor`) so they only land on
1097    /// visible-character boundaries, skipping `}` and empty-content markers.
1098    /// No-op when `no_styles_movement` is false.
1099    fn snap_to_content_pos(&mut self) {
1100        if !self.no_styles_movement { return; }
1101        let cp = styling::cursor_to_content(&self.text, self.cursor_pos);
1102        self.cursor_pos = styling::content_to_cursor(&self.text, cp, true);
1103        if let Some(anchor) = self.selection_anchor {
1104            let ac = styling::cursor_to_content(&self.text, anchor);
1105            self.selection_anchor = Some(
1106                styling::content_to_cursor(&self.text, ac, true),
1107            );
1108            if self.selection_anchor == Some(self.cursor_pos) {
1109                self.selection_anchor = None;
1110            }
1111        }
1112    }
1113
1114    /// Cleanup empty styles after a cursor movement.
1115    /// Called after any movement that doesn't modify text.
1116    fn cleanup_after_move(&mut self) {
1117        // Snap first so the cursor moves away from structural positions;
1118        // this lets cleanup_empty_styles remove tags the cursor isn't inside.
1119        self.snap_to_content_pos();
1120        let (cleaned, new_pos) = styling::cleanup_empty_styles(&self.text, self.cursor_pos);
1121        self.text = cleaned;
1122        self.cursor_pos = new_pos;
1123        // Re-snap after cleanup since the text may have changed.
1124        self.snap_to_content_pos();
1125        self.preferred_col = None;
1126        self.reset_blink();
1127    }
1128
1129    /// Convert the visual cursor_pos to a raw position for rendering.
1130    /// Enters empty style tags at the cursor boundary.
1131    pub fn cursor_pos_raw(&self) -> usize {
1132        styling::cursor_to_raw_for_insertion(&self.text, self.cursor_pos)
1133    }
1134
1135    /// Convert the visual selection_anchor to a raw position for rendering.
1136    pub fn selection_anchor_raw(&self) -> Option<usize> {
1137        self.selection_anchor.map(|a| styling::cursor_to_raw(&self.text, a))
1138    }
1139
1140    /// Get the selection range in raw positions for rendering.
1141    pub fn selection_range_raw(&self) -> Option<(usize, usize)> {
1142        self.selection_anchor.map(|anchor| {
1143            let raw_anchor = styling::cursor_to_raw(&self.text, anchor);
1144            let raw_cursor = styling::cursor_to_raw(&self.text, self.cursor_pos);
1145            let start = raw_anchor.min(raw_cursor);
1146            let end = raw_anchor.max(raw_cursor);
1147            (start, end)
1148        })
1149    }
1150}
1151
1152/// Configuration for a text input element's visual appearance.
1153/// Stored per-frame in `PlyContext::text_input_configs`.
1154#[derive(Debug, Clone)]
1155pub struct TextInputConfig {
1156    /// Placeholder text shown when input is empty.
1157    pub placeholder: String,
1158    /// Maximum number of characters allowed. `None` = unlimited.
1159    pub max_length: Option<usize>,
1160    /// When true, characters are displayed as `•`.
1161    pub is_password: bool,
1162    /// When true, the input supports multiple lines (Enter inserts newline).
1163    pub is_multiline: bool,
1164    /// Font size in pixels.
1165    pub font_size: u16,
1166    /// Color of the input text.
1167    pub text_color: Color,
1168    /// Color of the placeholder text.
1169    pub placeholder_color: Color,
1170    /// Color of the cursor line.
1171    pub cursor_color: Color,
1172    /// Color of the selection highlight rectangle.
1173    pub selection_color: Color,
1174    /// Override line height in pixels. When 0 (default), the natural font height is used.
1175    pub line_height: u16,
1176    /// When true, cursor movement skips over `}` and empty content style positions.
1177    pub no_styles_movement: bool,
1178    /// The font asset to use. Resolved by the renderer.
1179    pub font_asset: Option<&'static crate::renderer::FontAsset>,
1180}
1181
1182impl Default for TextInputConfig {
1183    fn default() -> Self {
1184        Self {
1185            placeholder: String::new(),
1186            max_length: None,
1187            is_password: false,
1188            is_multiline: false,
1189            font_size: 0,
1190            text_color: Color::rgba(255.0, 255.0, 255.0, 255.0),
1191            placeholder_color: Color::rgba(128.0, 128.0, 128.0, 255.0),
1192            cursor_color: Color::rgba(255.0, 255.0, 255.0, 255.0),
1193            selection_color: Color::rgba(69.0, 130.0, 181.0, 128.0),
1194            line_height: 0,
1195            no_styles_movement: false,
1196            font_asset: None,
1197        }
1198    }
1199}
1200
1201/// Builder for configuring a text input element via closure.
1202pub struct TextInputBuilder {
1203    pub(crate) config: TextInputConfig,
1204    pub(crate) on_changed_fn: Option<Box<dyn FnMut(&str) + 'static>>,
1205    pub(crate) on_submit_fn: Option<Box<dyn FnMut(&str) + 'static>>,
1206}
1207
1208impl TextInputBuilder {
1209    pub(crate) fn new() -> Self {
1210        Self {
1211            config: TextInputConfig::default(),
1212            on_changed_fn: None,
1213            on_submit_fn: None,
1214        }
1215    }
1216
1217    /// Sets the placeholder text shown when the input is empty.
1218    #[inline]
1219    pub fn placeholder(&mut self, text: &str) -> &mut Self {
1220        self.config.placeholder = text.to_string();
1221        self
1222    }
1223
1224    /// Sets the maximum number of characters allowed.
1225    #[inline]
1226    pub fn max_length(&mut self, len: usize) -> &mut Self {
1227        self.config.max_length = Some(len);
1228        self
1229    }
1230
1231    /// Enables password mode (characters shown as dots).
1232    #[inline]
1233    pub fn password(&mut self, enabled: bool) -> &mut Self {
1234        self.config.is_password = enabled;
1235        self
1236    }
1237
1238    /// Enables multiline mode (Enter inserts newline, up/down arrows navigate lines).
1239    #[inline]
1240    pub fn multiline(&mut self, enabled: bool) -> &mut Self {
1241        self.config.is_multiline = enabled;
1242        self
1243    }
1244
1245    /// Sets the font to use for this text input.
1246    ///
1247    /// The font is loaded asynchronously during rendering.
1248    #[inline]
1249    pub fn font(&mut self, asset: &'static crate::renderer::FontAsset) -> &mut Self {
1250        self.config.font_asset = Some(asset);
1251        self
1252    }
1253
1254    /// Sets the font size.
1255    #[inline]
1256    pub fn font_size(&mut self, size: u16) -> &mut Self {
1257        self.config.font_size = size;
1258        self
1259    }
1260
1261    /// Sets the text color.
1262    #[inline]
1263    pub fn text_color(&mut self, color: impl Into<Color>) -> &mut Self {
1264        self.config.text_color = color.into();
1265        self
1266    }
1267
1268    /// Sets the placeholder text color.
1269    #[inline]
1270    pub fn placeholder_color(&mut self, color: impl Into<Color>) -> &mut Self {
1271        self.config.placeholder_color = color.into();
1272        self
1273    }
1274
1275    /// Sets the cursor color.
1276    #[inline]
1277    pub fn cursor_color(&mut self, color: impl Into<Color>) -> &mut Self {
1278        self.config.cursor_color = color.into();
1279        self
1280    }
1281
1282    /// Sets the selection highlight color.
1283    #[inline]
1284    pub fn selection_color(&mut self, color: impl Into<Color>) -> &mut Self {
1285        self.config.selection_color = color.into();
1286        self
1287    }
1288
1289    /// Sets the line height in pixels for multiline inputs.
1290    ///
1291    /// When set to a value greater than 0, this overrides the natural font
1292    /// height for spacing between lines. Text is vertically centred within
1293    /// each line slot. A value of 0 (default) uses the natural font height.
1294    #[inline]
1295    pub fn line_height(&mut self, height: u16) -> &mut Self {
1296        self.config.line_height = height;
1297        self
1298    }
1299
1300    /// Enables no-styles movement mode.
1301    /// When enabled, cursor navigation skips over `}` exit positions and
1302    /// empty content markers, so the cursor only stops at visible character
1303    /// boundaries. Useful for live-highlighted text inputs where the user
1304    /// should not navigate through invisible style markup.
1305    #[inline]
1306    pub fn no_styles_movement(&mut self) -> &mut Self {
1307        self.config.no_styles_movement = true;
1308        self
1309    }
1310
1311    /// Registers a callback fired whenever the text content changes.
1312    #[inline]
1313    pub fn on_changed<F>(&mut self, callback: F) -> &mut Self
1314    where
1315        F: FnMut(&str) + 'static,
1316    {
1317        self.on_changed_fn = Some(Box::new(callback));
1318        self
1319    }
1320
1321    /// Registers a callback fired when the user presses Enter.
1322    #[inline]
1323    pub fn on_submit<F>(&mut self, callback: F) -> &mut Self
1324    where
1325        F: FnMut(&str) + 'static,
1326    {
1327        self.on_submit_fn = Some(Box::new(callback));
1328        self
1329    }
1330}
1331
1332/// Convert a character index to a byte index in the string.
1333pub fn char_index_to_byte(s: &str, char_idx: usize) -> usize {
1334    s.char_indices()
1335        .nth(char_idx)
1336        .map(|(byte_pos, _)| byte_pos)
1337        .unwrap_or(s.len())
1338}
1339
1340/// Find the char index of the start of the line containing `char_pos`.
1341/// A "line" is delimited by '\n'. Returns 0 for the first line.
1342pub fn line_start_char_pos(text: &str, char_pos: usize) -> usize {
1343    let chars: Vec<char> = text.chars().collect();
1344    let mut i = char_pos;
1345    while i > 0 && chars[i - 1] != '\n' {
1346        i -= 1;
1347    }
1348    i
1349}
1350
1351/// Find the char index of the end of the line containing `char_pos`.
1352/// Returns the position just before the '\n' or at text end.
1353pub fn line_end_char_pos(text: &str, char_pos: usize) -> usize {
1354    let chars: Vec<char> = text.chars().collect();
1355    let len = chars.len();
1356    let mut i = char_pos;
1357    while i < len && chars[i] != '\n' {
1358        i += 1;
1359    }
1360    i
1361}
1362
1363/// Returns (line_index, column) for a given char position.
1364/// Lines are 0-indexed, split by '\n'.
1365pub fn line_and_column(text: &str, char_pos: usize) -> (usize, usize) {
1366    let mut line = 0;
1367    let mut col = 0;
1368    for (i, ch) in text.chars().enumerate() {
1369        if i == char_pos {
1370            return (line, col);
1371        }
1372        if ch == '\n' {
1373            line += 1;
1374            col = 0;
1375        } else {
1376            col += 1;
1377        }
1378    }
1379    (line, col)
1380}
1381
1382/// Convert a (line, column) pair to a character position.
1383/// If the column exceeds the line length, clamps to line end.
1384pub fn char_pos_from_line_col(text: &str, target_line: usize, target_col: usize) -> usize {
1385    let mut line = 0;
1386    let mut col = 0;
1387    for (i, ch) in text.chars().enumerate() {
1388        if line == target_line && col == target_col {
1389            return i;
1390        }
1391        if ch == '\n' {
1392            if line == target_line {
1393                // Column exceeds this line length; return end of this line
1394                return i;
1395            }
1396            line += 1;
1397            col = 0;
1398        } else {
1399            col += 1;
1400        }
1401    }
1402    // If target is beyond text, return text length
1403    text.chars().count()
1404}
1405
1406/// Split text into lines (by '\n'), returning each line's text
1407/// and the global char index where it starts.
1408pub fn split_lines(text: &str) -> Vec<(usize, &str)> {
1409    let mut result = Vec::new();
1410    let mut char_start = 0;
1411    let mut byte_start = 0;
1412    for (byte_idx, ch) in text.char_indices() {
1413        if ch == '\n' {
1414            result.push((char_start, &text[byte_start..byte_idx]));
1415            char_start += text[byte_start..byte_idx].chars().count() + 1; // +1 for '\n'
1416            byte_start = byte_idx + 1; // '\n' is 1 byte
1417        }
1418    }
1419    // Last line (after final '\n' or entire text if no '\n')
1420    result.push((char_start, &text[byte_start..]));
1421    result
1422}
1423
1424/// A single visual line after word-wrapping.
1425#[derive(Debug, Clone)]
1426pub struct VisualLine {
1427    /// The text content of this visual line.
1428    pub text: String,
1429    /// The global character index where this visual line starts in the full text.
1430    pub global_char_start: usize,
1431    /// Number of characters in this visual line.
1432    pub char_count: usize,
1433}
1434
1435/// Word-wrap text into visual lines that fit within `max_width`.
1436/// Splits on '\n' first (hard breaks), then wraps long lines at word boundaries.
1437/// If `max_width <= 0`, no wrapping occurs (equivalent to `split_lines`).
1438pub fn wrap_lines(
1439    text: &str,
1440    max_width: f32,
1441    font_asset: Option<&'static crate::renderer::FontAsset>,
1442    font_size: u16,
1443    measure_fn: &dyn Fn(&str, &crate::text::TextConfig) -> crate::math::Dimensions,
1444) -> Vec<VisualLine> {
1445    let config = crate::text::TextConfig {
1446        font_asset,
1447        font_size,
1448        ..Default::default()
1449    };
1450
1451    let hard_lines = split_lines(text);
1452    let mut result = Vec::new();
1453
1454    for (global_start, line_text) in hard_lines {
1455        if line_text.is_empty() {
1456            result.push(VisualLine {
1457                text: String::new(),
1458                global_char_start: global_start,
1459                char_count: 0,
1460            });
1461            continue;
1462        }
1463
1464        if max_width <= 0.0 {
1465            // No wrapping
1466            result.push(VisualLine {
1467                text: line_text.to_string(),
1468                global_char_start: global_start,
1469                char_count: line_text.chars().count(),
1470            });
1471            continue;
1472        }
1473
1474        // Check if the whole line fits
1475        let full_width = measure_fn(line_text, &config).width;
1476        if full_width <= max_width {
1477            result.push(VisualLine {
1478                text: line_text.to_string(),
1479                global_char_start: global_start,
1480                char_count: line_text.chars().count(),
1481            });
1482            continue;
1483        }
1484
1485        // Need to wrap this line
1486        let chars: Vec<char> = line_text.chars().collect();
1487        let total_chars = chars.len();
1488        let mut line_char_start = 0; // index within chars[]
1489
1490        while line_char_start < total_chars {
1491            // Find how many characters fit in max_width
1492            let mut fit_count = 0;
1493
1494            #[cfg(feature = "text-styling")]
1495            {
1496                // Styling-aware measurement: skip calling measure_fn for substrings that
1497                // end inside a tag header ({name|) to avoid warnings from incomplete tags.
1498                // Tag header chars have zero visible width, so advancing fit_count is safe.
1499                let mut in_tag_hdr = false;
1500                let mut escaped = false;
1501                for i in 1..=(total_chars - line_char_start) {
1502                    let ch = chars[line_char_start + i - 1];
1503                    if escaped {
1504                        escaped = false;
1505                        // Escaped char is visible: measure
1506                        let substr: String = chars[line_char_start..line_char_start + i].iter().collect();
1507                        let w = measure_fn(&substr, &config).width;
1508                        if w > max_width { break; }
1509                        fit_count = i;
1510                        continue;
1511                    }
1512                    match ch {
1513                        '\\' => { escaped = true; /* don't update fit_count: \ and next char are atomic */ }
1514                        '{' => { in_tag_hdr = true; fit_count = i; }
1515                        '|' if in_tag_hdr => { in_tag_hdr = false; fit_count = i; }
1516                        '}' => { fit_count = i; }
1517                        _ if in_tag_hdr => { fit_count = i; }
1518                        _ => {
1519                            // Visible char: measure the substring
1520                            let substr: String = chars[line_char_start..line_char_start + i].iter().collect();
1521                            let w = measure_fn(&substr, &config).width;
1522                            if w > max_width { break; }
1523                            fit_count = i;
1524                        }
1525                    }
1526                }
1527            }
1528
1529            #[cfg(not(feature = "text-styling"))]
1530            {
1531                for i in 1..=(total_chars - line_char_start) {
1532                    let substr: String = chars[line_char_start..line_char_start + i].iter().collect();
1533                    let w = measure_fn(&substr, &config).width;
1534                    if w > max_width {
1535                        break;
1536                    }
1537                    fit_count = i;
1538                }
1539            }
1540
1541            if fit_count == 0 {
1542                // Even a single character doesn't fit; force at least one visible unit.
1543                // If the first char is a backslash (escape), include the next char too
1544                // so we never split an escape sequence across lines.
1545                #[cfg(feature = "text-styling")]
1546                if chars[line_char_start] == '\\' && line_char_start + 2 <= total_chars {
1547                    fit_count = 2;
1548                } else {
1549                    fit_count = 1;
1550                }
1551                #[cfg(not(feature = "text-styling"))]
1552                {
1553                    fit_count = 1;
1554                }
1555            }
1556
1557            if line_char_start + fit_count < total_chars {
1558                // Try to break at a word boundary (last space within fit_count)
1559                let mut break_at = fit_count;
1560                let mut found_space = false;
1561                for j in (1..=fit_count).rev() {
1562                    if chars[line_char_start + j - 1] == ' ' {
1563                        break_at = j;
1564                        found_space = true;
1565                        break;
1566                    }
1567                }
1568                // If we found a space, break there; otherwise force character-level break
1569                #[allow(unused_mut)]
1570                let mut wrap_count = if found_space { break_at } else { fit_count };
1571                // Never split an escape sequence (\{, \}, etc.) across lines
1572                #[cfg(feature = "text-styling")]
1573                if wrap_count > 0
1574                    && chars[line_char_start + wrap_count - 1] == '\\'
1575                    && line_char_start + wrap_count < total_chars
1576                {
1577                    if wrap_count > 1 {
1578                        wrap_count -= 1; // back up before the backslash
1579                    } else {
1580                        wrap_count = 2.min(total_chars - line_char_start); // include the escape pair
1581                    }
1582                }
1583                let segment: String = chars[line_char_start..line_char_start + wrap_count].iter().collect();
1584                result.push(VisualLine {
1585                    text: segment,
1586                    global_char_start: global_start + line_char_start,
1587                    char_count: wrap_count,
1588                });
1589                line_char_start += wrap_count;
1590                // Skip leading space on the next line if we broke at a space
1591                if found_space && line_char_start < total_chars && chars[line_char_start] == ' ' {
1592                    // Don't skip — the space is already consumed in the segment above
1593                    // Actually, break_at includes the space. Let's keep it as-is for now.
1594                }
1595            } else {
1596                // Remaining text fits
1597                let segment: String = chars[line_char_start..].iter().collect();
1598                let count = total_chars - line_char_start;
1599                result.push(VisualLine {
1600                    text: segment,
1601                    global_char_start: global_start + line_char_start,
1602                    char_count: count,
1603                });
1604                line_char_start = total_chars;
1605            }
1606        }
1607    }
1608
1609    // Ensure at least one visual line
1610    if result.is_empty() {
1611        result.push(VisualLine {
1612            text: String::new(),
1613            global_char_start: 0,
1614            char_count: 0,
1615        });
1616    }
1617
1618    result
1619}
1620
1621/// Given visual lines and a global cursor position, return (visual_line_index, column_in_visual_line).
1622pub fn cursor_to_visual_pos(visual_lines: &[VisualLine], cursor_pos: usize) -> (usize, usize) {
1623    for (i, vl) in visual_lines.iter().enumerate() {
1624        let line_end = vl.global_char_start + vl.char_count;
1625        if cursor_pos < line_end || i == visual_lines.len() - 1 {
1626            return (i, cursor_pos.saturating_sub(vl.global_char_start));
1627        }
1628        // If cursor_pos == line_end and this isn't the last line, it could be at the
1629        // start of the next line OR the end of this one. For wrapped lines (no \n),
1630        // prefer placing it at the start of the next line.
1631        if cursor_pos == line_end {
1632            // Check if next line continues from this one (wrapped) or is a new paragraph
1633            if i + 1 < visual_lines.len() {
1634                let next = &visual_lines[i + 1];
1635                if next.global_char_start == line_end {
1636                    // Wrapped continuation — cursor goes to start of next visual line
1637                    return (i + 1, 0);
1638                }
1639                // Hard break (\n between them) — cursor at end of this line
1640                return (i, cursor_pos - vl.global_char_start);
1641            }
1642            return (i, cursor_pos - vl.global_char_start);
1643        }
1644    }
1645    (0, 0)
1646}
1647
1648/// Navigate cursor one visual line up. Returns the new global cursor position.
1649/// `col` is the desired column (preserved across up/down moves).
1650pub fn visual_move_up(visual_lines: &[VisualLine], cursor_pos: usize) -> usize {
1651    let (line, col) = cursor_to_visual_pos(visual_lines, cursor_pos);
1652    if line == 0 {
1653        return 0; // Already on first visual line → move to start
1654    }
1655    let target_line = &visual_lines[line - 1];
1656    let new_col = col.min(target_line.char_count);
1657    target_line.global_char_start + new_col
1658}
1659
1660/// Navigate cursor one visual line down. Returns the new global cursor position.
1661pub fn visual_move_down(visual_lines: &[VisualLine], cursor_pos: usize, text_len: usize) -> usize {
1662    let (line, col) = cursor_to_visual_pos(visual_lines, cursor_pos);
1663    if line >= visual_lines.len() - 1 {
1664        return text_len; // Already on last visual line → move to end
1665    }
1666    let target_line = &visual_lines[line + 1];
1667    let new_col = col.min(target_line.char_count);
1668    target_line.global_char_start + new_col
1669}
1670
1671/// Move to start of current visual line. Returns the new global cursor position.
1672pub fn visual_line_home(visual_lines: &[VisualLine], cursor_pos: usize) -> usize {
1673    let (line, _col) = cursor_to_visual_pos(visual_lines, cursor_pos);
1674    visual_lines[line].global_char_start
1675}
1676
1677/// Move to end of current visual line. Returns the new global cursor position.
1678pub fn visual_line_end(visual_lines: &[VisualLine], cursor_pos: usize) -> usize {
1679    let (line, _col) = cursor_to_visual_pos(visual_lines, cursor_pos);
1680    visual_lines[line].global_char_start + visual_lines[line].char_count
1681}
1682
1683/// Find the nearest character boundary for a given pixel x-position.
1684/// `char_x_positions` has len = char_count + 1 (position 0 = left edge, position n = right edge).
1685pub fn find_nearest_char_boundary(click_x: f32, char_x_positions: &[f32]) -> usize {
1686    if char_x_positions.is_empty() {
1687        return 0;
1688    }
1689    let mut best = 0;
1690    let mut best_dist = f32::MAX;
1691    for (i, &x) in char_x_positions.iter().enumerate() {
1692        let dist = (click_x - x).abs();
1693        if dist < best_dist {
1694            best_dist = dist;
1695            best = i;
1696        }
1697    }
1698    best
1699}
1700
1701/// Find the word boundary to the left of `pos` (for Ctrl+Left / Ctrl+Backspace).
1702pub fn find_word_boundary_left(text: &str, pos: usize) -> usize {
1703    if pos == 0 {
1704        return 0;
1705    }
1706    let chars: Vec<char> = text.chars().collect();
1707    let mut i = pos.min(chars.len());
1708    // Skip whitespace to the left of cursor
1709    while i > 0 && chars[i - 1].is_whitespace() {
1710        i -= 1;
1711    }
1712    // Skip word characters to the left
1713    while i > 0 && !chars[i - 1].is_whitespace() {
1714        i -= 1;
1715    }
1716    i
1717}
1718
1719/// Find the word boundary to the right of `pos` (for Ctrl+Right / Ctrl+Delete).
1720/// Skips whitespace first, then stops at the end of the next word.
1721pub fn find_word_boundary_right(text: &str, pos: usize) -> usize {
1722    let chars: Vec<char> = text.chars().collect();
1723    let len = chars.len();
1724    if pos >= len {
1725        return len;
1726    }
1727    let mut i = pos;
1728    // Skip whitespace to the right
1729    while i < len && chars[i].is_whitespace() {
1730        i += 1;
1731    }
1732    // Skip non-whitespace (word) to the right
1733    while i < len && !chars[i].is_whitespace() {
1734        i += 1;
1735    }
1736    i
1737}
1738
1739/// Find the delete boundary to the right of `pos` (for Ctrl+Delete).
1740/// Deletes the current word AND trailing whitespace (skips word → skips spaces).
1741pub fn find_word_delete_boundary_right(text: &str, pos: usize) -> usize {
1742    let chars: Vec<char> = text.chars().collect();
1743    let len = chars.len();
1744    if pos >= len {
1745        return len;
1746    }
1747    let mut i = pos;
1748    // Skip non-whitespace (current word) to the right
1749    while i < len && !chars[i].is_whitespace() {
1750        i += 1;
1751    }
1752    // Skip whitespace to the right
1753    while i < len && chars[i].is_whitespace() {
1754        i += 1;
1755    }
1756    i
1757}
1758
1759/// Find the word boundaries (start, end) at the given character position.
1760/// Used for double-click word selection.
1761pub fn find_word_at(text: &str, pos: usize) -> (usize, usize) {
1762    let chars: Vec<char> = text.chars().collect();
1763    let len = chars.len();
1764    if len == 0 || pos >= len {
1765        return (pos, pos);
1766    }
1767    let is_word_char = |c: char| !c.is_whitespace();
1768    if !is_word_char(chars[pos]) {
1769        // On whitespace — select the whitespace run
1770        let mut start = pos;
1771        while start > 0 && !is_word_char(chars[start - 1]) {
1772            start -= 1;
1773        }
1774        let mut end = pos;
1775        while end < len && !is_word_char(chars[end]) {
1776            end += 1;
1777        }
1778        return (start, end);
1779    }
1780    // On a word char — find word boundaries
1781    let mut start = pos;
1782    while start > 0 && is_word_char(chars[start - 1]) {
1783        start -= 1;
1784    }
1785    let mut end = pos;
1786    while end < len && is_word_char(chars[end]) {
1787        end += 1;
1788    }
1789    (start, end)
1790}
1791
1792/// Build the display text for rendering.
1793/// Returns the string that should be measured/drawn.
1794pub fn display_text(text: &str, placeholder: &str, is_password: bool) -> String {
1795    if text.is_empty() {
1796        return placeholder.to_string();
1797    }
1798    if is_password {
1799        "•".repeat(text.chars().count())
1800    } else {
1801        text.to_string()
1802    }
1803}
1804
1805/// When text-styling is enabled, the raw string contains markup like `{red|...}` and
1806/// escape sequences like `\{`. The user-visible "visual" positions ignore all markup.
1807///
1808/// These helpers convert between visual positions (what the user sees / cursor_pos)
1809/// and raw char positions (byte-level indices into the raw string).
1810///
1811/// Terminology:
1812/// - "raw position" = char index into the full raw string (including markup)
1813/// - "visual position" = char index into the displayed (stripped) text
1814#[cfg(feature = "text-styling")]
1815pub mod styling {
1816    /// Escape a character that would be interpreted as styling markup.
1817    /// Characters `{`, `}`, `|`, and `\` are prefixed with `\`.
1818    pub fn escape_char(ch: char) -> String {
1819        match ch {
1820            '{' | '}' | '|' | '\\' => format!("\\{}", ch),
1821            _ => ch.to_string(),
1822        }
1823    }
1824
1825    /// Escape all styling-significant characters in a string.
1826    pub fn escape_str(s: &str) -> String {
1827        let mut result = String::with_capacity(s.len());
1828        for ch in s.chars() {
1829            match ch {
1830                '{' | '}' | '|' | '\\' => {
1831                    result.push('\\');
1832                    result.push(ch);
1833                }
1834                _ => result.push(ch),
1835            }
1836        }
1837        result
1838    }
1839
1840    /// Convert a visual (display) cursor position to a raw char position.
1841    ///
1842    /// Visual elements include:
1843    /// - Visible characters (escaped chars like `\{` count as one)
1844    /// - `}` closing a style tag (the "exit tag" position)
1845    /// - Empty content area of an empty `{name|}` tag
1846    ///
1847    /// `{name|` headers are transparent and occupy no visual positions.
1848    ///
1849    /// Position 0 always maps to raw 0.
1850    /// For visible char positions, the result is advanced past any following
1851    /// `{name|` headers so the cursor lands inside the tag content.
1852    pub fn cursor_to_raw(raw: &str, visual_pos: usize) -> usize {
1853        if visual_pos == 0 {
1854            return 0;
1855        }
1856
1857        let chars: Vec<char> = raw.chars().collect();
1858        let len = chars.len();
1859        let mut visual = 0usize;
1860        let mut raw_idx = 0usize;
1861        let mut escaped = false;
1862        let mut in_style_def = false;
1863
1864        while raw_idx < len {
1865            let c = chars[raw_idx];
1866
1867            if escaped {
1868                // Escaped char is a visible element
1869                visual += 1;
1870                escaped = false;
1871                raw_idx += 1;
1872                if visual == visual_pos {
1873                    return skip_tag_headers(&chars, raw_idx);
1874                }
1875                continue;
1876            }
1877
1878            match c {
1879                '\\' => {
1880                    escaped = true;
1881                    raw_idx += 1;
1882                }
1883                '{' if !in_style_def => {
1884                    in_style_def = true;
1885                    raw_idx += 1;
1886                }
1887                '|' if in_style_def => {
1888                    in_style_def = false;
1889                    // Check for empty content: if next char is `}`
1890                    if raw_idx + 1 < len && chars[raw_idx + 1] == '}' {
1891                        // Empty content marker — counts as a visual element
1892                        visual += 1;
1893                        raw_idx += 1; // now at `}`
1894                        if visual == visual_pos {
1895                            return raw_idx; // position at `}`, i.e. between `|` and `}`
1896                        }
1897                        // The `}` itself will be processed in the next iteration
1898                    } else {
1899                        raw_idx += 1;
1900                    }
1901                }
1902                '}' if !in_style_def => {
1903                    // Closing brace counts as a visual element (exit tag position)
1904                    visual += 1;
1905                    raw_idx += 1;
1906                    if visual == visual_pos {
1907                        // After `}` — DON'T skip tag headers; cursor is outside the tag
1908                        return raw_idx;
1909                    }
1910                }
1911                _ if in_style_def => {
1912                    raw_idx += 1;
1913                }
1914                _ => {
1915                    // Regular visible character
1916                    visual += 1;
1917                    raw_idx += 1;
1918                    if visual == visual_pos {
1919                        // After visible char — skip past any following `{name|` headers
1920                        // so the cursor lands inside the next tag's content area
1921                        return skip_tag_headers(&chars, raw_idx);
1922                    }
1923                }
1924            }
1925        }
1926
1927        len
1928    }
1929
1930    /// Skip past `{name|` tag headers starting at `pos`.
1931    /// Returns the position after all consecutive tag headers.
1932    fn skip_tag_headers(chars: &[char], pos: usize) -> usize {
1933        let len = chars.len();
1934        let mut p = pos;
1935        while p < len && chars[p] == '{' {
1936            let mut j = p + 1;
1937            while j < len && chars[j] != '|' && chars[j] != '}' {
1938                j += 1;
1939            }
1940            if j < len && chars[j] == '|' {
1941                p = j + 1; // skip past the `|`
1942            } else {
1943                break; // Not a valid tag header
1944            }
1945        }
1946        p
1947    }
1948
1949    /// Convert a raw char position to a visual (display) cursor position.
1950    /// Accounts for `}` and empty content positions.
1951    pub fn raw_to_cursor(raw: &str, raw_pos: usize) -> usize {
1952        let chars: Vec<char> = raw.chars().collect();
1953        let len = chars.len();
1954        let mut visual = 0usize;
1955        let mut raw_idx = 0usize;
1956        let mut escaped = false;
1957        let mut in_style_def = false;
1958
1959        while raw_idx < len && raw_idx < raw_pos {
1960            let c = chars[raw_idx];
1961
1962            if escaped {
1963                visual += 1;
1964                escaped = false;
1965                raw_idx += 1;
1966                continue;
1967            }
1968
1969            match c {
1970                '\\' => {
1971                    escaped = true;
1972                    raw_idx += 1;
1973                }
1974                '{' if !in_style_def => {
1975                    in_style_def = true;
1976                    raw_idx += 1;
1977                }
1978                '|' if in_style_def => {
1979                    in_style_def = false;
1980                    // Check for empty content
1981                    if raw_idx + 1 < len && chars[raw_idx + 1] == '}' {
1982                        visual += 1; // empty content position
1983                        raw_idx += 1; // now at `}`
1984                        // Don't increment raw_idx again; `}` will be processed next
1985                    } else {
1986                        raw_idx += 1;
1987                    }
1988                }
1989                '}' if !in_style_def => {
1990                    visual += 1; // exit tag position
1991                    raw_idx += 1;
1992                }
1993                _ if in_style_def => {
1994                    raw_idx += 1;
1995                }
1996                _ => {
1997                    visual += 1;
1998                    raw_idx += 1;
1999                }
2000            }
2001        }
2002
2003        visual
2004    }
2005
2006    /// Count the total number of visual positions in a raw styled string.
2007    /// Includes visible chars, `}` exit positions, and empty content positions.
2008    pub fn cursor_len(raw: &str) -> usize {
2009        raw_to_cursor(raw, raw.chars().count())
2010    }
2011
2012    /// If `pos` (a raw char index) points to the start of an empty `{name|}` tag,
2013    /// advance into it (returning the position between `|` and `}`).
2014    /// Handles consecutive empty tags by entering each one.
2015    fn enter_empty_tags_at(chars: &[char], pos: usize) -> usize {
2016        let len = chars.len();
2017        let mut p = pos;
2018        while p < len && chars[p] == '{' {
2019            // Scan for `|`
2020            let mut j = p + 1;
2021            while j < len && chars[j] != '|' && chars[j] != '}' {
2022                j += 1;
2023            }
2024            if j < len && chars[j] == '|' {
2025                // Found `{name|`, check if content is empty (next char is `}`)
2026                if j + 1 < len && chars[j + 1] == '}' {
2027                    // Empty tag! Enter it (position between `|` and `}`)
2028                    p = j + 1;
2029                } else {
2030                    break; // Non-empty tag — don't enter
2031                }
2032            } else {
2033                break; // Not a valid style tag
2034            }
2035        }
2036        p
2037    }
2038
2039    /// Like `cursor_to_raw`, but also enters empty style tags at the boundary
2040    /// when the basic position lands right before one.
2041    /// Use this for cursor positioning and single-point insertion.
2042    pub fn cursor_to_raw_for_insertion(raw: &str, visual_pos: usize) -> usize {
2043        let pos = cursor_to_raw(raw, visual_pos);
2044        let chars: Vec<char> = raw.chars().collect();
2045        // If pos lands right at the start of an empty {name|} tag, enter it
2046        enter_empty_tags_at(&chars, pos)
2047    }
2048
2049    /// Insert a (pre-escaped) string at the given visual position in the raw text.
2050    /// Returns the new raw string and the new visual cursor position after insertion.
2051    /// Enters empty style tags at the cursor boundary so typed text goes inside them.
2052    pub fn insert_at_visual(raw: &str, visual_pos: usize, insert: &str) -> (String, usize) {
2053        let raw_pos = cursor_to_raw_for_insertion(raw, visual_pos);
2054        let byte_pos = super::char_index_to_byte(raw, raw_pos);
2055        let mut new_raw = String::with_capacity(raw.len() + insert.len());
2056        new_raw.push_str(&raw[..byte_pos]);
2057        new_raw.push_str(insert);
2058        new_raw.push_str(&raw[byte_pos..]);
2059        let inserted_visual = cursor_len(insert);
2060        (new_raw, visual_pos + inserted_visual)
2061    }
2062
2063    /// Delete visible characters in the visual range `[visual_start, visual_end)`.
2064    /// Preserves all style tag structure (`{name|`, `}`) and only removes the content
2065    /// characters that fall within the visual range.
2066    pub fn delete_visual_range(raw: &str, visual_start: usize, visual_end: usize) -> String {
2067        if visual_start >= visual_end {
2068            return raw.to_string();
2069        }
2070
2071        let chars: Vec<char> = raw.chars().collect();
2072        let len = chars.len();
2073        let mut result = String::with_capacity(raw.len());
2074        let mut visual = 0usize;
2075        let mut i = 0;
2076        let mut in_style_def = false;
2077
2078        while i < len {
2079            let c = chars[i];
2080
2081            match c {
2082                '\\' if !in_style_def && i + 1 < len => {
2083                    // Escaped pair `\X` counts as one visible char
2084                    let in_range = visual >= visual_start && visual < visual_end;
2085                    if !in_range {
2086                        result.push(c);
2087                        result.push(chars[i + 1]);
2088                    }
2089                    visual += 1;
2090                    i += 2;
2091                }
2092                '{' if !in_style_def => {
2093                    in_style_def = true;
2094                    result.push(c); // Always keep tag structure
2095                    i += 1;
2096                }
2097                '|' if in_style_def => {
2098                    in_style_def = false;
2099                    result.push(c);
2100                    // Check for empty content
2101                    if i + 1 < len && chars[i + 1] == '}' {
2102                        visual += 1; // Empty content has a visual position but is structural
2103                    }
2104                    i += 1;
2105                }
2106                '}' if !in_style_def => {
2107                    result.push(c); // Always keep `}`
2108                    visual += 1; // `}` has a visual position but is structural
2109                    i += 1;
2110                }
2111                _ if in_style_def => {
2112                    result.push(c); // Tag name chars — always keep
2113                    i += 1;
2114                }
2115                _ => {
2116                    let in_range = visual >= visual_start && visual < visual_end;
2117                    if !in_range {
2118                        result.push(c);
2119                    }
2120                    visual += 1;
2121                    i += 1;
2122                }
2123            }
2124        }
2125
2126        result
2127    }
2128
2129    /// Remove empty style tags (`{style|}`) from the raw string,
2130    /// EXCEPT those that contain the cursor. A cursor is "inside" an empty
2131    /// style tag if its visual position equals the visual position of that tag's content area.
2132    ///
2133    /// Returns the new raw string and the (possibly adjusted) visual cursor position.
2134    pub fn cleanup_empty_styles(raw: &str, cursor_visual_pos: usize) -> (String, usize) {
2135        let chars: Vec<char> = raw.chars().collect();
2136        let len = chars.len();
2137        let mut result = String::with_capacity(raw.len());
2138        let mut i = 0;
2139        let mut visual = 0usize;
2140        let mut escaped = false;
2141        let mut cursor_adj = cursor_visual_pos;
2142
2143        // We need to track style nesting to correctly identify empty tags
2144        while i < len {
2145            let c = chars[i];
2146
2147            if escaped {
2148                result.push(c);
2149                visual += 1;
2150                escaped = false;
2151                i += 1;
2152                continue;
2153            }
2154
2155            match c {
2156                '\\' => {
2157                    escaped = true;
2158                    result.push(c);
2159                    i += 1;
2160                }
2161                '{' => {
2162                    // Look ahead: find the matching `|`, then check if there's content
2163                    // before the closing `}`. Pattern: `{...| <content> }`
2164                    // Find the `|` that ends this style definition
2165                    let mut j = i + 1;
2166                    let mut style_escaped = false;
2167                    let mut found_pipe = false;
2168                    while j < len {
2169                        if style_escaped {
2170                            style_escaped = false;
2171                            j += 1;
2172                            continue;
2173                        }
2174                        if chars[j] == '\\' {
2175                            style_escaped = true;
2176                            j += 1;
2177                            continue;
2178                        }
2179                        if chars[j] == '|' {
2180                            found_pipe = true;
2181                            j += 1; // j now points to first char after `|`
2182                            break;
2183                        }
2184                        if chars[j] == '{' {
2185                            // Nested `{` inside style def — not valid but push through
2186                            j += 1;
2187                            continue;
2188                        }
2189                        j += 1;
2190                    }
2191
2192                    if !found_pipe {
2193                        // Malformed — just push as-is
2194                        result.push(c);
2195                        i += 1;
2196                        continue;
2197                    }
2198
2199                    // j points to first char after `|`
2200                    // Now scan for the closing `}` and check if there's any visible content
2201                    let _content_start_raw = j;
2202                    let mut k = j;
2203                    let mut content_escaped = false;
2204                    let mut has_visible_content = false;
2205                    let mut nesting = 1; // Track nested style tags
2206                    while k < len && nesting > 0 {
2207                        if content_escaped {
2208                            has_visible_content = true;
2209                            content_escaped = false;
2210                            k += 1;
2211                            continue;
2212                        }
2213                        match chars[k] {
2214                            '\\' => {
2215                                content_escaped = true;
2216                                k += 1;
2217                            }
2218                            '{' => {
2219                                // Nested style opening
2220                                nesting += 1;
2221                                k += 1;
2222                            }
2223                            '}' => {
2224                                nesting -= 1;
2225                                if nesting == 0 {
2226                                    break; // k points to the closing `}`
2227                                }
2228                                k += 1;
2229                            }
2230                            '|' => {
2231                                // Could be pipe inside nested style def
2232                                k += 1;
2233                            }
2234                            _ => {
2235                                has_visible_content = true;
2236                                k += 1;
2237                            }
2238                        }
2239                    }
2240
2241                    if !has_visible_content && nesting == 0 {
2242                        // This is an empty style tag: `{style| <possibly nested empty tags> }`
2243                        // In the new model, empty content is at visual position `visual`
2244                        // and `}` is at visual position `visual + 1`.
2245                        // Keep the tag if cursor is at either position.
2246                        let cursor_is_inside = cursor_visual_pos == visual
2247                            || cursor_visual_pos == visual + 1;
2248                        if cursor_is_inside {
2249                            // Keep the tag — push everything from i to k (inclusive)
2250                            for idx in i..=k {
2251                                result.push(chars[idx]);
2252                            }
2253                            visual += 2; // empty content + }
2254                        } else {
2255                            // Remove the entire tag
2256                            // Adjust cursor if it was after this tag
2257                            if cursor_adj > visual {
2258                                cursor_adj = cursor_adj.saturating_sub(2);
2259                            }
2260                        }
2261                        i = k + 1;
2262                    } else {
2263                        // Non-empty style tag — keep the entire header `{...|`
2264                        // j points to the first char after `|`
2265                        for idx in i..j {
2266                            result.push(chars[idx]);
2267                        }
2268                        // Header is transparent — no visual increment
2269                        i = j;
2270                    }
2271                }
2272                '}' => {
2273                    result.push(c);
2274                    visual += 1; // } has a visual position in the new model
2275                    i += 1;
2276                }
2277                _ => {
2278                    result.push(c);
2279                    visual += 1;
2280                    i += 1;
2281                }
2282            }
2283        }
2284
2285        (result, cursor_adj)
2286    }
2287
2288    /// Get the visual character at a given visual position, or None if past end.
2289    pub fn visual_char_at(raw: &str, visual_pos: usize) -> Option<char> {
2290        let raw_pos = cursor_to_raw(raw, visual_pos);
2291        let chars: Vec<char> = raw.chars().collect();
2292        if raw_pos >= chars.len() {
2293            return None;
2294        }
2295        // If the char at raw_pos is `\`, the visible char is the next one
2296        if chars[raw_pos] == '\\' && raw_pos + 1 < chars.len() {
2297            Some(chars[raw_pos + 1])
2298        } else {
2299            Some(chars[raw_pos])
2300        }
2301    }
2302
2303    /// Strip all styling markup from a raw string, returning only visible text.
2304    pub fn strip_styling(raw: &str) -> String {
2305        let mut result = String::new();
2306        let mut escaped = false;
2307        let mut in_style_def = false;
2308        for c in raw.chars() {
2309            if escaped {
2310                result.push(c);
2311                escaped = false;
2312                continue;
2313            }
2314            match c {
2315                '\\' => { escaped = true; }
2316                '{' if !in_style_def => { in_style_def = true; }
2317                '|' if in_style_def => { in_style_def = false; }
2318                '}' if !in_style_def => { /* closing tag, skip */ }
2319                _ if in_style_def => { /* inside style def, skip */ }
2320                _ => { result.push(c); }
2321            }
2322        }
2323        result
2324    }
2325
2326    /// Convert a "structural visual" position (includes } and empty content markers)
2327    /// to a "content position" (just visible chars, matching strip_styling output).
2328    /// Content position is clamped to stripped text length.
2329    pub fn cursor_to_content(raw: &str, cursor_pos: usize) -> usize {
2330        let chars: Vec<char> = raw.chars().collect();
2331        let len = chars.len();
2332        let mut visual = 0usize;
2333        let mut content = 0usize;
2334        let mut escaped = false;
2335        let mut in_style_def = false;
2336
2337        for i in 0..len {
2338            if visual >= cursor_pos {
2339                break;
2340            }
2341            let c = chars[i];
2342
2343            if escaped {
2344                visual += 1;
2345                content += 1;
2346                escaped = false;
2347                continue;
2348            }
2349
2350            match c {
2351                '\\' => { escaped = true; }
2352                '{' if !in_style_def => { in_style_def = true; }
2353                '|' if in_style_def => {
2354                    in_style_def = false;
2355                    if i + 1 < len && chars[i + 1] == '}' {
2356                        visual += 1; // empty content position (not a real content char)
2357                    }
2358                }
2359                '}' if !in_style_def => {
2360                    visual += 1; // } position (not a real content char)
2361                }
2362                _ if in_style_def => {}
2363                _ => {
2364                    visual += 1;
2365                    content += 1;
2366                }
2367            }
2368        }
2369
2370        content
2371    }
2372
2373    /// Convert a "content position" (from strip_styling output) back to a
2374    /// "structural visual" position (includes } and empty content markers).
2375    ///
2376    /// When `skip_structural` is true, returns the visual position immediately
2377    /// before the `content_pos`-th visible character — or at the end of the
2378    /// visual text when `content_pos` equals the content length.  This means
2379    /// the cursor only ever lands on visible-character boundaries (used by
2380    /// `no_styles_movement`).
2381    pub fn content_to_cursor(raw: &str, content_pos: usize, snap_to_content: bool) -> usize {
2382        let chars: Vec<char> = raw.chars().collect();
2383        let len = chars.len();
2384        let mut visual = 0usize;
2385        let mut content = 0usize;
2386        let mut escaped = false;
2387        let mut in_style_def = false;
2388
2389        if snap_to_content {
2390            // No-structural mode: check `content >= content_pos` BEFORE advancing
2391            for i in 0..len {
2392                let c = chars[i];
2393
2394                if escaped {
2395                    if content >= content_pos {
2396                        return visual;
2397                    }
2398                    visual += 1;
2399                    content += 1;
2400                    escaped = false;
2401                    continue;
2402                }
2403
2404                match c {
2405                    '\\' => { escaped = true; }
2406                    '{' if !in_style_def => { in_style_def = true; }
2407                    '|' if in_style_def => {
2408                        in_style_def = false;
2409                        if i + 1 < len && chars[i + 1] == '}' {
2410                            visual += 1; // empty content marker — skip
2411                        }
2412                    }
2413                    '}' if !in_style_def => {
2414                        visual += 1; // } exit marker — skip
2415                    }
2416                    _ if in_style_def => {}
2417                    _ => {
2418                        if content >= content_pos {
2419                            return visual;
2420                        }
2421                        visual += 1;
2422                        content += 1;
2423                    }
2424                }
2425            }
2426        } else {
2427            // Structural mode: break when `content >= content_pos` at top of loop
2428            for i in 0..len {
2429                if content >= content_pos {
2430                    break;
2431                }
2432                let c = chars[i];
2433
2434                if escaped {
2435                    visual += 1;
2436                    content += 1;
2437                    escaped = false;
2438                    continue;
2439                }
2440
2441                match c {
2442                    '\\' => { escaped = true; }
2443                    '{' if !in_style_def => { in_style_def = true; }
2444                    '|' if in_style_def => {
2445                        in_style_def = false;
2446                        if i + 1 < len && chars[i + 1] == '}' {
2447                            visual += 1; // empty content
2448                        }
2449                    }
2450                    '}' if !in_style_def => {
2451                        visual += 1; // } position
2452                    }
2453                    _ if in_style_def => {}
2454                    _ => {
2455                        visual += 1;
2456                        content += 1;
2457                    }
2458                }
2459            }
2460        }
2461
2462        visual
2463    }
2464
2465    /// Delete content characters in `[content_start, content_end)` from the
2466    /// raw styled string, preserving all structural/tag characters.
2467    pub fn delete_content_range(raw: &str, content_start: usize, content_end: usize) -> String {
2468        if content_start >= content_end {
2469            return raw.to_string();
2470        }
2471
2472        let chars: Vec<char> = raw.chars().collect();
2473        let len = chars.len();
2474        let mut result = String::with_capacity(raw.len());
2475        let mut content = 0usize;
2476        let mut i = 0;
2477        let mut in_style_def = false;
2478
2479        while i < len {
2480            let c = chars[i];
2481
2482            match c {
2483                '\\' if !in_style_def && i + 1 < len => {
2484                    let in_range = content >= content_start && content < content_end;
2485                    if !in_range {
2486                        result.push(c);
2487                        result.push(chars[i + 1]);
2488                    }
2489                    content += 1;
2490                    i += 2;
2491                }
2492                '{' if !in_style_def => {
2493                    in_style_def = true;
2494                    result.push(c);
2495                    i += 1;
2496                }
2497                '|' if in_style_def => {
2498                    in_style_def = false;
2499                    result.push(c);
2500                    i += 1;
2501                }
2502                '}' if !in_style_def => {
2503                    result.push(c);
2504                    i += 1;
2505                }
2506                _ if in_style_def => {
2507                    result.push(c);
2508                    i += 1;
2509                }
2510                _ => {
2511                    let in_range = content >= content_start && content < content_end;
2512                    if !in_range {
2513                        result.push(c);
2514                    }
2515                    content += 1;
2516                    i += 1;
2517                }
2518            }
2519        }
2520
2521        result
2522    }
2523
2524    /// Find word boundary left in visual space.
2525    /// Returns a visual position.
2526    pub fn find_word_boundary_left_visual(raw: &str, visual_pos: usize) -> usize {
2527        let cp = cursor_to_content(raw, visual_pos);
2528        let stripped = strip_styling(raw);
2529        let boundary = super::find_word_boundary_left(&stripped, cp);
2530        content_to_cursor(raw, boundary, false)
2531    }
2532
2533    /// Find word boundary right in visual space.
2534    /// Returns a visual position.
2535    pub fn find_word_boundary_right_visual(raw: &str, visual_pos: usize) -> usize {
2536        let cp = cursor_to_content(raw, visual_pos);
2537        let stripped = strip_styling(raw);
2538        let boundary = super::find_word_boundary_right(&stripped, cp);
2539        content_to_cursor(raw, boundary, false)
2540    }
2541
2542    /// Find word delete boundary right in visual space (skips word then spaces).
2543    /// Used for Ctrl+Delete to delete word + trailing whitespace.
2544    pub fn find_word_delete_boundary_right_visual(raw: &str, visual_pos: usize) -> usize {
2545        let cp = cursor_to_content(raw, visual_pos);
2546        let stripped = strip_styling(raw);
2547        let boundary = super::find_word_delete_boundary_right(&stripped, cp);
2548        content_to_cursor(raw, boundary, false)
2549    }
2550
2551    /// Find word at a visual position (for double-click selection).
2552    /// Returns (start, end) in visual positions.
2553    pub fn find_word_at_visual(raw: &str, visual_pos: usize) -> (usize, usize) {
2554        let cp = cursor_to_content(raw, visual_pos);
2555        let stripped = strip_styling(raw);
2556        let (s, e) = super::find_word_at(&stripped, cp);
2557        (content_to_cursor(raw, s, false), content_to_cursor(raw, e, false))
2558    }
2559
2560    /// Count the number of hard lines (\n-separated) in a styled raw string.
2561    pub fn styled_line_count(raw: &str) -> usize {
2562        // Newlines in the raw text map 1:1 to hard lines regardless of styling
2563        raw.chars().filter(|&c| c == '\n').count() + 1
2564    }
2565
2566    /// Return (line_index, visual_column) for a visual cursor position in styled text.
2567    /// Lines are \n-separated. Visual column is the visual offset from line start.
2568    pub fn line_and_column_styled(raw: &str, visual_pos: usize) -> (usize, usize) {
2569        // Walk through the raw text, tracking visual position and line number.
2570        let chars: Vec<char> = raw.chars().collect();
2571        let len = chars.len();
2572        let mut visual = 0usize;
2573        let mut line = 0usize;
2574        let mut line_start_visual = 0usize;
2575        let mut escaped = false;
2576        let mut in_style_def = false;
2577
2578        for i in 0..len {
2579            if visual >= visual_pos {
2580                break;
2581            }
2582            let c = chars[i];
2583
2584            if escaped {
2585                visual += 1;
2586                escaped = false;
2587                continue;
2588            }
2589
2590            match c {
2591                '\\' => { escaped = true; }
2592                '\n' => {
2593                    visual += 1; // newline is a visible character
2594                    line += 1;
2595                    line_start_visual = visual;
2596                }
2597                '{' if !in_style_def => { in_style_def = true; }
2598                '|' if in_style_def => {
2599                    in_style_def = false;
2600                    if i + 1 < len && chars[i + 1] == '}' {
2601                        visual += 1; // empty content position
2602                    }
2603                }
2604                '}' if !in_style_def => {
2605                    visual += 1;
2606                }
2607                _ if in_style_def => {}
2608                _ => {
2609                    visual += 1;
2610                }
2611            }
2612        }
2613
2614        (line, visual_pos.saturating_sub(line_start_visual))
2615    }
2616
2617    /// Return the visual position of the start of line `line_idx` (0-based).
2618    pub fn line_start_visual_styled(raw: &str, line_idx: usize) -> usize {
2619        if line_idx == 0 {
2620            return 0;
2621        }
2622        let chars: Vec<char> = raw.chars().collect();
2623        let len = chars.len();
2624        let mut visual = 0usize;
2625        let mut line = 0usize;
2626        let mut escaped = false;
2627        let mut in_style_def = false;
2628
2629        for i in 0..len {
2630            let c = chars[i];
2631            if escaped {
2632                visual += 1;
2633                escaped = false;
2634                continue;
2635            }
2636            match c {
2637                '\\' => { escaped = true; }
2638                '\n' => {
2639                    visual += 1;
2640                    line += 1;
2641                    if line == line_idx {
2642                        return visual;
2643                    }
2644                }
2645                '{' if !in_style_def => { in_style_def = true; }
2646                '|' if in_style_def => {
2647                    in_style_def = false;
2648                    if i + 1 < len && chars[i + 1] == '}' {
2649                        visual += 1;
2650                    }
2651                }
2652                '}' if !in_style_def => { visual += 1; }
2653                _ if in_style_def => {}
2654                _ => { visual += 1; }
2655            }
2656        }
2657        visual // past last line
2658    }
2659
2660    /// Return the visual position of the end of line `line_idx` (0-based).
2661    pub fn line_end_visual_styled(raw: &str, line_idx: usize) -> usize {
2662        let chars: Vec<char> = raw.chars().collect();
2663        let len = chars.len();
2664        let mut visual = 0usize;
2665        let mut line = 0usize;
2666        let mut escaped = false;
2667        let mut in_style_def = false;
2668
2669        for i in 0..len {
2670            let c = chars[i];
2671            if escaped {
2672                visual += 1;
2673                escaped = false;
2674                continue;
2675            }
2676            match c {
2677                '\\' => { escaped = true; }
2678                '\n' => {
2679                    if line == line_idx {
2680                        return visual;
2681                    }
2682                    visual += 1;
2683                    line += 1;
2684                }
2685                '{' if !in_style_def => { in_style_def = true; }
2686                '|' if in_style_def => {
2687                    in_style_def = false;
2688                    if i + 1 < len && chars[i + 1] == '}' {
2689                        visual += 1;
2690                    }
2691                }
2692                '}' if !in_style_def => { visual += 1; }
2693                _ if in_style_def => {}
2694                _ => { visual += 1; }
2695            }
2696        }
2697        visual // last line ends at total visual length
2698    }
2699
2700    #[cfg(test)]
2701    mod tests {
2702        use super::*;
2703
2704        #[test]
2705        fn test_escape_char() {
2706            assert_eq!(escape_char('a'), "a");
2707            assert_eq!(escape_char('{'), "\\{");
2708            assert_eq!(escape_char('}'), "\\}");
2709            assert_eq!(escape_char('|'), "\\|");
2710            assert_eq!(escape_char('\\'), "\\\\");
2711        }
2712
2713        #[test]
2714        fn test_escape_str() {
2715            assert_eq!(escape_str("hello"), "hello");
2716            assert_eq!(escape_str("a{b}c"), "a\\{b\\}c");
2717            assert_eq!(escape_str("x|y\\z"), "x\\|y\\\\z");
2718        }
2719
2720        #[test]
2721        fn test_cursor_to_raw_no_styling() {
2722            // Plain text: visual == raw
2723            assert_eq!(cursor_to_raw("hello", 0), 0);
2724            assert_eq!(cursor_to_raw("hello", 3), 3);
2725            assert_eq!(cursor_to_raw("hello", 5), 5);
2726        }
2727
2728        #[test]
2729        fn test_cursor_to_raw_with_escape() {
2730            // "hel\{lo" → visual "hel{lo"
2731            let raw = r"hel\{lo";
2732            assert_eq!(cursor_to_raw(raw, 0), 0); // before h
2733            assert_eq!(cursor_to_raw(raw, 3), 3); // before \{  → raw pos 3 (the \)
2734            assert_eq!(cursor_to_raw(raw, 4), 5); // after { → raw pos 5 (l)
2735            assert_eq!(cursor_to_raw(raw, 5), 6); // after l → raw pos 6 (o)
2736            assert_eq!(cursor_to_raw(raw, 6), 7); // end
2737        }
2738
2739        #[test]
2740        fn test_cursor_to_raw_with_style() {
2741            // "{red|world}" → visual positions: w(1) o(2) r(3) l(4) d(5) }(6)
2742            let raw = "{red|world}";
2743            assert_eq!(cursor_to_raw(raw, 0), 0);  // before tag = raw 0
2744            // Position 0 with skip_tag_headers: raw 0 is the { → skip {red| → raw 5
2745            // But visual_pos == 0 returns raw 0 directly!
2746            assert_eq!(cursor_to_raw(raw, 1), 6);  // after 'w' (raw 5), returns raw 6
2747            assert_eq!(cursor_to_raw(raw, 5), 10); // after 'd' (raw 9), returns raw 10
2748            assert_eq!(cursor_to_raw(raw, 6), 11); // after '}' (raw 10), returns raw 11
2749        }
2750
2751        #[test]
2752        fn test_cursor_to_raw_mixed() {
2753            // "hel\{lo{red|world}" → visual: h(1) e(2) l(3) \{(4) l(5) o(6) w(7) o(8) r(9) l(10) d(11) }(12)
2754            let raw = r"hel\{lo{red|world}";
2755            assert_eq!(cursor_to_raw(raw, 0), 0);  // before everything
2756            assert_eq!(cursor_to_raw(raw, 3), 3);  // after 'l', before \{
2757            assert_eq!(cursor_to_raw(raw, 4), 5);  // after \{, before 'l'
2758            assert_eq!(cursor_to_raw(raw, 6), 12); // after 'o', skip {red| → raw 12 (before 'w')
2759            assert_eq!(cursor_to_raw(raw, 11), 17); // after 'd', before '}'
2760            assert_eq!(cursor_to_raw(raw, 12), 18); // after '}' = end
2761        }
2762
2763        #[test]
2764        fn test_raw_to_cursor_no_styling() {
2765            assert_eq!(raw_to_cursor("hello", 0), 0);
2766            assert_eq!(raw_to_cursor("hello", 3), 3);
2767            assert_eq!(raw_to_cursor("hello", 5), 5);
2768        }
2769
2770        #[test]
2771        fn test_raw_to_cursor_with_escape() {
2772            let raw = r"hel\{lo";
2773            assert_eq!(raw_to_cursor(raw, 0), 0);
2774            assert_eq!(raw_to_cursor(raw, 3), 3); // at the \ 
2775            assert_eq!(raw_to_cursor(raw, 5), 4); // at l after \{
2776            assert_eq!(raw_to_cursor(raw, 7), 6); // end
2777        }
2778
2779        #[test]
2780        fn test_raw_to_cursor_with_style() {
2781            // "{red|world}" → visual: w(1) o(2) r(3) l(4) d(5) }(6)
2782            let raw = "{red|world}";
2783            assert_eq!(raw_to_cursor(raw, 0), 0);
2784            assert_eq!(raw_to_cursor(raw, 5), 0);  // just after {red| (before content starts)
2785            assert_eq!(raw_to_cursor(raw, 6), 1);  // after 'w'
2786            assert_eq!(raw_to_cursor(raw, 10), 5); // after 'd'
2787            assert_eq!(raw_to_cursor(raw, 11), 6); // after '}' — the exit tag position
2788        }
2789
2790        #[test]
2791        fn test_cursor_len() {
2792            assert_eq!(cursor_len("hello"), 5);
2793            assert_eq!(cursor_len("{red|world}"), 6);  // 5 chars + 1 for }
2794            assert_eq!(cursor_len(r"hel\{lo{red|world}"), 12); // 11 chars + 1 for }
2795            assert_eq!(cursor_len(r"\\\{"), 2); // \\ → \, \{ → {
2796            assert_eq!(cursor_len("{red|}"), 2); // empty content + }
2797        }
2798
2799        #[test]
2800        fn test_insert_at_visual() {
2801            let (new, pos) = insert_at_visual("{red|hello}", 3, "XY");
2802            // visual "hello", insert "XY" at pos 3 → "helXYlo"
2803            // raw: {red| + hel + XY + lo + }
2804            assert_eq!(new, "{red|helXYlo}");
2805            assert_eq!(pos, 5);
2806        }
2807
2808        #[test]
2809        fn test_delete_visual_range() {
2810            let new = delete_visual_range("{red|hello}", 1, 3);
2811            // visual "hello", delete visual 1..3 → remove "el" → "hlo"
2812            assert_eq!(new, "{red|hlo}");
2813        }
2814
2815        #[test]
2816        fn test_cleanup_empty_styles_removes_empty() {
2817            let (result, _) = cleanup_empty_styles("{red|}", 999);
2818            assert_eq!(result, ""); // cursor not inside, remove it
2819        }
2820
2821        #[test]
2822        fn test_cleanup_empty_styles_keeps_if_cursor_inside() {
2823            // cursor at visual 0 is "inside" the empty tag at visual 0
2824            let (result, _) = cleanup_empty_styles("{red|}", 0);
2825            assert_eq!(result, "{red|}"); // cursor inside, keep it
2826        }
2827
2828        #[test]
2829        fn test_cleanup_empty_styles_nonempty_kept() {
2830            let (result, _) = cleanup_empty_styles("{red|hello}", 999);
2831            assert_eq!(result, "{red|hello}");
2832        }
2833
2834        #[test]
2835        fn test_cleanup_preserves_text_after_empty() {
2836            // "something{red|}more"
2837            // cursor not at visual position of the empty tag content
2838            let raw = "something{red|}more";
2839            // "something" = 9 visual chars, the tag content is at visual 9
2840            let (result, _) = cleanup_empty_styles(raw, 0); // cursor at 0 = not inside tag
2841            assert_eq!(result, "somethingmore");
2842        }
2843
2844        #[test]
2845        fn test_cleanup_keeps_empty_when_cursor_at_content() {
2846            let raw = "something{red|}more";
2847            // tag content is at visual position 9
2848            let (result, _) = cleanup_empty_styles(raw, 9);
2849            assert_eq!(result, "something{red|}more");
2850        }
2851
2852        #[test]
2853        fn test_cleanup_nonempty_nested_visual_counting() {
2854            // Regression test: cleanup_empty_styles must not inflate visual counter
2855            // when processing non-empty style tag headers like `{color=red|..}`
2856            let raw = "{color=red|hello}world";
2857            // Visual: h(1)e(2)l(3)l(4)o(5) }(6) w(7)o(8)r(9)l(10)d(11)
2858            // Cursor at 11 (end) — cleanup should return same text, cursor at 11
2859            let (result, new_cursor) = cleanup_empty_styles(raw, 11);
2860            assert_eq!(result, raw);
2861            assert_eq!(new_cursor, 11);
2862
2863            // With empty tag after non-empty: "{color=red|hello}{blue|}"
2864            let raw2 = "{color=red|hello}{blue|}";
2865            // Visual: h(1)e(2)l(3)l(4)o(5) }(6) [empty](7) }(8)
2866            // Cursor at 8 (after both), empty tag should be removed
2867            let (result2, new_cursor2) = cleanup_empty_styles(raw2, 8);
2868            assert_eq!(result2, "{color=red|hello}");
2869            // Cursor was at 8, empty tag was at visual 6-7, cursor_adj = 8-2 = 6
2870            assert_eq!(new_cursor2, 6);
2871        }
2872
2873        #[test]
2874        fn test_cleanup_deeply_nested_nonempty() {
2875            // Deeply nested non-empty tags shouldn't inflate visual counter
2876            let raw = "aaa{r|{g|{b|xyz}}}end";
2877            // Visual: a(1)a(2)a(3) x(4)y(5)z(6) }(7) }(8) }(9) e(10)n(11)d(12)
2878            let vl = cursor_len(raw);
2879            assert_eq!(vl, 12);
2880            let (result, new_cursor) = cleanup_empty_styles(raw, vl);
2881            assert_eq!(result, raw);
2882            assert_eq!(new_cursor, vl);
2883        }
2884
2885        #[test]
2886        fn test_word_boundary_visual_nested_tags() {
2887            // Regression test for crash: ctrl+left on text with deeply nested tags
2888            // The cleanup_empty_styles visual inflation bug caused cursor_pos
2889            // to exceed content length, crashing find_word_boundary_left.
2890            let raw = "aaa{r|{r|{r|bbb}}} ccc";
2891            // Visual: a(1)a(2)a(3) b(4)b(5)b(6) }(7) }(8) }(9) (10)c(11)c(12)c(13)
2892            let vl = cursor_len(raw);
2893            assert_eq!(vl, 13);
2894
2895            // Word boundary at end should work
2896            let result = find_word_boundary_left_visual(raw, vl);
2897            assert!(result <= vl, "word boundary should not exceed visual len");
2898
2899            // Word boundary from every visual position should not panic
2900            for v in 0..=vl {
2901                let _ = find_word_boundary_left_visual(raw, v);
2902                let _ = find_word_boundary_right_visual(raw, v);
2903            }
2904        }
2905
2906        #[test]
2907        fn test_word_boundary_visual_after_cleanup() {
2908            // Simulate the crash scenario: text with nested non-empty tags,
2909            // cleanup_empty_styles was inflating visual counter, then word
2910            // boundary was called with the resulting bad cursor position.
2911            let raw = "aaa{color=red|{color=red|bbb}}} ccc";
2912            let vl = cursor_len(raw);
2913            // First, do a cleanup (simulating move_word_left_styled)
2914            let (cleaned, cursor) = cleanup_empty_styles(raw, vl);
2915            let cleaned_vl = cursor_len(&cleaned);
2916            assert!(cursor <= cleaned_vl,
2917                "cursor {} should be <= cursor_len {} after cleanup",
2918                cursor, cleaned_vl);
2919
2920            // Now call word boundary on the cleaned text
2921            let _ = find_word_boundary_left_visual(&cleaned, cursor);
2922        }
2923
2924        #[test]
2925        fn test_roundtrip_visual_raw() {
2926            let raw = r"hel\{lo{red|world}";
2927            // cursor_len = 12 (11 visible chars + 1 for })
2928            for v in 0..=12 {
2929                let r = cursor_to_raw(raw, v);
2930                let v2 = raw_to_cursor(raw, r);
2931                assert_eq!(v, v2, "visual {} → raw {} → visual {} (expected {})", v, r, v2, v);
2932            }
2933        }
2934
2935        #[test]
2936        fn test_cursor_to_raw_for_insertion_enters_empty_tag() {
2937            // "test{red|}" — visual: t(1) e(2) s(3) t(4) [empty content](5) }(6)
2938            let raw = "test{red|}";
2939            // Position 4 skips tag header: raw 4 → skip {red| → raw 9 (inside empty tag)
2940            assert_eq!(cursor_to_raw(raw, 4), 9);
2941            // Position 5 = empty content marker → raw 9
2942            assert_eq!(cursor_to_raw(raw, 5), 9);
2943            // Position 6 = after } → raw 10
2944            assert_eq!(cursor_to_raw(raw, 6), 10);
2945            // Cursor variant at pos 4: cursor_to_raw returns 9, enter_empty_tags finds nothing
2946            assert_eq!(cursor_to_raw_for_insertion(raw, 4), 9);
2947        }
2948
2949        #[test]
2950        fn test_cursor_to_raw_for_insertion_nonempty_tag_not_entered() {
2951            // "test{red|x}" — visual: t(1) e(2) s(3) t(4) x(5) }(6)
2952            let raw = "test{red|x}";
2953            // Position 4 skips tag header: raw 4 → skip {red| → raw 9 (inside tag, before 'x')
2954            assert_eq!(cursor_to_raw(raw, 4), 9);
2955            assert_eq!(cursor_to_raw_for_insertion(raw, 4), 9);
2956        }
2957
2958        #[test]
2959        fn test_cursor_to_raw_for_insertion_at_start() {
2960            // "{red|}hello" — visual: [empty content](1) }(2) h(3) e(4) l(5) l(6) o(7)
2961            let raw = "{red|}hello";
2962            // Position 0 = raw 0 (before everything)
2963            assert_eq!(cursor_to_raw(raw, 0), 0);
2964            // Cursor variant enters the empty tag at raw 0 → {red|} → raw 5
2965            assert_eq!(cursor_to_raw_for_insertion(raw, 0), 5);
2966            // Position 1 = empty content → raw 5
2967            assert_eq!(cursor_to_raw(raw, 1), 5);
2968            // Position 2 = after } → raw 6
2969            assert_eq!(cursor_to_raw(raw, 2), 6);
2970        }
2971
2972        #[test]
2973        fn test_insert_at_visual_enters_empty_tag() {
2974            // Insertion at cursor position should go inside empty tag
2975            let raw = "test{red|}";
2976            let (new, pos) = insert_at_visual(raw, 4, "X");
2977            // X should go inside {red|}, not before it
2978            assert_eq!(new, "test{red|X}");
2979            assert_eq!(pos, 5);
2980        }
2981
2982        #[test]
2983        fn test_insert_at_visual_empty_tag_middle() {
2984            // "hello{red|}world" — insert at visual 5 (between "hello" and "world")
2985            let raw = "hello{red|}world";
2986            let (new, pos) = insert_at_visual(raw, 5, "X");
2987            assert_eq!(new, "hello{red|X}world");
2988            assert_eq!(pos, 6);
2989        }
2990
2991        #[test]
2992        fn test_user_scenario_backspace_to_empty_tag() {
2993            // User's example: "hel\{lo{red|world}" — visual "hel{loworld"
2994            // cursor at visual 11 (end), backspace 5 times to visual 6
2995            // Expected result: "hel\{lo{red|}" with cursor at 6 (inside empty tag)
2996
2997            // Simulate: start with the full text, delete chars at visual 6..11
2998            let raw = r"hel\{lo{red|world}";
2999            // Delete visual range [6, 11) — removes "world"
3000            let after_delete = delete_visual_range(raw, 6, 11);
3001            assert_eq!(after_delete, r"hel\{lo{red|}");
3002
3003            // Cursor is now at visual 6. The {red|} tag is empty.
3004            // cleanup_empty_styles at visual 6: tag content is at visual 6 → keep it
3005            let (cleaned, _) = cleanup_empty_styles(&after_delete, 6);
3006            assert_eq!(cleaned, r"hel\{lo{red|}");
3007
3008            // Typing inside the empty tag: insert "X" at visual 6
3009            let (after_insert, new_pos) = insert_at_visual(&cleaned, 6, "X");
3010            // X should go inside {red|}
3011            assert_eq!(after_insert, r"hel\{lo{red|X}");
3012            assert_eq!(new_pos, 7);
3013
3014            // Moving cursor away (to visual 5): cleanup removes empty tag
3015            // First make it empty again
3016            let empty_again = delete_visual_range(&after_insert, 6, 7);
3017            assert_eq!(empty_again, r"hel\{lo{red|}");
3018            let (after_move, _) = cleanup_empty_styles(&empty_again, 5);
3019            assert_eq!(after_move, r"hel\{lo");
3020        }
3021    }
3022}
3023
3024/// Compute x-positions for each character boundary in the display text.
3025/// Returns a Vec with len = char_count + 1.
3026/// Uses the provided measure function to measure substrings.
3027pub fn compute_char_x_positions(
3028    display_text: &str,
3029    font_asset: Option<&'static crate::renderer::FontAsset>,
3030    font_size: u16,
3031    measure_fn: &dyn Fn(&str, &crate::text::TextConfig) -> crate::math::Dimensions,
3032) -> Vec<f32> {
3033    let char_count = display_text.chars().count();
3034    let mut positions = Vec::with_capacity(char_count + 1);
3035    positions.push(0.0);
3036
3037    let config = crate::text::TextConfig {
3038        font_asset,
3039        font_size,
3040        ..Default::default()
3041    };
3042
3043    #[cfg(feature = "text-styling")]
3044    {
3045        // When text-styling is enabled, the display text contains markup like {red|...}
3046        // and escape sequences like \{. We must avoid measuring prefixes that end inside
3047        // a tag header ({name|) because measure_fn warns on incomplete style definitions.
3048        // For non-visible chars (tag headers, braces, backslashes), reuse the last width.
3049        let chars: Vec<char> = display_text.chars().collect();
3050        let mut in_tag_header = false;
3051        let mut escaped = false;
3052        let mut last_width = 0.0f32;
3053
3054        for i in 0..char_count {
3055            let ch = chars[i];
3056            if escaped {
3057                escaped = false;
3058                // Escaped char is visible: measure the prefix up to this char
3059                let byte_end = char_index_to_byte(display_text, i + 1);
3060                let substr = &display_text[..byte_end];
3061                let dims = measure_fn(substr, &config);
3062                last_width = dims.width;
3063                positions.push(last_width);
3064                continue;
3065            }
3066            match ch {
3067                '\\' => {
3068                    escaped = true;
3069                    // Backslash itself is not visible
3070                    positions.push(last_width);
3071                }
3072                '{' => {
3073                    in_tag_header = true;
3074                    positions.push(last_width);
3075                }
3076                '|' if in_tag_header => {
3077                    in_tag_header = false;
3078                    positions.push(last_width);
3079                }
3080                '}' => {
3081                    // Closing brace is not visible
3082                    positions.push(last_width);
3083                }
3084                _ if in_tag_header => {
3085                    // Inside tag name — not visible
3086                    positions.push(last_width);
3087                }
3088                _ => {
3089                    // Visible character: measure the prefix
3090                    let byte_end = char_index_to_byte(display_text, i + 1);
3091                    let substr = &display_text[..byte_end];
3092                    let dims = measure_fn(substr, &config);
3093                    last_width = dims.width;
3094                    positions.push(last_width);
3095                }
3096            }
3097        }
3098    }
3099
3100    #[cfg(not(feature = "text-styling"))]
3101    {
3102        for i in 1..=char_count {
3103            let byte_end = char_index_to_byte(display_text, i);
3104            let substr = &display_text[..byte_end];
3105            let dims = measure_fn(substr, &config);
3106            positions.push(dims.width);
3107        }
3108    }
3109
3110    positions
3111}
3112
3113#[cfg(test)]
3114mod tests {
3115    use super::*;
3116
3117    #[test]
3118    fn test_char_index_to_byte_ascii() {
3119        let s = "Hello";
3120        assert_eq!(char_index_to_byte(s, 0), 0);
3121        assert_eq!(char_index_to_byte(s, 3), 3);
3122        assert_eq!(char_index_to_byte(s, 5), 5);
3123    }
3124
3125    #[test]
3126    fn test_char_index_to_byte_unicode() {
3127        let s = "Héllo";
3128        assert_eq!(char_index_to_byte(s, 0), 0);
3129        assert_eq!(char_index_to_byte(s, 1), 1); // 'H'
3130        assert_eq!(char_index_to_byte(s, 2), 3); // 'é' is 2 bytes
3131        assert_eq!(char_index_to_byte(s, 5), 6);
3132    }
3133
3134    #[test]
3135    fn test_word_boundary_left() {
3136        assert_eq!(find_word_boundary_left("hello world", 11), 6);
3137        assert_eq!(find_word_boundary_left("hello world", 6), 0); // at start of "world", skip space + "hello"
3138        assert_eq!(find_word_boundary_left("hello world", 5), 0);
3139        assert_eq!(find_word_boundary_left("hello", 0), 0);
3140    }
3141
3142    #[test]
3143    fn test_word_boundary_right() {
3144        assert_eq!(find_word_boundary_right("hello world", 0), 5);  // end of "hello"
3145        assert_eq!(find_word_boundary_right("hello world", 5), 11); // skip space, end of "world"
3146        assert_eq!(find_word_boundary_right("hello world", 6), 11);
3147        assert_eq!(find_word_boundary_right("hello", 5), 5);
3148    }
3149
3150    #[test]
3151    fn test_find_word_at() {
3152        assert_eq!(find_word_at("hello world", 2), (0, 5));
3153        assert_eq!(find_word_at("hello world", 7), (6, 11));
3154        assert_eq!(find_word_at("hello world", 5), (5, 6)); // on space
3155    }
3156
3157    #[test]
3158    fn test_insert_text() {
3159        let mut state = TextEditState::default();
3160        state.insert_text("Hello", None);
3161        assert_eq!(state.text, "Hello");
3162        assert_eq!(state.cursor_pos, 5);
3163
3164        state.cursor_pos = 5;
3165        state.insert_text(" World", None);
3166        assert_eq!(state.text, "Hello World");
3167        assert_eq!(state.cursor_pos, 11);
3168    }
3169
3170    #[test]
3171    fn test_insert_text_max_length() {
3172        let mut state = TextEditState::default();
3173        state.insert_text("Hello World", Some(5));
3174        assert_eq!(state.text, "Hello");
3175        assert_eq!(state.cursor_pos, 5);
3176
3177        // Already at max, no more insertion
3178        state.insert_text("!", Some(5));
3179        assert_eq!(state.text, "Hello");
3180    }
3181
3182    #[test]
3183    fn test_backspace() {
3184        let mut state = TextEditState::default();
3185        state.text = "Hello".to_string();
3186        state.cursor_pos = 5;
3187        state.backspace();
3188        assert_eq!(state.text, "Hell");
3189        assert_eq!(state.cursor_pos, 4);
3190    }
3191
3192    #[test]
3193    fn test_delete_forward() {
3194        let mut state = TextEditState::default();
3195        state.text = "Hello".to_string();
3196        state.cursor_pos = 0;
3197        state.delete_forward();
3198        assert_eq!(state.text, "ello");
3199        assert_eq!(state.cursor_pos, 0);
3200    }
3201
3202    #[test]
3203    fn test_selection_delete() {
3204        let mut state = TextEditState::default();
3205        state.text = "Hello World".to_string();
3206        state.selection_anchor = Some(0);
3207        state.cursor_pos = 5;
3208        state.delete_selection();
3209        assert_eq!(state.text, " World");
3210        assert_eq!(state.cursor_pos, 0);
3211        assert!(state.selection_anchor.is_none());
3212    }
3213
3214    #[test]
3215    fn test_select_all() {
3216        let mut state = TextEditState::default();
3217        state.text = "Hello".to_string();
3218        state.cursor_pos = 2;
3219        state.select_all();
3220        assert_eq!(state.selection_anchor, Some(0));
3221        assert_eq!(state.cursor_pos, 5);
3222    }
3223
3224    #[test]
3225    fn test_move_left_right() {
3226        let mut state = TextEditState::default();
3227        state.text = "AB".to_string();
3228        state.cursor_pos = 1;
3229
3230        state.move_left(false);
3231        assert_eq!(state.cursor_pos, 0);
3232
3233        state.move_right(false);
3234        assert_eq!(state.cursor_pos, 1);
3235    }
3236
3237    #[test]
3238    fn test_move_with_shift_creates_selection() {
3239        let mut state = TextEditState::default();
3240        state.text = "Hello".to_string();
3241        state.cursor_pos = 2;
3242
3243        state.move_right(true);
3244        assert_eq!(state.cursor_pos, 3);
3245        assert_eq!(state.selection_anchor, Some(2));
3246
3247        state.move_right(true);
3248        assert_eq!(state.cursor_pos, 4);
3249        assert_eq!(state.selection_anchor, Some(2));
3250    }
3251
3252    #[test]
3253    fn test_display_text_normal() {
3254        assert_eq!(display_text("Hello", "Placeholder", false), "Hello");
3255    }
3256
3257    #[test]
3258    fn test_display_text_empty() {
3259        assert_eq!(display_text("", "Placeholder", false), "Placeholder");
3260    }
3261
3262    #[test]
3263    fn test_display_text_password() {
3264        assert_eq!(display_text("pass", "Placeholder", true), "••••");
3265    }
3266
3267    #[test]
3268    fn test_nearest_char_boundary() {
3269        let positions = vec![0.0, 10.0, 20.0, 30.0];
3270        assert_eq!(find_nearest_char_boundary(4.0, &positions), 0);
3271        assert_eq!(find_nearest_char_boundary(6.0, &positions), 1);
3272        assert_eq!(find_nearest_char_boundary(15.0, &positions), 1); // midpoint rounds to closer
3273        assert_eq!(find_nearest_char_boundary(25.0, &positions), 2);
3274        assert_eq!(find_nearest_char_boundary(100.0, &positions), 3);
3275    }
3276
3277    #[test]
3278    fn test_ensure_cursor_visible() {
3279        let mut state = TextEditState::default();
3280        state.scroll_offset = 0.0;
3281
3282        // Cursor at x=150, visible_width=100 → should scroll right
3283        state.ensure_cursor_visible(150.0, 100.0);
3284        assert_eq!(state.scroll_offset, 50.0);
3285
3286        // Cursor at x=30, scroll_offset=50 → 30-50 = -20 < 0 → scroll left
3287        state.ensure_cursor_visible(30.0, 100.0);
3288        assert_eq!(state.scroll_offset, 30.0);
3289    }
3290
3291    #[test]
3292    fn test_backspace_word() {
3293        let mut state = TextEditState::default();
3294        state.text = "hello world".to_string();
3295        state.cursor_pos = 11;
3296        state.backspace_word();
3297        assert_eq!(state.text, "hello ");
3298        assert_eq!(state.cursor_pos, 6);
3299    }
3300
3301    #[test]
3302    fn test_delete_word_forward() {
3303        let mut state = TextEditState::default();
3304        state.text = "hello world".to_string();
3305        state.cursor_pos = 0;
3306        state.delete_word_forward();
3307        assert_eq!(state.text, "world");
3308        assert_eq!(state.cursor_pos, 0);
3309    }
3310
3311    // ── Multiline helper tests ──
3312
3313    #[test]
3314    fn test_line_start_char_pos() {
3315        assert_eq!(line_start_char_pos("hello\nworld", 0), 0);
3316        assert_eq!(line_start_char_pos("hello\nworld", 3), 0);
3317        assert_eq!(line_start_char_pos("hello\nworld", 5), 0);
3318        assert_eq!(line_start_char_pos("hello\nworld", 6), 6); // 'w' on second line
3319        assert_eq!(line_start_char_pos("hello\nworld", 9), 6);
3320    }
3321
3322    #[test]
3323    fn test_line_end_char_pos() {
3324        assert_eq!(line_end_char_pos("hello\nworld", 0), 5);
3325        assert_eq!(line_end_char_pos("hello\nworld", 3), 5);
3326        assert_eq!(line_end_char_pos("hello\nworld", 6), 11);
3327        assert_eq!(line_end_char_pos("hello\nworld", 9), 11);
3328    }
3329
3330    #[test]
3331    fn test_line_and_column() {
3332        assert_eq!(line_and_column("hello\nworld", 0), (0, 0));
3333        assert_eq!(line_and_column("hello\nworld", 3), (0, 3));
3334        assert_eq!(line_and_column("hello\nworld", 5), (0, 5)); // at '\n'
3335        assert_eq!(line_and_column("hello\nworld", 6), (1, 0));
3336        assert_eq!(line_and_column("hello\nworld", 8), (1, 2));
3337        assert_eq!(line_and_column("hello\nworld", 11), (1, 5)); // end of text
3338    }
3339
3340    #[test]
3341    fn test_line_and_column_three_lines() {
3342        let text = "ab\ncd\nef";
3343        assert_eq!(line_and_column(text, 0), (0, 0));
3344        assert_eq!(line_and_column(text, 2), (0, 2)); // at '\n'
3345        assert_eq!(line_and_column(text, 3), (1, 0));
3346        assert_eq!(line_and_column(text, 5), (1, 2)); // at '\n'
3347        assert_eq!(line_and_column(text, 6), (2, 0));
3348        assert_eq!(line_and_column(text, 8), (2, 2)); // end
3349    }
3350
3351    #[test]
3352    fn test_char_pos_from_line_col() {
3353        assert_eq!(char_pos_from_line_col("hello\nworld", 0, 0), 0);
3354        assert_eq!(char_pos_from_line_col("hello\nworld", 0, 3), 3);
3355        assert_eq!(char_pos_from_line_col("hello\nworld", 1, 0), 6);
3356        assert_eq!(char_pos_from_line_col("hello\nworld", 1, 3), 9);
3357        // Column exceeds line length → clamp to end of line
3358        assert_eq!(char_pos_from_line_col("ab\ncd", 0, 10), 2); // line 0 ends at char 2
3359        assert_eq!(char_pos_from_line_col("ab\ncd", 1, 10), 5); // line 1 goes to end
3360    }
3361
3362    #[test]
3363    fn test_split_lines() {
3364        let lines = split_lines("hello\nworld");
3365        assert_eq!(lines.len(), 2);
3366        assert_eq!(lines[0], (0, "hello"));
3367        assert_eq!(lines[1], (6, "world"));
3368
3369        let lines2 = split_lines("a\nb\nc");
3370        assert_eq!(lines2.len(), 3);
3371        assert_eq!(lines2[0], (0, "a"));
3372        assert_eq!(lines2[1], (2, "b"));
3373        assert_eq!(lines2[2], (4, "c"));
3374
3375        let lines3 = split_lines("no newlines");
3376        assert_eq!(lines3.len(), 1);
3377        assert_eq!(lines3[0], (0, "no newlines"));
3378    }
3379
3380    #[test]
3381    fn test_split_lines_trailing_newline() {
3382        let lines = split_lines("hello\n");
3383        assert_eq!(lines.len(), 2);
3384        assert_eq!(lines[0], (0, "hello"));
3385        assert_eq!(lines[1], (6, ""));
3386    }
3387
3388    #[test]
3389    fn test_move_up_down() {
3390        let mut state = TextEditState::default();
3391        state.text = "hello\nworld".to_string();
3392        state.cursor_pos = 8; // 'r' on line 1, col 2
3393
3394        state.move_up(false);
3395        assert_eq!(state.cursor_pos, 2); // line 0, col 2
3396
3397        state.move_down(false);
3398        assert_eq!(state.cursor_pos, 8); // back to line 1, col 2
3399    }
3400
3401    #[test]
3402    fn test_move_up_clamps_column() {
3403        let mut state = TextEditState::default();
3404        state.text = "ab\nhello".to_string();
3405        state.cursor_pos = 7; // line 1, col 4 (before 'o')
3406
3407        state.move_up(false);
3408        assert_eq!(state.cursor_pos, 2); // line 0 only has 2 chars, clamp to end
3409    }
3410
3411    #[test]
3412    fn test_move_up_from_first_line() {
3413        let mut state = TextEditState::default();
3414        state.text = "hello\nworld".to_string();
3415        state.cursor_pos = 3;
3416
3417        state.move_up(false);
3418        assert_eq!(state.cursor_pos, 0); // moves to start
3419    }
3420
3421    #[test]
3422    fn test_move_down_from_last_line() {
3423        let mut state = TextEditState::default();
3424        state.text = "hello\nworld".to_string();
3425        state.cursor_pos = 8;
3426
3427        state.move_down(false);
3428        assert_eq!(state.cursor_pos, 11); // moves to end
3429    }
3430
3431    #[test]
3432    fn test_move_line_home_end() {
3433        let mut state = TextEditState::default();
3434        state.text = "hello\nworld".to_string();
3435        state.cursor_pos = 8; // line 1, col 2
3436
3437        state.move_line_home(false);
3438        assert_eq!(state.cursor_pos, 6); // start of line 1
3439
3440        state.move_line_end(false);
3441        assert_eq!(state.cursor_pos, 11); // end of line 1
3442    }
3443
3444    #[test]
3445    fn test_move_up_with_shift_selects() {
3446        let mut state = TextEditState::default();
3447        state.text = "hello\nworld".to_string();
3448        state.cursor_pos = 8;
3449
3450        state.move_up(true);
3451        assert_eq!(state.cursor_pos, 2);
3452        assert_eq!(state.selection_anchor, Some(8));
3453    }
3454
3455    #[test]
3456    fn test_ensure_cursor_visible_vertical() {
3457        let mut state = TextEditState::default();
3458        state.scroll_offset_y = 0.0;
3459
3460        // Cursor on line 5, line_height=20, visible_height=60
3461        // cursor_bottom = 5*20+20 = 120 > 60 → scroll down
3462        state.ensure_cursor_visible_vertical(5, 20.0, 60.0);
3463        assert_eq!(state.scroll_offset_y, 60.0); // 120 - 60
3464
3465        // Cursor on line 1, scroll_offset_y=60 → cursor_y = 20 < 60 → scroll up
3466        state.ensure_cursor_visible_vertical(1, 20.0, 60.0);
3467        assert_eq!(state.scroll_offset_y, 20.0);
3468    }
3469
3470    // ── Word wrapping tests ──
3471
3472    /// Simple fixed-width measure: each char is 10px wide.
3473    fn fixed_measure(text: &str, _config: &crate::text::TextConfig) -> crate::math::Dimensions {
3474        crate::math::Dimensions {
3475            width: text.chars().count() as f32 * 10.0,
3476            height: 20.0,
3477        }
3478    }
3479
3480    #[test]
3481    fn test_wrap_lines_no_wrap_needed() {
3482        let lines = wrap_lines("hello", 100.0, None, 16, &fixed_measure);
3483        assert_eq!(lines.len(), 1);
3484        assert_eq!(lines[0].text, "hello");
3485        assert_eq!(lines[0].global_char_start, 0);
3486        assert_eq!(lines[0].char_count, 5);
3487    }
3488
3489    #[test]
3490    fn test_wrap_lines_hard_break() {
3491        let lines = wrap_lines("ab\ncd", 100.0, None, 16, &fixed_measure);
3492        assert_eq!(lines.len(), 2);
3493        assert_eq!(lines[0].text, "ab");
3494        assert_eq!(lines[0].global_char_start, 0);
3495        assert_eq!(lines[1].text, "cd");
3496        assert_eq!(lines[1].global_char_start, 3); // after '\n'
3497    }
3498
3499    #[test]
3500    fn test_wrap_lines_word_wrap() {
3501        // "hello world" = 11 chars × 10px = 110px, max_width=60px
3502        // "hello " = 6 chars = 60px fits, then "world" = 5 chars = 50px fits
3503        let lines = wrap_lines("hello world", 60.0, None, 16, &fixed_measure);
3504        assert_eq!(lines.len(), 2);
3505        assert_eq!(lines[0].text, "hello ");
3506        assert_eq!(lines[0].global_char_start, 0);
3507        assert_eq!(lines[0].char_count, 6);
3508        assert_eq!(lines[1].text, "world");
3509        assert_eq!(lines[1].global_char_start, 6);
3510        assert_eq!(lines[1].char_count, 5);
3511    }
3512
3513    #[test]
3514    fn test_wrap_lines_char_level_break() {
3515        // "abcdefghij" = 10 chars × 10px = 100px, max_width=50px
3516        // No spaces → character-level break at 5 chars
3517        let lines = wrap_lines("abcdefghij", 50.0, None, 16, &fixed_measure);
3518        assert_eq!(lines.len(), 2);
3519        assert_eq!(lines[0].text, "abcde");
3520        assert_eq!(lines[0].char_count, 5);
3521        assert_eq!(lines[1].text, "fghij");
3522        assert_eq!(lines[1].global_char_start, 5);
3523    }
3524
3525    #[test]
3526    fn test_cursor_to_visual_pos_simple() {
3527        let lines = vec![
3528            VisualLine { text: "hello ".to_string(), global_char_start: 0, char_count: 6 },
3529            VisualLine { text: "world".to_string(), global_char_start: 6, char_count: 5 },
3530        ];
3531        assert_eq!(cursor_to_visual_pos(&lines, 0), (0, 0));
3532        assert_eq!(cursor_to_visual_pos(&lines, 3), (0, 3));
3533        assert_eq!(cursor_to_visual_pos(&lines, 6), (1, 0)); // Wrapped → start of next line
3534        assert_eq!(cursor_to_visual_pos(&lines, 8), (1, 2));
3535        assert_eq!(cursor_to_visual_pos(&lines, 11), (1, 5));
3536    }
3537
3538    #[test]
3539    fn test_cursor_to_visual_pos_hard_break() {
3540        // "ab\ncd" → line 0: "ab" (start=0, count=2), line 1: "cd" (start=3, count=2)
3541        let lines = vec![
3542            VisualLine { text: "ab".to_string(), global_char_start: 0, char_count: 2 },
3543            VisualLine { text: "cd".to_string(), global_char_start: 3, char_count: 2 },
3544        ];
3545        assert_eq!(cursor_to_visual_pos(&lines, 2), (0, 2)); // End of "ab" (before \n)
3546        assert_eq!(cursor_to_visual_pos(&lines, 3), (1, 0)); // Start of "cd"
3547    }
3548
3549    #[test]
3550    fn test_visual_move_up_down() {
3551        let lines = vec![
3552            VisualLine { text: "hello ".to_string(), global_char_start: 0, char_count: 6 },
3553            VisualLine { text: "world".to_string(), global_char_start: 6, char_count: 5 },
3554        ];
3555        // From cursor at pos 8 (line 1, col 2) → move up → line 0, col 2 = pos 2
3556        assert_eq!(visual_move_up(&lines, 8), 2);
3557        // From cursor at pos 2 (line 0, col 2) → move down → line 1, col 2 = pos 8
3558        assert_eq!(visual_move_down(&lines, 2, 11), 8);
3559    }
3560
3561    #[test]
3562    fn test_visual_line_home_end() {
3563        let lines = vec![
3564            VisualLine { text: "hello ".to_string(), global_char_start: 0, char_count: 6 },
3565            VisualLine { text: "world".to_string(), global_char_start: 6, char_count: 5 },
3566        ];
3567        // Cursor at pos 8 (line 1, col 2) → home = 6, end = 11
3568        assert_eq!(visual_line_home(&lines, 8), 6);
3569        assert_eq!(visual_line_end(&lines, 8), 11);
3570        // Cursor at pos 3 (line 0, col 3) → home = 0, end = 6
3571        assert_eq!(visual_line_home(&lines, 3), 0);
3572        assert_eq!(visual_line_end(&lines, 3), 6);
3573    }
3574
3575    #[test]
3576    fn test_undo_basic() {
3577        let mut state = TextEditState::default();
3578        state.text = "hello".to_string();
3579        state.cursor_pos = 5;
3580
3581        // Push undo, then modify
3582        state.push_undo(UndoActionKind::Paste);
3583        state.insert_text(" world", None);
3584        assert_eq!(state.text, "hello world");
3585
3586        // Undo should restore
3587        assert!(state.undo());
3588        assert_eq!(state.text, "hello");
3589        assert_eq!(state.cursor_pos, 5);
3590
3591        // Redo should restore the edit
3592        assert!(state.redo());
3593        assert_eq!(state.text, "hello world");
3594        assert_eq!(state.cursor_pos, 11);
3595    }
3596
3597    #[test]
3598    fn test_undo_grouping_insert_char() {
3599        let mut state = TextEditState::default();
3600
3601        // Simulate typing "abc" one char at a time
3602        state.push_undo(UndoActionKind::InsertChar);
3603        state.insert_text("a", None);
3604        state.push_undo(UndoActionKind::InsertChar);
3605        state.insert_text("b", None);
3606        state.push_undo(UndoActionKind::InsertChar);
3607        state.insert_text("c", None);
3608        assert_eq!(state.text, "abc");
3609
3610        // Should undo all at once (grouped)
3611        assert!(state.undo());
3612        assert_eq!(state.text, "");
3613        assert_eq!(state.cursor_pos, 0);
3614
3615        // No more undos
3616        assert!(!state.undo());
3617    }
3618
3619    #[test]
3620    fn test_undo_grouping_backspace() {
3621        let mut state = TextEditState::default();
3622        state.text = "hello".to_string();
3623        state.cursor_pos = 5;
3624
3625        // Backspace 3 times
3626        state.push_undo(UndoActionKind::Backspace);
3627        state.backspace();
3628        state.push_undo(UndoActionKind::Backspace);
3629        state.backspace();
3630        state.push_undo(UndoActionKind::Backspace);
3631        state.backspace();
3632        assert_eq!(state.text, "he");
3633
3634        // Should undo all backspaces at once
3635        assert!(state.undo());
3636        assert_eq!(state.text, "hello");
3637    }
3638
3639    #[test]
3640    fn test_undo_different_kinds_not_grouped() {
3641        let mut state = TextEditState::default();
3642
3643        // Type then delete — different kinds, not grouped
3644        state.push_undo(UndoActionKind::InsertChar);
3645        state.insert_text("abc", None);
3646        state.push_undo(UndoActionKind::Backspace);
3647        state.backspace();
3648        assert_eq!(state.text, "ab");
3649
3650        // First undo restores before backspace
3651        assert!(state.undo());
3652        assert_eq!(state.text, "abc");
3653
3654        // Second undo restores before insert
3655        assert!(state.undo());
3656        assert_eq!(state.text, "");
3657    }
3658
3659    #[test]
3660    fn test_redo_cleared_on_new_edit() {
3661        let mut state = TextEditState::default();
3662
3663        state.push_undo(UndoActionKind::Paste);
3664        state.insert_text("hello", None);
3665        state.undo();
3666        assert_eq!(state.text, "");
3667        assert!(!state.redo_stack.is_empty());
3668
3669        // New edit should clear redo
3670        state.push_undo(UndoActionKind::Paste);
3671        state.insert_text("world", None);
3672        assert!(state.redo_stack.is_empty());
3673    }
3674
3675    #[test]
3676    fn test_undo_empty_stack() {
3677        let mut state = TextEditState::default();
3678        assert!(!state.undo());
3679        assert!(!state.redo());
3680    }
3681
3682    #[cfg(feature = "text-styling")]
3683    fn make_no_styles_state(raw: &str) -> TextEditState {
3684        let mut s = TextEditState::default();
3685        s.text = raw.to_string();
3686        s.no_styles_movement = true;
3687        // Snap the cursor to a content boundary at position 0
3688        s.cursor_pos = 0;
3689        s
3690    }
3691
3692    #[test]
3693    #[cfg(feature = "text-styling")]
3694    fn test_content_to_cursor_no_structural_basic() {
3695        use crate::text_input::styling::content_to_cursor;
3696        // "a{red|}b" — visual: a@0, empty@1, }@2, b@3. content: a,b
3697        assert_eq!(content_to_cursor("a{red|}b", 0, true), 0);  // before 'a'
3698        assert_eq!(content_to_cursor("a{red|}b", 1, true), 3);  // before 'b' (skip empty + })
3699        assert_eq!(content_to_cursor("a{red|}b", 2, true), 4);  // after 'b'
3700    }
3701
3702    #[test]
3703    #[cfg(feature = "text-styling")]
3704    fn test_content_to_cursor_no_structural_nested() {
3705        use crate::text_input::styling::content_to_cursor;
3706        // "a{red|b}{blue|c}" — visual: a@0, b@1, }@2, c@3, }@4. content: a,b,c
3707        assert_eq!(content_to_cursor("a{red|b}{blue|c}", 0, true), 0);
3708        assert_eq!(content_to_cursor("a{red|b}{blue|c}", 1, true), 1);  // before 'b'
3709        assert_eq!(content_to_cursor("a{red|b}{blue|c}", 2, true), 3);  // before 'c' (skip } + header)
3710        assert_eq!(content_to_cursor("a{red|b}{blue|c}", 3, true), 5);  // after last } (end of text)
3711    }
3712
3713    #[test]
3714    #[cfg(feature = "text-styling")]
3715    fn test_delete_content_range() {
3716        use crate::text_input::styling::delete_content_range;
3717        // Delete 'b' from "a{red|b}c" → "a{red|}c"
3718        assert_eq!(delete_content_range("a{red|b}c", 1, 2), "a{red|}c");
3719        // Delete 'a' from "a{red|b}c" → "{red|b}c"
3720        assert_eq!(delete_content_range("a{red|b}c", 0, 1), "{red|b}c");
3721        // Delete all content
3722        assert_eq!(delete_content_range("a{red|b}c", 0, 3), "{red|}");
3723        // No-op
3724        assert_eq!(delete_content_range("abc", 1, 1), "abc");
3725    }
3726
3727    #[test]
3728    #[cfg(feature = "text-styling")]
3729    fn test_no_styles_move_right() {
3730        let mut s = make_no_styles_state("a{red|}b");
3731        // cursor at 0 (before 'a'), move right.
3732        // Because cursor moves away from {red|}, the empty tag gets cleaned up.
3733        // Text becomes "ab" and cursor lands at content 1 (between a and b).
3734        s.move_right_styled(false);
3735        assert_eq!(s.text, "ab");
3736        assert_eq!(s.cursor_pos, 1);
3737        // move right again → after 'b' (end)
3738        s.move_right_styled(false);
3739        assert_eq!(s.cursor_pos, 2);
3740        assert_eq!(styling::cursor_to_content(&s.text, s.cursor_pos), 2);
3741    }
3742
3743    #[test]
3744    #[cfg(feature = "text-styling")]
3745    fn test_no_styles_move_left() {
3746        let mut s = make_no_styles_state("a{red|}b");
3747        // Put cursor at end — the empty {red|} tag will be cleaned up since
3748        // cursor at end is not inside it. Text becomes "ab", cursor at 2.
3749        s.cursor_pos = styling::content_to_cursor(&s.text, 2, true);
3750        // Trigger cleanup to normalise
3751        s.move_end_styled(false);
3752        assert_eq!(s.text, "ab");
3753        assert_eq!(s.cursor_pos, 2);
3754        // move left → before 'b' (content 1)
3755        s.move_left_styled(false);
3756        let cp = styling::cursor_to_content(&s.text, s.cursor_pos);
3757        assert_eq!(cp, 1);
3758        // move left → before 'a' (content 0)
3759        s.move_left_styled(false);
3760        assert_eq!(s.cursor_pos, 0);
3761    }
3762
3763    #[test]
3764    #[cfg(feature = "text-styling")]
3765    fn test_no_styles_move_left_skips_closing_brace() {
3766        let mut s = make_no_styles_state("a{red|b}c");
3767        // visual: a@0, b@1, }@2, c@3. Set cursor before 'c' (content 2 → visual 3).
3768        s.cursor_pos = styling::content_to_cursor(&s.text, 2, true);
3769        // 'b' is at visual 1, '}' at 2, 'c' at 3.  cursor should be at visual 3.
3770        assert_eq!(s.cursor_pos, 3);
3771        // Move left → before 'b' (content 1, visual 1)
3772        s.move_left_styled(false);
3773        let cp = styling::cursor_to_content(&s.text, s.cursor_pos);
3774        assert_eq!(cp, 1);
3775    }
3776
3777    #[test]
3778    #[cfg(feature = "text-styling")]
3779    fn test_no_styles_backspace() {
3780        let mut s = make_no_styles_state("a{red|b}c");
3781        // Put cursor at content 2 (before 'c')
3782        s.cursor_pos = styling::content_to_cursor(&s.text, 2, true);
3783        s.backspace_styled();
3784        // Should delete 'b', leaving "a...c"
3785        let stripped = styling::strip_styling(&s.text);
3786        assert_eq!(stripped, "ac");
3787        // Cursor should be at content 1 (between a and c)
3788        let cp = styling::cursor_to_content(&s.text, s.cursor_pos);
3789        assert_eq!(cp, 1);
3790    }
3791
3792    #[test]
3793    #[cfg(feature = "text-styling")]
3794    fn test_no_styles_delete_forward() {
3795        let mut s = make_no_styles_state("{red|abc}");
3796        // Put cursor at content 1 (before 'b')
3797        s.cursor_pos = styling::content_to_cursor(&s.text, 1, true);
3798        s.delete_forward_styled();
3799        let stripped = styling::strip_styling(&s.text);
3800        assert_eq!(stripped, "ac");
3801        let cp = styling::cursor_to_content(&s.text, s.cursor_pos);
3802        assert_eq!(cp, 1);
3803    }
3804
3805    #[test]
3806    #[cfg(feature = "text-styling")]
3807    fn test_no_styles_home_end() {
3808        let mut s = make_no_styles_state("{red|}hello{blue|}");
3809        // Home — should be at the first content char
3810        s.move_home_styled(false);
3811        let cp = styling::cursor_to_content(&s.text, s.cursor_pos);
3812        assert_eq!(cp, 0);
3813        // End
3814        s.move_end_styled(false);
3815        let cp = styling::cursor_to_content(&s.text, s.cursor_pos);
3816        let content_len = styling::strip_styling(&s.text).chars().count();
3817        assert_eq!(cp, content_len);
3818    }
3819
3820    #[test]
3821    #[cfg(feature = "text-styling")]
3822    fn test_no_styles_select_all_and_delete() {
3823        let mut s = make_no_styles_state("a{red|b}c");
3824        s.select_all_styled();
3825        assert!(s.selection_anchor.is_some());
3826        // Delete selection
3827        s.delete_selection_styled();
3828        let stripped = styling::strip_styling(&s.text);
3829        assert!(stripped.is_empty());
3830    }
3831}