DEV Community

Marco Bollero
Marco Bollero

Posted on

I wrote Ratiform to stop writing forms in Ratatui

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.

ratiform

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,
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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 => {}
    }
}
Enter fullscreen mode Exit fullscreen mode

.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()
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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()))
Enter fullscreen mode Exit fullscreen mode

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()))
Enter fullscreen mode Exit fullscreen mode

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,
);
Enter fullscreen mode Exit fullscreen mode

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
_____________________ ______________ _______
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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 as Password's label — something no combination of Horizontal/Stacked allows, since there the three parts of a field always stay a single block.
  • A spacer is a cell with None content 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 in single_line(...), Tab moves in the "natural" order you'd expect looking at the screen; if the two sequences diverge, Tab still 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();
Enter fullscreen mode Exit fullscreen mode

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, or ANY for 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, or ANY.
  • States — the field's current state: NORMAL, FOCUSED, DISABLED, READ_ONLY, or ANY.

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);
Enter fullscreen mode Exit fullscreen mode

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 crossterm and passing each KeyEvent to handle_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_input only 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)