use ratatui_core::layout::Rect;
use ratatui_core::style::Style;
use unicode_segmentation::UnicodeSegmentation;
use crate::event::{Event, KeyCode};
use crate::geometry::Size;
use crate::surface::Surface;
use crate::view::{RenderCtx, View};
use crate::width::grapheme_cols;
#[derive(Clone, Debug)]
pub struct TextInputState {
lines: Vec<String>,
row: usize,
col: usize,
mode: TextInputMode,
revision: u64,
}
#[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)]
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(),
revision: 0,
}
}
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) -> crate::InputOutcome {
match self.enter_event(shift) {
TextInputEvent::Changed => crate::InputOutcome::Changed,
TextInputEvent::Submit => crate::InputOutcome::Submitted,
}
}
fn enter_event(&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) {
let mut lines: Vec<String> = text.split('\n').map(str::to_string).collect();
if lines.is_empty() {
lines.push(String::new());
}
if self.lines != lines {
self.revision = self.revision.wrapping_add(1);
}
self.lines = lines;
self.row = self.lines.len() - 1;
self.col = self.lines[self.row].chars().count();
}
pub fn clear(&mut self) {
if !self.is_empty() {
self.revision = self.revision.wrapping_add(1);
}
self.lines.clear();
self.lines.push(String::new());
self.row = 0;
self.col = 0;
}
pub fn set_cursor(&mut self, row: usize, col: usize) {
self.row = row.min(self.lines.len().saturating_sub(1));
self.col = grapheme_boundary_at_or_before(&self.lines[self.row], col);
}
fn row_chars(&self, row: usize) -> Vec<char> {
self.lines[row].chars().collect()
}
fn set_row(&mut self, row: usize, chars: Vec<char>) {
let line: String = chars.into_iter().collect();
if self.lines[row] != line {
self.lines[row] = line;
self.revision = self.revision.wrapping_add(1);
}
}
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.revision = self.revision.wrapping_add(1);
self.row += 1;
self.col = 0;
}
pub fn backspace(&mut self) {
if self.col > 0 {
let mut chars = self.row_chars(self.row);
let start = previous_grapheme_boundary(&self.lines[self.row], self.col);
chars.drain(start..self.col);
self.col = start;
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);
self.revision = self.revision.wrapping_add(1);
}
}
pub fn delete(&mut self) {
let mut chars = self.row_chars(self.row);
if self.col < chars.len() {
let end = next_grapheme_boundary(&self.lines[self.row], self.col);
chars.drain(self.col..end);
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);
self.revision = self.revision.wrapping_add(1);
}
}
fn clamp_col(&mut self) {
self.col = grapheme_boundary_at_or_before(&self.lines[self.row], self.col);
}
pub fn move_left(&mut self) {
if self.col > 0 {
self.col = previous_grapheme_boundary(&self.lines[self.row], self.col);
} 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 = next_grapheme_boundary(&self.lines[self.row], self.col);
} 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) -> crate::InputOutcome {
let before = (self.row, self.col, self.revision);
match self.handle_event(event) {
Some(TextInputEvent::Changed) if (self.row, self.col, self.revision) == before => {
crate::InputOutcome::Consumed
}
Some(TextInputEvent::Changed) => crate::InputOutcome::Changed,
Some(TextInputEvent::Submit) => crate::InputOutcome::Submitted,
None => crate::InputOutcome::Ignored,
}
}
fn handle_event(&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.enter_event(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)
}
}
fn grapheme_boundaries(line: &str) -> Vec<usize> {
let mut boundaries = Vec::with_capacity(line.graphemes(true).count() + 1);
boundaries.push(0);
let mut col = 0;
for grapheme in line.graphemes(true) {
col += grapheme.chars().count();
boundaries.push(col);
}
boundaries
}
fn grapheme_boundary_at_or_before(line: &str, col: usize) -> usize {
let col = col.min(line.chars().count());
grapheme_boundaries(line)
.into_iter()
.take_while(|boundary| *boundary <= col)
.last()
.unwrap_or(0)
}
fn previous_grapheme_boundary(line: &str, col: usize) -> usize {
grapheme_boundaries(line)
.into_iter()
.take_while(|boundary| *boundary < col)
.last()
.unwrap_or(0)
}
fn next_grapheme_boundary(line: &str, col: usize) -> usize {
grapheme_boundaries(line)
.into_iter()
.find(|boundary| *boundary > col)
.unwrap_or_else(|| line.chars().count())
}
#[derive(Clone, Copy)]
struct VisualCell<'a> {
text: &'a str,
start: usize,
end: usize,
width: u16,
}
struct VisualRow<'a> {
logical: usize,
start: usize,
end: usize,
cells: Vec<VisualCell<'a>>,
width: u16,
}
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, &VisualRow)> = None;
for (vi, vr) in rows.iter().enumerate() {
if vr.logical > row {
break;
}
if vr.logical != row {
continue;
}
last_on_line = Some((vi, vr));
if col >= vr.start && col < vr.end {
let visual_col = vr
.cells
.iter()
.take_while(|cell| cell.end <= col)
.map(|cell| cell.width)
.fold(0, u16::saturating_add);
return (vi as u16, visual_col);
}
}
if let Some((vi, vr)) = last_on_line {
return (vi as u16, vr.width);
}
(rows.len().saturating_sub(1) as u16, 0)
}
fn wrap_visual_rows<'a>(lines: &'a [String], width: u16) -> Vec<VisualRow<'a>> {
let width = width.max(1);
let mut rows = Vec::new();
for (r, line) in lines.iter().enumerate() {
let mut char_col = 0;
let cells: Vec<VisualCell<'a>> = line
.graphemes(true)
.map(|grapheme| {
let start = char_col;
char_col += grapheme.chars().count();
VisualCell {
text: grapheme,
start,
end: char_col,
width: grapheme_cols(grapheme),
}
})
.collect();
if cells.is_empty() {
rows.push(VisualRow {
logical: r,
start: 0,
end: 0,
cells: Vec::new(),
width: 0,
});
continue;
}
let mut start = 0;
let mut last_filled = false;
while start < cells.len() {
let mut hard_end = start;
let mut hard_width = 0u16;
while hard_end < cells.len() {
let next_width = hard_width.saturating_add(cells[hard_end].width);
if hard_end > start && next_width > width {
break;
}
hard_width = next_width;
hard_end += 1;
if hard_width >= width {
break;
}
}
let end = if hard_end == cells.len() {
hard_end
} else {
(start + 1..hard_end)
.rev()
.find(|&i| cells[i].text.chars().all(char::is_whitespace))
.map(|i| i + 1)
.unwrap_or(hard_end)
};
let row_width = cells[start..end]
.iter()
.map(|cell| cell.width)
.fold(0, u16::saturating_add);
last_filled = row_width == width;
rows.push(VisualRow {
logical: r,
start: cells[start].start,
end: cells[end - 1].end,
cells: cells[start..end].to_vec(),
width: row_width,
});
start = end;
}
if last_filled {
rows.push(VisualRow {
logical: r,
start: char_col,
end: char_col,
cells: Vec::new(),
width: 0,
});
}
}
rows
}
#[derive(Clone, Debug, Default)]
pub struct SingleLineInputState {
inner: TextInputState,
}
impl SingleLineInputState {
pub fn new() -> Self {
Self::default()
}
pub fn from_text(text: &str) -> Self {
let mut state = Self::new();
state.set_text(text);
state
}
pub fn text(&self) -> &str {
&self.inner.lines[0]
}
pub fn is_empty(&self) -> bool {
self.inner.is_empty()
}
pub fn cursor(&self) -> usize {
self.inner.col
}
pub fn set_text(&mut self, text: &str) {
self.inner.set_text(&normalize_single_line(text));
}
pub fn clear(&mut self) {
self.inner.clear();
}
pub fn insert_char(&mut self, ch: char) {
self.inner
.insert_char(if matches!(ch, '\r' | '\n') { ' ' } else { ch });
}
pub fn insert_str(&mut self, text: &str) {
self.inner.insert_str(&normalize_single_line(text));
}
pub fn as_text_input(&self) -> &TextInputState {
&self.inner
}
pub fn handle(&mut self, event: &Event) -> crate::InputOutcome {
match event {
Event::Paste(text) => {
let before = (self.inner.row, self.inner.col, self.inner.revision);
self.insert_str(text);
if (self.inner.row, self.inner.col, self.inner.revision) == before {
crate::InputOutcome::Consumed
} else {
crate::InputOutcome::Changed
}
}
Event::Key(key)
if key.plain()
&& matches!(key.code, KeyCode::Enter | KeyCode::Char('\n' | '\r')) =>
{
crate::InputOutcome::Submitted
}
Event::Key(key)
if key.ctrl && !key.alt && !key.shift && key.code == KeyCode::Char('j') =>
{
crate::InputOutcome::Submitted
}
_ => self.inner.handle(event),
}
}
}
fn normalize_single_line(text: &str) -> String {
let mut normalized = String::with_capacity(text.len());
let mut chars = text.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'\r' => {
if chars.peek() == Some(&'\n') {
chars.next();
}
normalized.push(' ');
}
'\n' => normalized.push(' '),
_ => normalized.push(ch),
}
}
normalized
}
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, _ctx: &RenderCtx) -> 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 cell in vr.cells {
if cell.width == 0 || x >= area.right() {
continue;
}
surface.set_string(x, y, cell.text, self.style_at(vr.logical, cell.start));
x = x.saturating_add(cell.width);
}
}
}
}
#[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;
#[test]
fn single_line_normalizes_setter_and_paste_newlines() {
let mut state = SingleLineInputState::from_text("alpha\r\nbeta\ngamma\rdelta");
assert_eq!(state.text(), "alpha beta gamma delta");
assert_eq!(
state.handle(&Event::Paste(" one\r\ntwo\nthree".into())),
crate::InputOutcome::Changed
);
assert_eq!(state.text(), "alpha beta gamma delta one two three");
}
#[test]
fn single_line_text_is_borrowed_and_cursor_stays_on_one_row() {
let mut state = SingleLineInputState::new();
state.insert_str("café");
let borrowed: &str = state.text();
assert_eq!(borrowed, "café");
assert_eq!(state.cursor(), 4);
assert_eq!(state.as_text_input().cursor(), (0, 4));
}
#[test]
fn single_line_enter_and_ctrl_j_submit_without_mutating() {
let mut state = SingleLineInputState::from_text("query");
for event in [
Event::Key(Key::new(KeyCode::Enter)),
Event::Key(Key {
code: KeyCode::Char('j'),
ctrl: true,
alt: false,
shift: false,
}),
] {
assert_eq!(state.handle(&event), crate::InputOutcome::Submitted);
assert_eq!(state.text(), "query");
}
}
fn press(state: &mut TextInputState, code: KeyCode) -> bool {
matches!(
state.handle(&Event::Key(Key::new(code))),
crate::InputOutcome::Changed
)
}
fn press_shift(state: &mut TextInputState, code: KeyCode) -> bool {
matches!(
state.handle(&Event::Key(Key {
code,
ctrl: false,
alt: false,
shift: true,
})),
crate::InputOutcome::Changed
)
}
fn press_ctrl(state: &mut TextInputState, code: KeyCode) -> bool {
matches!(
state.handle(&Event::Key(Key {
code,
ctrl: true,
alt: false,
shift: false,
})),
crate::InputOutcome::Changed
)
}
fn press_alt(state: &mut TextInputState, code: KeyCode) -> bool {
matches!(
state.handle(&Event::Key(Key {
code,
ctrl: false,
alt: true,
shift: false,
})),
crate::InputOutcome::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())),
crate::InputOutcome::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_wraps_and_places_the_cursor_by_terminal_cells() {
let state = TextInputState::from_text("界界界");
assert_eq!(state.visual_height(4), 2);
assert_eq!(state.cursor_screen(Rect::new(0, 0, 4, 2)), (2, 1));
assert_eq!(
render_view_rows(&TextInput::new(&state), 4, 2),
vec!["界 界", "界"]
);
let emoji = TextInputState::from_text("👩💻x");
assert_eq!(
render_view_rows(&TextInput::new(&emoji), 3, 2),
vec!["👩💻 x", ""]
);
}
#[test]
fn text_input_moves_and_deletes_whole_graphemes() {
let mut state = TextInputState::from_text("a\u{301}b");
state.move_left();
assert_eq!(state.cursor(), (0, 2));
state.move_left();
assert_eq!(state.cursor(), (0, 0));
state.set_cursor(0, 2);
state.backspace();
assert_eq!(state.text(), "b");
assert_eq!(state.cursor(), (0, 0));
let mut state = TextInputState::from_text("👩💻x");
state.move_left();
assert_eq!(state.cursor(), (0, 3));
state.backspace();
assert_eq!(state.text(), "x");
assert_eq!(state.cursor(), (0, 0));
}
#[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), crate::InputOutcome::Submitted);
assert_eq!(state.handle_enter(true), crate::InputOutcome::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), crate::InputOutcome::Changed);
assert_eq!(state.handle_enter(true), crate::InputOutcome::Submitted);
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),
crate::InputOutcome::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))),
crate::InputOutcome::Submitted,
"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))),
crate::InputOutcome::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), crate::InputOutcome::Submitted);
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");
}
}