Skip to main content

ratatui_interact/components/
textarea.rs

1//! TextArea component - Multi-line text input with cursor
2//!
3//! Supports multi-line text editing with cursor movement, line numbers,
4//! scrolling, focus styling, and click-to-focus.
5//!
6//! # Example
7//!
8//! ```rust
9//! use ratatui_interact::components::{TextArea, TextAreaState, TextAreaStyle};
10//!
11//! let mut state = TextAreaState::new("Hello\nWorld");
12//!
13//! // Cursor starts at beginning
14//! assert_eq!(state.cursor_line, 0);
15//! assert_eq!(state.cursor_col, 0);
16//!
17//! // Navigate to end
18//! state.move_to_end();
19//! assert_eq!(state.cursor_line, 1);
20//! assert_eq!(state.cursor_col, 5);
21//!
22//! // Create widget (state passed to render_stateful)
23//! let textarea = TextArea::new()
24//!     .label("Editor")
25//!     .placeholder("Enter text...");
26//! ```
27
28use ratatui::{
29    Frame,
30    layout::Rect,
31    style::{Color, Style},
32    text::{Line, Span},
33    widgets::{Block, Borders, Paragraph},
34};
35
36use crate::traits::{ClickRegion, FocusId};
37
38/// Convert character index to byte index in a string.
39fn char_to_byte_index(s: &str, char_idx: usize) -> usize {
40    s.char_indices()
41        .nth(char_idx)
42        .map(|(i, _)| i)
43        .unwrap_or(s.len())
44}
45
46/// Get character at index in a string.
47fn char_at(s: &str, index: usize) -> Option<char> {
48    s.chars().nth(index)
49}
50
51/// Actions a textarea can emit.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum TextAreaAction {
54    /// Focus the textarea.
55    Focus,
56}
57
58/// Tab handling configuration.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum TabConfig {
61    /// Insert spaces (default: 4 spaces).
62    Spaces(usize),
63    /// Insert a literal tab character.
64    Literal,
65}
66
67impl Default for TabConfig {
68    fn default() -> Self {
69        TabConfig::Spaces(4)
70    }
71}
72
73/// Wrap mode for long lines.
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
75pub enum WrapMode {
76    /// No wrapping - horizontal scroll instead.
77    #[default]
78    None,
79    /// Soft wrap at word boundaries.
80    Soft,
81}
82
83/// State for a multi-line text area.
84#[derive(Debug, Clone)]
85pub struct TextAreaState {
86    /// Lines of text.
87    pub lines: Vec<String>,
88    /// Current line (0-indexed).
89    pub cursor_line: usize,
90    /// Cursor column (character index within line).
91    pub cursor_col: usize,
92    /// Vertical scroll offset.
93    pub scroll_y: usize,
94    /// Horizontal scroll offset (for no-wrap mode).
95    pub scroll_x: usize,
96    /// Visible viewport height (set during render).
97    pub visible_height: usize,
98    /// Whether the textarea has focus.
99    pub focused: bool,
100    /// Whether the textarea is enabled.
101    pub enabled: bool,
102    /// Tab configuration.
103    pub tab_config: TabConfig,
104}
105
106impl Default for TextAreaState {
107    fn default() -> Self {
108        Self {
109            lines: vec![String::new()],
110            cursor_line: 0,
111            cursor_col: 0,
112            scroll_y: 0,
113            scroll_x: 0,
114            visible_height: 0,
115            focused: false,
116            enabled: true,
117            tab_config: TabConfig::default(),
118        }
119    }
120}
121
122impl TextAreaState {
123    /// Create a new textarea state with initial text.
124    ///
125    /// Cursor is positioned at the start of the text.
126    pub fn new(text: impl Into<String>) -> Self {
127        let text = text.into();
128        let lines: Vec<String> = if text.is_empty() {
129            vec![String::new()]
130        } else {
131            text.lines().map(|s| s.to_string()).collect()
132        };
133        // Ensure at least one line
134        let lines = if lines.is_empty() {
135            vec![String::new()]
136        } else {
137            lines
138        };
139
140        Self {
141            lines,
142            cursor_line: 0,
143            cursor_col: 0,
144            scroll_y: 0,
145            scroll_x: 0,
146            visible_height: 0,
147            focused: false,
148            enabled: true,
149            tab_config: TabConfig::default(),
150        }
151    }
152
153    /// Create an empty textarea state.
154    pub fn empty() -> Self {
155        Self::default()
156    }
157
158    /// Set the tab configuration.
159    pub fn with_tab_config(mut self, config: TabConfig) -> Self {
160        self.tab_config = config;
161        self
162    }
163
164    // ========================================================================
165    // Character operations
166    // ========================================================================
167
168    /// Insert a character at cursor position.
169    pub fn insert_char(&mut self, c: char) {
170        if !self.enabled {
171            return;
172        }
173        let byte_pos = char_to_byte_index(&self.lines[self.cursor_line], self.cursor_col);
174        self.lines[self.cursor_line].insert(byte_pos, c);
175        self.cursor_col += 1;
176    }
177
178    /// Insert a string at cursor position (handles multi-line input).
179    pub fn insert_str(&mut self, s: &str) {
180        if !self.enabled {
181            return;
182        }
183        for c in s.chars() {
184            if c == '\n' {
185                self.insert_newline();
186            } else if c != '\r' {
187                self.insert_char(c);
188            }
189        }
190    }
191
192    /// Insert a newline at cursor position.
193    pub fn insert_newline(&mut self) {
194        if !self.enabled {
195            return;
196        }
197
198        let byte_pos = char_to_byte_index(&self.lines[self.cursor_line], self.cursor_col);
199
200        // Split the current line
201        let rest = self.lines[self.cursor_line][byte_pos..].to_string();
202        self.lines[self.cursor_line].truncate(byte_pos);
203
204        // Insert new line after current
205        self.cursor_line += 1;
206        self.lines.insert(self.cursor_line, rest);
207        self.cursor_col = 0;
208
209        self.ensure_cursor_visible();
210    }
211
212    /// Insert a tab (spaces or literal depending on config).
213    pub fn insert_tab(&mut self) {
214        if !self.enabled {
215            return;
216        }
217        match self.tab_config {
218            TabConfig::Spaces(n) => {
219                for _ in 0..n {
220                    self.insert_char(' ');
221                }
222            }
223            TabConfig::Literal => {
224                self.insert_char('\t');
225            }
226        }
227    }
228
229    // ========================================================================
230    // Deletion operations
231    // ========================================================================
232
233    /// Delete character before cursor (backspace).
234    ///
235    /// At the start of a line, merges with previous line.
236    /// Returns `true` if any change was made.
237    pub fn delete_char_backward(&mut self) -> bool {
238        if !self.enabled {
239            return false;
240        }
241
242        if self.cursor_col > 0 {
243            // Delete character within line
244            self.cursor_col -= 1;
245            let byte_pos = char_to_byte_index(&self.lines[self.cursor_line], self.cursor_col);
246            if let Some(c) = self.lines[self.cursor_line][byte_pos..].chars().next() {
247                self.lines[self.cursor_line]
248                    .replace_range(byte_pos..byte_pos + c.len_utf8(), "");
249                return true;
250            }
251        } else if self.cursor_line > 0 {
252            // Merge with previous line
253            let current_line = self.lines.remove(self.cursor_line);
254            self.cursor_line -= 1;
255            self.cursor_col = self.lines[self.cursor_line].chars().count();
256            self.lines[self.cursor_line].push_str(&current_line);
257            self.ensure_cursor_visible();
258            return true;
259        }
260        false
261    }
262
263    /// Delete character at cursor (delete key).
264    ///
265    /// At the end of a line, merges with next line.
266    /// Returns `true` if any change was made.
267    pub fn delete_char_forward(&mut self) -> bool {
268        if !self.enabled {
269            return false;
270        }
271
272        let line_len = self.lines[self.cursor_line].chars().count();
273
274        if self.cursor_col < line_len {
275            // Delete character within line
276            let byte_pos = char_to_byte_index(&self.lines[self.cursor_line], self.cursor_col);
277            if let Some(c) = self.lines[self.cursor_line][byte_pos..].chars().next() {
278                self.lines[self.cursor_line]
279                    .replace_range(byte_pos..byte_pos + c.len_utf8(), "");
280                return true;
281            }
282        } else if self.cursor_line + 1 < self.lines.len() {
283            // Merge with next line
284            let next_line = self.lines.remove(self.cursor_line + 1);
285            self.lines[self.cursor_line].push_str(&next_line);
286            return true;
287        }
288        false
289    }
290
291    /// Delete word before cursor.
292    ///
293    /// Returns `true` if any characters were deleted.
294    pub fn delete_word_backward(&mut self) -> bool {
295        if !self.enabled || (self.cursor_col == 0 && self.cursor_line == 0) {
296            return false;
297        }
298
299        // If at start of line, just merge with previous line
300        if self.cursor_col == 0 {
301            return self.delete_char_backward();
302        }
303
304        let start_col = self.cursor_col;
305        let line = &self.lines[self.cursor_line];
306
307        // Skip trailing whitespace
308        while self.cursor_col > 0 {
309            if let Some(c) = char_at(line, self.cursor_col - 1) {
310                if c.is_whitespace() {
311                    self.cursor_col -= 1;
312                } else {
313                    break;
314                }
315            } else {
316                break;
317            }
318        }
319
320        // Delete word characters
321        while self.cursor_col > 0 {
322            if let Some(c) = char_at(&self.lines[self.cursor_line], self.cursor_col - 1)
323            {
324                if !c.is_whitespace() {
325                    self.delete_char_backward();
326                } else {
327                    break;
328                }
329            } else {
330                break;
331            }
332        }
333
334        start_col != self.cursor_col
335    }
336
337    /// Delete entire current line.
338    ///
339    /// If there's only one line, clears it instead.
340    pub fn delete_line(&mut self) {
341        if !self.enabled {
342            return;
343        }
344
345        if self.lines.len() == 1 {
346            self.lines[0].clear();
347            self.cursor_col = 0;
348        } else {
349            self.lines.remove(self.cursor_line);
350            if self.cursor_line >= self.lines.len() {
351                self.cursor_line = self.lines.len().saturating_sub(1);
352            }
353            // Adjust cursor column to fit new line
354            let new_line_len = self.lines[self.cursor_line].chars().count();
355            self.cursor_col = self.cursor_col.min(new_line_len);
356        }
357        self.ensure_cursor_visible();
358    }
359
360    /// Delete from cursor to line start (Ctrl+U).
361    pub fn delete_to_line_start(&mut self) {
362        if !self.enabled || self.cursor_col == 0 {
363            return;
364        }
365
366        let line = &self.lines[self.cursor_line];
367        let byte_pos = char_to_byte_index(line, self.cursor_col);
368        self.lines[self.cursor_line] = line[byte_pos..].to_string();
369        self.cursor_col = 0;
370    }
371
372    /// Delete from cursor to line end (Ctrl+K).
373    pub fn delete_to_line_end(&mut self) {
374        if !self.enabled {
375            return;
376        }
377
378        let line = &self.lines[self.cursor_line];
379        let byte_pos = char_to_byte_index(line, self.cursor_col);
380        self.lines[self.cursor_line] = line[..byte_pos].to_string();
381    }
382
383    // ========================================================================
384    // Cursor movement - Horizontal
385    // ========================================================================
386
387    /// Move cursor left by one character.
388    ///
389    /// At the start of a line, moves to end of previous line.
390    pub fn move_left(&mut self) {
391        if self.cursor_col > 0 {
392            self.cursor_col -= 1;
393        } else if self.cursor_line > 0 {
394            self.cursor_line -= 1;
395            self.cursor_col = self.lines[self.cursor_line].chars().count();
396            self.ensure_cursor_visible();
397        }
398    }
399
400    /// Move cursor right by one character.
401    ///
402    /// At the end of a line, moves to start of next line.
403    pub fn move_right(&mut self) {
404        let line_len = self.lines[self.cursor_line].chars().count();
405        if self.cursor_col < line_len {
406            self.cursor_col += 1;
407        } else if self.cursor_line + 1 < self.lines.len() {
408            self.cursor_line += 1;
409            self.cursor_col = 0;
410            self.ensure_cursor_visible();
411        }
412    }
413
414    /// Move cursor to start of line (Home).
415    pub fn move_line_start(&mut self) {
416        self.cursor_col = 0;
417    }
418
419    /// Move cursor to end of line (End).
420    pub fn move_line_end(&mut self) {
421        self.cursor_col = self.lines[self.cursor_line].chars().count();
422    }
423
424    /// Move cursor left by one word.
425    pub fn move_word_left(&mut self) {
426        if self.cursor_col == 0 {
427            if self.cursor_line > 0 {
428                self.cursor_line -= 1;
429                self.cursor_col = self.lines[self.cursor_line].chars().count();
430                self.ensure_cursor_visible();
431            }
432            return;
433        }
434
435        let line = &self.lines[self.cursor_line];
436
437        // Skip whitespace
438        while self.cursor_col > 0 {
439            if let Some(c) = char_at(line, self.cursor_col - 1) {
440                if c.is_whitespace() {
441                    self.cursor_col -= 1;
442                } else {
443                    break;
444                }
445            } else {
446                break;
447            }
448        }
449
450        // Skip word characters
451        while self.cursor_col > 0 {
452            if let Some(c) = char_at(line, self.cursor_col - 1) {
453                if !c.is_whitespace() {
454                    self.cursor_col -= 1;
455                } else {
456                    break;
457                }
458            } else {
459                break;
460            }
461        }
462    }
463
464    /// Move cursor right by one word.
465    pub fn move_word_right(&mut self) {
466        let line = &self.lines[self.cursor_line];
467        let line_len = line.chars().count();
468
469        if self.cursor_col >= line_len {
470            if self.cursor_line + 1 < self.lines.len() {
471                self.cursor_line += 1;
472                self.cursor_col = 0;
473                self.ensure_cursor_visible();
474            }
475            return;
476        }
477
478        // Skip current word
479        while self.cursor_col < line_len {
480            if let Some(c) = char_at(&self.lines[self.cursor_line], self.cursor_col) {
481                if !c.is_whitespace() {
482                    self.cursor_col += 1;
483                } else {
484                    break;
485                }
486            } else {
487                break;
488            }
489        }
490
491        // Skip whitespace
492        let line_len = self.lines[self.cursor_line].chars().count();
493        while self.cursor_col < line_len {
494            if let Some(c) = char_at(&self.lines[self.cursor_line], self.cursor_col) {
495                if c.is_whitespace() {
496                    self.cursor_col += 1;
497                } else {
498                    break;
499                }
500            } else {
501                break;
502            }
503        }
504    }
505
506    // ========================================================================
507    // Cursor movement - Vertical
508    // ========================================================================
509
510    /// Move cursor up by one line.
511    pub fn move_up(&mut self) {
512        if self.cursor_line > 0 {
513            self.cursor_line -= 1;
514            // Clamp column to new line length
515            let new_line_len = self.lines[self.cursor_line].chars().count();
516            self.cursor_col = self.cursor_col.min(new_line_len);
517            self.ensure_cursor_visible();
518        }
519    }
520
521    /// Move cursor down by one line.
522    pub fn move_down(&mut self) {
523        if self.cursor_line + 1 < self.lines.len() {
524            self.cursor_line += 1;
525            // Clamp column to new line length
526            let new_line_len = self.lines[self.cursor_line].chars().count();
527            self.cursor_col = self.cursor_col.min(new_line_len);
528            self.ensure_cursor_visible();
529        }
530    }
531
532    /// Move cursor up by one page.
533    pub fn move_page_up(&mut self) {
534        let page_size = self.visible_height.max(1);
535        if self.cursor_line >= page_size {
536            self.cursor_line -= page_size;
537        } else {
538            self.cursor_line = 0;
539        }
540        // Clamp column to new line length
541        let new_line_len = self.lines[self.cursor_line].chars().count();
542        self.cursor_col = self.cursor_col.min(new_line_len);
543        self.ensure_cursor_visible();
544    }
545
546    /// Move cursor down by one page.
547    pub fn move_page_down(&mut self) {
548        let page_size = self.visible_height.max(1);
549        let max_line = self.lines.len().saturating_sub(1);
550        self.cursor_line = (self.cursor_line + page_size).min(max_line);
551        // Clamp column to new line length
552        let new_line_len = self.lines[self.cursor_line].chars().count();
553        self.cursor_col = self.cursor_col.min(new_line_len);
554        self.ensure_cursor_visible();
555    }
556
557    /// Move cursor to start of document (Ctrl+Home).
558    pub fn move_to_start(&mut self) {
559        self.cursor_line = 0;
560        self.cursor_col = 0;
561        self.ensure_cursor_visible();
562    }
563
564    /// Move cursor to end of document (Ctrl+End).
565    pub fn move_to_end(&mut self) {
566        self.cursor_line = self.lines.len().saturating_sub(1);
567        self.cursor_col = self.lines[self.cursor_line].chars().count();
568        self.ensure_cursor_visible();
569    }
570
571    // ========================================================================
572    // Scroll management
573    // ========================================================================
574
575    /// Scroll to make cursor visible.
576    pub fn scroll_to_cursor(&mut self) {
577        // Vertical scroll
578        if self.cursor_line < self.scroll_y {
579            self.scroll_y = self.cursor_line;
580        } else if self.visible_height > 0 && self.cursor_line >= self.scroll_y + self.visible_height
581        {
582            self.scroll_y = self.cursor_line - self.visible_height + 1;
583        }
584    }
585
586    /// Ensure cursor is visible (alias for scroll_to_cursor).
587    pub fn ensure_cursor_visible(&mut self) {
588        self.scroll_to_cursor();
589    }
590
591    /// Scroll up by one line.
592    pub fn scroll_up(&mut self) {
593        self.scroll_y = self.scroll_y.saturating_sub(1);
594    }
595
596    /// Scroll down by one line.
597    pub fn scroll_down(&mut self) {
598        let max_scroll = self.lines.len().saturating_sub(self.visible_height.max(1));
599        if self.scroll_y < max_scroll {
600            self.scroll_y += 1;
601        }
602    }
603
604    /// Scroll left (for no-wrap mode).
605    pub fn scroll_left(&mut self) {
606        self.scroll_x = self.scroll_x.saturating_sub(4);
607    }
608
609    /// Scroll right (for no-wrap mode).
610    pub fn scroll_right(&mut self) {
611        self.scroll_x += 4;
612    }
613
614    // ========================================================================
615    // Content helpers
616    // ========================================================================
617
618    /// Get full text content (all lines joined with newlines).
619    pub fn text(&self) -> String {
620        self.lines.join("\n")
621    }
622
623    /// Set text content.
624    ///
625    /// Cursor moves to the end.
626    pub fn set_text(&mut self, text: impl Into<String>) {
627        let text = text.into();
628        self.lines = if text.is_empty() {
629            vec![String::new()]
630        } else {
631            text.lines().map(|s| s.to_string()).collect()
632        };
633        if self.lines.is_empty() {
634            self.lines.push(String::new());
635        }
636        self.cursor_line = self.lines.len().saturating_sub(1);
637        self.cursor_col = self.lines[self.cursor_line].chars().count();
638        self.scroll_y = 0;
639        self.scroll_x = 0;
640    }
641
642    /// Clear all text.
643    pub fn clear(&mut self) {
644        self.lines = vec![String::new()];
645        self.cursor_line = 0;
646        self.cursor_col = 0;
647        self.scroll_y = 0;
648        self.scroll_x = 0;
649    }
650
651    /// Get number of lines.
652    pub fn line_count(&self) -> usize {
653        self.lines.len()
654    }
655
656    /// Get current line content.
657    pub fn current_line(&self) -> &str {
658        &self.lines[self.cursor_line]
659    }
660
661    /// Check if textarea is empty.
662    pub fn is_empty(&self) -> bool {
663        self.lines.len() == 1 && self.lines[0].is_empty()
664    }
665
666    /// Get total character count (including newlines).
667    pub fn len(&self) -> usize {
668        let line_chars: usize = self.lines.iter().map(|l| l.chars().count()).sum();
669        let newlines = self.lines.len().saturating_sub(1);
670        line_chars + newlines
671    }
672
673    /// Get text before cursor on current line.
674    pub fn text_before_cursor(&self) -> &str {
675        let line = &self.lines[self.cursor_line];
676        let byte_pos = char_to_byte_index(line, self.cursor_col);
677        &line[..byte_pos]
678    }
679
680    /// Get text after cursor on current line.
681    pub fn text_after_cursor(&self) -> &str {
682        let line = &self.lines[self.cursor_line];
683        let byte_pos = char_to_byte_index(line, self.cursor_col);
684        &line[byte_pos..]
685    }
686}
687
688/// Configuration for textarea appearance.
689#[derive(Debug, Clone)]
690pub struct TextAreaStyle {
691    /// Border color when focused.
692    pub focused_border: Color,
693    /// Border color when unfocused.
694    pub unfocused_border: Color,
695    /// Border color when disabled.
696    pub disabled_border: Color,
697    /// Text foreground color.
698    pub text_fg: Color,
699    /// Cursor color.
700    pub cursor_fg: Color,
701    /// Placeholder text color.
702    pub placeholder_fg: Color,
703    /// Line number foreground color.
704    pub line_number_fg: Color,
705    /// Current line background highlight (optional).
706    pub current_line_bg: Option<Color>,
707    /// Whether to show line numbers.
708    pub show_line_numbers: bool,
709}
710
711impl Default for TextAreaStyle {
712    fn default() -> Self {
713        Self {
714            focused_border: Color::Yellow,
715            unfocused_border: Color::Gray,
716            disabled_border: Color::DarkGray,
717            text_fg: Color::White,
718            cursor_fg: Color::Yellow,
719            placeholder_fg: Color::DarkGray,
720            line_number_fg: Color::DarkGray,
721            current_line_bg: None,
722            show_line_numbers: false,
723        }
724    }
725}
726
727impl TextAreaStyle {
728    /// Set the focused border color.
729    pub fn focused_border(mut self, color: Color) -> Self {
730        self.focused_border = color;
731        self
732    }
733
734    /// Set the unfocused border color.
735    pub fn unfocused_border(mut self, color: Color) -> Self {
736        self.unfocused_border = color;
737        self
738    }
739
740    /// Set the disabled border color.
741    pub fn disabled_border(mut self, color: Color) -> Self {
742        self.disabled_border = color;
743        self
744    }
745
746    /// Set the text color.
747    pub fn text_fg(mut self, color: Color) -> Self {
748        self.text_fg = color;
749        self
750    }
751
752    /// Set the cursor color.
753    pub fn cursor_fg(mut self, color: Color) -> Self {
754        self.cursor_fg = color;
755        self
756    }
757
758    /// Set the placeholder color.
759    pub fn placeholder_fg(mut self, color: Color) -> Self {
760        self.placeholder_fg = color;
761        self
762    }
763
764    /// Set the line number color.
765    pub fn line_number_fg(mut self, color: Color) -> Self {
766        self.line_number_fg = color;
767        self
768    }
769
770    /// Set the current line background highlight.
771    pub fn current_line_bg(mut self, color: Option<Color>) -> Self {
772        self.current_line_bg = color;
773        self
774    }
775
776    /// Enable or disable line numbers.
777    pub fn show_line_numbers(mut self, show: bool) -> Self {
778        self.show_line_numbers = show;
779        self
780    }
781}
782
783/// TextArea widget.
784///
785/// A multi-line text input field with cursor, label, and placeholder support.
786pub struct TextArea<'a> {
787    label: Option<&'a str>,
788    placeholder: Option<&'a str>,
789    style: TextAreaStyle,
790    focus_id: FocusId,
791    with_border: bool,
792    wrap_mode: WrapMode,
793}
794
795impl TextArea<'_> {
796    /// Create a new textarea widget.
797    pub fn new() -> Self {
798        Self {
799            label: None,
800            placeholder: None,
801            style: TextAreaStyle::default(),
802            focus_id: FocusId::default(),
803            with_border: true,
804            wrap_mode: WrapMode::default(),
805        }
806    }
807}
808
809impl Default for TextArea<'_> {
810    fn default() -> Self {
811        Self::new()
812    }
813}
814
815impl<'a> TextArea<'a> {
816
817    /// Set the label (displayed in the border title).
818    pub fn label(mut self, label: &'a str) -> Self {
819        self.label = Some(label);
820        self
821    }
822
823    /// Set the placeholder text (shown when empty).
824    pub fn placeholder(mut self, placeholder: &'a str) -> Self {
825        self.placeholder = Some(placeholder);
826        self
827    }
828
829    /// Set the textarea style.
830    pub fn style(mut self, style: TextAreaStyle) -> Self {
831        self.style = style;
832        self
833    }
834
835    /// Set the focus ID.
836    pub fn focus_id(mut self, id: FocusId) -> Self {
837        self.focus_id = id;
838        self
839    }
840
841    /// Enable or disable the border.
842    pub fn with_border(mut self, with_border: bool) -> Self {
843        self.with_border = with_border;
844        self
845    }
846
847    /// Set the wrap mode.
848    pub fn wrap_mode(mut self, mode: WrapMode) -> Self {
849        self.wrap_mode = mode;
850        self
851    }
852
853    /// Render the textarea and return the click region.
854    pub fn render_stateful(
855        self,
856        frame: &mut Frame,
857        area: Rect,
858        state: &mut TextAreaState,
859    ) -> ClickRegion<TextAreaAction> {
860        let border_color = if !state.enabled {
861            self.style.disabled_border
862        } else if state.focused {
863            self.style.focused_border
864        } else {
865            self.style.unfocused_border
866        };
867
868        let block = if self.with_border {
869            let mut block = Block::default()
870                .borders(Borders::ALL)
871                .border_style(Style::default().fg(border_color));
872            if let Some(label) = self.label {
873                block = block.title(format!(" {} ", label));
874            }
875            Some(block)
876        } else {
877            None
878        };
879
880        let inner_area = if let Some(ref b) = block {
881            b.inner(area)
882        } else {
883            area
884        };
885
886        // Update visible height in state
887        state.visible_height = inner_area.height as usize;
888
889        // Calculate line number gutter width
890        let line_num_width = if self.style.show_line_numbers {
891            let max_line = state.lines.len();
892            let digits = max_line.to_string().len();
893            digits + 2 // digits + space + separator
894        } else {
895            0
896        };
897
898        // Calculate content width
899        let content_width = (inner_area.width as usize).saturating_sub(line_num_width);
900
901        // Handle empty state
902        if state.is_empty() && !state.focused {
903            if let Some(placeholder) = self.placeholder {
904                let display_line = Line::from(Span::styled(
905                    placeholder,
906                    Style::default().fg(self.style.placeholder_fg),
907                ));
908                let paragraph = Paragraph::new(display_line);
909
910                if let Some(block) = block {
911                    frame.render_widget(block, area);
912                }
913                frame.render_widget(paragraph, inner_area);
914                return ClickRegion::new(area, TextAreaAction::Focus);
915            }
916        }
917
918        // Build visible lines
919        let start_line = state.scroll_y;
920        let end_line = (start_line + state.visible_height).min(state.lines.len());
921
922        let mut display_lines: Vec<Line> = Vec::new();
923
924        for line_idx in start_line..end_line {
925            let line = &state.lines[line_idx];
926            let is_cursor_line = line_idx == state.cursor_line;
927
928            // Apply horizontal scroll
929            let chars: Vec<char> = line.chars().collect();
930            let visible_chars: String = chars
931                .iter()
932                .skip(state.scroll_x)
933                .take(content_width)
934                .collect();
935
936            let mut spans = Vec::new();
937
938            // Line number
939            if self.style.show_line_numbers {
940                let line_num = format!(
941                    "{:>width$} ",
942                    line_idx + 1,
943                    width = line_num_width.saturating_sub(2)
944                );
945                spans.push(Span::styled(
946                    line_num,
947                    Style::default().fg(self.style.line_number_fg),
948                ));
949            }
950
951            // Determine line style
952            let line_style = if is_cursor_line {
953                if let Some(bg) = self.style.current_line_bg {
954                    Style::default().fg(self.style.text_fg).bg(bg)
955                } else {
956                    Style::default().fg(self.style.text_fg)
957                }
958            } else {
959                Style::default().fg(self.style.text_fg)
960            };
961
962            // Build content with cursor
963            if is_cursor_line && state.focused {
964                // Calculate visible cursor position
965                let cursor_visible_col =
966                    state.cursor_col.saturating_sub(state.scroll_x);
967
968                let visible_char_count = visible_chars.chars().count();
969
970                if cursor_visible_col <= visible_char_count {
971                    let before: String = visible_chars.chars().take(cursor_visible_col).collect();
972                    let cursor_char: String = visible_chars
973                        .chars()
974                        .skip(cursor_visible_col)
975                        .take(1)
976                        .collect();
977                    let after: String = visible_chars.chars().skip(cursor_visible_col + 1).collect();
978
979                    if !before.is_empty() {
980                        spans.push(Span::styled(before, line_style));
981                    }
982
983                    // Cursor with inverted colors (block cursor)
984                    let cursor_style = Style::default()
985                        .fg(self.style.cursor_fg)
986                        .bg(self.style.text_fg);
987                    let cursor_display = if cursor_char.is_empty() { " " } else { &cursor_char };
988                    spans.push(Span::styled(cursor_display.to_string(), cursor_style));
989
990                    if !after.is_empty() {
991                        spans.push(Span::styled(after, line_style));
992                    }
993                } else {
994                    // Cursor is scrolled off to the right
995                    spans.push(Span::styled(visible_chars, line_style));
996                }
997            } else {
998                spans.push(Span::styled(visible_chars, line_style));
999            }
1000
1001            display_lines.push(Line::from(spans));
1002        }
1003
1004        // Handle case when there are no lines to display (but cursor is active)
1005        if display_lines.is_empty() && state.focused {
1006            let mut spans = Vec::new();
1007            if self.style.show_line_numbers {
1008                let line_num = format!("{:>width$} ", 1, width = line_num_width.saturating_sub(2));
1009                spans.push(Span::styled(
1010                    line_num,
1011                    Style::default().fg(self.style.line_number_fg),
1012                ));
1013            }
1014            // Block cursor (inverted colors)
1015            let cursor_style = Style::default()
1016                .fg(self.style.cursor_fg)
1017                .bg(self.style.text_fg);
1018            spans.push(Span::styled(" ", cursor_style));
1019            display_lines.push(Line::from(spans));
1020        }
1021
1022        let paragraph = Paragraph::new(display_lines);
1023
1024        if let Some(block) = block {
1025            frame.render_widget(block, area);
1026        }
1027        frame.render_widget(paragraph, inner_area);
1028
1029        ClickRegion::new(area, TextAreaAction::Focus)
1030    }
1031}
1032
1033#[cfg(test)]
1034mod tests {
1035    use super::*;
1036
1037    // ========================================================================
1038    // State construction tests
1039    // ========================================================================
1040
1041    #[test]
1042    fn test_state_default() {
1043        let state = TextAreaState::default();
1044        assert_eq!(state.lines.len(), 1);
1045        assert!(state.lines[0].is_empty());
1046        assert_eq!(state.cursor_line, 0);
1047        assert_eq!(state.cursor_col, 0);
1048        assert!(!state.focused);
1049        assert!(state.enabled);
1050    }
1051
1052    #[test]
1053    fn test_state_new_single_line() {
1054        let state = TextAreaState::new("Hello");
1055        assert_eq!(state.lines.len(), 1);
1056        assert_eq!(state.lines[0], "Hello");
1057        assert_eq!(state.cursor_line, 0);
1058        assert_eq!(state.cursor_col, 0);
1059    }
1060
1061    #[test]
1062    fn test_state_new_multi_line() {
1063        let state = TextAreaState::new("Hello\nWorld");
1064        assert_eq!(state.lines.len(), 2);
1065        assert_eq!(state.lines[0], "Hello");
1066        assert_eq!(state.lines[1], "World");
1067        assert_eq!(state.cursor_line, 0);
1068        assert_eq!(state.cursor_col, 0);
1069    }
1070
1071    #[test]
1072    fn test_state_new_empty() {
1073        let state = TextAreaState::new("");
1074        assert_eq!(state.lines.len(), 1);
1075        assert!(state.lines[0].is_empty());
1076        assert_eq!(state.cursor_line, 0);
1077        assert_eq!(state.cursor_col, 0);
1078    }
1079
1080    #[test]
1081    fn test_state_empty() {
1082        let state = TextAreaState::empty();
1083        assert!(state.is_empty());
1084    }
1085
1086    // ========================================================================
1087    // Character operations tests
1088    // ========================================================================
1089
1090    #[test]
1091    fn test_insert_char() {
1092        let mut state = TextAreaState::new("Hello");
1093        state.move_to_end();
1094        state.insert_char('!');
1095        assert_eq!(state.lines[0], "Hello!");
1096        assert_eq!(state.cursor_col, 6);
1097    }
1098
1099    #[test]
1100    fn test_insert_char_middle() {
1101        let mut state = TextAreaState::new("Hllo");
1102        state.cursor_col = 1;
1103        state.insert_char('e');
1104        assert_eq!(state.lines[0], "Hello");
1105        assert_eq!(state.cursor_col, 2);
1106    }
1107
1108    #[test]
1109    fn test_insert_str_single_line() {
1110        let mut state = TextAreaState::new("Hello");
1111        state.move_to_end();
1112        state.insert_str(" World");
1113        assert_eq!(state.lines[0], "Hello World");
1114    }
1115
1116    #[test]
1117    fn test_insert_str_multi_line() {
1118        let mut state = TextAreaState::new("Hello");
1119        state.move_to_end();
1120        state.insert_str(" World\nNew Line");
1121        assert_eq!(state.lines.len(), 2);
1122        assert_eq!(state.lines[0], "Hello World");
1123        assert_eq!(state.lines[1], "New Line");
1124    }
1125
1126    #[test]
1127    fn test_insert_newline() {
1128        let mut state = TextAreaState::new("HelloWorld");
1129        state.cursor_col = 5;
1130        state.insert_newline();
1131        assert_eq!(state.lines.len(), 2);
1132        assert_eq!(state.lines[0], "Hello");
1133        assert_eq!(state.lines[1], "World");
1134        assert_eq!(state.cursor_line, 1);
1135        assert_eq!(state.cursor_col, 0);
1136    }
1137
1138    #[test]
1139    fn test_insert_newline_at_start() {
1140        let mut state = TextAreaState::new("Hello");
1141        state.insert_newline();
1142        assert_eq!(state.lines.len(), 2);
1143        assert_eq!(state.lines[0], "");
1144        assert_eq!(state.lines[1], "Hello");
1145    }
1146
1147    #[test]
1148    fn test_insert_newline_at_end() {
1149        let mut state = TextAreaState::new("Hello");
1150        state.move_to_end();
1151        state.insert_newline();
1152        assert_eq!(state.lines.len(), 2);
1153        assert_eq!(state.lines[0], "Hello");
1154        assert_eq!(state.lines[1], "");
1155    }
1156
1157    #[test]
1158    fn test_insert_tab_spaces() {
1159        let mut state = TextAreaState::empty();
1160        state.tab_config = TabConfig::Spaces(4);
1161        state.insert_tab();
1162        assert_eq!(state.lines[0], "    ");
1163    }
1164
1165    #[test]
1166    fn test_insert_tab_literal() {
1167        let mut state = TextAreaState::empty();
1168        state.tab_config = TabConfig::Literal;
1169        state.insert_tab();
1170        assert_eq!(state.lines[0], "\t");
1171    }
1172
1173    // ========================================================================
1174    // Deletion tests
1175    // ========================================================================
1176
1177    #[test]
1178    fn test_delete_char_backward() {
1179        let mut state = TextAreaState::new("Hello");
1180        state.move_to_end();
1181        assert!(state.delete_char_backward());
1182        assert_eq!(state.lines[0], "Hell");
1183        assert_eq!(state.cursor_col, 4);
1184    }
1185
1186    #[test]
1187    fn test_delete_char_backward_at_start() {
1188        let mut state = TextAreaState::new("Hello");
1189        // Cursor starts at 0, so no need to set it
1190        assert!(!state.delete_char_backward());
1191        assert_eq!(state.lines[0], "Hello");
1192    }
1193
1194    #[test]
1195    fn test_delete_char_backward_merges_lines() {
1196        let mut state = TextAreaState::new("Hello\nWorld");
1197        state.cursor_line = 1;
1198        state.cursor_col = 0;
1199        assert!(state.delete_char_backward());
1200        assert_eq!(state.lines.len(), 1);
1201        assert_eq!(state.lines[0], "HelloWorld");
1202        assert_eq!(state.cursor_col, 5);
1203    }
1204
1205    #[test]
1206    fn test_delete_char_forward() {
1207        let mut state = TextAreaState::new("Hello");
1208        state.cursor_col = 0;
1209        assert!(state.delete_char_forward());
1210        assert_eq!(state.lines[0], "ello");
1211    }
1212
1213    #[test]
1214    fn test_delete_char_forward_at_end() {
1215        let mut state = TextAreaState::new("Hello");
1216        state.move_to_end();
1217        assert!(!state.delete_char_forward());
1218        assert_eq!(state.lines[0], "Hello");
1219    }
1220
1221    #[test]
1222    fn test_delete_char_forward_merges_lines() {
1223        let mut state = TextAreaState::new("Hello\nWorld");
1224        state.cursor_line = 0;
1225        state.cursor_col = 5;
1226        assert!(state.delete_char_forward());
1227        assert_eq!(state.lines.len(), 1);
1228        assert_eq!(state.lines[0], "HelloWorld");
1229    }
1230
1231    #[test]
1232    fn test_delete_word_backward() {
1233        let mut state = TextAreaState::new("Hello World");
1234        state.move_to_end();
1235        assert!(state.delete_word_backward());
1236        assert_eq!(state.lines[0], "Hello ");
1237    }
1238
1239    #[test]
1240    fn test_delete_word_backward_from_start() {
1241        let mut state = TextAreaState::new("Hello");
1242        // Cursor starts at 0
1243        assert!(!state.delete_word_backward());
1244    }
1245
1246    #[test]
1247    fn test_delete_line() {
1248        let mut state = TextAreaState::new("Line 1\nLine 2\nLine 3");
1249        state.cursor_line = 1;
1250        state.cursor_col = 0;
1251        state.delete_line();
1252        assert_eq!(state.lines.len(), 2);
1253        assert_eq!(state.lines[0], "Line 1");
1254        assert_eq!(state.lines[1], "Line 3");
1255    }
1256
1257    #[test]
1258    fn test_delete_line_single() {
1259        let mut state = TextAreaState::new("Hello");
1260        state.delete_line();
1261        assert_eq!(state.lines.len(), 1);
1262        assert!(state.lines[0].is_empty());
1263    }
1264
1265    #[test]
1266    fn test_delete_to_line_start() {
1267        let mut state = TextAreaState::new("Hello World");
1268        state.cursor_col = 6;
1269        state.delete_to_line_start();
1270        assert_eq!(state.lines[0], "World");
1271        assert_eq!(state.cursor_col, 0);
1272    }
1273
1274    #[test]
1275    fn test_delete_to_line_end() {
1276        let mut state = TextAreaState::new("Hello World");
1277        state.cursor_col = 5;
1278        state.delete_to_line_end();
1279        assert_eq!(state.lines[0], "Hello");
1280    }
1281
1282    // ========================================================================
1283    // Cursor movement tests
1284    // ========================================================================
1285
1286    #[test]
1287    fn test_move_left() {
1288        let mut state = TextAreaState::new("Hello");
1289        state.move_to_end();
1290        state.move_left();
1291        assert_eq!(state.cursor_col, 4);
1292    }
1293
1294    #[test]
1295    fn test_move_left_wraps_line() {
1296        let mut state = TextAreaState::new("Hello\nWorld");
1297        state.cursor_line = 1;
1298        state.cursor_col = 0;
1299        state.move_left();
1300        assert_eq!(state.cursor_line, 0);
1301        assert_eq!(state.cursor_col, 5);
1302    }
1303
1304    #[test]
1305    fn test_move_left_at_start() {
1306        let mut state = TextAreaState::new("Hello");
1307        state.cursor_col = 0;
1308        state.move_left();
1309        assert_eq!(state.cursor_col, 0);
1310    }
1311
1312    #[test]
1313    fn test_move_right() {
1314        let mut state = TextAreaState::new("Hello");
1315        state.cursor_col = 0;
1316        state.move_right();
1317        assert_eq!(state.cursor_col, 1);
1318    }
1319
1320    #[test]
1321    fn test_move_right_wraps_line() {
1322        let mut state = TextAreaState::new("Hello\nWorld");
1323        state.cursor_line = 0;
1324        state.cursor_col = 5;
1325        state.move_right();
1326        assert_eq!(state.cursor_line, 1);
1327        assert_eq!(state.cursor_col, 0);
1328    }
1329
1330    #[test]
1331    fn test_move_right_at_end() {
1332        let mut state = TextAreaState::new("Hello");
1333        state.move_to_end();
1334        state.move_right();
1335        assert_eq!(state.cursor_col, 5); // Should stay at end
1336    }
1337
1338    #[test]
1339    fn test_move_line_start() {
1340        let mut state = TextAreaState::new("Hello");
1341        state.move_line_start();
1342        assert_eq!(state.cursor_col, 0);
1343    }
1344
1345    #[test]
1346    fn test_move_line_end() {
1347        let mut state = TextAreaState::new("Hello");
1348        state.cursor_col = 0;
1349        state.move_line_end();
1350        assert_eq!(state.cursor_col, 5);
1351    }
1352
1353    #[test]
1354    fn test_move_up() {
1355        let mut state = TextAreaState::new("Line 1\nLine 2\nLine 3");
1356        state.cursor_line = 2; // Start at last line
1357        state.move_up();
1358        assert_eq!(state.cursor_line, 1);
1359    }
1360
1361    #[test]
1362    fn test_move_up_clamps_column() {
1363        let mut state = TextAreaState::new("AB\nCDEFG");
1364        state.cursor_line = 1; // Start on second line (CDEFG)
1365        state.cursor_col = 5;
1366        state.move_up();
1367        assert_eq!(state.cursor_line, 0);
1368        assert_eq!(state.cursor_col, 2); // Clamped to line length
1369    }
1370
1371    #[test]
1372    fn test_move_down() {
1373        let mut state = TextAreaState::new("Line 1\nLine 2\nLine 3");
1374        state.cursor_line = 0;
1375        state.move_down();
1376        assert_eq!(state.cursor_line, 1);
1377    }
1378
1379    #[test]
1380    fn test_move_down_at_last_line() {
1381        let mut state = TextAreaState::new("Hello");
1382        state.move_down();
1383        assert_eq!(state.cursor_line, 0);
1384    }
1385
1386    #[test]
1387    fn test_move_word_left() {
1388        let mut state = TextAreaState::new("Hello World Test");
1389        state.move_to_end(); // Start at end of text
1390        state.move_word_left();
1391        assert_eq!(state.cursor_col, 12); // Start of "Test"
1392    }
1393
1394    #[test]
1395    fn test_move_word_right() {
1396        let mut state = TextAreaState::new("Hello World Test");
1397        state.cursor_col = 0;
1398        state.move_word_right();
1399        assert_eq!(state.cursor_col, 6); // After "Hello "
1400    }
1401
1402    #[test]
1403    fn test_move_page_up() {
1404        let mut state = TextAreaState::new("1\n2\n3\n4\n5\n6\n7\n8\n9\n10");
1405        state.visible_height = 3;
1406        state.cursor_line = 9; // Start at last line
1407        state.move_page_up();
1408        assert_eq!(state.cursor_line, 6);
1409    }
1410
1411    #[test]
1412    fn test_move_page_down() {
1413        let mut state = TextAreaState::new("1\n2\n3\n4\n5\n6\n7\n8\n9\n10");
1414        state.cursor_line = 0;
1415        state.visible_height = 3;
1416        state.move_page_down();
1417        assert_eq!(state.cursor_line, 3);
1418    }
1419
1420    #[test]
1421    fn test_move_to_start() {
1422        let mut state = TextAreaState::new("Hello\nWorld");
1423        state.move_to_start();
1424        assert_eq!(state.cursor_line, 0);
1425        assert_eq!(state.cursor_col, 0);
1426    }
1427
1428    #[test]
1429    fn test_move_to_end() {
1430        let mut state = TextAreaState::new("Hello\nWorld");
1431        state.cursor_line = 0;
1432        state.cursor_col = 0;
1433        state.move_to_end();
1434        assert_eq!(state.cursor_line, 1);
1435        assert_eq!(state.cursor_col, 5);
1436    }
1437
1438    // ========================================================================
1439    // Content helpers tests
1440    // ========================================================================
1441
1442    #[test]
1443    fn test_text() {
1444        let state = TextAreaState::new("Hello\nWorld");
1445        assert_eq!(state.text(), "Hello\nWorld");
1446    }
1447
1448    #[test]
1449    fn test_set_text() {
1450        let mut state = TextAreaState::new("Old");
1451        state.set_text("New\nContent");
1452        assert_eq!(state.lines.len(), 2);
1453        assert_eq!(state.lines[0], "New");
1454        assert_eq!(state.lines[1], "Content");
1455        assert_eq!(state.cursor_line, 1);
1456        assert_eq!(state.cursor_col, 7);
1457    }
1458
1459    #[test]
1460    fn test_clear() {
1461        let mut state = TextAreaState::new("Hello\nWorld");
1462        state.clear();
1463        assert!(state.is_empty());
1464        assert_eq!(state.cursor_line, 0);
1465        assert_eq!(state.cursor_col, 0);
1466    }
1467
1468    #[test]
1469    fn test_line_count() {
1470        let state = TextAreaState::new("A\nB\nC");
1471        assert_eq!(state.line_count(), 3);
1472    }
1473
1474    #[test]
1475    fn test_current_line() {
1476        let mut state = TextAreaState::new("Hello\nWorld");
1477        state.cursor_line = 0;
1478        assert_eq!(state.current_line(), "Hello");
1479    }
1480
1481    #[test]
1482    fn test_is_empty() {
1483        let state = TextAreaState::empty();
1484        assert!(state.is_empty());
1485
1486        let state = TextAreaState::new("Hi");
1487        assert!(!state.is_empty());
1488    }
1489
1490    #[test]
1491    fn test_len() {
1492        let state = TextAreaState::new("Hi\nWorld");
1493        // "Hi" (2) + "\n" (1) + "World" (5) = 8
1494        assert_eq!(state.len(), 8);
1495    }
1496
1497    #[test]
1498    fn test_text_before_after_cursor() {
1499        let mut state = TextAreaState::new("Hello World");
1500        state.cursor_col = 5;
1501        assert_eq!(state.text_before_cursor(), "Hello");
1502        assert_eq!(state.text_after_cursor(), " World");
1503    }
1504
1505    // ========================================================================
1506    // Unicode handling tests
1507    // ========================================================================
1508
1509    #[test]
1510    fn test_unicode_handling() {
1511        let mut state = TextAreaState::new("你好");
1512        state.move_to_end();
1513        assert_eq!(state.cursor_col, 2);
1514
1515        state.move_left();
1516        assert_eq!(state.cursor_col, 1);
1517
1518        state.insert_char('世');
1519        assert_eq!(state.lines[0], "你世好");
1520    }
1521
1522    #[test]
1523    fn test_emoji_handling() {
1524        let mut state = TextAreaState::new("Hi 👋");
1525        assert_eq!(state.len(), 4);
1526
1527        state.move_to_end();
1528        state.delete_char_backward();
1529        assert_eq!(state.lines[0], "Hi ");
1530    }
1531
1532    // ========================================================================
1533    // Disabled state tests
1534    // ========================================================================
1535
1536    #[test]
1537    fn test_disabled_no_insert() {
1538        let mut state = TextAreaState::new("Hello");
1539        state.enabled = false;
1540        state.insert_char('!');
1541        assert_eq!(state.lines[0], "Hello");
1542    }
1543
1544    #[test]
1545    fn test_disabled_no_delete() {
1546        let mut state = TextAreaState::new("Hello");
1547        state.enabled = false;
1548        assert!(!state.delete_char_backward());
1549        assert_eq!(state.lines[0], "Hello");
1550    }
1551
1552    #[test]
1553    fn test_disabled_no_newline() {
1554        let mut state = TextAreaState::new("Hello");
1555        state.enabled = false;
1556        state.insert_newline();
1557        assert_eq!(state.lines.len(), 1);
1558    }
1559
1560    // ========================================================================
1561    // Scroll tests
1562    // ========================================================================
1563
1564    #[test]
1565    fn test_scroll_to_cursor_down() {
1566        let mut state = TextAreaState::new("1\n2\n3\n4\n5\n6\n7\n8\n9\n10");
1567        state.visible_height = 3;
1568        state.cursor_line = 8;
1569        state.scroll_to_cursor();
1570        assert_eq!(state.scroll_y, 6);
1571    }
1572
1573    #[test]
1574    fn test_scroll_to_cursor_up() {
1575        let mut state = TextAreaState::new("1\n2\n3\n4\n5\n6\n7\n8\n9\n10");
1576        state.visible_height = 3;
1577        state.scroll_y = 5;
1578        state.cursor_line = 2;
1579        state.scroll_to_cursor();
1580        assert_eq!(state.scroll_y, 2);
1581    }
1582
1583    #[test]
1584    fn test_scroll_up() {
1585        let mut state = TextAreaState::new("1\n2\n3");
1586        state.scroll_y = 2;
1587        state.scroll_up();
1588        assert_eq!(state.scroll_y, 1);
1589    }
1590
1591    #[test]
1592    fn test_scroll_down() {
1593        let mut state = TextAreaState::new("1\n2\n3\n4\n5");
1594        state.visible_height = 2;
1595        state.scroll_down();
1596        assert_eq!(state.scroll_y, 1);
1597    }
1598
1599    // ========================================================================
1600    // Style tests
1601    // ========================================================================
1602
1603    #[test]
1604    fn test_style_default() {
1605        let style = TextAreaStyle::default();
1606        assert_eq!(style.focused_border, Color::Yellow);
1607        assert_eq!(style.text_fg, Color::White);
1608        assert!(!style.show_line_numbers);
1609    }
1610
1611    #[test]
1612    fn test_style_builder() {
1613        let style = TextAreaStyle::default()
1614            .focused_border(Color::Cyan)
1615            .text_fg(Color::Green)
1616            .show_line_numbers(true);
1617
1618        assert_eq!(style.focused_border, Color::Cyan);
1619        assert_eq!(style.text_fg, Color::Green);
1620        assert!(style.show_line_numbers);
1621    }
1622
1623    // ========================================================================
1624    // Tab config tests
1625    // ========================================================================
1626
1627    #[test]
1628    fn test_tab_config_default() {
1629        let config = TabConfig::default();
1630        assert_eq!(config, TabConfig::Spaces(4));
1631    }
1632
1633    #[test]
1634    fn test_with_tab_config() {
1635        let state = TextAreaState::empty().with_tab_config(TabConfig::Spaces(2));
1636        assert_eq!(state.tab_config, TabConfig::Spaces(2));
1637    }
1638}