Input and Textarea
FrankenTUI ships two text-entry widgets:
Input(akaTextInput) — a single-line field with a cursor, range selection, and optional history. Deliberately stateless on the widget side: the parent holds theString.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
\ncharacters. 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
Stringinside the widget.Inputis 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,
Inputis 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_spancallback is called per visible line range. Reading globals or caches outside the range works but invalidation becomes your problem.