use crate::Theme;
use ratatui::prelude::*;
use ratatui::widgets::Paragraph;
use ratatui_textarea::{Input as TextAreaInput, Key, TextArea};
#[derive(Debug)]
pub struct InputState {
textarea: TextArea<'static>,
}
impl Default for InputState {
fn default() -> Self {
let mut textarea = TextArea::default();
textarea.remove_line_number();
textarea.set_cursor_line_style(Style::default());
textarea.set_wrap_mode(ratatui_textarea::WrapMode::Word);
Self { textarea }
}
}
impl InputState {
pub fn new() -> Self {
Self::default()
}
pub fn text(&self) -> String {
self.textarea.lines().join("\n")
}
pub fn set_text(&mut self, text: String) {
self.textarea.clear();
if !text.is_empty() {
self.textarea.insert_str(&text);
}
}
pub fn lines(&self) -> Vec<String> {
self.textarea.lines().to_vec()
}
pub fn clear(&mut self) {
self.textarea.clear();
}
pub fn set_placeholder(&mut self, placeholder: Option<String>) {
if let Some(p) = placeholder {
self.textarea.set_placeholder_text(&p);
} else {
self.textarea.set_placeholder_text("");
}
}
pub fn insert_char(&mut self, c: char) {
self.handle_char(c);
}
pub fn insert_str(&mut self, s: &str) {
self.textarea.insert_str(s);
}
pub fn backspace(&mut self) {
self.textarea.input(TextAreaInput {
key: Key::Backspace,
..Default::default()
});
}
pub fn delete(&mut self) {
self.textarea.input(TextAreaInput {
key: Key::Delete,
..Default::default()
});
}
pub fn move_left(&mut self) {
self.textarea
.move_cursor(ratatui_textarea::CursorMove::Back);
}
pub fn move_right(&mut self) {
self.textarea
.move_cursor(ratatui_textarea::CursorMove::Forward);
}
pub fn move_home(&mut self) {
self.textarea
.move_cursor(ratatui_textarea::CursorMove::Head);
}
pub fn move_end(&mut self) {
self.textarea.move_cursor(ratatui_textarea::CursorMove::End);
}
pub fn move_word_left(&mut self) {
self.textarea
.move_cursor(ratatui_textarea::CursorMove::WordBack);
}
pub fn move_word_right(&mut self) {
self.textarea
.move_cursor(ratatui_textarea::CursorMove::WordForward);
}
pub fn handle_key(&mut self, key: Key) -> bool {
match key {
Key::Enter => true, Key::Tab => true, _ => {
self.textarea.input(TextAreaInput {
key,
..Default::default()
});
false
}
}
}
pub fn handle_char(&mut self, c: char) {
self.textarea.input(TextAreaInput {
key: Key::Char(c),
ctrl: false,
alt: false,
shift: false,
});
}
pub fn handle_input(&mut self, input: TextAreaInput) -> bool {
if input.key == Key::Enter && !input.shift {
true } else {
self.textarea.input(input);
false
}
}
pub fn textarea_mut(&mut self) -> &mut TextArea<'static> {
&mut self.textarea
}
pub fn undo(&mut self) {
self.textarea.undo();
}
pub fn redo(&mut self) {
self.textarea.redo();
}
pub fn delete_to_line_start(&mut self) {
self.textarea.delete_line_by_head();
}
pub fn delete_to_line_end(&mut self) {
self.textarea.delete_line_by_end();
}
pub fn delete_word_backward(&mut self) {
self.textarea.delete_word();
}
pub fn delete_word_forward(&mut self) {
self.textarea.delete_next_word();
}
pub fn required_height(&self, width: u16, max_height: u16) -> u16 {
if width < 1 {
return 1;
}
let lines = self.textarea.lines();
if lines.is_empty() || (lines.len() == 1 && lines[0].is_empty()) {
return 1;
}
let text = Text::from(lines.join("\n"));
let paragraph = Paragraph::new(text).wrap(ratatui::widgets::Wrap { trim: true });
let count = paragraph.line_count(width) as u16;
count.clamp(1, max_height)
}
}
pub struct Input<'a> {
theme: &'a Theme,
placeholder: Option<&'a str>,
}
impl<'a> Input<'a> {
pub fn new(theme: &'a Theme) -> Self {
Self {
theme,
placeholder: None,
}
}
pub fn with_placeholder(mut self, placeholder: &'a str) -> Self {
self.placeholder = Some(placeholder);
self
}
}
impl ratatui::widgets::StatefulWidget for Input<'_> {
type State = InputState;
fn render(self, area: Rect, buf: &mut ratatui::buffer::Buffer, state: &mut Self::State) {
if area.height < 1 || area.width < 4 {
return;
}
let y = area.y;
let textarea = state.textarea_mut();
textarea.set_style(Style::default().fg(self.theme.colors.foreground.to_ratatui()));
textarea.set_cursor_style(
Style::default()
.fg(self.theme.colors.cursor_fg.to_ratatui())
.bg(self.theme.colors.cursor_bg.to_ratatui()),
);
textarea.set_cursor_line_style(Style::default());
textarea.remove_line_number();
textarea.set_placeholder_style(Style::default().fg(self.theme.colors.muted.to_ratatui()));
let content_area = Rect {
x: area.x + 1,
y,
width: area.width.saturating_sub(2), height: area.height,
};
let textarea_clone = textarea.clone();
textarea_clone.render(content_area, buf);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn input_state_empty() {
let state = InputState::default();
assert!(state.text().is_empty());
}
#[test]
fn input_state_insert() {
let mut state = InputState::default();
state.handle_char('a');
assert_eq!(state.text(), "a");
state.handle_char('b');
assert_eq!(state.text(), "ab");
state.handle_char('\u{0041}'); assert_eq!(state.text(), "abA");
}
#[test]
fn input_state_insert_str() {
let mut state = InputState::default();
state.insert_str("HelloWorld");
assert_eq!(state.text(), "HelloWorld");
}
#[test]
fn input_state_multiline() {
let mut state = InputState::default();
state.handle_char('a');
state.handle_input(TextAreaInput {
key: Key::Enter,
shift: true, ..Default::default()
});
state.handle_char('b');
assert_eq!(state.text(), "a\nb");
}
#[test]
fn input_state_clear() {
let mut state = InputState::default();
state.insert_str("hello");
state.clear();
assert!(state.text().is_empty());
}
#[test]
fn input_state_undo_redo() {
let mut state = InputState::default();
state.insert_str("hello");
assert_eq!(state.text(), "hello");
state.undo();
assert_eq!(state.text(), "");
state.redo();
assert_eq!(state.text(), "hello");
}
#[test]
fn required_height_empty() {
let state = InputState::default();
assert_eq!(state.required_height(80, 8), 1);
}
#[test]
fn required_height_short_text() {
let mut state = InputState::default();
state.insert_str("hello world");
assert_eq!(state.required_height(80, 8), 1);
}
#[test]
fn required_height_long_line_wraps() {
let mut state = InputState::default();
let long_text = "a".repeat(200);
state.insert_str(&long_text);
let height = state.required_height(80, 8);
assert!(
height >= 2,
"Long text should wrap to multiple lines, got {}",
height
);
}
#[test]
fn required_height_explicit_newlines() {
let mut state = InputState::default();
state.insert_str("line1\nline2\nline3");
assert_eq!(state.required_height(80, 8), 3);
}
#[test]
#[allow(trivial_casts)]
fn required_height_mixed_wrapping() {
let mut state = InputState::default();
state.insert_str("short\n");
state.insert_str(&"a".repeat(200)); let height = state.required_height(80, 8);
assert!(
height >= 2,
"Mixed content should need multiple lines, got {}",
height
);
}
#[test]
fn required_height_max_height_clamp() {
let mut state = InputState::default();
for i in 0..20 {
state.insert_str(&format!("line {}\n", i));
}
assert_eq!(state.required_height(80, 5), 5);
}
#[test]
fn required_height_zero_width() {
let mut state = InputState::default();
state.insert_str("hello");
assert_eq!(state.required_height(0, 8), 1);
}
}