Text Input

Text inputs are controlled - you own the value, the widget owns the cursor and keyboard handling.

You provide the current text via .value(). Every keystroke fires on_change with the new text. You decide whether to accept it by updating your state. This gives you full control over validation, transformation, and character limits.


Single-line input

Use TextInput for short text like names, search queries, or form fields:

let name = state!(cx, || String::new());

View::text_input()
    .value(name.get())
    .placeholder("Enter name...")
    .on_change(with!(name => move |s: String| name.set(s)))
    .build()

Run with: cargo run -p telex-tui --example 05_todo_list

The text input widget provides:

  • Keyboard editing (type, backspace, delete)
  • Left/right arrow navigation
  • Cursor positioning
  • Placeholder text when empty

The controlled pattern

Text inputs are controlled - you provide the value and handle changes:

let input = state!(cx, || String::new());

View::text_input()
    .value(input.get())                    // current value
    .on_change(with!(input => move |text: String| {
        input.set(text);                   // update on change
    }))
    .build()

Every keystroke triggers on_change with the new text. You decide whether to accept it by updating state.

This pattern enables:

  • Validation (reject invalid input)
  • Transformation (uppercase, trimming)
  • Character limits
  • Real-time feedback

Handling submit

Use on_submit to respond when the user presses Enter:

View::text_input()
    .value(input.get())
    .placeholder("Type something...")
    .on_change(with!(input => move |text: String| input.set(text)))
    .on_submit(with!(input, items => move || {
        let text = input.get();
        if !text.is_empty() {
            items.update(|v| v.push(text));
            input.set(String::new());  // clear after submit
        }
    }))
    .build()

Common pattern: validate on submit, clear the input if successful.

Arrow key callbacks

TextInput supports on_key_up and on_key_down for command history or autocomplete:

let history = state!(cx, || vec!["ls", "cd ..", "git status"]);
let history_idx = state!(cx, || None::<usize>);

View::text_input()
    .value(input.get())
    .on_change(on_change)
    .on_key_up(with!(history, history_idx, input => move || {
        let h = history.get();
        if h.is_empty() { return; }
        let idx = history_idx.get().map(|i| i.saturating_sub(1)).unwrap_or(h.len() - 1);
        history_idx.set(Some(idx));
        input.set(h[idx].to_string());
    }))
    .on_key_down(with!(history, history_idx, input => move || {
        let h = history.get();
        if let Some(idx) = history_idx.get() {
            if idx + 1 < h.len() {
                history_idx.set(Some(idx + 1));
                input.set(h[idx + 1].to_string());
            }
        }
    }))
    .build()

These fire when the input is focused and up/down arrows are pressed.

Validation example

Only allow numeric input:

let number = state!(cx, || String::new());

View::text_input()
    .value(number.get())
    .placeholder("Numbers only...")
    .on_change(with!(number => move |text: String| {
        // Only update if all chars are digits
        if text.chars().all(|c| c.is_ascii_digit()) {
            number.set(text);
        }
    }))
    .build()

Since you control what goes into state, invalid input is simply ignored.

Character limits

Enforce a maximum length:

View::text_input()
    .value(username.get())
    .on_change(with!(username => move |text: String| {
        if text.chars().count() <= 20 {
            username.set(text);
        }
    }))
    .build()

Important: Use .chars().count() not .len() for character limits. .len() counts bytes, not user-perceived characters. This matters for Unicode: "café" has 4 characters but 5 bytes, and emoji like "👍" are multiple bytes. Using .len() would incorrectly limit Unicode input.

Multi-line input

Use TextArea for longer content like notes, messages, or code:

let content = state!(cx, || String::new());

View::text_area()
    .value(content.get())
    .placeholder("Start typing...")
    .rows(10)  // height in lines
    .on_change(with!(content => move |text: String| content.set(text)))
    .build()

Run with: cargo run -p telex-tui --example 12_text_area

TextArea supports:

  • Multi-line editing with Enter
  • Up/down/left/right navigation
  • Scrolling when content exceeds height
  • Line wrapping

Tracking cursor position

TextArea can report cursor location:

let cursor_line = state!(cx, || 0usize);
let cursor_col = state!(cx, || 0usize);

View::text_area()
    .value(content.get())
    .rows(12)
    .on_change(with!(content => move |text: String| content.set(text)))
    .on_cursor_change(with!(cursor_line, cursor_col => move |line: usize, col: usize| {
        cursor_line.set(line);
        cursor_col.set(col);
    }))
    .build()

Use this to show cursor position, implement syntax highlighting, or track editing location.

Real-time stats

Calculate line/word/char counts as the user types:

let text = content.get();
let line_count = if text.is_empty() { 0 } else { text.lines().count() };
let word_count = text.split_whitespace().count();
let char_count = text.chars().count();

View::text(format!("Lines: {} | Words: {} | Chars: {}",
    line_count, word_count, char_count))

This demonstrates the reactive model - state changes automatically trigger re-render with updated stats.

Initial focus

Make an input focused when the component appears:

View::text_input()
    .value(input.get())
    .focused(true)  // focus on mount
    .on_change(on_change)
    .build()

Useful for dialogs, forms, or any UI where the user's next action is typing.

TextInput vs TextArea

Use TextInput when:

  • Single line of text
  • Short responses (names, URLs, search)
  • Submit on Enter makes sense
  • Forms with multiple fields

Use TextArea when:

  • Multiple lines expected
  • Long-form content (notes, descriptions, messages)
  • Editing structured text (JSON, code, logs)
  • Height can be predetermined

Placeholder text

Show hints when the input is empty:

View::text_input()
    .placeholder("Search files...")
    .value(search.get())
    .on_change(on_change)
    .build()

Placeholders disappear when the user starts typing. Keep them short and actionable.

Common patterns

Clear on submit:

.on_submit(with!(input => move || {
    process_input(input.get());
    input.set(String::new());
}))

Trim whitespace:

.on_submit(with!(input => move || {
    let trimmed = input.get().trim().to_string();
    if !trimmed.is_empty() {
        process_input(trimmed);
    }
    input.set(String::new());
}))

Character counter:

let remaining = 280 - input.get().len();
View::text(format!("{} characters remaining", remaining))

Tips

Don't validate on every keystroke - Let users type freely, validate on submit or blur.

Show errors clearly - If validation fails, show why. Don't silently reject input.

Preserve cursor position - If you transform input, the cursor may jump. Consider whether transformations should happen on change or on submit.

TextArea scrolling - Content longer than rows automatically scrolls. Don't try to fit everything by making rows huge.

Next: Modals