Ho scritto Ratiform per smettere di scrivere i form in Ratatui
DEV Community

Ho scritto Ratiform per smettere di scrivere i form in Ratatui

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 e Form 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 - 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 = 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:: (&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 . .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:: , che sfrutta T: FromStr - utile anche con tipi che non appartengono a ratiform , come parsable:: (...) 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! {

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.