Forms
Declarative form validation with built-in validators and error handling.
let form = state!(cx, || {
FormState::new()
.field(
FieldBuilder::new("email")
.required()
.email()
.error_message("Please enter a valid email")
.build()
)
});
View::form_field("email")
.label("Email Address")
.value(form.get().get_value("email"))
.error(form.get().get_error("email"))
.on_change(with!(form => move |v: String| {
form.get().set_value("email", v);
}))
.build()
Run with: cargo run -p telex-tui --example 22_forms
Form state
Create a FormState to manage all fields:
let form = state!(cx, || {
FormState::new()
.field(FieldBuilder::new("username").required().build())
.field(FieldBuilder::new("email").required().email().build())
.field(FieldBuilder::new("password").required().min_length(8).build())
});
Each field has a name, validators, and optional error messages.
Built-in validators
FieldBuilder provides common validators:
FieldBuilder::new("field_name")
.required() // must not be empty
.email() // must be valid email
.min_length(n) // at least n characters
.max_length(n) // at most n characters
.integer() // must parse as integer
.error_message("Custom error") // shown on validation failure
.build()
Validators chain - all must pass for the field to be valid.
Custom validators
Add custom validation logic:
FieldBuilder::new("username")
.custom(|value| {
if value.contains(' ') {
Some("Username cannot contain spaces".into())
} else if !value.chars().all(|c| c.is_alphanumeric()) {
Some("Only letters and numbers allowed".into())
} else {
None // Valid
}
})
.build()
Custom validators return Option<String>:
None- field is validSome(msg)- field is invalid, show this error
Form fields
Render fields with View::form_field():
View::form_field("email")
.label("Email Address *")
.value(form.get().get_value("email"))
.placeholder("you@example.com")
.error(form.get().get_error("email"))
.on_change(with!(form => move |v: String| {
form.get().set_value("email", v);
}))
.on_blur(with!(form => move || {
form.get().touch("email");
}))
.build()
The form field widget handles:
- Label display
- Input rendering
- Error message display (red text below input)
- Placeholder text
Validation timing
Validation happens on two events:
On change: Update the value, but don't show errors yet
.on_change(with!(form => move |v| {
form.get().set_value("field", v);
}))
On blur: Mark field as "touched" to show errors
.on_blur(with!(form => move || {
form.get().touch("field");
}))
This prevents showing errors before the user finishes typing.
Submitting the form
Check validity before processing:
let on_submit = with!(form => move || {
if form.get().validate() {
// Form is valid - process it
let values = form.get().values();
let email = values.get("email").unwrap();
let password = values.get("password").unwrap();
save_user(email, password);
} else {
// Show error message
show_error("Please fix the errors above");
}
});
View::button()
.label("Submit")
.on_press(on_submit)
.build()
.validate() returns true if all fields are valid.
Accessing values
Get individual field values:
let email = form.get().get_value("email"); // returns String
Get all values as a map:
let values = form.get().values(); // HashMap<String, String>
let email = values.get("email").unwrap_or(&String::new());
Accessing errors
Get field-specific errors:
let email_error = form.get().get_error("email"); // Option<String>
Check if the whole form is valid:
let is_valid = form.get().is_valid(); // bool
Password fields
For sensitive input like passwords, use the .password(true) method to hide the text as asterisks:
View::form_field("password")
.label("Password")
.value(password)
.password(true) // display as ****
.error(error)
.on_change(on_change)
.build()
The actual value is still stored normally in state - only the display is masked.
Optional fields
Fields without .required() can be empty:
FieldBuilder::new("age")
.integer() // if not empty, must be an integer
.custom(|v| {
if v.is_empty() {
return None; // empty is ok
}
match v.parse::<i32>() {
Ok(n) if n < 0 => Some("Age must be positive".into()),
Ok(n) if n > 150 => Some("Please enter a realistic age".into()),
Ok(_) => None,
Err(_) => Some("Must be a number".into()),
}
})
.build()
Resetting the form
Clear all values and errors:
let on_reset = with!(form => move || {
form.get().reset();
});
View::button()
.label("Reset")
.on_press(on_reset)
.build()
Form layout
Use View::form() to layout fields with consistent spacing:
View::form()
.spacing(1)
.child(email_field)
.child(password_field)
.child(username_field)
.build()
This is just a vstack with spacing - purely cosmetic.
Real-world example
Registration form:
let form = state!(cx, || {
FormState::new()
.field(
FieldBuilder::new("email")
.required()
.email()
.error_message("Please enter a valid email address")
.build()
)
.field(
FieldBuilder::new("username")
.required()
.min_length(3)
.max_length(20)
.custom(|v| {
if !v.chars().all(|c| c.is_alphanumeric() || c == '_') {
Some("Only letters, numbers, and underscores".into())
} else {
None
}
})
.build()
)
.field(
FieldBuilder::new("password")
.required()
.min_length(8)
.custom(|v| {
if !v.chars().any(|c| c.is_numeric()) {
Some("Must contain at least one number".into())
} else {
None
}
})
.build()
)
});
Validation messages
Show form-level validation status:
View::text(format!(
"Form valid: {}",
if form.get().is_valid() { "Yes" } else { "No" }
))
.color(if form.get().is_valid() { Color::Green } else { Color::Red })
Common patterns
Confirm password:
FieldBuilder::new("confirm_password")
.custom({
let password = password.clone();
move |v| {
if v != password.get() {
Some("Passwords do not match".into())
} else {
None
}
}
})
.build()
Trim whitespace before validation:
.on_change(with!(form => move |v: String| {
form.get().set_value("email", v.trim().to_string());
}))
Disable submit until valid:
View::button()
.label("Submit")
.disabled(!form.get().is_valid())
.on_press(on_submit)
.build()
Show error count:
let error_count = form.get().values().keys()
.filter(|k| form.get().get_error(k).is_some())
.count();
if error_count > 0 {
View::text(format!("{} errors remaining", error_count))
}
Tips
Touch on blur - Only call .touch() when the user leaves a field. This prevents showing errors too early.
Required fields in labels - Mark required fields with * in the label: "Email Address *".
Custom error messages - Use .error_message() for friendlier errors than the defaults.
Validate on submit - Always call .validate() before processing, even if you track validity in the UI.
Field names are keys - Use consistent field names throughout (get_value, set_value, touch, get_error all use the same name).
Validators compose - Chain multiple validators: .required().email().min_length(5).
Custom validators are flexible - Access other state, make async checks (in the validator), whatever you need.
Reset doesn't re-validate - After .reset(), the form is back to initial state. Errors won't show until fields are touched again.
Next: Menus