intelli_shell/widgets/
textarea.rs

1use std::{
2    borrow::Cow,
3    ops::{Deref, DerefMut},
4};
5
6use ratatui::{
7    buffer::Buffer,
8    layout::{Constraint, Layout, Rect},
9    style::{Modifier, Style},
10    text::Text,
11    widgets::{Block, Borders, Paragraph, Widget},
12};
13use regex::Regex;
14use tui_textarea::{CursorMove, TextArea};
15
16use crate::utils::remove_newlines;
17
18const SPINNER_CHARS: [char; 6] = ['✸', '✷', '✹', '✺', '✹', '✷'];
19const DEFAULT_STYLE: Style = Style::new();
20
21/// A custom text area widget
22#[derive(Clone)]
23pub struct CustomTextArea<'a> {
24    inline: bool,
25    inline_title: Option<Text<'a>>,
26    textarea: TextArea<'a>,
27    cursor_style: Style,
28    focus: bool,
29    multiline: bool,
30    ai_loading: bool,
31    spinner_state: usize,
32    original_title: Option<Cow<'a, str>>,
33    forbidden_chars_regex: Option<Regex>,
34}
35
36impl<'a> CustomTextArea<'a> {
37    /// Creates a new custom text area
38    pub fn new(style: impl Into<Style>, inline: bool, multiline: bool, text: impl Into<String>) -> Self {
39        let style = style.into();
40        let cursor_style = style.add_modifier(Modifier::REVERSED);
41        let cursor_line_style = style;
42
43        let text = text.into();
44        let mut textarea = if multiline {
45            TextArea::from(
46                text.split('\n')
47                    .map(|s| s.strip_suffix('\r').unwrap_or(s).to_string())
48                    .collect::<Vec<_>>(),
49            )
50        } else {
51            TextArea::from([remove_newlines(text)])
52        };
53        textarea.set_style(style);
54        textarea.set_cursor_style(DEFAULT_STYLE);
55        textarea.set_cursor_line_style(cursor_line_style);
56        textarea.move_cursor(CursorMove::Jump(u16::MAX, u16::MAX));
57        if !inline {
58            textarea.set_block(Block::default().borders(Borders::ALL).style(style));
59        }
60
61        Self {
62            inline,
63            inline_title: None,
64            textarea,
65            cursor_style,
66            focus: false,
67            multiline,
68            ai_loading: false,
69            spinner_state: 0,
70            original_title: None,
71            forbidden_chars_regex: None,
72        }
73    }
74
75    /// Updates the title of the text area
76    pub fn title(mut self, title: impl Into<Cow<'a, str>>) -> Self {
77        self.set_title(title);
78        self
79    }
80
81    /// Updates the regex to filter out forbidden characters
82    pub fn forbidden_chars_regex(mut self, regex: Regex) -> Self {
83        self.set_forbidden_chars_regex(regex);
84        self
85    }
86
87    /// Updates the text area to be focused
88    pub fn focused(mut self) -> Self {
89        self.set_focus(true);
90        self
91    }
92
93    /// Updates the textarea mask char
94    pub fn secret(mut self, secret: bool) -> Self {
95        self.set_secret(secret);
96        self
97    }
98
99    /// Returns whether the text area supports multiple lines or not
100    pub fn is_multiline(&self) -> bool {
101        self.multiline
102    }
103
104    /// Returns whether the text area is currently focused or not
105    pub fn is_focused(&self) -> bool {
106        self.focus
107    }
108
109    /// Sets or clear the the text area mask char
110    pub fn set_secret(&mut self, secret: bool) {
111        if secret {
112            self.textarea.set_mask_char('●');
113        } else {
114            self.textarea.clear_mask_char();
115        }
116    }
117
118    /// Sets the focus state of the text area
119    pub fn set_focus(&mut self, focus: bool) {
120        if focus != self.focus {
121            self.focus = focus;
122            if self.focus {
123                self.textarea.set_cursor_style(self.cursor_style);
124            } else {
125                self.textarea.set_cursor_style(DEFAULT_STYLE);
126            }
127        }
128    }
129
130    /// Updates the title of the text area
131    pub fn set_title(&mut self, new_title: impl Into<Cow<'a, str>>) {
132        let new_title = new_title.into();
133        self.original_title = Some(new_title.clone());
134        let style = self.textarea.style();
135
136        if self.inline {
137            self.inline_title = Some(Text::from(new_title).style(style));
138        } else {
139            let title_content = if self.ai_loading {
140                let spinner_char = SPINNER_CHARS[self.spinner_state];
141                Cow::from(format!("{new_title}{spinner_char}  "))
142            } else {
143                new_title
144            };
145            let new_block = Block::default().borders(Borders::ALL).style(style).title(title_content);
146            self.textarea.set_block(new_block);
147        }
148    }
149
150    /// Updates the regex to filter out forbidden characters
151    pub fn set_forbidden_chars_regex(&mut self, regex: Regex) {
152        self.forbidden_chars_regex = Some(regex);
153    }
154
155    /// Updates the style of this text area
156    pub fn set_style(&mut self, style: impl Into<Style>) {
157        let style = style.into();
158        self.cursor_style = style.add_modifier(Modifier::REVERSED);
159
160        self.textarea.set_style(style);
161        self.textarea
162            .set_cursor_style(if self.focus { self.cursor_style } else { DEFAULT_STYLE });
163        self.textarea.set_cursor_line_style(style);
164
165        if let Some(ref mut inline_title) = self.inline_title {
166            *inline_title = inline_title.clone().style(style);
167        } else if let Some(block) = self.textarea.block().cloned() {
168            self.textarea.set_block(block.style(style));
169        }
170    }
171
172    /// Sets the AI loading state, showing or hiding a spinner
173    pub fn set_ai_loading(&mut self, loading: bool) {
174        self.ai_loading = loading;
175        if !loading {
176            self.spinner_state = 0;
177            if !self.inline
178                && let Some(title) = self.original_title.clone()
179            {
180                let style = self.textarea.style();
181                let new_block = Block::default().borders(Borders::ALL).style(style).title(title);
182                self.textarea.set_block(new_block);
183            }
184        }
185    }
186
187    /// Checks if the textarea has the ai loading state
188    pub fn is_ai_loading(&self) -> bool {
189        self.ai_loading
190    }
191
192    /// Advances the spinner animation if active
193    pub fn tick(&mut self) {
194        if self.ai_loading {
195            self.spinner_state = (self.spinner_state + 1) % SPINNER_CHARS.len();
196            if !self.inline
197                && let Some(title) = &self.original_title
198            {
199                let style = self.textarea.style();
200                let spinner_char = SPINNER_CHARS[self.spinner_state];
201                let new_title = format!("{title}{spinner_char}  ");
202                let new_block = Block::default().borders(Borders::ALL).style(style).title(new_title);
203                self.textarea.set_block(new_block);
204            }
205        }
206    }
207
208    /// Retrieves the current text in the text area as a single string
209    pub fn lines_as_string(&self) -> String {
210        self.textarea.lines().join("\n")
211    }
212
213    /// Moves the cursor to the left, optionally by word
214    pub fn move_cursor_left(&mut self, word: bool) {
215        if self.focus && !self.ai_loading {
216            self.textarea
217                .move_cursor(if word { CursorMove::WordBack } else { CursorMove::Back });
218        }
219    }
220
221    /// Moves the cursor to the right, optionally by word
222    pub fn move_cursor_right(&mut self, word: bool) {
223        if self.focus && !self.ai_loading {
224            self.textarea.move_cursor(if word {
225                CursorMove::WordForward
226            } else {
227                CursorMove::Forward
228            });
229        }
230    }
231
232    /// Moves the cursor to the head of the line, or the absolute head
233    pub fn move_home(&mut self, absolute: bool) {
234        if self.focus && !self.ai_loading {
235            self.textarea.move_cursor(if absolute {
236                CursorMove::Jump(0, 0)
237            } else {
238                CursorMove::Head
239            });
240        }
241    }
242
243    /// Moves the cursor to the end of the line, or the absolute end
244    pub fn move_end(&mut self, absolute: bool) {
245        if self.focus && !self.ai_loading {
246            self.textarea.move_cursor(if absolute {
247                CursorMove::Jump(u16::MAX, u16::MAX)
248            } else {
249                CursorMove::End
250            });
251        }
252    }
253
254    /// Inserts a char at the current cursor position
255    pub fn insert_char(&mut self, c: char) {
256        if self.focus && !self.ai_loading && (self.multiline || c != '\n') {
257            if let Some(ref regex) = self.forbidden_chars_regex {
258                let mut buf = [0u8; 4];
259                let char_str = c.encode_utf8(&mut buf);
260                // If the character matches the forbidden regex, skip the insertion
261                if regex.is_match(char_str) {
262                    return;
263                }
264            }
265            self.textarea.insert_char(c);
266        }
267    }
268
269    /// Inserts a text at the current cursor position
270    pub fn insert_str<S>(&mut self, text: S)
271    where
272        S: AsRef<str>,
273    {
274        if self.focus && !self.ai_loading {
275            let text_to_insert = if let Some(ref regex) = self.forbidden_chars_regex {
276                // Filter the input string to remove the forbidden characters matching the regex
277                regex.replace_all(text.as_ref(), "")
278            } else {
279                Cow::Borrowed(text.as_ref())
280            };
281
282            if self.multiline {
283                self.textarea.insert_str(text_to_insert);
284            } else {
285                self.textarea.insert_str(remove_newlines(text_to_insert.as_ref()));
286            };
287        }
288    }
289
290    /// Inserts a newline at the current cursor position, if multiline is enabled
291    pub fn insert_newline(&mut self) {
292        if self.focus && !self.ai_loading && self.multiline {
293            self.textarea.insert_newline();
294        }
295    }
296
297    /// Delete characters at the cursor position based on the backspace and word flags
298    pub fn delete(&mut self, backspace: bool, word: bool) {
299        if self.focus && !self.ai_loading {
300            match (backspace, word) {
301                (true, true) => self.textarea.delete_word(),
302                (true, false) => self.textarea.delete_char(),
303                (false, true) => self.textarea.delete_next_word(),
304                (false, false) => self.textarea.delete_next_char(),
305            };
306        }
307    }
308}
309
310impl<'a> Widget for &CustomTextArea<'a> {
311    fn render(self, area: Rect, buf: &mut Buffer) {
312        if let Some(ref inline_title) = self.inline_title {
313            if self.ai_loading {
314                let layout = Layout::horizontal([
315                    Constraint::Length(inline_title.width() as u16 + 1),
316                    Constraint::Length(3),
317                    Constraint::Min(1),
318                ]);
319                let [title_area, spinner_area, textarea_area] = layout.areas(area);
320
321                inline_title.render(title_area, buf);
322
323                let spinner_char = SPINNER_CHARS[self.spinner_state];
324                let spinner_widget = Paragraph::new(format!("{spinner_char}  ")).style(self.textarea.style());
325                spinner_widget.render(spinner_area, buf);
326
327                self.textarea.render(textarea_area, buf);
328            } else {
329                let layout =
330                    Layout::horizontal([Constraint::Length(inline_title.width() as u16 + 1), Constraint::Min(1)]);
331                let [title_area, textarea_area] = layout.areas(area);
332                inline_title.render(title_area, buf);
333                self.textarea.render(textarea_area, buf);
334            }
335        } else {
336            self.textarea.render(area, buf);
337        }
338    }
339}
340
341impl<'a> Deref for CustomTextArea<'a> {
342    type Target = TextArea<'a>;
343
344    fn deref(&self) -> &Self::Target {
345        &self.textarea
346    }
347}
348
349impl<'a> DerefMut for CustomTextArea<'a> {
350    fn deref_mut(&mut self) -> &mut Self::Target {
351        &mut self.textarea
352    }
353}