use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use unicode_segmentation::UnicodeSegmentation;
#[derive(Debug, Clone, Default)]
pub struct InputState {
buffer: String,
cursor: usize,
error: Option<String>,
pub context_path: Option<std::path::PathBuf>,
}
impl InputState {
pub fn new() -> Self {
Self::default()
}
pub fn with_initial(value: &str) -> Self {
Self {
buffer: value.to_string(),
cursor: value.len(),
error: None,
context_path: None,
}
}
pub fn buffer(&self) -> &str {
&self.buffer
}
pub fn cursor(&self) -> usize {
self.cursor
}
pub fn error(&self) -> Option<&str> {
self.error.as_deref()
}
pub fn set_error(&mut self, error: impl Into<String>) {
self.error = Some(error.into());
}
pub fn clear_error(&mut self) {
self.error = None;
}
pub fn handle_key(&mut self, key: KeyEvent) -> InputResult {
self.clear_error();
match (key.code, key.modifiers) {
(KeyCode::Enter, _) => {
let value = self.buffer.clone();
InputResult::Submit(value)
}
(KeyCode::Esc, _) => InputResult::Cancel,
(KeyCode::Backspace, _) => {
if self.cursor > 0 {
let start = self.previous_boundary();
self.buffer.replace_range(start..self.cursor, "");
self.cursor = start;
self.snap_cursor_forward();
}
InputResult::Continue
}
(KeyCode::Delete, _) => {
if self.cursor < self.buffer.len() {
let end = self.next_boundary();
self.buffer.replace_range(self.cursor..end, "");
self.snap_cursor_forward();
}
InputResult::Continue
}
(KeyCode::Left, _) => {
self.cursor = self.previous_boundary();
InputResult::Continue
}
(KeyCode::Right, _) => {
self.cursor = self.next_boundary();
InputResult::Continue
}
(KeyCode::Home, _) | (KeyCode::Char('a'), KeyModifiers::CONTROL) => {
self.cursor = 0;
InputResult::Continue
}
(KeyCode::End, _) | (KeyCode::Char('e'), KeyModifiers::CONTROL) => {
self.cursor = self.buffer.len();
InputResult::Continue
}
(KeyCode::Char('u'), KeyModifiers::CONTROL) => {
self.buffer.clear();
self.cursor = 0;
InputResult::Continue
}
(KeyCode::Char('k'), KeyModifiers::CONTROL) => {
self.buffer.truncate(self.cursor);
InputResult::Continue
}
(KeyCode::Char('w'), KeyModifiers::CONTROL) => {
if self.cursor > 0 {
let mut start = self.cursor;
let mut found_word = false;
for (index, grapheme) in self.buffer[..self.cursor].grapheme_indices(true).rev()
{
let whitespace = grapheme.chars().all(char::is_whitespace);
if found_word && whitespace {
break;
}
found_word |= !whitespace;
start = index;
}
self.buffer.replace_range(start..self.cursor, "");
self.cursor = start;
self.snap_cursor_forward();
}
InputResult::Continue
}
(KeyCode::Char(c), KeyModifiers::NONE | KeyModifiers::SHIFT) => {
self.buffer.insert(self.cursor, c);
self.cursor += c.len_utf8();
self.snap_cursor_forward();
InputResult::Continue
}
_ => InputResult::Continue,
}
}
pub fn validate_filename(&self) -> Result<(), String> {
gravityfile_ops::validate_filename(&self.buffer)
}
fn previous_boundary(&self) -> usize {
self.buffer
.grapheme_indices(true)
.map(|(index, _)| index)
.take_while(|&index| index < self.cursor)
.last()
.unwrap_or(0)
}
fn next_boundary(&self) -> usize {
self.buffer
.grapheme_indices(true)
.map(|(index, _)| index)
.find(|&index| index > self.cursor)
.unwrap_or(self.buffer.len())
}
fn snap_cursor_forward(&mut self) {
self.cursor = self
.buffer
.grapheme_indices(true)
.map(|(index, _)| index)
.find(|&index| index >= self.cursor)
.unwrap_or(self.buffer.len());
}
}
#[derive(Debug, Clone)]
pub enum InputResult {
Continue,
Cancel,
Submit(String),
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
fn key_event(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent {
KeyEvent {
code,
modifiers,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
}
}
#[test]
fn unicode_editing_preserves_grapheme_boundaries() {
use ratatui::{buffer::Buffer, layout::Rect, style::Modifier, widgets::Widget};
for value in ["é日", "e\u{301}x", "👩💻🇺🇸1️⃣", "a\u{2003}word "] {
let mut input = InputState::new();
for character in value.chars() {
input.handle_key(key_event(KeyCode::Char(character), KeyModifiers::NONE));
assert!(input.buffer.is_char_boundary(input.cursor));
for width in [0, 1, 6, 20, 80] {
let area = Rect::new(0, 0, width, 24);
let mut buffer = Buffer::empty(area);
crate::ui::modals::InputModal::new(
&crate::theme::Theme::default(),
&input,
"Rename",
"Name",
)
.render(area, &mut buffer);
}
}
assert_eq!(input.buffer(), value);
let boundaries: Vec<_> = value
.grapheme_indices(true)
.map(|(index, _)| index)
.chain(std::iter::once(value.len()))
.collect();
for &boundary in boundaries.iter().rev().skip(1) {
input.handle_key(key_event(KeyCode::Left, KeyModifiers::NONE));
assert_eq!(input.cursor(), boundary);
}
for &boundary in boundaries.iter().skip(1) {
input.handle_key(key_event(KeyCode::Right, KeyModifiers::NONE));
assert_eq!(input.cursor(), boundary);
}
for remaining in (0..value.graphemes(true).count()).rev() {
input.handle_key(key_event(KeyCode::Backspace, KeyModifiers::NONE));
assert_eq!(input.buffer.graphemes(true).count(), remaining);
}
let mut input = InputState::with_initial(value);
input.handle_key(key_event(KeyCode::Home, KeyModifiers::NONE));
for remaining in (0..value.graphemes(true).count()).rev() {
input.handle_key(key_event(KeyCode::Delete, KeyModifiers::NONE));
assert_eq!(input.buffer.graphemes(true).count(), remaining);
}
}
let mut input = InputState::with_initial("é\u{2003}word ");
input.handle_key(key_event(KeyCode::Char('w'), KeyModifiers::CONTROL));
assert_eq!(input.buffer(), "é\u{2003}");
input.handle_key(key_event(KeyCode::Char('w'), KeyModifiers::CONTROL));
assert!(input.buffer().is_empty());
let mut input = InputState::with_initial("🇺x🇸");
input.handle_key(key_event(KeyCode::Left, KeyModifiers::NONE));
input.handle_key(key_event(KeyCode::Backspace, KeyModifiers::NONE));
assert_eq!(input.buffer(), "🇺🇸");
assert_eq!(input.cursor(), input.buffer().len());
let mut input = InputState::with_initial("é日");
input.handle_key(key_event(KeyCode::Left, KeyModifiers::NONE));
let area = Rect::new(0, 0, 80, 24);
let mut buffer = Buffer::empty(area);
crate::ui::modals::InputModal::new(
&crate::theme::Theme::default(),
&input,
"Rename",
"Name",
)
.render(area, &mut buffer);
assert!(
buffer
.content()
.iter()
.any(|cell| cell.symbol() == "日" && cell.modifier.contains(Modifier::REVERSED))
);
}
#[test]
fn test_input_basic() {
let mut input = InputState::new();
input.handle_key(key_event(KeyCode::Char('t'), KeyModifiers::NONE));
input.handle_key(key_event(KeyCode::Char('e'), KeyModifiers::NONE));
input.handle_key(key_event(KeyCode::Char('s'), KeyModifiers::NONE));
input.handle_key(key_event(KeyCode::Char('t'), KeyModifiers::NONE));
assert_eq!(input.buffer(), "test");
assert_eq!(input.cursor(), 4);
}
#[test]
fn test_input_backspace() {
let mut input = InputState::with_initial("test");
input.handle_key(key_event(KeyCode::Backspace, KeyModifiers::NONE));
assert_eq!(input.buffer(), "tes");
assert_eq!(input.cursor(), 3);
}
#[test]
fn test_input_cursor_movement() {
let mut input = InputState::with_initial("test");
input.handle_key(key_event(KeyCode::Home, KeyModifiers::NONE));
assert_eq!(input.cursor(), 0);
input.handle_key(key_event(KeyCode::End, KeyModifiers::NONE));
assert_eq!(input.cursor(), 4);
input.handle_key(key_event(KeyCode::Left, KeyModifiers::NONE));
assert_eq!(input.cursor(), 3);
input.handle_key(key_event(KeyCode::Right, KeyModifiers::NONE));
assert_eq!(input.cursor(), 4);
}
#[test]
fn test_validate_filename() {
let mut input = InputState::new();
input.buffer = "test.txt".to_string();
assert!(input.validate_filename().is_ok());
input.buffer = ".hidden".to_string();
assert!(input.validate_filename().is_ok());
input.buffer = "my-file_v2".to_string();
assert!(input.validate_filename().is_ok());
input.buffer = "".to_string();
assert!(input.validate_filename().is_err());
input.buffer = "test/file".to_string();
assert!(input.validate_filename().is_err());
input.buffer = ".".to_string();
assert!(input.validate_filename().is_err());
input.buffer = "..".to_string();
assert!(input.validate_filename().is_err());
}
#[test]
fn test_submit_and_cancel() {
let mut input = InputState::with_initial("test");
let result = input.handle_key(key_event(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(result, InputResult::Submit(s) if s == "test"));
let result = input.handle_key(key_event(KeyCode::Esc, KeyModifiers::NONE));
assert!(matches!(result, InputResult::Cancel));
}
}