Skip to main content

ply_engine/
text_input.rs

1use crate::{color::Color, elements, engine};
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    /// When true, mouse drag performs selection instead of drag-scrolling.
1165    pub drag_select: bool,
1166    /// Font size in pixels.
1167    pub font_size: u16,
1168    /// Color of the input text.
1169    pub text_color: Color,
1170    /// Color of the placeholder text.
1171    pub placeholder_color: Color,
1172    /// Color of the cursor line.
1173    pub cursor_color: Color,
1174    /// Color of the selection highlight rectangle.
1175    pub selection_color: Color,
1176    /// Override line height in pixels. When 0 (default), the natural font height is used.
1177    pub line_height: u16,
1178    /// When true, cursor movement skips over `}` and empty content style positions.
1179    pub no_styles_movement: bool,
1180    /// The font asset to use. Resolved by the renderer.
1181    pub font_asset: Option<&'static crate::renderer::FontAsset>,
1182    /// Optional scrollbar configuration.
1183    pub scrollbar: Option<engine::ScrollbarConfig>,
1184}
1185
1186impl Default for TextInputConfig {
1187    fn default() -> Self {
1188        Self {
1189            placeholder: String::new(),
1190            max_length: None,
1191            is_password: false,
1192            is_multiline: false,
1193            drag_select: false,
1194            font_size: 0,
1195            text_color: Color::rgba(255.0, 255.0, 255.0, 255.0),
1196            placeholder_color: Color::rgba(128.0, 128.0, 128.0, 255.0),
1197            cursor_color: Color::rgba(255.0, 255.0, 255.0, 255.0),
1198            selection_color: Color::rgba(69.0, 130.0, 181.0, 128.0),
1199            line_height: 0,
1200            no_styles_movement: false,
1201            font_asset: None,
1202            scrollbar: None,
1203        }
1204    }
1205}
1206
1207/// Builder for configuring a text input element via closure.
1208pub struct TextInputBuilder {
1209    pub(crate) config: TextInputConfig,
1210    pub(crate) on_changed_fn: Option<Box<dyn FnMut(&str) + 'static>>,
1211    pub(crate) on_submit_fn: Option<Box<dyn FnMut(&str) + 'static>>,
1212}
1213
1214impl TextInputBuilder {
1215    pub(crate) fn new() -> Self {
1216        Self {
1217            config: TextInputConfig::default(),
1218            on_changed_fn: None,
1219            on_submit_fn: None,
1220        }
1221    }
1222
1223    /// Sets the placeholder text shown when the input is empty.
1224    #[inline]
1225    pub fn placeholder(&mut self, text: &str) -> &mut Self {
1226        self.config.placeholder = text.to_string();
1227        self
1228    }
1229
1230    /// Sets the maximum number of characters allowed.
1231    #[inline]
1232    pub fn max_length(&mut self, len: usize) -> &mut Self {
1233        self.config.max_length = Some(len);
1234        self
1235    }
1236
1237    /// Enables password mode (characters shown as dots).
1238    #[inline]
1239    pub fn password(&mut self) -> &mut Self {
1240        self.config.is_password = true;
1241        self
1242    }
1243
1244    /// Enables multiline mode (Enter inserts newline, up/down arrows navigate lines).
1245    #[inline]
1246    pub fn multiline(&mut self) -> &mut Self {
1247        self.config.is_multiline = true;
1248        self
1249    }
1250
1251    /// Enables mouse drag-to-select behavior.
1252    #[inline]
1253    pub fn drag_select(&mut self) -> &mut Self {
1254        self.config.drag_select = true;
1255        self
1256    }
1257
1258    /// Sets the font to use for this text input.
1259    ///
1260    /// The font is loaded asynchronously during rendering.
1261    #[inline]
1262    pub fn font(&mut self, asset: &'static crate::renderer::FontAsset) -> &mut Self {
1263        self.config.font_asset = Some(asset);
1264        self
1265    }
1266
1267    /// Sets the font size.
1268    #[inline]
1269    pub fn font_size(&mut self, size: u16) -> &mut Self {
1270        self.config.font_size = size;
1271        self
1272    }
1273
1274    /// Sets the text color.
1275    #[inline]
1276    pub fn text_color(&mut self, color: impl Into<Color>) -> &mut Self {
1277        self.config.text_color = color.into();
1278        self
1279    }
1280
1281    /// Sets the placeholder text color.
1282    #[inline]
1283    pub fn placeholder_color(&mut self, color: impl Into<Color>) -> &mut Self {
1284        self.config.placeholder_color = color.into();
1285        self
1286    }
1287
1288    /// Sets the cursor color.
1289    #[inline]
1290    pub fn cursor_color(&mut self, color: impl Into<Color>) -> &mut Self {
1291        self.config.cursor_color = color.into();
1292        self
1293    }
1294
1295    /// Sets the selection highlight color.
1296    #[inline]
1297    pub fn selection_color(&mut self, color: impl Into<Color>) -> &mut Self {
1298        self.config.selection_color = color.into();
1299        self
1300    }
1301
1302    /// Sets the line height in pixels for multiline inputs.
1303    ///
1304    /// When set to a value greater than 0, this overrides the natural font
1305    /// height for spacing between lines. Text is vertically centred within
1306    /// each line slot. A value of 0 (default) uses the natural font height.
1307    #[inline]
1308    pub fn line_height(&mut self, height: u16) -> &mut Self {
1309        self.config.line_height = height;
1310        self
1311    }
1312
1313    /// Enables and configures the input scrollbar.
1314    #[inline]
1315    pub fn scrollbar(
1316        &mut self,
1317        f: impl for<'a> FnOnce(&'a mut elements::ScrollbarBuilder) -> &'a mut elements::ScrollbarBuilder,
1318    ) -> &mut Self {
1319        let mut builder = elements::ScrollbarBuilder {
1320            config: self.config.scrollbar.unwrap_or_default(),
1321        };
1322        f(&mut builder);
1323        self.config.scrollbar = Some(builder.config);
1324        self
1325    }
1326
1327    /// Enables no-styles movement mode.
1328    /// When enabled, cursor navigation skips over `}` exit positions and
1329    /// empty content markers, so the cursor only stops at visible character
1330    /// boundaries. Useful for live-highlighted text inputs where the user
1331    /// should not navigate through invisible style markup.
1332    #[inline]
1333    pub fn no_styles_movement(&mut self) -> &mut Self {
1334        self.config.no_styles_movement = true;
1335        self
1336    }
1337
1338    /// Registers a callback fired whenever the text content changes.
1339    #[inline]
1340    pub fn on_changed<F>(&mut self, callback: F) -> &mut Self
1341    where
1342        F: FnMut(&str) + 'static,
1343    {
1344        self.on_changed_fn = Some(Box::new(callback));
1345        self
1346    }
1347
1348    /// Registers a callback fired when the user presses Enter.
1349    #[inline]
1350    pub fn on_submit<F>(&mut self, callback: F) -> &mut Self
1351    where
1352        F: FnMut(&str) + 'static,
1353    {
1354        self.on_submit_fn = Some(Box::new(callback));
1355        self
1356    }
1357}
1358
1359/// Convert a character index to a byte index in the string.
1360pub fn char_index_to_byte(s: &str, char_idx: usize) -> usize {
1361    s.char_indices()
1362        .nth(char_idx)
1363        .map(|(byte_pos, _)| byte_pos)
1364        .unwrap_or(s.len())
1365}
1366
1367/// Find the char index of the start of the line containing `char_pos`.
1368/// A "line" is delimited by '\n'. Returns 0 for the first line.
1369pub fn line_start_char_pos(text: &str, char_pos: usize) -> usize {
1370    let chars: Vec<char> = text.chars().collect();
1371    let mut i = char_pos;
1372    while i > 0 && chars[i - 1] != '\n' {
1373        i -= 1;
1374    }
1375    i
1376}
1377
1378/// Find the char index of the end of the line containing `char_pos`.
1379/// Returns the position just before the '\n' or at text end.
1380pub fn line_end_char_pos(text: &str, char_pos: usize) -> usize {
1381    let chars: Vec<char> = text.chars().collect();
1382    let len = chars.len();
1383    let mut i = char_pos;
1384    while i < len && chars[i] != '\n' {
1385        i += 1;
1386    }
1387    i
1388}
1389
1390/// Returns (line_index, column) for a given char position.
1391/// Lines are 0-indexed, split by '\n'.
1392pub fn line_and_column(text: &str, char_pos: usize) -> (usize, usize) {
1393    let mut line = 0;
1394    let mut col = 0;
1395    for (i, ch) in text.chars().enumerate() {
1396        if i == char_pos {
1397            return (line, col);
1398        }
1399        if ch == '\n' {
1400            line += 1;
1401            col = 0;
1402        } else {
1403            col += 1;
1404        }
1405    }
1406    (line, col)
1407}
1408
1409/// Convert a (line, column) pair to a character position.
1410/// If the column exceeds the line length, clamps to line end.
1411pub fn char_pos_from_line_col(text: &str, target_line: usize, target_col: usize) -> usize {
1412    let mut line = 0;
1413    let mut col = 0;
1414    for (i, ch) in text.chars().enumerate() {
1415        if line == target_line && col == target_col {
1416            return i;
1417        }
1418        if ch == '\n' {
1419            if line == target_line {
1420                // Column exceeds this line length; return end of this line
1421                return i;
1422            }
1423            line += 1;
1424            col = 0;
1425        } else {
1426            col += 1;
1427        }
1428    }
1429    // If target is beyond text, return text length
1430    text.chars().count()
1431}
1432
1433/// Split text into lines (by '\n'), returning each line's text
1434/// and the global char index where it starts.
1435pub fn split_lines(text: &str) -> Vec<(usize, &str)> {
1436    let mut result = Vec::new();
1437    let mut char_start = 0;
1438    let mut byte_start = 0;
1439    for (byte_idx, ch) in text.char_indices() {
1440        if ch == '\n' {
1441            result.push((char_start, &text[byte_start..byte_idx]));
1442            char_start += text[byte_start..byte_idx].chars().count() + 1; // +1 for '\n'
1443            byte_start = byte_idx + 1; // '\n' is 1 byte
1444        }
1445    }
1446    // Last line (after final '\n' or entire text if no '\n')
1447    result.push((char_start, &text[byte_start..]));
1448    result
1449}
1450
1451/// A single visual line after word-wrapping.
1452#[derive(Debug, Clone)]
1453pub struct VisualLine {
1454    /// The text content of this visual line.
1455    pub text: String,
1456    /// The global character index where this visual line starts in the full text.
1457    pub global_char_start: usize,
1458    /// Number of characters in this visual line.
1459    pub char_count: usize,
1460}
1461
1462/// Word-wrap text into visual lines that fit within `max_width`.
1463/// Splits on '\n' first (hard breaks), then wraps long lines at word boundaries.
1464/// If `max_width <= 0`, no wrapping occurs (equivalent to `split_lines`).
1465pub fn wrap_lines(
1466    text: &str,
1467    max_width: f32,
1468    font_asset: Option<&'static crate::renderer::FontAsset>,
1469    font_size: u16,
1470    measure_fn: &dyn Fn(&str, &crate::text::TextConfig) -> crate::math::Dimensions,
1471) -> Vec<VisualLine> {
1472    let config = crate::text::TextConfig {
1473        font_asset,
1474        font_size,
1475        ..Default::default()
1476    };
1477
1478    let hard_lines = split_lines(text);
1479    let mut result = Vec::new();
1480
1481    for (global_start, line_text) in hard_lines {
1482        if line_text.is_empty() {
1483            result.push(VisualLine {
1484                text: String::new(),
1485                global_char_start: global_start,
1486                char_count: 0,
1487            });
1488            continue;
1489        }
1490
1491        if max_width <= 0.0 {
1492            // No wrapping
1493            result.push(VisualLine {
1494                text: line_text.to_string(),
1495                global_char_start: global_start,
1496                char_count: line_text.chars().count(),
1497            });
1498            continue;
1499        }
1500
1501        // Check if the whole line fits
1502        let full_width = measure_fn(line_text, &config).width;
1503        if full_width <= max_width {
1504            result.push(VisualLine {
1505                text: line_text.to_string(),
1506                global_char_start: global_start,
1507                char_count: line_text.chars().count(),
1508            });
1509            continue;
1510        }
1511
1512        // Need to wrap this line
1513        let chars: Vec<char> = line_text.chars().collect();
1514        let total_chars = chars.len();
1515        let mut line_char_start = 0; // index within chars[]
1516
1517        while line_char_start < total_chars {
1518            // Find how many characters fit in max_width
1519            let mut fit_count = 0;
1520
1521            #[cfg(feature = "text-styling")]
1522            {
1523                // Styling-aware measurement: skip calling measure_fn for substrings that
1524                // end inside a tag header ({name|) to avoid warnings from incomplete tags.
1525                // Tag header chars have zero visible width, so advancing fit_count is safe.
1526                let mut in_tag_hdr = false;
1527                let mut escaped = false;
1528                for i in 1..=(total_chars - line_char_start) {
1529                    let ch = chars[line_char_start + i - 1];
1530                    if escaped {
1531                        escaped = false;
1532                        // Escaped char is visible: measure
1533                        let substr: String = chars[line_char_start..line_char_start + i].iter().collect();
1534                        let w = measure_fn(&substr, &config).width;
1535                        if w > max_width { break; }
1536                        fit_count = i;
1537                        continue;
1538                    }
1539                    match ch {
1540                        '\\' => { escaped = true; /* don't update fit_count: \ and next char are atomic */ }
1541                        '{' => { in_tag_hdr = true; fit_count = i; }
1542                        '|' if in_tag_hdr => { in_tag_hdr = false; fit_count = i; }
1543                        '}' => { fit_count = i; }
1544                        _ if in_tag_hdr => { fit_count = i; }
1545                        _ => {
1546                            // Visible char: measure the substring
1547                            let substr: String = chars[line_char_start..line_char_start + i].iter().collect();
1548                            let w = measure_fn(&substr, &config).width;
1549                            if w > max_width { break; }
1550                            fit_count = i;
1551                        }
1552                    }
1553                }
1554            }
1555
1556            #[cfg(not(feature = "text-styling"))]
1557            {
1558                for i in 1..=(total_chars - line_char_start) {
1559                    let substr: String = chars[line_char_start..line_char_start + i].iter().collect();
1560                    let w = measure_fn(&substr, &config).width;
1561                    if w > max_width {
1562                        break;
1563                    }
1564                    fit_count = i;
1565                }
1566            }
1567
1568            if fit_count == 0 {
1569                // Even a single character doesn't fit; force at least one visible unit.
1570                // If the first char is a backslash (escape), include the next char too
1571                // so we never split an escape sequence across lines.
1572                #[cfg(feature = "text-styling")]
1573                if chars[line_char_start] == '\\' && line_char_start + 2 <= total_chars {
1574                    fit_count = 2;
1575                } else {
1576                    fit_count = 1;
1577                }
1578                #[cfg(not(feature = "text-styling"))]
1579                {
1580                    fit_count = 1;
1581                }
1582            }
1583
1584            if line_char_start + fit_count < total_chars {
1585                // Try to break at a word boundary (last space within fit_count)
1586                let mut break_at = fit_count;
1587                let mut found_space = false;
1588                for j in (1..=fit_count).rev() {
1589                    if chars[line_char_start + j - 1] == ' ' {
1590                        break_at = j;
1591                        found_space = true;
1592                        break;
1593                    }
1594                }
1595                // If we found a space, break there; otherwise force character-level break
1596                #[allow(unused_mut)]
1597                let mut wrap_count = if found_space { break_at } else { fit_count };
1598                // Never split an escape sequence (\{, \}, etc.) across lines
1599                #[cfg(feature = "text-styling")]
1600                if wrap_count > 0
1601                    && chars[line_char_start + wrap_count - 1] == '\\'
1602                    && line_char_start + wrap_count < total_chars
1603                {
1604                    if wrap_count > 1 {
1605                        wrap_count -= 1; // back up before the backslash
1606                    } else {
1607                        wrap_count = 2.min(total_chars - line_char_start); // include the escape pair
1608                    }
1609                }
1610                let segment: String = chars[line_char_start..line_char_start + wrap_count].iter().collect();
1611                result.push(VisualLine {
1612                    text: segment,
1613                    global_char_start: global_start + line_char_start,
1614                    char_count: wrap_count,
1615                });
1616                line_char_start += wrap_count;
1617                // Skip leading space on the next line if we broke at a space
1618                if found_space && line_char_start < total_chars && chars[line_char_start] == ' ' {
1619                    // Don't skip — the space is already consumed in the segment above
1620                    // Actually, break_at includes the space. Let's keep it as-is for now.
1621                }
1622            } else {
1623                // Remaining text fits
1624                let segment: String = chars[line_char_start..].iter().collect();
1625                let count = total_chars - line_char_start;
1626                result.push(VisualLine {
1627                    text: segment,
1628                    global_char_start: global_start + line_char_start,
1629                    char_count: count,
1630                });
1631                line_char_start = total_chars;
1632            }
1633        }
1634    }
1635
1636    // Ensure at least one visual line
1637    if result.is_empty() {
1638        result.push(VisualLine {
1639            text: String::new(),
1640            global_char_start: 0,
1641            char_count: 0,
1642        });
1643    }
1644
1645    result
1646}
1647
1648/// Given visual lines and a global cursor position, return (visual_line_index, column_in_visual_line).
1649pub fn cursor_to_visual_pos(visual_lines: &[VisualLine], cursor_pos: usize) -> (usize, usize) {
1650    for (i, vl) in visual_lines.iter().enumerate() {
1651        let line_end = vl.global_char_start + vl.char_count;
1652        if cursor_pos < line_end || i == visual_lines.len() - 1 {
1653            return (i, cursor_pos.saturating_sub(vl.global_char_start));
1654        }
1655        // If cursor_pos == line_end and this isn't the last line, it could be at the
1656        // start of the next line OR the end of this one. For wrapped lines (no \n),
1657        // prefer placing it at the start of the next line.
1658        if cursor_pos == line_end {
1659            // Check if next line continues from this one (wrapped) or is a new paragraph
1660            if i + 1 < visual_lines.len() {
1661                let next = &visual_lines[i + 1];
1662                if next.global_char_start == line_end {
1663                    // Wrapped continuation — cursor goes to start of next visual line
1664                    return (i + 1, 0);
1665                }
1666                // Hard break (\n between them) — cursor at end of this line
1667                return (i, cursor_pos - vl.global_char_start);
1668            }
1669            return (i, cursor_pos - vl.global_char_start);
1670        }
1671    }
1672    (0, 0)
1673}
1674
1675/// Navigate cursor one visual line up. Returns the new global cursor position.
1676/// `col` is the desired column (preserved across up/down moves).
1677pub fn visual_move_up(visual_lines: &[VisualLine], cursor_pos: usize) -> usize {
1678    let (line, col) = cursor_to_visual_pos(visual_lines, cursor_pos);
1679    if line == 0 {
1680        return 0; // Already on first visual line → move to start
1681    }
1682    let target_line = &visual_lines[line - 1];
1683    let new_col = col.min(target_line.char_count);
1684    target_line.global_char_start + new_col
1685}
1686
1687/// Navigate cursor one visual line down. Returns the new global cursor position.
1688pub fn visual_move_down(visual_lines: &[VisualLine], cursor_pos: usize, text_len: usize) -> usize {
1689    let (line, col) = cursor_to_visual_pos(visual_lines, cursor_pos);
1690    if line >= visual_lines.len() - 1 {
1691        return text_len; // Already on last visual line → move to end
1692    }
1693    let target_line = &visual_lines[line + 1];
1694    let new_col = col.min(target_line.char_count);
1695    target_line.global_char_start + new_col
1696}
1697
1698/// Move to start of current visual line. Returns the new global cursor position.
1699pub fn visual_line_home(visual_lines: &[VisualLine], cursor_pos: usize) -> usize {
1700    let (line, _col) = cursor_to_visual_pos(visual_lines, cursor_pos);
1701    visual_lines[line].global_char_start
1702}
1703
1704/// Move to end of current visual line. Returns the new global cursor position.
1705pub fn visual_line_end(visual_lines: &[VisualLine], cursor_pos: usize) -> usize {
1706    let (line, _col) = cursor_to_visual_pos(visual_lines, cursor_pos);
1707    visual_lines[line].global_char_start + visual_lines[line].char_count
1708}
1709
1710/// Find the nearest character boundary for a given pixel x-position.
1711/// `char_x_positions` has len = char_count + 1 (position 0 = left edge, position n = right edge).
1712pub fn find_nearest_char_boundary(click_x: f32, char_x_positions: &[f32]) -> usize {
1713    if char_x_positions.is_empty() {
1714        return 0;
1715    }
1716    let mut best = 0;
1717    let mut best_dist = f32::MAX;
1718    for (i, &x) in char_x_positions.iter().enumerate() {
1719        let dist = (click_x - x).abs();
1720        if dist < best_dist {
1721            best_dist = dist;
1722            best = i;
1723        }
1724    }
1725    best
1726}
1727
1728/// Find the word boundary to the left of `pos` (for Ctrl+Left / Ctrl+Backspace).
1729pub fn find_word_boundary_left(text: &str, pos: usize) -> usize {
1730    if pos == 0 {
1731        return 0;
1732    }
1733    let chars: Vec<char> = text.chars().collect();
1734    let mut i = pos.min(chars.len());
1735    // Skip whitespace to the left of cursor
1736    while i > 0 && chars[i - 1].is_whitespace() {
1737        i -= 1;
1738    }
1739    // Skip word characters to the left
1740    while i > 0 && !chars[i - 1].is_whitespace() {
1741        i -= 1;
1742    }
1743    i
1744}
1745
1746/// Find the word boundary to the right of `pos` (for Ctrl+Right / Ctrl+Delete).
1747/// Skips whitespace first, then stops at the end of the next word.
1748pub fn find_word_boundary_right(text: &str, pos: usize) -> usize {
1749    let chars: Vec<char> = text.chars().collect();
1750    let len = chars.len();
1751    if pos >= len {
1752        return len;
1753    }
1754    let mut i = pos;
1755    // Skip whitespace to the right
1756    while i < len && chars[i].is_whitespace() {
1757        i += 1;
1758    }
1759    // Skip non-whitespace (word) to the right
1760    while i < len && !chars[i].is_whitespace() {
1761        i += 1;
1762    }
1763    i
1764}
1765
1766/// Find the delete boundary to the right of `pos` (for Ctrl+Delete).
1767/// Deletes the current word AND trailing whitespace (skips word → skips spaces).
1768pub fn find_word_delete_boundary_right(text: &str, pos: usize) -> usize {
1769    let chars: Vec<char> = text.chars().collect();
1770    let len = chars.len();
1771    if pos >= len {
1772        return len;
1773    }
1774    let mut i = pos;
1775    // Skip non-whitespace (current word) to the right
1776    while i < len && !chars[i].is_whitespace() {
1777        i += 1;
1778    }
1779    // Skip whitespace to the right
1780    while i < len && chars[i].is_whitespace() {
1781        i += 1;
1782    }
1783    i
1784}
1785
1786/// Find the word boundaries (start, end) at the given character position.
1787/// Used for double-click word selection.
1788pub fn find_word_at(text: &str, pos: usize) -> (usize, usize) {
1789    let chars: Vec<char> = text.chars().collect();
1790    let len = chars.len();
1791    if len == 0 || pos >= len {
1792        return (pos, pos);
1793    }
1794    let is_word_char = |c: char| !c.is_whitespace();
1795    if !is_word_char(chars[pos]) {
1796        // On whitespace — select the whitespace run
1797        let mut start = pos;
1798        while start > 0 && !is_word_char(chars[start - 1]) {
1799            start -= 1;
1800        }
1801        let mut end = pos;
1802        while end < len && !is_word_char(chars[end]) {
1803            end += 1;
1804        }
1805        return (start, end);
1806    }
1807    // On a word char — find word boundaries
1808    let mut start = pos;
1809    while start > 0 && is_word_char(chars[start - 1]) {
1810        start -= 1;
1811    }
1812    let mut end = pos;
1813    while end < len && is_word_char(chars[end]) {
1814        end += 1;
1815    }
1816    (start, end)
1817}
1818
1819/// Build the display text for rendering.
1820/// Returns the string that should be measured/drawn.
1821pub fn display_text(text: &str, placeholder: &str, is_password: bool) -> String {
1822    if text.is_empty() {
1823        return placeholder.to_string();
1824    }
1825    if is_password {
1826        "•".repeat(text.chars().count())
1827    } else {
1828        text.to_string()
1829    }
1830}
1831
1832/// When text-styling is enabled, the raw string contains markup like `{red|...}` and
1833/// escape sequences like `\{`. The user-visible "visual" positions ignore all markup.
1834///
1835/// These helpers convert between visual positions (what the user sees / cursor_pos)
1836/// and raw char positions (byte-level indices into the raw string).
1837///
1838/// Terminology:
1839/// - "raw position" = char index into the full raw string (including markup)
1840/// - "visual position" = char index into the displayed (stripped) text
1841#[cfg(feature = "text-styling")]
1842pub mod styling {
1843    /// Escape a character that would be interpreted as styling markup.
1844    /// Characters `{`, `}`, `|`, and `\` are prefixed with `\`.
1845    pub fn escape_char(ch: char) -> String {
1846        match ch {
1847            '{' | '}' | '|' | '\\' => format!("\\{}", ch),
1848            _ => ch.to_string(),
1849        }
1850    }
1851
1852    /// Escape all styling-significant characters in a string.
1853    pub fn escape_str(s: &str) -> String {
1854        let mut result = String::with_capacity(s.len());
1855        for ch in s.chars() {
1856            match ch {
1857                '{' | '}' | '|' | '\\' => {
1858                    result.push('\\');
1859                    result.push(ch);
1860                }
1861                _ => result.push(ch),
1862            }
1863        }
1864        result
1865    }
1866
1867    /// Convert a visual (display) cursor position to a raw char position.
1868    ///
1869    /// Visual elements include:
1870    /// - Visible characters (escaped chars like `\{` count as one)
1871    /// - `}` closing a style tag (the "exit tag" position)
1872    /// - Empty content area of an empty `{name|}` tag
1873    ///
1874    /// `{name|` headers are transparent and occupy no visual positions.
1875    ///
1876    /// Position 0 always maps to raw 0.
1877    /// For visible char positions, the result is advanced past any following
1878    /// `{name|` headers so the cursor lands inside the tag content.
1879    pub fn cursor_to_raw(raw: &str, visual_pos: usize) -> usize {
1880        if visual_pos == 0 {
1881            return 0;
1882        }
1883
1884        let chars: Vec<char> = raw.chars().collect();
1885        let len = chars.len();
1886        let mut visual = 0usize;
1887        let mut raw_idx = 0usize;
1888        let mut escaped = false;
1889        let mut in_style_def = false;
1890
1891        while raw_idx < len {
1892            let c = chars[raw_idx];
1893
1894            if escaped {
1895                // Escaped char is a visible element
1896                visual += 1;
1897                escaped = false;
1898                raw_idx += 1;
1899                if visual == visual_pos {
1900                    return skip_tag_headers(&chars, raw_idx);
1901                }
1902                continue;
1903            }
1904
1905            match c {
1906                '\\' => {
1907                    escaped = true;
1908                    raw_idx += 1;
1909                }
1910                '{' if !in_style_def => {
1911                    in_style_def = true;
1912                    raw_idx += 1;
1913                }
1914                '|' if in_style_def => {
1915                    in_style_def = false;
1916                    // Check for empty content: if next char is `}`
1917                    if raw_idx + 1 < len && chars[raw_idx + 1] == '}' {
1918                        // Empty content marker — counts as a visual element
1919                        visual += 1;
1920                        raw_idx += 1; // now at `}`
1921                        if visual == visual_pos {
1922                            return raw_idx; // position at `}`, i.e. between `|` and `}`
1923                        }
1924                        // The `}` itself will be processed in the next iteration
1925                    } else {
1926                        raw_idx += 1;
1927                    }
1928                }
1929                '}' if !in_style_def => {
1930                    // Closing brace counts as a visual element (exit tag position)
1931                    visual += 1;
1932                    raw_idx += 1;
1933                    if visual == visual_pos {
1934                        // After `}` — DON'T skip tag headers; cursor is outside the tag
1935                        return raw_idx;
1936                    }
1937                }
1938                _ if in_style_def => {
1939                    raw_idx += 1;
1940                }
1941                _ => {
1942                    // Regular visible character
1943                    visual += 1;
1944                    raw_idx += 1;
1945                    if visual == visual_pos {
1946                        // After visible char — skip past any following `{name|` headers
1947                        // so the cursor lands inside the next tag's content area
1948                        return skip_tag_headers(&chars, raw_idx);
1949                    }
1950                }
1951            }
1952        }
1953
1954        len
1955    }
1956
1957    /// Skip past `{name|` tag headers starting at `pos`.
1958    /// Returns the position after all consecutive tag headers.
1959    fn skip_tag_headers(chars: &[char], pos: usize) -> usize {
1960        let len = chars.len();
1961        let mut p = pos;
1962        while p < len && chars[p] == '{' {
1963            let mut j = p + 1;
1964            while j < len && chars[j] != '|' && chars[j] != '}' {
1965                j += 1;
1966            }
1967            if j < len && chars[j] == '|' {
1968                p = j + 1; // skip past the `|`
1969            } else {
1970                break; // Not a valid tag header
1971            }
1972        }
1973        p
1974    }
1975
1976    /// Convert a raw char position to a visual (display) cursor position.
1977    /// Accounts for `}` and empty content positions.
1978    pub fn raw_to_cursor(raw: &str, raw_pos: usize) -> usize {
1979        let chars: Vec<char> = raw.chars().collect();
1980        let len = chars.len();
1981        let mut visual = 0usize;
1982        let mut raw_idx = 0usize;
1983        let mut escaped = false;
1984        let mut in_style_def = false;
1985
1986        while raw_idx < len && raw_idx < raw_pos {
1987            let c = chars[raw_idx];
1988
1989            if escaped {
1990                visual += 1;
1991                escaped = false;
1992                raw_idx += 1;
1993                continue;
1994            }
1995
1996            match c {
1997                '\\' => {
1998                    escaped = true;
1999                    raw_idx += 1;
2000                }
2001                '{' if !in_style_def => {
2002                    in_style_def = true;
2003                    raw_idx += 1;
2004                }
2005                '|' if in_style_def => {
2006                    in_style_def = false;
2007                    // Check for empty content
2008                    if raw_idx + 1 < len && chars[raw_idx + 1] == '}' {
2009                        visual += 1; // empty content position
2010                        raw_idx += 1; // now at `}`
2011                        // Don't increment raw_idx again; `}` will be processed next
2012                    } else {
2013                        raw_idx += 1;
2014                    }
2015                }
2016                '}' if !in_style_def => {
2017                    visual += 1; // exit tag position
2018                    raw_idx += 1;
2019                }
2020                _ if in_style_def => {
2021                    raw_idx += 1;
2022                }
2023                _ => {
2024                    visual += 1;
2025                    raw_idx += 1;
2026                }
2027            }
2028        }
2029
2030        visual
2031    }
2032
2033    /// Count the total number of visual positions in a raw styled string.
2034    /// Includes visible chars, `}` exit positions, and empty content positions.
2035    pub fn cursor_len(raw: &str) -> usize {
2036        raw_to_cursor(raw, raw.chars().count())
2037    }
2038
2039    /// If `pos` (a raw char index) points to the start of an empty `{name|}` tag,
2040    /// advance into it (returning the position between `|` and `}`).
2041    /// Handles consecutive empty tags by entering each one.
2042    fn enter_empty_tags_at(chars: &[char], pos: usize) -> usize {
2043        let len = chars.len();
2044        let mut p = pos;
2045        while p < len && chars[p] == '{' {
2046            // Scan for `|`
2047            let mut j = p + 1;
2048            while j < len && chars[j] != '|' && chars[j] != '}' {
2049                j += 1;
2050            }
2051            if j < len && chars[j] == '|' {
2052                // Found `{name|`, check if content is empty (next char is `}`)
2053                if j + 1 < len && chars[j + 1] == '}' {
2054                    // Empty tag! Enter it (position between `|` and `}`)
2055                    p = j + 1;
2056                } else {
2057                    break; // Non-empty tag — don't enter
2058                }
2059            } else {
2060                break; // Not a valid style tag
2061            }
2062        }
2063        p
2064    }
2065
2066    /// Like `cursor_to_raw`, but also enters empty style tags at the boundary
2067    /// when the basic position lands right before one.
2068    /// Use this for cursor positioning and single-point insertion.
2069    pub fn cursor_to_raw_for_insertion(raw: &str, visual_pos: usize) -> usize {
2070        let pos = cursor_to_raw(raw, visual_pos);
2071        let chars: Vec<char> = raw.chars().collect();
2072        // If pos lands right at the start of an empty {name|} tag, enter it
2073        enter_empty_tags_at(&chars, pos)
2074    }
2075
2076    /// Insert a (pre-escaped) string at the given visual position in the raw text.
2077    /// Returns the new raw string and the new visual cursor position after insertion.
2078    /// Enters empty style tags at the cursor boundary so typed text goes inside them.
2079    pub fn insert_at_visual(raw: &str, visual_pos: usize, insert: &str) -> (String, usize) {
2080        let raw_pos = cursor_to_raw_for_insertion(raw, visual_pos);
2081        let byte_pos = super::char_index_to_byte(raw, raw_pos);
2082        let mut new_raw = String::with_capacity(raw.len() + insert.len());
2083        new_raw.push_str(&raw[..byte_pos]);
2084        new_raw.push_str(insert);
2085        new_raw.push_str(&raw[byte_pos..]);
2086        let inserted_visual = cursor_len(insert);
2087        (new_raw, visual_pos + inserted_visual)
2088    }
2089
2090    /// Delete visible characters in the visual range `[visual_start, visual_end)`.
2091    /// Preserves all style tag structure (`{name|`, `}`) and only removes the content
2092    /// characters that fall within the visual range.
2093    pub fn delete_visual_range(raw: &str, visual_start: usize, visual_end: usize) -> String {
2094        if visual_start >= visual_end {
2095            return raw.to_string();
2096        }
2097
2098        let chars: Vec<char> = raw.chars().collect();
2099        let len = chars.len();
2100        let mut result = String::with_capacity(raw.len());
2101        let mut visual = 0usize;
2102        let mut i = 0;
2103        let mut in_style_def = false;
2104
2105        while i < len {
2106            let c = chars[i];
2107
2108            match c {
2109                '\\' if !in_style_def && i + 1 < len => {
2110                    // Escaped pair `\X` counts as one visible char
2111                    let in_range = visual >= visual_start && visual < visual_end;
2112                    if !in_range {
2113                        result.push(c);
2114                        result.push(chars[i + 1]);
2115                    }
2116                    visual += 1;
2117                    i += 2;
2118                }
2119                '{' if !in_style_def => {
2120                    in_style_def = true;
2121                    result.push(c); // Always keep tag structure
2122                    i += 1;
2123                }
2124                '|' if in_style_def => {
2125                    in_style_def = false;
2126                    result.push(c);
2127                    // Check for empty content
2128                    if i + 1 < len && chars[i + 1] == '}' {
2129                        visual += 1; // Empty content has a visual position but is structural
2130                    }
2131                    i += 1;
2132                }
2133                '}' if !in_style_def => {
2134                    result.push(c); // Always keep `}`
2135                    visual += 1; // `}` has a visual position but is structural
2136                    i += 1;
2137                }
2138                _ if in_style_def => {
2139                    result.push(c); // Tag name chars — always keep
2140                    i += 1;
2141                }
2142                _ => {
2143                    let in_range = visual >= visual_start && visual < visual_end;
2144                    if !in_range {
2145                        result.push(c);
2146                    }
2147                    visual += 1;
2148                    i += 1;
2149                }
2150            }
2151        }
2152
2153        result
2154    }
2155
2156    /// Remove empty style tags (`{style|}`) from the raw string,
2157    /// EXCEPT those that contain the cursor. A cursor is "inside" an empty
2158    /// style tag if its visual position equals the visual position of that tag's content area.
2159    ///
2160    /// Returns the new raw string and the (possibly adjusted) visual cursor position.
2161    pub fn cleanup_empty_styles(raw: &str, cursor_visual_pos: usize) -> (String, usize) {
2162        let chars: Vec<char> = raw.chars().collect();
2163        let len = chars.len();
2164        let mut result = String::with_capacity(raw.len());
2165        let mut i = 0;
2166        let mut visual = 0usize;
2167        let mut escaped = false;
2168        let mut cursor_adj = cursor_visual_pos;
2169
2170        // We need to track style nesting to correctly identify empty tags
2171        while i < len {
2172            let c = chars[i];
2173
2174            if escaped {
2175                result.push(c);
2176                visual += 1;
2177                escaped = false;
2178                i += 1;
2179                continue;
2180            }
2181
2182            match c {
2183                '\\' => {
2184                    escaped = true;
2185                    result.push(c);
2186                    i += 1;
2187                }
2188                '{' => {
2189                    // Look ahead: find the matching `|`, then check if there's content
2190                    // before the closing `}`. Pattern: `{...| <content> }`
2191                    // Find the `|` that ends this style definition
2192                    let mut j = i + 1;
2193                    let mut style_escaped = false;
2194                    let mut found_pipe = false;
2195                    while j < len {
2196                        if style_escaped {
2197                            style_escaped = false;
2198                            j += 1;
2199                            continue;
2200                        }
2201                        if chars[j] == '\\' {
2202                            style_escaped = true;
2203                            j += 1;
2204                            continue;
2205                        }
2206                        if chars[j] == '|' {
2207                            found_pipe = true;
2208                            j += 1; // j now points to first char after `|`
2209                            break;
2210                        }
2211                        if chars[j] == '{' {
2212                            // Nested `{` inside style def — not valid but push through
2213                            j += 1;
2214                            continue;
2215                        }
2216                        j += 1;
2217                    }
2218
2219                    if !found_pipe {
2220                        // Malformed — just push as-is
2221                        result.push(c);
2222                        i += 1;
2223                        continue;
2224                    }
2225
2226                    // j points to first char after `|`
2227                    // Now scan for the closing `}` and check if there's any visible content
2228                    let _content_start_raw = j;
2229                    let mut k = j;
2230                    let mut content_escaped = false;
2231                    let mut has_visible_content = false;
2232                    let mut nesting = 1; // Track nested style tags
2233                    while k < len && nesting > 0 {
2234                        if content_escaped {
2235                            has_visible_content = true;
2236                            content_escaped = false;
2237                            k += 1;
2238                            continue;
2239                        }
2240                        match chars[k] {
2241                            '\\' => {
2242                                content_escaped = true;
2243                                k += 1;
2244                            }
2245                            '{' => {
2246                                // Nested style opening
2247                                nesting += 1;
2248                                k += 1;
2249                            }
2250                            '}' => {
2251                                nesting -= 1;
2252                                if nesting == 0 {
2253                                    break; // k points to the closing `}`
2254                                }
2255                                k += 1;
2256                            }
2257                            '|' => {
2258                                // Could be pipe inside nested style def
2259                                k += 1;
2260                            }
2261                            _ => {
2262                                has_visible_content = true;
2263                                k += 1;
2264                            }
2265                        }
2266                    }
2267
2268                    if !has_visible_content && nesting == 0 {
2269                        // This is an empty style tag: `{style| <possibly nested empty tags> }`
2270                        // In the new model, empty content is at visual position `visual`
2271                        // and `}` is at visual position `visual + 1`.
2272                        // Keep the tag if cursor is at either position.
2273                        let cursor_is_inside = cursor_visual_pos == visual
2274                            || cursor_visual_pos == visual + 1;
2275                        if cursor_is_inside {
2276                            // Keep the tag — push everything from i to k (inclusive)
2277                            for idx in i..=k {
2278                                result.push(chars[idx]);
2279                            }
2280                            visual += 2; // empty content + }
2281                        } else {
2282                            // Remove the entire tag
2283                            // Adjust cursor if it was after this tag
2284                            if cursor_adj > visual {
2285                                cursor_adj = cursor_adj.saturating_sub(2);
2286                            }
2287                        }
2288                        i = k + 1;
2289                    } else {
2290                        // Non-empty style tag — keep the entire header `{...|`
2291                        // j points to the first char after `|`
2292                        for idx in i..j {
2293                            result.push(chars[idx]);
2294                        }
2295                        // Header is transparent — no visual increment
2296                        i = j;
2297                    }
2298                }
2299                '}' => {
2300                    result.push(c);
2301                    visual += 1; // } has a visual position in the new model
2302                    i += 1;
2303                }
2304                _ => {
2305                    result.push(c);
2306                    visual += 1;
2307                    i += 1;
2308                }
2309            }
2310        }
2311
2312        (result, cursor_adj)
2313    }
2314
2315    /// Get the visual character at a given visual position, or None if past end.
2316    pub fn visual_char_at(raw: &str, visual_pos: usize) -> Option<char> {
2317        let raw_pos = cursor_to_raw(raw, visual_pos);
2318        let chars: Vec<char> = raw.chars().collect();
2319        if raw_pos >= chars.len() {
2320            return None;
2321        }
2322        // If the char at raw_pos is `\`, the visible char is the next one
2323        if chars[raw_pos] == '\\' && raw_pos + 1 < chars.len() {
2324            Some(chars[raw_pos + 1])
2325        } else {
2326            Some(chars[raw_pos])
2327        }
2328    }
2329
2330    /// Strip all styling markup from a raw string, returning only visible text.
2331    pub fn strip_styling(raw: &str) -> String {
2332        let mut result = String::new();
2333        let mut escaped = false;
2334        let mut in_style_def = false;
2335        for c in raw.chars() {
2336            if escaped {
2337                result.push(c);
2338                escaped = false;
2339                continue;
2340            }
2341            match c {
2342                '\\' => { escaped = true; }
2343                '{' if !in_style_def => { in_style_def = true; }
2344                '|' if in_style_def => { in_style_def = false; }
2345                '}' if !in_style_def => { /* closing tag, skip */ }
2346                _ if in_style_def => { /* inside style def, skip */ }
2347                _ => { result.push(c); }
2348            }
2349        }
2350        result
2351    }
2352
2353    /// Convert a "structural visual" position (includes } and empty content markers)
2354    /// to a "content position" (just visible chars, matching strip_styling output).
2355    /// Content position is clamped to stripped text length.
2356    pub fn cursor_to_content(raw: &str, cursor_pos: usize) -> usize {
2357        let chars: Vec<char> = raw.chars().collect();
2358        let len = chars.len();
2359        let mut visual = 0usize;
2360        let mut content = 0usize;
2361        let mut escaped = false;
2362        let mut in_style_def = false;
2363
2364        for i in 0..len {
2365            if visual >= cursor_pos {
2366                break;
2367            }
2368            let c = chars[i];
2369
2370            if escaped {
2371                visual += 1;
2372                content += 1;
2373                escaped = false;
2374                continue;
2375            }
2376
2377            match c {
2378                '\\' => { escaped = true; }
2379                '{' if !in_style_def => { in_style_def = true; }
2380                '|' if in_style_def => {
2381                    in_style_def = false;
2382                    if i + 1 < len && chars[i + 1] == '}' {
2383                        visual += 1; // empty content position (not a real content char)
2384                    }
2385                }
2386                '}' if !in_style_def => {
2387                    visual += 1; // } position (not a real content char)
2388                }
2389                _ if in_style_def => {}
2390                _ => {
2391                    visual += 1;
2392                    content += 1;
2393                }
2394            }
2395        }
2396
2397        content
2398    }
2399
2400    /// Convert a "content position" (from strip_styling output) back to a
2401    /// "structural visual" position (includes } and empty content markers).
2402    ///
2403    /// When `skip_structural` is true, returns the visual position immediately
2404    /// before the `content_pos`-th visible character — or at the end of the
2405    /// visual text when `content_pos` equals the content length.  This means
2406    /// the cursor only ever lands on visible-character boundaries (used by
2407    /// `no_styles_movement`).
2408    pub fn content_to_cursor(raw: &str, content_pos: usize, snap_to_content: bool) -> usize {
2409        let chars: Vec<char> = raw.chars().collect();
2410        let len = chars.len();
2411        let mut visual = 0usize;
2412        let mut content = 0usize;
2413        let mut escaped = false;
2414        let mut in_style_def = false;
2415
2416        if snap_to_content {
2417            // No-structural mode: check `content >= content_pos` BEFORE advancing
2418            for i in 0..len {
2419                let c = chars[i];
2420
2421                if escaped {
2422                    if content >= content_pos {
2423                        return visual;
2424                    }
2425                    visual += 1;
2426                    content += 1;
2427                    escaped = false;
2428                    continue;
2429                }
2430
2431                match c {
2432                    '\\' => { escaped = true; }
2433                    '{' if !in_style_def => { in_style_def = true; }
2434                    '|' if in_style_def => {
2435                        in_style_def = false;
2436                        if i + 1 < len && chars[i + 1] == '}' {
2437                            visual += 1; // empty content marker — skip
2438                        }
2439                    }
2440                    '}' if !in_style_def => {
2441                        visual += 1; // } exit marker — skip
2442                    }
2443                    _ if in_style_def => {}
2444                    _ => {
2445                        if content >= content_pos {
2446                            return visual;
2447                        }
2448                        visual += 1;
2449                        content += 1;
2450                    }
2451                }
2452            }
2453        } else {
2454            // Structural mode: break when `content >= content_pos` at top of loop
2455            for i in 0..len {
2456                if content >= content_pos {
2457                    break;
2458                }
2459                let c = chars[i];
2460
2461                if escaped {
2462                    visual += 1;
2463                    content += 1;
2464                    escaped = false;
2465                    continue;
2466                }
2467
2468                match c {
2469                    '\\' => { escaped = true; }
2470                    '{' if !in_style_def => { in_style_def = true; }
2471                    '|' if in_style_def => {
2472                        in_style_def = false;
2473                        if i + 1 < len && chars[i + 1] == '}' {
2474                            visual += 1; // empty content
2475                        }
2476                    }
2477                    '}' if !in_style_def => {
2478                        visual += 1; // } position
2479                    }
2480                    _ if in_style_def => {}
2481                    _ => {
2482                        visual += 1;
2483                        content += 1;
2484                    }
2485                }
2486            }
2487        }
2488
2489        visual
2490    }
2491
2492    /// Delete content characters in `[content_start, content_end)` from the
2493    /// raw styled string, preserving all structural/tag characters.
2494    pub fn delete_content_range(raw: &str, content_start: usize, content_end: usize) -> String {
2495        if content_start >= content_end {
2496            return raw.to_string();
2497        }
2498
2499        let chars: Vec<char> = raw.chars().collect();
2500        let len = chars.len();
2501        let mut result = String::with_capacity(raw.len());
2502        let mut content = 0usize;
2503        let mut i = 0;
2504        let mut in_style_def = false;
2505
2506        while i < len {
2507            let c = chars[i];
2508
2509            match c {
2510                '\\' if !in_style_def && i + 1 < len => {
2511                    let in_range = content >= content_start && content < content_end;
2512                    if !in_range {
2513                        result.push(c);
2514                        result.push(chars[i + 1]);
2515                    }
2516                    content += 1;
2517                    i += 2;
2518                }
2519                '{' if !in_style_def => {
2520                    in_style_def = true;
2521                    result.push(c);
2522                    i += 1;
2523                }
2524                '|' if in_style_def => {
2525                    in_style_def = false;
2526                    result.push(c);
2527                    i += 1;
2528                }
2529                '}' if !in_style_def => {
2530                    result.push(c);
2531                    i += 1;
2532                }
2533                _ if in_style_def => {
2534                    result.push(c);
2535                    i += 1;
2536                }
2537                _ => {
2538                    let in_range = content >= content_start && content < content_end;
2539                    if !in_range {
2540                        result.push(c);
2541                    }
2542                    content += 1;
2543                    i += 1;
2544                }
2545            }
2546        }
2547
2548        result
2549    }
2550
2551    /// Find word boundary left in visual space.
2552    /// Returns a visual position.
2553    pub fn find_word_boundary_left_visual(raw: &str, visual_pos: usize) -> usize {
2554        let cp = cursor_to_content(raw, visual_pos);
2555        let stripped = strip_styling(raw);
2556        let boundary = super::find_word_boundary_left(&stripped, cp);
2557        content_to_cursor(raw, boundary, false)
2558    }
2559
2560    /// Find word boundary right in visual space.
2561    /// Returns a visual position.
2562    pub fn find_word_boundary_right_visual(raw: &str, visual_pos: usize) -> usize {
2563        let cp = cursor_to_content(raw, visual_pos);
2564        let stripped = strip_styling(raw);
2565        let boundary = super::find_word_boundary_right(&stripped, cp);
2566        content_to_cursor(raw, boundary, false)
2567    }
2568
2569    /// Find word delete boundary right in visual space (skips word then spaces).
2570    /// Used for Ctrl+Delete to delete word + trailing whitespace.
2571    pub fn find_word_delete_boundary_right_visual(raw: &str, visual_pos: usize) -> usize {
2572        let cp = cursor_to_content(raw, visual_pos);
2573        let stripped = strip_styling(raw);
2574        let boundary = super::find_word_delete_boundary_right(&stripped, cp);
2575        content_to_cursor(raw, boundary, false)
2576    }
2577
2578    /// Find word at a visual position (for double-click selection).
2579    /// Returns (start, end) in visual positions.
2580    pub fn find_word_at_visual(raw: &str, visual_pos: usize) -> (usize, usize) {
2581        let cp = cursor_to_content(raw, visual_pos);
2582        let stripped = strip_styling(raw);
2583        let (s, e) = super::find_word_at(&stripped, cp);
2584        (content_to_cursor(raw, s, false), content_to_cursor(raw, e, false))
2585    }
2586
2587    /// Count the number of hard lines (\n-separated) in a styled raw string.
2588    pub fn styled_line_count(raw: &str) -> usize {
2589        // Newlines in the raw text map 1:1 to hard lines regardless of styling
2590        raw.chars().filter(|&c| c == '\n').count() + 1
2591    }
2592
2593    /// Return (line_index, visual_column) for a visual cursor position in styled text.
2594    /// Lines are \n-separated. Visual column is the visual offset from line start.
2595    pub fn line_and_column_styled(raw: &str, visual_pos: usize) -> (usize, usize) {
2596        // Walk through the raw text, tracking visual position and line number.
2597        let chars: Vec<char> = raw.chars().collect();
2598        let len = chars.len();
2599        let mut visual = 0usize;
2600        let mut line = 0usize;
2601        let mut line_start_visual = 0usize;
2602        let mut escaped = false;
2603        let mut in_style_def = false;
2604
2605        for i in 0..len {
2606            if visual >= visual_pos {
2607                break;
2608            }
2609            let c = chars[i];
2610
2611            if escaped {
2612                visual += 1;
2613                escaped = false;
2614                continue;
2615            }
2616
2617            match c {
2618                '\\' => { escaped = true; }
2619                '\n' => {
2620                    visual += 1; // newline is a visible character
2621                    line += 1;
2622                    line_start_visual = visual;
2623                }
2624                '{' if !in_style_def => { in_style_def = true; }
2625                '|' if in_style_def => {
2626                    in_style_def = false;
2627                    if i + 1 < len && chars[i + 1] == '}' {
2628                        visual += 1; // empty content position
2629                    }
2630                }
2631                '}' if !in_style_def => {
2632                    visual += 1;
2633                }
2634                _ if in_style_def => {}
2635                _ => {
2636                    visual += 1;
2637                }
2638            }
2639        }
2640
2641        (line, visual_pos.saturating_sub(line_start_visual))
2642    }
2643
2644    /// Return the visual position of the start of line `line_idx` (0-based).
2645    pub fn line_start_visual_styled(raw: &str, line_idx: usize) -> usize {
2646        if line_idx == 0 {
2647            return 0;
2648        }
2649        let chars: Vec<char> = raw.chars().collect();
2650        let len = chars.len();
2651        let mut visual = 0usize;
2652        let mut line = 0usize;
2653        let mut escaped = false;
2654        let mut in_style_def = false;
2655
2656        for i in 0..len {
2657            let c = chars[i];
2658            if escaped {
2659                visual += 1;
2660                escaped = false;
2661                continue;
2662            }
2663            match c {
2664                '\\' => { escaped = true; }
2665                '\n' => {
2666                    visual += 1;
2667                    line += 1;
2668                    if line == line_idx {
2669                        return visual;
2670                    }
2671                }
2672                '{' if !in_style_def => { in_style_def = true; }
2673                '|' if in_style_def => {
2674                    in_style_def = false;
2675                    if i + 1 < len && chars[i + 1] == '}' {
2676                        visual += 1;
2677                    }
2678                }
2679                '}' if !in_style_def => { visual += 1; }
2680                _ if in_style_def => {}
2681                _ => { visual += 1; }
2682            }
2683        }
2684        visual // past last line
2685    }
2686
2687    /// Return the visual position of the end of line `line_idx` (0-based).
2688    pub fn line_end_visual_styled(raw: &str, line_idx: usize) -> usize {
2689        let chars: Vec<char> = raw.chars().collect();
2690        let len = chars.len();
2691        let mut visual = 0usize;
2692        let mut line = 0usize;
2693        let mut escaped = false;
2694        let mut in_style_def = false;
2695
2696        for i in 0..len {
2697            let c = chars[i];
2698            if escaped {
2699                visual += 1;
2700                escaped = false;
2701                continue;
2702            }
2703            match c {
2704                '\\' => { escaped = true; }
2705                '\n' => {
2706                    if line == line_idx {
2707                        return visual;
2708                    }
2709                    visual += 1;
2710                    line += 1;
2711                }
2712                '{' if !in_style_def => { in_style_def = true; }
2713                '|' if in_style_def => {
2714                    in_style_def = false;
2715                    if i + 1 < len && chars[i + 1] == '}' {
2716                        visual += 1;
2717                    }
2718                }
2719                '}' if !in_style_def => { visual += 1; }
2720                _ if in_style_def => {}
2721                _ => { visual += 1; }
2722            }
2723        }
2724        visual // last line ends at total visual length
2725    }
2726
2727    #[cfg(test)]
2728    mod tests {
2729        use super::*;
2730
2731        #[test]
2732        fn test_escape_char() {
2733            assert_eq!(escape_char('a'), "a");
2734            assert_eq!(escape_char('{'), "\\{");
2735            assert_eq!(escape_char('}'), "\\}");
2736            assert_eq!(escape_char('|'), "\\|");
2737            assert_eq!(escape_char('\\'), "\\\\");
2738        }
2739
2740        #[test]
2741        fn test_escape_str() {
2742            assert_eq!(escape_str("hello"), "hello");
2743            assert_eq!(escape_str("a{b}c"), "a\\{b\\}c");
2744            assert_eq!(escape_str("x|y\\z"), "x\\|y\\\\z");
2745        }
2746
2747        #[test]
2748        fn test_cursor_to_raw_no_styling() {
2749            // Plain text: visual == raw
2750            assert_eq!(cursor_to_raw("hello", 0), 0);
2751            assert_eq!(cursor_to_raw("hello", 3), 3);
2752            assert_eq!(cursor_to_raw("hello", 5), 5);
2753        }
2754
2755        #[test]
2756        fn test_cursor_to_raw_with_escape() {
2757            // "hel\{lo" → visual "hel{lo"
2758            let raw = r"hel\{lo";
2759            assert_eq!(cursor_to_raw(raw, 0), 0); // before h
2760            assert_eq!(cursor_to_raw(raw, 3), 3); // before \{  → raw pos 3 (the \)
2761            assert_eq!(cursor_to_raw(raw, 4), 5); // after { → raw pos 5 (l)
2762            assert_eq!(cursor_to_raw(raw, 5), 6); // after l → raw pos 6 (o)
2763            assert_eq!(cursor_to_raw(raw, 6), 7); // end
2764        }
2765
2766        #[test]
2767        fn test_cursor_to_raw_with_style() {
2768            // "{red|world}" → visual positions: w(1) o(2) r(3) l(4) d(5) }(6)
2769            let raw = "{red|world}";
2770            assert_eq!(cursor_to_raw(raw, 0), 0);  // before tag = raw 0
2771            // Position 0 with skip_tag_headers: raw 0 is the { → skip {red| → raw 5
2772            // But visual_pos == 0 returns raw 0 directly!
2773            assert_eq!(cursor_to_raw(raw, 1), 6);  // after 'w' (raw 5), returns raw 6
2774            assert_eq!(cursor_to_raw(raw, 5), 10); // after 'd' (raw 9), returns raw 10
2775            assert_eq!(cursor_to_raw(raw, 6), 11); // after '}' (raw 10), returns raw 11
2776        }
2777
2778        #[test]
2779        fn test_cursor_to_raw_mixed() {
2780            // "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)
2781            let raw = r"hel\{lo{red|world}";
2782            assert_eq!(cursor_to_raw(raw, 0), 0);  // before everything
2783            assert_eq!(cursor_to_raw(raw, 3), 3);  // after 'l', before \{
2784            assert_eq!(cursor_to_raw(raw, 4), 5);  // after \{, before 'l'
2785            assert_eq!(cursor_to_raw(raw, 6), 12); // after 'o', skip {red| → raw 12 (before 'w')
2786            assert_eq!(cursor_to_raw(raw, 11), 17); // after 'd', before '}'
2787            assert_eq!(cursor_to_raw(raw, 12), 18); // after '}' = end
2788        }
2789
2790        #[test]
2791        fn test_raw_to_cursor_no_styling() {
2792            assert_eq!(raw_to_cursor("hello", 0), 0);
2793            assert_eq!(raw_to_cursor("hello", 3), 3);
2794            assert_eq!(raw_to_cursor("hello", 5), 5);
2795        }
2796
2797        #[test]
2798        fn test_raw_to_cursor_with_escape() {
2799            let raw = r"hel\{lo";
2800            assert_eq!(raw_to_cursor(raw, 0), 0);
2801            assert_eq!(raw_to_cursor(raw, 3), 3); // at the \ 
2802            assert_eq!(raw_to_cursor(raw, 5), 4); // at l after \{
2803            assert_eq!(raw_to_cursor(raw, 7), 6); // end
2804        }
2805
2806        #[test]
2807        fn test_raw_to_cursor_with_style() {
2808            // "{red|world}" → visual: w(1) o(2) r(3) l(4) d(5) }(6)
2809            let raw = "{red|world}";
2810            assert_eq!(raw_to_cursor(raw, 0), 0);
2811            assert_eq!(raw_to_cursor(raw, 5), 0);  // just after {red| (before content starts)
2812            assert_eq!(raw_to_cursor(raw, 6), 1);  // after 'w'
2813            assert_eq!(raw_to_cursor(raw, 10), 5); // after 'd'
2814            assert_eq!(raw_to_cursor(raw, 11), 6); // after '}' — the exit tag position
2815        }
2816
2817        #[test]
2818        fn test_cursor_len() {
2819            assert_eq!(cursor_len("hello"), 5);
2820            assert_eq!(cursor_len("{red|world}"), 6);  // 5 chars + 1 for }
2821            assert_eq!(cursor_len(r"hel\{lo{red|world}"), 12); // 11 chars + 1 for }
2822            assert_eq!(cursor_len(r"\\\{"), 2); // \\ → \, \{ → {
2823            assert_eq!(cursor_len("{red|}"), 2); // empty content + }
2824        }
2825
2826        #[test]
2827        fn test_insert_at_visual() {
2828            let (new, pos) = insert_at_visual("{red|hello}", 3, "XY");
2829            // visual "hello", insert "XY" at pos 3 → "helXYlo"
2830            // raw: {red| + hel + XY + lo + }
2831            assert_eq!(new, "{red|helXYlo}");
2832            assert_eq!(pos, 5);
2833        }
2834
2835        #[test]
2836        fn test_delete_visual_range() {
2837            let new = delete_visual_range("{red|hello}", 1, 3);
2838            // visual "hello", delete visual 1..3 → remove "el" → "hlo"
2839            assert_eq!(new, "{red|hlo}");
2840        }
2841
2842        #[test]
2843        fn test_cleanup_empty_styles_removes_empty() {
2844            let (result, _) = cleanup_empty_styles("{red|}", 999);
2845            assert_eq!(result, ""); // cursor not inside, remove it
2846        }
2847
2848        #[test]
2849        fn test_cleanup_empty_styles_keeps_if_cursor_inside() {
2850            // cursor at visual 0 is "inside" the empty tag at visual 0
2851            let (result, _) = cleanup_empty_styles("{red|}", 0);
2852            assert_eq!(result, "{red|}"); // cursor inside, keep it
2853        }
2854
2855        #[test]
2856        fn test_cleanup_empty_styles_nonempty_kept() {
2857            let (result, _) = cleanup_empty_styles("{red|hello}", 999);
2858            assert_eq!(result, "{red|hello}");
2859        }
2860
2861        #[test]
2862        fn test_cleanup_preserves_text_after_empty() {
2863            // "something{red|}more"
2864            // cursor not at visual position of the empty tag content
2865            let raw = "something{red|}more";
2866            // "something" = 9 visual chars, the tag content is at visual 9
2867            let (result, _) = cleanup_empty_styles(raw, 0); // cursor at 0 = not inside tag
2868            assert_eq!(result, "somethingmore");
2869        }
2870
2871        #[test]
2872        fn test_cleanup_keeps_empty_when_cursor_at_content() {
2873            let raw = "something{red|}more";
2874            // tag content is at visual position 9
2875            let (result, _) = cleanup_empty_styles(raw, 9);
2876            assert_eq!(result, "something{red|}more");
2877        }
2878
2879        #[test]
2880        fn test_cleanup_nonempty_nested_visual_counting() {
2881            // Regression test: cleanup_empty_styles must not inflate visual counter
2882            // when processing non-empty style tag headers like `{color=red|..}`
2883            let raw = "{color=red|hello}world";
2884            // Visual: h(1)e(2)l(3)l(4)o(5) }(6) w(7)o(8)r(9)l(10)d(11)
2885            // Cursor at 11 (end) — cleanup should return same text, cursor at 11
2886            let (result, new_cursor) = cleanup_empty_styles(raw, 11);
2887            assert_eq!(result, raw);
2888            assert_eq!(new_cursor, 11);
2889
2890            // With empty tag after non-empty: "{color=red|hello}{blue|}"
2891            let raw2 = "{color=red|hello}{blue|}";
2892            // Visual: h(1)e(2)l(3)l(4)o(5) }(6) [empty](7) }(8)
2893            // Cursor at 8 (after both), empty tag should be removed
2894            let (result2, new_cursor2) = cleanup_empty_styles(raw2, 8);
2895            assert_eq!(result2, "{color=red|hello}");
2896            // Cursor was at 8, empty tag was at visual 6-7, cursor_adj = 8-2 = 6
2897            assert_eq!(new_cursor2, 6);
2898        }
2899
2900        #[test]
2901        fn test_cleanup_deeply_nested_nonempty() {
2902            // Deeply nested non-empty tags shouldn't inflate visual counter
2903            let raw = "aaa{r|{g|{b|xyz}}}end";
2904            // Visual: a(1)a(2)a(3) x(4)y(5)z(6) }(7) }(8) }(9) e(10)n(11)d(12)
2905            let vl = cursor_len(raw);
2906            assert_eq!(vl, 12);
2907            let (result, new_cursor) = cleanup_empty_styles(raw, vl);
2908            assert_eq!(result, raw);
2909            assert_eq!(new_cursor, vl);
2910        }
2911
2912        #[test]
2913        fn test_word_boundary_visual_nested_tags() {
2914            // Regression test for crash: ctrl+left on text with deeply nested tags
2915            // The cleanup_empty_styles visual inflation bug caused cursor_pos
2916            // to exceed content length, crashing find_word_boundary_left.
2917            let raw = "aaa{r|{r|{r|bbb}}} ccc";
2918            // Visual: a(1)a(2)a(3) b(4)b(5)b(6) }(7) }(8) }(9) (10)c(11)c(12)c(13)
2919            let vl = cursor_len(raw);
2920            assert_eq!(vl, 13);
2921
2922            // Word boundary at end should work
2923            let result = find_word_boundary_left_visual(raw, vl);
2924            assert!(result <= vl, "word boundary should not exceed visual len");
2925
2926            // Word boundary from every visual position should not panic
2927            for v in 0..=vl {
2928                let _ = find_word_boundary_left_visual(raw, v);
2929                let _ = find_word_boundary_right_visual(raw, v);
2930            }
2931        }
2932
2933        #[test]
2934        fn test_word_boundary_visual_after_cleanup() {
2935            // Simulate the crash scenario: text with nested non-empty tags,
2936            // cleanup_empty_styles was inflating visual counter, then word
2937            // boundary was called with the resulting bad cursor position.
2938            let raw = "aaa{color=red|{color=red|bbb}}} ccc";
2939            let vl = cursor_len(raw);
2940            // First, do a cleanup (simulating move_word_left_styled)
2941            let (cleaned, cursor) = cleanup_empty_styles(raw, vl);
2942            let cleaned_vl = cursor_len(&cleaned);
2943            assert!(cursor <= cleaned_vl,
2944                "cursor {} should be <= cursor_len {} after cleanup",
2945                cursor, cleaned_vl);
2946
2947            // Now call word boundary on the cleaned text
2948            let _ = find_word_boundary_left_visual(&cleaned, cursor);
2949        }
2950
2951        #[test]
2952        fn test_roundtrip_visual_raw() {
2953            let raw = r"hel\{lo{red|world}";
2954            // cursor_len = 12 (11 visible chars + 1 for })
2955            for v in 0..=12 {
2956                let r = cursor_to_raw(raw, v);
2957                let v2 = raw_to_cursor(raw, r);
2958                assert_eq!(v, v2, "visual {} → raw {} → visual {} (expected {})", v, r, v2, v);
2959            }
2960        }
2961
2962        #[test]
2963        fn test_cursor_to_raw_for_insertion_enters_empty_tag() {
2964            // "test{red|}" — visual: t(1) e(2) s(3) t(4) [empty content](5) }(6)
2965            let raw = "test{red|}";
2966            // Position 4 skips tag header: raw 4 → skip {red| → raw 9 (inside empty tag)
2967            assert_eq!(cursor_to_raw(raw, 4), 9);
2968            // Position 5 = empty content marker → raw 9
2969            assert_eq!(cursor_to_raw(raw, 5), 9);
2970            // Position 6 = after } → raw 10
2971            assert_eq!(cursor_to_raw(raw, 6), 10);
2972            // Cursor variant at pos 4: cursor_to_raw returns 9, enter_empty_tags finds nothing
2973            assert_eq!(cursor_to_raw_for_insertion(raw, 4), 9);
2974        }
2975
2976        #[test]
2977        fn test_cursor_to_raw_for_insertion_nonempty_tag_not_entered() {
2978            // "test{red|x}" — visual: t(1) e(2) s(3) t(4) x(5) }(6)
2979            let raw = "test{red|x}";
2980            // Position 4 skips tag header: raw 4 → skip {red| → raw 9 (inside tag, before 'x')
2981            assert_eq!(cursor_to_raw(raw, 4), 9);
2982            assert_eq!(cursor_to_raw_for_insertion(raw, 4), 9);
2983        }
2984
2985        #[test]
2986        fn test_cursor_to_raw_for_insertion_at_start() {
2987            // "{red|}hello" — visual: [empty content](1) }(2) h(3) e(4) l(5) l(6) o(7)
2988            let raw = "{red|}hello";
2989            // Position 0 = raw 0 (before everything)
2990            assert_eq!(cursor_to_raw(raw, 0), 0);
2991            // Cursor variant enters the empty tag at raw 0 → {red|} → raw 5
2992            assert_eq!(cursor_to_raw_for_insertion(raw, 0), 5);
2993            // Position 1 = empty content → raw 5
2994            assert_eq!(cursor_to_raw(raw, 1), 5);
2995            // Position 2 = after } → raw 6
2996            assert_eq!(cursor_to_raw(raw, 2), 6);
2997        }
2998
2999        #[test]
3000        fn test_insert_at_visual_enters_empty_tag() {
3001            // Insertion at cursor position should go inside empty tag
3002            let raw = "test{red|}";
3003            let (new, pos) = insert_at_visual(raw, 4, "X");
3004            // X should go inside {red|}, not before it
3005            assert_eq!(new, "test{red|X}");
3006            assert_eq!(pos, 5);
3007        }
3008
3009        #[test]
3010        fn test_insert_at_visual_empty_tag_middle() {
3011            // "hello{red|}world" — insert at visual 5 (between "hello" and "world")
3012            let raw = "hello{red|}world";
3013            let (new, pos) = insert_at_visual(raw, 5, "X");
3014            assert_eq!(new, "hello{red|X}world");
3015            assert_eq!(pos, 6);
3016        }
3017
3018        #[test]
3019        fn test_user_scenario_backspace_to_empty_tag() {
3020            // User's example: "hel\{lo{red|world}" — visual "hel{loworld"
3021            // cursor at visual 11 (end), backspace 5 times to visual 6
3022            // Expected result: "hel\{lo{red|}" with cursor at 6 (inside empty tag)
3023
3024            // Simulate: start with the full text, delete chars at visual 6..11
3025            let raw = r"hel\{lo{red|world}";
3026            // Delete visual range [6, 11) — removes "world"
3027            let after_delete = delete_visual_range(raw, 6, 11);
3028            assert_eq!(after_delete, r"hel\{lo{red|}");
3029
3030            // Cursor is now at visual 6. The {red|} tag is empty.
3031            // cleanup_empty_styles at visual 6: tag content is at visual 6 → keep it
3032            let (cleaned, _) = cleanup_empty_styles(&after_delete, 6);
3033            assert_eq!(cleaned, r"hel\{lo{red|}");
3034
3035            // Typing inside the empty tag: insert "X" at visual 6
3036            let (after_insert, new_pos) = insert_at_visual(&cleaned, 6, "X");
3037            // X should go inside {red|}
3038            assert_eq!(after_insert, r"hel\{lo{red|X}");
3039            assert_eq!(new_pos, 7);
3040
3041            // Moving cursor away (to visual 5): cleanup removes empty tag
3042            // First make it empty again
3043            let empty_again = delete_visual_range(&after_insert, 6, 7);
3044            assert_eq!(empty_again, r"hel\{lo{red|}");
3045            let (after_move, _) = cleanup_empty_styles(&empty_again, 5);
3046            assert_eq!(after_move, r"hel\{lo");
3047        }
3048    }
3049}
3050
3051/// Compute x-positions for each character boundary in the display text.
3052/// Returns a Vec with len = char_count + 1.
3053/// Uses the provided measure function to measure substrings.
3054pub fn compute_char_x_positions(
3055    display_text: &str,
3056    font_asset: Option<&'static crate::renderer::FontAsset>,
3057    font_size: u16,
3058    measure_fn: &dyn Fn(&str, &crate::text::TextConfig) -> crate::math::Dimensions,
3059) -> Vec<f32> {
3060    let char_count = display_text.chars().count();
3061    let mut positions = Vec::with_capacity(char_count + 1);
3062    positions.push(0.0);
3063
3064    let config = crate::text::TextConfig {
3065        font_asset,
3066        font_size,
3067        ..Default::default()
3068    };
3069
3070    #[cfg(feature = "text-styling")]
3071    {
3072        // When text-styling is enabled, the display text contains markup like {red|...}
3073        // and escape sequences like \{. We must avoid measuring prefixes that end inside
3074        // a tag header ({name|) because measure_fn warns on incomplete style definitions.
3075        // For non-visible chars (tag headers, braces, backslashes), reuse the last width.
3076        let chars: Vec<char> = display_text.chars().collect();
3077        let mut in_tag_header = false;
3078        let mut escaped = false;
3079        let mut last_width = 0.0f32;
3080
3081        for i in 0..char_count {
3082            let ch = chars[i];
3083            if escaped {
3084                escaped = false;
3085                // Escaped char is visible: measure the prefix up to this char
3086                let byte_end = char_index_to_byte(display_text, i + 1);
3087                let substr = &display_text[..byte_end];
3088                let dims = measure_fn(substr, &config);
3089                last_width = dims.width;
3090                positions.push(last_width);
3091                continue;
3092            }
3093            match ch {
3094                '\\' => {
3095                    escaped = true;
3096                    // Backslash itself is not visible
3097                    positions.push(last_width);
3098                }
3099                '{' => {
3100                    in_tag_header = true;
3101                    positions.push(last_width);
3102                }
3103                '|' if in_tag_header => {
3104                    in_tag_header = false;
3105                    positions.push(last_width);
3106                }
3107                '}' => {
3108                    // Closing brace is not visible
3109                    positions.push(last_width);
3110                }
3111                _ if in_tag_header => {
3112                    // Inside tag name — not visible
3113                    positions.push(last_width);
3114                }
3115                _ => {
3116                    // Visible character: measure the prefix
3117                    let byte_end = char_index_to_byte(display_text, i + 1);
3118                    let substr = &display_text[..byte_end];
3119                    let dims = measure_fn(substr, &config);
3120                    last_width = dims.width;
3121                    positions.push(last_width);
3122                }
3123            }
3124        }
3125    }
3126
3127    #[cfg(not(feature = "text-styling"))]
3128    {
3129        for i in 1..=char_count {
3130            let byte_end = char_index_to_byte(display_text, i);
3131            let substr = &display_text[..byte_end];
3132            let dims = measure_fn(substr, &config);
3133            positions.push(dims.width);
3134        }
3135    }
3136
3137    positions
3138}
3139
3140#[cfg(test)]
3141mod tests {
3142    use super::*;
3143
3144    #[test]
3145    fn test_char_index_to_byte_ascii() {
3146        let s = "Hello";
3147        assert_eq!(char_index_to_byte(s, 0), 0);
3148        assert_eq!(char_index_to_byte(s, 3), 3);
3149        assert_eq!(char_index_to_byte(s, 5), 5);
3150    }
3151
3152    #[test]
3153    fn test_char_index_to_byte_unicode() {
3154        let s = "Héllo";
3155        assert_eq!(char_index_to_byte(s, 0), 0);
3156        assert_eq!(char_index_to_byte(s, 1), 1); // 'H'
3157        assert_eq!(char_index_to_byte(s, 2), 3); // 'é' is 2 bytes
3158        assert_eq!(char_index_to_byte(s, 5), 6);
3159    }
3160
3161    #[test]
3162    fn test_word_boundary_left() {
3163        assert_eq!(find_word_boundary_left("hello world", 11), 6);
3164        assert_eq!(find_word_boundary_left("hello world", 6), 0); // at start of "world", skip space + "hello"
3165        assert_eq!(find_word_boundary_left("hello world", 5), 0);
3166        assert_eq!(find_word_boundary_left("hello", 0), 0);
3167    }
3168
3169    #[test]
3170    fn test_word_boundary_right() {
3171        assert_eq!(find_word_boundary_right("hello world", 0), 5);  // end of "hello"
3172        assert_eq!(find_word_boundary_right("hello world", 5), 11); // skip space, end of "world"
3173        assert_eq!(find_word_boundary_right("hello world", 6), 11);
3174        assert_eq!(find_word_boundary_right("hello", 5), 5);
3175    }
3176
3177    #[test]
3178    fn test_find_word_at() {
3179        assert_eq!(find_word_at("hello world", 2), (0, 5));
3180        assert_eq!(find_word_at("hello world", 7), (6, 11));
3181        assert_eq!(find_word_at("hello world", 5), (5, 6)); // on space
3182    }
3183
3184    #[test]
3185    fn test_insert_text() {
3186        let mut state = TextEditState::default();
3187        state.insert_text("Hello", None);
3188        assert_eq!(state.text, "Hello");
3189        assert_eq!(state.cursor_pos, 5);
3190
3191        state.cursor_pos = 5;
3192        state.insert_text(" World", None);
3193        assert_eq!(state.text, "Hello World");
3194        assert_eq!(state.cursor_pos, 11);
3195    }
3196
3197    #[test]
3198    fn test_insert_text_max_length() {
3199        let mut state = TextEditState::default();
3200        state.insert_text("Hello World", Some(5));
3201        assert_eq!(state.text, "Hello");
3202        assert_eq!(state.cursor_pos, 5);
3203
3204        // Already at max, no more insertion
3205        state.insert_text("!", Some(5));
3206        assert_eq!(state.text, "Hello");
3207    }
3208
3209    #[test]
3210    fn test_backspace() {
3211        let mut state = TextEditState::default();
3212        state.text = "Hello".to_string();
3213        state.cursor_pos = 5;
3214        state.backspace();
3215        assert_eq!(state.text, "Hell");
3216        assert_eq!(state.cursor_pos, 4);
3217    }
3218
3219    #[test]
3220    fn test_delete_forward() {
3221        let mut state = TextEditState::default();
3222        state.text = "Hello".to_string();
3223        state.cursor_pos = 0;
3224        state.delete_forward();
3225        assert_eq!(state.text, "ello");
3226        assert_eq!(state.cursor_pos, 0);
3227    }
3228
3229    #[test]
3230    fn test_selection_delete() {
3231        let mut state = TextEditState::default();
3232        state.text = "Hello World".to_string();
3233        state.selection_anchor = Some(0);
3234        state.cursor_pos = 5;
3235        state.delete_selection();
3236        assert_eq!(state.text, " World");
3237        assert_eq!(state.cursor_pos, 0);
3238        assert!(state.selection_anchor.is_none());
3239    }
3240
3241    #[test]
3242    fn test_select_all() {
3243        let mut state = TextEditState::default();
3244        state.text = "Hello".to_string();
3245        state.cursor_pos = 2;
3246        state.select_all();
3247        assert_eq!(state.selection_anchor, Some(0));
3248        assert_eq!(state.cursor_pos, 5);
3249    }
3250
3251    #[test]
3252    fn test_move_left_right() {
3253        let mut state = TextEditState::default();
3254        state.text = "AB".to_string();
3255        state.cursor_pos = 1;
3256
3257        state.move_left(false);
3258        assert_eq!(state.cursor_pos, 0);
3259
3260        state.move_right(false);
3261        assert_eq!(state.cursor_pos, 1);
3262    }
3263
3264    #[test]
3265    fn test_move_with_shift_creates_selection() {
3266        let mut state = TextEditState::default();
3267        state.text = "Hello".to_string();
3268        state.cursor_pos = 2;
3269
3270        state.move_right(true);
3271        assert_eq!(state.cursor_pos, 3);
3272        assert_eq!(state.selection_anchor, Some(2));
3273
3274        state.move_right(true);
3275        assert_eq!(state.cursor_pos, 4);
3276        assert_eq!(state.selection_anchor, Some(2));
3277    }
3278
3279    #[test]
3280    fn test_display_text_normal() {
3281        assert_eq!(display_text("Hello", "Placeholder", false), "Hello");
3282    }
3283
3284    #[test]
3285    fn test_display_text_empty() {
3286        assert_eq!(display_text("", "Placeholder", false), "Placeholder");
3287    }
3288
3289    #[test]
3290    fn test_display_text_password() {
3291        assert_eq!(display_text("pass", "Placeholder", true), "••••");
3292    }
3293
3294    #[test]
3295    fn test_nearest_char_boundary() {
3296        let positions = vec![0.0, 10.0, 20.0, 30.0];
3297        assert_eq!(find_nearest_char_boundary(4.0, &positions), 0);
3298        assert_eq!(find_nearest_char_boundary(6.0, &positions), 1);
3299        assert_eq!(find_nearest_char_boundary(15.0, &positions), 1); // midpoint rounds to closer
3300        assert_eq!(find_nearest_char_boundary(25.0, &positions), 2);
3301        assert_eq!(find_nearest_char_boundary(100.0, &positions), 3);
3302    }
3303
3304    #[test]
3305    fn test_ensure_cursor_visible() {
3306        let mut state = TextEditState::default();
3307        state.scroll_offset = 0.0;
3308
3309        // Cursor at x=150, visible_width=100 → should scroll right
3310        state.ensure_cursor_visible(150.0, 100.0);
3311        assert_eq!(state.scroll_offset, 50.0);
3312
3313        // Cursor at x=30, scroll_offset=50 → 30-50 = -20 < 0 → scroll left
3314        state.ensure_cursor_visible(30.0, 100.0);
3315        assert_eq!(state.scroll_offset, 30.0);
3316    }
3317
3318    #[test]
3319    fn test_backspace_word() {
3320        let mut state = TextEditState::default();
3321        state.text = "hello world".to_string();
3322        state.cursor_pos = 11;
3323        state.backspace_word();
3324        assert_eq!(state.text, "hello ");
3325        assert_eq!(state.cursor_pos, 6);
3326    }
3327
3328    #[test]
3329    fn test_delete_word_forward() {
3330        let mut state = TextEditState::default();
3331        state.text = "hello world".to_string();
3332        state.cursor_pos = 0;
3333        state.delete_word_forward();
3334        assert_eq!(state.text, "world");
3335        assert_eq!(state.cursor_pos, 0);
3336    }
3337
3338    // ── Multiline helper tests ──
3339
3340    #[test]
3341    fn test_line_start_char_pos() {
3342        assert_eq!(line_start_char_pos("hello\nworld", 0), 0);
3343        assert_eq!(line_start_char_pos("hello\nworld", 3), 0);
3344        assert_eq!(line_start_char_pos("hello\nworld", 5), 0);
3345        assert_eq!(line_start_char_pos("hello\nworld", 6), 6); // 'w' on second line
3346        assert_eq!(line_start_char_pos("hello\nworld", 9), 6);
3347    }
3348
3349    #[test]
3350    fn test_line_end_char_pos() {
3351        assert_eq!(line_end_char_pos("hello\nworld", 0), 5);
3352        assert_eq!(line_end_char_pos("hello\nworld", 3), 5);
3353        assert_eq!(line_end_char_pos("hello\nworld", 6), 11);
3354        assert_eq!(line_end_char_pos("hello\nworld", 9), 11);
3355    }
3356
3357    #[test]
3358    fn test_line_and_column() {
3359        assert_eq!(line_and_column("hello\nworld", 0), (0, 0));
3360        assert_eq!(line_and_column("hello\nworld", 3), (0, 3));
3361        assert_eq!(line_and_column("hello\nworld", 5), (0, 5)); // at '\n'
3362        assert_eq!(line_and_column("hello\nworld", 6), (1, 0));
3363        assert_eq!(line_and_column("hello\nworld", 8), (1, 2));
3364        assert_eq!(line_and_column("hello\nworld", 11), (1, 5)); // end of text
3365    }
3366
3367    #[test]
3368    fn test_line_and_column_three_lines() {
3369        let text = "ab\ncd\nef";
3370        assert_eq!(line_and_column(text, 0), (0, 0));
3371        assert_eq!(line_and_column(text, 2), (0, 2)); // at '\n'
3372        assert_eq!(line_and_column(text, 3), (1, 0));
3373        assert_eq!(line_and_column(text, 5), (1, 2)); // at '\n'
3374        assert_eq!(line_and_column(text, 6), (2, 0));
3375        assert_eq!(line_and_column(text, 8), (2, 2)); // end
3376    }
3377
3378    #[test]
3379    fn test_char_pos_from_line_col() {
3380        assert_eq!(char_pos_from_line_col("hello\nworld", 0, 0), 0);
3381        assert_eq!(char_pos_from_line_col("hello\nworld", 0, 3), 3);
3382        assert_eq!(char_pos_from_line_col("hello\nworld", 1, 0), 6);
3383        assert_eq!(char_pos_from_line_col("hello\nworld", 1, 3), 9);
3384        // Column exceeds line length → clamp to end of line
3385        assert_eq!(char_pos_from_line_col("ab\ncd", 0, 10), 2); // line 0 ends at char 2
3386        assert_eq!(char_pos_from_line_col("ab\ncd", 1, 10), 5); // line 1 goes to end
3387    }
3388
3389    #[test]
3390    fn test_split_lines() {
3391        let lines = split_lines("hello\nworld");
3392        assert_eq!(lines.len(), 2);
3393        assert_eq!(lines[0], (0, "hello"));
3394        assert_eq!(lines[1], (6, "world"));
3395
3396        let lines2 = split_lines("a\nb\nc");
3397        assert_eq!(lines2.len(), 3);
3398        assert_eq!(lines2[0], (0, "a"));
3399        assert_eq!(lines2[1], (2, "b"));
3400        assert_eq!(lines2[2], (4, "c"));
3401
3402        let lines3 = split_lines("no newlines");
3403        assert_eq!(lines3.len(), 1);
3404        assert_eq!(lines3[0], (0, "no newlines"));
3405    }
3406
3407    #[test]
3408    fn test_split_lines_trailing_newline() {
3409        let lines = split_lines("hello\n");
3410        assert_eq!(lines.len(), 2);
3411        assert_eq!(lines[0], (0, "hello"));
3412        assert_eq!(lines[1], (6, ""));
3413    }
3414
3415    #[test]
3416    fn test_move_up_down() {
3417        let mut state = TextEditState::default();
3418        state.text = "hello\nworld".to_string();
3419        state.cursor_pos = 8; // 'r' on line 1, col 2
3420
3421        state.move_up(false);
3422        assert_eq!(state.cursor_pos, 2); // line 0, col 2
3423
3424        state.move_down(false);
3425        assert_eq!(state.cursor_pos, 8); // back to line 1, col 2
3426    }
3427
3428    #[test]
3429    fn test_move_up_clamps_column() {
3430        let mut state = TextEditState::default();
3431        state.text = "ab\nhello".to_string();
3432        state.cursor_pos = 7; // line 1, col 4 (before 'o')
3433
3434        state.move_up(false);
3435        assert_eq!(state.cursor_pos, 2); // line 0 only has 2 chars, clamp to end
3436    }
3437
3438    #[test]
3439    fn test_move_up_from_first_line() {
3440        let mut state = TextEditState::default();
3441        state.text = "hello\nworld".to_string();
3442        state.cursor_pos = 3;
3443
3444        state.move_up(false);
3445        assert_eq!(state.cursor_pos, 0); // moves to start
3446    }
3447
3448    #[test]
3449    fn test_move_down_from_last_line() {
3450        let mut state = TextEditState::default();
3451        state.text = "hello\nworld".to_string();
3452        state.cursor_pos = 8;
3453
3454        state.move_down(false);
3455        assert_eq!(state.cursor_pos, 11); // moves to end
3456    }
3457
3458    #[test]
3459    fn test_move_line_home_end() {
3460        let mut state = TextEditState::default();
3461        state.text = "hello\nworld".to_string();
3462        state.cursor_pos = 8; // line 1, col 2
3463
3464        state.move_line_home(false);
3465        assert_eq!(state.cursor_pos, 6); // start of line 1
3466
3467        state.move_line_end(false);
3468        assert_eq!(state.cursor_pos, 11); // end of line 1
3469    }
3470
3471    #[test]
3472    fn test_move_up_with_shift_selects() {
3473        let mut state = TextEditState::default();
3474        state.text = "hello\nworld".to_string();
3475        state.cursor_pos = 8;
3476
3477        state.move_up(true);
3478        assert_eq!(state.cursor_pos, 2);
3479        assert_eq!(state.selection_anchor, Some(8));
3480    }
3481
3482    #[test]
3483    fn test_ensure_cursor_visible_vertical() {
3484        let mut state = TextEditState::default();
3485        state.scroll_offset_y = 0.0;
3486
3487        // Cursor on line 5, line_height=20, visible_height=60
3488        // cursor_bottom = 5*20+20 = 120 > 60 → scroll down
3489        state.ensure_cursor_visible_vertical(5, 20.0, 60.0);
3490        assert_eq!(state.scroll_offset_y, 60.0); // 120 - 60
3491
3492        // Cursor on line 1, scroll_offset_y=60 → cursor_y = 20 < 60 → scroll up
3493        state.ensure_cursor_visible_vertical(1, 20.0, 60.0);
3494        assert_eq!(state.scroll_offset_y, 20.0);
3495    }
3496
3497    // ── Word wrapping tests ──
3498
3499    /// Simple fixed-width measure: each char is 10px wide.
3500    fn fixed_measure(text: &str, _config: &crate::text::TextConfig) -> crate::math::Dimensions {
3501        crate::math::Dimensions {
3502            width: text.chars().count() as f32 * 10.0,
3503            height: 20.0,
3504        }
3505    }
3506
3507    #[test]
3508    fn test_wrap_lines_no_wrap_needed() {
3509        let lines = wrap_lines("hello", 100.0, None, 16, &fixed_measure);
3510        assert_eq!(lines.len(), 1);
3511        assert_eq!(lines[0].text, "hello");
3512        assert_eq!(lines[0].global_char_start, 0);
3513        assert_eq!(lines[0].char_count, 5);
3514    }
3515
3516    #[test]
3517    fn test_wrap_lines_hard_break() {
3518        let lines = wrap_lines("ab\ncd", 100.0, None, 16, &fixed_measure);
3519        assert_eq!(lines.len(), 2);
3520        assert_eq!(lines[0].text, "ab");
3521        assert_eq!(lines[0].global_char_start, 0);
3522        assert_eq!(lines[1].text, "cd");
3523        assert_eq!(lines[1].global_char_start, 3); // after '\n'
3524    }
3525
3526    #[test]
3527    fn test_wrap_lines_word_wrap() {
3528        // "hello world" = 11 chars × 10px = 110px, max_width=60px
3529        // "hello " = 6 chars = 60px fits, then "world" = 5 chars = 50px fits
3530        let lines = wrap_lines("hello world", 60.0, None, 16, &fixed_measure);
3531        assert_eq!(lines.len(), 2);
3532        assert_eq!(lines[0].text, "hello ");
3533        assert_eq!(lines[0].global_char_start, 0);
3534        assert_eq!(lines[0].char_count, 6);
3535        assert_eq!(lines[1].text, "world");
3536        assert_eq!(lines[1].global_char_start, 6);
3537        assert_eq!(lines[1].char_count, 5);
3538    }
3539
3540    #[test]
3541    fn test_wrap_lines_char_level_break() {
3542        // "abcdefghij" = 10 chars × 10px = 100px, max_width=50px
3543        // No spaces → character-level break at 5 chars
3544        let lines = wrap_lines("abcdefghij", 50.0, None, 16, &fixed_measure);
3545        assert_eq!(lines.len(), 2);
3546        assert_eq!(lines[0].text, "abcde");
3547        assert_eq!(lines[0].char_count, 5);
3548        assert_eq!(lines[1].text, "fghij");
3549        assert_eq!(lines[1].global_char_start, 5);
3550    }
3551
3552    #[test]
3553    fn test_cursor_to_visual_pos_simple() {
3554        let lines = vec![
3555            VisualLine { text: "hello ".to_string(), global_char_start: 0, char_count: 6 },
3556            VisualLine { text: "world".to_string(), global_char_start: 6, char_count: 5 },
3557        ];
3558        assert_eq!(cursor_to_visual_pos(&lines, 0), (0, 0));
3559        assert_eq!(cursor_to_visual_pos(&lines, 3), (0, 3));
3560        assert_eq!(cursor_to_visual_pos(&lines, 6), (1, 0)); // Wrapped → start of next line
3561        assert_eq!(cursor_to_visual_pos(&lines, 8), (1, 2));
3562        assert_eq!(cursor_to_visual_pos(&lines, 11), (1, 5));
3563    }
3564
3565    #[test]
3566    fn test_cursor_to_visual_pos_hard_break() {
3567        // "ab\ncd" → line 0: "ab" (start=0, count=2), line 1: "cd" (start=3, count=2)
3568        let lines = vec![
3569            VisualLine { text: "ab".to_string(), global_char_start: 0, char_count: 2 },
3570            VisualLine { text: "cd".to_string(), global_char_start: 3, char_count: 2 },
3571        ];
3572        assert_eq!(cursor_to_visual_pos(&lines, 2), (0, 2)); // End of "ab" (before \n)
3573        assert_eq!(cursor_to_visual_pos(&lines, 3), (1, 0)); // Start of "cd"
3574    }
3575
3576    #[test]
3577    fn test_visual_move_up_down() {
3578        let lines = vec![
3579            VisualLine { text: "hello ".to_string(), global_char_start: 0, char_count: 6 },
3580            VisualLine { text: "world".to_string(), global_char_start: 6, char_count: 5 },
3581        ];
3582        // From cursor at pos 8 (line 1, col 2) → move up → line 0, col 2 = pos 2
3583        assert_eq!(visual_move_up(&lines, 8), 2);
3584        // From cursor at pos 2 (line 0, col 2) → move down → line 1, col 2 = pos 8
3585        assert_eq!(visual_move_down(&lines, 2, 11), 8);
3586    }
3587
3588    #[test]
3589    fn test_visual_line_home_end() {
3590        let lines = vec![
3591            VisualLine { text: "hello ".to_string(), global_char_start: 0, char_count: 6 },
3592            VisualLine { text: "world".to_string(), global_char_start: 6, char_count: 5 },
3593        ];
3594        // Cursor at pos 8 (line 1, col 2) → home = 6, end = 11
3595        assert_eq!(visual_line_home(&lines, 8), 6);
3596        assert_eq!(visual_line_end(&lines, 8), 11);
3597        // Cursor at pos 3 (line 0, col 3) → home = 0, end = 6
3598        assert_eq!(visual_line_home(&lines, 3), 0);
3599        assert_eq!(visual_line_end(&lines, 3), 6);
3600    }
3601
3602    #[test]
3603    fn test_undo_basic() {
3604        let mut state = TextEditState::default();
3605        state.text = "hello".to_string();
3606        state.cursor_pos = 5;
3607
3608        // Push undo, then modify
3609        state.push_undo(UndoActionKind::Paste);
3610        state.insert_text(" world", None);
3611        assert_eq!(state.text, "hello world");
3612
3613        // Undo should restore
3614        assert!(state.undo());
3615        assert_eq!(state.text, "hello");
3616        assert_eq!(state.cursor_pos, 5);
3617
3618        // Redo should restore the edit
3619        assert!(state.redo());
3620        assert_eq!(state.text, "hello world");
3621        assert_eq!(state.cursor_pos, 11);
3622    }
3623
3624    #[test]
3625    fn test_undo_grouping_insert_char() {
3626        let mut state = TextEditState::default();
3627
3628        // Simulate typing "abc" one char at a time
3629        state.push_undo(UndoActionKind::InsertChar);
3630        state.insert_text("a", None);
3631        state.push_undo(UndoActionKind::InsertChar);
3632        state.insert_text("b", None);
3633        state.push_undo(UndoActionKind::InsertChar);
3634        state.insert_text("c", None);
3635        assert_eq!(state.text, "abc");
3636
3637        // Should undo all at once (grouped)
3638        assert!(state.undo());
3639        assert_eq!(state.text, "");
3640        assert_eq!(state.cursor_pos, 0);
3641
3642        // No more undos
3643        assert!(!state.undo());
3644    }
3645
3646    #[test]
3647    fn test_undo_grouping_backspace() {
3648        let mut state = TextEditState::default();
3649        state.text = "hello".to_string();
3650        state.cursor_pos = 5;
3651
3652        // Backspace 3 times
3653        state.push_undo(UndoActionKind::Backspace);
3654        state.backspace();
3655        state.push_undo(UndoActionKind::Backspace);
3656        state.backspace();
3657        state.push_undo(UndoActionKind::Backspace);
3658        state.backspace();
3659        assert_eq!(state.text, "he");
3660
3661        // Should undo all backspaces at once
3662        assert!(state.undo());
3663        assert_eq!(state.text, "hello");
3664    }
3665
3666    #[test]
3667    fn test_undo_different_kinds_not_grouped() {
3668        let mut state = TextEditState::default();
3669
3670        // Type then delete — different kinds, not grouped
3671        state.push_undo(UndoActionKind::InsertChar);
3672        state.insert_text("abc", None);
3673        state.push_undo(UndoActionKind::Backspace);
3674        state.backspace();
3675        assert_eq!(state.text, "ab");
3676
3677        // First undo restores before backspace
3678        assert!(state.undo());
3679        assert_eq!(state.text, "abc");
3680
3681        // Second undo restores before insert
3682        assert!(state.undo());
3683        assert_eq!(state.text, "");
3684    }
3685
3686    #[test]
3687    fn test_redo_cleared_on_new_edit() {
3688        let mut state = TextEditState::default();
3689
3690        state.push_undo(UndoActionKind::Paste);
3691        state.insert_text("hello", None);
3692        state.undo();
3693        assert_eq!(state.text, "");
3694        assert!(!state.redo_stack.is_empty());
3695
3696        // New edit should clear redo
3697        state.push_undo(UndoActionKind::Paste);
3698        state.insert_text("world", None);
3699        assert!(state.redo_stack.is_empty());
3700    }
3701
3702    #[test]
3703    fn test_undo_empty_stack() {
3704        let mut state = TextEditState::default();
3705        assert!(!state.undo());
3706        assert!(!state.redo());
3707    }
3708
3709    #[cfg(feature = "text-styling")]
3710    fn make_no_styles_state(raw: &str) -> TextEditState {
3711        let mut s = TextEditState::default();
3712        s.text = raw.to_string();
3713        s.no_styles_movement = true;
3714        // Snap the cursor to a content boundary at position 0
3715        s.cursor_pos = 0;
3716        s
3717    }
3718
3719    #[test]
3720    #[cfg(feature = "text-styling")]
3721    fn test_content_to_cursor_no_structural_basic() {
3722        use crate::text_input::styling::content_to_cursor;
3723        // "a{red|}b" — visual: a@0, empty@1, }@2, b@3. content: a,b
3724        assert_eq!(content_to_cursor("a{red|}b", 0, true), 0);  // before 'a'
3725        assert_eq!(content_to_cursor("a{red|}b", 1, true), 3);  // before 'b' (skip empty + })
3726        assert_eq!(content_to_cursor("a{red|}b", 2, true), 4);  // after 'b'
3727    }
3728
3729    #[test]
3730    #[cfg(feature = "text-styling")]
3731    fn test_content_to_cursor_no_structural_nested() {
3732        use crate::text_input::styling::content_to_cursor;
3733        // "a{red|b}{blue|c}" — visual: a@0, b@1, }@2, c@3, }@4. content: a,b,c
3734        assert_eq!(content_to_cursor("a{red|b}{blue|c}", 0, true), 0);
3735        assert_eq!(content_to_cursor("a{red|b}{blue|c}", 1, true), 1);  // before 'b'
3736        assert_eq!(content_to_cursor("a{red|b}{blue|c}", 2, true), 3);  // before 'c' (skip } + header)
3737        assert_eq!(content_to_cursor("a{red|b}{blue|c}", 3, true), 5);  // after last } (end of text)
3738    }
3739
3740    #[test]
3741    #[cfg(feature = "text-styling")]
3742    fn test_delete_content_range() {
3743        use crate::text_input::styling::delete_content_range;
3744        // Delete 'b' from "a{red|b}c" → "a{red|}c"
3745        assert_eq!(delete_content_range("a{red|b}c", 1, 2), "a{red|}c");
3746        // Delete 'a' from "a{red|b}c" → "{red|b}c"
3747        assert_eq!(delete_content_range("a{red|b}c", 0, 1), "{red|b}c");
3748        // Delete all content
3749        assert_eq!(delete_content_range("a{red|b}c", 0, 3), "{red|}");
3750        // No-op
3751        assert_eq!(delete_content_range("abc", 1, 1), "abc");
3752    }
3753
3754    #[test]
3755    #[cfg(feature = "text-styling")]
3756    fn test_no_styles_move_right() {
3757        let mut s = make_no_styles_state("a{red|}b");
3758        // cursor at 0 (before 'a'), move right.
3759        // Because cursor moves away from {red|}, the empty tag gets cleaned up.
3760        // Text becomes "ab" and cursor lands at content 1 (between a and b).
3761        s.move_right_styled(false);
3762        assert_eq!(s.text, "ab");
3763        assert_eq!(s.cursor_pos, 1);
3764        // move right again → after 'b' (end)
3765        s.move_right_styled(false);
3766        assert_eq!(s.cursor_pos, 2);
3767        assert_eq!(styling::cursor_to_content(&s.text, s.cursor_pos), 2);
3768    }
3769
3770    #[test]
3771    #[cfg(feature = "text-styling")]
3772    fn test_no_styles_move_left() {
3773        let mut s = make_no_styles_state("a{red|}b");
3774        // Put cursor at end — the empty {red|} tag will be cleaned up since
3775        // cursor at end is not inside it. Text becomes "ab", cursor at 2.
3776        s.cursor_pos = styling::content_to_cursor(&s.text, 2, true);
3777        // Trigger cleanup to normalise
3778        s.move_end_styled(false);
3779        assert_eq!(s.text, "ab");
3780        assert_eq!(s.cursor_pos, 2);
3781        // move left → before 'b' (content 1)
3782        s.move_left_styled(false);
3783        let cp = styling::cursor_to_content(&s.text, s.cursor_pos);
3784        assert_eq!(cp, 1);
3785        // move left → before 'a' (content 0)
3786        s.move_left_styled(false);
3787        assert_eq!(s.cursor_pos, 0);
3788    }
3789
3790    #[test]
3791    #[cfg(feature = "text-styling")]
3792    fn test_no_styles_move_left_skips_closing_brace() {
3793        let mut s = make_no_styles_state("a{red|b}c");
3794        // visual: a@0, b@1, }@2, c@3. Set cursor before 'c' (content 2 → visual 3).
3795        s.cursor_pos = styling::content_to_cursor(&s.text, 2, true);
3796        // 'b' is at visual 1, '}' at 2, 'c' at 3.  cursor should be at visual 3.
3797        assert_eq!(s.cursor_pos, 3);
3798        // Move left → before 'b' (content 1, visual 1)
3799        s.move_left_styled(false);
3800        let cp = styling::cursor_to_content(&s.text, s.cursor_pos);
3801        assert_eq!(cp, 1);
3802    }
3803
3804    #[test]
3805    #[cfg(feature = "text-styling")]
3806    fn test_no_styles_backspace() {
3807        let mut s = make_no_styles_state("a{red|b}c");
3808        // Put cursor at content 2 (before 'c')
3809        s.cursor_pos = styling::content_to_cursor(&s.text, 2, true);
3810        s.backspace_styled();
3811        // Should delete 'b', leaving "a...c"
3812        let stripped = styling::strip_styling(&s.text);
3813        assert_eq!(stripped, "ac");
3814        // Cursor should be at content 1 (between a and c)
3815        let cp = styling::cursor_to_content(&s.text, s.cursor_pos);
3816        assert_eq!(cp, 1);
3817    }
3818
3819    #[test]
3820    #[cfg(feature = "text-styling")]
3821    fn test_no_styles_delete_forward() {
3822        let mut s = make_no_styles_state("{red|abc}");
3823        // Put cursor at content 1 (before 'b')
3824        s.cursor_pos = styling::content_to_cursor(&s.text, 1, true);
3825        s.delete_forward_styled();
3826        let stripped = styling::strip_styling(&s.text);
3827        assert_eq!(stripped, "ac");
3828        let cp = styling::cursor_to_content(&s.text, s.cursor_pos);
3829        assert_eq!(cp, 1);
3830    }
3831
3832    #[test]
3833    #[cfg(feature = "text-styling")]
3834    fn test_no_styles_home_end() {
3835        let mut s = make_no_styles_state("{red|}hello{blue|}");
3836        // Home — should be at the first content char
3837        s.move_home_styled(false);
3838        let cp = styling::cursor_to_content(&s.text, s.cursor_pos);
3839        assert_eq!(cp, 0);
3840        // End
3841        s.move_end_styled(false);
3842        let cp = styling::cursor_to_content(&s.text, s.cursor_pos);
3843        let content_len = styling::strip_styling(&s.text).chars().count();
3844        assert_eq!(cp, content_len);
3845    }
3846
3847    #[test]
3848    #[cfg(feature = "text-styling")]
3849    fn test_no_styles_select_all_and_delete() {
3850        let mut s = make_no_styles_state("a{red|b}c");
3851        s.select_all_styled();
3852        assert!(s.selection_anchor.is_some());
3853        // Delete selection
3854        s.delete_selection_styled();
3855        let stripped = styling::strip_styling(&s.text);
3856        assert!(stripped.is_empty());
3857    }
3858}