The problem: two fields are fine, three start to hurt
Anyone who has written even a small TUI with Ratatui knows the progression. The first input is trivial: a String in the app's state, a match on keyboard events to handle typing and deletion, a Paragraph widget to draw it. Maybe five minutes of work.
The second field is basically a copy-paste of the first. It still works, but something starts to creak: now you also need to track which of the two fields has focus, and Tab has to move it from one to the other.
It's with the third field that the problem changes nature. It's no longer "a bit of repeated code" — it's state management that doesn't scale: three Strings synced by hand, an index or an enum for focus, a keyboard whose events need to be routed to the right field based on that index, plus — if you want to do it properly — validation, error messages, a disabled field here, a read-only one there. Every new field adds complexity, and it adds it combinatorially, because it touches focus, validation, and rendering for every other field...
At that point there are two ways forward: rewrite, for the umpteenth time, the same focus-and-validation infrastructure, or look for a widget that handles it for you.
It's exactly out of this friction that ratiform was born — a crate I wrote to move that infrastructure out of application code.
The idea: the field identifier is a real Rust type
ratiform makes one deliberate structural choice: a field's identifier isn't a &str, it's a generic type T — in practice, almost always your own enum, though even an integer works as an identifier!
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum Field {
Name,
Email,
Country,
Terms,
}
FormBuilder, FormState<T> and Form<T> are all generic over this T. In practice, that means state.value(&Field::Email) is code the compiler checks for you: rename a variant of the enum, and every place that references it stops compiling until you update it. There's no "email" string hiding somewhere in the code that can silently drift out of sync with everything else.
"Typed", here, is about the field's identifier: Field::Email can never be confused with an arbitrary string. Values themselves remain strings, optionally convertible via value_as.
With that foundation in place, let's see ratiform in action — starting from the bare minimum and working up to theming.
1. The bare minimum: a single field
First, install it:
cargo add ratiform
And a form with a single text field:
use ratiform::{Form, builder::FormBuilder};
#[derive(Debug, Hash, Eq, PartialEq)]
enum Field {
Name,
}
let mut state = FormBuilder::new()
.single_line(Field::Name, "Name")
.build();
state is a FormState<Field> — the object your application owns for as long as the form is active: no hidden global state, no callback registry. Rendering happens by passing Form::default() (a stateless StatefulWidget) and &mut state to render_stateful_widget, exactly as you would with a List or a Table from Ratatui.
Worth noting: every field is required by default. You don't need to call .required(...) to get that behavior — you need .optional() if you want to opt out, or .required("your custom message") if you just want to replace the built-in error message with your own.
2. More fields, one Tab to move between them
Adding fields means chaining more calls onto the same builder — this is where the difference from the "three hand-synced Strings" scenario shows up:
#[derive(Debug, Hash, Eq, PartialEq)]
enum Field {
Username,
Password,
}
let mut state = FormBuilder::new()
.single_line(Field::Username, "Username")
.required("Username is required".to_owned())
.single_line(Field::Password, "Password")
.masked()
.required("Password is required".to_owned())
.build();
Focus, navigation and validation are already handled: Tab/Shift+Tab move focus between fields, Ctrl+Enter (or plain Enter, if the focused field doesn't intercept it for something else) submits the form — but only if no field is invalid — and Esc cancels it. The event loop in your application shrinks down to a handful of lines:
if let Event::Key(key) = event::read()?
&& key.kind == event::KeyEventKind::Press
{
state.handle_input(key);
match state.result() {
FormResult::Submitted | FormResult::Cancelled => break Ok(()),
FormResult::Working => {}
}
}
.masked() already hints at an important detail: masking is purely cosmetic. A password field shows dots on screen, but validation and value() still see exactly what the user typed.
3. Not just text: checkbox, select, text area
A form is rarely made only of single-line inputs. ratiform covers three other field kinds, with the same chained syntax.
A checkbox is a boolean, toggled with Space:
.checkbox(Field::Terms, "I accept the terms")
.checked(false)
.optional()
A select is a list of (value, label) pairs, navigated with the arrow keys, Home/End, PageUp/PageDown. A subtle point: value() returns the value, not the label shown on screen:
.select(Field::Country, "Country")
.values_ref(&[("IT", "Italy"), ("FR", "France"), ("DE", "Germany")])
.selected(1)
.height(5)
A text area handles multi-line text, with scrolling and paging. There's one keyboard-ergonomics detail worth calling out: since Enter inserts a newline instead of submitting the form, the combination to confirm while a TextArea has focus becomes Ctrl+Enter:
.text_area(Field::Notes, "Notes")
.placeholder("Write here...")
.height(5)
4. Getting values back: still no magic strings
Once the form has been submitted, values() consumes the state and returns an iterator of (T, String) pairs — which you can collect directly into a HashMap typed on your own enum, as long as that enum implements Hash and Eq, of course:
let values: HashMap<Field, String> = state.values().collect();
And if you need to read a value while the form is still in progress — say, to reflect what's been typed elsewhere in your UI, like a live preview — you don't have to wait for submission: value(&Field::Email) and value_as::<u16>(&Field::Port) — the latter parsing directly into any FromStr type — both work at any point.
5. Validation: "required" and "well-formed" are two separate questions
ratiform conceptually separates two different questions: "can this field be empty?" and "if it isn't empty, does its content have the right shape?". The first question is handled by the required/optional mechanism above; the second by .validator(...), a function Fn(&str) -> Result<(), String>.
.single_line(Field::CodiceFiscale, "Codice fiscale")
.validator(ratiform::validators::max_length(16, "Too long".to_owned()))
The library already ships a set of common validators under ratiform::validators: min_length/max_length (Unicode-aware), is_numeric, alphabetic/alphanumeric, no_whitespace, and parsable::<T>, which relies on T: FromStr — useful even with types that don't belong to ratiform, such as parsable::<chrono::NaiveDate>(...) for a correct, leap-year-aware date validator without ratiform itself depending on chrono.
One detail: a validator never sees an empty string. If the field is empty, the required check decides on its own — so none of the built-in validators (or your own) ever needs to special-case an empty string.
6. Normalizing, not just validating
Where validator judges an already-typed value, normalizer rewrites it into a canonical form before validation sees it:
.single_line(Field::CodiceFiscale, "Codice fiscale")
.normalizer(|value: &str| value.to_uppercase())
.validator(ratiform::validators::max_length(16, "Too long".to_owned()))
The normalizer runs on every keystroke, on set_value, and on the initial value: the field is never seen — not by the validator, not by is_dirty(), not by values() — in any form other than its normalized one. Paired with alphabet(...) on a single-line field, the split of responsibilities is clean: alphabet rejects a character outright, normalizer rewrites one that was allowed in but needs correcting.
7. Layout: horizontal or stacked, decided at runtime
By default, every field shows its label and value on the same row (FormLayout::Horizontal). If the terminal is narrow, FormLayout::Stacked puts the label above the value instead:
frame.render_stateful_widget(
Form::default().with_layout(FormLayout::Stacked),
area,
&mut state,
);
Since Form is rebuilt fresh every frame, nothing stops you from picking the layout based on the available width at that moment — which is exactly what examples/layouts.rs does, switching to Stacked once the terminal gets too narrow, with no dedicated code needed to intercept the resize.
Horizontal and Stacked are enough as long as the form's shape is "one row per field". But a shipping address, say, has a natural geometry of its own: street on its own full row, then city/state/zip split across three columns of different widths. For cases like this there's a third variant, FormLayout::Custom, which describes the form as an explicit grid of rows and columns, cell by cell:
Email Password
_______________________ ____________________
Address
____________________________________________
City State Zip
_____________________ ______________ _______
Each grid cell is a (Constraint, content) pair: the Constraint is the same Ratatui type you already use for ordinary layouts (Length, Fill, ...), and the content declares what to draw in that cell — a field's label (Label), its input widget (Value), its validation message (Error) — or nothing at all, for a spacer. The most readable way to write it is the custom_layout! macro, which mirrors the grid row by row:
use ratiform::{Form, FormLayout, builder::FormBuilder, custom_layout};
let grid_layout = custom_layout! {
// Email | Password
row [
(Constraint::Fill(1), Label(Field::Email)),
(Constraint::Fill(1), Label(Field::Password)),
],
row [
(Constraint::Fill(1), Value(Field::Email)),
(Constraint::Fill(1), Value(Field::Password)),
],
row [
(Constraint::Length(15), Error(Field::Email)),
(Constraint::Fill(1), Error(Field::Password)),
],
// Address
row [(Constraint::Fill(1), Label(Field::Address))],
row [(Constraint::Fill(1), Value(Field::Address))],
row [(Constraint::Fill(1), Error(Field::Address))],
// City | State | Zip
row [
(Constraint::Fill(1), Label(Field::City)),
(Constraint::Fill(1), Label(Field::State)),
(Constraint::Length(9), Label(Field::Zip)),
],
row [
(Constraint::Fill(1), Value(Field::City)),
(Constraint::Fill(1), Value(Field::State)),
(Constraint::Length(9), Value(Field::Zip)),
],
row [
(Constraint::Fill(1), Error(Field::City)),
(Constraint::Fill(1), Error(Field::State)),
(Constraint::Length(9), Error(Field::Zip)),
],
};
let form = Form::default().with_layout(FormLayout::Custom(grid_layout));
Three things worth noting:
-
Label, value, and error are independent cells. This isn't a cosmetic detail: you can place
Email's label above its value, yet aligned on the same row asPassword's label — something no combination ofHorizontal/Stackedallows, since there the three parts of a field always stay a single block. -
A spacer is a cell with
Nonecontent instead of one of the three identifiers — handy for visually separating groups of fields without inventing a dummy field. -
Focus still follows the order fields were declared in the builder, not their order on the grid. If the grid mirrors the
Email → Password → Address → ...sequence used insingle_line(...),Tabmoves in the "natural" order you'd expect looking at the screen; if the two sequences diverge,Tabstill works, but jumps from one point on screen to another in an order the eye doesn't anticipate. The reverse also holds: a field left out of the grid still receives focus when its turn comes — just with no visible cursor, and nowhere to show a validation error.
If you'd rather assemble the grid programmatically — rows generated in a loop, for instance — CustomLayout::builder() is the fluent equivalent of the same macro:
let grid_layout = CustomLayout::builder()
.row()
.label(Constraint::Fill(1), Field::Email)
.label(Constraint::Fill(1), Field::Password)
.row()
.value(Constraint::Fill(1), Field::Email)
.value(Constraint::Fill(1), Field::Password)
.build();
Finally, .with_column_gap(n) on CustomLayout sets the horizontal gap between adjacent columns in the same row (1 by default) — the first column of every row is naturally never preceded by any spacing.
8. Theming: when the default gray isn't enough
By default, Form::default() renders with a gray/bold/reversed scheme. To customize it, you build a FormStyle and pass it to Form::with_style(...) — nothing surprising so far. What makes the difference is how you describe that FormStyle: a small rule engine, where each rule declares who it applies to, across three independent axes:
-
Widgets— which kind of field:SINGLE_LINE,TEXT_AREA,CHECK_BOX,SELECT,MULTI_SELECT, orANYfor all of them. -
Parts— which visual part of that field:LABEL(the caption),AREA(the box's background),TEXT(the typed text),PLACEHOLDER,MARKER(a checkbox's[x]glyph, or a multi-select's marker),ITEM/ACTIVE/SELECTED(a select's rows, and which of them has the cursor or is chosen),ERROR, orANY. -
States— the field's current state:NORMAL,FOCUSED,DISABLED,READ_ONLY, orANY.
Each of the three is a bitmask combined with | — Widgets::SINGLE_LINE | Widgets::TEXT_AREA is just as valid a rule as a single constant. A rule is written with FormStyleBuilder::add(widgets, parts, states, style), and you declare as many as you need:
use ratatui::style::{Color, Style};
use ratiform::style::{FormStyle, Parts, States, Widgets};
let label = Style::default().fg(Color::Cyan);
let value = Style::default().fg(Color::White);
let my_style = FormStyle::builder()
.add(Widgets::ANY, Parts::LABEL, States::NORMAL, label)
.add(Widgets::ANY, Parts::LABEL, States::FOCUSED, label.bold())
.add(
Widgets::ANY,
Parts::TEXT | Parts::ITEM | Parts::MARKER,
States::NORMAL,
value,
)
.add(
Widgets::ANY,
Parts::TEXT | Parts::ITEM | Parts::MARKER,
States::FOCUSED,
value.bold(),
)
.add(
Widgets::ANY,
Parts::AREA | Parts::ACTIVE,
States::FOCUSED,
value.bg(Color::Blue),
)
.add(
Widgets::ANY,
Parts::ERROR,
States::ANY,
Style::default().bg(Color::Red).fg(Color::White).bold(),
)
.build();
frame.render_stateful_widget(Form::default().with_style(my_style), area, &mut state);
This example (taken from examples/theming.rs) shows two concrete advantages over five fixed slots: TEXT, ITEM and MARKER — that is, "the actual content", whether it's typed text, a select's row, or a checkbox's marker — share the same rule in one shot, instead of being three separate properties you'd have to keep in sync by hand; and AREA | ACTIVE does the same for highlighting, whether that's a focused SingleLine's background or a Select's active row.
Rules can be declared in any order. FormStyleBuilder::build() sorts them by specificity on its own before resolving any overlaps: a rule naming a single widget beats one naming all five with ANY, even if the latter was written afterward; the same cascades through Parts and then States. Declaration order only matters as the final tie-break, when two rules are equally specific — in that case the last one written wins. In practice: you can start with a broad rule (Widgets::ANY, Parts::ANY, States::ANY, normal) as the baseline for the whole form, then refine it with progressively more targeted rules, without worrying about which line comes first in the code.
The default theme itself is built with the exact same mechanism — it isn't a special case, just a FormStyle::builder() with a particular set of rules already written for you: a dark background for the AREA of SingleLine/TextArea, bold for the label and content when a field has focus, reversed colors for a select's active row, red and bold for errors, italic gray for placeholders, and a uniform strikethrough — across every part, with a single Parts::ANY rule — for disabled fields.
Closing the loop
The thread running through everything, from the first field to full theming, stays the same: ratiform owns input, focus, validation and navigation — exactly the part that becomes painful to write by hand past the second field — but it never owns your data model. The identifier stays your own type from beginning to end, the value always comes back as a String tied to that identifier, and no step in between forces you to invent an interchange format for data the compiler already knew about from the start.
Having said what it does, it's worth being just as clear about what ratiform doesn't do:
- it isn't an application framework: it owns focus, input and validation for individual fields, not domain logic, screen routing, or data persistence;
- it doesn't manage the event loop: you're the one reading events from
crosstermand passing eachKeyEventtohandle_input(...); - it doesn't automatically turn the form into your own
struct:values()returns(T, String)pairs, and mapping them to a typed struct is left to you; - it doesn't handle the mouse:
handle_inputonly accepts keyboard events.
ratiform is still at version 0.x: its public API can therefore still evolve, even though the core ideas — typed identifiers, the builder/state/widget split, validation and normalization — are by now fairly settled.
The code, the full examples, and the changelog are on the GitHub repository; the crate is published on crates.io.
This article was originally written in Italian and translated into English with the help of AI.
Top comments (0)