mod input;
mod render;
use crate::primitives::grapheme;
use ratatui::layout::Rect;
use ratatui::style::Color;
pub use input::TextInputEvent;
pub use render::{render_text_input, render_text_input_aligned};
use super::FocusState;
#[derive(Debug, Clone)]
pub struct TextInputState {
pub value: String,
pub cursor: usize,
pub label: String,
pub placeholder: String,
pub focus: FocusState,
pub validate_json: bool,
}
impl TextInputState {
pub fn new(label: impl Into<String>) -> Self {
Self {
value: String::new(),
cursor: 0,
label: label.into(),
placeholder: String::new(),
focus: FocusState::Normal,
validate_json: false,
}
}
pub fn with_json_validation(mut self) -> Self {
self.validate_json = true;
self
}
pub fn is_valid(&self) -> bool {
if self.validate_json {
serde_json::from_str::<serde_json::Value>(&self.value).is_ok()
} else {
true
}
}
pub fn with_value(mut self, value: impl Into<String>) -> Self {
self.value = value.into();
self.cursor = self.value.len();
self
}
pub fn with_placeholder(mut self, placeholder: impl Into<String>) -> Self {
self.placeholder = placeholder.into();
self
}
pub fn with_focus(mut self, focus: FocusState) -> Self {
self.focus = focus;
self
}
pub fn is_enabled(&self) -> bool {
self.focus != FocusState::Disabled
}
pub fn insert(&mut self, c: char) {
if !self.is_enabled() {
return;
}
self.value.insert(self.cursor, c);
self.cursor += c.len_utf8();
}
pub fn insert_str(&mut self, s: &str) {
if !self.is_enabled() {
return;
}
self.value.insert_str(self.cursor, s);
self.cursor += s.len();
}
pub fn backspace(&mut self) {
if !self.is_enabled() || self.cursor == 0 {
return;
}
let prev_boundary = self.value[..self.cursor]
.char_indices()
.next_back()
.map(|(i, _)| i)
.unwrap_or(0);
self.value.remove(prev_boundary);
self.cursor = prev_boundary;
}
pub fn delete(&mut self) {
if !self.is_enabled() || self.cursor >= self.value.len() {
return;
}
let next_boundary = grapheme::next_grapheme_boundary(&self.value, self.cursor);
self.value.drain(self.cursor..next_boundary);
}
pub fn move_left(&mut self) {
if self.cursor > 0 {
self.cursor = grapheme::prev_grapheme_boundary(&self.value, self.cursor);
}
}
pub fn move_right(&mut self) {
if self.cursor < self.value.len() {
self.cursor = grapheme::next_grapheme_boundary(&self.value, self.cursor);
}
}
pub fn move_home(&mut self) {
self.cursor = 0;
}
pub fn move_end(&mut self) {
self.cursor = self.value.len();
}
pub fn clear(&mut self) {
if self.is_enabled() {
self.value.clear();
self.cursor = 0;
}
}
pub fn set_value(&mut self, value: impl Into<String>) {
if self.is_enabled() {
self.value = value.into();
self.cursor = self.value.len();
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct TextInputColors {
pub label: Color,
pub text: Color,
pub border: Color,
pub placeholder: Color,
pub cursor: Color,
pub focused: Color,
pub disabled: Color,
}
impl Default for TextInputColors {
fn default() -> Self {
Self {
label: Color::White,
text: Color::White,
border: Color::Gray,
placeholder: Color::DarkGray,
cursor: Color::Yellow,
focused: Color::Cyan,
disabled: Color::DarkGray,
}
}
}
impl TextInputColors {
pub fn from_theme(theme: &crate::view::theme::Theme) -> Self {
Self {
label: theme.editor_fg,
text: theme.editor_fg,
border: theme.line_number_fg,
placeholder: theme.line_number_fg,
cursor: theme.cursor,
focused: theme.selection_bg,
disabled: theme.line_number_fg,
}
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct TextInputLayout {
pub input_area: Rect,
pub full_area: Rect,
pub cursor_pos: Option<(u16, u16)>,
}
impl TextInputLayout {
pub fn is_input(&self, x: u16, y: u16) -> bool {
x >= self.input_area.x
&& x < self.input_area.x + self.input_area.width
&& y >= self.input_area.y
&& y < self.input_area.y + self.input_area.height
}
pub fn contains(&self, x: u16, y: u16) -> bool {
x >= self.full_area.x
&& x < self.full_area.x + self.full_area.width
&& y >= self.full_area.y
&& y < self.full_area.y + self.full_area.height
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::backend::TestBackend;
use ratatui::Terminal;
fn test_frame<F>(width: u16, height: u16, f: F)
where
F: FnOnce(&mut ratatui::Frame, Rect),
{
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
let area = Rect::new(0, 0, width, height);
f(frame, area);
})
.unwrap();
}
#[test]
fn test_text_input_renders() {
test_frame(40, 1, |frame, area| {
let state = TextInputState::new("Name").with_value("John");
let colors = TextInputColors::default();
let layout = render_text_input(frame, area, &state, &colors, 20);
assert!(layout.input_area.width > 0);
});
}
#[test]
fn test_text_input_insert() {
let mut state = TextInputState::new("Test");
state.insert('a');
state.insert('b');
state.insert('c');
assert_eq!(state.value, "abc");
assert_eq!(state.cursor, 3);
}
#[test]
fn test_text_input_backspace() {
let mut state = TextInputState::new("Test").with_value("abc");
state.backspace();
assert_eq!(state.value, "ab");
assert_eq!(state.cursor, 2);
}
#[test]
fn test_text_input_cursor_movement() {
let mut state = TextInputState::new("Test").with_value("hello");
assert_eq!(state.cursor, 5);
state.move_left();
assert_eq!(state.cursor, 4);
state.move_home();
assert_eq!(state.cursor, 0);
state.move_right();
assert_eq!(state.cursor, 1);
state.move_end();
assert_eq!(state.cursor, 5);
}
#[test]
fn test_text_input_delete() {
let mut state = TextInputState::new("Test").with_value("abc");
state.move_home();
state.delete();
assert_eq!(state.value, "bc");
assert_eq!(state.cursor, 0);
}
#[test]
fn test_text_input_disabled() {
let mut state = TextInputState::new("Test").with_focus(FocusState::Disabled);
state.insert('a');
assert_eq!(state.value, "");
}
#[test]
fn test_text_input_clear() {
let mut state = TextInputState::new("Test").with_value("hello");
state.clear();
assert_eq!(state.value, "");
assert_eq!(state.cursor, 0);
}
#[test]
fn test_text_input_multibyte_insert_and_backspace() {
let mut state = TextInputState::new("Test");
state.insert('©');
assert_eq!(state.value, "©");
assert_eq!(state.cursor, 2);
state.backspace();
assert_eq!(state.value, "");
assert_eq!(state.cursor, 0);
}
#[test]
fn test_text_input_multibyte_cursor_movement() {
let mut state = TextInputState::new("Test").with_value("日本語");
assert_eq!(state.cursor, 9);
state.move_left();
assert_eq!(state.cursor, 6);
state.move_left();
assert_eq!(state.cursor, 3);
state.move_right();
assert_eq!(state.cursor, 6);
state.move_home();
assert_eq!(state.cursor, 0);
state.move_right();
assert_eq!(state.cursor, 3); }
#[test]
fn test_text_input_multibyte_delete() {
let mut state = TextInputState::new("Test").with_value("a日b");
assert_eq!(state.cursor, 5);
state.move_home();
state.move_right(); assert_eq!(state.cursor, 1);
state.delete(); assert_eq!(state.value, "ab");
assert_eq!(state.cursor, 1);
}
#[test]
fn test_text_input_insert_between_multibyte() {
let mut state = TextInputState::new("Test").with_value("日語");
state.move_home();
state.move_right(); assert_eq!(state.cursor, 3);
state.insert('本');
assert_eq!(state.value, "日本語");
assert_eq!(state.cursor, 6);
}
}