use alloc::string::String;
use retroglyph_core::event::{Event, KeyCode, KeyModifiers};
use retroglyph_core::text::{char_width, width_usize};
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct TextInputState {
value: String,
cursor: usize,
scroll: u16,
}
impl TextInputState {
#[must_use]
pub const fn new() -> Self {
Self {
value: String::new(),
cursor: 0,
scroll: 0,
}
}
#[must_use]
pub fn value(&self) -> &str {
&self.value
}
pub fn set_value(&mut self, s: impl Into<String>) {
self.value = s.into();
self.cursor = self.value.len();
self.scroll = 0;
}
#[must_use]
pub const fn cursor(&self) -> usize {
self.cursor
}
#[must_use]
pub const fn scroll(&self) -> u16 {
self.scroll
}
pub fn insert(&mut self, c: char) {
self.value.insert(self.cursor, c);
self.cursor += c.len_utf8();
self.debug_assert_cursor_valid();
}
pub fn insert_str(&mut self, s: &str) {
self.value.insert_str(self.cursor, s);
self.cursor += s.len();
self.debug_assert_cursor_valid();
}
pub fn backspace(&mut self) {
let Some(prev) = self.prev_boundary() else {
return;
};
self.value.drain(prev..self.cursor);
self.cursor = prev;
self.debug_assert_cursor_valid();
}
pub fn delete(&mut self) {
let Some(next) = self.next_boundary() else {
return;
};
self.value.drain(self.cursor..next);
self.debug_assert_cursor_valid();
}
pub fn move_left(&mut self) {
if let Some(prev) = self.prev_boundary() {
self.cursor = prev;
}
self.debug_assert_cursor_valid();
}
pub fn move_right(&mut self) {
if let Some(next) = self.next_boundary() {
self.cursor = next;
}
self.debug_assert_cursor_valid();
}
pub const fn move_home(&mut self) {
self.cursor = 0;
}
pub const fn move_end(&mut self) {
self.cursor = self.value.len();
}
pub fn handle_event(&mut self, event: &Event) -> bool {
match event {
Event::Key(key) if key.is_down() => match key.code {
KeyCode::Char(c)
if (key.modifiers
& (KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER))
.is_empty() =>
{
self.insert(c);
true
}
KeyCode::Backspace => {
self.backspace();
true
}
KeyCode::Delete => {
self.delete();
true
}
KeyCode::Left => {
self.move_left();
true
}
KeyCode::Right => {
self.move_right();
true
}
KeyCode::Home => {
self.move_home();
true
}
KeyCode::End => {
self.move_end();
true
}
_ => false,
},
Event::Paste(s) => {
self.insert_str(s);
true
}
_ => false,
}
}
pub fn ensure_visible(&mut self, width: u16) {
if width == 0 {
return;
}
let caret_col = self.caret_column();
if caret_col < self.scroll {
self.scroll = self.snap_to_char_boundary(caret_col);
} else if caret_col >= self.scroll.saturating_add(width) {
let target = caret_col.saturating_add(1).saturating_sub(width);
self.scroll = self.snap_to_char_boundary(target);
}
}
#[must_use]
fn snap_to_char_boundary(&self, col: u16) -> u16 {
let mut cols = 0u16;
for ch in self.value.chars() {
let next = cols.saturating_add(char_width(ch));
if next > col {
break;
}
cols = next;
}
cols
}
#[must_use]
fn caret_column(&self) -> u16 {
#[allow(clippy::cast_possible_truncation)] let col = width_usize(&self.value[..self.cursor]).min(usize::from(u16::MAX)) as u16;
col
}
fn prev_boundary(&self) -> Option<usize> {
self.value[..self.cursor]
.char_indices()
.next_back()
.map(|(i, _)| i)
}
fn next_boundary(&self) -> Option<usize> {
let mut chars = self.value[self.cursor..].char_indices();
chars.next()?;
Some(
chars
.next()
.map_or(self.value.len(), |(i, _)| self.cursor + i),
)
}
#[inline]
fn debug_assert_cursor_valid(&self) {
debug_assert!(
self.value.is_char_boundary(self.cursor),
"cursor {} is not a char boundary in {:?}",
self.cursor,
self.value
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use retroglyph_core::event::{KeyEvent, KeyEventKind};
#[test]
fn insert_and_backspace_move_the_byte_cursor() {
let mut state = TextInputState::new();
state.insert('h');
state.insert('i');
assert_eq!(state.value(), "hi");
assert_eq!(state.cursor(), 2);
state.backspace();
assert_eq!(state.value(), "h");
assert_eq!(state.cursor(), 1);
}
#[test]
fn backspace_and_delete_stay_on_char_boundaries_for_multibyte_chars() {
let mut state = TextInputState::new();
state.set_value("aあb");
state.move_left(); state.backspace(); assert_eq!(state.value(), "ab");
assert_eq!(state.cursor(), 1);
state.set_value("aあb");
state.move_home();
state.move_right(); state.delete(); assert_eq!(state.value(), "ab");
assert_eq!(state.cursor(), 1);
}
#[test]
fn move_left_right_home_end_traverse_whole_characters() {
let mut state = TextInputState::new();
state.set_value("hi");
state.move_home();
assert_eq!(state.cursor(), 0);
state.move_right();
assert_eq!(state.cursor(), 1);
state.move_end();
assert_eq!(state.cursor(), 2);
state.move_left();
assert_eq!(state.cursor(), 1);
}
#[test]
fn insert_str_handles_paste() {
let mut state = TextInputState::new();
state.insert_str("hello");
assert_eq!(state.value(), "hello");
assert_eq!(state.cursor(), 5);
}
#[test]
fn handle_event_consumes_typed_keys_and_paste() {
let mut state = TextInputState::new();
let key = |code| Event::Key(KeyEvent::new(code, KeyModifiers::NONE));
assert!(state.handle_event(&key(KeyCode::Char('x'))));
assert_eq!(state.value(), "x");
assert!(state.handle_event(&Event::Paste("yz".into())));
assert_eq!(state.value(), "xyz");
assert!(state.handle_event(&key(KeyCode::Backspace)));
assert_eq!(state.value(), "xy");
assert!(!state.handle_event(&key(KeyCode::Enter)));
}
#[test]
fn text_input_handle_event_does_not_type_ctrl_shortcut_characters() {
let mut state = TextInputState::new();
let ctrl_s = Event::Key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL));
let consumed = state.handle_event(&ctrl_s);
assert_eq!(state.value(), "");
assert!(!consumed);
}
#[test]
fn handle_event_still_types_shifted_characters() {
let mut state = TextInputState::new();
let shift_s = Event::Key(KeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT));
let consumed = state.handle_event(&shift_s);
assert_eq!(state.value(), "S");
assert!(consumed);
}
#[test]
fn handle_event_ignores_key_releases() {
let mut state = TextInputState::new();
let release = Event::Key(KeyEvent::with_kind(
KeyCode::Char('x'),
KeyModifiers::NONE,
KeyEventKind::Release,
));
assert!(!state.handle_event(&release));
assert_eq!(state.value(), "");
}
#[test]
fn ensure_visible_scrolls_to_keep_the_caret_in_view() {
let mut state = TextInputState::new();
state.set_value("hello world");
state.ensure_visible(5);
assert_eq!(state.scroll(), 11 + 1 - 5);
state.move_home();
state.ensure_visible(5);
assert_eq!(state.scroll(), 0);
}
#[test]
fn ensure_visible_accounts_for_wide_characters() {
let mut state = TextInputState::new();
state.set_value("aああ"); state.ensure_visible(3);
assert_eq!(state.scroll(), 5 + 1 - 3);
}
#[test]
fn ensure_visible_does_not_overflow_when_the_caret_column_saturates() {
let mut state = TextInputState::new();
state.insert_str(&"a".repeat(70_000));
state.ensure_visible(5);
assert_eq!(state.scroll(), u16::MAX - 5);
}
#[test]
fn ensure_visible_is_a_noop_for_zero_width() {
let mut state = TextInputState::new();
state.set_value("hello");
state.ensure_visible(0);
assert_eq!(state.scroll(), 0);
}
#[test]
fn text_input_ensure_visible_scroll_does_not_split_a_wide_character() {
let mut state = TextInputState::new();
state.set_value("ああ"); state.ensure_visible(4);
assert_eq!(state.scroll(), 0);
}
}