use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, StatefulWidget, Widget},
};
use crate::BorderType;
use crate::{BORDER_GRAY, TEXT_PADDING};
#[derive(Clone, Default)]
pub struct TextInputState {
pub content: String,
pub cursor: usize,
}
impl TextInputState {
pub fn insert_char(&mut self, c: char) {
self.content.insert(self.cursor, c);
self.cursor += c.len_utf8();
}
pub fn delete_before(&mut self) {
if let Some((prev, _)) = self.content[..self.cursor].char_indices().last() {
self.cursor = prev;
self.content.remove(self.cursor);
}
}
pub fn delete_at(&mut self) {
if self.cursor < self.content.len() {
if let Some(c) = self.content[self.cursor..].chars().next() {
let end = self.cursor + c.len_utf8();
self.content.drain(self.cursor..end);
}
}
}
pub fn cursor_left(&mut self) {
if let Some((prev, _)) = self.content[..self.cursor].char_indices().last() {
self.cursor = prev;
}
}
pub fn cursor_right(&mut self) {
if self.cursor < self.content.len() {
if let Some(c) = self.content[self.cursor..].chars().next() {
self.cursor += c.len_utf8();
}
}
}
pub fn cursor_home(&mut self) {
self.cursor = 0;
}
pub fn cursor_end(&mut self) {
self.cursor = self.content.len();
}
}
#[derive(Clone)]
pub struct TextInput<'a> {
cursor_style: Style,
text_style: Style,
border_style: Style,
borders: Borders,
border_type: BorderType,
placeholder: Option<&'a str>,
bg_color: Option<Color>,
rows: u16,
cols: Option<u16>,
scroll_padding: u16,
scroll_reserve: u16,
}
impl<'a> TextInput<'a> {
pub fn new() -> Self {
Self {
cursor_style: Style::new().fg(Color::Black).bg(Color::White),
text_style: Style::new().fg(Color::White),
border_style: Style::new().fg(BORDER_GRAY),
borders: Borders::NONE,
border_type: BorderType::None,
placeholder: None,
bg_color: None,
rows: 1,
cols: None,
scroll_padding: 8,
scroll_reserve: 8,
}
}
pub fn cursor_style(mut self, style: Style) -> Self {
self.cursor_style = style;
self
}
pub fn text_style(mut self, style: Style) -> Self {
self.text_style = style;
self
}
pub fn border_style(mut self, style: Style) -> Self {
self.border_style = style;
self
}
pub fn borders(mut self, borders: Borders) -> Self {
self.borders = borders;
self
}
pub fn border_type(mut self, border_type: BorderType) -> Self {
self.border_type = border_type;
self
}
pub fn placeholder(mut self, placeholder: &'a str) -> Self {
self.placeholder = Some(placeholder);
self
}
pub fn bg_color(mut self, color: Color) -> Self {
self.bg_color = Some(color);
self
}
pub fn rows(mut self, rows: u16) -> Self {
self.rows = rows;
self
}
pub fn cols(mut self, cols: u16) -> Self {
self.cols = Some(cols);
self
}
pub fn scroll_padding(mut self, padding: u16) -> Self {
self.scroll_padding = padding;
self
}
pub fn scroll_reserve(mut self, reserve: u16) -> Self {
self.scroll_reserve = reserve;
self
}
pub fn required_height(&self) -> u16 {
let has_top = self.borders.contains(Borders::TOP);
let has_bottom = self.borders.contains(Borders::BOTTOM);
let border_lines = (has_top as u16) + (has_bottom as u16);
self.rows + border_lines
}
}
impl Default for TextInput<'_> {
fn default() -> Self {
Self::new()
}
}
impl StatefulWidget for TextInput<'_> {
type State = TextInputState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
let has_border = !matches!(self.border_type, BorderType::None);
let effective_borders = if has_border { self.borders } else { Borders::NONE };
let ratatui_border_type = if has_border {
self.border_type.to_ratatui()
} else {
ratatui::widgets::BorderType::Plain
};
let block = Block::bordered()
.borders(effective_borders)
.border_type(ratatui_border_type)
.border_style(self.border_style)
.padding(TEXT_PADDING);
let inner = block.inner(area);
block.render(area, buf);
if let Some(bg) = self.bg_color {
buf.set_style(inner, Style::new().bg(bg));
}
let display = if state.content.is_empty() {
self.placeholder.unwrap_or("")
} else {
&state.content
};
let inner_width = inner.width as usize;
let cursor_byte = state.cursor.min(display.len());
let cursor_byte = if display.is_char_boundary(cursor_byte) {
cursor_byte
} else {
let mut i = cursor_byte;
while i > 0 && !display.is_char_boundary(i) {
i -= 1;
}
i
};
let cursor_char = display[..cursor_byte].chars().count();
let total_chars = display.chars().count();
let sp = self.scroll_padding as usize;
let scroll_char = if inner_width > sp {
cursor_char
.saturating_sub(inner_width.saturating_sub(sp))
.min(total_chars.saturating_sub(inner_width.saturating_sub(sp)))
} else {
0
};
let visible: String = display.chars().skip(scroll_char).take(inner_width).collect();
let visible_chars = visible.chars().count();
let visible_cursor = cursor_char.saturating_sub(scroll_char).min(visible_chars);
let before: String = visible.chars().take(visible_cursor).collect();
let at = visible.chars().nth(visible_cursor);
let after: String = visible.chars().skip(visible_cursor + 1).collect();
let mut spans: Vec<Span<'_>> = Vec::with_capacity(inner_width + 1);
if !before.is_empty() {
spans.push(Span::styled(before, self.text_style));
}
match at {
Some(c) => spans.push(Span::styled(c.to_string(), self.cursor_style)),
None => spans.push(Span::styled(" ", self.cursor_style)),
}
if !after.is_empty() {
spans.push(Span::styled(after, self.text_style));
}
let text_rect = if self.rows > 1 && inner.height >= self.rows {
Rect { x: inner.x, y: inner.y, width: inner.width, height: 1 }
} else {
inner
};
Paragraph::new(Line::from(spans)).render(text_rect, buf);
}
}
#[cfg(test)]
mod tests;