use color_eyre::Result;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{
layout::Rect,
style::{Color, Modifier, Style},
widgets::Widget,
};
use tui_textarea::{Input, Key, TextArea};
use crate::cache::CacheManager;
use crate::config::Theme;
use super::text_input_common::{add_to_history, load_history_impl, save_history_impl};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TextInputEvent {
None,
Submit, Cancel, HistoryChanged, }
pub struct MultiLineTextInput {
textarea: TextArea<'static>,
pub value: String,
pub cursor: usize,
pub history_id: Option<String>,
pub history: Vec<String>,
pub history_index: Option<usize>,
pub history_temp: Option<String>,
pub history_limit: usize,
pub history_loaded: bool,
pub scroll_offset: usize, pub horizontal_scroll_offset: usize, pub cursor_line: usize, pub cursor_col: usize, text_color: Option<Color>,
cursor_bg: Option<Color>,
cursor_fg: Option<Color>,
background_color: Option<Color>,
cursor_focused: Option<Color>, focused: bool, }
impl MultiLineTextInput {
pub fn new() -> Self {
let mut textarea = TextArea::default();
use ratatui::style::Style;
textarea.set_cursor_line_style(Style::default());
let mut widget = Self {
textarea,
value: String::new(),
cursor: 0,
history_id: None,
history: Vec::new(),
history_index: None,
history_temp: None,
history_limit: 1000,
history_loaded: false,
scroll_offset: 0,
horizontal_scroll_offset: 0,
cursor_line: 0,
cursor_col: 0,
text_color: None,
cursor_bg: None,
cursor_fg: None,
background_color: None,
cursor_focused: None,
focused: false,
};
widget.apply_colors_to_textarea();
widget
}
#[allow(deprecated)]
pub fn with_style(mut self, text_color: Color, cursor_bg: Color, cursor_fg: Color) -> Self {
self.text_color = Some(text_color);
self.cursor_bg = Some(cursor_bg);
self.cursor_fg = Some(cursor_fg);
self.apply_colors_to_textarea();
self
}
pub fn with_text_color(mut self, color: Color) -> Self {
self.text_color = Some(color);
self.apply_colors_to_textarea();
self
}
#[deprecated(note = "Cursor colors are now automatically reversed from text/background colors")]
pub fn with_cursor_colors(mut self, bg: Color, fg: Color) -> Self {
self.cursor_bg = Some(bg);
self.cursor_fg = Some(fg);
self
}
pub fn with_background(mut self, color: Color) -> Self {
self.background_color = Some(color);
self.apply_colors_to_textarea();
self
}
pub fn with_theme(mut self, theme: &Theme) -> Self {
let text_primary = theme.get("text_primary");
self.text_color = Some(text_primary);
self.cursor_focused = Some(theme.get("cursor_focused"));
self.apply_colors_to_textarea();
self
}
pub fn with_history(mut self, history_id: String) -> Self {
self.history_id = Some(history_id);
self
}
pub fn with_history_limit(mut self, limit: usize) -> Self {
self.history_limit = limit;
self
}
pub fn set_focused(&mut self, focused: bool) {
self.focused = focused;
if focused {
let cursor_color = self.cursor_focused.unwrap_or(Color::Reset);
let cursor_style = if cursor_color == Color::Reset {
Style::default().add_modifier(Modifier::REVERSED)
} else {
let text_color = match cursor_color {
Color::White => Color::Black,
Color::Black => Color::White,
Color::Red => Color::White,
Color::Green => Color::Black,
Color::Yellow => Color::Black,
Color::Blue => Color::White,
Color::Magenta => Color::White,
Color::Cyan => Color::Black,
Color::Gray => Color::Black,
Color::DarkGray => Color::White,
Color::LightRed => Color::Black,
Color::LightGreen => Color::Black,
Color::LightYellow => Color::Black,
Color::LightBlue => Color::Black,
Color::LightMagenta => Color::Black,
Color::LightCyan => Color::Black,
_ => Color::Black,
};
Style::default().bg(cursor_color).fg(text_color)
};
self.textarea.set_cursor_style(cursor_style);
} else {
let textarea_style = self.textarea.style();
self.textarea.set_cursor_style(textarea_style);
}
}
pub fn value(&self) -> &str {
&self.value
}
fn sync_from_textarea(&mut self) {
let lines = self.textarea.lines();
self.value = lines.join("\n");
let (line, col) = self.textarea.cursor();
self.cursor_line = line;
self.cursor_col = col;
let mut char_pos = 0;
for (i, line_text) in lines.iter().enumerate() {
if i < self.cursor_line {
char_pos += line_text.chars().count() + 1; } else if i == self.cursor_line {
char_pos += self.cursor_col;
break;
}
}
self.cursor = char_pos;
}
fn apply_colors_to_textarea(&mut self) {
let mut style = Style::default();
if let Some(text_color) = self.text_color {
style = style.fg(text_color);
}
if let Some(bg_color) = self.background_color {
style = style.bg(bg_color);
}
self.textarea.set_style(style);
self.textarea.set_cursor_line_style(Style::default());
}
fn sync_to_textarea(&mut self) {
let lines: Vec<String> = self.value.lines().map(|s| s.to_string()).collect();
self.textarea = if lines.is_empty() {
TextArea::default()
} else {
TextArea::new(lines)
};
self.apply_colors_to_textarea();
let was_focused = self.focused;
self.focused = false; self.set_focused(was_focused);
use tui_textarea::CursorMove;
self.textarea.move_cursor(CursorMove::Jump(
self.cursor_line.min(u16::MAX as usize) as u16,
self.cursor_col.min(u16::MAX as usize) as u16,
));
}
pub fn line_count(&self) -> usize {
self.textarea.lines().len()
}
pub fn line_at(&self, line_idx: usize) -> Option<&str> {
self.textarea.lines().get(line_idx).map(|s| s.as_str())
}
pub fn update_line_col_from_cursor(&mut self) {
self.sync_from_textarea();
}
pub fn line_col_to_cursor(&self, line: usize, col: usize) -> usize {
let lines = self.textarea.lines();
let mut char_pos = 0;
for (i, line_text) in lines.iter().enumerate() {
if i < line {
char_pos += line_text.chars().count() + 1; } else if i == line {
char_pos += col.min(line_text.chars().count());
break;
}
}
char_pos
}
pub fn ensure_cursor_visible(&mut self, _area_height: u16, _area_width: u16) {
self.sync_from_textarea();
}
pub fn load_history(&mut self, cache: &CacheManager) -> Result<()> {
if self.history_loaded {
return Ok(());
}
if let Some(ref history_id) = self.history_id {
self.history = load_history_impl(cache, history_id)?;
self.history_loaded = true;
}
Ok(())
}
pub fn save_to_history(&mut self, cache: &CacheManager) -> Result<()> {
if let Some(history_id) = self.history_id.clone() {
self.sync_from_textarea(); if !self.value.is_empty() {
add_to_history(&mut self.history, self.value.clone());
save_history_impl(cache, &history_id, &self.history, self.history_limit)?;
}
}
Ok(())
}
pub fn clear(&mut self) {
self.textarea = TextArea::default();
self.value.clear();
self.cursor = 0;
self.cursor_line = 0;
self.cursor_col = 0;
self.history_index = None;
self.history_temp = None;
}
pub fn is_empty(&self) -> bool {
self.value.is_empty()
}
pub fn navigate_history_up(&mut self, cache: Option<&CacheManager>) {
if self.history_id.is_none() {
return;
}
if !self.history_loaded {
if let Some(cache) = cache {
if self.load_history(cache).is_err() {
return;
}
} else {
return;
}
}
if self.history.is_empty() {
return;
}
if self.history_index.is_none() {
self.sync_from_textarea(); self.history_temp = Some(self.value.clone());
}
let new_index = if let Some(current_idx) = self.history_index {
if current_idx > 0 {
current_idx - 1
} else {
current_idx }
} else {
self.history.len() - 1 };
self.history_index = Some(new_index);
if let Some(entry) = self.history.get(new_index) {
self.value = entry.clone();
let lines: Vec<&str> = self.value.lines().collect();
if let Some(last_line) = lines.last() {
self.cursor_line = lines.len().saturating_sub(1);
self.cursor_col = last_line.chars().count();
}
self.sync_to_textarea();
}
}
pub fn navigate_history_down(&mut self) {
if self.history_id.is_none() || self.history_index.is_none() {
return;
}
let current_idx = self.history_index.unwrap();
if current_idx >= self.history.len() - 1 {
if let Some(ref temp) = self.history_temp {
self.value = temp.clone();
let lines: Vec<&str> = self.value.lines().collect();
if let Some(last_line) = lines.last() {
self.cursor_line = lines.len().saturating_sub(1);
self.cursor_col = last_line.chars().count();
}
self.sync_to_textarea();
}
self.history_index = None;
self.history_temp = None;
} else {
let new_index = current_idx + 1;
self.history_index = Some(new_index);
if let Some(entry) = self.history.get(new_index) {
self.value = entry.clone();
let lines: Vec<&str> = self.value.lines().collect();
if let Some(last_line) = lines.last() {
self.cursor_line = lines.len().saturating_sub(1);
self.cursor_col = last_line.chars().count();
}
self.sync_to_textarea();
}
}
}
pub fn handle_key(&mut self, event: &KeyEvent, cache: Option<&CacheManager>) -> TextInputEvent {
let input = self.key_event_to_input(event);
match event.code {
KeyCode::Esc => {
return TextInputEvent::Cancel;
}
KeyCode::Char('p') | KeyCode::Char('P')
if event.modifiers.contains(KeyModifiers::CONTROL) =>
{
if self.history_id.is_some() {
self.navigate_history_up(cache);
return TextInputEvent::HistoryChanged;
}
}
KeyCode::Char('n') | KeyCode::Char('N')
if event.modifiers.contains(KeyModifiers::CONTROL) =>
{
if self.history_id.is_some() {
self.navigate_history_down();
return TextInputEvent::HistoryChanged;
}
}
_ => {
self.textarea.input(input);
self.sync_from_textarea();
if self.history_index.is_some() {
self.history_index = None;
self.history_temp = None;
}
}
}
TextInputEvent::None
}
fn key_event_to_input(&self, event: &KeyEvent) -> Input {
let ctrl = event.modifiers.contains(KeyModifiers::CONTROL);
let alt = event.modifiers.contains(KeyModifiers::ALT);
let shift = event.modifiers.contains(KeyModifiers::SHIFT);
let key = match event.code {
KeyCode::Char(c) => Key::Char(c),
KeyCode::Backspace => Key::Backspace,
KeyCode::Enter => Key::Enter,
KeyCode::Left => Key::Left,
KeyCode::Right => Key::Right,
KeyCode::Up => Key::Up,
KeyCode::Down => Key::Down,
KeyCode::Home => Key::Home,
KeyCode::End => Key::End,
KeyCode::PageUp => Key::PageUp,
KeyCode::PageDown => Key::PageDown,
KeyCode::Tab => Key::Tab,
KeyCode::BackTab => Key::Tab,
KeyCode::Delete => Key::Delete,
KeyCode::Insert => Key::Null,
KeyCode::F(_) => Key::Null,
KeyCode::Null => Key::Null,
KeyCode::Esc => Key::Esc,
KeyCode::CapsLock
| KeyCode::ScrollLock
| KeyCode::NumLock
| KeyCode::PrintScreen
| KeyCode::Pause
| KeyCode::Menu
| KeyCode::Media(_)
| KeyCode::Modifier(_)
| KeyCode::KeypadBegin => Key::Null,
};
Input {
key,
ctrl,
alt,
shift,
}
}
}
impl Default for MultiLineTextInput {
fn default() -> Self {
Self::new()
}
}
impl Widget for &MultiLineTextInput {
fn render(self, area: Rect, buf: &mut ratatui::buffer::Buffer) {
self.textarea.render(area, buf);
for y in area.y..area.bottom() {
for x in area.x..area.right() {
let cell = &mut buf[(x, y)];
let mut style = cell.style();
style = style.remove_modifier(Modifier::UNDERLINED);
cell.set_style(style);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_multiline_text_input_new() {
let input = MultiLineTextInput::new();
assert_eq!(input.value(), "");
assert_eq!(input.cursor_line, 0);
assert_eq!(input.cursor_col, 0);
assert_eq!(input.history_id, None);
assert_eq!(input.history_limit, 1000);
assert!(!input.focused);
}
#[test]
fn test_set_value() {
let mut input = MultiLineTextInput::new();
input.value = "line1\nline2".to_string();
input.sync_to_textarea();
assert_eq!(input.line_count(), 2);
}
#[test]
fn test_clear() {
let mut input = MultiLineTextInput::new();
input.value = "hello".to_string();
input.clear();
assert_eq!(input.value, "");
assert_eq!(input.cursor, 0);
}
#[test]
fn test_is_empty() {
let mut input = MultiLineTextInput::new();
assert!(input.is_empty());
input.value = "hello".to_string();
assert!(!input.is_empty());
}
}