Skip to main content

ftui_widgets/
textarea.rs

1//! Multi-line text editing widget.
2//!
3//! [`TextArea`] wraps [`Editor`] for text manipulation and
4//! provides Frame-based rendering with viewport scrolling and cursor display.
5//!
6//! # Example
7//! ```
8//! use ftui_widgets::textarea::{TextArea, TextAreaState};
9//!
10//! let mut ta = TextArea::new();
11//! ta.insert_text("Hello\nWorld");
12//! assert_eq!(ta.line_count(), 2);
13//! ```
14
15use ftui_core::event::{Event, KeyCode, KeyEvent, KeyEventKind, Modifiers};
16use ftui_core::geometry::Rect;
17use ftui_render::frame::Frame;
18use ftui_style::Style;
19use ftui_text::editor::{Editor, Selection};
20use ftui_text::wrap::display_width;
21use ftui_text::{CursorNavigator, CursorPosition};
22use unicode_segmentation::UnicodeSegmentation;
23
24use crate::{StatefulWidget, Widget, apply_style, clear_text_area, draw_text_span};
25
26/// Multi-line text editor widget.
27#[derive(Debug, Clone)]
28pub struct TextArea {
29    editor: Editor,
30    /// Placeholder text shown when empty.
31    placeholder: String,
32    /// Whether the widget has input focus.
33    focused: bool,
34    /// Show line numbers in gutter.
35    show_line_numbers: bool,
36    /// Base style.
37    style: Style,
38    /// Cursor line highlight style.
39    cursor_line_style: Option<Style>,
40    /// Selection highlight style.
41    selection_style: Style,
42    /// Placeholder style.
43    placeholder_style: Style,
44    /// Line number style.
45    line_number_style: Style,
46    /// Soft-wrap long lines.
47    ///
48    /// Visual wrapping preserves whitespace and breaks on word boundaries with
49    /// a grapheme fallback for long segments.
50    soft_wrap: bool,
51    /// Maximum height in lines (0 = unlimited / fill area).
52    max_height: usize,
53    /// Viewport scroll anchor (logical_line_idx, visual_wrap_offset).
54    ///
55    /// In soft-wrap mode, this tracks the first visible visual line.
56    /// In no-wrap mode, the second element is ignored (always 0).
57    scroll_anchor: std::cell::Cell<(usize, usize)>,
58    /// Horizontal scroll offset (visual columns).
59    scroll_left: std::cell::Cell<usize>,
60    /// Last viewport height for page movement and visibility checks.
61    #[allow(dead_code)]
62    last_viewport_height: std::cell::Cell<usize>,
63    /// Last viewport width for visibility checks.
64    last_viewport_width: std::cell::Cell<usize>,
65}
66
67impl Default for TextArea {
68    fn default() -> Self {
69        Self::new()
70    }
71}
72
73/// Render state tracked across frames.
74#[derive(Debug, Clone, Default)]
75pub struct TextAreaState {
76    /// Viewport height from last render.
77    pub last_viewport_height: u16,
78    /// Viewport width from last render.
79    pub last_viewport_width: u16,
80}
81
82#[derive(Debug, Clone)]
83struct WrappedSlice<'a> {
84    text: &'a str,
85    start_byte: usize,
86    start_col: usize,
87    width: usize,
88}
89
90impl TextArea {
91    /// Create a new empty text area.
92    #[must_use]
93    pub fn new() -> Self {
94        Self {
95            editor: Editor::new(),
96            placeholder: String::new(),
97            focused: false,
98            show_line_numbers: false,
99            style: Style::default(),
100            cursor_line_style: None,
101            selection_style: Style::new().reverse(),
102            placeholder_style: Style::new().dim(),
103            line_number_style: Style::new().dim(),
104            soft_wrap: false,
105            max_height: 0,
106            scroll_anchor: std::cell::Cell::new((usize::MAX, 0)), // sentinel
107            scroll_left: std::cell::Cell::new(0),
108            last_viewport_height: std::cell::Cell::new(0),
109            last_viewport_width: std::cell::Cell::new(0),
110        }
111    }
112
113    // ── Event Handling ─────────────────────────────────────────────
114
115    /// Handle a terminal event.
116    ///
117    /// Returns `true` if the state changed.
118    pub fn handle_event(&mut self, event: &Event) -> bool {
119        match event {
120            Event::Key(key)
121                if key.kind == KeyEventKind::Press || key.kind == KeyEventKind::Repeat =>
122            {
123                self.handle_key(key)
124            }
125            Event::Paste(paste) => {
126                self.insert_text(&paste.text);
127                true
128            }
129            _ => false,
130        }
131    }
132
133    fn handle_key(&mut self, key: &KeyEvent) -> bool {
134        let ctrl = key.modifiers.contains(Modifiers::CTRL);
135        let shift = key.modifiers.contains(Modifiers::SHIFT);
136        let _alt = key.modifiers.contains(Modifiers::ALT);
137
138        match key.code {
139            KeyCode::Char(c) if !ctrl => {
140                self.insert_char(c);
141                true
142            }
143            KeyCode::Enter => {
144                self.insert_newline();
145                true
146            }
147            KeyCode::Backspace => {
148                if ctrl {
149                    self.delete_word_backward();
150                } else {
151                    self.delete_backward();
152                }
153                true
154            }
155            KeyCode::Delete => {
156                if ctrl {
157                    self.delete_word_forward();
158                } else {
159                    self.delete_forward();
160                }
161                true
162            }
163            KeyCode::Left => {
164                if ctrl && shift {
165                    self.select_word_left();
166                } else if ctrl {
167                    self.move_word_left();
168                } else if shift {
169                    self.select_left();
170                } else {
171                    self.move_left();
172                }
173                true
174            }
175            KeyCode::Right => {
176                if ctrl && shift {
177                    self.select_word_right();
178                } else if ctrl {
179                    self.move_word_right();
180                } else if shift {
181                    self.select_right();
182                } else {
183                    self.move_right();
184                }
185                true
186            }
187            KeyCode::Up => {
188                if shift {
189                    self.select_up();
190                } else {
191                    self.move_up();
192                }
193                true
194            }
195            KeyCode::Down => {
196                if shift {
197                    self.select_down();
198                } else {
199                    self.move_down();
200                }
201                true
202            }
203            KeyCode::Home => {
204                self.move_to_line_start();
205                true
206            }
207            KeyCode::End => {
208                self.move_to_line_end();
209                true
210            }
211            KeyCode::PageUp => {
212                let page = self.last_viewport_height.get().max(1);
213                if self.soft_wrap {
214                    self.move_cursor_visual_up(page, shift);
215                } else {
216                    for _ in 0..page {
217                        if shift {
218                            self.editor.select_up();
219                        } else {
220                            self.editor.move_up();
221                        }
222                    }
223                }
224                self.ensure_cursor_visible();
225                true
226            }
227            KeyCode::PageDown => {
228                let page = self.last_viewport_height.get().max(1);
229                if self.soft_wrap {
230                    self.move_cursor_visual_down(page, shift);
231                } else {
232                    for _ in 0..page {
233                        if shift {
234                            self.editor.select_down();
235                        } else {
236                            self.editor.move_down();
237                        }
238                    }
239                }
240                self.ensure_cursor_visible();
241                true
242            }
243            KeyCode::Char('a') if ctrl => {
244                self.select_all();
245                true
246            }
247            // Ctrl+K: Delete to end of line (common emacs/shell binding)
248            KeyCode::Char('k') if ctrl => {
249                self.delete_to_end_of_line();
250                true
251            }
252            // Ctrl+Z: Undo
253            KeyCode::Char('z') if ctrl => {
254                self.undo();
255                true
256            }
257            // Ctrl+Y: Redo
258            KeyCode::Char('y') if ctrl => {
259                self.redo();
260                true
261            }
262            _ => false,
263        }
264    }
265
266    // ── Builder methods ────────────────────────────────────────────
267
268    /// Set initial text content (builder).
269    #[must_use]
270    pub fn with_text(mut self, text: &str) -> Self {
271        self.editor = Editor::with_text(text);
272        self.editor.move_to_document_start();
273        self
274    }
275
276    /// Set placeholder text (builder).
277    #[must_use]
278    pub fn with_placeholder(mut self, text: impl Into<String>) -> Self {
279        self.placeholder = text.into();
280        self
281    }
282
283    /// Set focused state (builder).
284    #[must_use]
285    pub fn with_focus(mut self, focused: bool) -> Self {
286        self.focused = focused;
287        self
288    }
289
290    /// Enable line numbers (builder).
291    #[must_use]
292    pub fn with_line_numbers(mut self, show: bool) -> Self {
293        self.show_line_numbers = show;
294        self
295    }
296
297    /// Set base style (builder).
298    #[must_use]
299    pub fn with_style(mut self, style: Style) -> Self {
300        self.style = style;
301        self
302    }
303
304    /// Set cursor line highlight style (builder).
305    #[must_use]
306    pub fn with_cursor_line_style(mut self, style: Style) -> Self {
307        self.cursor_line_style = Some(style);
308        self
309    }
310
311    /// Set selection style (builder).
312    #[must_use]
313    pub fn with_selection_style(mut self, style: Style) -> Self {
314        self.selection_style = style;
315        self
316    }
317
318    /// Enable soft wrapping (builder).
319    #[must_use]
320    pub fn with_soft_wrap(mut self, wrap: bool) -> Self {
321        self.soft_wrap = wrap;
322        self
323    }
324
325    /// Set maximum height in lines (builder). 0 = fill available area.
326    #[must_use]
327    pub fn with_max_height(mut self, max: usize) -> Self {
328        self.max_height = max;
329        self
330    }
331
332    // ── State access ───────────────────────────────────────────────
333
334    /// Get the full text content.
335    #[must_use]
336    pub fn text(&self) -> String {
337        self.editor.text()
338    }
339
340    /// Set the full text content (resets cursor and undo history).
341    pub fn set_text(&mut self, text: &str) {
342        self.editor.set_text(text);
343        self.scroll_anchor.set((0, 0));
344        self.scroll_left.set(0);
345    }
346
347    /// Number of lines.
348    #[must_use]
349    pub fn line_count(&self) -> usize {
350        self.editor.line_count()
351    }
352
353    /// Current cursor position.
354    #[inline]
355    #[must_use]
356    pub fn cursor(&self) -> CursorPosition {
357        self.editor.cursor()
358    }
359
360    /// Set cursor position (clamped to bounds). Clears selection.
361    pub fn set_cursor_position(&mut self, pos: CursorPosition) {
362        self.editor.set_cursor(pos);
363        self.ensure_cursor_visible();
364    }
365
366    /// Whether the textarea is empty.
367    #[inline]
368    #[must_use]
369    pub fn is_empty(&self) -> bool {
370        self.editor.is_empty()
371    }
372
373    /// Current selection, if any.
374    #[must_use = "use the returned selection (if any)"]
375    pub fn selection(&self) -> Option<Selection> {
376        self.editor.selection()
377    }
378
379    /// Get selected text.
380    #[must_use = "use the returned selected text (if any)"]
381    pub fn selected_text(&self) -> Option<String> {
382        self.editor.selected_text()
383    }
384
385    /// Whether the widget has focus.
386    #[must_use]
387    pub fn is_focused(&self) -> bool {
388        self.focused
389    }
390
391    /// Set focus state.
392    pub fn set_focused(&mut self, focused: bool) {
393        self.focused = focused;
394    }
395
396    /// Access the underlying editor.
397    #[must_use]
398    pub fn editor(&self) -> &Editor {
399        &self.editor
400    }
401
402    /// Mutable access to the underlying editor.
403    pub fn editor_mut(&mut self) -> &mut Editor {
404        &mut self.editor
405    }
406
407    // ── Editing operations (delegated to Editor) ───────────────────
408
409    /// Insert text at cursor.
410    pub fn insert_text(&mut self, text: &str) {
411        self.editor.insert_text(text);
412        self.ensure_cursor_visible();
413    }
414
415    /// Insert a single character.
416    pub fn insert_char(&mut self, ch: char) {
417        self.editor.insert_char(ch);
418        self.ensure_cursor_visible();
419    }
420
421    /// Insert a newline.
422    pub fn insert_newline(&mut self) {
423        self.editor.insert_newline();
424        self.ensure_cursor_visible();
425    }
426
427    /// Delete backward (backspace).
428    pub fn delete_backward(&mut self) {
429        self.editor.delete_backward();
430        self.ensure_cursor_visible();
431    }
432
433    /// Delete forward (delete key).
434    pub fn delete_forward(&mut self) {
435        self.editor.delete_forward();
436        self.ensure_cursor_visible();
437    }
438
439    /// Delete word backward (Ctrl+Backspace).
440    pub fn delete_word_backward(&mut self) {
441        self.editor.delete_word_backward();
442        self.ensure_cursor_visible();
443    }
444
445    /// Delete word forward (Ctrl+Delete).
446    pub fn delete_word_forward(&mut self) {
447        self.editor.delete_word_forward();
448        self.ensure_cursor_visible();
449    }
450
451    /// Delete to end of line (Ctrl+K).
452    pub fn delete_to_end_of_line(&mut self) {
453        self.editor.delete_to_end_of_line();
454        self.ensure_cursor_visible();
455    }
456
457    /// Undo last edit.
458    pub fn undo(&mut self) {
459        self.editor.undo();
460        self.ensure_cursor_visible();
461    }
462
463    /// Redo last undo.
464    pub fn redo(&mut self) {
465        self.editor.redo();
466        self.ensure_cursor_visible();
467    }
468
469    // ── Navigation ─────────────────────────────────────────────────
470
471    /// Move cursor left.
472    pub fn move_left(&mut self) {
473        self.editor.move_left();
474        self.ensure_cursor_visible();
475    }
476
477    /// Move cursor right.
478    pub fn move_right(&mut self) {
479        self.editor.move_right();
480        self.ensure_cursor_visible();
481    }
482
483    /// Move cursor up.
484    pub fn move_up(&mut self) {
485        if self.soft_wrap {
486            self.move_cursor_visual_up(1, false);
487        } else {
488            self.editor.move_up();
489        }
490        self.ensure_cursor_visible();
491    }
492
493    /// Move cursor down.
494    pub fn move_down(&mut self) {
495        if self.soft_wrap {
496            self.move_cursor_visual_down(1, false);
497        } else {
498            self.editor.move_down();
499        }
500        self.ensure_cursor_visible();
501    }
502
503    /// Move cursor left by word.
504    pub fn move_word_left(&mut self) {
505        self.editor.move_word_left();
506        self.ensure_cursor_visible();
507    }
508
509    /// Move cursor right by word.
510    pub fn move_word_right(&mut self) {
511        self.editor.move_word_right();
512        self.ensure_cursor_visible();
513    }
514
515    /// Extend selection left by word.
516    pub fn select_word_left(&mut self) {
517        self.editor.select_word_left();
518        self.ensure_cursor_visible();
519    }
520
521    /// Extend selection right by word.
522    pub fn select_word_right(&mut self) {
523        self.editor.select_word_right();
524        self.ensure_cursor_visible();
525    }
526
527    /// Move to start of line.
528    pub fn move_to_line_start(&mut self) {
529        self.editor.move_to_line_start();
530        self.ensure_cursor_visible();
531    }
532
533    /// Move to end of line.
534    pub fn move_to_line_end(&mut self) {
535        self.editor.move_to_line_end();
536        self.ensure_cursor_visible();
537    }
538
539    /// Move to start of document.
540    pub fn move_to_document_start(&mut self) {
541        self.editor.move_to_document_start();
542        self.ensure_cursor_visible();
543    }
544
545    /// Move cursor to end of document.
546    pub fn move_to_document_end(&mut self) {
547        self.editor.move_to_document_end();
548        self.ensure_cursor_visible();
549    }
550
551    fn move_cursor_visual_down(&mut self, count: usize, extend_selection: bool) {
552        let width = self.last_viewport_width.get();
553        if width == 0 {
554            for _ in 0..count {
555                if extend_selection {
556                    self.editor.select_down();
557                } else {
558                    self.editor.move_down();
559                }
560            }
561            return;
562        }
563
564        let rope = self.editor.rope();
565        let mut cursor = self.editor.cursor();
566        let mut remaining = count;
567
568        let line_text = rope
569            .line(cursor.line)
570            .unwrap_or(std::borrow::Cow::Borrowed(""));
571        let line_text = line_text.trim_end_matches(['\n', '\r']);
572        let (mut current_v_row, _) =
573            Self::cursor_wrap_position(line_text, width, cursor.visual_col);
574
575        // Calculate target screen X (relative to slice start) to preserve column
576        let initial_slices = Self::wrap_line_slices(line_text, width);
577        let initial_slice_start = initial_slices
578            .get(current_v_row)
579            .map(|s| s.start_col)
580            .unwrap_or(0);
581        let target_screen_x = cursor.visual_col.saturating_sub(initial_slice_start);
582
583        while remaining > 0 {
584            let line_text = rope
585                .line(cursor.line)
586                .unwrap_or(std::borrow::Cow::Borrowed(""));
587            let line_text = line_text.trim_end_matches(['\n', '\r']);
588            let wrap_count = Self::measure_wrap_count(line_text, width);
589
590            let available_in_line = wrap_count.saturating_sub(1).saturating_sub(current_v_row);
591
592            if remaining <= available_in_line {
593                current_v_row += remaining;
594                remaining = 0;
595            } else {
596                remaining -= available_in_line + 1;
597                if cursor.line + 1 < self.editor.line_count() {
598                    cursor.line += 1;
599                    current_v_row = 0;
600                } else {
601                    current_v_row = wrap_count.saturating_sub(1);
602                    remaining = 0;
603                }
604            }
605        }
606
607        let line_text = rope
608            .line(cursor.line)
609            .unwrap_or(std::borrow::Cow::Borrowed(""));
610        let line_text = line_text.trim_end_matches(['\n', '\r']);
611        let slices = Self::wrap_line_slices(line_text, width);
612
613        if let Some(slice) = slices.get(current_v_row) {
614            let target_in_slice = target_screen_x.min(slice.width);
615
616            let mut g_idx = 0;
617            let mut v_w = 0;
618            for g in slice.text.graphemes(true) {
619                let w = display_width(g);
620                if v_w + w > target_in_slice {
621                    break;
622                }
623                v_w += w;
624                g_idx += 1;
625            }
626
627            let nav = CursorNavigator::new(rope);
628            let line_start_byte = nav.to_byte_index(nav.from_line_grapheme(cursor.line, 0));
629            let slice_byte_offset: usize = slice
630                .text
631                .graphemes(true)
632                .take(g_idx)
633                .map(|g| g.len())
634                .sum();
635            let final_byte = line_start_byte + slice.start_byte + slice_byte_offset;
636            cursor = nav.from_byte_index(final_byte);
637        }
638
639        if extend_selection {
640            self.editor.extend_selection_to(cursor);
641        } else {
642            self.editor.set_cursor(cursor);
643        }
644    }
645
646    fn move_cursor_visual_up(&mut self, count: usize, extend_selection: bool) {
647        let width = self.last_viewport_width.get();
648        if width == 0 {
649            for _ in 0..count {
650                if extend_selection {
651                    self.editor.select_up();
652                } else {
653                    self.editor.move_up();
654                }
655            }
656            return;
657        }
658
659        let rope = self.editor.rope();
660        let mut cursor = self.editor.cursor();
661        let mut remaining = count;
662
663        let line_text = rope
664            .line(cursor.line)
665            .unwrap_or(std::borrow::Cow::Borrowed(""));
666        let line_text = line_text.trim_end_matches(['\n', '\r']);
667        let (mut current_v_row, _) =
668            Self::cursor_wrap_position(line_text, width, cursor.visual_col);
669
670        // Calculate target screen X (relative to slice start) to preserve column
671        let initial_slices = Self::wrap_line_slices(line_text, width);
672        let initial_slice_start = initial_slices
673            .get(current_v_row)
674            .map(|s| s.start_col)
675            .unwrap_or(0);
676        let target_screen_x = cursor.visual_col.saturating_sub(initial_slice_start);
677
678        while remaining > 0 {
679            if remaining <= current_v_row {
680                current_v_row -= remaining;
681                remaining = 0;
682            } else {
683                remaining -= current_v_row + 1;
684                if cursor.line > 0 {
685                    cursor.line -= 1;
686                    let line_text = rope
687                        .line(cursor.line)
688                        .unwrap_or(std::borrow::Cow::Borrowed(""));
689                    let line_text = line_text.trim_end_matches(['\n', '\r']);
690                    let wrap_count = Self::measure_wrap_count(line_text, width);
691                    current_v_row = wrap_count.saturating_sub(1);
692                } else {
693                    current_v_row = 0;
694                    remaining = 0;
695                }
696            }
697        }
698
699        let line_text = rope
700            .line(cursor.line)
701            .unwrap_or(std::borrow::Cow::Borrowed(""));
702        let line_text = line_text.trim_end_matches(['\n', '\r']);
703        let slices = Self::wrap_line_slices(line_text, width);
704
705        if let Some(slice) = slices.get(current_v_row) {
706            let target_in_slice = target_screen_x.min(slice.width);
707
708            let mut g_idx = 0;
709            let mut v_w = 0;
710            for g in slice.text.graphemes(true) {
711                let w = display_width(g);
712                if v_w + w > target_in_slice {
713                    break;
714                }
715                v_w += w;
716                g_idx += 1;
717            }
718
719            let nav = CursorNavigator::new(rope);
720            let line_start_byte = nav.to_byte_index(nav.from_line_grapheme(cursor.line, 0));
721            let slice_byte_offset: usize = slice
722                .text
723                .graphemes(true)
724                .take(g_idx)
725                .map(|g| g.len())
726                .sum();
727            let final_byte = line_start_byte + slice.start_byte + slice_byte_offset;
728            cursor = nav.from_byte_index(final_byte);
729        }
730
731        if extend_selection {
732            self.editor.extend_selection_to(cursor);
733        } else {
734            self.editor.set_cursor(cursor);
735        }
736    }
737
738    // ── Selection ──────────────────────────────────────────────────
739
740    /// Extend selection left.
741    pub fn select_left(&mut self) {
742        self.editor.select_left();
743        self.ensure_cursor_visible();
744    }
745
746    /// Extend selection right.
747    pub fn select_right(&mut self) {
748        self.editor.select_right();
749        self.ensure_cursor_visible();
750    }
751
752    /// Extend selection up.
753    pub fn select_up(&mut self) {
754        if self.soft_wrap {
755            self.move_cursor_visual_up(1, true);
756        } else {
757            self.editor.select_up();
758        }
759        self.ensure_cursor_visible();
760    }
761
762    /// Extend selection down.
763    pub fn select_down(&mut self) {
764        if self.soft_wrap {
765            self.move_cursor_visual_down(1, true);
766        } else {
767            self.editor.select_down();
768        }
769        self.ensure_cursor_visible();
770    }
771
772    /// Select all.
773    pub fn select_all(&mut self) {
774        self.editor.select_all();
775    }
776
777    /// Clear selection.
778    pub fn clear_selection(&mut self) {
779        self.editor.clear_selection();
780    }
781
782    // ── Viewport management ────────────────────────────────────────
783
784    /// Page up (move viewport and cursor up by viewport height).
785    pub fn page_up(&mut self, state: &TextAreaState) {
786        let page = state.last_viewport_height.max(1) as usize;
787        let text_area_width = state
788            .last_viewport_width
789            .saturating_sub(self.gutter_width());
790        self.last_viewport_height.set(page);
791        self.last_viewport_width.set(text_area_width as usize);
792        if self.soft_wrap {
793            if text_area_width > 0 {
794                self.move_cursor_visual_up(page, false);
795            } else {
796                for _ in 0..page {
797                    self.editor.move_up();
798                }
799            }
800        } else {
801            for _ in 0..page {
802                self.editor.move_up();
803            }
804        }
805        self.ensure_cursor_visible();
806    }
807
808    /// Page down (move viewport and cursor down by viewport height).
809    pub fn page_down(&mut self, state: &TextAreaState) {
810        let page = state.last_viewport_height.max(1) as usize;
811        let text_area_width = state
812            .last_viewport_width
813            .saturating_sub(self.gutter_width());
814        self.last_viewport_height.set(page);
815        self.last_viewport_width.set(text_area_width as usize);
816        if self.soft_wrap {
817            if text_area_width > 0 {
818                self.move_cursor_visual_down(page, false);
819            } else {
820                for _ in 0..page {
821                    self.editor.move_down();
822                }
823            }
824        } else {
825            for _ in 0..page {
826                self.editor.move_down();
827            }
828        }
829        self.ensure_cursor_visible();
830    }
831
832    /// Width of the line number gutter.
833    fn gutter_width(&self) -> u16 {
834        if !self.show_line_numbers {
835            return 0;
836        }
837        let digits = {
838            let mut count = self.line_count().max(1);
839            let mut d: u16 = 0;
840            while count > 0 {
841                d += 1;
842                count /= 10;
843            }
844            d
845        };
846        digits + 2 // digit width + space + separator
847    }
848
849    /// Count how many wrapped lines this text will occupy.
850    ///
851    /// This is a zero-allocation version of `wrap_line_slices` for layout calculations.
852    fn measure_wrap_count(line_text: &str, max_width: usize) -> usize {
853        if line_text.is_empty() {
854            return 1;
855        }
856
857        let mut count = 0;
858        let mut current_width = 0;
859        let mut has_content = false;
860
861        Self::run_wrapping_logic(line_text, max_width, |_, width, flush| {
862            if flush {
863                if width > 0 {
864                    count += 1;
865                }
866                current_width = 0;
867                has_content = false;
868            } else {
869                current_width = width;
870                has_content = true;
871            }
872        });
873
874        // If there's pending content or if we flushed but started a new empty line (which shouldn't happen with this logic usually,
875        // but let's be safe), count the last line.
876        // Actually run_wrapping_logic only flushes when a line is full.
877        // We need to count the current line if it has content or if it's the only line.
878        if has_content || count == 0 {
879            count += 1;
880        }
881
882        count
883    }
884
885    /// Core wrapping logic that emits events for layout or slicing.
886    ///
887    /// The callback receives `(start_index, width, flush)`.
888    /// - `flush == true`: The current line is full/done. `width` is the width of the flushed line.
889    /// - `flush == false`: Update current line width.
890    fn run_wrapping_logic<F>(line_text: &str, max_width: usize, mut callback: F)
891    where
892        F: FnMut(usize, usize, bool),
893    {
894        let mut current_width = 0;
895        let mut byte_cursor = 0;
896
897        for segment in line_text.split_word_bounds() {
898            let seg_len = segment.len();
899            let seg_width: usize = segment.graphemes(true).map(display_width).sum();
900
901            if max_width > 0 && current_width + seg_width > max_width {
902                // Flush current
903                callback(byte_cursor, current_width, true);
904                current_width = 0;
905            }
906
907            if max_width > 0 && seg_width > max_width {
908                for grapheme in segment.graphemes(true) {
909                    let g_width = display_width(grapheme);
910                    let g_len = grapheme.len();
911
912                    if max_width > 0 && current_width + g_width > max_width && current_width > 0 {
913                        callback(byte_cursor, current_width, true);
914                        current_width = 0;
915                    }
916
917                    current_width += g_width;
918                    byte_cursor += g_len;
919                    callback(byte_cursor, current_width, false);
920                }
921                continue;
922            }
923
924            current_width += seg_width;
925            byte_cursor += seg_len;
926            callback(byte_cursor, current_width, false);
927        }
928    }
929
930    fn wrap_line_slices<'a>(line_text: &'a str, max_width: usize) -> Vec<WrappedSlice<'a>> {
931        if line_text.is_empty() {
932            return vec![WrappedSlice {
933                text: "",
934                start_byte: 0,
935                start_col: 0,
936                width: 0,
937            }];
938        }
939
940        let mut slices = Vec::new();
941        let mut current_width = 0;
942        let mut slice_start_byte = 0;
943        let mut slice_start_col = 0;
944        let mut byte_cursor = 0;
945        let mut col_cursor = 0;
946
947        let push_current = |slices: &mut Vec<WrappedSlice<'a>>,
948                            width: &mut usize,
949                            start_byte: &mut usize,
950                            start_col: &mut usize,
951                            byte_cursor: usize,
952                            col_cursor: usize| {
953            if byte_cursor == *start_byte && *width == 0 {
954                return;
955            }
956            slices.push(WrappedSlice {
957                text: &line_text[*start_byte..byte_cursor],
958                start_byte: *start_byte,
959                start_col: *start_col,
960                width: *width,
961            });
962            *start_byte = byte_cursor;
963            *start_col = col_cursor;
964            *width = 0;
965        };
966
967        for segment in line_text.split_word_bounds() {
968            let seg_len = segment.len();
969            let seg_width: usize = segment.graphemes(true).map(display_width).sum();
970
971            if max_width > 0 && current_width + seg_width > max_width {
972                push_current(
973                    &mut slices,
974                    &mut current_width,
975                    &mut slice_start_byte,
976                    &mut slice_start_col,
977                    byte_cursor,
978                    col_cursor,
979                );
980            }
981
982            if max_width > 0 && seg_width > max_width {
983                for grapheme in segment.graphemes(true) {
984                    let g_width = display_width(grapheme);
985                    let g_len = grapheme.len();
986
987                    if max_width > 0 && current_width + g_width > max_width && current_width > 0 {
988                        push_current(
989                            &mut slices,
990                            &mut current_width,
991                            &mut slice_start_byte,
992                            &mut slice_start_col,
993                            byte_cursor,
994                            col_cursor,
995                        );
996                    }
997
998                    current_width += g_width;
999                    byte_cursor += g_len;
1000                    col_cursor += g_width;
1001                }
1002                continue;
1003            }
1004
1005            current_width += seg_width;
1006            byte_cursor += seg_len;
1007            col_cursor += seg_width;
1008        }
1009
1010        if byte_cursor > slice_start_byte || current_width > 0 || slices.is_empty() {
1011            slices.push(WrappedSlice {
1012                text: &line_text[slice_start_byte..byte_cursor],
1013                start_byte: slice_start_byte,
1014                start_col: slice_start_col,
1015                width: current_width,
1016            });
1017        }
1018
1019        slices
1020    }
1021
1022    fn cursor_wrap_position(
1023        line_text: &str,
1024        max_width: usize,
1025        cursor_col: usize,
1026    ) -> (usize, usize) {
1027        let slices = Self::wrap_line_slices(line_text, max_width);
1028        if slices.is_empty() {
1029            return (0, 0);
1030        }
1031
1032        for (idx, slice) in slices.iter().enumerate() {
1033            let end_col = slice.start_col.saturating_add(slice.width);
1034            // If cursor is at the wrap boundary (equal to end_col), we prefer the start
1035            // of the *next* slice (by skipping this one) unless this is the last slice.
1036            let is_last = idx == slices.len().saturating_sub(1);
1037            if cursor_col < end_col || (cursor_col == end_col && is_last) {
1038                let col_in_slice = cursor_col.saturating_sub(slice.start_col);
1039                return (idx, col_in_slice.min(slice.width));
1040            }
1041        }
1042
1043        (0, 0)
1044    }
1045
1046    /// Get the visual width of the character immediately before the cursor.
1047    fn get_prev_char_width(&self) -> usize {
1048        let cursor = self.editor.cursor();
1049        if cursor.grapheme == 0 {
1050            return 0;
1051        }
1052        let rope = self.editor.rope();
1053        let line = rope
1054            .line(cursor.line)
1055            .unwrap_or(std::borrow::Cow::Borrowed(""));
1056
1057        line.graphemes(true)
1058            .nth(cursor.grapheme - 1)
1059            .map(display_width)
1060            .unwrap_or(0)
1061    }
1062
1063    /// Ensure the cursor line and column are visible in the viewport.
1064    fn ensure_cursor_visible(&self) {
1065        let cursor = self.editor.cursor();
1066
1067        let last_height = self.last_viewport_height.get();
1068        let vp_height = if last_height == 0 { 20 } else { last_height };
1069
1070        let last_width = self.last_viewport_width.get();
1071        let vp_width = if last_width == 0 { 80 } else { last_width };
1072
1073        if self.scroll_anchor.get().0 == usize::MAX {
1074            self.scroll_anchor.set((0, 0));
1075        }
1076
1077        self.ensure_cursor_visible_internal(vp_height, vp_width, cursor);
1078    }
1079
1080    fn ensure_cursor_visible_internal(
1081        &self,
1082        vp_height: usize,
1083        vp_width: usize,
1084        cursor: CursorPosition,
1085    ) {
1086        let (anchor_line, anchor_vrow) = self.scroll_anchor.get();
1087
1088        if !self.soft_wrap {
1089            // Vertical scroll (logical lines)
1090            if cursor.line < anchor_line {
1091                self.scroll_anchor.set((cursor.line, 0));
1092            } else if vp_height > 0 && cursor.line >= anchor_line + vp_height {
1093                self.scroll_anchor
1094                    .set((cursor.line.saturating_sub(vp_height - 1), 0));
1095            }
1096
1097            // Horizontal scroll
1098            let current_left = self.scroll_left.get();
1099            let visual_col = cursor.visual_col;
1100
1101            if visual_col < current_left {
1102                self.scroll_left.set(visual_col);
1103            } else if vp_width > 0 && visual_col >= current_left + vp_width {
1104                let candidate_scroll = visual_col.saturating_sub(vp_width - 1);
1105                let prev_width = self.get_prev_char_width();
1106                let max_scroll_for_prev = visual_col.saturating_sub(prev_width);
1107
1108                let new_scroll = if vp_width > prev_width {
1109                    candidate_scroll.min(max_scroll_for_prev)
1110                } else {
1111                    candidate_scroll
1112                };
1113
1114                self.scroll_left.set(new_scroll);
1115            }
1116            return;
1117        }
1118
1119        // Soft wrap logic
1120        let rope = self.editor.rope();
1121
1122        // 1. Is cursor before anchor?
1123        if cursor.line < anchor_line {
1124            let line_text = rope
1125                .line(cursor.line)
1126                .unwrap_or(std::borrow::Cow::Borrowed(""));
1127            let line_text = line_text.trim_end_matches(['\n', '\r']);
1128            let (v_row, _) = Self::cursor_wrap_position(line_text, vp_width, cursor.visual_col);
1129            self.scroll_anchor.set((cursor.line, v_row));
1130            return;
1131        }
1132
1133        if cursor.line == anchor_line {
1134            let line_text = rope
1135                .line(cursor.line)
1136                .unwrap_or(std::borrow::Cow::Borrowed(""));
1137            let line_text = line_text.trim_end_matches(['\n', '\r']);
1138            let (v_row, _) = Self::cursor_wrap_position(line_text, vp_width, cursor.visual_col);
1139            if v_row < anchor_vrow {
1140                self.scroll_anchor.set((cursor.line, v_row));
1141                return;
1142            }
1143        }
1144
1145        // 2. Is cursor after viewport? Trace forward from anchor.
1146        let mut visual_rows_capacity = vp_height;
1147        let mut current_line = anchor_line;
1148        let mut current_v_start = anchor_vrow;
1149
1150        loop {
1151            if current_line > cursor.line {
1152                // Reached past cursor line without exhausting capacity -> Visible.
1153                return;
1154            }
1155
1156            let line_text = rope
1157                .line(current_line)
1158                .unwrap_or(std::borrow::Cow::Borrowed(""));
1159            let line_text = line_text.trim_end_matches(['\n', '\r']);
1160            let wrap_count = Self::measure_wrap_count(line_text, vp_width);
1161
1162            if current_line == cursor.line {
1163                let (cursor_v_row, _) =
1164                    Self::cursor_wrap_position(line_text, vp_width, cursor.visual_col);
1165                if cursor_v_row >= current_v_start {
1166                    let displayed_row_index = cursor_v_row - current_v_start;
1167                    if displayed_row_index < visual_rows_capacity {
1168                        // Visible.
1169                        return;
1170                    } else {
1171                        // Not visible (below).
1172                        break;
1173                    }
1174                } else {
1175                    // Cursor is before the anchor start? Handled by step 1.
1176                    return;
1177                }
1178            }
1179
1180            let rows_remaining_in_line = wrap_count.saturating_sub(current_v_start);
1181            if rows_remaining_in_line >= visual_rows_capacity {
1182                break;
1183            }
1184
1185            visual_rows_capacity -= rows_remaining_in_line;
1186            current_line += 1;
1187            current_v_start = 0;
1188
1189            if current_line >= self.editor.line_count() {
1190                break;
1191            }
1192        }
1193
1194        // 3. Scroll Down (Backwards Scan)
1195        let mut needed = vp_height;
1196        let mut scan_line = cursor.line;
1197
1198        // For the cursor line itself
1199        let line_text = rope
1200            .line(scan_line)
1201            .unwrap_or(std::borrow::Cow::Borrowed(""));
1202        let line_text = line_text.trim_end_matches(['\n', '\r']);
1203        let (cursor_v, _) = Self::cursor_wrap_position(line_text, vp_width, cursor.visual_col);
1204
1205        let rows_above = cursor_v + 1;
1206        if rows_above >= needed {
1207            let new_v_start = cursor_v + 1 - needed;
1208            self.scroll_anchor.set((scan_line, new_v_start));
1209            return;
1210        }
1211
1212        needed -= rows_above;
1213
1214        // Scan previous lines
1215        while scan_line > 0 {
1216            scan_line -= 1;
1217            let line_text = rope
1218                .line(scan_line)
1219                .unwrap_or(std::borrow::Cow::Borrowed(""));
1220            let line_text = line_text.trim_end_matches(['\n', '\r']);
1221            let wrap_count = Self::measure_wrap_count(line_text, vp_width);
1222
1223            if wrap_count >= needed {
1224                let new_v_start = wrap_count - needed;
1225                self.scroll_anchor.set((scan_line, new_v_start));
1226                return;
1227            }
1228            needed -= wrap_count;
1229        }
1230
1231        // Reached top
1232        self.scroll_anchor.set((0, 0));
1233    }
1234}
1235
1236impl Widget for TextArea {
1237    fn render(&self, area: Rect, frame: &mut Frame) {
1238        if area.width < 1 || area.height < 1 {
1239            return;
1240        }
1241
1242        self.last_viewport_height.set(area.height as usize);
1243
1244        let deg = frame.buffer.degradation;
1245        let base_style = if deg.apply_styling() {
1246            self.style
1247        } else {
1248            Style::default()
1249        };
1250        clear_text_area(frame, area, base_style);
1251
1252        let gutter_w = self.gutter_width();
1253        let text_area_x = area.x.saturating_add(gutter_w);
1254        let text_area_w = area.width.saturating_sub(gutter_w) as usize;
1255        let vp_height = area.height as usize;
1256
1257        self.last_viewport_width.set(text_area_w);
1258
1259        let cursor = self.editor.cursor();
1260
1261        self.ensure_cursor_visible();
1262
1263        // Re-fetch potentially updated anchor
1264        let (scroll_top_line, scroll_top_vrow) = self.scroll_anchor.get();
1265        let scroll_left = self.scroll_left.get();
1266
1267        let rope = self.editor.rope();
1268        let nav = CursorNavigator::new(rope);
1269
1270        // Selection byte range for highlighting
1271        let sel_range = self.editor.selection().and_then(|sel| {
1272            if sel.is_empty() {
1273                None
1274            } else {
1275                let (a, b) = sel.byte_range(&nav);
1276                Some((a, b))
1277            }
1278        });
1279
1280        // Show placeholder if empty
1281        if self.editor.is_empty() && !self.placeholder.is_empty() {
1282            let style = if deg.apply_styling() {
1283                self.placeholder_style
1284            } else {
1285                Style::default()
1286            };
1287            draw_text_span(
1288                frame,
1289                text_area_x,
1290                area.y,
1291                &self.placeholder,
1292                style,
1293                area.right(),
1294            );
1295            if self.focused {
1296                frame.set_cursor(Some((text_area_x, area.y)));
1297            }
1298            return;
1299        }
1300
1301        if self.soft_wrap {
1302            self.scroll_left.set(0);
1303
1304            // Pre-calculate cursor wrap position
1305            let cursor_line_text = rope
1306                .line(cursor.line)
1307                .unwrap_or(std::borrow::Cow::Borrowed(""));
1308            let cursor_line_text = cursor_line_text.trim_end_matches(['\n', '\r']);
1309            let (cursor_wrap_idx, cursor_col_in_wrap) =
1310                Self::cursor_wrap_position(cursor_line_text, text_area_w, cursor.visual_col);
1311
1312            // Render wrapped lines starting from anchor
1313            let mut current_y = area.y;
1314            let bottom_y = area.bottom();
1315            let line_count = self.editor.line_count();
1316
1317            for line_idx in scroll_top_line..line_count {
1318                if current_y >= bottom_y {
1319                    break;
1320                }
1321
1322                let line_text = rope
1323                    .line(line_idx)
1324                    .unwrap_or(std::borrow::Cow::Borrowed(""));
1325                let line_text = line_text.trim_end_matches(['\n', '\r']);
1326
1327                let line_start_byte = nav.to_byte_index(nav.from_line_grapheme(line_idx, 0));
1328                let slices = Self::wrap_line_slices(line_text, text_area_w);
1329
1330                // If this is the start line, skip slices before anchor_vrow
1331                let start_slice = if line_idx == scroll_top_line {
1332                    scroll_top_vrow
1333                } else {
1334                    0
1335                };
1336
1337                for (slice_idx, slice) in slices.iter().enumerate().skip(start_slice) {
1338                    if current_y >= bottom_y {
1339                        break;
1340                    }
1341
1342                    // Line number gutter (only for first wrapped slice of the line)
1343                    if self.show_line_numbers && slice_idx == 0 {
1344                        let style = if deg.apply_styling() {
1345                            self.line_number_style
1346                        } else {
1347                            Style::default()
1348                        };
1349                        let num_str =
1350                            format!("{:>width$} ", line_idx + 1, width = (gutter_w - 2) as usize);
1351                        draw_text_span(frame, area.x, current_y, &num_str, style, text_area_x);
1352                    }
1353
1354                    // Cursor line highlight
1355                    if line_idx == cursor.line
1356                        && slice_idx == cursor_wrap_idx
1357                        && let Some(cl_style) = self.cursor_line_style
1358                        && deg.apply_styling()
1359                    {
1360                        for cx in text_area_x..area.right() {
1361                            if let Some(cell) = frame.buffer.get_mut(cx, current_y) {
1362                                apply_style(cell, cl_style);
1363                            }
1364                        }
1365                    }
1366
1367                    // Render graphemes inside the wrapped slice
1368                    let mut visual_x: usize = 0;
1369                    let mut grapheme_byte_offset = line_start_byte + slice.start_byte;
1370
1371                    for g in slice.text.graphemes(true) {
1372                        let g_width = display_width(g);
1373                        let g_byte_len = g.len();
1374
1375                        if visual_x >= text_area_w {
1376                            break;
1377                        }
1378
1379                        let px = text_area_x + visual_x as u16;
1380
1381                        // Determine style (selection highlight)
1382                        let mut g_style = base_style;
1383                        if let Some((sel_start, sel_end)) = sel_range
1384                            && grapheme_byte_offset >= sel_start
1385                            && grapheme_byte_offset < sel_end
1386                            && deg.apply_styling()
1387                        {
1388                            g_style = g_style.merge(&self.selection_style);
1389                        }
1390
1391                        if g_width > 0 {
1392                            draw_text_span(frame, px, current_y, g, g_style, area.right());
1393                        }
1394
1395                        visual_x += g_width;
1396                        grapheme_byte_offset += g_byte_len;
1397                    }
1398
1399                    // Set cursor position if focused
1400                    if self.focused && line_idx == cursor.line && slice_idx == cursor_wrap_idx {
1401                        let cursor_screen_x = text_area_x.saturating_add(cursor_col_in_wrap as u16);
1402                        if cursor_screen_x < area.right() {
1403                            frame.set_cursor(Some((cursor_screen_x, current_y)));
1404                        }
1405                    }
1406
1407                    current_y += 1;
1408                }
1409            }
1410
1411            return;
1412        }
1413
1414        // Render visible lines (no soft wrap)
1415        for row in 0..vp_height {
1416            let line_idx = scroll_top_line + row;
1417            let y = area.y.saturating_add(row as u16);
1418
1419            if line_idx >= self.editor.line_count() {
1420                break;
1421            }
1422
1423            // Line number gutter
1424            if self.show_line_numbers {
1425                let style = if deg.apply_styling() {
1426                    self.line_number_style
1427                } else {
1428                    Style::default()
1429                };
1430                let num_str = format!("{:>width$} ", line_idx + 1, width = (gutter_w - 2) as usize);
1431                draw_text_span(frame, area.x, y, &num_str, style, text_area_x);
1432            }
1433
1434            // Cursor line highlight
1435            if line_idx == cursor.line
1436                && let Some(cl_style) = self.cursor_line_style
1437                && deg.apply_styling()
1438            {
1439                for cx in text_area_x..area.right() {
1440                    if let Some(cell) = frame.buffer.get_mut(cx, y) {
1441                        apply_style(cell, cl_style);
1442                    }
1443                }
1444            }
1445
1446            // Get line text
1447            let line_text = rope
1448                .line(line_idx)
1449                .unwrap_or(std::borrow::Cow::Borrowed(""));
1450            let line_text = line_text.trim_end_matches(['\n', '\r']);
1451
1452            // Calculate line byte offset for selection mapping
1453            let line_start_byte = nav.to_byte_index(nav.from_line_grapheme(line_idx, 0));
1454
1455            // Render each grapheme
1456            let mut visual_x: usize = 0;
1457            let graphemes: Vec<&str> = line_text.graphemes(true).collect();
1458            let mut grapheme_byte_offset = line_start_byte;
1459
1460            for g in &graphemes {
1461                let g_width = display_width(g);
1462                let g_byte_len = g.len();
1463
1464                // Determine style (selection highlight)
1465                let mut g_style = base_style;
1466                if let Some((sel_start, sel_end)) = sel_range
1467                    && grapheme_byte_offset >= sel_start
1468                    && grapheme_byte_offset < sel_end
1469                    && deg.apply_styling()
1470                {
1471                    g_style = g_style.merge(&self.selection_style);
1472                }
1473
1474                // Skip graphemes before horizontal scroll
1475                if visual_x + g_width <= scroll_left {
1476                    visual_x += g_width;
1477                    grapheme_byte_offset += g_byte_len;
1478                    continue;
1479                }
1480
1481                // Handle partial overlap at left edge
1482                if visual_x < scroll_left {
1483                    // Calculate visible width of the partially scrolled grapheme
1484                    let end_x = visual_x + g_width;
1485                    let visible_width = end_x.saturating_sub(scroll_left);
1486
1487                    // Render padding spaces for the visible portion to preserve background style
1488                    for i in 0..visible_width {
1489                        let screen_x = i; // Starts at 0 relative to text area
1490                        let px = text_area_x + screen_x as u16;
1491                        if px < area.right() {
1492                            draw_text_span(frame, px, y, " ", g_style, area.right());
1493                        }
1494                    }
1495
1496                    visual_x += g_width;
1497                    grapheme_byte_offset += g_byte_len;
1498                    continue;
1499                }
1500
1501                // Stop if past viewport
1502                let screen_x = visual_x.saturating_sub(scroll_left);
1503                if screen_x >= text_area_w {
1504                    break;
1505                }
1506
1507                let px = text_area_x + screen_x as u16;
1508
1509                // Write grapheme to buffer
1510                if g_width > 0 {
1511                    draw_text_span(frame, px, y, g, g_style, area.right());
1512                }
1513
1514                visual_x += g_width;
1515                grapheme_byte_offset += g_byte_len;
1516            }
1517        }
1518
1519        // Set cursor position if focused
1520        if self.focused {
1521            let cursor_row = cursor.line.saturating_sub(scroll_top_line);
1522            if cursor_row < vp_height {
1523                let cursor_screen_x = (cursor.visual_col.saturating_sub(scroll_left) as u16)
1524                    .saturating_add(text_area_x);
1525                let cursor_screen_y = area.y.saturating_add(cursor_row as u16);
1526                if cursor_screen_x < area.right() && cursor_screen_y < area.bottom() {
1527                    frame.set_cursor(Some((cursor_screen_x, cursor_screen_y)));
1528                }
1529            }
1530        }
1531    }
1532
1533    fn is_essential(&self) -> bool {
1534        true
1535    }
1536}
1537
1538impl StatefulWidget for TextArea {
1539    type State = TextAreaState;
1540
1541    fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
1542        state.last_viewport_height = area.height;
1543        state.last_viewport_width = area.width;
1544        Widget::render(self, area, frame);
1545    }
1546}
1547
1548#[cfg(test)]
1549mod tests {
1550    use super::*;
1551
1552    fn raw_row_text(frame: &Frame, y: u16, width: u16) -> String {
1553        (0..width)
1554            .map(|x| {
1555                frame
1556                    .buffer
1557                    .get(x, y)
1558                    .and_then(|cell| cell.content.as_char())
1559                    .unwrap_or(' ')
1560            })
1561            .collect()
1562    }
1563
1564    #[test]
1565    fn new_textarea_is_empty() {
1566        let ta = TextArea::new();
1567        assert!(ta.is_empty());
1568        assert_eq!(ta.text(), "");
1569        assert_eq!(ta.line_count(), 1); // empty rope has 1 line
1570    }
1571
1572    #[test]
1573    fn with_text_builder() {
1574        let ta = TextArea::new().with_text("hello\nworld");
1575        assert_eq!(ta.text(), "hello\nworld");
1576        assert_eq!(ta.line_count(), 2);
1577    }
1578
1579    #[test]
1580    fn insert_text_and_newline() {
1581        let mut ta = TextArea::new();
1582        ta.insert_text("hello");
1583        ta.insert_newline();
1584        ta.insert_text("world");
1585        assert_eq!(ta.text(), "hello\nworld");
1586        assert_eq!(ta.line_count(), 2);
1587    }
1588
1589    #[test]
1590    fn delete_backward_works() {
1591        let mut ta = TextArea::new().with_text("hello");
1592        ta.move_to_document_end();
1593        ta.delete_backward();
1594        assert_eq!(ta.text(), "hell");
1595    }
1596
1597    #[test]
1598    fn cursor_movement() {
1599        let mut ta = TextArea::new().with_text("abc\ndef\nghi");
1600        ta.move_to_document_start();
1601        assert_eq!(ta.cursor().line, 0);
1602        assert_eq!(ta.cursor().grapheme, 0);
1603
1604        ta.move_down();
1605        assert_eq!(ta.cursor().line, 1);
1606
1607        ta.move_to_line_end();
1608        assert_eq!(ta.cursor().grapheme, 3);
1609
1610        ta.move_to_document_end();
1611        assert_eq!(ta.cursor().line, 2);
1612    }
1613
1614    #[test]
1615    fn undo_redo() {
1616        let mut ta = TextArea::new();
1617        ta.insert_text("abc");
1618        assert_eq!(ta.text(), "abc");
1619        ta.undo();
1620        assert_eq!(ta.text(), "");
1621        ta.redo();
1622        assert_eq!(ta.text(), "abc");
1623    }
1624
1625    #[test]
1626    fn selection_and_delete() {
1627        let mut ta = TextArea::new().with_text("hello world");
1628        ta.move_to_document_start();
1629        for _ in 0..5 {
1630            ta.select_right();
1631        }
1632        assert_eq!(ta.selected_text(), Some("hello".to_string()));
1633        ta.delete_backward();
1634        assert_eq!(ta.text(), " world");
1635    }
1636
1637    #[test]
1638    fn select_all() {
1639        let mut ta = TextArea::new().with_text("abc\ndef");
1640        ta.select_all();
1641        assert_eq!(ta.selected_text(), Some("abc\ndef".to_string()));
1642    }
1643
1644    #[test]
1645    fn set_text_resets() {
1646        let mut ta = TextArea::new().with_text("old");
1647        ta.insert_text(" stuff");
1648        ta.set_text("new");
1649        assert_eq!(ta.text(), "new");
1650    }
1651
1652    #[test]
1653    fn scroll_follows_cursor() {
1654        let mut ta = TextArea::new();
1655        // Insert many lines
1656        for i in 0..50 {
1657            ta.insert_text(&format!("line {}\n", i));
1658        }
1659        // Cursor should be at the bottom, scroll anchor adjusted.
1660        assert!(ta.scroll_anchor.get().0 > 0);
1661        assert!(ta.cursor().line >= 49);
1662
1663        // Move to top
1664        ta.move_to_document_start();
1665        assert_eq!(ta.scroll_anchor.get().0, 0);
1666    }
1667
1668    #[test]
1669    fn gutter_width_without_line_numbers() {
1670        let ta = TextArea::new();
1671        assert_eq!(ta.gutter_width(), 0);
1672    }
1673
1674    #[test]
1675    fn gutter_width_with_line_numbers() {
1676        let mut ta = TextArea::new().with_line_numbers(true);
1677        ta.insert_text("a\nb\nc");
1678        assert_eq!(ta.gutter_width(), 3); // 1 digit + space + separator
1679    }
1680
1681    #[test]
1682    fn gutter_width_many_lines() {
1683        let mut ta = TextArea::new().with_line_numbers(true);
1684        for i in 0..100 {
1685            ta.insert_text(&format!("line {}\n", i));
1686        }
1687        assert_eq!(ta.gutter_width(), 5); // 3 digits + space + separator
1688    }
1689
1690    #[test]
1691    fn focus_state() {
1692        let mut ta = TextArea::new();
1693        assert!(!ta.is_focused());
1694        ta.set_focused(true);
1695        assert!(ta.is_focused());
1696    }
1697
1698    #[test]
1699    fn word_movement() {
1700        let mut ta = TextArea::new().with_text("hello world foo");
1701        ta.move_to_document_start();
1702        ta.move_word_right();
1703        assert_eq!(ta.cursor().grapheme, 6);
1704        ta.move_word_left();
1705        assert_eq!(ta.cursor().grapheme, 0);
1706    }
1707
1708    #[test]
1709    fn page_up_down() {
1710        let mut ta = TextArea::new();
1711        for i in 0..50 {
1712            ta.insert_text(&format!("line {}\n", i));
1713        }
1714        ta.move_to_document_start();
1715        let state = TextAreaState {
1716            last_viewport_height: 10,
1717            last_viewport_width: 80,
1718        };
1719        ta.page_down(&state);
1720        assert!(ta.cursor().line >= 10);
1721        ta.page_up(&state);
1722        assert_eq!(ta.cursor().line, 0);
1723    }
1724
1725    #[test]
1726    fn insert_replaces_selection() {
1727        let mut ta = TextArea::new().with_text("hello world");
1728        ta.move_to_document_start();
1729        for _ in 0..5 {
1730            ta.select_right();
1731        }
1732        ta.insert_text("goodbye");
1733        assert_eq!(ta.text(), "goodbye world");
1734    }
1735
1736    #[test]
1737    fn insert_single_char() {
1738        let mut ta = TextArea::new();
1739        ta.insert_char('X');
1740        assert_eq!(ta.text(), "X");
1741        assert_eq!(ta.cursor().grapheme, 1);
1742    }
1743
1744    #[test]
1745    fn insert_multiline_text() {
1746        let mut ta = TextArea::new();
1747        ta.insert_text("line1\nline2\nline3");
1748        assert_eq!(ta.line_count(), 3);
1749        assert_eq!(ta.cursor().line, 2);
1750    }
1751
1752    #[test]
1753    fn delete_forward_works() {
1754        let mut ta = TextArea::new().with_text("hello");
1755        ta.move_to_document_start();
1756        ta.delete_forward();
1757        assert_eq!(ta.text(), "ello");
1758    }
1759
1760    #[test]
1761    fn delete_backward_at_line_start_joins_lines() {
1762        let mut ta = TextArea::new().with_text("abc\ndef");
1763        // Move to start of line 2
1764        ta.move_to_document_start();
1765        ta.move_down();
1766        ta.move_to_line_start();
1767        ta.delete_backward();
1768        assert_eq!(ta.text(), "abcdef");
1769        assert_eq!(ta.line_count(), 1);
1770    }
1771
1772    #[test]
1773    fn cursor_horizontal_movement() {
1774        let mut ta = TextArea::new().with_text("abc");
1775        ta.move_to_document_start();
1776        ta.move_right();
1777        assert_eq!(ta.cursor().grapheme, 1);
1778        ta.move_right();
1779        assert_eq!(ta.cursor().grapheme, 2);
1780        ta.move_left();
1781        assert_eq!(ta.cursor().grapheme, 1);
1782    }
1783
1784    #[test]
1785    fn cursor_vertical_maintains_column() {
1786        let mut ta = TextArea::new().with_text("abcde\nfg\nhijkl");
1787        ta.move_to_document_start();
1788        ta.move_to_line_end(); // col 5
1789        ta.move_down(); // line 1 only has 2 chars, should clamp
1790        assert_eq!(ta.cursor().line, 1);
1791        ta.move_down(); // line 2 has 5 chars, should restore col
1792        assert_eq!(ta.cursor().line, 2);
1793    }
1794
1795    #[test]
1796    fn selection_shift_arrow() {
1797        let mut ta = TextArea::new().with_text("abcdef");
1798        ta.move_to_document_start();
1799        ta.select_right();
1800        ta.select_right();
1801        ta.select_right();
1802        assert_eq!(ta.selected_text(), Some("abc".to_string()));
1803    }
1804
1805    #[test]
1806    fn selection_extends_up_down() {
1807        let mut ta = TextArea::new().with_text("line1\nline2\nline3");
1808        ta.move_to_document_start();
1809        ta.select_down();
1810        let sel = ta.selected_text().unwrap();
1811        assert!(sel.contains('\n'));
1812    }
1813
1814    #[test]
1815    fn undo_chain() {
1816        let mut ta = TextArea::new();
1817        ta.insert_text("a");
1818        ta.insert_text("b");
1819        ta.insert_text("c");
1820        assert_eq!(ta.text(), "abc");
1821        ta.undo();
1822        ta.undo();
1823        ta.undo();
1824        assert_eq!(ta.text(), "");
1825    }
1826
1827    #[test]
1828    fn redo_discarded_on_new_edit() {
1829        let mut ta = TextArea::new();
1830        ta.insert_text("abc");
1831        ta.undo();
1832        ta.insert_text("xyz");
1833        ta.redo(); // should be no-op
1834        assert_eq!(ta.text(), "xyz");
1835    }
1836
1837    #[test]
1838    fn clear_selection() {
1839        let mut ta = TextArea::new().with_text("hello");
1840        ta.select_all();
1841        assert!(ta.selection().is_some());
1842        ta.clear_selection();
1843        assert!(ta.selection().is_none());
1844    }
1845
1846    #[test]
1847    fn delete_word_backward() {
1848        let mut ta = TextArea::new().with_text("hello world");
1849        ta.move_to_document_end();
1850        ta.delete_word_backward();
1851        assert_eq!(ta.text(), "hello ");
1852    }
1853
1854    #[test]
1855    fn delete_to_end_of_line() {
1856        let mut ta = TextArea::new().with_text("hello world");
1857        ta.move_to_document_start();
1858        ta.move_right(); // after 'h'
1859        ta.delete_to_end_of_line();
1860        assert_eq!(ta.text(), "h");
1861    }
1862
1863    #[test]
1864    fn placeholder_builder() {
1865        let ta = TextArea::new().with_placeholder("Enter text...");
1866        assert!(ta.is_empty());
1867        assert_eq!(ta.placeholder, "Enter text...");
1868    }
1869
1870    #[test]
1871    fn soft_wrap_builder() {
1872        let ta = TextArea::new().with_soft_wrap(true);
1873        assert!(ta.soft_wrap);
1874    }
1875
1876    #[test]
1877    fn soft_wrap_renders_wrapped_lines() {
1878        use crate::Widget;
1879        use ftui_render::grapheme_pool::GraphemePool;
1880
1881        let ta = TextArea::new().with_soft_wrap(true).with_text("abcdef");
1882        let area = Rect::new(0, 0, 3, 2);
1883        let mut pool = GraphemePool::new();
1884        let mut frame = Frame::new(3, 2, &mut pool);
1885        Widget::render(&ta, area, &mut frame);
1886
1887        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('a'));
1888        assert_eq!(frame.buffer.get(2, 0).unwrap().content.as_char(), Some('c'));
1889        assert_eq!(frame.buffer.get(0, 1).unwrap().content.as_char(), Some('d'));
1890        assert_eq!(frame.buffer.get(2, 1).unwrap().content.as_char(), Some('f'));
1891    }
1892
1893    #[test]
1894    fn max_height_builder() {
1895        let ta = TextArea::new().with_max_height(10);
1896        assert_eq!(ta.max_height, 10);
1897    }
1898
1899    #[test]
1900    fn editor_access() {
1901        let mut ta = TextArea::new().with_text("test");
1902        assert_eq!(ta.editor().text(), "test");
1903        ta.editor_mut().insert_char('!');
1904        assert!(ta.text().contains('!'));
1905    }
1906
1907    #[test]
1908    fn move_to_line_start_and_end() {
1909        let mut ta = TextArea::new().with_text("hello world");
1910        ta.move_to_document_start();
1911        ta.move_to_line_end();
1912        assert_eq!(ta.cursor().grapheme, 11);
1913        ta.move_to_line_start();
1914        assert_eq!(ta.cursor().grapheme, 0);
1915    }
1916
1917    #[test]
1918    fn render_empty_with_placeholder() {
1919        use ftui_render::grapheme_pool::GraphemePool;
1920        let ta = TextArea::new()
1921            .with_placeholder("Type here")
1922            .with_focus(true);
1923        let mut pool = GraphemePool::new();
1924        let mut frame = Frame::new(20, 5, &mut pool);
1925        let area = Rect::new(0, 0, 20, 5);
1926        Widget::render(&ta, area, &mut frame);
1927        // Placeholder should be rendered
1928        let cell = frame.buffer.get(0, 0).unwrap();
1929        assert_eq!(cell.content.as_char(), Some('T'));
1930        // Cursor should be set
1931        assert!(frame.cursor_position.is_some());
1932    }
1933
1934    #[test]
1935    fn render_with_content() {
1936        use ftui_render::grapheme_pool::GraphemePool;
1937        let ta = TextArea::new().with_text("abc\ndef").with_focus(true);
1938        let mut pool = GraphemePool::new();
1939        let mut frame = Frame::new(20, 5, &mut pool);
1940        let area = Rect::new(0, 0, 20, 5);
1941        Widget::render(&ta, area, &mut frame);
1942        let cell = frame.buffer.get(0, 0).unwrap();
1943        assert_eq!(cell.content.as_char(), Some('a'));
1944    }
1945
1946    #[test]
1947    fn render_shorter_text_clears_stale_suffix_and_extra_lines() {
1948        use ftui_render::grapheme_pool::GraphemePool;
1949
1950        let area = Rect::new(0, 0, 8, 3);
1951        let mut pool = GraphemePool::new();
1952        let mut frame = Frame::new(8, 3, &mut pool);
1953
1954        Widget::render(
1955            &TextArea::new().with_text("abcdef\nghijkl"),
1956            area,
1957            &mut frame,
1958        );
1959        Widget::render(&TextArea::new().with_text("hi"), area, &mut frame);
1960
1961        assert_eq!(raw_row_text(&frame, 0, 8), "hi      ");
1962        assert_eq!(raw_row_text(&frame, 1, 8), "        ");
1963        assert_eq!(raw_row_text(&frame, 2, 8), "        ");
1964    }
1965
1966    #[test]
1967    fn render_line_numbers_without_styling() {
1968        use ftui_render::budget::DegradationLevel;
1969        use ftui_render::grapheme_pool::GraphemePool;
1970
1971        let ta = TextArea::new().with_text("a\nb").with_line_numbers(true);
1972        let mut pool = GraphemePool::new();
1973        let mut frame = Frame::new(8, 2, &mut pool);
1974        frame.set_degradation(DegradationLevel::NoStyling);
1975
1976        Widget::render(&ta, Rect::new(0, 0, 8, 2), &mut frame);
1977
1978        let cell = frame.buffer.get(0, 0).unwrap();
1979        assert_eq!(cell.content.as_char(), Some('1'));
1980    }
1981
1982    #[test]
1983    fn stateful_render_updates_viewport_state() {
1984        use ftui_render::grapheme_pool::GraphemePool;
1985
1986        let ta = TextArea::new();
1987        let mut state = TextAreaState::default();
1988        let mut pool = GraphemePool::new();
1989        let mut frame = Frame::new(10, 3, &mut pool);
1990        let area = Rect::new(0, 0, 10, 3);
1991
1992        StatefulWidget::render(&ta, area, &mut frame, &mut state);
1993
1994        assert_eq!(state.last_viewport_height, 3);
1995        assert_eq!(state.last_viewport_width, 10);
1996    }
1997
1998    #[test]
1999    fn render_zero_area_no_panic() {
2000        let ta = TextArea::new().with_text("test");
2001        use ftui_render::grapheme_pool::GraphemePool;
2002        let mut pool = GraphemePool::new();
2003        let mut frame = Frame::new(10, 10, &mut pool);
2004        Widget::render(&ta, Rect::new(0, 0, 0, 0), &mut frame);
2005    }
2006
2007    #[test]
2008    fn is_essential() {
2009        let ta = TextArea::new();
2010        assert!(Widget::is_essential(&ta));
2011    }
2012
2013    #[test]
2014    fn default_impl() {
2015        let ta = TextArea::default();
2016        assert!(ta.is_empty());
2017    }
2018
2019    #[test]
2020    fn insert_newline_splits_line() {
2021        let mut ta = TextArea::new().with_text("abcdef");
2022        ta.move_to_document_start();
2023        ta.move_right();
2024        ta.move_right();
2025        ta.move_right();
2026        ta.insert_newline();
2027        assert_eq!(ta.line_count(), 2);
2028        assert_eq!(ta.cursor().line, 1);
2029    }
2030
2031    #[test]
2032    fn unicode_grapheme_cluster() {
2033        let mut ta = TextArea::new();
2034        ta.insert_text("café");
2035        // 'é' is a single grapheme even if composed
2036        assert_eq!(ta.text(), "café");
2037    }
2038
2039    mod proptests {
2040        use super::*;
2041        use proptest::prelude::*;
2042
2043        proptest! {
2044            #[test]
2045            fn insert_delete_inverse(text in "[a-zA-Z0-9 ]{1,50}") {
2046                let mut ta = TextArea::new();
2047                ta.insert_text(&text);
2048                // Delete all characters backwards
2049                for _ in 0..text.len() {
2050                    ta.delete_backward();
2051                }
2052                prop_assert!(ta.is_empty() || ta.text().is_empty());
2053            }
2054
2055            #[test]
2056            fn undo_redo_inverse(text in "[a-zA-Z0-9]{1,30}") {
2057                let mut ta = TextArea::new();
2058                ta.insert_text(&text);
2059                let after_insert = ta.text();
2060                ta.undo();
2061                ta.redo();
2062                prop_assert_eq!(ta.text(), after_insert);
2063            }
2064
2065            #[test]
2066            fn cursor_always_valid(ops in proptest::collection::vec(0u8..10, 1..20)) {
2067                let mut ta = TextArea::new().with_text("abc\ndef\nghi\njkl");
2068                for op in ops {
2069                    match op {
2070                        0 => ta.move_left(),
2071                        1 => ta.move_right(),
2072                        2 => ta.move_up(),
2073                        3 => ta.move_down(),
2074                        4 => ta.move_to_line_start(),
2075                        5 => ta.move_to_line_end(),
2076                        6 => ta.move_to_document_start(),
2077                        7 => ta.move_to_document_end(),
2078                        8 => ta.move_word_left(),
2079                        _ => ta.move_word_right(),
2080                    }
2081                    let cursor = ta.cursor();
2082                    prop_assert!(cursor.line < ta.line_count(),
2083                        "cursor line {} >= line_count {}", cursor.line, ta.line_count());
2084                }
2085            }
2086
2087            #[test]
2088            fn selection_ordered(n in 1usize..20) {
2089                let mut ta = TextArea::new().with_text("hello world foo bar");
2090                ta.move_to_document_start();
2091                for _ in 0..n {
2092                    ta.select_right();
2093                }
2094                if let Some(sel) = ta.selection() {
2095                    // When selecting right from start, anchor should be at/before head
2096                    prop_assert!(sel.anchor.line <= sel.head.line
2097                        || (sel.anchor.line == sel.head.line
2098                            && sel.anchor.grapheme <= sel.head.grapheme));
2099                }
2100            }
2101        }
2102    }
2103}