use ratatui_core::layout::Rect;
use ratatui_core::style::Style;
use unicode_width::UnicodeWidthChar;
use crate::event::{Event, KeyCode};
use crate::geometry::Size;
use crate::surface::Surface;
use crate::view::{RenderCtx, View};
#[derive(Clone, Debug)]
pub struct TextInputState {
lines: Vec<String>,
row: usize,
col: usize,
mode: TextInputMode,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TextInputMode {
#[default]
SubmitOnEnter,
SubmitOnShiftEnter,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TriggerAnchor {
Anywhere,
#[default]
WordStart,
LineStart,
BufferStart,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Trigger {
pub start: char,
pub anchor: TriggerAnchor,
pub stop_at_whitespace: bool,
}
impl Trigger {
pub fn new(start: char) -> Self {
Self {
start,
anchor: TriggerAnchor::default(),
stop_at_whitespace: true,
}
}
pub fn anchor(mut self, anchor: TriggerAnchor) -> Self {
self.anchor = anchor;
self
}
pub fn to_line_end(mut self) -> Self {
self.stop_at_whitespace = false;
self
}
fn anchored_at(&self, chars: &[char], row: usize, col: usize) -> bool {
match self.anchor {
TriggerAnchor::Anywhere => true,
TriggerAnchor::WordStart => {
col == 0 || chars.get(col - 1).is_some_and(|c| c.is_whitespace())
}
TriggerAnchor::LineStart => col == 0,
TriggerAnchor::BufferStart => row == 0 && col == 0,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Token {
pub trigger: char,
pub row: usize,
pub start: usize,
pub end: usize,
pub text: String,
}
impl Token {
pub fn query(&self) -> &str {
let mut chars = self.text.chars();
chars.next();
chars.as_str()
}
pub fn span(&self, style: Style) -> TextSpan {
TextSpan {
row: self.row,
start: self.start,
end: self.end,
style,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TextSpan {
pub row: usize,
pub start: usize,
pub end: usize,
pub style: Style,
}
impl TextSpan {
pub fn new(row: usize, start: usize, end: usize, style: Style) -> Self {
Self {
row,
start,
end,
style,
}
}
fn covers(&self, row: usize, col: usize) -> bool {
self.row == row && col >= self.start && col < self.end
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TextInputEvent {
Changed,
Submit,
}
impl Default for TextInputState {
fn default() -> Self {
Self::new()
}
}
impl TextInputState {
pub fn new() -> Self {
Self {
lines: vec![String::new()],
row: 0,
col: 0,
mode: TextInputMode::default(),
}
}
pub fn set_mode(&mut self, mode: TextInputMode) {
self.mode = mode;
}
pub fn mode(&self) -> TextInputMode {
self.mode
}
pub fn handle_enter(&mut self, shift: bool) -> TextInputEvent {
let submit = match self.mode {
TextInputMode::SubmitOnEnter => !shift,
TextInputMode::SubmitOnShiftEnter => shift,
};
if submit {
TextInputEvent::Submit
} else {
self.newline();
TextInputEvent::Changed
}
}
pub fn from_text(text: &str) -> Self {
let mut s = Self::new();
s.set_text(text);
s
}
pub fn text(&self) -> String {
self.lines.join("\n")
}
pub fn is_empty(&self) -> bool {
self.lines.len() == 1 && self.lines[0].is_empty()
}
pub fn line_count(&self) -> usize {
self.lines.len()
}
pub fn cursor(&self) -> (usize, usize) {
(self.row, self.col)
}
pub fn set_text(&mut self, text: &str) {
self.lines = text.split('\n').map(str::to_string).collect();
if self.lines.is_empty() {
self.lines.push(String::new());
}
self.row = self.lines.len() - 1;
self.col = self.lines[self.row].chars().count();
}
pub fn clear(&mut self) {
let mode = self.mode;
*self = Self::new();
self.mode = mode;
}
pub fn set_cursor(&mut self, row: usize, col: usize) {
self.row = row.min(self.lines.len().saturating_sub(1));
self.col = col.min(self.lines[self.row].chars().count());
}
fn row_chars(&self, row: usize) -> Vec<char> {
self.lines[row].chars().collect()
}
fn set_row(&mut self, row: usize, chars: Vec<char>) {
self.lines[row] = chars.into_iter().collect();
}
pub fn insert_char(&mut self, ch: char) {
if ch == '\n' {
self.newline();
return;
}
let mut chars = self.row_chars(self.row);
let at = self.col.min(chars.len());
chars.insert(at, ch);
self.set_row(self.row, chars);
self.col = at + 1;
}
pub fn insert_str(&mut self, s: &str) {
for ch in s.chars() {
self.insert_char(ch);
}
}
pub fn newline(&mut self) {
let chars = self.row_chars(self.row);
let at = self.col.min(chars.len());
let tail: String = chars[at..].iter().collect();
let head: String = chars[..at].iter().collect();
self.lines[self.row] = head;
self.lines.insert(self.row + 1, tail);
self.row += 1;
self.col = 0;
}
pub fn backspace(&mut self) {
if self.col > 0 {
let mut chars = self.row_chars(self.row);
chars.remove(self.col - 1);
self.col -= 1;
self.set_row(self.row, chars);
} else if self.row > 0 {
let cur = self.lines.remove(self.row);
self.row -= 1;
self.col = self.lines[self.row].chars().count();
self.lines[self.row].push_str(&cur);
}
}
pub fn delete(&mut self) {
let mut chars = self.row_chars(self.row);
if self.col < chars.len() {
chars.remove(self.col);
self.set_row(self.row, chars);
} else if self.row + 1 < self.lines.len() {
let next = self.lines.remove(self.row + 1);
self.lines[self.row].push_str(&next);
}
}
fn clamp_col(&mut self) {
self.col = self.col.min(self.lines[self.row].chars().count());
}
pub fn move_left(&mut self) {
if self.col > 0 {
self.col -= 1;
} else if self.row > 0 {
self.row -= 1;
self.col = self.lines[self.row].chars().count();
}
}
pub fn move_right(&mut self) {
let len = self.lines[self.row].chars().count();
if self.col < len {
self.col += 1;
} else if self.row + 1 < self.lines.len() {
self.row += 1;
self.col = 0;
}
}
pub fn move_up(&mut self) {
if self.row > 0 {
self.row -= 1;
self.clamp_col();
} else {
self.col = 0;
}
}
pub fn move_down(&mut self) {
if self.row + 1 < self.lines.len() {
self.row += 1;
self.clamp_col();
} else {
self.col = self.lines[self.row].chars().count();
}
}
pub fn move_home(&mut self) {
self.col = 0;
}
pub fn move_end(&mut self) {
self.col = self.lines[self.row].chars().count();
}
fn prev_word_col(&self) -> usize {
let chars = self.row_chars(self.row);
let mut i = self.col.min(chars.len());
while i > 0 && chars[i - 1].is_whitespace() {
i -= 1;
}
while i > 0 && !chars[i - 1].is_whitespace() {
i -= 1;
}
i
}
fn next_word_col(&self) -> usize {
let chars = self.row_chars(self.row);
let len = chars.len();
let mut i = self.col.min(len);
while i < len && chars[i].is_whitespace() {
i += 1;
}
while i < len && !chars[i].is_whitespace() {
i += 1;
}
i
}
pub fn move_word_left(&mut self) {
if self.col == 0 {
self.move_left();
return;
}
self.col = self.prev_word_col();
}
pub fn move_word_right(&mut self) {
if self.col >= self.lines[self.row].chars().count() {
self.move_right();
return;
}
self.col = self.next_word_col();
}
pub fn delete_word_left(&mut self) {
if self.col == 0 {
self.backspace();
return;
}
let start = self.prev_word_col();
let mut chars = self.row_chars(self.row);
chars.drain(start..self.col);
self.col = start;
self.set_row(self.row, chars);
}
pub fn delete_word_right(&mut self) {
let len = self.lines[self.row].chars().count();
if self.col >= len {
self.delete();
return;
}
let end = self.next_word_col();
let mut chars = self.row_chars(self.row);
chars.drain(self.col..end);
self.set_row(self.row, chars);
}
pub fn kill_to_line_end(&mut self) {
let mut chars = self.row_chars(self.row);
if self.col < chars.len() {
chars.truncate(self.col);
self.set_row(self.row, chars);
} else {
self.delete();
}
}
pub fn kill_to_line_start(&mut self) {
let chars = self.row_chars(self.row);
let tail: Vec<char> = chars[self.col.min(chars.len())..].to_vec();
self.set_row(self.row, tail);
self.col = 0;
}
pub fn handle(&mut self, event: &Event) -> Option<TextInputEvent> {
match event {
Event::Key(k) if k.ctrl && !k.alt => match k.code {
KeyCode::Char('a') => {
self.move_home();
Some(TextInputEvent::Changed)
}
KeyCode::Char('e') => {
self.move_end();
Some(TextInputEvent::Changed)
}
KeyCode::Char('f') => {
self.move_right();
Some(TextInputEvent::Changed)
}
KeyCode::Char('b') => {
self.move_left();
Some(TextInputEvent::Changed)
}
KeyCode::Char('p') => {
self.move_up();
Some(TextInputEvent::Changed)
}
KeyCode::Char('n') => {
self.move_down();
Some(TextInputEvent::Changed)
}
KeyCode::Char('h') => {
self.backspace();
Some(TextInputEvent::Changed)
}
KeyCode::Char('d') => {
self.delete();
Some(TextInputEvent::Changed)
}
KeyCode::Char('j') => {
self.newline();
Some(TextInputEvent::Changed)
}
KeyCode::Char('k') => {
self.kill_to_line_end();
Some(TextInputEvent::Changed)
}
KeyCode::Char('u') => {
self.kill_to_line_start();
Some(TextInputEvent::Changed)
}
KeyCode::Char('w') => {
self.delete_word_left();
Some(TextInputEvent::Changed)
}
_ => None,
},
Event::Key(k) if k.alt && !k.ctrl => match k.code {
KeyCode::Char('f') => {
self.move_word_right();
Some(TextInputEvent::Changed)
}
KeyCode::Char('b') => {
self.move_word_left();
Some(TextInputEvent::Changed)
}
KeyCode::Char('d') => {
self.delete_word_right();
Some(TextInputEvent::Changed)
}
KeyCode::Backspace => {
self.delete_word_left();
Some(TextInputEvent::Changed)
}
_ => None,
},
Event::Key(k) if !k.ctrl && !k.alt => match k.code {
KeyCode::Char(c) => {
self.insert_char(c);
Some(TextInputEvent::Changed)
}
KeyCode::Enter => Some(self.handle_enter(k.shift)),
KeyCode::Backspace => {
self.backspace();
Some(TextInputEvent::Changed)
}
KeyCode::Delete => {
self.delete();
Some(TextInputEvent::Changed)
}
KeyCode::Left => {
self.move_left();
Some(TextInputEvent::Changed)
}
KeyCode::Right => {
self.move_right();
Some(TextInputEvent::Changed)
}
KeyCode::Up => {
self.move_up();
Some(TextInputEvent::Changed)
}
KeyCode::Down => {
self.move_down();
Some(TextInputEvent::Changed)
}
KeyCode::Home => {
self.move_home();
Some(TextInputEvent::Changed)
}
KeyCode::End => {
self.move_end();
Some(TextInputEvent::Changed)
}
_ => None,
},
Event::Paste(text) => {
self.insert_str(text);
Some(TextInputEvent::Changed)
}
_ => None,
}
}
pub fn tokens(&self, triggers: &[Trigger]) -> Vec<Token> {
let mut out = Vec::new();
for (row, line) in self.lines.iter().enumerate() {
let chars: Vec<char> = line.chars().collect();
let mut col = 0;
while col < chars.len() {
let Some(trigger) = triggers
.iter()
.find(|t| t.start == chars[col] && t.anchored_at(&chars, row, col))
else {
col += 1;
continue;
};
let mut end = col + 1;
if trigger.stop_at_whitespace {
while end < chars.len() && !chars[end].is_whitespace() {
end += 1;
}
} else {
end = chars.len();
}
out.push(Token {
trigger: trigger.start,
row,
start: col,
end,
text: chars[col..end].iter().collect(),
});
col = end.max(col + 1);
}
}
out
}
pub fn active_token(&self, triggers: &[Trigger]) -> Option<Token> {
self.tokens(triggers)
.into_iter()
.find(|t| t.row == self.row && self.col > t.start && self.col <= t.end)
}
pub fn replace_token(&mut self, token: &Token, replacement: &str) {
let Some(line) = self.lines.get_mut(token.row) else {
return;
};
let chars: Vec<char> = line.chars().collect();
let start = token.start.min(chars.len());
let end = token.end.min(chars.len()).max(start);
let mut next: String = chars[..start].iter().collect();
next.push_str(replacement);
next.extend(chars[end..].iter());
*line = next;
self.row = token.row;
self.col = start + replacement.chars().count();
}
pub fn visual_height(&self, width: u16) -> u16 {
wrap_visual_rows(&self.lines, width).len().max(1) as u16
}
fn visual_cursor(&self, width: u16) -> (u16, u16) {
visual_cursor_at(&self.lines, self.row, self.col, width)
}
pub fn scroll_offset(&self, width: u16, height: u16) -> u16 {
self.visual_cursor(width)
.0
.saturating_sub(height.saturating_sub(1))
}
pub fn cursor_screen(&self, area: Rect) -> (u16, u16) {
let (vrow, vcol) = self.visual_cursor(area.width);
let offset = vrow.saturating_sub(area.height.saturating_sub(1));
let x = area
.x
.saturating_add(vcol.min(area.width.saturating_sub(1)));
let y = area
.y
.saturating_add((vrow - offset).min(area.height.saturating_sub(1)));
(x, y)
}
}
struct VisualRow {
logical: usize,
start: usize,
chars: Vec<char>,
}
fn visual_cursor_at(lines: &[String], row: usize, col: usize, width: u16) -> (u16, u16) {
let rows = wrap_visual_rows(lines, width);
let mut last_on_line: Option<(usize, usize)> = None; for (vi, vr) in rows.iter().enumerate() {
if vr.logical > row {
break;
}
if vr.logical != row {
continue;
}
let end = vr.start + vr.chars.len();
last_on_line = Some((vi, vr.start));
if col >= vr.start && col < end {
return (vi as u16, (col - vr.start) as u16);
}
}
if let Some((vi, start)) = last_on_line {
return (vi as u16, col.saturating_sub(start) as u16);
}
(rows.len().saturating_sub(1) as u16, 0)
}
fn wrap_visual_rows(lines: &[String], width: u16) -> Vec<VisualRow> {
let width = width.max(1) as usize;
let mut rows = Vec::new();
for (r, line) in lines.iter().enumerate() {
let chars: Vec<char> = line.chars().collect();
if chars.is_empty() {
rows.push(VisualRow {
logical: r,
start: 0,
chars: Vec::new(),
});
continue;
}
let mut start = 0;
let mut last_filled = false;
while start < chars.len() {
let remaining = chars.len() - start;
let end = if remaining <= width {
chars.len()
} else {
let hard = start + width;
let brk = (start + 1..hard).rev().find(|&i| chars[i] == ' ');
brk.map(|i| i + 1).unwrap_or(hard)
};
last_filled = end - start == width;
rows.push(VisualRow {
logical: r,
start,
chars: chars[start..end].to_vec(),
});
start = end;
}
if last_filled {
rows.push(VisualRow {
logical: r,
start: chars.len(),
chars: Vec::new(),
});
}
}
rows
}
pub struct TextInput {
lines: Vec<String>,
cursor: (usize, usize),
style: Style,
highlights: Vec<TextSpan>,
placeholder: Option<(String, Style)>,
}
impl TextInput {
pub fn new(state: &TextInputState) -> Self {
Self {
lines: state.lines.clone(),
cursor: (state.row, state.col),
style: Style::default(),
highlights: Vec::new(),
placeholder: None,
}
}
pub fn style(mut self, style: Style) -> Self {
self.style = style;
self
}
pub fn highlights(mut self, spans: Vec<TextSpan>) -> Self {
self.highlights = spans;
self
}
pub fn placeholder(mut self, text: impl Into<String>, style: Style) -> Self {
self.placeholder = Some((text.into(), style));
self
}
fn style_at(&self, row: usize, col: usize) -> Style {
self.highlights
.iter()
.filter(|span| span.covers(row, col))
.fold(self.style, |style, span| style.patch(span.style))
}
fn is_empty(&self) -> bool {
self.lines.len() == 1 && self.lines[0].is_empty()
}
fn scroll_offset(&self, width: u16, height: u16) -> u16 {
visual_cursor_at(&self.lines, self.cursor.0, self.cursor.1, width)
.0
.saturating_sub(height.saturating_sub(1))
}
}
impl View for TextInput {
fn measure(&self, available: Size) -> Size {
let height = wrap_visual_rows(&self.lines, available.width).len().max(1) as u16;
Size::new(available.width, height)
}
fn render(&self, area: Rect, surface: &mut Surface, _ctx: &RenderCtx) {
if area.width == 0 || area.height == 0 {
return;
}
if let Some((text, style)) = &self.placeholder
&& self.is_empty()
{
surface.set_string(area.x, area.y, text, *style);
return;
}
let offset = self.scroll_offset(area.width, area.height) as usize;
for (i, vr) in wrap_visual_rows(&self.lines, area.width)
.into_iter()
.enumerate()
.skip(offset)
{
let y = area.y.saturating_add((i - offset) as u16);
if y >= area.bottom() {
break;
}
let mut x = area.x;
for (n, ch) in vr.chars.into_iter().enumerate() {
let w = UnicodeWidthChar::width(ch).unwrap_or(0) as u16;
if w == 0 || x >= area.right() {
continue;
}
surface.set(x, y, ch, self.style_at(vr.logical, vr.start + n));
x = x.saturating_add(w);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::{Event, Key, KeyCode};
use crate::style::Theme;
use crate::surface::Surface;
use crate::tests::support::{buffer, render_el, render_view_rows};
use crate::view::{RenderCtx, element};
use ratatui_core::layout::Rect;
use ratatui_core::style::Color;
fn press(state: &mut TextInputState, code: KeyCode) -> bool {
matches!(
state.handle(&Event::Key(Key::new(code))),
Some(TextInputEvent::Changed)
)
}
fn press_shift(state: &mut TextInputState, code: KeyCode) -> bool {
matches!(
state.handle(&Event::Key(Key {
code,
ctrl: false,
alt: false,
shift: true,
})),
Some(TextInputEvent::Changed)
)
}
fn press_ctrl(state: &mut TextInputState, code: KeyCode) -> bool {
matches!(
state.handle(&Event::Key(Key {
code,
ctrl: true,
alt: false,
shift: false,
})),
Some(TextInputEvent::Changed)
)
}
fn press_alt(state: &mut TextInputState, code: KeyCode) -> bool {
matches!(
state.handle(&Event::Key(Key {
code,
ctrl: false,
alt: true,
shift: false,
})),
Some(TextInputEvent::Changed)
)
}
fn type_str(state: &mut TextInputState, s: &str) {
for ch in s.chars() {
assert!(press(state, KeyCode::Char(ch)));
}
}
#[test]
fn text_input_starts_empty() {
let state = TextInputState::new();
assert!(state.is_empty());
assert_eq!(state.text(), "");
assert_eq!(state.cursor(), (0, 0));
assert_eq!(state.line_count(), 1);
}
#[test]
fn text_input_types_and_edits() {
let mut state = TextInputState::new();
type_str(&mut state, "helo");
assert_eq!(state.text(), "helo");
assert_eq!(state.cursor(), (0, 4));
press(&mut state, KeyCode::Left);
press(&mut state, KeyCode::Left);
assert_eq!(state.cursor(), (0, 2));
assert!(press(&mut state, KeyCode::Char('l')));
assert_eq!(state.text(), "hello");
assert_eq!(state.cursor(), (0, 3));
}
#[test]
fn text_input_backspace_and_delete() {
let mut state = TextInputState::from_text("abc");
assert_eq!(state.cursor(), (0, 3));
press(&mut state, KeyCode::Backspace);
assert_eq!(state.text(), "ab");
press(&mut state, KeyCode::Home);
press(&mut state, KeyCode::Delete);
assert_eq!(state.text(), "b");
assert_eq!(state.cursor(), (0, 0));
}
#[test]
fn text_input_newline_splits_and_backspace_joins() {
let mut state = TextInputState::from_text("abcd");
press(&mut state, KeyCode::Home);
press(&mut state, KeyCode::Right);
press(&mut state, KeyCode::Right);
assert_eq!(state.cursor(), (0, 2));
assert!(press_shift(&mut state, KeyCode::Enter));
assert_eq!(state.text(), "ab\ncd");
assert_eq!(state.line_count(), 2);
assert_eq!(state.cursor(), (1, 0));
press(&mut state, KeyCode::Backspace);
assert_eq!(state.text(), "abcd");
assert_eq!(state.cursor(), (0, 2));
assert_eq!(state.line_count(), 1);
}
#[test]
fn text_input_vertical_movement_clamps_column() {
let mut state = TextInputState::from_text("longline\nhi");
press(&mut state, KeyCode::Up);
assert_eq!(state.cursor(), (0, 2));
press(&mut state, KeyCode::End);
assert_eq!(state.cursor(), (0, 8));
press(&mut state, KeyCode::Down);
assert_eq!(state.cursor(), (1, 2));
}
#[test]
fn text_input_paste_inserts_multiline() {
let mut state = TextInputState::new();
assert_eq!(
state.handle(&Event::Paste("one\ntwo".to_string())),
Some(TextInputEvent::Changed)
);
assert_eq!(state.text(), "one\ntwo");
assert_eq!(state.line_count(), 2);
assert_eq!(state.cursor(), (1, 3));
}
#[test]
fn text_input_unbound_ctrl_keys_ignored() {
let mut state = TextInputState::from_text("x");
assert!(!press_ctrl(&mut state, KeyCode::Char('z')));
assert_eq!(state.text(), "x");
}
#[test]
fn text_input_emacs_cursor_bindings() {
let mut state = TextInputState::from_text("hello");
assert!(press_ctrl(&mut state, KeyCode::Char('a')));
assert_eq!(state.cursor(), (0, 0));
assert!(press_ctrl(&mut state, KeyCode::Char('f')));
assert_eq!(state.cursor(), (0, 1));
assert!(press_ctrl(&mut state, KeyCode::Char('e')));
assert_eq!(state.cursor(), (0, 5));
assert!(press_ctrl(&mut state, KeyCode::Char('b')));
assert_eq!(state.cursor(), (0, 4));
state = TextInputState::from_text("ab\ncd");
press(&mut state, KeyCode::Home);
assert!(press_ctrl(&mut state, KeyCode::Char('p')));
assert_eq!(state.cursor(), (0, 0));
assert!(press_ctrl(&mut state, KeyCode::Char('n')));
assert_eq!(state.cursor(), (1, 0));
}
#[test]
fn text_input_emacs_delete_bindings() {
let mut state = TextInputState::from_text("abc");
assert!(press_ctrl(&mut state, KeyCode::Char('h')));
assert_eq!(state.text(), "ab");
press(&mut state, KeyCode::Home);
assert!(press_ctrl(&mut state, KeyCode::Char('d')));
assert_eq!(state.text(), "b");
}
#[test]
fn text_input_kill_to_line_end_and_start() {
let mut state = TextInputState::from_text("hello world");
press(&mut state, KeyCode::Home);
press(&mut state, KeyCode::Right);
press(&mut state, KeyCode::Right);
press(&mut state, KeyCode::Right);
press(&mut state, KeyCode::Right);
press(&mut state, KeyCode::Right); assert!(press_ctrl(&mut state, KeyCode::Char('k')));
assert_eq!(state.text(), "hello");
assert_eq!(state.cursor(), (0, 5));
let mut state = TextInputState::from_text("ab\ncd");
press(&mut state, KeyCode::Home);
press(&mut state, KeyCode::Up);
press(&mut state, KeyCode::End);
assert!(press_ctrl(&mut state, KeyCode::Char('k')));
assert_eq!(state.text(), "abcd");
let mut state = TextInputState::from_text("hello world");
assert!(press_ctrl(&mut state, KeyCode::Char('u')));
assert_eq!(state.text(), "");
assert_eq!(state.cursor(), (0, 0));
}
#[test]
fn text_input_word_move_and_delete() {
let mut state = TextInputState::from_text("foo bar baz");
assert!(press_alt(&mut state, KeyCode::Char('b')));
assert_eq!(state.cursor(), (0, 8)); assert!(press_alt(&mut state, KeyCode::Char('b')));
assert_eq!(state.cursor(), (0, 4));
assert!(press_ctrl(&mut state, KeyCode::Char('w')));
assert_eq!(state.text(), "bar baz");
assert_eq!(state.cursor(), (0, 0));
assert!(press_alt(&mut state, KeyCode::Char('f')));
assert_eq!(state.cursor(), (0, 3)); assert!(press_alt(&mut state, KeyCode::Char('d')));
assert_eq!(state.text(), "bar");
let mut state = TextInputState::from_text("alpha beta");
assert!(press_alt(&mut state, KeyCode::Backspace));
assert_eq!(state.text(), "alpha ");
}
#[test]
fn text_input_scrolls_to_cursor_when_taller_than_area() {
let mut state = TextInputState::new();
for i in 0..10 {
type_str(&mut state, &format!("line{i}"));
if i < 9 {
state.newline();
}
}
let out = render_view_rows(&TextInput::new(&state), 10, 3);
assert_eq!(out, vec!["line7", "line8", "line9"]);
let (_, y) = state.cursor_screen(Rect::new(0, 0, 10, 3));
assert_eq!(y, 2);
for _ in 0..9 {
state.move_up();
}
let out = render_view_rows(&TextInput::new(&state), 10, 3);
assert_eq!(out, vec!["line0", "line1", "line2"]);
assert_eq!(state.cursor_screen(Rect::new(0, 0, 10, 3)).1, 0);
}
#[test]
fn text_input_renders_wrapped_rows() {
let mut state = TextInputState::new();
type_str(&mut state, "abcdef");
assert_eq!(state.visual_height(4), 2);
let out = render_view_rows(&TextInput::new(&state), 4, 2);
assert_eq!(out[0], "abcd");
assert_eq!(out[1], "ef");
}
#[test]
fn text_input_word_wraps_at_spaces() {
let mut state = TextInputState::new();
type_str(&mut state, "hello world foo");
assert_eq!(state.visual_height(8), 3);
let out = render_view_rows(&TextInput::new(&state), 8, 3);
assert_eq!(out[0], "hello");
assert_eq!(out[1], "world");
assert_eq!(out[2], "foo");
}
#[test]
fn text_input_hard_breaks_overlong_word() {
let mut state = TextInputState::new();
type_str(&mut state, "abcdefghij");
assert_eq!(state.visual_height(4), 3);
let out = render_view_rows(&TextInput::new(&state), 4, 3);
assert_eq!(out[0], "abcd");
assert_eq!(out[1], "efgh");
assert_eq!(out[2], "ij");
}
#[test]
fn text_input_cursor_tracks_word_wrap() {
let mut state = TextInputState::from_text("hello world");
let area = Rect::new(0, 0, 8, 3);
assert_eq!(state.cursor_screen(area), (5, 1));
state.move_home();
for _ in 0..6 {
state.move_right();
}
assert_eq!(state.cursor_screen(area), (0, 1));
}
#[test]
fn text_input_cursor_screen_follows_wrap() {
let mut state = TextInputState::new();
type_str(&mut state, "abcd");
assert_eq!(state.visual_height(4), 2);
let area = Rect::new(2, 1, 4, 3);
assert_eq!(state.cursor_screen(area), (2, 2));
press(&mut state, KeyCode::Home);
assert_eq!(state.cursor_screen(area), (2, 1));
}
#[test]
fn text_input_set_and_clear() {
let mut state = TextInputState::new();
state.set_text("hello\nworld");
assert_eq!(state.cursor(), (1, 5));
assert_eq!(state.line_count(), 2);
state.clear();
assert!(state.is_empty());
assert_eq!(state.cursor(), (0, 0));
}
#[test]
fn text_input_set_cursor_clamps() {
let mut state = TextInputState::from_text("hi\nthere");
state.set_cursor(0, 1);
assert_eq!(state.cursor(), (0, 1));
state.set_cursor(9, 9);
assert_eq!(state.cursor(), (1, 5));
}
#[test]
fn text_input_composes_into_view_tree() {
let mut state = TextInputState::new();
type_str(&mut state, "hi");
let tree = element(TextInput::new(&state));
let out = render_el(&tree, 4, 1);
assert_eq!(out[0], "hi");
}
#[test]
fn submit_on_enter_mode_uses_shift_enter_for_newline() {
let mut state = TextInputState::new();
assert_eq!(state.mode(), TextInputMode::SubmitOnEnter);
assert_eq!(state.handle_enter(false), TextInputEvent::Submit);
assert_eq!(state.handle_enter(true), TextInputEvent::Changed);
assert_eq!(state.text(), "\n");
}
#[test]
fn submit_on_shift_enter_mode_reverses_enter_chords() {
let mut state = TextInputState::new();
state.set_mode(TextInputMode::SubmitOnShiftEnter);
assert_eq!(state.handle_enter(false), TextInputEvent::Changed);
assert_eq!(state.handle_enter(true), TextInputEvent::Submit);
assert_eq!(state.text(), "\n");
}
#[test]
fn handle_honors_submit_on_enter_mode_for_shift_enter_newline() {
let mut state = TextInputState::new();
assert_eq!(state.mode(), TextInputMode::SubmitOnEnter);
type_str(&mut state, "one");
let shift_enter = Event::Key(Key {
code: KeyCode::Enter,
ctrl: false,
alt: false,
shift: true,
});
assert_eq!(
state.handle(&shift_enter),
Some(TextInputEvent::Changed),
"Shift+Enter must insert a newline under SubmitOnEnter"
);
assert_eq!(state.text(), "one\n");
type_str(&mut state, "two");
assert_eq!(
state.handle(&Event::Key(Key::new(KeyCode::Enter))),
Some(TextInputEvent::Submit),
"plain Enter must submit under SubmitOnEnter, not insert another newline"
);
assert_eq!(
state.text(),
"one\ntwo",
"submit must leave the draft text intact"
);
}
#[test]
fn handle_honors_submit_on_shift_enter_mode() {
let mut state = TextInputState::new();
state.set_mode(TextInputMode::SubmitOnShiftEnter);
type_str(&mut state, "one");
assert_eq!(
state.handle(&Event::Key(Key::new(KeyCode::Enter))),
Some(TextInputEvent::Changed)
);
assert_eq!(state.text(), "one\n");
type_str(&mut state, "two");
let shift_enter = Event::Key(Key {
code: KeyCode::Enter,
ctrl: false,
alt: false,
shift: true,
});
assert_eq!(state.handle(&shift_enter), Some(TextInputEvent::Submit));
assert_eq!(state.text(), "one\ntwo");
}
#[test]
fn clear_preserves_enter_mode() {
let mut state = TextInputState::new();
state.set_mode(TextInputMode::SubmitOnShiftEnter);
type_str(&mut state, "draft");
state.clear();
assert!(state.is_empty());
assert_eq!(state.mode(), TextInputMode::SubmitOnShiftEnter);
}
#[test]
fn handle_ctrl_j_inserts_newline() {
let mut state = TextInputState::new();
type_str(&mut state, "ab");
assert!(press_ctrl(&mut state, KeyCode::Char('j')));
assert_eq!(state.text(), "ab\n");
assert_eq!(state.cursor(), (1, 0));
}
#[test]
fn tokens_respect_each_trigger_anchor() {
let state = TextInputState::from_text("/model gpt\nsee @src/lib.rs and me@example.com");
let triggers = [
Trigger::new('/').anchor(TriggerAnchor::BufferStart),
Trigger::new('@'),
];
let found = state.tokens(&triggers);
assert_eq!(found.len(), 2);
assert_eq!((found[0].trigger, found[0].text.as_str()), ('/', "/model"));
assert_eq!(
(found[1].trigger, found[1].text.as_str(), found[1].row),
('@', "@src/lib.rs", 1)
);
}
#[test]
fn a_trigger_can_span_a_query_with_spaces() {
let state = TextInputState::from_text("/model gpt 5");
let to_end = [Trigger::new('/')
.anchor(TriggerAnchor::BufferStart)
.to_line_end()];
let tokens = state.tokens(&to_end);
assert_eq!(tokens[0].text, "/model gpt 5");
assert_eq!(tokens[0].query(), "model gpt 5");
}
#[test]
fn active_token_follows_the_cursor() {
let mut state = TextInputState::from_text("ship @doc");
let triggers = [Trigger::new('@')];
assert_eq!(state.active_token(&triggers).unwrap().query(), "doc");
state.set_cursor(0, 5);
assert!(state.active_token(&triggers).is_none());
state.set_cursor(0, 6);
assert_eq!(state.active_token(&triggers).unwrap().query(), "doc");
}
#[test]
fn replace_token_completes_in_place() {
let mut state = TextInputState::from_text("ship @doc now");
state.set_cursor(0, 9);
let token = state.active_token(&[Trigger::new('@')]).unwrap();
state.replace_token(&token, "@docs/readme.md");
assert_eq!(state.text(), "ship @docs/readme.md now");
assert_eq!(state.cursor(), (0, 20));
}
#[test]
fn highlights_style_only_their_range() {
let state = TextInputState::from_text("hi @you");
let mention = Style::default().fg(Color::Blue);
let spans: Vec<TextSpan> = state
.tokens(&[Trigger::new('@')])
.iter()
.map(|t| t.span(mention))
.collect();
let view = TextInput::new(&state)
.style(Style::default().fg(Color::White))
.highlights(spans);
let theme = Theme::default();
let mut buf = buffer(8, 1);
let area = buf.area;
let ctx = RenderCtx::new(&theme);
view.render(area, &mut Surface::new(&mut buf, area), &ctx);
assert_eq!(buf[(0, 0)].fg, Color::White); assert_eq!(buf[(3, 0)].fg, Color::Blue); assert_eq!(buf[(6, 0)].fg, Color::Blue); }
#[test]
fn placeholder_shows_only_while_empty() {
let mut state = TextInputState::new();
let dim = Style::default().fg(Color::DarkGray);
let rows = render_view_rows(
&TextInput::new(&state).placeholder("Ask me something", dim),
18,
1,
);
assert_eq!(rows[0].trim_end(), "Ask me something");
state.insert_char('x');
let rows = render_view_rows(
&TextInput::new(&state).placeholder("Ask me something", dim),
18,
1,
);
assert_eq!(rows[0].trim_end(), "x");
}
}