use ratatui::{
widgets::StatefulWidget,
buffer::Buffer,
layout::Rect,
style::{Style, Modifier},
};
use crate::Theme;
use unicode_width::UnicodeWidthStr;
use unicode_width::UnicodeWidthChar;
#[derive(Debug, Clone)]
pub struct Completion {
pub text: String,
pub display: String,
}
#[derive(Debug, Default)]
pub struct InputState {
pub text: String,
pub cursor: usize,
pub placeholder: Option<String>,
completions: Vec<Completion>,
completion_index: usize,
completion_active: bool,
}
impl InputState {
pub fn clear(&mut self) {
self.text.clear();
self.cursor = 0;
}
pub fn insert_char(&mut self, c: char) {
let byte_pos = self.char_to_byte(self.cursor);
self.text.insert(byte_pos, c);
self.cursor += 1;
}
pub fn insert_str(&mut self, s: &str) {
let byte_pos = self.char_to_byte(self.cursor);
self.text.insert_str(byte_pos, s);
self.cursor += s.chars().count();
}
pub fn backspace(&mut self) {
if self.cursor > 0 {
self.cursor -= 1;
let byte_pos = self.char_to_byte(self.cursor);
self.text.remove(byte_pos);
}
}
pub fn delete(&mut self) {
if self.cursor < self.text.chars().count() {
let byte_pos = self.char_to_byte(self.cursor);
self.text.remove(byte_pos);
}
}
pub fn move_left(&mut self) {
self.cursor = self.cursor.saturating_sub(1);
}
pub fn move_right(&mut self) {
let max = self.text.chars().count();
self.cursor = (self.cursor + 1).min(max);
}
pub fn move_home(&mut self) {
self.cursor = 0;
}
pub fn move_end(&mut self) {
self.cursor = self.text.chars().count();
}
fn char_to_byte(&self, char_idx: usize) -> usize {
self.text.char_indices().nth(char_idx).map(|(i, _)| i).unwrap_or(self.text.len())
}
fn display_width_up_to(&self, char_idx: usize) -> usize {
let s: String = self.text.chars().take(char_idx).collect();
UnicodeWidthStr::width(s.as_str())
}
pub fn accept_completion(&mut self) -> bool {
if !self.completion_active || self.completions.is_empty() {
return false;
}
let completion = &self.completions[self.completion_index];
let chars: Vec<char> = self.text.chars().collect();
let mut trigger_pos = self.cursor;
while trigger_pos > 0 {
match chars.get(trigger_pos - 1).copied() {
Some(c) if !c.is_whitespace() => trigger_pos -= 1,
_ => break,
}
}
let prefix: String = chars[..trigger_pos].iter().collect();
self.text = format!("{}{}", prefix, completion.text);
self.cursor = self.text.chars().count();
self.completion_active = false;
true
}
pub fn next_completion(&mut self) {
if !self.completions.is_empty() {
self.completion_index = (self.completion_index + 1) % self.completions.len();
}
}
pub fn prev_completion(&mut self) {
if !self.completions.is_empty() {
self.completion_index = self.completion_index.saturating_sub(1);
}
}
}
pub struct Input<'a> {
theme: &'a Theme,
placeholder: Option<&'a str>,
prompt_char: char,
}
impl<'a> Input<'a> {
pub fn new(theme: &'a Theme) -> Self {
Self {
theme,
placeholder: None,
prompt_char: '❯',
}
}
pub fn with_theme(mut self, theme: &'a Theme) -> Self {
self.theme = theme;
self
}
pub fn with_placeholder(mut self, placeholder: &'a str) -> Self {
self.placeholder = Some(placeholder);
self
}
pub fn with_prompt_char(mut self, c: char) -> Self {
self.prompt_char = c;
self
}
}
impl StatefulWidget for Input<'_> {
type State = InputState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
if area.height < 1 || area.width < 4 {
return;
}
let styles = self.theme.to_styles();
let y = area.y;
let prompt_width = self.prompt_char.width().unwrap_or(1) as u16;
buf[(area.x, y)].set_char(self.prompt_char)
.set_style(styles.primary);
if prompt_width > 1 {
buf[(area.x + 1, y)].set_char(' ')
.set_style(styles.primary);
}
let content_start = area.x + prompt_width + 1; buf[(area.x + prompt_width, y)].set_char(' ')
.set_style(styles.normal);
let display_text = if state.text.is_empty() {
self.placeholder.unwrap_or("")
} else {
&state.text
};
let text_fg = if state.text.is_empty() {
styles.muted
} else {
styles.normal
};
let max_cols = (area.width - prompt_width - 2) as usize; let _text_display_width = UnicodeWidthStr::width(display_text);
let cursor_col = if state.text.is_empty() {
0
} else {
state.display_width_up_to(state.cursor)
};
let scroll_col = if cursor_col >= max_cols {
cursor_col - max_cols + 1
} else {
0
};
let mut col = 0u16; let mut char_iter = display_text.chars().enumerate().peekable();
let mut chars_before_cursor = 0usize;
let mut cursor_rendered = false;
let mut skipped_width = 0usize;
while let Some((char_idx, c)) = char_iter.peek().cloned() {
let cw = c.width().unwrap_or(0);
if skipped_width + cw <= scroll_col {
skipped_width += cw;
chars_before_cursor = char_idx + 1;
char_iter.next();
} else {
break;
}
}
for (char_idx, c) in char_iter {
let cw = c.width().unwrap_or(1) as u16;
let screen_col = content_start + col;
if screen_col + cw > area.x + area.width - 1 {
break; }
let is_cursor = state.cursor == char_idx && !state.text.is_empty();
if is_cursor {
buf[(screen_col, y)].set_char(c)
.set_style(Style::default()
.fg(self.theme.colors.cursor_fg.to_ratatui())
.bg(self.theme.colors.cursor_bg.to_ratatui())
.add_modifier(Modifier::BOLD));
if cw > 1 {
buf[(screen_col + 1, y)].set_char(' ')
.set_style(Style::default()
.fg(self.theme.colors.cursor_fg.to_ratatui())
.bg(self.theme.colors.cursor_bg.to_ratatui()));
}
cursor_rendered = true;
} else {
buf[(screen_col, y)].set_char(c).set_style(text_fg);
}
col += cw;
}
let end_col = content_start + col;
if state.cursor >= state.text.chars().count() && end_col < area.x + area.width - 1 {
let cursor_col_pos = if state.text.is_empty() && self.placeholder.is_some() {
content_start
} else {
end_col
};
buf[(cursor_col_pos, y)].set_char(' ')
.set_style(Style::default()
.fg(self.theme.colors.cursor_fg.to_ratatui())
.bg(self.theme.colors.cursor_bg.to_ratatui()));
}
let clear_from = if state.text.is_empty() {
let ph_width = UnicodeWidthStr::width(self.placeholder.unwrap_or(""));
content_start + (ph_width as u16).min(area.width - prompt_width - 2)
} else {
end_col + 1
};
for c in clear_from..area.x + area.width {
buf[(c, y)].set_char(' ').set_style(text_fg);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn input_state_empty() {
let state = InputState::default();
assert!(state.text.is_empty());
assert_eq!(state.cursor, 0);
}
#[test]
fn input_state_insert() {
let mut state = InputState::default();
state.insert_char('a');
assert_eq!(state.text, "a");
state.insert_char('b');
assert_eq!(state.text, "ab");
state.insert_char('한');
assert_eq!(state.text, "ab한");
assert_eq!(state.cursor, 3); }
#[test]
fn input_state_insert_str() {
let mut state = InputState::default();
state.insert_str("안녕하세요");
assert_eq!(state.text, "안녕하세요");
assert_eq!(state.cursor, 5);
}
#[test]
fn input_state_backspace() {
let mut state = InputState::default();
state.text = "ab한".to_string();
state.cursor = 3;
state.backspace();
assert_eq!(state.text, "ab");
assert_eq!(state.cursor, 2);
}
#[test]
fn input_state_backspace_korean() {
let mut state = InputState::default();
state.text = "안녕하세요".to_string();
state.cursor = 5;
state.backspace();
assert_eq!(state.text, "안녕하세");
assert_eq!(state.cursor, 4);
}
#[test]
fn input_state_cursor_movement() {
let mut state = InputState::default();
state.text = "hello".to_string();
state.cursor = 5;
state.move_left();
assert_eq!(state.cursor, 4);
state.move_right();
assert_eq!(state.cursor, 5);
state.move_home();
assert_eq!(state.cursor, 0);
state.move_end();
assert_eq!(state.cursor, 5);
}
#[test]
fn input_state_display_width() {
let mut state = InputState::default();
state.text = "ab한글".to_string();
assert_eq!(state.display_width_up_to(0), 0);
assert_eq!(state.display_width_up_to(1), 1); assert_eq!(state.display_width_up_to(2), 2); assert_eq!(state.display_width_up_to(3), 4); assert_eq!(state.display_width_up_to(4), 6); }
}