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#[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 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 pub fn title(mut self, title: impl Into<Cow<'a, str>>) -> Self {
77 self.set_title(title);
78 self
79 }
80
81 pub fn forbidden_chars_regex(mut self, regex: Regex) -> Self {
83 self.set_forbidden_chars_regex(regex);
84 self
85 }
86
87 pub fn focused(mut self) -> Self {
89 self.set_focus(true);
90 self
91 }
92
93 pub fn secret(mut self, secret: bool) -> Self {
95 self.set_secret(secret);
96 self
97 }
98
99 pub fn is_multiline(&self) -> bool {
101 self.multiline
102 }
103
104 pub fn is_focused(&self) -> bool {
106 self.focus
107 }
108
109 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 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 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 pub fn set_forbidden_chars_regex(&mut self, regex: Regex) {
152 self.forbidden_chars_regex = Some(regex);
153 }
154
155 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 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 pub fn is_ai_loading(&self) -> bool {
189 self.ai_loading
190 }
191
192 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 pub fn lines_as_string(&self) -> String {
210 self.textarea.lines().join("\n")
211 }
212
213 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 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 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 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 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 regex.is_match(char_str) {
262 return;
263 }
264 }
265 self.textarea.insert_char(c);
266 }
267 }
268
269 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 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 pub fn insert_newline(&mut self) {
292 if self.focus && !self.ai_loading && self.multiline {
293 self.textarea.insert_newline();
294 }
295 }
296
297 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}