gpui-starter v0.3.0 · auto-updater with signed manifests and ed25519 verification→
gpui-starter
Back to FAQ

Features

How do I add form validation in a GPUI Rust app?

gpui-starter uses gpui-form derive macros with koruma validation rules for type-safe, localized form handling. Covers built-in validators, custom rules, inline errors, and the touched pattern.

gpui-starter validates forms through two crates: gpui-form generates form state and UI bindings from derive macros on your struct, and koruma runs composable validation rules against the field values. Error messages go through Fluent so they follow the active locale without extra work.

Defining a form with validation

One struct with derive macros does the wiring. Each field gets a component type from gpui-form and a validator from koruma-collection:

#[derive(Clone, Debug, Default, EsFluentVariants, GpuiForm, Koruma, KorumaAllFluent)]
#[fluent_variants(keys = ["description", "label"])]
#[gpui_form(koruma(fluent))]
pub struct RegistrationForm {
#[gpui_form(component(gpui_form_collection::input::Input::<_>))]
#[koruma(NonEmptyValidation::<_>)]
pub name: String,
#[gpui_form(component(gpui_form_collection::input::Input::<_>))]
#[koruma(EmailValidation::<_>)]
pub email: String,
#[gpui_form(component(gpui_form_collection::input::Input::<_>))]
#[koruma(NonEmptyValidation::<_>)]
pub password: String,
#[gpui_form(component(gpui_form_collection::input::Input::<_>))]
#[koruma(PhoneNumberValidation::<_>)]
pub phone: String,
#[gpui_form(component(gpui_form_collection::input::Input::<_>))]
#[koruma(UrlValidation::<_>)]
pub website: String,
}

Four derives on one struct, each with a specific job:

Derive What it produces
GpuiForm Entity holders, typed value storage, factory methods for input entities
Koruma A validate() method that runs all field rules and returns Result<(), ValidationErrors>
EsFluentVariants Typed enums for looking up labels and descriptions from Fluent
KorumaAllFluent Fluent-backed error messages for every validator, localized to the current locale

Built-in validators

koruma-collection ships validators in two categories:

Collection validators (koruma_collection::collection):

Validator Checks Use for
NonEmptyValidation Field is not empty or whitespace-only Required fields: name, password

Format validators (koruma_collection::format):

Validator Checks Example valid input
EmailValidation Standard email format user@example.com
PhoneNumberValidation North American phone format (555) 123-4567
UrlValidation Valid URL with scheme https://example.com

In the #[koruma(...)] attribute, validators are written as bare paths; chain option setters directly on the path when a validator needs configuration.

How validation and rendering connect

The GpuiForm derive generates a RegistrationFormFormFields struct that holds an Entity<InputState> per field. You create these in the page constructor and subscribe to change events to keep a current_data struct in sync.

Validation runs on submit, not on every keystroke. A touched boolean tracks whether the user has attempted to submit. Errors only render when touched is true, so the form stays clean until the user acts.

Button::new("submit")
.primary()
.label(crate::i18n::localize("form_submit", None))
.on_click(cx.listener(|this, _, window, cx| {
this.touched = true;
let valid = this.current_data.validate().is_ok();
if valid && this.agree_terms {
this.submitted = true;
window.push_notification(
crate::i18n::localize("form_notification_submitted", None),
cx,
);
} else if valid && !this.agree_terms {
window.push_notification(
crate::i18n::localize("form_notification_agree_terms", None),
cx,
);
} else {
window.push_notification(
crate::i18n::localize("form_notification_fix_errors", None),
cx,
);
}
cx.notify();
}))

Three outcomes: valid with terms accepted (success), valid without terms (reminder notification), or invalid (fix-errors notification plus inline error display on the next render).

Inline errors per field

Validation produces a ValidationErrors struct with typed accessors for each field. You extract errors and render them next to the relevant input:

let error_for = |field_name: &str| -> Option<String> {
validation_errors.as_ref().and_then(|e| {
let errs: Vec<String> = match field_name {
"name" => e.name().all().iter().map(crate::i18n::localize_message).collect(),
"email" => e.email().all().iter().map(crate::i18n::localize_message).collect(),
_ => Vec::new(),
};
if errs.is_empty() { None } else { Some(errs.join("\n")) }
})
};

localize_message converts each validation failure to a Fluent string in the current locale. Multiple errors on the same field are joined with newlines.

Custom validators

If the built-in validators do not cover your use case, you can implement koruma’s validation trait directly. The form validation tutorial walks through building a password strength validator from scratch. The koruma deep dive explains the trait interface and how to compose custom rules with the built-in ones.

Localized labels and descriptions

The #[fluent_variants(keys = ["description", "label"])] attribute generates enums that map to keys in your .ftl files:

registration_form_label_variants-name = Full Name
registration_form_description_variants-name = Enter your full name.

Because KorumaAllFluent is derived alongside EsFluentVariants, validation error messages also resolve through Fluent. A missing name field produces the localized help text, not a hard-coded English string. See the i18n docs for how to add new locales.

Where to go next