gpui-form is a type-safe form-generation ecosystem for gpui and
gpui-kit, centered on
#[derive(GpuiForm)].
It is designed for three things:
- Compile-time generation of strongly typed form state and helper types.
- Concise field annotations on normal application structs.
- Runtime helpers, metadata, and prototyping support around the derive-based workflow.
freeoxide-gpui-form |
gpui-kit |
gpui |
|---|---|---|
| git | ||
branch = "master" |
0.7.3 |
0.3.9 (gpui-pre) |
[dependencies]
gpui = { package = "gpui-pre", version = "0.3.9" }
gpui-kit = "0.7.3"
freeoxide-gpui-form = { git = "https://github.com/freeoxide/gpui-form" }
# Optional: inventory registration for prototyping/code generation
# freeoxide-gpui-form = { git = "https://github.com/freeoxide/gpui-form", features = ["inventory"] }
# Optional: form-state persistence + dirty tracking (serde on the holder)
# freeoxide-gpui-form = { git = "https://github.com/freeoxide/gpui-form", features = ["serde"] }
# Optional: parser-backed phone-number validation helpers
# freeoxide-gpui-form = { git = "https://github.com/freeoxide/gpui-form", features = ["phone"] }
# Optional: your crate's own locale files (required when the crate calls t!)
# rust-i18n = "4"The packages publish to crates.io under the freeoxide- names
(freeoxide-gpui-form and friends); every lib target keeps its historical
name (gpui_form, gpui_form_core, ...), so use gpui_form::... code
compiles unchanged.
use gpui_form::{GpuiForm, SelectItem};
use strum::EnumIter;
#[derive(Clone, Debug, Default, EnumIter, PartialEq, SelectItem)]
pub enum Country {
#[default]
UnitedStates,
France,
Japan,
}
#[derive(Clone, Debug, Default, GpuiForm)]
pub struct UserProfile {
#[gpui_form(component(input))]
pub username: Option<String>,
#[gpui_form(component(number_input))]
pub age: Option<u32>,
#[gpui_form(component(select), default = Country::France)]
pub country: Country,
#[gpui_form(component(checkbox))]
pub subscribe: bool,
}#[derive(GpuiForm)] generates the typed form support around that struct:
UserProfileFormFieldsfor GPUI entity stateUserProfileFormComponentsconstructors for those fieldsUserProfileFormValueHolderfor typed editing, defaults, validation, and conversion back into the original modelUserProfileFormPathtyped field paths so validation, dirty tracking, focus, analytics, and schema export share ONE typed way to name fields (see Typed Field Paths)
These component forms are currently supported:
#[gpui_form(component(input))]#[gpui_form(component(number_input))]#[gpui_form(component(number_input(as = f64)))]#[gpui_form(component(phone_input))](requires thephonefeature)#[gpui_form(component(phone_input(country = <field>)))](requires thephonefeature)#[gpui_form(component(checkbox))]#[gpui_form(component(switch))]#[gpui_form(component(select))]#[gpui_form(component(select(searchable)))]#[gpui_form(component(select(partial)))]#[gpui_form(component(infinite_select))]#[gpui_form(component(infinite_select(searchable, max_depth = 3)))]#[gpui_form(component(date_picker))]#[gpui_form(component(file_picker))]#[gpui_form(component(custom(shape = my::Shape)))]#[gpui_form(component(custom(state = my::State)))]#[gpui_form(component(custom(shape = my::Shape, component = my::ui::Widget)))]#[gpui_form(component(custom(shape = my::Shape, wraps_in_option = false)))]#[gpui_form(component(custom(shape = my::Shape, value_binding)))]#[gpui_form(component(nested))]— compose another#[derive(GpuiForm)]struct as a nested form (see Nested Forms and Repeated Fields)#[gpui_form(component(list(item = input)))]— an editableVec<T>with scalar item components (input,number_input,select,checkbox,switch,phone_input,infinite_select,date_picker,file_picker), plus optionaladd_label = "..."for the generated add control
Common field-level helpers:
#[gpui_form(default = <expr>)]seeds the generated value holder and initial select-like choices.#[gpui_form(skip)]excludes a field from generated form widgets while still allowing prefill from the original model.#[gpui_form(type = <form_type>, from = <expr>, into = <expr>)]lets the generated form edit a type that differs from the original field type.component(input)parses non-Stringform-side value types withFromStrin prototyping output, so value objects can usetype,from, andintowhile the source model keeps its storage type.- Field-level
#[koruma(...)]attributes are accepted byGpuiFormand copied onto the generated value holder, including fields that usetype,from, andintoto validate a form-side type. #[gpui_form(section = ..., label = ..., description = ..., placeholder = ..., width = ...)]attaches non-rendering layout hints. See Layout and Section Hints.#[gpui_form(depends_on = <field>)]records a sibling-field dependency as metadata. See Field Dependencies and Conditional Requiredness.#[gpui_form(required_if = <field>)]makes a field conditionally required. See Field Dependencies and Conditional Requiredness.
Enable the optional phone feature when a form needs parser-backed phone
validation. It provides both a first-class component(phone_input) derive
field and headless validation helpers that centralize the strict validation
logic so every phone field uses the same rules.
freeoxide-gpui-form = { git = "https://github.com/freeoxide/gpui-form", features = ["derive", "phone"] }component(phone_input) renders a text input wired to the phone validator.
Use the bare form for a globally valid number, and phone_input(country = <field>) to bind a phone field to a sibling country-select field:
use gpui_form::{GpuiForm, SelectItem};
#[derive(Clone, Debug, Default, PartialEq, SelectItem, strum::EnumIter)]
enum Region {
#[default]
UnitedStates,
France,
}
#[derive(Clone, Debug, Default, GpuiForm)]
struct Contact {
#[gpui_form(component(select))]
region: Region,
// Global phone: any globally valid number is accepted.
#[gpui_form(component(phone_input))]
mobile_number: Option<String>,
// Country-bound phone: the `country` binding records the sibling field
// whose selection the number should match.
#[gpui_form(component(phone_input(country = region)))]
local_number: Option<String>,
}The phone value is stored as Option<String> in the generated value holder
(the same storage as component(input)), and the generated input control
accepts empty input so a partially typed field is not flagged mid-entry.
use gpui_form::phone::{
country,
validate_phone_number,
validate_phone_number_for_country_label,
};
// General mode: any valid global number is accepted.
let general = validate_phone_number("+1 415 550 2222", Some(country::FR));
assert!(general.is_valid());
// Strict mode: the parsed country must match the selected country.
let result = validate_phone_number_for_country_label(
"+1 415 550 2222",
country::FR,
"France",
);
assert!(!result.is_valid());
// Inspect the parsed result separately.
let valid = validate_phone_number("415 555 2671", Some(country::US));
assert_eq!(valid.country(), Some(country::US));
assert_eq!(valid.e164(), Some("+14155552671"));Use validate_phone_number when a field should accept any valid global number.
Use validate_phone_number_for_country_label when the parsed country must match
the selected country. For example, +1 415 550 2222 is a valid US number, but
strict mode rejects it when the selected country is France.
validate_optional_phone_number treats empty input as acceptable (pair it with
PhoneNumberValidation::is_valid_or_empty for live input gating), while
validate_required_phone_number rejects empty input with
PhoneNumberValidationError::Required. Both have _for_country_label variants.
Implement the PhoneCountry trait on an application country enum to map it to a
country::Id and label once, then validate with validate_phone_number_for:
use gpui_form::phone::{country, validate_phone_number_for, PhoneCountry};
enum Region {
UnitedStates,
France,
}
impl PhoneCountry for Region {
fn phone_country_id(&self) -> country::Id {
match self {
Self::UnitedStates => country::US,
Self::France => country::FR,
}
}
fn phone_country_label(&self) -> String {
match self {
Self::UnitedStates => "United States".to_string(),
Self::France => "France".to_string(),
}
}
}
let result = validate_phone_number_for("415 555 2671", &Region::UnitedStates);
assert!(result.is_valid());component(infinite_select) expects the field type to implement
gpui_form::InfiniteSelect, usually by deriving it on the enum tree. The enum
tree must also implement PartialEq because the backing gpui-kit
select compares selected values.
Lower-level users can derive the same runtime contract from
gpui-form-component or gpui-form-component-derive; the macro resolves
whichever runtime crate is present, including renamed dependencies.
Common struct-level helpers:
#[gpui_form(empty)]marks an intentional empty form.#[gpui_form(koruma)]enables Koruma-backed validation wiring.#[gpui_form(koruma(fluent))]enables Koruma validation plus localized error rendering throughrust-i18n(see Localization).
Fields can declare non-rendering layout hints that generated and prototyped forms consume. These are metadata-only in v1: they describe intent, they do not drive any GPUI rendering. Application code and prototyping generators decide how (or whether) to render each hint.
#[derive(gpui_form::GpuiForm)]
pub struct AccountSettings {
#[gpui_form(section = "Account", label = "Username", component(input))]
pub username: String,
#[gpui_form(
label = "Enable experiments",
description = "Toggles unreleased features",
component(switch)
)]
pub enable_experimental: bool,
#[gpui_form(placeholder = "you@example.com", width = half, component(input))]
pub email: String,
}Supported hints (all optional):
section = "<str>"— groups consecutive fields under a named section.label = "<str>"— preferred human-readable label. Defaults to the field name at consumption time when absent.description = "<str>"— help text / comment hint shown alongside the field.placeholder = "<str>"— placeholder text for inputs that support one.width = full | half | third— relative width hint. Accepts the bare ident (width = half) or a quoted string (width = "half"). This is a hint, not a layout engine: consumers may ignore it or map it onto their own grid.
Section grouping is order-preserving: consecutive fields with the same
section form one group, and fields are never reordered across the form. This
is the foundation richer layouts (columns, collapsible sections) can build on
later.
Layout hints are ignored on #[gpui_form(skip)] fields — skipped fields emit no
form metadata, so their hints never reach the schema. The generated form code
itself is unchanged; the hints ride along on the field metadata that
prototyping and tooling consume.
At runtime the hints are reachable through gpui_form::schema::FieldVariant as
a gpui_form::schema::FieldLayout, and the width enum is re-exported as
gpui_form::LayoutWidth for ergonomic matching. The prototyping generator
groups fields by section, prefers label over the field name, and emits
description where it already produces help text.
component(infinite_select) fields are backed by
gpui_form::infinite_select::InfiniteSelectState, which owns the root and
child SelectStates, exposes render-ready level snapshots, and emits a single
typed change event with the rebuilt nested value, both path forms, the
previous paths, and the changed depth.
use gpui_form::infinite_select::{InfiniteSelectEvent, InfiniteSelectState};
let location = cx.new(|cx| {
InfiniteSelectState::new(Country::default(), window, cx)
});
cx.subscribe_in(
&location,
window,
|_, _, event: &InfiniteSelectEvent<Country>, _, _| {
let _value = event.value();
let _path = event.path();
let _key_path = event.key_path();
let _previous_key_path = event.previous_key_path();
let _changed_depth = event.changed_depth();
},
);Rendering code can stay on the runtime helper instead of combining select handles with separate label lookups:
for field in location.read(cx).form_fields() {
let _ = field;
}The derive/runtime pair also exposes typed option labels, stable key paths, and typed path errors:
- root option titles come from
variant_label()instead of rawvariant_name() #[fluent_kv(keys = ["label", "description"], keys_this)]is accepted for compatibility but emits no localization metadata; generated runtime labels use variant-name fallbacks, and applications localize labels through their ownrust-i18nlocales (see Localization)#[tuple_enum(key = "...")]overrides persisted keys when enum names should stay decoupled from storageselection_key_path()/build_from_key_path(...)round-trip nested values without depending on enum orderingInfiniteSelectKeyPathsupportsDisplay,FromStr, and serde string round-trips for URLs and persisted configbuild_from_path(...),build_from_key_path(...),set_path(...), andset_key_path(...)returnInfiniteSelectPathErrorwith the failing depth plus the invalid key/index segmentset_selected_index_at_depth(...)/set_selected_key_at_depth(...)support incremental programmatic updates
gpui-form can mirror Koruma validation metadata into the generated value
holder so your form state and your domain model stay aligned.
use gpui_form::GpuiForm;
use koruma::{Koruma, KorumaAllDisplay};
use koruma_collection::{
collection::NonEmptyValidation,
numeric::RangeValidation,
};
#[derive(Clone, Debug, GpuiForm, Koruma, KorumaAllDisplay)]
#[gpui_form(koruma(fluent))]
pub struct Signup {
#[gpui_form(component(input))]
#[koruma(NonEmptyValidation::<_>::builder())]
pub username: String,
#[gpui_form(component(number_input))]
#[koruma(RangeValidation::<_>::builder().min(18).max(120))]
pub age: Option<u32>,
}When validation is enabled:
- required-value semantics are preserved when the generated holder wraps fields
in
Option<T> - builder-chain Koruma attrs are mirrored
- generated value-holder validation uses the same validator set as the source struct
- the holder gains path-keyed typed error access:
validate_fields()returns a<Name>FormFieldErrorsmap re-keying koruma's typed per-field error structs by<Name>FormPath(see the next section)
Instead of hand-matching koruma output per field, select errors through the typed path and read the failed validators' i18n keys directly:
use gpui_form::core::FormFieldErrors;
let errors = holder.validate_fields();
if let Some(field_error) = errors.error_for(&SignupFormPath::username()) {
assert_eq!(field_error.issue_keys(), vec!["validation.required"]);
assert_eq!(field_error.path().to_string(), "username");
}
// Declaration-ordered failing paths, generic over any koruma form:
let failing: Vec<String> = errors
.failing_paths()
.iter()
.map(|path| path.to_string())
.collect();error_for returns None for valid fields and for unknown paths. Each
<Name>FormFieldError variant borrows that field's koruma error struct, so
koruma's own typed surface (.all(), .inner(), per-validator getters) stays
reachable through the variant payload. issue_keys() names the field-level
validators the derive can see; failures nested inside #[koruma(newtype)] or
#[koruma(nested)] inner types report the generic "validation.invalid" key
and remain readable through the typed accessors.
With koruma(fluent), the generated holder also resolves validation text
through rust-i18n: it emits a <FORM>_I18N_KEY_PREFIX const, a
<FIELD>_LABEL_KEY = "<form>.<field>_label" const per non-skipped field, and a
localized_validation_issue(kind) helper backed by validation.<kind> keys
(validation.required, validation.email, ...). Your crate must depend on
rust-i18n, call rust_i18n::i18n! at its crate root, and own those keys —
crates/gpui-form-derive/locales is the reference template for the
validation.* strings.
Localization is backed by rust-i18n
major 4 (4.2.1 in this workspace) — the same backend gpui-kit 0.7
components use, so widget text and form text share ONE active locale.
Locale files live under locales/ in your crate, one YAML file per locale,
named after the locale (locales/en.yml, locales/fr-FR.yml,
locales/zh-CN.yml):
# locales/en.yml
signup:
username_label: "Username"
validation:
required: "%{field} is required"rust_i18n::i18n!("locales", fallback = "en");Keys are namespaced per crate (date_picker.select_date,
file_picker.browse, signup.username_label, validation.required). t! is
crate-local: every crate that calls it needs its own i18n! line and locale
files — including apps that use koruma(fluent), #[select_item(fluent)], or
prototyping output. t! returns the key itself on a miss, so a miss fallback
compares the translation against the key.
Switch the locale for the whole process through the gpui-form-i18n bridge,
re-exported as gpui_form::i18n:
use gpui_form::i18n::{change_locale, init, localize_message, locale};
use unic_langid::LanguageIdentifier;
init(cx);
change_locale(cx, "fr-FR".parse::<LanguageIdentifier>().unwrap()).unwrap();
assert_eq!(locale(cx), "fr-FR");
assert_eq!(localize_message(cx, "file_picker.browse"), "Parcourir");Built-in runtime strings (date-picker placeholder and selected-date label,
file-picker prompts and counts) ship in gpui-form-component locales for
en, fr-FR, and zh-CN and follow the same shared locale. Missed keys fall
back to a humanized form of the key ("file_picker.browse" -> "File Picker
Browse").
Enable the optional serde feature to make generated forms saveable/restorable
and to know whether the user edited them. The feature is additive and opt-in.
freeoxide-gpui-form = { git = "https://github.com/freeoxide/gpui-form", features = ["serde"] }With the feature on, #[derive(GpuiForm)] adds Serialize and Deserialize
to the generated ...FormValueHolder, so the holder round-trips through any
serde format. The holder always derives PartialEq (not Eq, since field
types like number_input(as = f64) are not Eq), so FormState's dirty
tracking works on default features too. Separately, the facade re-exports
gpui_form::FormState (from gpui-form-core) for dirty tracking, reset, and
diffing — pure logic with no GPUI dependency.
use gpui_form::{FormState, GpuiForm};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Default, GpuiForm, Serialize, Deserialize, PartialEq)]
pub struct Settings {
#[gpui_form(component(input))]
pub username: Option<String>,
#[gpui_form(component(number_input))]
pub age: Option<u32>,
}
// Save: serialize the live holder.
let holder = SettingsFormValueHolder::default();
let json = serde_json::to_string(&holder).expect("serialize");
// Restore: deserialize into a fresh FormState.
let restored: SettingsFormValueHolder =
serde_json::from_str(&json).expect("deserialize");
let mut state = FormState::new(restored);
assert!(!state.is_dirty());
// Edit, then ask whether the user changed anything.
state.current_mut().username = Some("ada".into());
assert!(state.is_dirty());
// Reset to discard edits, or sync after a save to mark clean.
state.reset_to_baseline();
assert!(!state.is_dirty());Scope of this feature:
FormStatestores holder data only — not component runtime UI state such as open menus, scroll positions, orInfiniteSelectStatesnapshots.FormState's own dirty/diff stays boolean-level:is_dirty()anddiff_against(&other)tell you whether the holder changed. The field-level diff (which fields changed, typed patches, touched tracking) lives on the generated<Name>FormStatefacade below.- A holder carrying
#[gpui_form(skip)]fields round-trips through serde on its own, but cannot fully reconstruct the source struct viainto_original, since skipped values are not on the holder. This mirrors the existinghas_skipped_fieldsbehavior. Per-field serde passthrough (rename/skip) is backlog feature #15. - No undo/redo in this feature.
FormState is available unconditionally from gpui_form::FormState (or
gpui-form-core directly); only the holder serde derives require the serde
feature.
Every #[derive(GpuiForm)] form also emits a <Name>FormState facade that
composes the standalone core primitives — FormState<H> for baseline/current
values and TouchedFields for engagement tracking — behind typed
<Name>FormPath accessors. The holder itself stays plain data; all state
lives on the facade.
use gpui_form::GpuiForm;
#[derive(GpuiForm)]
pub struct Settings {
#[gpui_form(component(input))]
pub username: String,
#[gpui_form(component(number_input))]
pub age: Option<u32>,
}
let mut state = SettingsFormState::new(SettingsFormValueHolder::default());
assert!(!state.is_dirty());
state.current_mut().username = Some("ada".into());
state.touch(&SettingsFormPath::username()); // from a blur/change handler
// Per-field dirty + changed fields, in declaration order.
assert!(state.is_field_dirty(&SettingsFormPath::username()));
assert_eq!(state.changed_fields(), vec![SettingsFormPath::username()]);
// Touched tracking is independent of value deltas.
assert!(state.is_touched(&SettingsFormPath::username()));
assert_eq!(state.touched_fields(), vec![SettingsFormPath::username()]);
// Typed patch: one Option<holder-field-type> slot per non-skipped field
// (Some(None) = "changed to cleared" for option-backed fields).
let patch = state.patch();
assert_eq!(patch.username, Some(Some("ada".to_string())));
assert!(patch.age.is_none());
patch.apply_to(&mut SettingsFormValueHolder::default()); // replay the diff
// Reset semantics: default reset is a fresh canvas (values + touched clear);
// the keeping-touched variant is an undo-style revert; a save via
// sync_baseline() never un-visits fields.
state.reset_to_baseline();
assert!(!state.is_dirty() && !state.is_any_touched());With the derive crate's serde feature on, <Name>FormPatch also derives
Serialize/Deserialize with skip_serializing_if = "Option::is_none" per
field, so the wire format is the PATCH tri-state: absent = unchanged, null =
cleared, value = set.
The patch is also the "partial holder" workflow: filtering it by the touched set gives a save-only-touched-fields payload without a separate generated type (the feature-parity plan's decision D10 — the patch tri-state already expresses cleared-vs-unchanged).
// Only send what the user actually touched AND changed.
let touched: std::collections::HashSet<String> = state
.touched_fields()
.iter()
.map(|path| path.to_string())
.collect();
let mut patch = state.patch();
if !touched.contains("username") {
patch.username = None; // unchanged: leave the slot out of the payload
}TouchedFields (the headless bitset the facade composes) is re-exported as
gpui_form::TouchedFields for non-derived holders; touched state is transient
UI-session state and is never serialized. Blur/change wiring stays
app-authored: call state.touch(&path) from your component event handlers.
Koruma-gated <Name>FormState facades also compose a validation-outcome
cache for a live is_valid, and every form can opt into a headless submit
tracker through #[gpui_form(submit)]. No feature flag, no new
dependencies; the holder stays plain data in both cases.
use gpui_form::GpuiForm;
#[derive(GpuiForm, koruma::Koruma)]
pub struct Signup {
#[gpui_form(component(input))]
#[koruma(koruma_collection::collection::NonEmptyValidation::<_>::builder())]
pub username: String,
#[gpui_form(component(number_input))]
#[koruma(koruma_collection::numeric::RangeValidation::<_>::builder().min(18).max(120))]
pub age: Option<u32>,
}
let mut state = SignupFormState::new(SignupFormValueHolder::default());
assert!(!state.is_valid()); // fresh compute, nothing cached yet
state.validate(); // re-run koruma, cache the outcome
assert!(state.validation().is_some());
state.current_mut().age = Some(42); // edits invalidate the cache...
assert!(state.validation().is_none());
assert!(state.is_valid()); // ...but is_valid never lies (recomputes)
let field_error = state.error_for(&SignupFormPath::age()); // validates + caches on missFreshness is snapshot-compare: the cache stores the holder snapshot it
validated and a read is fresh iff current == snapshot. That makes it sound
under every mutation route — including the raw state_mut() escape hatch —
with zero invalidation discipline. The facade's own mutators
(current_mut, replace_current, both resets) invalidate up front,
sync_baseline() deliberately does not (validity depends only on current),
and invalidate_validation() is the manual escape. Render paths are safe:
is_valid() serves the cached verdict when fresh (one PartialEq) and
otherwise recomputes without caching.
The underlying gpui_form::ValidationCache<H, O> is a standalone core
primitive (like FormState and TouchedFields), usable for non-derived
holders with any outcome type. The cached method set emits only when the
holder derives koruma and the source struct is non-generic — the same gate
as the <Name>FormFieldErrors map it stores.
Add the opt-in attribute to get a per-form tracker alias over the headless
gpui_form::SubmitTracker:
#[derive(GpuiForm, koruma::Koruma)]
#[gpui_form(submit)]
pub struct Signup { /* fields as above */ }
let mut submission: SignupFormSubmission = gpui_form::SubmitTracker::new();
// Begin, gate, drive your save, finish — begin() FIRST, so every
// attempted submit counts (valid or not):
if submission.begin() {
if !state.is_valid() {
// A failed-validation attempt still counts; finish it with the
// typed koruma error so last_error carries the field errors.
submission.finish(state.current().validate());
} else {
let result = my_save(&state.current()).await; // your code drives the future
submission.finish(result.map(|_: Saved| ()));
if submission.is_submit_successful() {
state.sync_baseline(); // the save protocol
}
}
}begin()is the re-entry guard: it returnsfalsewhile a submit is in flight (double-click protection data), and only a successfulbegincounts an attempt (submit_count()). Call it before any validity gate: like react-hook-form'ssubmitCount, every attempted submit counts, success or failure — pre-gating onis_valid()would silently freeze the counter on invalid forms.finish(Err(e))recordslast_error();finish(Ok(()))clears it and flipsis_submit_successful(). Stale completions are ignored — afinisharriving afterreset()(or with no attempt in flight) changes nothing, so a slow submit can never write its outcome into a tracker that has moved on (the generation/epoch guard).is_submitted(),can_submit()(!is_submitting),abandon(),set_error/clear_error(manual server-error injection),take_last_error()(one-shot banners), andreset()round out the surface.- On koruma forms the alias's error parameter is the form's typed koruma
error (
<Name>FormValueHolder as koruma::ValidateExt>::Error), so a failed validate-then-submit carries the typed field errors — re-key them with<Name>FormFieldErrors::from_koruma_errors(&error)and render through the samevalidation.*keys. Without koruma (and on generic structs) the alias usesgpui_form::submit::BoxError. - Driving the submit is application code (the derive emits no
subscriptions): wrap
begin()→ your future →finish()incx.spawnor your own runtime.SubmitAction<H>is the executor-free hook contract for exactly that shape:
use gpui_form::SubmitAction;
struct SaveSignup;
impl SubmitAction<SignupFormValueHolder> for SaveSignup {
type Output = ();
type Error = <SignupFormValueHolder as koruma::ValidateExt>::Error;
async fn submit(&mut self, holder: &SignupFormValueHolder)
-> Result<(), Self::Error>
{
match holder.validate() { Ok(()) => save(holder).await, Err(e) => Err(e) }
}
}Async checks ("is this username taken?") have a headless contract,
gpui_form::AsyncFieldValidation<H> — an async fn in a trait, path-keyed by
gpui_form::FieldPath, with no executor and no Send bound. Implement it
once per form and return Ok(()) for fields you do not handle:
use gpui_form::{AsyncFieldValidation, FieldPath};
struct EmailTaken;
impl AsyncFieldValidation<SignupFormValueHolder> for EmailTaken {
type Error = &'static str;
async fn validate_field(&self, holder: &SignupFormValueHolder, field: &FieldPath)
-> Result<(), Self::Error>
{
if field.segments() != ["email"] { return Ok(()); }
match holder.email.as_deref() {
Some(email) if is_taken(email).await => Err("email taken"),
_ => Ok(()),
}
}
}Debounce, latest-wins cancellation, and merging async outcomes into the sync
error map are owned by the driving layer (GPUI glue or the app), not the
trait. The derive side is an attribute sketch in this milestone:
#[gpui_form(validate(async_fn = my_check), component(input))] parses and
records the validator path as field metadata (FieldVariant::async_validator)
— nothing executes it yet; wiring is a future phase. The forms-demo
example drives the contract by hand today (generation-guarded spawn per
edit).
Every #[derive(GpuiForm)] form also emits a <Name>FormPath type — a
strongly-typed newtype around the shared headless primitive
gpui_form::FieldPath — so every consumer of a form (validation, dirty
tracking, focus, analytics, schema export) can refer to fields through ONE
typed value instead of ad-hoc strings.
use gpui_form::{FieldPath, GpuiForm};
#[derive(GpuiForm)]
pub struct Settings {
#[gpui_form(component(input))]
pub username: String,
#[gpui_form(component(number_input))]
pub age: Option<u32>,
#[gpui_form(skip)]
pub internal_id: u32,
}
// One constructor per non-skipped field, named identically to the field.
let username = SettingsFormPath::username();
let age = SettingsFormPath::age();
assert_eq!(username.to_string(), "username");
assert_ne!(username, age);
// `Deref`/`AsRef`/`into_path` all reach the shared primitive, so any code
// that takes a `&FieldPath` (the validation/diff/schema surfaces)
// also accepts the typed newtype. Segments are the typed `PathSegment`
// enum — names plus list-item indices — since milestone M3.
fn records(path: &FieldPath) -> &[gpui_form::PathSegment] {
path.segments()
}
assert_eq!(
records(username.as_ref()),
&[gpui_form::PathSegment::Name("username")]
);
// Declaration-order tables and dense index mapping feed field-level state
// (touched/dirty/errors) and generic iteration:
assert_eq!(SettingsFormPath::FIELD_NAMES, &["username", "age"]);
assert_eq!(SettingsFormPath::FIELD_COUNT, 2);
assert_eq!(SettingsFormPath::all_paths().count(), 2);
assert_eq!(username.field_index(), Some(0));
assert_eq!(SettingsFormPath::path_for_index(0).as_ref(), Some(&username));Scope of this feature:
- Each flat field has one constructor named identically to the field.
Milestone M3 (backlog #2/#3) added typed composition on top: nested fields
get
<field>_root()plus a consuming appenderfn <field>(self), list fields get<field>_root()plusfn <field>(index: usize)carrying a typed index segment, and every form getsjoinfor cross-form composition (SettingsFormPath::address_root().join(&AddressFormPath::city())namesaddress.city). Hand-built multi-segment names-only paths viaSettingsFormPath::new(&["a", "b"])keep working. #[gpui_form(skip)]fields have NO constructor — they are absent from the holder too, and fromFIELD_NAMES/all_paths()/field_index().field_index()maps only single-segment root paths naming a real field (flat constructors and nested/list_root()constructors); hand-built multi-segment paths and list-item paths returnNone(the field-level state accessors treat them as inert — item-level touched tracking is sequenced after v1).FieldPathis a headless primitive: no GPUI, noserde, no feature flag. It is the shared naming foundation for the field-level validation (#6) and field-level diff/delta reporting (#9) surfaces shipped on the<Name>FormStatefacade anderror_for, and for the upcoming schema export (#14).
FieldPath and PathSegment are available unconditionally from
gpui_form::{FieldPath, PathSegment} (or gpui-form-core directly). The
segment representation is the typed PathSegment enum
(Name(&'static str) / Index(usize)) — decision [D8] of the
feature-parity plan — so list-item paths like tags.2 are one uniform
FieldPath; indices are stored typed, never formatted into strings.
component(nested) composes another generated form, and
component(list(item = ...)) edits a Vec<T>. Both store composed values in
the holder — no leaf widget is generated for them (like checkbox/switch), so
rendering the group stays application- or prototyping-authored.
The nested struct derives GpuiForm itself; the outer holder stores the
nested form's own generated value holder and converts through its From
impls. #[koruma(nested)] on the source field runs the nested form's
validators through the outer validate():
#[derive(Clone, Debug, Default, GpuiForm, Koruma)]
pub struct Address {
#[gpui_form(component(input))]
#[koruma(NonEmptyValidation::<_>::builder())]
pub city: String,
#[gpui_form(component(input))]
pub zip: Option<String>,
}
#[derive(Clone, Debug, Default, GpuiForm, Koruma)]
pub struct Resident {
#[gpui_form(component(input))]
#[koruma(NonEmptyValidation::<_>::builder())]
pub name: String,
#[gpui_form(component(nested))]
#[koruma(nested)]
pub address: Address,
}Typed paths compose across the forms — the outer form names the nested root,
join appends the nested form's own field paths:
let city = ResidentFormPath::address_root().join(&AddressFormPath::city());
assert_eq!(city.to_string(), "address.city");
// Errors route over composed paths, and the generated recursion accessor
// re-keys into the nested form's own error map for the inner validator
// kinds:
let errors = holder.validate_fields();
let Some(city_error) = errors
.error_for(&city)
.map(|_| ()) // any `address...` path routes to the nested slot
else { unreachable!() };
let inner = errors.address_errors() // Option<AddressFormFieldErrors>
.unwrap()
.error_for(&AddressFormPath::city())
.unwrap();The holder stores the plain item Vec (v1: no per-item Option) and gains a
typed add/remove/items API. each(...) element validators stay
element-scoped so koruma reports failures per item index:
#[derive(GpuiForm, Koruma)]
struct PostEditor {
#[gpui_form(component(list(item = input)))]
#[koruma(each(LenValidation::<_>::builder().min(2).max(32)))]
tags: Vec<String>,
}
let mut holder = PostEditorFormValueHolder::default();
holder.add_tags("gpui".to_string());
holder.remove_tags(0);
let items: &[String] = holder.tags_items();List-item paths carry a typed index segment — never a formatted string:
assert_eq!(PostEditorFormPath::tags(2).to_string(), "tags.2");
let errors = holder.validate_fields();
// Some only when item 2 failed its `each(...)` validators:
let item = errors.error_for(&PostEditorFormPath::tags(2));
// Index-addressable element errors:
let element = errors.tags_element_error(2);The M1 field-level state surface keys composed fields by their root paths
(tags_root() / address_root()): touched, per-field dirty, and patch slots
track the composed value as a whole.
Per the backlog's conservative-first rules, v1 ships:
Vec<T>only (no other collections),- add/remove — no reorder,
- scalar item components —
item = nestedanditem = custom(...)are rejected at parse time until the follow-up milestone.
Two field-level attributes describe relationships between fields. Both name a
sibling field of the same struct — a bare ident (depends_on = country) or the
quoted form (required_if = "subscribe") — and the reference is
existence-checked at derive time, so a typo is a compile-time error rather than
a silently-dead rule. Naming the field itself is rejected the same way: a
field cannot depend on or require itself. There is no expression language: the
controlling sibling is a field, not a condition string.
required_if desugars to a generated koruma validator on the holder field, so
a violation is a typed, path-addressable error — not display-only metadata:
use gpui_form::GpuiForm;
#[derive(GpuiForm)]
pub struct Newsletter {
#[gpui_form(component(switch))]
pub subscribe: bool,
#[gpui_form(component(input), required_if = subscribe)]
pub email: Option<String>,
}
let holder = NewsletterFormValueHolder { subscribe: true, email: None };
let errors = holder.validate_fields();
let error = errors.error_for(&NewsletterFormPath::email()).unwrap();
assert_eq!(error.issue_keys(), vec!["validation.required"]);Rules of the desugaring:
- The target field's holder value must be
Option<T>(anOption<T>source field, or a component that wraps inOption). - The controlling sibling holds a
bool(required whentrue), anOption<bool>(required whenSome(true)— an explicit off does not require), or any otherOption<T>(required when set). - A
required_iffield never also gets the synthetic unconditionalRequiredValidation; the conditional rule supersedes it. - Failures reuse the localized
validation.requiredkey, so existing locale entries render them with no new keys. - The rule composes with mirrored
#[koruma(...)]validators on the same field; each reports its own issue key. - The pure predicate is
gpui_form::required_when(required_when(requires, present)), re-exported fromgpui_form::corefor headless consumers.
depends_on records the sibling relationship as schema metadata — the
FieldVariant payload is the sibling's field name, the same representation a
FieldPath name segment carries — for conditional loading,
reset-on-parent-change, and tooling. The derive emits no event wiring ([D5]):
apps reset dependents from the parent's change handler, and the prototyping
scaffold emits a placeholder reset of the dependent's holder value.
#[gpui_form(component(select(searchable)))]
pub country: Option<Country>,
#[gpui_form(component(select), depends_on = country)]
pub state: Option<UsState>,
// App-authored reset-on-parent-change (the demo wires exactly this):
// on_country_confirm: self.form.current_mut().state = None;
// state_select.update(cx, |s, cx| s.set_selected_index(None, window, cx));Both attributes require a component(...) on the same field — the schema
FieldVariant that records them exists only for component fields.
Every #[derive(GpuiForm)] form also emits a <Name>FormSchema type whose
schema() accessor returns an owned, serializable description of the form —
component kinds and behaviour payloads, value and conversion types, validation
rules, layout hints, nested/list composition, and field dependencies. It is a
read-only projection of the same metadata the inventory feature registers
for prototyping, so the export can never drift from what the derive builds.
use gpui_form::FormSchema;
use gpui_form::GpuiForm;
#[derive(GpuiForm)]
pub struct UserProfile {
#[gpui_form(component(input), section = "Identity")]
pub username: String,
#[gpui_form(component(input), required_if = subscribe)]
pub email: Option<String>,
#[gpui_form(component(switch))]
pub subscribe: bool,
#[gpui_form(component(nested))]
pub address: Address,
#[gpui_form(component(list(item = input)))]
pub tags: Vec<String>,
}
let schema: FormSchema = UserProfileFormSchema::schema();
assert_eq!(schema.form, "UserProfile");
assert_eq!(
schema.field_names().collect::<Vec<_>>(),
vec!["username", "email", "subscribe", "address", "tags"]
);
let email = schema.field("email").unwrap();
assert_eq!(email.required_if.as_deref(), Some("subscribe"));
let address = schema.field("address").unwrap();
assert!(address.is_nested()); // composes the `Address` form
let tags = schema.field("tags").unwrap();
assert!(tags.is_list()); // item kind: inputThe document round-trips through serde out of the box — no feature flag — which makes it a fit for documentation generation, test fixtures, and non-GPUI consumers that want to inspect a form's shape without running the GPUI runtime:
let json = serde_json::to_string_pretty(&schema).unwrap();
let restored: FormSchema = serde_json::from_str(&json).unwrap();
assert_eq!(restored, schema);Notes:
- Field entries carry the layout hints (
section/label/description/placeholder/width), thedepends_on/required_ifsiblings, mirrored koruma rule names,default/from/intoexpressions, and custom component hints. - Component payloads are spelled like the attribute syntax: a select entry
records
searchable/partial, a nested entry the nested form struct, a list entry its item kind andadd_label. - Skipped fields are absent from the export; the document flags
has_skipped_fieldsso tooling can explain why. - The
inventoryfeature is not required: only the inventory registration is feature-gated; the export always emits.#[gpui_form(empty)]forms export an empty field list. gpui_form::FormSchemais the facade re-export; the field/component/layout entry types live undergpui_form::schema::registry.- For human-readable output,
gpui-form-prototyping-coreshipsschema_markdown(&schema), which renders the document as a Markdown reference table.
There are two supported custom-component workflows.
use gpui_form::{CustomComponentState, GpuiForm};
#[derive(Clone, Debug, CustomComponentState)]
#[gpui_form_custom(new = Self::new, component = TagsInput)]
pub struct TagsInputState;
#[derive(Clone, Debug, Default, GpuiForm)]
pub struct PostEditor {
#[gpui_form(component(custom(state = TagsInputState, wraps_in_option = false)))]
pub tags: Vec<String>,
}gpui_form::custom_component_shape!(
pub EmailInputShape,
state = gpui_kit::component::input::InputState,
new = gpui_kit::component::input::InputState::new,
component = gpui_kit::component::input::Input,
);
#[derive(Clone, Debug, Default, gpui_form::GpuiForm)]
pub struct ContactForm {
#[gpui_form(component(custom(shape = EmailInputShape)))]
pub email: String,
}Custom components can also opt into generated value synchronization by
implementing gpui_form::custom::CustomComponentValueAdapter<T> on the shape
and adding value_binding to the custom component options. The adapter remains
application-owned; gpui-form only calls its generic seed and event-conversion
hooks.
Runtime helpers are available from both:
gpui_form::runtime- legacy compatibility re-exports such as
gpui_form::custom,gpui_form::date_picker,gpui_form::file_picker,gpui_form::infinite_select, andgpui_form::numeric
For generated native path selection, use #[gpui_form(component(file_picker))]
on a PathBuf field or combine it with type, from, and into when the
model stores something derived from a path.
For manual native path selection, use gpui_form::file_picker or
gpui_form::runtime::file_picker. The runtime uses GPUI's
PathPromptOptions from the pinned GPUI dependency and renders the control
with gpui-kit buttons, icons, sizing, and theme tokens.
Built-in placeholder, prompt, browse-label, dropped-dialog, and selected-count
text resolve through rust-i18n and ship for en, fr-FR, and zh-CN; they
follow the shared locale managed through gpui_form::i18n. Explicit builder
values such as placeholder(...), prompt(...), and browse_label(...) remain
caller-provided text.
use gpui_form::file_picker::{FilePicker, FilePickerEvent, FilePickerState};
let picker = cx.new(|cx| FilePickerState::new(window, cx));
cx.subscribe_in(&picker, window, |_, _, event: &FilePickerEvent, _, _| {
if let FilePickerEvent::Change(paths) = event {
let _paths = paths;
}
});
FilePicker::new(&picker)
.placeholder("Choose a file")
.prompt("Choose a file")
.cleanable(true);date_picker fields can edit a different form-side type than the original model
field by combining type, from, and into:
#[derive(Clone, Debug, gpui_form::GpuiForm)]
pub struct User {
#[gpui_form(
type = chrono::NaiveDate,
from = to_form_date,
into = to_model_timestamp,
component(date_picker)
)]
pub birth_date: Option<Timestamp>,
}This pattern is useful when the model stores a domain-specific timestamp type
but the UI should edit a calendar date.
The built-in empty placeholder and selected-date label resolve through
rust-i18n and follow the shared locale managed through gpui_form::i18n;
pass DatePicker::placeholder(...) for custom copy. The
selected-date label and calendar popover use ICU4X for localized month names,
weekday headers, day/year labels, and locale-specific week starts. Manual
runtime code can use DateRangePicker and DateRangePickerState for range
selection; generated component(date_picker) fields remain single-date fields.
Both picker states accept optional selectable bounds: days outside
min_date/max_date render disabled and reject clicks, while the selected
value is unconstrained. Bounds are a runtime capability of the picker states;
schema/codegen integration is not wired yet.
use gpui_form::date_picker::{DatePickerState, DateRangePickerState};
let picker = cx.new(|cx| {
DatePickerState::new(window, cx)
.min_date(jiff::civil::date(2026, 1, 1))
.max_date(jiff::civil::date(2026, 12, 31))
});
let range = cx.new(|cx| {
DateRangePickerState::new(window, cx).max_date(jiff::civil::date(2026, 12, 31))
});Enable the inventory feature when you want to generate scaffolding from
registered GpuiFormShape metadata:
use gpui_form::schema::registry::{GpuiFormShape, inventory};
use gpui_form_prototyping_core::FormShapeAdapter;
for shape in inventory::iter::<GpuiFormShape>() {
let parts = FormShapeAdapter::new(shape)
.parts()
.expect("shape metadata should be valid");
let _imports = parts.imports;
}See examples/prototyping for a complete generator
that reads shape inventory, clears stale generated form modules, and writes
scaffolded GPUI form files. The form titles generated by that example
use the GPUI app context so they follow the active locale.
examples/README.md is the canonical index for runnable
workspace examples.
cargo run -p prototyping: generate scaffolded GPUI form files from shape inventory