Backend
Ho scritto Ratiform per smettere di scrivere i form in Ratatui
Marco Bollero Dev.to (EN Zone)
3 views
Il problema: due campi vanno bene, tre iniziano a far male
Chi ha scritto anche solo una TUI con Ratatui conosce la progressione. Il primo input è banale: uno String nello stato dell'app, un match sugli eventi tastiera per gestire inserimento e cancellazione, un widget Paragraph per disegnarlo. Forse solo cinque minuti di lavoro.
Il secondo campo si scrive praticamente per copia-incolla del primo. Funziona ancora, ma qualcosa inizia a scricchiolare: ora serve anche capire quale dei due campi ha il focus, e Tab deve spostarlo dall'uno all'altro.
È con il terzo campo che il problema cambia natura. Non è più "un po' di codice ripetuto", è gestione dello stato che non scala: tre pezzi di String sincronizzati a mano, un indice o un enum per il focus, una tastiera i cui eventi vanno smistati al campo giusto in base a quell'indice, più — se vuoi fare le cose per bene — validazione, messaggi di errore, un campo disabilitato qui e uno in sola lettura là. Ogni nuovo campo aggiunge complessità e la aggiunge in modo combinatorio, perché tocca il focus, la validazione e il rendering di tutti gli altri...
A questo punto le strade sono due: scrivere daccapo, per l'ennesima volta, la stessa infrastruttura di gestione-focus-e-validazione, oppure cercare un widget che se ne occupi al posto tuo.
È proprio da questa frizione che è nato ratiform, una crate che ho scritto per spostare questa infrastruttura fuori dal codice dell'applicazione.
L'idea: l'identificatore del campo è un tipo Rust vero
ratiform fa una scelta strutturale precisa: l'identificatore di un campo non è un &str, è un tipo generico T — nella pratica, quasi sempre un tuo enum, ma anche un intero può essere usato come identificatore!
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum Field {
Name,
Email,
Country,
Terms,
}
FormBuilder, FormState<T> e Form<T> sono tutti generici su questo T. Il risultato pratico è che state.value(&Field::Email) è codice che il compilatore controlla per te: se rinomini una variante dell'enum, ogni punto che la referenzia smette di compilare finché non lo aggiorni. Non c'è nessuna stringa "email" nascosta in un punto qualunque del codice che può disallinearsi dal resto senza produrre un errore di compilazione.
“Tipizzato”, qui, riguarda l'identificatore del campo: Field::Email non può essere confuso con una stringa arbitraria. I valori rimangono invece stringhe, eventualmente convertibili tramite value_as.
Con questa base, vediamo ratiform in azione, partendo dal minimo indispensabile e arrivando fino al theming.
1. Il minimo indispensabile: un solo campo
Prima di tutto, l'installazione:
cargo add ratiform
E un form con un solo campo di testo:
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 è un FormState<Field> — l'oggetto che la tua applicazione possiede per tutta la vita del form: niente stato globale nascosto, niente registro di callback. Il rendering avviene passando Form::default() (uno StatefulWidget stateless) e &mut state a render_stateful_widget, esattamente come faresti con una List o una Table di Ratatui.
Da notare: ogni campo è obbligatorio di default. Non serve chiamare .required(...) per ottenere questo comportamento — serve semmai .optional() per disattivarlo, o .required("messaggio personalizzato") se vuoi sostituire il messaggio d'errore predefinito con uno tuo.
2. Più campi, un solo Tab per spostarsi
Aggiungere campi significa incatenare altre chiamate sullo stesso builder — è qui che si vede la differenza rispetto allo scenario "tre String sincronizzate a mano":
#[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, navigazione e validazione sono già gestiti: Tab/Shift+Tab spostano il focus tra i campi, Ctrl+Enter (o semplicemente Enter, se il campo attivo non lo intercetta per un altro scopo) invia il form — ma solo se nessun campo è invalido — ed Esc lo annulla. Il ciclo di eventi nella tua applicazione si riduce a una manciata di righe:
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 => {}
}
}
Da .masked() si intuisce già un dettaglio importante: il mascheramento è solo cosmetico. Un campo password mostra puntini a schermo, ma validazione e value() continuano a vedere quello che l'utente ha effettivamente digitato.
3. Non solo testo: checkbox, select, area di testo
Un form raramente è fatto solo di input a riga singola. ratiform copre altri tre tipi di campo, con la stessa sintassi a catena.
Una checkbox è un booleano, attivato con Space:
.checkbox(Field::Terms, "I accept the terms")
.checked(false)
.optional()
Una select è una lista di coppie (valore, etichetta), navigabile con le frecce, Home/End, PageUp/PageDown. Punto sottile: value() restituisce il valore, non l'etichetta visualizzata:
.select(Field::Country, "Country")
.values_ref(&[("IT", "Italy"), ("FR", "France"), ("DE", "Germany")])
.selected(1)
.height(5)
Una text area gestisce testo multilinea, con scrolling e paginazione. Qui c'è un dettaglio di ergonomia della tastiera che vale la pena notare: dato che Enter inserisce un a-capo invece di invia il form, la combinazione per confermare mentre il focus è su una TextArea diventa Ctrl+Enter:
.text_area(Field::Notes, "Notes")
.placeholder("Write here...")
.height(5)
4. Recuperare i valori: ancora niente stringhe magiche
Una volta che il form è stato sottomesso, values() consuma lo stato e restituisce un iteratore di coppie (T, String) — che puoi raccogliere direttamente in una HashMap tipizzata sul tuo enum, se na enum implementa Hash ed Eq ovviamente:
let values: HashMap<Field, String> = state.values().collect();
E se ti serve leggere un valore mentre il form è ancora in corso (per una validazione incrociata tra campi, per esempio), non devi aspettare la sottomissione: value(&Field::Email) e value_as::<u16>(&Field::Port) — quest'ultimo per parsare direttamente in un tipo FromStr — funzionano in ogni momento.
5. Validazione: obbligatorietà e regole di forma sono due cose distinte
ratiform separa concettualmente due domande diverse: "questo campo può essere vuoto?" e "se non è vuoto, il suo contenuto rispetta una certa forma?". La prima domanda è gestita dal meccanismo required/optional visto sopra; la seconda da .validator(...), una funzione Fn(&str) -> Result<(), String>.
.single_line(Field::CodiceFiscale, "Codice fiscale")
.validator(ratiform::validators::max_length(16, "Troppo lungo".to_owned()))
La libreria include già un pacchetto di validatori comuni in ratiform::validators: min_length/max_length (Unicode-aware), is_numeric, alphabetic/alphanumeric, no_whitespace, e parsable::<T>, che sfrutta T: FromStr — utile anche con tipi che non appartengono a ratiform, come parsable::<chrono::NaiveDate>(...) per una validazione di data corretta (bisestili inclusi) senza che ratiform debba dipendere da chrono.
Un dettaglio: un validatore non vede mai una stringa vuota. Se il campo è vuoto, decide il controllo di obbligatorietà, non il validatore — quindi nessuno dei validatori built-in (né i tuoi) deve gestire il caso speciale della stringa vuota.
6. Normalizzare, non solo validare
Dove validator giudica un valore già digitato, normalizer lo riscrive in una forma canonica prima che la validazione lo veda:
.single_line(Field::CodiceFiscale, "Codice fiscale")
.normalizer(|value: &str| value.to_uppercase())
.validator(ratiform::validators::max_length(16, "Troppo lungo".to_owned()))
Il normalizer gira a ogni tasto premuto, su set_value e sul valore iniziale: il campo non viene mai visto — né dal validatore, né da is_dirty(), né da values() — in una forma diversa da quella normalizzata. Abbinato ad alphabet(...) su un campo a riga singola, la divisione dei compiti è pulita: alphabet rifiuta un carattere non ammesso, normalizer riscrive uno che era ammesso ma va corretto.
7. Layout: orizzontale o impilato, deciso a runtime
Di default ogni campo mostra etichetta e valore sulla stessa riga (FormLayout::Horizontal). Se il terminale è stretto, FormLayout::Stacked mette l'etichetta sopra il valore:
frame.render_stateful_widget(
Form::default().with_layout(FormLayout::Stacked),
area,
&mut state,
);
Dato che Form viene ricostruito a ogni frame, nulla impedisce di scegliere il layout in base alla larghezza disponibile in quel momento — è esattamente quello che fa examples/layouts.rs, passando a Stacked quando il terminale si stringe, senza bisogno di codice dedicato per intercettare il resize.
Horizontal e Stacked bastano finché la forma del form è "una riga per campo". Ma un indirizzo di spedizione, per dire, ha una sua geometria naturale: via su una riga intera, poi città/provincia/CAP spartiti su tre colonne di larghezza diversa. Per questi casi c'è una terza variante, FormLayout::Custom, che descrive il form come una griglia esplicita di righe e colonne, cella per cella:
Email Password
_______________________ ____________________
Address
____________________________________________
City State Zip
_____________________ ______________ _______
Ogni cella della griglia è una coppia (Constraint, contenuto): il Constraint è lo stesso tipo di Ratatui che già usi per i layout normali (Length, Fill, ...), il contenuto dichiara cosa disegnare in quella cella — l'etichetta di un campo (Label), il suo widget di input (Value), il suo messaggio di errore (Error) — oppure niente, per uno spaziatore. Il modo più leggibile per scriverla è la macro custom_layout!, che rispecchia la griglia riga per riga:
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));
Da notare tre cose:
Etichetta, valore ed errore sono celle indipendenti. Non è un dettaglio decorativo: puoi mettere l'etichetta di Email sopra il suo valore, ma allineata sulla stessa riga dell'etichetta di Password — cosa che nessuna combinazione di Horizontal/Stacked permette, perché lì le tre parti di un campo restano sempre un blocco unico.
Uno spaziatore è una cella con contenuto None invece di uno dei tre identificatori — utile per separare visivamente gruppi di campi senza inventarsi un campo fittizio.
Il focus segue comunque l'ordine di dichiarazione dei campi nel builder, non l'ordine sulla griglia. Se la griglia rispecchia la sequenza Email → Password → Address → ... usata in single_line(...), Tab si muove nell'ordine "naturale" che ci si aspetta guardando lo schermo; se le due sequenze divergono, Tab continua a funzionare, ma salta da un punto all'altro dello schermo in un ordine che l'occhio non prevede. Vale anche l'inverso: un campo dimenticato nella griglia riceve comunque il focus quando gli tocca — semplicemente senza un cursore visibile e senza un posto dove mostrare un eventuale errore.
Chi preferisce assemblare la griglia in modo programmatico — righe generate da un ciclo, per esempio — ha a disposizione CustomLayout::builder(), l'equivalente fluente della stessa 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();
Infine, .with_column_gap(n) su CustomLayout regola lo spazio orizzontale tra colonne adiacenti della stessa riga (di default 1) — la prima colonna di ogni riga non viene mai preceduta da spaziatura ovviamente.
8. Theming: quando il grigio di default non basta
Di default, Form::default() disegna con uno schema grigio/grassetto/invertito. Per personalizzarlo si costruisce un FormStyle e lo si passa a Form::with_style(...) — fin qui nulla di sorprendente. Quello che fa la differenza è come si descrive quel FormStyle: un piccolo motore di regole dove ogni regola dichiara a chi si applica, su tre assi indipendenti:
Widgets — quale tipo di campo: SINGLE_LINE, TEXT_AREA, CHECK_BOX, SELECT, MULTI_SELECT, o ANY per tutti.
Parts — quale parte visiva di quel campo: LABEL (l'etichetta), AREA (lo sfondo del riquadro), TEXT (il testo digitato), PLACEHOLDER, MARKER (il simbolo [x] di una checkbox, o il pallino di una multi-select), ITEM/ACTIVE/SELECTED (le righe di una select e quale di esse ha il cursore o è scelta), ERROR, o ANY.
States — lo stato del campo in quel momento: NORMAL, FOCUSED, DISABLED, READ_ONLY, o ANY.
Ognuna delle tre è una maschera di bit che si combina con | — Widgets::SINGLE_LINE | Widgets::TEXT_AREA è una regola valida quanto una singola costante. Una regola si scrive con FormStyleBuilder::add(widgets, parts, states, style), e se ne dichiarano quante servono:
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);
Da questo esempio (ripreso da examples/theming.rs) si vedono i due vantaggi concreti rispetto alle cinque caselle fisse: TEXT, ITEM e MARKER — cioè "il contenuto vero e proprio", che sia testo digitato, riga di una select o marker di una checkbox — condividono la stessa regola in un colpo solo, invece di essere tre proprietà separate da tenere sincronizzate a mano; e AREA | ACTIVE fa lo stesso per l'evidenziazione, che si tratti dello sfondo di un SingleLine a fuoco o della riga attiva di una Select.
Le regole si dichiarano in qualunque ordine. FormStyleBuilder::build() le ordina da sé per specificità prima di risolvere eventuali sovrapposizioni: una regola che nomina un solo widget batte una che ne nomina cinque con ANY, anche se quest'ultima è stata scritta dopo; lo stesso vale, in cascata, per Parts e poi per States. L'ordine di dichiarazione conta solo come spareggio finale, quando due regole sono ugualmente specifiche — in quel caso vince l'ultima scritta. In pratica: puoi partire da una regola larga (Widgets::ANY, Parts::ANY, States::ANY, normal) come base di tutto il form, e poi affinarla con regole via via più mirate, senza doverti preoccupare di che riga viene prima nel codice.
Il tema di default stesso è costruito con lo stesso identico meccanismo — non è un caso speciale, è solo un FormStyle::builder() con un certo insieme di regole già scritto per te: sfondo scuro per l'area di SingleLine/TextArea, grassetto per etichetta e contenuto quando il campo ha il focus, colori invertiti per la riga attiva di una select, rosso e grassetto per gli errori, corsivo grigio per i placeholder, e barrato uniforme — su ogni parte, con un'unica regola Parts::ANY — per i campi disabilitati.
Chiudendo il cerchio
Il filo conduttore, dal primo campo al theming completo, è sempre lo stesso: ratiform possiede input, focus, validazione e navigazione — cioè esattamente la parte che diventa dolorosa da scrivere a mano oltre il secondo campo — ma non possiede mai il tuo modello dati. L'identificatore resta un tuo tipo dall'inizio alla fine, il valore torna sempre come String legata a quell'identificatore, e nessun passaggio nel mezzo ti obbliga a inventarti un formato di interscambio per dati che il compilatore conosceva già in partenza.
Detto cosa fa, vale la pena essere altrettanto chiari su cosa ratiform non fa:
non è un framework applicativo: possiede focus, input e validazione dei singoli campi, non la logica di dominio, il routing tra schermate o la persistenza dei dati;
non gestisce l'event loop: sei tu a leggere gli eventi da crossterm e a passare ogni KeyEvent a handle_input(...);
non converte automaticamente il form in una tua struct: values() restituisce coppie (T, String), il passaggio a un tipo tipizzato resta a tuo carico;
non gestisce il mouse: handle_input accetta solo eventi da tastiera.
ratiform è ancora in versione 0.x: l'API pubblica può quindi evolvere, anche se le idee centrali — identificatori tipizzati, separazione tra builder, stato e widget, validazione e normalizzazione — sono ormai abbastanza stabili.
Il codice, gli esempi completi e il changelog sono sul repository GitHub; la crate è pubblicata su crates.io.
Read original: https://dev.to/marco_bollero_ba3c38ddd27/ho-scritto-ratiform-per-smettere-di-scrivere-i-form-in-ratatui-28ep
← Previous
Idempotency: Protecting User Intent Beyond the Buy Button
Next →
Gitea SSL with Apache as reverse proxy
Related
Astra won't kill anything. It will silence everything. (And why that's good news.)
Backend
0
DEV Community
Idempotency: Protecting User Intent Beyond the Buy Button
Backend
0
Dev.to (EN Zone)
What One Iteration Costs
Backend
1
Dev.to (EN Zone)
I Built a Git Hook That Makes Revert Commits Conventional
Backend
2
DEV Community
Comments0
No comments yet — be the first