use std::{
borrow::Cow,
ops::{Deref, DerefMut},
};
use ratatui::{
buffer::Buffer,
layout::{Constraint, Layout, Rect},
style::{Modifier, Style},
text::Text,
widgets::{Block, Borders, Paragraph, Widget},
};
use regex::Regex;
use tui_textarea::{CursorMove, TextArea};
use crate::utils::remove_newlines;
const SPINNER_CHARS: [char; 6] = ['✸', '✷', '✹', '✺', '✹', '✷'];
const DEFAULT_STYLE: Style = Style::new();
#[derive(Clone)]
pub struct CustomTextArea<'a> {
inline: bool,
inline_title: Option<Text<'a>>,
textarea: TextArea<'a>,
cursor_style: Style,
focus: bool,
multiline: bool,
ai_loading: bool,
spinner_state: usize,
original_title: Option<Cow<'a, str>>,
forbidden_chars_regex: Option<Regex>,
}
impl<'a> CustomTextArea<'a> {
pub fn new(style: impl Into<Style>, inline: bool, multiline: bool, text: impl Into<String>) -> Self {
let style = style.into();
let cursor_style = style.add_modifier(Modifier::REVERSED);
let cursor_line_style = style;
let text = text.into();
let mut textarea = if multiline {
TextArea::from(
text.split('\n')
.map(|s| s.strip_suffix('\r').unwrap_or(s).to_string())
.collect::<Vec<_>>(),
)
} else {
TextArea::from([remove_newlines(text)])
};
textarea.set_style(style);
textarea.set_cursor_style(DEFAULT_STYLE);
textarea.set_cursor_line_style(cursor_line_style);
textarea.move_cursor(CursorMove::Jump(u16::MAX, u16::MAX));
if !inline {
textarea.set_block(Block::default().borders(Borders::ALL).style(style));
}
Self {
inline,
inline_title: None,
textarea,
cursor_style,
focus: false,
multiline,
ai_loading: false,
spinner_state: 0,
original_title: None,
forbidden_chars_regex: None,
}
}
pub fn title(mut self, title: impl Into<Cow<'a, str>>) -> Self {
self.set_title(title);
self
}
pub fn forbidden_chars_regex(mut self, regex: Regex) -> Self {
self.set_forbidden_chars_regex(regex);
self
}
pub fn focused(mut self) -> Self {
self.set_focus(true);
self
}
pub fn secret(mut self, secret: bool) -> Self {
self.set_secret(secret);
self
}
pub fn is_multiline(&self) -> bool {
self.multiline
}
pub fn is_focused(&self) -> bool {
self.focus
}
pub fn set_secret(&mut self, secret: bool) {
if secret {
self.textarea.set_mask_char('●');
} else {
self.textarea.clear_mask_char();
}
}
pub fn set_focus(&mut self, focus: bool) {
if focus != self.focus {
self.focus = focus;
if self.focus {
self.textarea.set_cursor_style(self.cursor_style);
} else {
self.textarea.set_cursor_style(DEFAULT_STYLE);
}
}
}
pub fn set_title(&mut self, new_title: impl Into<Cow<'a, str>>) {
let new_title = new_title.into();
self.original_title = Some(new_title.clone());
let style = self.textarea.style();
if self.inline {
self.inline_title = Some(Text::from(new_title).style(style));
} else {
let title_content = if self.ai_loading {
let spinner_char = SPINNER_CHARS[self.spinner_state];
Cow::from(format!("{new_title}{spinner_char} "))
} else {
new_title
};
let new_block = Block::default().borders(Borders::ALL).style(style).title(title_content);
self.textarea.set_block(new_block);
}
}
pub fn set_forbidden_chars_regex(&mut self, regex: Regex) {
self.forbidden_chars_regex = Some(regex);
}
pub fn set_style(&mut self, style: impl Into<Style>) {
let style = style.into();
self.cursor_style = style.add_modifier(Modifier::REVERSED);
self.textarea.set_style(style);
self.textarea
.set_cursor_style(if self.focus { self.cursor_style } else { DEFAULT_STYLE });
self.textarea.set_cursor_line_style(style);
if let Some(ref mut inline_title) = self.inline_title {
*inline_title = inline_title.clone().style(style);
} else if let Some(block) = self.textarea.block().cloned() {
self.textarea.set_block(block.style(style));
}
}
pub fn set_ai_loading(&mut self, loading: bool) {
self.ai_loading = loading;
if !loading {
self.spinner_state = 0;
if !self.inline
&& let Some(title) = self.original_title.clone()
{
let style = self.textarea.style();
let new_block = Block::default().borders(Borders::ALL).style(style).title(title);
self.textarea.set_block(new_block);
}
}
}
pub fn is_ai_loading(&self) -> bool {
self.ai_loading
}
pub fn tick(&mut self) {
if self.ai_loading {
self.spinner_state = (self.spinner_state + 1) % SPINNER_CHARS.len();
if !self.inline
&& let Some(title) = &self.original_title
{
let style = self.textarea.style();
let spinner_char = SPINNER_CHARS[self.spinner_state];
let new_title = format!("{title}{spinner_char} ");
let new_block = Block::default().borders(Borders::ALL).style(style).title(new_title);
self.textarea.set_block(new_block);
}
}
}
pub fn lines_as_string(&self) -> String {
self.textarea.lines().join("\n")
}
pub fn move_cursor_left(&mut self, word: bool) {
if self.focus && !self.ai_loading {
self.textarea
.move_cursor(if word { CursorMove::WordBack } else { CursorMove::Back });
}
}
pub fn move_cursor_right(&mut self, word: bool) {
if self.focus && !self.ai_loading {
self.textarea.move_cursor(if word {
CursorMove::WordForward
} else {
CursorMove::Forward
});
}
}
pub fn move_home(&mut self, absolute: bool) {
if self.focus && !self.ai_loading {
self.textarea.move_cursor(if absolute {
CursorMove::Jump(0, 0)
} else {
CursorMove::Head
});
}
}
pub fn move_end(&mut self, absolute: bool) {
if self.focus && !self.ai_loading {
self.textarea.move_cursor(if absolute {
CursorMove::Jump(u16::MAX, u16::MAX)
} else {
CursorMove::End
});
}
}
pub fn insert_char(&mut self, c: char) {
if self.focus && !self.ai_loading && (self.multiline || c != '\n') {
if let Some(ref regex) = self.forbidden_chars_regex {
let mut buf = [0u8; 4];
let char_str = c.encode_utf8(&mut buf);
if regex.is_match(char_str) {
return;
}
}
self.textarea.insert_char(c);
}
}
pub fn insert_str<S>(&mut self, text: S)
where
S: AsRef<str>,
{
if self.focus && !self.ai_loading {
let text_to_insert = if let Some(ref regex) = self.forbidden_chars_regex {
regex.replace_all(text.as_ref(), "")
} else {
Cow::Borrowed(text.as_ref())
};
if self.multiline {
self.textarea.insert_str(text_to_insert);
} else {
self.textarea.insert_str(remove_newlines(text_to_insert.as_ref()));
};
}
}
pub fn insert_newline(&mut self) {
if self.focus && !self.ai_loading && self.multiline {
self.textarea.insert_newline();
}
}
pub fn delete(&mut self, backspace: bool, word: bool) {
if self.focus && !self.ai_loading {
match (backspace, word) {
(true, true) => self.textarea.delete_word(),
(true, false) => self.textarea.delete_char(),
(false, true) => self.textarea.delete_next_word(),
(false, false) => self.textarea.delete_next_char(),
};
}
}
}
impl<'a> Widget for &CustomTextArea<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
if let Some(ref inline_title) = self.inline_title {
if self.ai_loading {
let layout = Layout::horizontal([
Constraint::Length(inline_title.width() as u16 + 1),
Constraint::Length(3),
Constraint::Min(1),
]);
let [title_area, spinner_area, textarea_area] = layout.areas(area);
inline_title.render(title_area, buf);
let spinner_char = SPINNER_CHARS[self.spinner_state];
let spinner_widget = Paragraph::new(format!("{spinner_char} ")).style(self.textarea.style());
spinner_widget.render(spinner_area, buf);
self.textarea.render(textarea_area, buf);
} else {
let layout =
Layout::horizontal([Constraint::Length(inline_title.width() as u16 + 1), Constraint::Min(1)]);
let [title_area, textarea_area] = layout.areas(area);
inline_title.render(title_area, buf);
self.textarea.render(textarea_area, buf);
}
} else {
self.textarea.render(area, buf);
}
}
}
impl<'a> Deref for CustomTextArea<'a> {
type Target = TextArea<'a>;
fn deref(&self) -> &Self::Target {
&self.textarea
}
}
impl<'a> DerefMut for CustomTextArea<'a> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.textarea
}
}