bubbletea_widgets/textarea/
mod.rs

1//! Textarea component for Bubble Tea applications.
2//!
3//! This module provides a multi-line text input component with feature parity
4//! to the Go `bubbles/textarea` package. It supports soft-wrapping, line
5//! numbers, customizable prompts, clipboard integration, and rich theming via
6//! Lip Gloss styles.
7//!
8//! The component implements the crate's `Component` trait so it can be focused
9//! and blurred, and it exposes a `Model` with idiomatic methods for editing and
10//! navigation (insert, delete, move by character/word/line, etc.).
11//!
12//! ### Features
13//! - Soft-wrapped lines with correct column/character accounting for double-width runes
14//! - Optional line numbers and per-line prompts (static or via a prompt function)
15//! - Cursor movement by character, word, and line with deletion/edit helpers
16//! - Viewport-driven rendering for large inputs
17//! - Clipboard paste integration (platform dependent)
18//! - Theming via `TextareaStyle` for focused and blurred states
19//!
20//! ### Example
21//! ```rust
22//! use bubbletea_widgets::{textarea, Component};
23//!
24//! // Create a textarea with defaults
25//! let mut ta = textarea::new();
26//! ta.set_width(40);
27//! ta.set_height(6);
28//! ta.placeholder = "Type here…".into();
29//!
30//! // Focus to start receiving input
31//! let _ = ta.focus();
32//!
33//! // Programmatic edits
34//! ta.insert_string("Hello\nworld!");
35//! ta.word_left();
36//! ta.uppercase_right();
37//!
38//! // Render view (string with ANSI styling)
39//! let view = ta.view();
40//! println!("{}", view);
41//! ```
42//!
43//! See the `helpers` module for key bindings and styling utilities, and
44//! `memoization` for the internal soft-wrap cache.
45
46pub mod helpers;
47pub mod memoization;
48
49#[cfg(test)]
50mod tests;
51
52use helpers::*;
53use memoization::MemoizedWrap;
54
55use crate::{cursor, viewport, Component};
56use bubbletea_rs::{Cmd, Model as BubbleTeaModel};
57use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
58
59// Constants matching Go implementation
60const MIN_HEIGHT: usize = 1;
61const DEFAULT_HEIGHT: usize = 6;
62const DEFAULT_WIDTH: usize = 40;
63const DEFAULT_CHAR_LIMIT: usize = 0; // no limit
64const DEFAULT_MAX_HEIGHT: usize = 99;
65const DEFAULT_MAX_WIDTH: usize = 500;
66const MAX_LINES: usize = 10000;
67
68/// Internal messages for clipboard operations
69#[derive(Debug, Clone)]
70pub struct PasteMsg(pub String);
71
72/// Error message produced when a paste operation fails.
73#[derive(Debug, Clone)]
74pub struct PasteErrMsg(pub String);
75
76/// LineInfo helper for tracking line information regarding soft-wrapped lines
77/// Direct port from Go's LineInfo struct
78#[derive(Debug, Clone, Default)]
79pub struct LineInfo {
80    /// Width is the number of columns in the line
81    pub width: usize,
82    /// CharWidth is the number of characters in the line to account for double-width runes
83    pub char_width: usize,
84    /// Height is the number of rows in the line  
85    pub height: usize,
86    /// StartColumn is the index of the first column of the line
87    pub start_column: usize,
88    /// ColumnOffset is the number of columns that the cursor is offset from the start of the line
89    pub column_offset: usize,
90    /// RowOffset is the number of rows that the cursor is offset from the start of the line
91    pub row_offset: usize,
92    /// CharOffset is the number of characters that the cursor is offset from the start of the line
93    pub char_offset: usize,
94}
95
96/// Model is the Bubble Tea model for this text area element.
97/// Direct port from Go's Model struct with all fields preserved
98#[derive(Debug)]
99pub struct Model {
100    // Error state
101    /// Optional error string surfaced by the component.
102    pub err: Option<String>,
103
104    // General settings - memoization cache
105    cache: MemoizedWrap,
106
107    // Display settings
108    /// Prompt is printed at the beginning of each line
109    pub prompt: String,
110    /// Placeholder is the text displayed when the user hasn't entered anything yet
111    pub placeholder: String,
112    /// ShowLineNumbers, if enabled, causes line numbers to be printed after the prompt
113    pub show_line_numbers: bool,
114    /// EndOfBufferCharacter is displayed at the end of the input
115    pub end_of_buffer_character: char,
116
117    // KeyMap encodes the keybindings recognized by the widget
118    /// Key bindings recognized by the widget.
119    pub key_map: TextareaKeyMap,
120
121    // Styling. FocusedStyle and BlurredStyle are used to style the textarea in focused and blurred states
122    /// Style used when the textarea is focused.
123    pub focused_style: TextareaStyle,
124    /// Style used when the textarea is blurred.
125    pub blurred_style: TextareaStyle,
126    // style is the current styling to use - pointer equivalent in Rust
127    current_style: TextareaStyle,
128
129    // Cursor is the text area cursor
130    /// Embedded cursor model for caret rendering and blinking.
131    pub cursor: cursor::Model,
132
133    // Limits
134    /// CharLimit is the maximum number of characters this input element will accept
135    pub char_limit: usize,
136    /// MaxHeight is the maximum height of the text area in rows
137    pub max_height: usize,
138    /// MaxWidth is the maximum width of the text area in columns
139    pub max_width: usize,
140
141    // Dynamic prompt function - Option to handle nullable function pointer
142    prompt_func: Option<fn(usize) -> String>,
143    /// promptWidth is the width of the prompt
144    prompt_width: usize,
145
146    // Dimensions
147    /// width is the maximum number of characters that can be displayed at once
148    width: usize,
149    /// height is the maximum number of lines that can be displayed at once
150    height: usize,
151
152    // Content - using Vec<Vec<char>> to match Go's [][]rune
153    /// Underlying text value as runes (characters)
154    value: Vec<Vec<char>>,
155
156    // State
157    /// focus indicates whether user input focus should be on this input component
158    focus: bool,
159    /// Cursor column
160    col: usize,
161    /// Cursor row  
162    row: usize,
163    /// Last character offset, used to maintain state when cursor is moved vertically
164    last_char_offset: usize,
165
166    // Viewport is the vertically-scrollable viewport of the multi-line text input
167    viewport: viewport::Model,
168}
169
170impl Model {
171    /// Create a new textarea model with default settings - port of Go's New()
172    pub fn new() -> Self {
173        let vp = viewport::Model::new(0, 0);
174        // Disable viewport key handling to let textarea handle keys (no keymap field in viewport)
175
176        let cur = cursor::Model::new();
177
178        let (focused_style, blurred_style) = default_styles();
179
180        let mut model = Self {
181            err: None,
182            cache: MemoizedWrap::new(),
183            prompt: format!("{} ", lipgloss::thick_border().left),
184            placeholder: String::new(),
185            show_line_numbers: true,
186            end_of_buffer_character: ' ',
187            key_map: TextareaKeyMap::default(),
188            focused_style: focused_style.clone(),
189            blurred_style: blurred_style.clone(),
190            current_style: blurred_style, // Start blurred
191            cursor: cur,
192            char_limit: DEFAULT_CHAR_LIMIT,
193            max_height: DEFAULT_MAX_HEIGHT,
194            max_width: DEFAULT_MAX_WIDTH,
195            prompt_func: None,
196            prompt_width: 0,
197            width: DEFAULT_WIDTH,
198            height: DEFAULT_HEIGHT,
199            value: vec![vec![]; MIN_HEIGHT],
200            focus: false,
201            col: 0,
202            row: 0,
203            last_char_offset: 0,
204            viewport: vp,
205        };
206
207        // Ensure value has minimum height and maxLines capacity
208        model.value.reserve(MAX_LINES);
209        model.set_height(DEFAULT_HEIGHT);
210        model.set_width(DEFAULT_WIDTH);
211
212        model
213    }
214
215    /// Set the value of the text input - port of Go's SetValue
216    pub fn set_value(&mut self, s: impl Into<String>) {
217        self.reset();
218        self.insert_string(s.into());
219        // After setting full value, position cursor at end of input (last line)
220        self.row = self.value.len().saturating_sub(1);
221        if let Some(line) = self.value.get(self.row) {
222            self.set_cursor(line.len());
223        }
224    }
225
226    /// Insert a string at the cursor position - port of Go's InsertString
227    pub fn insert_string(&mut self, s: impl Into<String>) {
228        let s = s.into();
229        let runes: Vec<char> = s.chars().collect();
230        self.insert_runes_from_user_input(runes);
231    }
232
233    /// Insert a rune at the cursor position - port of Go's InsertRune
234    pub fn insert_rune(&mut self, r: char) {
235        self.insert_runes_from_user_input(vec![r]);
236    }
237
238    /// Get the current value as a string - port of Go's Value()
239    pub fn value(&self) -> String {
240        if self.value.is_empty() {
241            return String::new();
242        }
243
244        let mut result = String::new();
245        for (i, line) in self.value.iter().enumerate() {
246            if i > 0 {
247                result.push('\n');
248            }
249            result.extend(line.iter());
250        }
251        result
252    }
253
254    /// Length returns the number of characters currently in the text input - port of Go's Length()
255    pub fn length(&self) -> usize {
256        let mut l = 0;
257        for row in &self.value {
258            l += row
259                .iter()
260                .map(|&ch| UnicodeWidthChar::width(ch).unwrap_or(0))
261                .sum::<usize>();
262        }
263        // Add newline characters count
264        l + self.value.len().saturating_sub(1)
265    }
266
267    /// LineCount returns the number of lines currently in the text input - port of Go's LineCount()
268    pub fn line_count(&self) -> usize {
269        self.value.len()
270    }
271
272    /// Line returns the line position - port of Go's Line()
273    pub fn line(&self) -> usize {
274        self.row
275    }
276
277    /// Focused returns the focus state on the model - port of Go's Focused()
278    pub fn focused(&self) -> bool {
279        self.focus
280    }
281
282    /// Reset sets the input to its default state with no input - port of Go's Reset()
283    pub fn reset(&mut self) {
284        self.value = vec![vec![]; MIN_HEIGHT];
285        self.value.reserve(MAX_LINES);
286        self.col = 0;
287        self.row = 0;
288        self.viewport.goto_top();
289        self.set_cursor(0);
290    }
291
292    /// Width returns the width of the textarea - port of Go's Width()
293    pub fn width(&self) -> usize {
294        self.width
295    }
296
297    /// Height returns the current height of the textarea - port of Go's Height()
298    pub fn height(&self) -> usize {
299        self.height
300    }
301
302    /// SetWidth sets the width of the textarea - port of Go's SetWidth()
303    pub fn set_width(&mut self, w: usize) {
304        // Update prompt width only if there is no prompt function
305        if self.prompt_func.is_none() {
306            self.prompt_width = self.prompt.width();
307        }
308
309        // Add base style borders and padding to reserved outer width
310        let reserved_outer = 0; // Simplified for now, lipgloss API differs
311
312        // Add prompt width to reserved inner width
313        let mut reserved_inner = self.prompt_width;
314
315        // Add line number width to reserved inner width
316        if self.show_line_numbers {
317            let ln_width = 4; // Up to 3 digits for line number plus 1 margin
318            reserved_inner += ln_width;
319        }
320
321        // Input width must be at least one more than the reserved inner and outer width
322        let min_width = reserved_inner + reserved_outer + 1;
323        let mut input_width = w.max(min_width);
324
325        // Input width must be no more than maximum width
326        if self.max_width > 0 {
327            input_width = input_width.min(self.max_width);
328        }
329
330        self.viewport.width = input_width.saturating_sub(reserved_outer);
331        self.width = input_width
332            .saturating_sub(reserved_outer)
333            .saturating_sub(reserved_inner);
334    }
335
336    /// SetHeight sets the height of the textarea - port of Go's SetHeight()
337    pub fn set_height(&mut self, h: usize) {
338        if self.max_height > 0 {
339            self.height = clamp(h, MIN_HEIGHT, self.max_height);
340            self.viewport.height = clamp(h, MIN_HEIGHT, self.max_height);
341        } else {
342            self.height = h.max(MIN_HEIGHT);
343            self.viewport.height = h.max(MIN_HEIGHT);
344        }
345    }
346
347    /// SetPromptFunc supersedes the Prompt field and sets a dynamic prompt instead
348    /// Port of Go's SetPromptFunc
349    pub fn set_prompt_func(&mut self, prompt_width: usize, func: fn(usize) -> String) {
350        self.prompt_func = Some(func);
351        self.prompt_width = prompt_width;
352    }
353
354    /// SetCursor moves the cursor to the given position - port of Go's SetCursor()
355    pub fn set_cursor(&mut self, col: usize) {
356        self.col = clamp(
357            col,
358            0,
359            self.value.get(self.row).map_or(0, |line| line.len()),
360        );
361        // Reset last char offset when moving cursor horizontally
362        self.last_char_offset = 0;
363    }
364
365    /// CursorStart moves the cursor to the start of the input field - port of Go's CursorStart()
366    pub fn cursor_start(&mut self) {
367        self.set_cursor(0);
368    }
369
370    /// CursorEnd moves the cursor to the end of the input field - port of Go's CursorEnd()
371    pub fn cursor_end(&mut self) {
372        if let Some(line) = self.value.get(self.row) {
373            self.set_cursor(line.len());
374        }
375    }
376
377    /// CursorDown moves the cursor down by one line - port of Go's CursorDown()
378    pub fn cursor_down(&mut self) {
379        let li = self.line_info();
380        let char_offset = self.last_char_offset.max(li.char_offset);
381        self.last_char_offset = char_offset;
382
383        if li.row_offset + 1 >= li.height && self.row < self.value.len().saturating_sub(1) {
384            self.row += 1;
385            self.col = 0;
386        } else {
387            // Move the cursor to the start of the next line
388            const TRAILING_SPACE: usize = 2;
389            if let Some(line) = self.value.get(self.row) {
390                self.col =
391                    (li.start_column + li.width + TRAILING_SPACE).min(line.len().saturating_sub(1));
392            }
393        }
394
395        let nli = self.line_info();
396        self.col = nli.start_column;
397
398        if nli.width == 0 {
399            return;
400        }
401
402        let mut offset = 0;
403        while offset < char_offset {
404            if self.row >= self.value.len()
405                || self.col >= self.value.get(self.row).map_or(0, |line| line.len())
406                || offset >= nli.char_width.saturating_sub(1)
407            {
408                break;
409            }
410            if let Some(line) = self.value.get(self.row) {
411                if let Some(&ch) = line.get(self.col) {
412                    offset += UnicodeWidthChar::width(ch).unwrap_or(0);
413                }
414            }
415            self.col += 1;
416        }
417    }
418
419    /// CursorUp moves the cursor up by one line - port of Go's CursorUp()
420    pub fn cursor_up(&mut self) {
421        let li = self.line_info();
422        let char_offset = self.last_char_offset.max(li.char_offset);
423        self.last_char_offset = char_offset;
424
425        if li.row_offset == 0 && self.row > 0 {
426            self.row -= 1;
427            if let Some(line) = self.value.get(self.row) {
428                self.col = line.len();
429            }
430        } else {
431            // Move the cursor to the end of the previous line
432            const TRAILING_SPACE: usize = 2;
433            self.col = li.start_column.saturating_sub(TRAILING_SPACE);
434        }
435
436        let nli = self.line_info();
437        self.col = nli.start_column;
438
439        if nli.width == 0 {
440            return;
441        }
442
443        let mut offset = 0;
444        while offset < char_offset {
445            if let Some(line) = self.value.get(self.row) {
446                if self.col >= line.len() || offset >= nli.char_width.saturating_sub(1) {
447                    break;
448                }
449                if let Some(&ch) = line.get(self.col) {
450                    offset += UnicodeWidthChar::width(ch).unwrap_or(0);
451                }
452                self.col += 1;
453            } else {
454                break;
455            }
456        }
457    }
458
459    /// Move to the beginning of input - port of Go's moveToBegin()
460    pub fn move_to_begin(&mut self) {
461        self.row = 0;
462        self.set_cursor(0);
463    }
464
465    /// Move to the end of input - port of Go's moveToEnd()
466    pub fn move_to_end(&mut self) {
467        self.row = self.value.len().saturating_sub(1);
468        if let Some(line) = self.value.get(self.row) {
469            self.set_cursor(line.len());
470        }
471    }
472
473    // Internal helper functions matching Go implementation structure
474
475    /// Port of Go's insertRunesFromUserInput
476    fn insert_runes_from_user_input(&mut self, mut runes: Vec<char>) {
477        // Clean up any special characters in the input
478        runes = self.sanitize_runes(runes);
479
480        if self.char_limit > 0 {
481            let avail_space = self.char_limit.saturating_sub(self.length());
482            if avail_space == 0 {
483                return;
484            }
485            if avail_space < runes.len() {
486                runes.truncate(avail_space);
487            }
488        }
489
490        // Split the input into lines
491        let mut lines = Vec::new();
492        let mut lstart = 0;
493
494        for (i, &r) in runes.iter().enumerate() {
495            if r == '\n' {
496                lines.push(runes[lstart..i].to_vec());
497                lstart = i + 1;
498            }
499        }
500
501        if lstart <= runes.len() {
502            lines.push(runes[lstart..].to_vec());
503        }
504
505        // Obey the maximum line limit
506        if MAX_LINES > 0 && self.value.len() + lines.len() - 1 > MAX_LINES {
507            let allowed_height = (MAX_LINES - self.value.len() + 1).max(0);
508            lines.truncate(allowed_height);
509        }
510
511        if lines.is_empty() {
512            return;
513        }
514
515        // Ensure current row exists
516        while self.row >= self.value.len() {
517            self.value.push(Vec::new());
518        }
519
520        // Save the remainder of the original line at the current cursor position
521        let tail = if self.col < self.value[self.row].len() {
522            self.value[self.row][self.col..].to_vec()
523        } else {
524            Vec::new()
525        };
526
527        // Paste the first line at the current cursor position
528        if self.col <= self.value[self.row].len() {
529            self.value[self.row].truncate(self.col);
530        }
531        self.value[self.row].extend_from_slice(&lines[0]);
532        self.col += lines[0].len();
533
534        if lines.len() > 1 {
535            // Add the new lines maintaining cursor on the first line's end
536            for (i, line) in lines[1..].iter().enumerate() {
537                self.value.insert(self.row + 1 + i, line.clone());
538            }
539            // Move cursor to end of the last inserted line (Go behavior on SetValue)
540            self.row += lines.len() - 1;
541            self.col = lines.last().map(|l| l.len()).unwrap_or(0);
542            // Append tail to current line
543            self.value[self.row].extend_from_slice(&tail);
544        } else {
545            // No newlines: append tail back to current line
546            self.value[self.row].extend_from_slice(&tail);
547        }
548
549        self.set_cursor(self.col);
550    }
551
552    /// Sanitize runes for input - simple version
553    fn sanitize_runes(&self, runes: Vec<char>) -> Vec<char> {
554        // For now, just return as-is. In Go this handles special characters
555        runes
556    }
557
558    /// LineInfo returns line information for the current cursor position
559    /// Port of Go's LineInfo()
560    pub fn line_info(&mut self) -> LineInfo {
561        if self.row >= self.value.len() {
562            return LineInfo::default();
563        }
564
565        // Clone the line to avoid borrowing issues
566        let current_line = self.value[self.row].clone();
567        let width = self.width;
568        let grid = self.cache.wrap(&current_line, width);
569
570        // Find out which visual wrap line we are currently on
571        let mut counter = 0;
572        for (i, line) in grid.iter().enumerate() {
573            // We've found the line that we are on
574            if counter + line.len() == self.col && i + 1 < grid.len() {
575                // Wrap around to the next line
576                return LineInfo {
577                    char_offset: 0,
578                    column_offset: 0,
579                    height: grid.len(),
580                    row_offset: i + 1,
581                    start_column: self.col,
582                    width: grid.get(i + 1).map_or(0, |l| l.len()),
583                    char_width: line
584                        .iter()
585                        .map(|&ch| UnicodeWidthChar::width(ch).unwrap_or(0))
586                        .sum(),
587                };
588            }
589
590            if counter + line.len() >= self.col {
591                let col_in_line = self.col.saturating_sub(counter);
592                let char_off: usize = line[..col_in_line.min(line.len())]
593                    .iter()
594                    .map(|&ch| UnicodeWidthChar::width(ch).unwrap_or(0))
595                    .sum();
596                return LineInfo {
597                    char_offset: char_off,
598                    column_offset: col_in_line, // column within current wrap line
599                    height: grid.len(),
600                    row_offset: i,
601                    start_column: counter,
602                    width: line.len(),
603                    char_width: line
604                        .iter()
605                        .map(|&ch| UnicodeWidthChar::width(ch).unwrap_or(0))
606                        .sum(),
607                };
608            }
609
610            counter += line.len();
611        }
612
613        LineInfo::default()
614    }
615
616    /// Delete before cursor - port of Go's deleteBeforeCursor()
617    pub fn delete_before_cursor(&mut self) {
618        if let Some(line) = self.value.get_mut(self.row) {
619            let tail = if self.col <= line.len() {
620                line[self.col..].to_vec()
621            } else {
622                Vec::new()
623            };
624            *line = tail;
625        }
626        self.set_cursor(0);
627    }
628
629    /// Delete after cursor - port of Go's deleteAfterCursor()
630    pub fn delete_after_cursor(&mut self) {
631        if let Some(line) = self.value.get_mut(self.row) {
632            line.truncate(self.col);
633            let line_len = line.len();
634            self.set_cursor(line_len);
635        }
636    }
637
638    /// Delete character backward - port of Go's deleteCharacterBackward()
639    pub fn delete_character_backward(&mut self) {
640        self.col = clamp(
641            self.col,
642            0,
643            self.value.get(self.row).map_or(0, |line| line.len()),
644        );
645        if self.col == 0 {
646            self.merge_line_above(self.row);
647            return;
648        }
649
650        if let Some(line) = self.value.get_mut(self.row) {
651            if !line.is_empty() && self.col > 0 {
652                line.remove(self.col - 1);
653                self.set_cursor(self.col - 1);
654            }
655        }
656    }
657
658    /// Delete character forward - port of Go's deleteCharacterForward()
659    pub fn delete_character_forward(&mut self) {
660        if let Some(line) = self.value.get_mut(self.row) {
661            if !line.is_empty() && self.col < line.len() {
662                line.remove(self.col);
663            }
664        }
665
666        if self.col >= self.value.get(self.row).map_or(0, |line| line.len()) {
667            self.merge_line_below(self.row);
668        }
669    }
670
671    /// Delete word backward - port of Go's deleteWordLeft()
672    pub fn delete_word_backward(&mut self) {
673        if self.col == 0 {
674            self.merge_line_above(self.row);
675            return;
676        }
677
678        let line = if let Some(line) = self.value.get(self.row) {
679            line.clone()
680        } else {
681            return;
682        };
683
684        if line.is_empty() {
685            return;
686        }
687
688        // Find word boundaries - Go bubbles deleteWordLeft behavior
689        let mut start = self.col;
690        let mut end = self.col;
691
692        // If we're not at the end of a word, find the end first
693        while end < line.len() && line.get(end).is_some_and(|&c| !c.is_whitespace()) {
694            end += 1;
695        }
696
697        // Find start of the word we're in or before
698        while start > 0 && line.get(start - 1).is_some_and(|&c| !c.is_whitespace()) {
699            start -= 1;
700        }
701
702        // Only include preceding space if cursor is not at end of word
703        if self.col < line.len() && line.get(self.col).is_some_and(|&c| !c.is_whitespace()) {
704            // Cursor is inside word, include preceding space
705            if start > 0 && line.get(start - 1).is_some_and(|&c| c.is_whitespace()) {
706                start -= 1;
707            }
708        }
709
710        if let Some(line_mut) = self.value.get_mut(self.row) {
711            let end_clamped = end.min(line_mut.len());
712            let start_clamped = start.min(end_clamped);
713            line_mut.drain(start_clamped..end_clamped);
714        }
715
716        self.set_cursor(start);
717    }
718
719    /// Delete word forward - port of Go's deleteWordRight()
720    pub fn delete_word_forward(&mut self) {
721        let line = if let Some(line) = self.value.get(self.row) {
722            line.clone()
723        } else {
724            return;
725        };
726
727        if self.col >= line.len() || line.is_empty() {
728            self.merge_line_below(self.row);
729            return;
730        }
731
732        let old_col = self.col;
733        let mut new_col = self.col;
734
735        // Skip whitespace
736        while new_col < line.len() {
737            if let Some(&ch) = line.get(new_col) {
738                if ch.is_whitespace() {
739                    new_col += 1;
740                } else {
741                    break;
742                }
743            } else {
744                break;
745            }
746        }
747
748        // Skip word characters
749        while new_col < line.len() {
750            if let Some(&ch) = line.get(new_col) {
751                if !ch.is_whitespace() {
752                    new_col += 1;
753                } else {
754                    break;
755                }
756            } else {
757                break;
758            }
759        }
760
761        // Delete the selected text
762        if let Some(line) = self.value.get_mut(self.row) {
763            if new_col > line.len() {
764                line.truncate(old_col);
765            } else {
766                line.drain(old_col..new_col);
767            }
768        }
769
770        self.set_cursor(old_col);
771    }
772
773    /// Merge line below - port of Go's mergeLineBelow()
774    fn merge_line_below(&mut self, row: usize) {
775        if row >= self.value.len().saturating_sub(1) {
776            return;
777        }
778
779        // Combine the two lines
780        if let Some(next_line) = self.value.get(row + 1).cloned() {
781            if let Some(current_line) = self.value.get_mut(row) {
782                current_line.extend_from_slice(&next_line);
783            }
784        }
785
786        // Remove the next line by shifting all lines up
787        self.value.remove(row + 1);
788    }
789
790    /// Merge line above - port of Go's mergeLineAbove()
791    fn merge_line_above(&mut self, row: usize) {
792        if row == 0 {
793            return;
794        }
795
796        if let Some(prev_line) = self.value.get(row - 1) {
797            self.col = prev_line.len();
798        }
799        self.row = row - 1;
800
801        // Combine the two lines
802        if let Some(current_line) = self.value.get(row).cloned() {
803            if let Some(prev_line) = self.value.get_mut(row - 1) {
804                prev_line.extend_from_slice(&current_line);
805            }
806        }
807
808        // Remove the current line
809        self.value.remove(row);
810    }
811
812    /// Split line - port of Go's splitLine()
813    fn split_line(&mut self, row: usize, col: usize) {
814        if let Some(line) = self.value.get(row) {
815            let head = line[..col].to_vec();
816            let tail = line[col..].to_vec();
817
818            // Replace current line with head
819            self.value[row] = head;
820
821            // Insert tail as new line
822            self.value.insert(row + 1, tail);
823
824            self.col = 0;
825            self.row += 1;
826        }
827    }
828
829    /// Insert newline - port of Go's InsertNewline()
830    pub fn insert_newline(&mut self) {
831        if self.max_height > 0 && self.value.len() >= self.max_height {
832            return;
833        }
834
835        self.col = clamp(
836            self.col,
837            0,
838            self.value.get(self.row).map_or(0, |line| line.len()),
839        );
840        self.split_line(self.row, self.col);
841    }
842
843    /// Move cursor one character left - port of Go's characterLeft()
844    pub fn character_left(&mut self, inside_line: bool) {
845        if self.col == 0 && self.row != 0 {
846            self.row -= 1;
847            if let Some(line) = self.value.get(self.row) {
848                self.col = line.len();
849                if !inside_line {
850                    return;
851                }
852            }
853        }
854        if self.col > 0 {
855            self.set_cursor(self.col - 1);
856        }
857    }
858
859    /// Move cursor one character right - port of Go's characterRight()
860    pub fn character_right(&mut self) {
861        if let Some(line) = self.value.get(self.row) {
862            if self.col < line.len() {
863                self.set_cursor(self.col + 1);
864            } else if self.row < self.value.len() - 1 {
865                self.row += 1;
866                self.cursor_start();
867            }
868        }
869    }
870
871    /// Move cursor one word left - port of Go's wordLeft()
872    pub fn word_left(&mut self) {
873        // Move left over any spaces
874        while self.col > 0 {
875            if let Some(line) = self.value.get(self.row) {
876                if line.get(self.col - 1).is_some_and(|c| c.is_whitespace()) {
877                    self.set_cursor(self.col - 1);
878                } else {
879                    break;
880                }
881            } else {
882                break;
883            }
884        }
885        // Then move left over the previous word
886        while self.col > 0 {
887            if let Some(line) = self.value.get(self.row) {
888                if line.get(self.col - 1).is_some_and(|c| !c.is_whitespace()) {
889                    self.set_cursor(self.col - 1);
890                } else {
891                    break;
892                }
893            } else {
894                break;
895            }
896        }
897    }
898
899    /// Move cursor one word right - port of Go's wordRight()
900    pub fn word_right(&mut self) {
901        self.do_word_right(|_, _| {});
902    }
903
904    /// Internal word right with callback - port of Go's doWordRight()  
905    fn do_word_right<F>(&mut self, mut func: F)
906    where
907        F: FnMut(usize, usize),
908    {
909        if self.row >= self.value.len() {
910            return;
911        }
912
913        let line = match self.value.get(self.row) {
914            Some(line) => line.clone(),
915            None => return,
916        };
917
918        if self.col >= line.len() {
919            return;
920        }
921
922        let mut pos = self.col;
923        let mut char_idx = 0;
924
925        // Skip any spaces at current position first
926        while pos < line.len() && line[pos].is_whitespace() {
927            pos += 1;
928        }
929
930        // Move through word characters until we reach whitespace or end
931        while pos < line.len() && !line[pos].is_whitespace() {
932            func(char_idx, pos);
933            pos += 1;
934            char_idx += 1;
935        }
936
937        // Update cursor position
938        self.set_cursor(pos);
939    }
940
941    /// Transform word to uppercase - port of Go's uppercaseRight()
942    pub fn uppercase_right(&mut self) {
943        let start_col = self.col;
944        let start_row = self.row;
945
946        // Find the word boundaries
947        self.word_right(); // Move to end of word
948        let end_col = self.col;
949
950        // Transform characters
951        if let Some(line) = self.value.get_mut(start_row) {
952            let end_idx = end_col.min(line.len());
953            if let Some(slice) = line.get_mut(start_col..end_idx) {
954                for ch in slice.iter_mut() {
955                    *ch = ch.to_uppercase().next().unwrap_or(*ch);
956                }
957            }
958        }
959    }
960
961    /// Transform word to lowercase - port of Go's lowercaseRight()  
962    pub fn lowercase_right(&mut self) {
963        let start_col = self.col;
964        let start_row = self.row;
965
966        // Find the word boundaries
967        self.word_right(); // Move to end of word
968        let end_col = self.col;
969
970        // Transform characters
971        if let Some(line) = self.value.get_mut(start_row) {
972            let end_idx = end_col.min(line.len());
973            if let Some(slice) = line.get_mut(start_col..end_idx) {
974                for ch in slice.iter_mut() {
975                    *ch = ch.to_lowercase().next().unwrap_or(*ch);
976                }
977            }
978        }
979    }
980
981    /// Transform word to title case - port of Go's capitalizeRight()
982    pub fn capitalize_right(&mut self) {
983        let start_col = self.col;
984        let start_row = self.row;
985
986        // Find the word boundaries
987        self.word_right(); // Move to end of word
988        let end_col = self.col;
989
990        // Transform characters
991        if let Some(line) = self.value.get_mut(start_row) {
992            let end_idx = end_col.min(line.len());
993            if let Some(slice) = line.get_mut(start_col..end_idx) {
994                for (i, ch) in slice.iter_mut().enumerate() {
995                    if i == 0 {
996                        *ch = ch.to_uppercase().next().unwrap_or(*ch);
997                    }
998                }
999            }
1000        }
1001    }
1002
1003    /// Transpose characters - port of Go's transposeLeft()
1004    pub fn transpose_left(&mut self) {
1005        let row = self.row;
1006        let mut col = self.col;
1007
1008        if let Some(line) = self.value.get_mut(row) {
1009            if col == 0 || line.len() < 2 {
1010                return;
1011            }
1012
1013            if col >= line.len() {
1014                col -= 1;
1015                self.col = col;
1016            }
1017
1018            if col > 0 && col < line.len() {
1019                line.swap(col - 1, col);
1020                if col < line.len() {
1021                    self.col = col + 1;
1022                }
1023            }
1024        }
1025    }
1026
1027    /// View renders the text area - port of Go's View()
1028    pub fn view(&self) -> String {
1029        // Early return for empty placeholder case
1030        if self.value.is_empty() || (self.value.len() == 1 && self.value[0].is_empty()) {
1031            return self.placeholder_view();
1032        }
1033
1034        let mut lines = Vec::new();
1035        let style = &self.current_style;
1036
1037        // Calculate visible lines based on viewport
1038        let start_line = self.viewport.y_offset;
1039        let end_line = (start_line + self.height).min(self.value.len());
1040
1041        for (line_idx, line) in self
1042            .value
1043            .iter()
1044            .enumerate()
1045            .skip(start_line)
1046            .take(end_line - start_line)
1047        {
1048            let mut line_str = String::new();
1049
1050            // Add prompt
1051            if let Some(prompt_func) = self.prompt_func {
1052                line_str.push_str(&style.computed_prompt().render(&prompt_func(line_idx + 1)));
1053            } else {
1054                line_str.push_str(&style.computed_prompt().render(&self.prompt));
1055            }
1056
1057            // Add line number
1058            if self.show_line_numbers {
1059                let line_num = format!("{:>3} ", line_idx + 1);
1060                line_str.push_str(&style.computed_line_number().render(&line_num));
1061            }
1062
1063            // Add line content with soft wrapping
1064            let mut cache = self.cache.clone();
1065            let wrapped_lines = cache.wrap(line, self.width);
1066
1067            for (wrap_idx, wrapped_line) in wrapped_lines.iter().enumerate() {
1068                let mut display_line = line_str.clone();
1069
1070                if wrap_idx > 0 {
1071                    // Continuation line - adjust prompt/line number spacing
1072                    if self.show_line_numbers {
1073                        display_line =
1074                            format!("{}    ", style.computed_prompt().render(&self.prompt));
1075                    } else {
1076                        display_line = style.computed_prompt().render(&self.prompt);
1077                    }
1078                }
1079
1080                let wrapped_content: String = wrapped_line.iter().collect();
1081
1082                // Apply cursor line highlighting if this is the current line
1083                if line_idx == self.row {
1084                    display_line.push_str(&style.computed_cursor_line().render(&wrapped_content));
1085                } else {
1086                    display_line.push_str(&style.computed_text().render(&wrapped_content));
1087                }
1088
1089                lines.push(display_line);
1090            }
1091        }
1092
1093        // Fill remaining height with empty lines or end-of-buffer characters
1094        while lines.len() < self.height {
1095            let mut empty_line = String::new();
1096
1097            // Add prompt
1098            empty_line.push_str(&style.computed_prompt().render(&self.prompt));
1099
1100            // Add end-of-buffer character or space
1101            if self.end_of_buffer_character != ' ' {
1102                empty_line.push_str(
1103                    &style
1104                        .computed_end_of_buffer()
1105                        .render(&self.end_of_buffer_character.to_string()),
1106                );
1107            }
1108
1109            lines.push(empty_line);
1110        }
1111
1112        // Apply base style to entire view, but strip ANSI for tests
1113        let content = lines.join("\n");
1114        let styled = style.base.render(&content);
1115        lipgloss::strip_ansi(&styled)
1116    }
1117
1118    /// Render placeholder text - port of Go's placeholder view logic
1119    fn placeholder_view(&self) -> String {
1120        if self.placeholder.is_empty() {
1121            return String::new();
1122        }
1123
1124        let mut lines = Vec::new();
1125        let style = &self.current_style;
1126
1127        // Split placeholder into lines
1128        let placeholder_lines: Vec<&str> = self.placeholder.lines().collect();
1129
1130        for (line_idx, &placeholder_line) in placeholder_lines.iter().enumerate() {
1131            let mut line_str = String::new();
1132
1133            // Add prompt
1134            if let Some(prompt_func) = self.prompt_func {
1135                line_str.push_str(&style.computed_prompt().render(&prompt_func(line_idx + 1)));
1136            } else {
1137                line_str.push_str(&style.computed_prompt().render(&self.prompt));
1138            }
1139
1140            // Add line number for first line only
1141            if self.show_line_numbers {
1142                if line_idx == 0 {
1143                    line_str.push_str(&style.computed_line_number().render("  1 "));
1144                } else {
1145                    line_str.push_str(&style.computed_line_number().render("    "));
1146                }
1147            }
1148
1149            // Add placeholder content with wrapping
1150            let mut cache = self.cache.clone();
1151            let wrapped = cache.wrap(&placeholder_line.chars().collect::<Vec<_>>(), self.width);
1152
1153            for (wrap_idx, wrapped_line) in wrapped.iter().enumerate() {
1154                let mut display_line = line_str.clone();
1155
1156                if wrap_idx > 0 {
1157                    // Continuation line
1158                    if self.show_line_numbers {
1159                        display_line =
1160                            format!("{}    ", style.computed_prompt().render(&self.prompt));
1161                    } else {
1162                        display_line = style.computed_prompt().render(&self.prompt);
1163                    }
1164                }
1165
1166                let wrapped_content: String = wrapped_line.iter().collect();
1167                display_line.push_str(&style.computed_placeholder().render(&wrapped_content));
1168
1169                lines.push(display_line);
1170
1171                if lines.len() >= self.height {
1172                    break;
1173                }
1174            }
1175
1176            if lines.len() >= self.height {
1177                break;
1178            }
1179        }
1180
1181        // Fill remaining height with empty lines
1182        while lines.len() < self.height {
1183            let mut empty_line = String::new();
1184
1185            // Add prompt
1186            empty_line.push_str(&style.computed_prompt().render(&self.prompt));
1187
1188            // Add end-of-buffer character or space
1189            if self.end_of_buffer_character != ' ' {
1190                empty_line.push_str(
1191                    &style
1192                        .computed_end_of_buffer()
1193                        .render(&self.end_of_buffer_character.to_string()),
1194                );
1195            }
1196
1197            lines.push(empty_line);
1198        }
1199
1200        // Apply base style to entire view, but strip ANSI for tests
1201        let content = lines.join("\n");
1202        let styled = style.base.render(&content);
1203        lipgloss::strip_ansi(&styled)
1204    }
1205
1206    /// Scroll viewport down by n lines - for testing viewport functionality
1207    pub fn scroll_down(&mut self, lines: usize) {
1208        self.viewport.set_y_offset(self.viewport.y_offset + lines);
1209    }
1210
1211    /// Scroll viewport up by n lines - for testing viewport functionality  
1212    pub fn scroll_up(&mut self, lines: usize) {
1213        self.viewport
1214            .set_y_offset(self.viewport.y_offset.saturating_sub(lines));
1215    }
1216
1217    /// Get cursor line number for display - port of Go's cursorLineNumber()
1218    pub fn cursor_line_number(&mut self) -> usize {
1219        if self.row >= self.value.len() {
1220            return 0;
1221        }
1222
1223        // Count visual lines from all preceding document lines
1224        let mut line_count = 0;
1225        for i in 0..self.row {
1226            if let Some(line) = self.value.get(i).cloned() {
1227                let wrapped_lines = self.cache.wrap(&line, self.width);
1228                line_count += wrapped_lines.len();
1229            }
1230        }
1231
1232        // Add the row offset within the current document line
1233        line_count += self.line_info().row_offset;
1234        line_count
1235    }
1236
1237    /// Update handles incoming messages and updates the textarea state - port of Go's Update()
1238    pub fn update(&mut self, msg: Option<bubbletea_rs::Msg>) -> Option<bubbletea_rs::Cmd> {
1239        if !self.focus {
1240            return None;
1241        }
1242
1243        if let Some(msg) = msg {
1244            // Handle clipboard messages first
1245            if let Some(paste_msg) = msg.downcast_ref::<PasteMsg>() {
1246                self.insert_string(paste_msg.0.clone());
1247                return None;
1248            }
1249
1250            if let Some(_paste_err) = msg.downcast_ref::<PasteErrMsg>() {
1251                // Handle paste error - could be logged or shown to user
1252                return None;
1253            }
1254
1255            // Handle key messages
1256            if let Some(key_msg) = msg.downcast_ref::<bubbletea_rs::KeyMsg>() {
1257                return self.handle_key_msg(key_msg);
1258            }
1259
1260            // Pass other messages to cursor and viewport
1261            let cursor_cmd = self.cursor.update(&msg);
1262            let viewport_cmd = self.viewport.update(msg);
1263
1264            // Return the first available command
1265            cursor_cmd.or(viewport_cmd)
1266        } else {
1267            None
1268        }
1269    }
1270
1271    /// Handle key messages - port of Go's key handling logic
1272    fn handle_key_msg(&mut self, key_msg: &bubbletea_rs::KeyMsg) -> Option<bubbletea_rs::Cmd> {
1273        use crate::key::matches_binding;
1274
1275        // Character movement
1276        if matches_binding(key_msg, &self.key_map.character_forward) {
1277            self.character_right();
1278        } else if matches_binding(key_msg, &self.key_map.character_backward) {
1279            self.character_left(false);
1280
1281        // Word movement
1282        } else if matches_binding(key_msg, &self.key_map.word_forward) {
1283            self.word_right();
1284        } else if matches_binding(key_msg, &self.key_map.word_backward) {
1285            self.word_left();
1286
1287        // Line movement
1288        } else if matches_binding(key_msg, &self.key_map.line_next) {
1289            self.cursor_down();
1290        } else if matches_binding(key_msg, &self.key_map.line_previous) {
1291            self.cursor_up();
1292        } else if matches_binding(key_msg, &self.key_map.line_start) {
1293            self.cursor_start();
1294        } else if matches_binding(key_msg, &self.key_map.line_end) {
1295            self.cursor_end();
1296
1297        // Input navigation
1298        } else if matches_binding(key_msg, &self.key_map.input_begin) {
1299            self.move_to_begin();
1300        } else if matches_binding(key_msg, &self.key_map.input_end) {
1301            self.move_to_end();
1302
1303        // Deletion
1304        } else if matches_binding(key_msg, &self.key_map.delete_character_backward) {
1305            self.delete_character_backward();
1306        } else if matches_binding(key_msg, &self.key_map.delete_character_forward) {
1307            self.delete_character_forward();
1308        } else if matches_binding(key_msg, &self.key_map.delete_word_backward) {
1309            self.delete_word_backward();
1310        } else if matches_binding(key_msg, &self.key_map.delete_word_forward) {
1311            self.delete_word_forward();
1312        } else if matches_binding(key_msg, &self.key_map.delete_after_cursor) {
1313            self.delete_after_cursor();
1314        } else if matches_binding(key_msg, &self.key_map.delete_before_cursor) {
1315            self.delete_before_cursor();
1316
1317        // Text insertion
1318        } else if matches_binding(key_msg, &self.key_map.insert_newline) {
1319            self.insert_newline();
1320
1321        // Clipboard operations
1322        } else if matches_binding(key_msg, &self.key_map.paste) {
1323            return Some(self.paste_command());
1324
1325        // Advanced text operations
1326        } else if matches_binding(key_msg, &self.key_map.uppercase_word_forward) {
1327            self.uppercase_right();
1328        } else if matches_binding(key_msg, &self.key_map.lowercase_word_forward) {
1329            self.lowercase_right();
1330        } else if matches_binding(key_msg, &self.key_map.capitalize_word_forward) {
1331            self.capitalize_right();
1332        } else if matches_binding(key_msg, &self.key_map.transpose_character_backward) {
1333            self.transpose_left();
1334        } else {
1335            // Handle regular character input
1336            if let Some(ch) = self.extract_character_from_key_msg(key_msg) {
1337                if ch.is_control() {
1338                    // Ignore control characters that aren't handled above
1339                    return None;
1340                }
1341                self.insert_rune(ch);
1342            }
1343        }
1344
1345        None
1346    }
1347
1348    /// Extract character from key message for regular text input
1349    fn extract_character_from_key_msg(&self, _key_msg: &bubbletea_rs::KeyMsg) -> Option<char> {
1350        // This would depend on the actual KeyMsg structure in bubbletea_rs
1351        // For now, we'll return None as a placeholder
1352        // In a real implementation, this would extract the character from the key event
1353        None
1354    }
1355
1356    /// Create paste command for clipboard integration
1357    fn paste_command(&self) -> bubbletea_rs::Cmd {
1358        bubbletea_rs::tick(
1359            std::time::Duration::from_nanos(1),
1360            |_| match Self::read_clipboard() {
1361                Ok(content) => Box::new(PasteMsg(content)) as bubbletea_rs::Msg,
1362                Err(err) => Box::new(PasteErrMsg(err)) as bubbletea_rs::Msg,
1363            },
1364        )
1365    }
1366
1367    /// Read from system clipboard
1368    fn read_clipboard() -> Result<String, String> {
1369        #[cfg(feature = "clipboard-support")]
1370        {
1371            use clipboard::{ClipboardContext, ClipboardProvider};
1372
1373            let mut ctx: ClipboardContext = ClipboardProvider::new()
1374                .map_err(|e| format!("Failed to create clipboard context: {}", e))?;
1375
1376            ctx.get_contents()
1377                .map_err(|e| format!("Failed to read clipboard: {}", e))
1378        }
1379        #[cfg(not(feature = "clipboard-support"))]
1380        {
1381            Err("Clipboard support not enabled".to_string())
1382        }
1383    }
1384
1385    /// Copy text to system clipboard
1386    pub fn copy_to_clipboard(&self, text: &str) -> Result<(), String> {
1387        #[cfg(feature = "clipboard-support")]
1388        {
1389            use clipboard::{ClipboardContext, ClipboardProvider};
1390
1391            let mut ctx: ClipboardContext = ClipboardProvider::new()
1392                .map_err(|e| format!("Failed to create clipboard context: {}", e))?;
1393
1394            ctx.set_contents(text.to_string())
1395                .map_err(|e| format!("Failed to write to clipboard: {}", e))
1396        }
1397        #[cfg(not(feature = "clipboard-support"))]
1398        {
1399            let _ = text; // Suppress unused parameter warning
1400            Err("Clipboard support not enabled".to_string())
1401        }
1402    }
1403
1404    /// Copy current selection to clipboard (if selection is implemented)
1405    pub fn copy_selection(&self) -> Result<(), String> {
1406        // For now, copy entire content
1407        // In a full implementation, this would copy only selected text
1408        let content = self.value();
1409        self.copy_to_clipboard(&content)
1410    }
1411
1412    /// Cut current selection to clipboard (if selection is implemented)
1413    pub fn cut_selection(&mut self) -> Result<(), String> {
1414        // For now, cut entire content
1415        // In a full implementation, this would cut only selected text
1416        let content = self.value();
1417        self.copy_to_clipboard(&content)?;
1418        self.reset();
1419        Ok(())
1420    }
1421}
1422
1423impl Default for Model {
1424    fn default() -> Self {
1425        Self::new()
1426    }
1427}
1428
1429/// Focus sets the focus state on the model - port of Go's Focus()
1430impl Component for Model {
1431    fn focus(&mut self) -> Option<Cmd> {
1432        self.focus = true;
1433        self.current_style = self.focused_style.clone();
1434        self.cursor.focus()
1435    }
1436
1437    fn blur(&mut self) {
1438        self.focus = false;
1439        self.current_style = self.blurred_style.clone();
1440        self.cursor.blur();
1441    }
1442
1443    fn focused(&self) -> bool {
1444        self.focus
1445    }
1446}
1447
1448/// Default styles matching Go's DefaultStyles() function
1449pub fn default_styles() -> (TextareaStyle, TextareaStyle) {
1450    let focused = default_focused_style();
1451    let blurred = default_blurred_style();
1452    (focused, blurred)
1453}
1454
1455/// Create a new textarea model - convenience function
1456pub fn new() -> Model {
1457    Model::new()
1458}