Skip to content

About

No description, website, or topics provided.

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

 
 

Repository files navigation

Build Status Docs Crates.io

gpui-form

gpui-form is a type-safe form-generation ecosystem for gpui and gpui-kit, centered on #[derive(GpuiForm)].

It is designed for three things:

  1. Compile-time generation of strongly typed form state and helper types.
  2. Concise field annotations on normal application structs.
  3. Runtime helpers, metadata, and prototyping support around the derive-based workflow.

Compatibility

freeoxide-gpui-form gpui-kit gpui
git
branch = "master" 0.7.3 0.3.9 (gpui-pre)

Installation

[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.

Quick Start

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:

  • UserProfileFormFields for GPUI entity state
  • UserProfileFormComponents constructors for those fields
  • UserProfileFormValueHolder for typed editing, defaults, validation, and conversion back into the original model
  • UserProfileFormPath typed field paths so validation, dirty tracking, focus, analytics, and schema export share ONE typed way to name fields (see Typed Field Paths)

Component Syntax

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 the phone feature)
  • #[gpui_form(component(phone_input(country = <field>)))] (requires the phone feature)
  • #[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 editable Vec<T> with scalar item components (input, number_input, select, checkbox, switch, phone_input, infinite_select, date_picker, file_picker), plus optional add_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-String form-side value types with FromStr in prototyping output, so value objects can use type, from, and into while the source model keeps its storage type.
  • Field-level #[koruma(...)] attributes are accepted by GpuiForm and copied onto the generated value holder, including fields that use type, from, and into to 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.

Phone Number Validation

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"] }

As a generated form field

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.

As headless helpers

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.

Empty handling and the PhoneCountry trait

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 through rust-i18n (see Localization).

Layout and Section Hints

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.

Infinite Select Runtime

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 raw variant_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 own rust-i18n locales (see Localization)
  • #[tuple_enum(key = "...")] overrides persisted keys when enum names should stay decoupled from storage
  • selection_key_path() / build_from_key_path(...) round-trip nested values without depending on enum ordering
  • InfiniteSelectKeyPath supports Display, FromStr, and serde string round-trips for URLs and persisted config
  • build_from_path(...), build_from_key_path(...), set_path(...), and set_key_path(...) return InfiniteSelectPathError with the failing depth plus the invalid key/index segment
  • set_selected_index_at_depth(...) / set_selected_key_at_depth(...) support incremental programmatic updates

Validation With Koruma

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>FormFieldErrors map re-keying koruma's typed per-field error structs by <Name>FormPath (see the next section)

Typed error access (error_for)

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 (rust-i18n)

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").

Saving, Restoring, and Dirty Tracking

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:

  • FormState stores holder data only — not component runtime UI state such as open menus, scroll positions, or InfiniteSelectState snapshots.
  • FormState's own dirty/diff stays boolean-level: is_dirty() and diff_against(&other) tell you whether the holder changed. The field-level diff (which fields changed, typed patches, touched tracking) lives on the generated <Name>FormState facade below.
  • A holder carrying #[gpui_form(skip)] fields round-trips through serde on its own, but cannot fully reconstruct the source struct via into_original, since skipped values are not on the holder. This mirrors the existing has_skipped_fields behavior. 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.

Field-level state: <Name>FormState, patches, and touched tracking

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.

Submit Lifecycle and Live Validation

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.

Live is_valid (validation-state cache)

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 miss

Freshness 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.

Submit lifecycle (#[gpui_form(submit)])

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 returns false while a submit is in flight (double-click protection data), and only a successful begin counts an attempt (submit_count()). Call it before any validity gate: like react-hook-form's submitCount, every attempted submit counts, success or failure — pre-gating on is_valid() would silently freeze the counter on invalid forms.
  • finish(Err(e)) records last_error(); finish(Ok(())) clears it and flips is_submit_successful(). Stale completions are ignored — a finish arriving after reset() (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), and reset() 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 same validation.* keys. Without koruma (and on generic structs) the alias uses gpui_form::submit::BoxError.
  • Driving the submit is application code (the derive emits no subscriptions): wrap begin() → your future → finish() in cx.spawn or 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 validation contract (metadata-only attribute)

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).

Typed Field Paths

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 appender fn <field>(self), list fields get <field>_root() plus fn <field>(index: usize) carrying a typed index segment, and every form gets join for cross-form composition (SettingsFormPath::address_root().join(&AddressFormPath::city()) names address.city). Hand-built multi-segment names-only paths via SettingsFormPath::new(&["a", "b"]) keep working.
  • #[gpui_form(skip)] fields have NO constructor — they are absent from the holder too, and from FIELD_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 return None (the field-level state accessors treat them as inert — item-level touched tracking is sequenced after v1).
  • FieldPath is a headless primitive: no GPUI, no serde, 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>FormState facade and error_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.

Nested Forms and Repeated Fields

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.

Nested forms (component(nested))

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();

Repeated fields (component(list(item = ...)))

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.

v1 boundaries

Per the backlog's conservative-first rules, v1 ships:

  • Vec<T> only (no other collections),
  • add/remove — no reorder,
  • scalar item components — item = nested and item = custom(...) are rejected at parse time until the follow-up milestone.

Field Dependencies and Conditional Requiredness

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 = <field> (conditional requiredness)

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> (an Option<T> source field, or a component that wraps in Option).
  • The controlling sibling holds a bool (required when true), an Option<bool> (required when Some(true) — an explicit off does not require), or any other Option<T> (required when set).
  • A required_if field never also gets the synthetic unconditional RequiredValidation; the conditional rule supersedes it.
  • Failures reuse the localized validation.required key, 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 from gpui_form::core for headless consumers.

depends_on = <field> (dependency metadata)

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.

Schema Export

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: input

The 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), the depends_on/required_if siblings, mirrored koruma rule names, default/from/into expressions, 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 and add_label.
  • Skipped fields are absent from the export; the document flags has_skipped_fields so tooling can explain why.
  • The inventory feature 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::FormSchema is the facade re-export; the field/component/layout entry types live under gpui_form::schema::registry.
  • For human-readable output, gpui-form-prototyping-core ships schema_markdown(&schema), which renders the document as a Markdown reference table.

Custom Components

There are two supported custom-component workflows.

1. Derive directly on a state type

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>,
}

2. Declare a reusable shape

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, and gpui_form::numeric

File Picker Runtime

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 Conversion

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))
});

Prototyping

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

examples/README.md is the canonical index for runnable workspace examples.

  • cargo run -p prototyping: generate scaffolded GPUI form files from shape inventory

About

No description, website, or topics provided.

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages