Skip to main content

bubbles/
textinput.rs

1//! Single-line text input component.
2//!
3//! This module provides a text input field for TUI applications with features
4//! like password masking, suggestions, and validation.
5//!
6//! # Example
7//!
8//! ```rust
9//! use bubbles::textinput::TextInput;
10//!
11//! let mut input = TextInput::new();
12//! input.set_placeholder("Enter your name");
13//! input.set_value("Hello");
14//!
15//! // Render the input
16//! let view = input.view();
17//! ```
18
19use crate::cursor::{Cursor, blink_cmd};
20use crate::key::{Binding, matches};
21use crate::runeutil::Sanitizer;
22use bubbletea::{Cmd, KeyMsg, Message, Model};
23use lipgloss::{Color, Style};
24use unicode_width::UnicodeWidthChar;
25
26/// Echo mode for the text input.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
28pub enum EchoMode {
29    /// Display text as-is (default).
30    #[default]
31    Normal,
32    /// Display echo character instead of actual text (for passwords).
33    Password,
34    /// Display nothing (hidden input).
35    None,
36}
37
38/// Validation function type.
39pub type ValidateFn = Box<dyn Fn(&str) -> Option<String> + Send + Sync>;
40
41/// Key bindings for text input navigation.
42#[derive(Debug, Clone)]
43pub struct KeyMap {
44    /// Move cursor forward one character.
45    pub character_forward: Binding,
46    /// Move cursor backward one character.
47    pub character_backward: Binding,
48    /// Move cursor forward one word.
49    pub word_forward: Binding,
50    /// Move cursor backward one word.
51    pub word_backward: Binding,
52    /// Delete word backward.
53    pub delete_word_backward: Binding,
54    /// Delete word forward.
55    pub delete_word_forward: Binding,
56    /// Delete text after cursor.
57    pub delete_after_cursor: Binding,
58    /// Delete text before cursor.
59    pub delete_before_cursor: Binding,
60    /// Delete character backward.
61    pub delete_character_backward: Binding,
62    /// Delete character forward.
63    pub delete_character_forward: Binding,
64    /// Move to start of line.
65    pub line_start: Binding,
66    /// Move to end of line.
67    pub line_end: Binding,
68    /// Paste from clipboard.
69    pub paste: Binding,
70    /// Accept current suggestion.
71    pub accept_suggestion: Binding,
72    /// Next suggestion.
73    pub next_suggestion: Binding,
74    /// Previous suggestion.
75    pub prev_suggestion: Binding,
76}
77
78impl Default for KeyMap {
79    fn default() -> Self {
80        Self {
81            character_forward: Binding::new().keys(&["right", "ctrl+f"]),
82            character_backward: Binding::new().keys(&["left", "ctrl+b"]),
83            word_forward: Binding::new().keys(&["alt+right", "ctrl+right", "alt+f"]),
84            word_backward: Binding::new().keys(&["alt+left", "ctrl+left", "alt+b"]),
85            delete_word_backward: Binding::new().keys(&["alt+backspace", "ctrl+w"]),
86            delete_word_forward: Binding::new().keys(&["alt+delete", "alt+d"]),
87            delete_after_cursor: Binding::new().keys(&["ctrl+k"]),
88            delete_before_cursor: Binding::new().keys(&["ctrl+u"]),
89            delete_character_backward: Binding::new().keys(&["backspace", "ctrl+h"]),
90            delete_character_forward: Binding::new().keys(&["delete", "ctrl+d"]),
91            line_start: Binding::new().keys(&["home", "ctrl+a"]),
92            line_end: Binding::new().keys(&["end", "ctrl+e"]),
93            paste: Binding::new().keys(&["ctrl+v"]),
94            accept_suggestion: Binding::new().keys(&["tab"]),
95            next_suggestion: Binding::new().keys(&["down", "ctrl+n"]),
96            prev_suggestion: Binding::new().keys(&["up", "ctrl+p"]),
97        }
98    }
99}
100
101/// Message for paste operations.
102#[derive(Debug, Clone)]
103pub struct PasteMsg(pub String);
104
105/// Message for paste errors.
106#[derive(Debug, Clone)]
107pub struct PasteErrMsg(pub String);
108
109/// Single-line text input model.
110pub struct TextInput {
111    /// Current error from validation.
112    pub err: Option<String>,
113    /// Prompt displayed before input.
114    pub prompt: String,
115    /// Placeholder text when empty.
116    pub placeholder: String,
117    /// Echo mode (normal, password, none).
118    pub echo_mode: EchoMode,
119    /// Character to display in password mode.
120    pub echo_character: char,
121    /// Cursor model.
122    pub cursor: Cursor,
123    /// Style for the prompt.
124    pub prompt_style: Style,
125    /// Style for the text.
126    pub text_style: Style,
127    /// Style for the placeholder.
128    pub placeholder_style: Style,
129    /// Style for completions.
130    pub completion_style: Style,
131    /// Maximum characters allowed (0 = no limit).
132    pub char_limit: usize,
133    /// Maximum display width (0 = no limit).
134    pub width: usize,
135    /// Key bindings.
136    pub key_map: KeyMap,
137    /// Whether to show suggestions.
138    pub show_suggestions: bool,
139    /// Underlying text value.
140    value: Vec<char>,
141    /// Focus state.
142    focus: bool,
143    /// Cursor position.
144    pos: usize,
145    /// Viewport offset (left).
146    offset: usize,
147    /// Viewport offset (right).
148    offset_right: usize,
149    /// Validation function.
150    validate: Option<ValidateFn>,
151    /// Rune sanitizer.
152    sanitizer: Sanitizer,
153    /// Available suggestions.
154    suggestions: Vec<Vec<char>>,
155    /// Matched suggestions.
156    matched_suggestions: Vec<Vec<char>>,
157    /// Current suggestion index.
158    current_suggestion_index: usize,
159}
160
161impl Default for TextInput {
162    fn default() -> Self {
163        Self::new()
164    }
165}
166
167impl std::fmt::Debug for TextInput {
168    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
169        f.debug_struct("TextInput")
170            .field("err", &self.err)
171            .field("prompt", &self.prompt)
172            .field("placeholder", &self.placeholder)
173            .field("echo_mode", &self.echo_mode)
174            .field("cursor", &self.cursor)
175            .field("char_limit", &self.char_limit)
176            .field("width", &self.width)
177            .field("focus", &self.focus)
178            .field("pos", &self.pos)
179            .field("value_len", &self.value.len())
180            .field("validate", &self.validate.as_ref().map(|_| "<fn>"))
181            .finish()
182    }
183}
184
185impl Clone for TextInput {
186    fn clone(&self) -> Self {
187        Self {
188            err: self.err.clone(),
189            prompt: self.prompt.clone(),
190            placeholder: self.placeholder.clone(),
191            echo_mode: self.echo_mode,
192            echo_character: self.echo_character,
193            cursor: self.cursor.clone(),
194            prompt_style: self.prompt_style.clone(),
195            text_style: self.text_style.clone(),
196            placeholder_style: self.placeholder_style.clone(),
197            completion_style: self.completion_style.clone(),
198            char_limit: self.char_limit,
199            width: self.width,
200            key_map: self.key_map.clone(),
201            show_suggestions: self.show_suggestions,
202            value: self.value.clone(),
203            focus: self.focus,
204            pos: self.pos,
205            offset: self.offset,
206            offset_right: self.offset_right,
207            validate: None, // Can't clone Box<dyn Fn>
208            sanitizer: self.sanitizer.clone(),
209            suggestions: self.suggestions.clone(),
210            matched_suggestions: self.matched_suggestions.clone(),
211            current_suggestion_index: self.current_suggestion_index,
212        }
213    }
214}
215
216impl TextInput {
217    /// Creates a new text input with default settings.
218    #[must_use]
219    pub fn new() -> Self {
220        let sanitizer = Sanitizer::new()
221            .with_tab_replacement(" ")
222            .with_newline_replacement(" ");
223
224        Self {
225            err: None,
226            prompt: "> ".to_string(),
227            placeholder: String::new(),
228            echo_mode: EchoMode::Normal,
229            echo_character: '*',
230            cursor: Cursor::new(),
231            prompt_style: Style::new(),
232            text_style: Style::new(),
233            placeholder_style: Style::new().foreground_color(Color::from("240")),
234            completion_style: Style::new().foreground_color(Color::from("240")),
235            char_limit: 0,
236            width: 0,
237            key_map: KeyMap::default(),
238            show_suggestions: false,
239            value: Vec::new(),
240            focus: false,
241            pos: 0,
242            offset: 0,
243            offset_right: 0,
244            validate: None,
245            sanitizer,
246            suggestions: Vec::new(),
247            matched_suggestions: Vec::new(),
248            current_suggestion_index: 0,
249        }
250    }
251
252    /// Sets the prompt string.
253    pub fn set_prompt(&mut self, prompt: impl Into<String>) {
254        self.prompt = prompt.into();
255    }
256
257    /// Sets the placeholder text.
258    pub fn set_placeholder(&mut self, placeholder: impl Into<String>) {
259        self.placeholder = placeholder.into();
260    }
261
262    /// Sets the echo mode.
263    pub fn set_echo_mode(&mut self, mode: EchoMode) {
264        self.echo_mode = mode;
265    }
266
267    /// Sets the value of the text input.
268    pub fn set_value(&mut self, s: &str) {
269        let mut runes = self.sanitizer.sanitize(&s.chars().collect::<Vec<_>>());
270        if self.char_limit > 0 && runes.len() > self.char_limit {
271            runes.truncate(self.char_limit);
272        }
273        let err = self.do_validate(&runes);
274        self.set_value_internal(runes, err);
275    }
276
277    fn set_value_internal(&mut self, runes: Vec<char>, err: Option<String>) {
278        self.err = err;
279        let empty = self.value.is_empty();
280
281        if self.char_limit > 0 && runes.len() > self.char_limit {
282            self.value = runes[..self.char_limit].to_vec();
283        } else {
284            self.value = runes;
285        }
286
287        if (self.pos == 0 && empty) || self.pos > self.value.len() {
288            self.set_cursor(self.value.len());
289        }
290        self.handle_overflow();
291        self.update_suggestions();
292    }
293
294    /// Returns the current value as a string.
295    #[must_use]
296    pub fn value(&self) -> String {
297        self.value.iter().collect()
298    }
299
300    /// Returns the cursor position.
301    #[must_use]
302    pub fn position(&self) -> usize {
303        self.pos
304    }
305
306    /// Sets the cursor position.
307    pub fn set_cursor(&mut self, pos: usize) {
308        self.pos = pos.min(self.value.len());
309        self.handle_overflow();
310    }
311
312    /// Moves cursor to start of input.
313    pub fn cursor_start(&mut self) {
314        self.set_cursor(0);
315    }
316
317    /// Moves cursor to end of input.
318    pub fn cursor_end(&mut self) {
319        self.set_cursor(self.value.len());
320    }
321
322    /// Returns whether the input is focused.
323    #[must_use]
324    pub fn focused(&self) -> bool {
325        self.focus
326    }
327
328    /// Focuses the input and returns the cursor blink command.
329    pub fn focus(&mut self) -> Option<Cmd> {
330        self.focus = true;
331        self.cursor.focus()
332    }
333
334    /// Blurs the input.
335    pub fn blur(&mut self) {
336        self.focus = false;
337        self.cursor.blur();
338    }
339
340    /// Resets the input to empty.
341    pub fn reset(&mut self) {
342        self.value.clear();
343        self.err = self.do_validate(&self.value);
344        self.set_cursor(0);
345        self.update_suggestions();
346    }
347
348    /// Sets the suggestions list.
349    pub fn set_suggestions(&mut self, suggestions: &[&str]) {
350        self.suggestions = suggestions.iter().map(|s| s.chars().collect()).collect();
351        self.update_suggestions();
352    }
353
354    /// Sets the validation function.
355    pub fn set_validate<F>(&mut self, f: F)
356    where
357        F: Fn(&str) -> Option<String> + Send + Sync + 'static,
358    {
359        self.validate = Some(Box::new(f));
360    }
361
362    /// Returns available suggestions as strings.
363    #[must_use]
364    pub fn available_suggestions(&self) -> Vec<String> {
365        self.suggestions
366            .iter()
367            .map(|s| s.iter().collect())
368            .collect()
369    }
370
371    /// Returns matched suggestions as strings.
372    #[must_use]
373    pub fn matched_suggestions(&self) -> Vec<String> {
374        self.matched_suggestions
375            .iter()
376            .map(|s| s.iter().collect())
377            .collect()
378    }
379
380    /// Returns the current suggestion index.
381    #[must_use]
382    pub fn current_suggestion_index(&self) -> usize {
383        self.current_suggestion_index
384    }
385
386    /// Returns the current suggestion.
387    #[must_use]
388    pub fn current_suggestion(&self) -> String {
389        self.matched_suggestions
390            .get(self.current_suggestion_index)
391            .map(|s| s.iter().collect())
392            .unwrap_or_default()
393    }
394
395    fn do_validate(&self, v: &[char]) -> Option<String> {
396        self.validate
397            .as_ref()
398            .and_then(|f| f(&v.iter().collect::<String>()))
399    }
400
401    fn insert_runes_from_user_input(&mut self, v: &[char]) {
402        let paste = self.sanitizer.sanitize(v);
403
404        let mut available = if self.char_limit > 0 {
405            let avail = self.char_limit.saturating_sub(self.value.len());
406            if avail == 0 {
407                return;
408            }
409            avail
410        } else {
411            usize::MAX
412        };
413
414        let paste = if paste.len() > available {
415            &paste[..available]
416        } else {
417            &paste
418        };
419
420        // Split at cursor
421        let head = &self.value[..self.pos];
422        let tail = &self.value[self.pos..];
423
424        let mut new_value = head.to_vec();
425        for &c in paste {
426            if available == 0 {
427                break;
428            }
429            new_value.push(c);
430            self.pos += 1;
431            available = available.saturating_sub(1);
432        }
433        new_value.extend_from_slice(tail);
434
435        let err = self.do_validate(&new_value);
436        self.set_value_internal(new_value, err);
437    }
438
439    fn handle_overflow(&mut self) {
440        let total_width: usize = self.value.iter().map(|c| c.width().unwrap_or(0)).sum();
441        if self.width == 0 || total_width <= self.width {
442            self.offset = 0;
443            self.offset_right = self.value.len();
444            return;
445        }
446
447        self.offset_right = self.offset_right.min(self.value.len());
448
449        if self.pos < self.offset {
450            self.offset = self.pos;
451            let mut w = 0;
452            let mut i = 0;
453            let runes = &self.value[self.offset..];
454
455            while i < runes.len() {
456                let cw = runes[i].width().unwrap_or(0);
457                if w + cw > self.width {
458                    break;
459                }
460                w += cw;
461                i += 1;
462            }
463            self.offset_right = self.offset + i;
464        } else if self.pos >= self.offset_right {
465            self.offset_right = self.pos;
466            let mut w = 0;
467            let runes = &self.value[..self.offset_right];
468            let mut start_index = self.offset_right;
469
470            // Scan backwards from offset_right
471            while start_index > 0 {
472                let prev = start_index - 1;
473                let cw = runes[prev].width().unwrap_or(0);
474                if w + cw > self.width {
475                    break;
476                }
477                w += cw;
478                start_index = prev;
479            }
480            self.offset = start_index;
481        }
482    }
483
484    fn delete_before_cursor(&mut self) {
485        self.value = self.value[self.pos..].to_vec();
486        self.err = self.do_validate(&self.value);
487        self.offset = 0;
488        self.set_cursor(0);
489    }
490
491    fn delete_after_cursor(&mut self) {
492        self.value = self.value[..self.pos].to_vec();
493        self.err = self.do_validate(&self.value);
494        self.set_cursor(self.value.len());
495    }
496
497    fn delete_word_backward(&mut self) {
498        if self.pos == 0 || self.value.is_empty() {
499            return;
500        }
501
502        if self.echo_mode != EchoMode::Normal {
503            self.delete_before_cursor();
504            return;
505        }
506
507        let old_pos = self.pos;
508        self.set_cursor(self.pos.saturating_sub(1));
509
510        // Skip whitespace backward
511        while self.pos > 0 {
512            let prev = self.pos - 1;
513            if let Some(c) = self.value.get(prev) {
514                if c.is_whitespace() {
515                    self.set_cursor(prev);
516                } else {
517                    break;
518                }
519            } else {
520                break;
521            }
522        }
523
524        // Skip non-whitespace backward
525        while self.pos > 0 {
526            let prev = self.pos - 1;
527            if let Some(c) = self.value.get(prev) {
528                if !c.is_whitespace() {
529                    self.set_cursor(prev);
530                } else {
531                    break;
532                }
533            } else {
534                break;
535            }
536        }
537
538        if old_pos > self.value.len() {
539            self.value = self.value[..self.pos].to_vec();
540        } else {
541            let mut new_value = self.value[..self.pos].to_vec();
542            new_value.extend_from_slice(&self.value[old_pos..]);
543            self.value = new_value;
544        }
545        self.err = self.do_validate(&self.value);
546        self.handle_overflow();
547    }
548
549    fn delete_word_forward(&mut self) {
550        if self.pos >= self.value.len() || self.value.is_empty() {
551            return;
552        }
553
554        if self.echo_mode != EchoMode::Normal {
555            self.delete_after_cursor();
556            return;
557        }
558
559        let old_pos = self.pos;
560        self.set_cursor(self.pos + 1);
561
562        // Skip whitespace
563        while self.pos < self.value.len()
564            && self.value.get(self.pos).is_some_and(|c| c.is_whitespace())
565        {
566            self.set_cursor(self.pos + 1);
567        }
568
569        // Skip non-whitespace
570        while self.pos < self.value.len() {
571            if !self.value.get(self.pos).is_some_and(|c| c.is_whitespace()) {
572                self.set_cursor(self.pos + 1);
573            } else {
574                break;
575            }
576        }
577
578        if self.pos > self.value.len() {
579            self.value = self.value[..old_pos].to_vec();
580        } else {
581            let mut new_value = self.value[..old_pos].to_vec();
582            new_value.extend_from_slice(&self.value[self.pos..]);
583            self.value = new_value;
584        }
585        self.err = self.do_validate(&self.value);
586        self.set_cursor(old_pos);
587    }
588
589    fn word_backward(&mut self) {
590        if self.pos == 0 || self.value.is_empty() {
591            return;
592        }
593
594        if self.echo_mode != EchoMode::Normal {
595            self.cursor_start();
596            return;
597        }
598
599        // Skip whitespace backward
600        while self.pos > 0 {
601            let prev = self.pos - 1;
602            if let Some(c) = self.value.get(prev) {
603                if c.is_whitespace() {
604                    self.set_cursor(prev);
605                } else {
606                    break;
607                }
608            } else {
609                break;
610            }
611        }
612
613        // Skip non-whitespace backward
614        while self.pos > 0 {
615            let prev = self.pos - 1;
616            if let Some(c) = self.value.get(prev) {
617                if !c.is_whitespace() {
618                    self.set_cursor(prev);
619                } else {
620                    break;
621                }
622            } else {
623                break;
624            }
625        }
626    }
627
628    fn word_forward(&mut self) {
629        if self.pos >= self.value.len() || self.value.is_empty() {
630            return;
631        }
632
633        if self.echo_mode != EchoMode::Normal {
634            self.cursor_end();
635            return;
636        }
637
638        let mut i = self.pos;
639
640        // Skip whitespace
641        while i < self.value.len() && self.value.get(i).is_some_and(|c| c.is_whitespace()) {
642            self.set_cursor(self.pos + 1);
643            i += 1;
644        }
645
646        // Skip non-whitespace
647        while i < self.value.len() {
648            if !self.value.get(i).is_some_and(|c| c.is_whitespace()) {
649                self.set_cursor(self.pos + 1);
650                i += 1;
651            } else {
652                break;
653            }
654        }
655    }
656
657    fn echo_transform(&self, v: &str) -> String {
658        match self.echo_mode {
659            EchoMode::Normal => v.to_string(),
660            EchoMode::Password => self.echo_character.to_string().repeat(v.chars().count()),
661            EchoMode::None => String::new(),
662        }
663    }
664
665    fn can_accept_suggestion(&self) -> bool {
666        !self.matched_suggestions.is_empty()
667    }
668
669    fn update_suggestions(&mut self) {
670        if !self.show_suggestions {
671            return;
672        }
673
674        if self.value.is_empty() || self.suggestions.is_empty() {
675            self.matched_suggestions.clear();
676            return;
677        }
678
679        let value_str: String = self.value.iter().collect();
680        let value_lower = value_str.to_lowercase();
681
682        let matches: Vec<Vec<char>> = self
683            .suggestions
684            .iter()
685            .filter(|s| {
686                let suggestion: String = s.iter().collect();
687                suggestion.to_lowercase().starts_with(&value_lower)
688            })
689            .cloned()
690            .collect();
691
692        if matches != self.matched_suggestions {
693            self.current_suggestion_index = 0;
694        }
695
696        self.matched_suggestions = matches;
697    }
698
699    fn next_suggestion(&mut self) {
700        if self.matched_suggestions.is_empty() {
701            return;
702        }
703        self.current_suggestion_index =
704            (self.current_suggestion_index + 1) % self.matched_suggestions.len();
705    }
706
707    fn previous_suggestion(&mut self) {
708        if self.matched_suggestions.is_empty() {
709            return;
710        }
711        if self.current_suggestion_index == 0 {
712            self.current_suggestion_index = self.matched_suggestions.len().saturating_sub(1);
713        } else {
714            self.current_suggestion_index -= 1;
715        }
716    }
717
718    /// Updates the text input based on messages.
719    pub fn update(&mut self, msg: Message) -> Option<Cmd> {
720        if !self.focus {
721            return None;
722        }
723
724        // Handle paste message
725        if let Some(paste) = msg.downcast_ref::<PasteMsg>() {
726            self.insert_runes_from_user_input(&paste.0.chars().collect::<Vec<_>>());
727            return None;
728        }
729
730        if let Some(paste_err) = msg.downcast_ref::<PasteErrMsg>() {
731            self.err = Some(paste_err.0.clone());
732            return None;
733        }
734
735        let old_pos = self.pos;
736
737        if let Some(key) = msg.downcast_ref::<KeyMsg>() {
738            let key_str = key.to_string();
739
740            // Check for suggestion acceptance first
741            if matches(&key_str, &[&self.key_map.accept_suggestion])
742                && self.can_accept_suggestion()
743                && let Some(suggestion) =
744                    self.matched_suggestions.get(self.current_suggestion_index)
745                && self.value.len() < suggestion.len()
746            {
747                self.value
748                    .extend_from_slice(&suggestion[self.value.len()..]);
749                self.cursor_end();
750            }
751
752            if matches(&key_str, &[&self.key_map.delete_word_backward]) {
753                self.delete_word_backward();
754            } else if matches(&key_str, &[&self.key_map.delete_character_backward]) {
755                self.err = None;
756                if !self.value.is_empty() && self.pos > 0 {
757                    self.value.remove(self.pos - 1);
758                    self.err = self.do_validate(&self.value);
759                    self.set_cursor(self.pos.saturating_sub(1));
760                }
761            } else if matches(&key_str, &[&self.key_map.word_backward]) {
762                self.word_backward();
763            } else if matches(&key_str, &[&self.key_map.character_backward]) {
764                if self.pos > 0 {
765                    self.set_cursor(self.pos - 1);
766                }
767            } else if matches(&key_str, &[&self.key_map.word_forward]) {
768                self.word_forward();
769            } else if matches(&key_str, &[&self.key_map.character_forward]) {
770                if self.pos < self.value.len() {
771                    self.set_cursor(self.pos + 1);
772                }
773            } else if matches(&key_str, &[&self.key_map.line_start]) {
774                self.cursor_start();
775            } else if matches(&key_str, &[&self.key_map.delete_character_forward]) {
776                if !self.value.is_empty() && self.pos < self.value.len() {
777                    self.value.remove(self.pos);
778                    self.err = self.do_validate(&self.value);
779                }
780            } else if matches(&key_str, &[&self.key_map.line_end]) {
781                self.cursor_end();
782            } else if matches(&key_str, &[&self.key_map.delete_after_cursor]) {
783                self.delete_after_cursor();
784            } else if matches(&key_str, &[&self.key_map.delete_before_cursor]) {
785                self.delete_before_cursor();
786            } else if matches(&key_str, &[&self.key_map.delete_word_forward]) {
787                self.delete_word_forward();
788            } else if matches(&key_str, &[&self.key_map.next_suggestion]) {
789                self.next_suggestion();
790            } else if matches(&key_str, &[&self.key_map.prev_suggestion]) {
791                self.previous_suggestion();
792            } else if !matches(
793                &key_str,
794                &[&self.key_map.paste, &self.key_map.accept_suggestion],
795            ) {
796                // Input regular characters
797                let runes: Vec<char> = key.runes.clone();
798                if !runes.is_empty() {
799                    self.insert_runes_from_user_input(&runes);
800                }
801            }
802
803            self.update_suggestions();
804        }
805
806        let mut cmds: Vec<Option<Cmd>> = Vec::new();
807
808        if let Some(cmd) = self.cursor.update(msg) {
809            cmds.push(Some(cmd));
810        }
811
812        if old_pos != self.pos && self.cursor.mode() == crate::cursor::Mode::Blink {
813            // Reset blink state when cursor moves - trigger blink cycle
814            cmds.push(Some(blink_cmd()));
815        }
816
817        self.handle_overflow();
818
819        bubbletea::batch(cmds)
820    }
821
822    /// Renders the text input.
823    #[must_use]
824    pub fn view(&self) -> String {
825        if self.value.is_empty() && !self.placeholder.is_empty() {
826            return self.placeholder_view();
827        }
828
829        let value: Vec<char> = self.value[self.offset..self.offset_right].to_vec();
830        let pos = self.pos.saturating_sub(self.offset);
831
832        let before: String = value[..pos.min(value.len())].iter().collect();
833        let mut v = self
834            .text_style
835            .clone()
836            .inline()
837            .render(&self.echo_transform(&before));
838
839        if pos < value.len() {
840            let char_at_cursor: String = value[pos..pos + 1].iter().collect();
841            let char_display = self.echo_transform(&char_at_cursor);
842
843            let mut cursor = self.cursor.clone();
844            cursor.set_char(&char_display);
845            v.push_str(&cursor.view());
846
847            let after: String = value[pos + 1..].iter().collect();
848            v.push_str(
849                &self
850                    .text_style
851                    .clone()
852                    .inline()
853                    .render(&self.echo_transform(&after)),
854            );
855            v.push_str(&self.completion_view(0));
856        } else if self.focus && self.can_accept_suggestion() {
857            if let Some(suggestion) = self.matched_suggestions.get(self.current_suggestion_index) {
858                if self.value.len() < suggestion.len() && self.pos < suggestion.len() {
859                    let mut cursor = self.cursor.clone();
860                    cursor.text_style = self.completion_style.clone();
861                    let char_display: String = suggestion[self.pos..self.pos + 1].iter().collect();
862                    cursor.set_char(&self.echo_transform(&char_display));
863                    v.push_str(&cursor.view());
864                    v.push_str(&self.completion_view(1));
865                } else {
866                    let mut cursor = self.cursor.clone();
867                    cursor.set_char(" ");
868                    v.push_str(&cursor.view());
869                }
870            }
871        } else {
872            let mut cursor = self.cursor.clone();
873            cursor.set_char(" ");
874            v.push_str(&cursor.view());
875        }
876
877        // Padding for width
878        if self.width > 0 {
879            let val_width: usize = value.iter().map(|c| c.width().unwrap_or(0)).sum();
880            if val_width <= self.width {
881                let padding = self.width.saturating_sub(val_width);
882                v.push_str(
883                    &self
884                        .text_style
885                        .clone()
886                        .inline()
887                        .render(&" ".repeat(padding)),
888                );
889            }
890        }
891
892        format!("{}{}", self.prompt_style.render(&self.prompt), v)
893    }
894
895    fn placeholder_view(&self) -> String {
896        let prompt = self.prompt_style.render(&self.prompt);
897
898        let mut cursor = self.cursor.clone();
899        cursor.text_style = self.placeholder_style.clone();
900
901        let first_char: String = self.placeholder.chars().take(1).collect();
902        let rest: String = self.placeholder.chars().skip(1).collect();
903
904        cursor.set_char(&first_char);
905        let v = cursor.view();
906
907        let styled_rest = self.placeholder_style.clone().inline().render(&rest);
908
909        format!("{}{}{}", prompt, v, styled_rest)
910    }
911
912    fn completion_view(&self, offset: usize) -> String {
913        if self.can_accept_suggestion()
914            && let Some(suggestion) = self.matched_suggestions.get(self.current_suggestion_index)
915            && self.value.len() + offset <= suggestion.len()
916        {
917            let completion: String = suggestion[self.value.len() + offset..].iter().collect();
918            return self.placeholder_style.clone().inline().render(&completion);
919        }
920        String::new()
921    }
922}
923
924impl Model for TextInput {
925    /// Initialize the text input.
926    ///
927    /// If focused and cursor is in blink mode, returns a blink command.
928    fn init(&self) -> Option<Cmd> {
929        if self.focus { Some(blink_cmd()) } else { None }
930    }
931
932    /// Update the text input state based on incoming messages.
933    ///
934    /// Handles:
935    /// - `KeyMsg` - Keyboard input for text entry and navigation
936    /// - `PasteMsg` - Paste operations
937    /// - Cursor blink messages
938    fn update(&mut self, msg: Message) -> Option<Cmd> {
939        TextInput::update(self, msg)
940    }
941
942    /// Render the text input.
943    fn view(&self) -> String {
944        TextInput::view(self)
945    }
946}
947
948#[cfg(test)]
949mod tests {
950    use super::*;
951
952    #[test]
953    fn test_textinput_new() {
954        let input = TextInput::new();
955        assert_eq!(input.prompt, "> ");
956        assert_eq!(input.echo_character, '*');
957        assert!(!input.focused());
958    }
959
960    #[test]
961    fn test_textinput_set_value() {
962        let mut input = TextInput::new();
963        input.set_value("hello");
964        assert_eq!(input.value(), "hello");
965    }
966
967    #[test]
968    fn test_textinput_cursor_position() {
969        let mut input = TextInput::new();
970        input.set_value("hello");
971        assert_eq!(input.position(), 5);
972
973        input.set_cursor(2);
974        assert_eq!(input.position(), 2);
975
976        input.cursor_start();
977        assert_eq!(input.position(), 0);
978
979        input.cursor_end();
980        assert_eq!(input.position(), 5);
981    }
982
983    #[test]
984    fn test_textinput_focus_blur() {
985        let mut input = TextInput::new();
986        assert!(!input.focused());
987
988        input.focus();
989        assert!(input.focused());
990
991        input.blur();
992        assert!(!input.focused());
993    }
994
995    #[test]
996    fn test_textinput_reset() {
997        let mut input = TextInput::new();
998        input.set_value("hello");
999        assert!(!input.value.is_empty());
1000
1001        input.reset();
1002        assert!(input.value.is_empty());
1003    }
1004
1005    #[test]
1006    fn test_textinput_reset_clears_error_and_suggestions() {
1007        let mut input = TextInput::new();
1008        input.show_suggestions = true;
1009        input.set_suggestions(&["apple", "apricot"]);
1010        input.set_validate(|v| (!v.is_empty()).then(|| "err".to_string()));
1011
1012        input.set_value("ap");
1013        assert!(input.err.is_some());
1014        assert!(!input.matched_suggestions().is_empty());
1015
1016        input.reset();
1017        assert!(input.err.is_none());
1018        assert!(input.matched_suggestions().is_empty());
1019    }
1020
1021    #[test]
1022    fn test_textinput_char_limit() {
1023        let mut input = TextInput::new();
1024        input.char_limit = 5;
1025        input.set_value("hello world");
1026        assert_eq!(input.value(), "hello");
1027    }
1028
1029    #[test]
1030    fn test_textinput_echo_mode() {
1031        let mut input = TextInput::new();
1032        input.set_value("secret");
1033
1034        assert_eq!(input.echo_transform("secret"), "secret");
1035
1036        input.echo_mode = EchoMode::Password;
1037        assert_eq!(input.echo_transform("secret"), "******");
1038
1039        input.echo_mode = EchoMode::None;
1040        assert_eq!(input.echo_transform("secret"), "");
1041    }
1042
1043    #[test]
1044    fn test_textinput_placeholder() {
1045        let mut input = TextInput::new();
1046        input.set_placeholder("Enter text...");
1047        assert_eq!(input.placeholder, "Enter text...");
1048    }
1049
1050    #[test]
1051    fn test_textinput_suggestions() {
1052        let mut input = TextInput::new();
1053        input.show_suggestions = true;
1054        input.set_suggestions(&["apple", "apricot", "banana"]);
1055        input.set_value("ap");
1056        input.update_suggestions();
1057
1058        assert_eq!(input.matched_suggestions().len(), 2);
1059        assert!(input.matched_suggestions().contains(&"apple".to_string()));
1060        assert!(input.matched_suggestions().contains(&"apricot".to_string()));
1061    }
1062
1063    #[test]
1064    fn test_textinput_set_value_updates_suggestions() {
1065        let mut input = TextInput::new();
1066        input.show_suggestions = true;
1067        input.set_suggestions(&["apple", "banana"]);
1068
1069        input.set_value("ap");
1070
1071        assert_eq!(input.matched_suggestions().len(), 1);
1072        assert!(input.matched_suggestions().contains(&"apple".to_string()));
1073    }
1074
1075    #[test]
1076    fn test_textinput_suggestion_overflow_uses_global_position() {
1077        let mut input = TextInput::new();
1078        input.width = 5;
1079        input.show_suggestions = true;
1080        input.set_value("abcdefghij");
1081        input.set_suggestions(&["abcdefghijZ"]);
1082        input.focus();
1083        input.cursor_end();
1084
1085        let view = input.view();
1086        assert!(
1087            view.contains("Z"),
1088            "Expected suggestion character to render at cursor when scrolled"
1089        );
1090    }
1091
1092    #[test]
1093    fn test_textinput_validation() {
1094        let mut input = TextInput::new();
1095        input.set_validate(|s| {
1096            if s.contains("bad") {
1097                Some("Contains bad word".to_string())
1098            } else {
1099                None
1100            }
1101        });
1102
1103        input.set_value("good");
1104        assert!(input.err.is_none());
1105
1106        input.set_value("bad");
1107        assert!(input.err.is_some());
1108    }
1109
1110    #[test]
1111    fn test_textinput_view() {
1112        let mut input = TextInput::new();
1113        input.set_value("hello");
1114        let view = input.view();
1115        assert!(view.contains("> "));
1116        assert!(view.contains("hello"));
1117    }
1118
1119    #[test]
1120    fn test_textinput_placeholder_view() {
1121        let mut input = TextInput::new();
1122        input.set_placeholder("Type here...");
1123        let view = input.view();
1124        assert!(view.contains("> "));
1125    }
1126
1127    #[test]
1128    fn test_keymap_default() {
1129        let km = KeyMap::default();
1130        assert!(!km.character_forward.get_keys().is_empty());
1131        assert!(!km.delete_character_backward.get_keys().is_empty());
1132    }
1133
1134    // Model trait implementation tests
1135    #[test]
1136    fn test_model_init_unfocused() {
1137        let input = TextInput::new();
1138        // Unfocused input should not return init command
1139        let cmd = Model::init(&input);
1140        assert!(cmd.is_none());
1141    }
1142
1143    #[test]
1144    fn test_model_init_focused() {
1145        let mut input = TextInput::new();
1146        input.focus();
1147        // Focused input should return init command for cursor blink
1148        let cmd = Model::init(&input);
1149        assert!(cmd.is_some());
1150    }
1151
1152    #[test]
1153    fn test_model_view() {
1154        let mut input = TextInput::new();
1155        input.set_value("test");
1156        // Model::view should return same result as TextInput::view
1157        let model_view = Model::view(&input);
1158        let textinput_view = TextInput::view(&input);
1159        assert_eq!(model_view, textinput_view);
1160    }
1161
1162    #[test]
1163    fn test_model_update_handles_paste_msg() {
1164        let mut input = TextInput::new();
1165        input.focus();
1166        assert_eq!(input.value(), "");
1167
1168        // Use Model::update to handle a paste message
1169        let paste_msg = Message::new(PasteMsg("hello world".to_string()));
1170        let _ = Model::update(&mut input, paste_msg);
1171
1172        assert_eq!(input.value(), "hello world");
1173    }
1174
1175    #[test]
1176    fn test_model_update_unfocused_ignores_input() {
1177        let mut input = TextInput::new();
1178        assert!(!input.focused());
1179        assert_eq!(input.value(), "");
1180
1181        // Unfocused input should ignore paste
1182        let paste_msg = Message::new(PasteMsg("ignored".to_string()));
1183        let _ = Model::update(&mut input, paste_msg);
1184
1185        assert_eq!(input.value(), "", "Unfocused input should ignore messages");
1186    }
1187
1188    #[test]
1189    fn test_model_update_handles_key_input() {
1190        let mut input = TextInput::new();
1191        input.focus();
1192        input.set_value("hello");
1193        assert_eq!(input.position(), 5);
1194
1195        // Create a left arrow key message to move cursor
1196        let key_msg = Message::new(KeyMsg {
1197            key_type: bubbletea::KeyType::Left,
1198            runes: vec![],
1199            alt: false,
1200            paste: false,
1201        });
1202        let _ = Model::update(&mut input, key_msg);
1203
1204        assert_eq!(input.position(), 4, "Cursor should have moved left");
1205    }
1206
1207    #[test]
1208    fn test_textinput_satisfies_model_bounds() {
1209        // Verify TextInput can be used where Model + Send + 'static is required
1210        fn accepts_model<M: Model + Send + 'static>(_model: M) {}
1211        let input = TextInput::new();
1212        accepts_model(input);
1213    }
1214
1215    #[test]
1216    fn test_word_backward_boundary() {
1217        let mut input = TextInput::new();
1218        input.set_value("abc");
1219        input.set_cursor(1); // "a|bc" (cursor is at 1, so 'b' is to the right, 'a' to the left)
1220        input.word_backward();
1221        assert_eq!(input.position(), 0); // Should move to start
1222    }
1223
1224    #[test]
1225    fn test_delete_word_backward_boundary() {
1226        let mut input = TextInput::new();
1227        input.set_value("abc");
1228        input.set_cursor(1); // "a|bc"
1229        input.delete_word_backward();
1230        assert_eq!(input.value(), "bc");
1231        assert_eq!(input.position(), 0);
1232    }
1233
1234    #[test]
1235    fn test_handle_overflow_wide_chars() {
1236        let mut input = TextInput::new();
1237        input.width = 3;
1238        input.set_value("aπŸ˜€b"); // 'a' (1), 'πŸ˜€' (2), 'b' (1). Total 4.
1239        // pos=0. offset=0. offset_right=?
1240        // w=0. 'a'(1) -> w=1. 'πŸ˜€'(2) -> w=3. 'b'(1) -> w=4 > 3. Break.
1241        // offset_right should be 2 ("aπŸ˜€").
1242
1243        // Force overflow update
1244        input.set_cursor(0);
1245        // internal update triggers handle_overflow
1246
1247        // Can't check internal state easily without exposing or deducing from view
1248        // But let's check view length
1249        let view = input.view();
1250        // view should contain "aπŸ˜€" (char count 2) or something fitting width 3.
1251        // But view strips ANSI from result to check?
1252        // Let's rely on logic correctness.
1253        // width=3. "aπŸ˜€" is width 3. "aπŸ˜€b" is 4.
1254        // So it should show "aπŸ˜€".
1255        assert!(view.contains("aπŸ˜€"));
1256        assert!(!view.contains("b")); // 'b' is clipped
1257    }
1258
1259    #[test]
1260    fn test_delete_word_backward_on_whitespace() {
1261        let mut input = TextInput::new();
1262        input.set_value("abc   ");
1263        input.set_cursor(6); // At end
1264
1265        input.delete_word_backward();
1266
1267        // Current implementation: deletes whitespace AND word -> ""
1268        // "Standard" (bash) behavior: deletes whitespace -> "abc"
1269        // Let's see what it does.
1270        assert_eq!(
1271            input.value(),
1272            "",
1273            "Aggressive deletion: deleted both whitespace and word"
1274        );
1275    }
1276
1277    // === Bracketed Paste Tests ===
1278    // These tests verify paste behavior when receiving KeyMsg with paste=true,
1279    // which is how terminals deliver bracketed paste sequences.
1280
1281    #[test]
1282    fn test_bracketed_paste_basic() {
1283        let mut input = TextInput::new();
1284        input.focus();
1285
1286        // Simulate bracketed paste: KeyMsg with paste=true and runes
1287        let key_msg = Message::new(KeyMsg {
1288            key_type: bubbletea::KeyType::Runes,
1289            runes: vec!['h', 'e', 'l', 'l', 'o'],
1290            alt: false,
1291            paste: true,
1292        });
1293        let _ = Model::update(&mut input, key_msg);
1294
1295        assert_eq!(input.value(), "hello");
1296    }
1297
1298    #[test]
1299    fn test_bracketed_paste_multiline_converts_newlines() {
1300        let mut input = TextInput::new();
1301        input.focus();
1302
1303        // Paste with newlines - should be converted to spaces for single-line input
1304        let key_msg = Message::new(KeyMsg {
1305            key_type: bubbletea::KeyType::Runes,
1306            runes: "line1\nline2\nline3".chars().collect(),
1307            alt: false,
1308            paste: true,
1309        });
1310        let _ = Model::update(&mut input, key_msg);
1311
1312        assert_eq!(
1313            input.value(),
1314            "line1 line2 line3",
1315            "Newlines should be converted to spaces in single-line input"
1316        );
1317    }
1318
1319    #[test]
1320    fn test_bracketed_paste_crlf_converts_to_space() {
1321        let mut input = TextInput::new();
1322        input.focus();
1323
1324        // Windows-style CRLF should also be converted
1325        let key_msg = Message::new(KeyMsg {
1326            key_type: bubbletea::KeyType::Runes,
1327            runes: "line1\r\nline2".chars().collect(),
1328            alt: false,
1329            paste: true,
1330        });
1331        let _ = Model::update(&mut input, key_msg);
1332
1333        assert_eq!(
1334            input.value(),
1335            "line1 line2",
1336            "CRLF should be converted to single space"
1337        );
1338    }
1339
1340    #[test]
1341    fn test_bracketed_paste_respects_char_limit() {
1342        let mut input = TextInput::new();
1343        input.focus();
1344        input.char_limit = 10;
1345
1346        // Try to paste more than the limit
1347        let key_msg = Message::new(KeyMsg {
1348            key_type: bubbletea::KeyType::Runes,
1349            runes: "this is a very long paste that exceeds the limit"
1350                .chars()
1351                .collect(),
1352            alt: false,
1353            paste: true,
1354        });
1355        let _ = Model::update(&mut input, key_msg);
1356
1357        assert_eq!(
1358            input.value().len(),
1359            10,
1360            "Paste should be truncated at char_limit"
1361        );
1362        assert_eq!(input.value(), "this is a ");
1363    }
1364
1365    #[test]
1366    fn test_bracketed_paste_respects_remaining_capacity() {
1367        let mut input = TextInput::new();
1368        input.focus();
1369        input.char_limit = 15;
1370        input.set_value("hello ");
1371
1372        // Paste should only insert up to the remaining capacity
1373        let key_msg = Message::new(KeyMsg {
1374            key_type: bubbletea::KeyType::Runes,
1375            runes: "world and more text".chars().collect(),
1376            alt: false,
1377            paste: true,
1378        });
1379        let _ = Model::update(&mut input, key_msg);
1380
1381        assert_eq!(input.value().len(), 15);
1382        assert_eq!(input.value(), "hello world and");
1383    }
1384
1385    #[test]
1386    fn test_bracketed_paste_at_full_capacity_ignored() {
1387        let mut input = TextInput::new();
1388        input.focus();
1389        input.char_limit = 5;
1390        input.set_value("hello");
1391
1392        // Input is at capacity, paste should be ignored
1393        let key_msg = Message::new(KeyMsg {
1394            key_type: bubbletea::KeyType::Runes,
1395            runes: "world".chars().collect(),
1396            alt: false,
1397            paste: true,
1398        });
1399        let _ = Model::update(&mut input, key_msg);
1400
1401        assert_eq!(
1402            input.value(),
1403            "hello",
1404            "Paste at full capacity should be ignored"
1405        );
1406    }
1407
1408    #[test]
1409    fn test_bracketed_paste_unfocused_ignored() {
1410        let mut input = TextInput::new();
1411        // Not focused!
1412        assert_eq!(input.value(), "");
1413
1414        let key_msg = Message::new(KeyMsg {
1415            key_type: bubbletea::KeyType::Runes,
1416            runes: "ignored".chars().collect(),
1417            alt: false,
1418            paste: true,
1419        });
1420        let _ = Model::update(&mut input, key_msg);
1421
1422        assert_eq!(input.value(), "", "Unfocused input should ignore paste");
1423    }
1424
1425    #[test]
1426    fn test_bracketed_paste_inserts_at_cursor() {
1427        let mut input = TextInput::new();
1428        input.focus();
1429        input.set_value("helloworld");
1430        input.set_cursor(5); // Position after "hello"
1431
1432        let key_msg = Message::new(KeyMsg {
1433            key_type: bubbletea::KeyType::Runes,
1434            runes: " ".chars().collect(),
1435            alt: false,
1436            paste: true,
1437        });
1438        let _ = Model::update(&mut input, key_msg);
1439
1440        assert_eq!(input.value(), "hello world");
1441        assert_eq!(input.position(), 6, "Cursor should be after pasted content");
1442    }
1443
1444    #[test]
1445    fn test_bracketed_paste_strips_control_chars() {
1446        let mut input = TextInput::new();
1447        input.focus();
1448
1449        // Paste with control characters that should be stripped
1450        let key_msg = Message::new(KeyMsg {
1451            key_type: bubbletea::KeyType::Runes,
1452            runes: "hello\x01\x02world".chars().collect(),
1453            alt: false,
1454            paste: true,
1455        });
1456        let _ = Model::update(&mut input, key_msg);
1457
1458        assert_eq!(
1459            input.value(),
1460            "helloworld",
1461            "Control characters should be stripped"
1462        );
1463    }
1464
1465    #[test]
1466    fn test_bracketed_paste_preserves_unicode() {
1467        let mut input = TextInput::new();
1468        input.focus();
1469
1470        let key_msg = Message::new(KeyMsg {
1471            key_type: bubbletea::KeyType::Runes,
1472            runes: "hello δΈ–η•Œ 🌍".chars().collect(),
1473            alt: false,
1474            paste: true,
1475        });
1476        let _ = Model::update(&mut input, key_msg);
1477
1478        assert_eq!(input.value(), "hello δΈ–η•Œ 🌍");
1479    }
1480
1481    #[test]
1482    fn test_bracketed_paste_tabs_to_spaces() {
1483        let mut input = TextInput::new();
1484        input.focus();
1485
1486        // Tabs should be converted to spaces
1487        let key_msg = Message::new(KeyMsg {
1488            key_type: bubbletea::KeyType::Runes,
1489            runes: "col1\tcol2".chars().collect(),
1490            alt: false,
1491            paste: true,
1492        });
1493        let _ = Model::update(&mut input, key_msg);
1494
1495        assert_eq!(
1496            input.value(),
1497            "col1 col2",
1498            "Tabs should be converted to single space"
1499        );
1500    }
1501
1502    #[test]
1503    fn test_set_value_validates_after_truncation() {
1504        let mut input = TextInput::new();
1505        input.char_limit = 3;
1506        // Validator fails if length > 3
1507        input.set_validate(|s| {
1508            if s.len() > 3 {
1509                Some("Too long".to_string())
1510            } else {
1511                None
1512            }
1513        });
1514
1515        // "1234" -> truncated to "123"
1516        // Validation should see "123", which is valid
1517        input.set_value("1234");
1518
1519        assert_eq!(input.value(), "123");
1520        assert!(
1521            input.err.is_none(),
1522            "Validation should run on truncated value"
1523        );
1524    }
1525}