Skip to Content
ftui-widgetsInput & textarea

Input and Textarea

FrankenTUI ships two text-entry widgets:

  • Input (aka TextInput) — a single-line field with a cursor, range selection, and optional history. Deliberately stateless on the widget side: the parent holds the String.
  • Textarea — a multi-line editor backed by the rope editor, with soft-wrap, optional line numbers, and hooks for syntax-highlighting.

Both widgets report their cursor position to the frame so the presenter can emit the terminal cursor at the right cell. Neither touches stdin — events flow through the runtime.

Input — single-line

Source: input.rs:142.

use ftui_widgets::input::{Input, InputState}; let input = Input::new(&self.query) // the String is borrowed from your model .placeholder("Search…") .cursor(self.cursor_pos); // cursor index in bytes input.render(area, frame);

The widget is a pure Widget — it takes &self.query: &str, cursor: usize, and renders them. The String and the cursor index live on your model; InputState is a small helper with conveniences for history and selection.

Cursor and selection

Input draws the cursor at the requested byte offset (cursor reporting goes through frame.cursor_position). For a selection range, pass a (start, end) pair; the widget paints the range with the selection style from your theme.

History ring

InputState includes an optional bounded history ring — up / down cycles previous entries. Wire it through your update():

match event { Event::Key(k) if k.code == KeyCode::Up => { if let Some(prev) = self.input_state.history_prev() { self.query = prev.to_string(); self.cursor_pos = self.query.len(); } } Event::Key(k) if k.code == KeyCode::Enter => { self.input_state.history_push(self.query.clone()); self.submit(); } // … normal character handling }

Essential by default

Input::is_essential() returns true — text inputs keep rendering even at EssentialOnly degradation. The user must see what they’re typing.

Textarea — multi-line

Source: textarea.rs:94.

use ftui_widgets::textarea::Textarea; let textarea = Textarea::new(&self.body) .line_numbers(true) .soft_wrap(true); textarea.render(area, frame);

The backing store is a rope — see rope for the data structure and editor for the cursor / selection model. This lets a Textarea scale gracefully to multi-megabyte buffers: inserts and deletes are O(log n), not O(n).

Soft wrap vs hard wrap

  • Soft wrap (default): long lines wrap visually but remain one logical line. Cursor navigation by “end of line” uses logical lines, not display lines.
  • Hard wrap: the widget inserts real \n characters. Less common; useful for plain-text editors targeting fixed-width output.

Switch with .soft_wrap(true | false). Both modes keep the underlying rope correct for undo / redo.

Line numbers

line_numbers(true) adds a left gutter with right-aligned line numbers. Width is computed from the line count: a 10K-line document uses a 5-cell gutter. The numbers themselves are styled through the frame theme.

Syntax highlighting hooks

Textarea exposes a styling callback per line span:

let textarea = Textarea::new(&self.body) .style_span(|range: Range<usize>| -> Option<Style> { highlight_rust(&self.body[range]) });

Wire this to ftui-extras/syntax for tree-sitter-backed highlighting (see the extras syntax.rs).

Undo / redo

The rope editor provides undo coalescing — consecutive single-character inserts collapse into one undo step. Textarea forwards keystrokes to the editor, which handles undo / redo transparently. Bind it in update():

Event::Key(k) if k.code == KeyCode::Char('z') && k.modifiers.ctrl() => { self.editor.undo(); } Event::Key(k) if k.code == KeyCode::Char('y') && k.modifiers.ctrl() => { self.editor.redo(); }

Worked example: search box + comment field

A simple form with both widgets.

use ftui_widgets::{ Widget, block::{Block, Borders}, input::Input, textarea::Textarea, }; use ftui_layout::{Layout, Direction, Constraint}; pub struct Model { query: String, query_cursor: usize, body: String, } // Helper called from `Model::view(&self, frame)`. The `Model` trait // requires `&self`; use `&mut self` here and call it from `view` via a // `RefCell` or rename once you wire it in. impl Model { fn render_form(&mut self, frame: &mut ftui_render::frame::Frame) { let area = frame.buffer.bounds(); let [top, bottom] = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Length(3), Constraint::Min(5)]) .split(area); Input::new(&self.query) .cursor(self.query_cursor) .placeholder("Search…") .block(Block::default().borders(Borders::ALL).title(" Filter ")) .render(top, frame); Textarea::new(&self.body) .line_numbers(true) .soft_wrap(true) .block(Block::default().borders(Borders::ALL).title(" Comment ")) .render(bottom, frame); } }

Focus management is your responsibility — typically you wire a FocusManager with two nodes (one per input) and route characters to whichever is focused.

Pitfalls

  • Holding a String inside the widget. Input is designed for the parent to own the buffer. If you find yourself copying the string into the widget each frame, you’ve reversed the ownership.
  • Cursor in character units. The cursor is a byte offset, not a character index. On UTF-8 with multi-byte code points, advance carefully — or use the editor’s cursor helpers which do grapheme-aware stepping.
  • Textarea for small strings. The rope has fixed overhead; for ten characters, Input is simpler and faster.
  • Forgetting to report cursor position. Both widgets call frame.set_cursor(x, y) internally. If you manually draw around them and also set cursor, the last setter wins — make sure the input widget renders last if you want its cursor to show.
  • Syntax highlighting that reads beyond the range. The style_span callback is called per visible line range. Reading globals or caches outside the range works but invalidation becomes your problem.

Where next