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, 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 offset (first visible line).
54    scroll_top: std::cell::Cell<usize>,
55    /// Horizontal scroll offset (visual columns).
56    scroll_left: std::cell::Cell<usize>,
57    /// Last viewport height for page movement and visibility checks.
58    #[allow(dead_code)]
59    last_viewport_height: std::cell::Cell<usize>,
60    /// Last viewport width for visibility checks.
61    last_viewport_width: std::cell::Cell<usize>,
62}
63
64impl Default for TextArea {
65    fn default() -> Self {
66        Self::new()
67    }
68}
69
70/// Render state tracked across frames.
71#[derive(Debug, Clone, Default)]
72pub struct TextAreaState {
73    /// Viewport height from last render.
74    pub last_viewport_height: u16,
75    /// Viewport width from last render.
76    pub last_viewport_width: u16,
77}
78
79#[derive(Debug, Clone)]
80struct WrappedSlice {
81    text: String,
82    start_byte: usize,
83    start_col: usize,
84    width: usize,
85}
86
87impl TextArea {
88    /// Create a new empty text area.
89    #[must_use]
90    pub fn new() -> Self {
91        Self {
92            editor: Editor::new(),
93            placeholder: String::new(),
94            focused: false,
95            show_line_numbers: false,
96            style: Style::default(),
97            cursor_line_style: None,
98            selection_style: Style::new().reverse(),
99            placeholder_style: Style::new().dim(),
100            line_number_style: Style::new().dim(),
101            soft_wrap: false,
102            max_height: 0,
103            scroll_top: std::cell::Cell::new(usize::MAX), // sentinel: will be set on first render
104            scroll_left: std::cell::Cell::new(0),
105            last_viewport_height: std::cell::Cell::new(0),
106            last_viewport_width: std::cell::Cell::new(0),
107        }
108    }
109
110    // ── Event Handling ─────────────────────────────────────────────
111
112    /// Handle a terminal event.
113    ///
114    /// Returns `true` if the state changed.
115    pub fn handle_event(&mut self, event: &Event) -> bool {
116        match event {
117            Event::Key(key)
118                if key.kind == KeyEventKind::Press || key.kind == KeyEventKind::Repeat =>
119            {
120                self.handle_key(key)
121            }
122            Event::Paste(paste) => {
123                self.insert_text(&paste.text);
124                true
125            }
126            _ => false,
127        }
128    }
129
130    fn handle_key(&mut self, key: &KeyEvent) -> bool {
131        let ctrl = key.modifiers.contains(Modifiers::CTRL);
132        let shift = key.modifiers.contains(Modifiers::SHIFT);
133        let _alt = key.modifiers.contains(Modifiers::ALT);
134
135        match key.code {
136            KeyCode::Char(c) if !ctrl => {
137                self.insert_char(c);
138                true
139            }
140            KeyCode::Enter => {
141                self.insert_newline();
142                true
143            }
144            KeyCode::Backspace => {
145                if ctrl {
146                    self.delete_word_backward();
147                } else {
148                    self.delete_backward();
149                }
150                true
151            }
152            KeyCode::Delete => {
153                self.delete_forward();
154                true
155            }
156            KeyCode::Left => {
157                if ctrl {
158                    self.move_word_left();
159                } else if shift {
160                    self.select_left();
161                } else {
162                    self.move_left();
163                }
164                true
165            }
166            KeyCode::Right => {
167                if ctrl {
168                    self.move_word_right();
169                } else if shift {
170                    self.select_right();
171                } else {
172                    self.move_right();
173                }
174                true
175            }
176            KeyCode::Up => {
177                if shift {
178                    self.select_up();
179                } else {
180                    self.move_up();
181                }
182                true
183            }
184            KeyCode::Down => {
185                if shift {
186                    self.select_down();
187                } else {
188                    self.move_down();
189                }
190                true
191            }
192            KeyCode::Home => {
193                self.move_to_line_start();
194                true
195            }
196            KeyCode::End => {
197                self.move_to_line_end();
198                true
199            }
200            KeyCode::PageUp => {
201                let page = self.last_viewport_height.get().max(1);
202                for _ in 0..page {
203                    self.editor.move_up();
204                }
205                self.ensure_cursor_visible();
206                true
207            }
208            KeyCode::PageDown => {
209                let page = self.last_viewport_height.get().max(1);
210                for _ in 0..page {
211                    self.editor.move_down();
212                }
213                self.ensure_cursor_visible();
214                true
215            }
216            KeyCode::Char('a') if ctrl => {
217                self.select_all();
218                true
219            }
220            // Ctrl+K: Delete to end of line (common emacs/shell binding)
221            KeyCode::Char('k') if ctrl => {
222                self.delete_to_end_of_line();
223                true
224            }
225            // Ctrl+Z: Undo
226            KeyCode::Char('z') if ctrl => {
227                self.undo();
228                true
229            }
230            // Ctrl+Y: Redo
231            KeyCode::Char('y') if ctrl => {
232                self.redo();
233                true
234            }
235            _ => false,
236        }
237    }
238
239    // ── Builder methods ────────────────────────────────────────────
240
241    /// Set initial text content (builder).
242    #[must_use]
243    pub fn with_text(mut self, text: &str) -> Self {
244        self.editor = Editor::with_text(text);
245        self.editor.move_to_document_start();
246        self
247    }
248
249    /// Set placeholder text (builder).
250    #[must_use]
251    pub fn with_placeholder(mut self, text: impl Into<String>) -> Self {
252        self.placeholder = text.into();
253        self
254    }
255
256    /// Set focused state (builder).
257    #[must_use]
258    pub fn with_focus(mut self, focused: bool) -> Self {
259        self.focused = focused;
260        self
261    }
262
263    /// Enable line numbers (builder).
264    #[must_use]
265    pub fn with_line_numbers(mut self, show: bool) -> Self {
266        self.show_line_numbers = show;
267        self
268    }
269
270    /// Set base style (builder).
271    #[must_use]
272    pub fn with_style(mut self, style: Style) -> Self {
273        self.style = style;
274        self
275    }
276
277    /// Set cursor line highlight style (builder).
278    #[must_use]
279    pub fn with_cursor_line_style(mut self, style: Style) -> Self {
280        self.cursor_line_style = Some(style);
281        self
282    }
283
284    /// Set selection style (builder).
285    #[must_use]
286    pub fn with_selection_style(mut self, style: Style) -> Self {
287        self.selection_style = style;
288        self
289    }
290
291    /// Enable soft wrapping (builder).
292    #[must_use]
293    pub fn with_soft_wrap(mut self, wrap: bool) -> Self {
294        self.soft_wrap = wrap;
295        self
296    }
297
298    /// Set maximum height in lines (builder). 0 = fill available area.
299    #[must_use]
300    pub fn with_max_height(mut self, max: usize) -> Self {
301        self.max_height = max;
302        self
303    }
304
305    // ── State access ───────────────────────────────────────────────
306
307    /// Get the full text content.
308    #[must_use]
309    pub fn text(&self) -> String {
310        self.editor.text()
311    }
312
313    /// Set the full text content (resets cursor and undo history).
314    pub fn set_text(&mut self, text: &str) {
315        self.editor.set_text(text);
316        self.scroll_top.set(0);
317        self.scroll_left.set(0);
318    }
319
320    /// Number of lines.
321    #[must_use]
322    pub fn line_count(&self) -> usize {
323        self.editor.line_count()
324    }
325
326    /// Current cursor position.
327    #[inline]
328    #[must_use]
329    pub fn cursor(&self) -> CursorPosition {
330        self.editor.cursor()
331    }
332
333    /// Set cursor position (clamped to bounds). Clears selection.
334    pub fn set_cursor_position(&mut self, pos: CursorPosition) {
335        self.editor.set_cursor(pos);
336        self.ensure_cursor_visible();
337    }
338
339    /// Whether the textarea is empty.
340    #[inline]
341    #[must_use]
342    pub fn is_empty(&self) -> bool {
343        self.editor.is_empty()
344    }
345
346    /// Current selection, if any.
347    #[must_use = "use the returned selection (if any)"]
348    pub fn selection(&self) -> Option<Selection> {
349        self.editor.selection()
350    }
351
352    /// Get selected text.
353    #[must_use = "use the returned selected text (if any)"]
354    pub fn selected_text(&self) -> Option<String> {
355        self.editor.selected_text()
356    }
357
358    /// Whether the widget has focus.
359    #[must_use]
360    pub fn is_focused(&self) -> bool {
361        self.focused
362    }
363
364    /// Set focus state.
365    pub fn set_focused(&mut self, focused: bool) {
366        self.focused = focused;
367    }
368
369    /// Access the underlying editor.
370    #[must_use]
371    pub fn editor(&self) -> &Editor {
372        &self.editor
373    }
374
375    /// Mutable access to the underlying editor.
376    pub fn editor_mut(&mut self) -> &mut Editor {
377        &mut self.editor
378    }
379
380    // ── Editing operations (delegated to Editor) ───────────────────
381
382    /// Insert text at cursor.
383    pub fn insert_text(&mut self, text: &str) {
384        self.editor.insert_text(text);
385        self.ensure_cursor_visible();
386    }
387
388    /// Insert a single character.
389    pub fn insert_char(&mut self, ch: char) {
390        self.editor.insert_char(ch);
391        self.ensure_cursor_visible();
392    }
393
394    /// Insert a newline.
395    pub fn insert_newline(&mut self) {
396        self.editor.insert_newline();
397        self.ensure_cursor_visible();
398    }
399
400    /// Delete backward (backspace).
401    pub fn delete_backward(&mut self) {
402        self.editor.delete_backward();
403        self.ensure_cursor_visible();
404    }
405
406    /// Delete forward (delete key).
407    pub fn delete_forward(&mut self) {
408        self.editor.delete_forward();
409        self.ensure_cursor_visible();
410    }
411
412    /// Delete word backward (Ctrl+Backspace).
413    pub fn delete_word_backward(&mut self) {
414        self.editor.delete_word_backward();
415        self.ensure_cursor_visible();
416    }
417
418    /// Delete to end of line (Ctrl+K).
419    pub fn delete_to_end_of_line(&mut self) {
420        self.editor.delete_to_end_of_line();
421        self.ensure_cursor_visible();
422    }
423
424    /// Undo last edit.
425    pub fn undo(&mut self) {
426        self.editor.undo();
427        self.ensure_cursor_visible();
428    }
429
430    /// Redo last undo.
431    pub fn redo(&mut self) {
432        self.editor.redo();
433        self.ensure_cursor_visible();
434    }
435
436    // ── Navigation ─────────────────────────────────────────────────
437
438    /// Move cursor left.
439    pub fn move_left(&mut self) {
440        self.editor.move_left();
441        self.ensure_cursor_visible();
442    }
443
444    /// Move cursor right.
445    pub fn move_right(&mut self) {
446        self.editor.move_right();
447        self.ensure_cursor_visible();
448    }
449
450    /// Move cursor up.
451    pub fn move_up(&mut self) {
452        self.editor.move_up();
453        self.ensure_cursor_visible();
454    }
455
456    /// Move cursor down.
457    pub fn move_down(&mut self) {
458        self.editor.move_down();
459        self.ensure_cursor_visible();
460    }
461
462    /// Move cursor left by word.
463    pub fn move_word_left(&mut self) {
464        self.editor.move_word_left();
465        self.ensure_cursor_visible();
466    }
467
468    /// Move cursor right by word.
469    pub fn move_word_right(&mut self) {
470        self.editor.move_word_right();
471        self.ensure_cursor_visible();
472    }
473
474    /// Move to start of line.
475    pub fn move_to_line_start(&mut self) {
476        self.editor.move_to_line_start();
477        self.ensure_cursor_visible();
478    }
479
480    /// Move to end of line.
481    pub fn move_to_line_end(&mut self) {
482        self.editor.move_to_line_end();
483        self.ensure_cursor_visible();
484    }
485
486    /// Move to start of document.
487    pub fn move_to_document_start(&mut self) {
488        self.editor.move_to_document_start();
489        self.ensure_cursor_visible();
490    }
491
492    /// Move to end of document.
493    pub fn move_to_document_end(&mut self) {
494        self.editor.move_to_document_end();
495        self.ensure_cursor_visible();
496    }
497
498    // ── Selection ──────────────────────────────────────────────────
499
500    /// Extend selection left.
501    pub fn select_left(&mut self) {
502        self.editor.select_left();
503        self.ensure_cursor_visible();
504    }
505
506    /// Extend selection right.
507    pub fn select_right(&mut self) {
508        self.editor.select_right();
509        self.ensure_cursor_visible();
510    }
511
512    /// Extend selection up.
513    pub fn select_up(&mut self) {
514        self.editor.select_up();
515        self.ensure_cursor_visible();
516    }
517
518    /// Extend selection down.
519    pub fn select_down(&mut self) {
520        self.editor.select_down();
521        self.ensure_cursor_visible();
522    }
523
524    /// Select all.
525    pub fn select_all(&mut self) {
526        self.editor.select_all();
527    }
528
529    /// Clear selection.
530    pub fn clear_selection(&mut self) {
531        self.editor.clear_selection();
532    }
533
534    // ── Viewport management ────────────────────────────────────────
535
536    /// Page up (move viewport and cursor up by viewport height).
537    pub fn page_up(&mut self, state: &TextAreaState) {
538        let page = state.last_viewport_height.max(1) as usize;
539        for _ in 0..page {
540            self.editor.move_up();
541        }
542        self.ensure_cursor_visible();
543    }
544
545    /// Page down (move viewport and cursor down by viewport height).
546    pub fn page_down(&mut self, state: &TextAreaState) {
547        let page = state.last_viewport_height.max(1) as usize;
548        for _ in 0..page {
549            self.editor.move_down();
550        }
551        self.ensure_cursor_visible();
552    }
553
554    /// Width of the line number gutter.
555    fn gutter_width(&self) -> u16 {
556        if !self.show_line_numbers {
557            return 0;
558        }
559        let digits = {
560            let mut count = self.line_count().max(1);
561            let mut d: u16 = 0;
562            while count > 0 {
563                d += 1;
564                count /= 10;
565            }
566            d
567        };
568        digits + 2 // digit width + space + separator
569    }
570
571    /// Count how many wrapped lines this text will occupy.
572    ///
573    /// This is a zero-allocation version of `wrap_line_slices` for layout calculations.
574    fn measure_wrap_count(line_text: &str, max_width: usize) -> usize {
575        if line_text.is_empty() {
576            return 1;
577        }
578
579        let mut count = 0;
580        let mut current_width = 0;
581        let mut has_content = false;
582
583        Self::run_wrapping_logic(line_text, max_width, |_, width, flush| {
584            if flush {
585                count += 1;
586                current_width = 0;
587                has_content = false;
588            } else {
589                current_width = width;
590                has_content = true;
591            }
592        });
593
594        // If there's pending content or if we flushed but started a new empty line (which shouldn't happen with this logic usually,
595        // but let's be safe), count the last line.
596        // Actually run_wrapping_logic only flushes when a line is full.
597        // We need to count the current line if it has content or if it's the only line.
598        if has_content || count == 0 {
599            count += 1;
600        }
601
602        count
603    }
604
605    /// Core wrapping logic that emits events for layout or slicing.
606    ///
607    /// The callback receives `(start_index, width, flush)`.
608    /// - `flush == true`: The current line is full/done. `width` is the width of the flushed line.
609    /// - `flush == false`: Update current line width.
610    fn run_wrapping_logic<F>(line_text: &str, max_width: usize, mut callback: F)
611    where
612        F: FnMut(usize, usize, bool),
613    {
614        let mut current_width = 0;
615        let mut byte_cursor = 0;
616
617        for segment in line_text.split_word_bounds() {
618            let seg_len = segment.len();
619            let seg_width: usize = segment.graphemes(true).map(display_width).sum();
620
621            if max_width > 0 && current_width + seg_width > max_width {
622                // Flush current
623                callback(byte_cursor, current_width, true);
624                current_width = 0;
625            }
626
627            if max_width > 0 && seg_width > max_width {
628                for grapheme in segment.graphemes(true) {
629                    let g_width = display_width(grapheme);
630                    let g_len = grapheme.len();
631
632                    if max_width > 0 && current_width + g_width > max_width && current_width > 0 {
633                        callback(byte_cursor, current_width, true);
634                        current_width = 0;
635                    }
636
637                    current_width += g_width;
638                    byte_cursor += g_len;
639                    callback(byte_cursor, current_width, false);
640                }
641                continue;
642            }
643
644            current_width += seg_width;
645            byte_cursor += seg_len;
646            callback(byte_cursor, current_width, false);
647        }
648    }
649
650    fn wrap_line_slices(line_text: &str, max_width: usize) -> Vec<WrappedSlice> {
651        if line_text.is_empty() {
652            return vec![WrappedSlice {
653                text: String::new(),
654                start_byte: 0,
655                start_col: 0,
656                width: 0,
657            }];
658        }
659
660        let mut slices = Vec::new();
661        let mut current_text = String::new();
662        let mut current_width = 0;
663        let mut slice_start_byte = 0;
664        let mut slice_start_col = 0;
665        let mut byte_cursor = 0;
666        let mut col_cursor = 0;
667
668        let push_current = |slices: &mut Vec<WrappedSlice>,
669                            text: &mut String,
670                            width: &mut usize,
671                            start_byte: &mut usize,
672                            start_col: &mut usize,
673                            byte_cursor: usize,
674                            col_cursor: usize| {
675            // Push even if empty if it's forced by logic (though usually check width > 0)
676            // But we match original logic:
677            if text.is_empty() && *width == 0 {
678                return;
679            }
680            slices.push(WrappedSlice {
681                text: std::mem::take(text),
682                start_byte: *start_byte,
683                start_col: *start_col,
684                width: *width,
685            });
686            *start_byte = byte_cursor;
687            *start_col = col_cursor;
688            *width = 0;
689        };
690
691        for segment in line_text.split_word_bounds() {
692            let seg_len = segment.len();
693            let seg_width: usize = segment.graphemes(true).map(display_width).sum();
694
695            if max_width > 0 && current_width + seg_width > max_width {
696                push_current(
697                    &mut slices,
698                    &mut current_text,
699                    &mut current_width,
700                    &mut slice_start_byte,
701                    &mut slice_start_col,
702                    byte_cursor,
703                    col_cursor,
704                );
705            }
706
707            if max_width > 0 && seg_width > max_width {
708                for grapheme in segment.graphemes(true) {
709                    let g_width = display_width(grapheme);
710                    let g_len = grapheme.len();
711
712                    if max_width > 0 && current_width + g_width > max_width && current_width > 0 {
713                        push_current(
714                            &mut slices,
715                            &mut current_text,
716                            &mut current_width,
717                            &mut slice_start_byte,
718                            &mut slice_start_col,
719                            byte_cursor,
720                            col_cursor,
721                        );
722                    }
723
724                    current_text.push_str(grapheme);
725                    current_width += g_width;
726                    byte_cursor += g_len;
727                    col_cursor += g_width;
728                }
729                continue;
730            }
731
732            current_text.push_str(segment);
733            current_width += seg_width;
734            byte_cursor += seg_len;
735            col_cursor += seg_width;
736        }
737
738        if !current_text.is_empty() || current_width > 0 || slices.is_empty() {
739            slices.push(WrappedSlice {
740                text: current_text,
741                start_byte: slice_start_byte,
742                start_col: slice_start_col,
743                width: current_width,
744            });
745        }
746
747        slices
748    }
749
750    fn cursor_wrap_position(
751        line_text: &str,
752        max_width: usize,
753        cursor_col: usize,
754    ) -> (usize, usize) {
755        let slices = Self::wrap_line_slices(line_text, max_width);
756        if slices.is_empty() {
757            return (0, 0);
758        }
759
760        for (idx, slice) in slices.iter().enumerate() {
761            let end_col = slice.start_col.saturating_add(slice.width);
762            if cursor_col <= end_col || idx == slices.len().saturating_sub(1) {
763                let col_in_slice = cursor_col.saturating_sub(slice.start_col);
764                return (idx, col_in_slice.min(slice.width));
765            }
766        }
767
768        (0, 0)
769    }
770
771    /// Get the visual width of the character immediately before the cursor.
772    fn get_prev_char_width(&self) -> usize {
773        let cursor = self.editor.cursor();
774        if cursor.grapheme == 0 {
775            return 0;
776        }
777        let rope = self.editor.rope();
778        let line = rope
779            .line(cursor.line)
780            .unwrap_or(std::borrow::Cow::Borrowed(""));
781
782        line.graphemes(true)
783            .nth(cursor.grapheme - 1)
784            .map(display_width)
785            .unwrap_or(0)
786    }
787
788    /// Ensure the cursor line and column are visible in the viewport.
789    fn ensure_cursor_visible(&mut self) {
790        let cursor = self.editor.cursor();
791
792        let last_height = self.last_viewport_height.get();
793
794        // Use a default viewport of 20 lines if we haven't rendered yet (height is 0)
795
796        let vp_height = if last_height == 0 { 20 } else { last_height };
797
798        let last_width = self.last_viewport_width.get();
799
800        let vp_width = if last_width == 0 { 80 } else { last_width };
801
802        if self.scroll_top.get() == usize::MAX {
803            self.scroll_top.set(0);
804        }
805
806        self.ensure_cursor_visible_internal(vp_height, vp_width, cursor);
807    }
808
809    fn ensure_cursor_visible_internal(
810        &mut self,
811
812        vp_height: usize,
813
814        vp_width: usize,
815
816        cursor: CursorPosition,
817    ) {
818        let current_top = self.scroll_top.get();
819
820        // Vertical scroll
821
822        if cursor.line < current_top {
823            self.scroll_top.set(cursor.line);
824        } else if vp_height > 0 && cursor.line >= current_top + vp_height {
825            self.scroll_top
826                .set(cursor.line.saturating_sub(vp_height - 1));
827        }
828
829        // Horizontal scroll
830
831        if !self.soft_wrap {
832            let current_left = self.scroll_left.get();
833
834            let visual_col = cursor.visual_col;
835
836            // Scroll left if cursor is before viewport
837
838            if visual_col < current_left {
839                self.scroll_left.set(visual_col);
840            }
841            // Scroll right if cursor is past viewport
842            // Need space for gutter if we had access to it, but this is a best effort
843            // approximation using the last known width.
844            // Effective text width approx vp_width - gutter (assume ~4 for gutter if unknown)
845            // But we use raw vp_width here.
846            else if vp_width > 0 && visual_col >= current_left + vp_width {
847                let candidate_scroll = visual_col.saturating_sub(vp_width - 1);
848                let prev_width = self.get_prev_char_width();
849                let max_scroll_for_prev = visual_col.saturating_sub(prev_width);
850
851                self.scroll_left
852                    .set(candidate_scroll.min(max_scroll_for_prev));
853            }
854        }
855    }
856}
857
858impl Widget for TextArea {
859    fn render(&self, area: Rect, frame: &mut Frame) {
860        if area.width < 1 || area.height < 1 {
861            return;
862        }
863
864        self.last_viewport_height.set(area.height as usize);
865
866        let deg = frame.buffer.degradation;
867        if deg.apply_styling() {
868            crate::set_style_area(&mut frame.buffer, area, self.style);
869        }
870
871        let gutter_w = self.gutter_width();
872        let text_area_x = area.x.saturating_add(gutter_w);
873        let text_area_w = area.width.saturating_sub(gutter_w) as usize;
874        let vp_height = area.height as usize;
875
876        self.last_viewport_width.set(text_area_w);
877
878        let cursor = self.editor.cursor();
879        // Use a mutable copy for scroll adjustment
880        let mut scroll_top = if self.scroll_top.get() == usize::MAX {
881            0
882        } else {
883            self.scroll_top.get()
884        };
885        if vp_height > 0 {
886            if cursor.line < scroll_top {
887                scroll_top = cursor.line;
888            } else if cursor.line >= scroll_top + vp_height {
889                scroll_top = cursor.line.saturating_sub(vp_height - 1);
890            }
891        }
892        self.scroll_top.set(scroll_top);
893
894        let mut scroll_left = self.scroll_left.get();
895        if !self.soft_wrap && text_area_w > 0 {
896            let visual_col = cursor.visual_col;
897            if visual_col < scroll_left {
898                scroll_left = visual_col;
899            } else if visual_col >= scroll_left + text_area_w {
900                let candidate_scroll = visual_col.saturating_sub(text_area_w - 1);
901                let prev_width = self.get_prev_char_width();
902                let max_scroll_for_prev = visual_col.saturating_sub(prev_width);
903
904                scroll_left = candidate_scroll.min(max_scroll_for_prev);
905            }
906        }
907        self.scroll_left.set(scroll_left);
908
909        let rope = self.editor.rope();
910        let nav = CursorNavigator::new(rope);
911
912        // Selection byte range for highlighting
913        let sel_range = self.editor.selection().and_then(|sel| {
914            if sel.is_empty() {
915                None
916            } else {
917                let (a, b) = sel.byte_range(&nav);
918                Some((a, b))
919            }
920        });
921
922        // Show placeholder if empty
923        if self.editor.is_empty() && !self.placeholder.is_empty() {
924            let style = if deg.apply_styling() {
925                self.placeholder_style
926            } else {
927                Style::default()
928            };
929            draw_text_span(
930                frame,
931                text_area_x,
932                area.y,
933                &self.placeholder,
934                style,
935                area.right(),
936            );
937            if self.focused {
938                frame.set_cursor(Some((text_area_x, area.y)));
939            }
940            return;
941        }
942
943        if self.soft_wrap {
944            self.scroll_left.set(0);
945
946            // Compute cursor virtual position (wrapped lines)
947            let mut cursor_virtual = 0;
948            for line_idx in 0..cursor.line {
949                let line_text = rope
950                    .line(line_idx)
951                    .unwrap_or(std::borrow::Cow::Borrowed(""));
952                let line_text = line_text.strip_suffix('\n').unwrap_or(&line_text);
953                cursor_virtual += Self::measure_wrap_count(line_text, text_area_w);
954            }
955
956            let cursor_line_text = rope
957                .line(cursor.line)
958                .unwrap_or(std::borrow::Cow::Borrowed(""));
959            let cursor_line_text = cursor_line_text
960                .strip_suffix('\n')
961                .unwrap_or(&cursor_line_text);
962            let (cursor_wrap_idx, cursor_col_in_wrap) =
963                Self::cursor_wrap_position(cursor_line_text, text_area_w, cursor.visual_col);
964            cursor_virtual = cursor_virtual.saturating_add(cursor_wrap_idx);
965
966            // Adjust scroll to keep cursor visible
967            let mut scroll_virtual = self.scroll_top.get();
968            if cursor_virtual < scroll_virtual {
969                scroll_virtual = cursor_virtual;
970            } else if cursor_virtual >= scroll_virtual + vp_height {
971                scroll_virtual = cursor_virtual.saturating_sub(vp_height - 1);
972            }
973            self.scroll_top.set(scroll_virtual);
974
975            // Render wrapped lines in virtual space
976            let mut virtual_index = 0usize;
977            for line_idx in 0..self.editor.line_count() {
978                if virtual_index >= scroll_virtual + vp_height {
979                    break;
980                }
981
982                let line_text = rope
983                    .line(line_idx)
984                    .unwrap_or(std::borrow::Cow::Borrowed(""));
985                let line_text = line_text.strip_suffix('\n').unwrap_or(&line_text);
986
987                // Fast path: check if this whole physical line is skipped
988                let wrap_count = Self::measure_wrap_count(line_text, text_area_w);
989                if virtual_index + wrap_count <= scroll_virtual {
990                    virtual_index += wrap_count;
991                    continue;
992                }
993
994                let line_start_byte = nav.to_byte_index(nav.from_line_grapheme(line_idx, 0));
995                let slices = Self::wrap_line_slices(line_text, text_area_w);
996
997                for (slice_idx, slice) in slices.iter().enumerate() {
998                    if virtual_index < scroll_virtual {
999                        virtual_index += 1;
1000                        continue;
1001                    }
1002
1003                    let row = virtual_index.saturating_sub(scroll_virtual);
1004                    if row >= vp_height {
1005                        break;
1006                    }
1007
1008                    let y = area.y.saturating_add(row as u16);
1009
1010                    // Line number gutter (only for first wrapped slice)
1011                    if self.show_line_numbers && slice_idx == 0 {
1012                        let style = if deg.apply_styling() {
1013                            self.line_number_style
1014                        } else {
1015                            Style::default()
1016                        };
1017                        let num_str =
1018                            format!("{:>width$} ", line_idx + 1, width = (gutter_w - 2) as usize);
1019                        draw_text_span(frame, area.x, y, &num_str, style, text_area_x);
1020                    }
1021
1022                    // Cursor line highlight (only for the active wrapped slice)
1023                    if line_idx == cursor.line
1024                        && slice_idx == cursor_wrap_idx
1025                        && let Some(cl_style) = self.cursor_line_style
1026                        && deg.apply_styling()
1027                    {
1028                        for cx in text_area_x..area.right() {
1029                            if let Some(cell) = frame.buffer.get_mut(cx, y) {
1030                                apply_style(cell, cl_style);
1031                            }
1032                        }
1033                    }
1034
1035                    // Render graphemes inside the wrapped slice
1036                    let mut visual_x: usize = 0;
1037                    let mut grapheme_byte_offset = line_start_byte + slice.start_byte;
1038
1039                    for g in slice.text.graphemes(true) {
1040                        let g_width = display_width(g);
1041                        let g_byte_len = g.len();
1042
1043                        if visual_x >= text_area_w {
1044                            break;
1045                        }
1046
1047                        let px = text_area_x + visual_x as u16;
1048
1049                        // Determine style (selection highlight)
1050                        let mut g_style = self.style;
1051                        if let Some((sel_start, sel_end)) = sel_range
1052                            && grapheme_byte_offset >= sel_start
1053                            && grapheme_byte_offset < sel_end
1054                            && deg.apply_styling()
1055                        {
1056                            g_style = g_style.merge(&self.selection_style);
1057                        }
1058
1059                        if g_width > 0 {
1060                            draw_text_span(frame, px, y, g, g_style, area.right());
1061                        }
1062
1063                        visual_x += g_width;
1064                        grapheme_byte_offset += g_byte_len;
1065                    }
1066
1067                    virtual_index += 1;
1068                }
1069            }
1070
1071            // Set cursor position if focused
1072            if self.focused && cursor_virtual >= scroll_virtual {
1073                let row = cursor_virtual.saturating_sub(scroll_virtual);
1074                if row < vp_height {
1075                    let cursor_screen_x = text_area_x.saturating_add(cursor_col_in_wrap as u16);
1076                    let cursor_screen_y = area.y.saturating_add(row as u16);
1077                    if cursor_screen_x < area.right() && cursor_screen_y < area.bottom() {
1078                        frame.set_cursor(Some((cursor_screen_x, cursor_screen_y)));
1079                    }
1080                }
1081            }
1082
1083            return;
1084        }
1085
1086        // Render visible lines (no soft wrap)
1087        for row in 0..vp_height {
1088            let line_idx = scroll_top + row;
1089            let y = area.y.saturating_add(row as u16);
1090
1091            if line_idx >= self.editor.line_count() {
1092                break;
1093            }
1094
1095            // Line number gutter
1096            if self.show_line_numbers {
1097                let style = if deg.apply_styling() {
1098                    self.line_number_style
1099                } else {
1100                    Style::default()
1101                };
1102                let num_str = format!("{:>width$} ", line_idx + 1, width = (gutter_w - 2) as usize);
1103                draw_text_span(frame, area.x, y, &num_str, style, text_area_x);
1104            }
1105
1106            // Cursor line highlight
1107            if line_idx == cursor.line
1108                && let Some(cl_style) = self.cursor_line_style
1109                && deg.apply_styling()
1110            {
1111                for cx in text_area_x..area.right() {
1112                    if let Some(cell) = frame.buffer.get_mut(cx, y) {
1113                        apply_style(cell, cl_style);
1114                    }
1115                }
1116            }
1117
1118            // Get line text
1119            let line_text = rope
1120                .line(line_idx)
1121                .unwrap_or(std::borrow::Cow::Borrowed(""));
1122            let line_text = line_text.strip_suffix('\n').unwrap_or(&line_text);
1123
1124            // Calculate line byte offset for selection mapping
1125            let line_start_byte = nav.to_byte_index(nav.from_line_grapheme(line_idx, 0));
1126
1127            // Render each grapheme
1128            let mut visual_x: usize = 0;
1129            let graphemes: Vec<&str> = line_text.graphemes(true).collect();
1130            let mut grapheme_byte_offset = line_start_byte;
1131
1132            for g in &graphemes {
1133                let g_width = display_width(g);
1134                let g_byte_len = g.len();
1135
1136                // Skip graphemes before horizontal scroll
1137                if visual_x + g_width <= scroll_left {
1138                    visual_x += g_width;
1139                    grapheme_byte_offset += g_byte_len;
1140                    continue;
1141                }
1142
1143                // Handle partial overlap at left edge
1144                if visual_x < scroll_left {
1145                    visual_x += g_width;
1146                    grapheme_byte_offset += g_byte_len;
1147                    continue;
1148                }
1149
1150                // Stop if past viewport
1151                let screen_x = visual_x.saturating_sub(scroll_left);
1152                if screen_x >= text_area_w {
1153                    break;
1154                }
1155
1156                let px = text_area_x + screen_x as u16;
1157
1158                // Determine style (selection highlight)
1159                let mut g_style = self.style;
1160                if let Some((sel_start, sel_end)) = sel_range
1161                    && grapheme_byte_offset >= sel_start
1162                    && grapheme_byte_offset < sel_end
1163                    && deg.apply_styling()
1164                {
1165                    g_style = g_style.merge(&self.selection_style);
1166                }
1167
1168                // Write grapheme to buffer
1169                if g_width > 0 {
1170                    draw_text_span(frame, px, y, g, g_style, area.right());
1171                }
1172
1173                visual_x += g_width;
1174                grapheme_byte_offset += g_byte_len;
1175            }
1176        }
1177
1178        // Set cursor position if focused
1179        if self.focused {
1180            let cursor_row = cursor.line.saturating_sub(scroll_top);
1181            if cursor_row < vp_height {
1182                let cursor_screen_x = (cursor.visual_col.saturating_sub(scroll_left) as u16)
1183                    .saturating_add(text_area_x);
1184                let cursor_screen_y = area.y.saturating_add(cursor_row as u16);
1185                if cursor_screen_x < area.right() && cursor_screen_y < area.bottom() {
1186                    frame.set_cursor(Some((cursor_screen_x, cursor_screen_y)));
1187                }
1188            }
1189        }
1190    }
1191
1192    fn is_essential(&self) -> bool {
1193        true
1194    }
1195}
1196
1197impl StatefulWidget for TextArea {
1198    type State = TextAreaState;
1199
1200    fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
1201        state.last_viewport_height = area.height;
1202        state.last_viewport_width = area.width;
1203        Widget::render(self, area, frame);
1204    }
1205}
1206
1207#[cfg(test)]
1208mod tests {
1209    use super::*;
1210
1211    #[test]
1212    fn new_textarea_is_empty() {
1213        let ta = TextArea::new();
1214        assert!(ta.is_empty());
1215        assert_eq!(ta.text(), "");
1216        assert_eq!(ta.line_count(), 1); // empty rope has 1 line
1217    }
1218
1219    #[test]
1220    fn with_text_builder() {
1221        let ta = TextArea::new().with_text("hello\nworld");
1222        assert_eq!(ta.text(), "hello\nworld");
1223        assert_eq!(ta.line_count(), 2);
1224    }
1225
1226    #[test]
1227    fn insert_text_and_newline() {
1228        let mut ta = TextArea::new();
1229        ta.insert_text("hello");
1230        ta.insert_newline();
1231        ta.insert_text("world");
1232        assert_eq!(ta.text(), "hello\nworld");
1233        assert_eq!(ta.line_count(), 2);
1234    }
1235
1236    #[test]
1237    fn delete_backward_works() {
1238        let mut ta = TextArea::new().with_text("hello");
1239        ta.move_to_document_end();
1240        ta.delete_backward();
1241        assert_eq!(ta.text(), "hell");
1242    }
1243
1244    #[test]
1245    fn cursor_movement() {
1246        let mut ta = TextArea::new().with_text("abc\ndef\nghi");
1247        ta.move_to_document_start();
1248        assert_eq!(ta.cursor().line, 0);
1249        assert_eq!(ta.cursor().grapheme, 0);
1250
1251        ta.move_down();
1252        assert_eq!(ta.cursor().line, 1);
1253
1254        ta.move_to_line_end();
1255        assert_eq!(ta.cursor().grapheme, 3);
1256
1257        ta.move_to_document_end();
1258        assert_eq!(ta.cursor().line, 2);
1259    }
1260
1261    #[test]
1262    fn undo_redo() {
1263        let mut ta = TextArea::new();
1264        ta.insert_text("abc");
1265        assert_eq!(ta.text(), "abc");
1266        ta.undo();
1267        assert_eq!(ta.text(), "");
1268        ta.redo();
1269        assert_eq!(ta.text(), "abc");
1270    }
1271
1272    #[test]
1273    fn selection_and_delete() {
1274        let mut ta = TextArea::new().with_text("hello world");
1275        ta.move_to_document_start();
1276        for _ in 0..5 {
1277            ta.select_right();
1278        }
1279        assert_eq!(ta.selected_text(), Some("hello".to_string()));
1280        ta.delete_backward();
1281        assert_eq!(ta.text(), " world");
1282    }
1283
1284    #[test]
1285    fn select_all() {
1286        let mut ta = TextArea::new().with_text("abc\ndef");
1287        ta.select_all();
1288        assert_eq!(ta.selected_text(), Some("abc\ndef".to_string()));
1289    }
1290
1291    #[test]
1292    fn set_text_resets() {
1293        let mut ta = TextArea::new().with_text("old");
1294        ta.insert_text(" stuff");
1295        ta.set_text("new");
1296        assert_eq!(ta.text(), "new");
1297    }
1298
1299    #[test]
1300    fn scroll_follows_cursor() {
1301        let mut ta = TextArea::new();
1302        // Insert many lines
1303        for i in 0..50 {
1304            ta.insert_text(&format!("line {}\n", i));
1305        }
1306        // Cursor should be at the bottom, scroll_top adjusted
1307        assert!(ta.scroll_top.get() > 0);
1308        assert!(ta.cursor().line >= 49);
1309
1310        // Move to top
1311        ta.move_to_document_start();
1312        assert_eq!(ta.scroll_top.get(), 0);
1313    }
1314
1315    #[test]
1316    fn gutter_width_without_line_numbers() {
1317        let ta = TextArea::new();
1318        assert_eq!(ta.gutter_width(), 0);
1319    }
1320
1321    #[test]
1322    fn gutter_width_with_line_numbers() {
1323        let mut ta = TextArea::new().with_line_numbers(true);
1324        ta.insert_text("a\nb\nc");
1325        assert_eq!(ta.gutter_width(), 3); // 1 digit + space + separator
1326    }
1327
1328    #[test]
1329    fn gutter_width_many_lines() {
1330        let mut ta = TextArea::new().with_line_numbers(true);
1331        for i in 0..100 {
1332            ta.insert_text(&format!("line {}\n", i));
1333        }
1334        assert_eq!(ta.gutter_width(), 5); // 3 digits + space + separator
1335    }
1336
1337    #[test]
1338    fn focus_state() {
1339        let mut ta = TextArea::new();
1340        assert!(!ta.is_focused());
1341        ta.set_focused(true);
1342        assert!(ta.is_focused());
1343    }
1344
1345    #[test]
1346    fn word_movement() {
1347        let mut ta = TextArea::new().with_text("hello world foo");
1348        ta.move_to_document_start();
1349        ta.move_word_right();
1350        assert_eq!(ta.cursor().grapheme, 6);
1351        ta.move_word_left();
1352        assert_eq!(ta.cursor().grapheme, 0);
1353    }
1354
1355    #[test]
1356    fn page_up_down() {
1357        let mut ta = TextArea::new();
1358        for i in 0..50 {
1359            ta.insert_text(&format!("line {}\n", i));
1360        }
1361        ta.move_to_document_start();
1362        let state = TextAreaState {
1363            last_viewport_height: 10,
1364            last_viewport_width: 80,
1365        };
1366        ta.page_down(&state);
1367        assert!(ta.cursor().line >= 10);
1368        ta.page_up(&state);
1369        assert_eq!(ta.cursor().line, 0);
1370    }
1371
1372    #[test]
1373    fn insert_replaces_selection() {
1374        let mut ta = TextArea::new().with_text("hello world");
1375        ta.move_to_document_start();
1376        for _ in 0..5 {
1377            ta.select_right();
1378        }
1379        ta.insert_text("goodbye");
1380        assert_eq!(ta.text(), "goodbye world");
1381    }
1382
1383    #[test]
1384    fn insert_single_char() {
1385        let mut ta = TextArea::new();
1386        ta.insert_char('X');
1387        assert_eq!(ta.text(), "X");
1388        assert_eq!(ta.cursor().grapheme, 1);
1389    }
1390
1391    #[test]
1392    fn insert_multiline_text() {
1393        let mut ta = TextArea::new();
1394        ta.insert_text("line1\nline2\nline3");
1395        assert_eq!(ta.line_count(), 3);
1396        assert_eq!(ta.cursor().line, 2);
1397    }
1398
1399    #[test]
1400    fn delete_forward_works() {
1401        let mut ta = TextArea::new().with_text("hello");
1402        ta.move_to_document_start();
1403        ta.delete_forward();
1404        assert_eq!(ta.text(), "ello");
1405    }
1406
1407    #[test]
1408    fn delete_backward_at_line_start_joins_lines() {
1409        let mut ta = TextArea::new().with_text("abc\ndef");
1410        // Move to start of line 2
1411        ta.move_to_document_start();
1412        ta.move_down();
1413        ta.move_to_line_start();
1414        ta.delete_backward();
1415        assert_eq!(ta.text(), "abcdef");
1416        assert_eq!(ta.line_count(), 1);
1417    }
1418
1419    #[test]
1420    fn cursor_horizontal_movement() {
1421        let mut ta = TextArea::new().with_text("abc");
1422        ta.move_to_document_start();
1423        ta.move_right();
1424        assert_eq!(ta.cursor().grapheme, 1);
1425        ta.move_right();
1426        assert_eq!(ta.cursor().grapheme, 2);
1427        ta.move_left();
1428        assert_eq!(ta.cursor().grapheme, 1);
1429    }
1430
1431    #[test]
1432    fn cursor_vertical_maintains_column() {
1433        let mut ta = TextArea::new().with_text("abcde\nfg\nhijkl");
1434        ta.move_to_document_start();
1435        ta.move_to_line_end(); // col 5
1436        ta.move_down(); // line 1 only has 2 chars, should clamp
1437        assert_eq!(ta.cursor().line, 1);
1438        ta.move_down(); // line 2 has 5 chars, should restore col
1439        assert_eq!(ta.cursor().line, 2);
1440    }
1441
1442    #[test]
1443    fn selection_shift_arrow() {
1444        let mut ta = TextArea::new().with_text("abcdef");
1445        ta.move_to_document_start();
1446        ta.select_right();
1447        ta.select_right();
1448        ta.select_right();
1449        assert_eq!(ta.selected_text(), Some("abc".to_string()));
1450    }
1451
1452    #[test]
1453    fn selection_extends_up_down() {
1454        let mut ta = TextArea::new().with_text("line1\nline2\nline3");
1455        ta.move_to_document_start();
1456        ta.select_down();
1457        let sel = ta.selected_text().unwrap();
1458        assert!(sel.contains('\n'));
1459    }
1460
1461    #[test]
1462    fn undo_chain() {
1463        let mut ta = TextArea::new();
1464        ta.insert_text("a");
1465        ta.insert_text("b");
1466        ta.insert_text("c");
1467        assert_eq!(ta.text(), "abc");
1468        ta.undo();
1469        ta.undo();
1470        ta.undo();
1471        assert_eq!(ta.text(), "");
1472    }
1473
1474    #[test]
1475    fn redo_discarded_on_new_edit() {
1476        let mut ta = TextArea::new();
1477        ta.insert_text("abc");
1478        ta.undo();
1479        ta.insert_text("xyz");
1480        ta.redo(); // should be no-op
1481        assert_eq!(ta.text(), "xyz");
1482    }
1483
1484    #[test]
1485    fn clear_selection() {
1486        let mut ta = TextArea::new().with_text("hello");
1487        ta.select_all();
1488        assert!(ta.selection().is_some());
1489        ta.clear_selection();
1490        assert!(ta.selection().is_none());
1491    }
1492
1493    #[test]
1494    fn delete_word_backward() {
1495        let mut ta = TextArea::new().with_text("hello world");
1496        ta.move_to_document_end();
1497        ta.delete_word_backward();
1498        assert_eq!(ta.text(), "hello ");
1499    }
1500
1501    #[test]
1502    fn delete_to_end_of_line() {
1503        let mut ta = TextArea::new().with_text("hello world");
1504        ta.move_to_document_start();
1505        ta.move_right(); // after 'h'
1506        ta.delete_to_end_of_line();
1507        assert_eq!(ta.text(), "h");
1508    }
1509
1510    #[test]
1511    fn placeholder_builder() {
1512        let ta = TextArea::new().with_placeholder("Enter text...");
1513        assert!(ta.is_empty());
1514        assert_eq!(ta.placeholder, "Enter text...");
1515    }
1516
1517    #[test]
1518    fn soft_wrap_builder() {
1519        let ta = TextArea::new().with_soft_wrap(true);
1520        assert!(ta.soft_wrap);
1521    }
1522
1523    #[test]
1524    fn soft_wrap_renders_wrapped_lines() {
1525        use crate::Widget;
1526        use ftui_render::grapheme_pool::GraphemePool;
1527
1528        let ta = TextArea::new().with_soft_wrap(true).with_text("abcdef");
1529        let area = Rect::new(0, 0, 3, 2);
1530        let mut pool = GraphemePool::new();
1531        let mut frame = Frame::new(3, 2, &mut pool);
1532        Widget::render(&ta, area, &mut frame);
1533
1534        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('a'));
1535        assert_eq!(frame.buffer.get(2, 0).unwrap().content.as_char(), Some('c'));
1536        assert_eq!(frame.buffer.get(0, 1).unwrap().content.as_char(), Some('d'));
1537        assert_eq!(frame.buffer.get(2, 1).unwrap().content.as_char(), Some('f'));
1538    }
1539
1540    #[test]
1541    fn max_height_builder() {
1542        let ta = TextArea::new().with_max_height(10);
1543        assert_eq!(ta.max_height, 10);
1544    }
1545
1546    #[test]
1547    fn editor_access() {
1548        let mut ta = TextArea::new().with_text("test");
1549        assert_eq!(ta.editor().text(), "test");
1550        ta.editor_mut().insert_char('!');
1551        assert!(ta.text().contains('!'));
1552    }
1553
1554    #[test]
1555    fn move_to_line_start_and_end() {
1556        let mut ta = TextArea::new().with_text("hello world");
1557        ta.move_to_document_start();
1558        ta.move_to_line_end();
1559        assert_eq!(ta.cursor().grapheme, 11);
1560        ta.move_to_line_start();
1561        assert_eq!(ta.cursor().grapheme, 0);
1562    }
1563
1564    #[test]
1565    fn render_empty_with_placeholder() {
1566        use ftui_render::grapheme_pool::GraphemePool;
1567        let ta = TextArea::new()
1568            .with_placeholder("Type here")
1569            .with_focus(true);
1570        let mut pool = GraphemePool::new();
1571        let mut frame = Frame::new(20, 5, &mut pool);
1572        let area = Rect::new(0, 0, 20, 5);
1573        Widget::render(&ta, area, &mut frame);
1574        // Placeholder should be rendered
1575        let cell = frame.buffer.get(0, 0).unwrap();
1576        assert_eq!(cell.content.as_char(), Some('T'));
1577        // Cursor should be set
1578        assert!(frame.cursor_position.is_some());
1579    }
1580
1581    #[test]
1582    fn render_with_content() {
1583        use ftui_render::grapheme_pool::GraphemePool;
1584        let ta = TextArea::new().with_text("abc\ndef").with_focus(true);
1585        let mut pool = GraphemePool::new();
1586        let mut frame = Frame::new(20, 5, &mut pool);
1587        let area = Rect::new(0, 0, 20, 5);
1588        Widget::render(&ta, area, &mut frame);
1589        let cell = frame.buffer.get(0, 0).unwrap();
1590        assert_eq!(cell.content.as_char(), Some('a'));
1591    }
1592
1593    #[test]
1594    fn render_line_numbers_without_styling() {
1595        use ftui_render::budget::DegradationLevel;
1596        use ftui_render::grapheme_pool::GraphemePool;
1597
1598        let ta = TextArea::new().with_text("a\nb").with_line_numbers(true);
1599        let mut pool = GraphemePool::new();
1600        let mut frame = Frame::new(8, 2, &mut pool);
1601        frame.set_degradation(DegradationLevel::NoStyling);
1602
1603        Widget::render(&ta, Rect::new(0, 0, 8, 2), &mut frame);
1604
1605        let cell = frame.buffer.get(0, 0).unwrap();
1606        assert_eq!(cell.content.as_char(), Some('1'));
1607    }
1608
1609    #[test]
1610    fn stateful_render_updates_viewport_state() {
1611        use ftui_render::grapheme_pool::GraphemePool;
1612
1613        let ta = TextArea::new();
1614        let mut state = TextAreaState::default();
1615        let mut pool = GraphemePool::new();
1616        let mut frame = Frame::new(10, 3, &mut pool);
1617        let area = Rect::new(0, 0, 10, 3);
1618
1619        StatefulWidget::render(&ta, area, &mut frame, &mut state);
1620
1621        assert_eq!(state.last_viewport_height, 3);
1622        assert_eq!(state.last_viewport_width, 10);
1623    }
1624
1625    #[test]
1626    fn render_zero_area_no_panic() {
1627        let ta = TextArea::new().with_text("test");
1628        use ftui_render::grapheme_pool::GraphemePool;
1629        let mut pool = GraphemePool::new();
1630        let mut frame = Frame::new(10, 10, &mut pool);
1631        Widget::render(&ta, Rect::new(0, 0, 0, 0), &mut frame);
1632    }
1633
1634    #[test]
1635    fn is_essential() {
1636        let ta = TextArea::new();
1637        assert!(Widget::is_essential(&ta));
1638    }
1639
1640    #[test]
1641    fn default_impl() {
1642        let ta = TextArea::default();
1643        assert!(ta.is_empty());
1644    }
1645
1646    #[test]
1647    fn insert_newline_splits_line() {
1648        let mut ta = TextArea::new().with_text("abcdef");
1649        ta.move_to_document_start();
1650        ta.move_right();
1651        ta.move_right();
1652        ta.move_right();
1653        ta.insert_newline();
1654        assert_eq!(ta.line_count(), 2);
1655        assert_eq!(ta.cursor().line, 1);
1656    }
1657
1658    #[test]
1659    fn unicode_grapheme_cluster() {
1660        let mut ta = TextArea::new();
1661        ta.insert_text("café");
1662        // 'é' is a single grapheme even if composed
1663        assert_eq!(ta.text(), "café");
1664    }
1665
1666    mod proptests {
1667        use super::*;
1668        use proptest::prelude::*;
1669
1670        proptest! {
1671            #[test]
1672            fn insert_delete_inverse(text in "[a-zA-Z0-9 ]{1,50}") {
1673                let mut ta = TextArea::new();
1674                ta.insert_text(&text);
1675                // Delete all characters backwards
1676                for _ in 0..text.len() {
1677                    ta.delete_backward();
1678                }
1679                prop_assert!(ta.is_empty() || ta.text().is_empty());
1680            }
1681
1682            #[test]
1683            fn undo_redo_inverse(text in "[a-zA-Z0-9]{1,30}") {
1684                let mut ta = TextArea::new();
1685                ta.insert_text(&text);
1686                let after_insert = ta.text();
1687                ta.undo();
1688                ta.redo();
1689                prop_assert_eq!(ta.text(), after_insert);
1690            }
1691
1692            #[test]
1693            fn cursor_always_valid(ops in proptest::collection::vec(0u8..10, 1..20)) {
1694                let mut ta = TextArea::new().with_text("abc\ndef\nghi\njkl");
1695                for op in ops {
1696                    match op {
1697                        0 => ta.move_left(),
1698                        1 => ta.move_right(),
1699                        2 => ta.move_up(),
1700                        3 => ta.move_down(),
1701                        4 => ta.move_to_line_start(),
1702                        5 => ta.move_to_line_end(),
1703                        6 => ta.move_to_document_start(),
1704                        7 => ta.move_to_document_end(),
1705                        8 => ta.move_word_left(),
1706                        _ => ta.move_word_right(),
1707                    }
1708                    let cursor = ta.cursor();
1709                    prop_assert!(cursor.line < ta.line_count(),
1710                        "cursor line {} >= line_count {}", cursor.line, ta.line_count());
1711                }
1712            }
1713
1714            #[test]
1715            fn selection_ordered(n in 1usize..20) {
1716                let mut ta = TextArea::new().with_text("hello world foo bar");
1717                ta.move_to_document_start();
1718                for _ in 0..n {
1719                    ta.select_right();
1720                }
1721                if let Some(sel) = ta.selection() {
1722                    // When selecting right from start, anchor should be at/before head
1723                    prop_assert!(sel.anchor.line <= sel.head.line
1724                        || (sel.anchor.line == sel.head.line
1725                            && sel.anchor.grapheme <= sel.head.grapheme));
1726                }
1727            }
1728        }
1729    }
1730}