use ratatui::Frame;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::layout::{Position, Rect};
use ratatui::style::Style;
use ratatui::widgets::Paragraph;
use unicode_width::UnicodeWidthStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InputOutcome {
Consumed,
Changed,
Submit,
Cancel,
NotConsumed,
}
#[derive(Default)]
pub struct SingleLineInput {
value: String,
cursor: usize,
}
impl SingleLineInput {
pub fn new() -> Self {
Self::default()
}
pub fn with_value(value: impl Into<String>) -> Self {
let value = value.into();
let cursor = value.len();
Self { value, cursor }
}
pub fn value(&self) -> &str {
&self.value
}
pub fn is_empty(&self) -> bool {
self.value.is_empty()
}
pub fn set_value(&mut self, value: impl Into<String>) {
self.value = value.into();
self.cursor = self.value.len();
}
pub fn clear(&mut self) {
self.value.clear();
self.cursor = 0;
}
#[cfg(test)]
pub(crate) fn cursor_char_offset(&self) -> usize {
self.value[..self.cursor].chars().count()
}
pub fn cursor_display_col(&self) -> usize {
self.value[..self.cursor].width()
}
pub fn display_width(&self) -> usize {
self.value.width()
}
pub fn handle_key(&mut self, key: &KeyEvent) -> InputOutcome {
match (key.modifiers, key.code) {
(_, KeyCode::Enter) => InputOutcome::Submit,
(_, KeyCode::Esc) => InputOutcome::Cancel,
(_, KeyCode::Backspace) => {
if self.cursor == 0 {
return InputOutcome::Consumed;
}
let prev = prev_char_boundary(&self.value, self.cursor);
self.value.drain(prev..self.cursor);
self.cursor = prev;
InputOutcome::Changed
}
(_, KeyCode::Delete) => {
if self.cursor >= self.value.len() {
return InputOutcome::Consumed;
}
let next = next_char_boundary(&self.value, self.cursor);
self.value.drain(self.cursor..next);
InputOutcome::Changed
}
(_, KeyCode::Left) => {
if self.cursor == 0 {
return InputOutcome::Consumed;
}
self.cursor = prev_char_boundary(&self.value, self.cursor);
InputOutcome::Consumed
}
(_, KeyCode::Right) => {
if self.cursor >= self.value.len() {
return InputOutcome::Consumed;
}
self.cursor = next_char_boundary(&self.value, self.cursor);
InputOutcome::Consumed
}
(_, KeyCode::Home) => {
self.cursor = 0;
InputOutcome::Consumed
}
(_, KeyCode::End) => {
self.cursor = self.value.len();
InputOutcome::Consumed
}
(m, KeyCode::Char(c)) if !m.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => {
self.value.insert(self.cursor, c);
self.cursor += c.len_utf8();
InputOutcome::Changed
}
_ => InputOutcome::NotConsumed,
}
}
pub fn render(
&self,
f: &mut Frame,
rect: Rect,
style: Style,
value_offset_x: u16,
focused: bool,
) {
let inner = Rect {
x: rect.x.saturating_add(value_offset_x),
width: rect.width.saturating_sub(value_offset_x),
..rect
};
f.render_widget(Paragraph::new(self.value.as_str()).style(style), inner);
if focused {
let caret_x = inner
.x
.saturating_add(self.cursor_display_col() as u16)
.min(inner.x + inner.width.saturating_sub(1));
f.set_cursor_position(Position {
x: caret_x,
y: inner.y,
});
}
}
}
fn prev_char_boundary(s: &str, from: usize) -> usize {
s[..from]
.char_indices()
.next_back()
.map(|(i, _)| i)
.unwrap_or(0)
}
fn next_char_boundary(s: &str, from: usize) -> usize {
s[from..]
.char_indices()
.nth(1)
.map(|(i, _)| from + i)
.unwrap_or(s.len())
}
#[cfg(test)]
mod tests {
use super::*;
fn k(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
#[test]
fn new_is_empty_cursor_zero() {
let i = SingleLineInput::new();
assert!(i.is_empty());
assert_eq!(i.cursor_char_offset(), 0);
}
#[test]
fn with_value_places_cursor_at_end() {
let i = SingleLineInput::with_value("hello");
assert_eq!(i.value(), "hello");
assert_eq!(i.cursor_char_offset(), 5);
}
#[test]
fn typing_chars_appends_and_advances_cursor() {
let mut i = SingleLineInput::new();
assert_eq!(i.handle_key(&k(KeyCode::Char('a'))), InputOutcome::Changed);
assert_eq!(i.handle_key(&k(KeyCode::Char('b'))), InputOutcome::Changed);
assert_eq!(i.value(), "ab");
assert_eq!(i.cursor_char_offset(), 2);
}
#[test]
fn left_then_insert_inserts_mid_string() {
let mut i = SingleLineInput::with_value("ac");
i.handle_key(&k(KeyCode::Left));
assert_eq!(i.cursor_char_offset(), 1);
i.handle_key(&k(KeyCode::Char('b')));
assert_eq!(i.value(), "abc");
assert_eq!(i.cursor_char_offset(), 2);
}
#[test]
fn backspace_at_start_is_noop() {
let mut i = SingleLineInput::with_value("abc");
i.handle_key(&k(KeyCode::Home));
assert_eq!(i.handle_key(&k(KeyCode::Backspace)), InputOutcome::Consumed);
assert_eq!(i.value(), "abc");
}
#[test]
fn delete_at_end_is_noop() {
let mut i = SingleLineInput::with_value("abc");
assert_eq!(i.handle_key(&k(KeyCode::Delete)), InputOutcome::Consumed);
assert_eq!(i.value(), "abc");
}
#[test]
fn home_end_jump_cursor() {
let mut i = SingleLineInput::with_value("abc");
i.handle_key(&k(KeyCode::Home));
assert_eq!(i.cursor_char_offset(), 0);
i.handle_key(&k(KeyCode::End));
assert_eq!(i.cursor_char_offset(), 3);
}
#[test]
fn unicode_chars_count_by_codepoint_not_bytes() {
let mut i = SingleLineInput::new();
i.handle_key(&k(KeyCode::Char('あ')));
i.handle_key(&k(KeyCode::Char('い')));
assert_eq!(i.value(), "あい");
assert_eq!(i.cursor_char_offset(), 2);
i.handle_key(&k(KeyCode::Left));
assert_eq!(i.cursor_char_offset(), 1);
i.handle_key(&k(KeyCode::Backspace));
assert_eq!(i.value(), "い");
}
#[test]
fn enter_returns_submit_esc_returns_cancel() {
let mut i = SingleLineInput::with_value("x");
assert_eq!(i.handle_key(&k(KeyCode::Enter)), InputOutcome::Submit);
assert_eq!(i.handle_key(&k(KeyCode::Esc)), InputOutcome::Cancel);
}
#[test]
fn ctrl_char_is_not_consumed_as_text() {
let mut i = SingleLineInput::new();
let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL);
assert_eq!(i.handle_key(&key), InputOutcome::NotConsumed);
assert!(i.is_empty());
}
#[test]
fn alt_char_is_not_consumed_as_text() {
let mut i = SingleLineInput::new();
let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::ALT);
assert_eq!(i.handle_key(&key), InputOutcome::NotConsumed);
assert!(i.is_empty());
}
#[test]
fn cjk_chars_count_two_display_cols_per_char() {
let mut i = SingleLineInput::new();
i.handle_key(&k(KeyCode::Char('あ')));
i.handle_key(&k(KeyCode::Char('い')));
assert_eq!(i.cursor_char_offset(), 2);
assert_eq!(i.cursor_display_col(), 4);
assert_eq!(i.display_width(), 4);
}
#[test]
fn mixed_ascii_and_cjk_caret_column() {
let mut i = SingleLineInput::with_value("ab猫");
assert_eq!(i.cursor_display_col(), 4);
i.handle_key(&k(KeyCode::Left));
assert_eq!(i.cursor_display_col(), 2);
}
#[test]
fn shift_char_inserts() {
let mut i = SingleLineInput::new();
let key = KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT);
assert_eq!(i.handle_key(&key), InputOutcome::Changed);
assert_eq!(i.value(), "A");
}
#[test]
fn set_value_resets_cursor_to_end() {
let mut i = SingleLineInput::with_value("abc");
i.handle_key(&k(KeyCode::Home));
i.set_value("xyz!");
assert_eq!(i.value(), "xyz!");
assert_eq!(i.cursor_char_offset(), 4);
}
#[test]
fn clear_resets_both() {
let mut i = SingleLineInput::with_value("abc");
i.clear();
assert!(i.is_empty());
assert_eq!(i.cursor_char_offset(), 0);
}
}