GPUI Box GitHub

Reactive state

gpui_kit::reactive is caller-owned state and the wiring that connects it to a control. Nothing in it renders, and no component owns any of it.

TypeWhat it is
Signal<T>A value the caller creates and keeps. Changing it notifies every watcher once.
Binding<T>A read and a write of one value, handed to a control. Not storage.
History<T>A bounded undo/redo stack of caller-owned records. It stores records and applies nothing.
FormNamed Signal<String> fields, the caller's rules, and a ValidationState per field.
validatorsrequired, email, min_len, equals_field — the rules most forms are built from.

A bound control is still a reader of caller data. .bind sets the same value and the same handler the caller would have written by hand, so a caller that refuses a change still sees the control keep showing what is true.

A signal

let volume = Signal::new(cx, 0.6f32);

volume.get(cx);                         // 0.6
volume.update(cx, |value| *value += 0.1);   // notifies
volume.set(cx, 0.7);                    // notifies nobody: it is already 0.7

A view watches it, and keeps the subscription for as long as it draws it:

struct Mixer {
    volume: Signal<f32>,
    _watch: Subscription,
}

impl Mixer {
    fn new(volume: Signal<f32>, cx: &mut Context<Self>) -> Self {
        Self {
            _watch: volume.watch(cx),
            volume,
        }
    }
}

set says nothing when the value did not move. That is the whole echo guard: a control reports a change, the change writes the signal, and the signal writing the control back is what would move the caret or fire the handler again.

A binding

let binding = volume.binding();
binding.get(cx);
binding.set(cx, 0.4);

map converts in both directions, and lens projects one field of a struct:

let percent: Binding<f64> = volume
    .binding()
    .map(|value| f64::from(*value) * 100.0, |value, percent| {
        *value = (percent / 100.0) as f32
    });

let name: Binding<String> = profile.lens(
    |profile| &profile.name,
    |profile, value| profile.name = value,
);

A lens write is a read-modify-write of the whole value, so it moves the field it projects and leaves every other field exactly as it was.

A history

History<T> holds reversible records, not copies of application state and not commands it can execute. The caller applies the record returned by undo in reverse and the one returned by redo forwards:

let mut history = History::new(200);
history.push(edit);

if let Some(edit) = history.undo() {
    document.apply_reverse(edit);
}

Recording after an undo starts a new branch and clears redo. set_ignoring refuses records at the storage boundary, which lets a caller replay changes without relying on every call site to remember a flag. The oldest records are dropped when the declared capacity is reached; a capacity of zero records nothing.

Binding a control

Builders take a Binding and a context, because the current value is read at build time:

Checkbox::new("terms.accept").label("Accept the terms").bind(&accepted.binding(), cx)
Switch::new("run.notify").bind(&notify.binding(), cx)
Toggle::new("format.bold").icon(Icon::Bold).bind(&bold.binding(), cx)
ToggleGroup::new("format.marks").items(marks).bind(&pressed.binding(), cx)
SegmentedControl::new("view.mode").segments(modes).bind(&mode.binding(), cx)
Slider::new("mixer.volume").bind(&volume.binding(), cx)
Radio::new("plan.monthly").bind_value(&plan.binding(), Plan::Monthly, cx)

A radio binds a value rather than a flag: every button in the group binds the same binding with its own value, and the exclusivity is the equality.

Views are entities, so they take the signal and answer with the subscriptions that are the binding. The caller holds them for as long as the two should stay together:

let subscriptions = TextInput::bind(&email_field, &email, cx);
ControlSignalReported by
TextInputSignal<String>TextInputEvent::Change
TextAreaSignal<String>TextAreaEvent::Change
PasswordInputSignal<String>PasswordInputEvent::Change
NumberInputSignal<f64>NumberInputEvent::Changed
SelectSignal<Option<SharedString>>SelectEvent::Selected, Cleared
TagInputSignal<Vec<SharedString>>TagInputEvent::Added, Removed, Moved

Two of those are worth reading twice.

A NumberInput that holds text which is not a number writes nothing: the signal keeps the last number that was one and the field keeps showing what was typed, so the disagreement stays visible instead of being resolved by guessing. A value outside the range is still a number and is still written.

A TagInput reports what the typist asked for and never applies it, so bind is what applying it looks like. A duplicate and a full field are refusals — the field is already saying so where the typist is looking — and the set does not change.

A form

Every field is its own Signal<String>. Rules are the caller's, they run synchronously, and each is given the field's own text and the whole form, so a rule that compares two fields needs no second mechanism.

let form = Form::new()
    .field(cx, "email", "")
    .field(cx, "password", "")
    .field(cx, "confirm", "")
    .rule("email", validators::required())
    .rule("email", validators::email())
    .rule("password", validators::min_len(12))
    .rule("confirm", validators::equals_field("password"));

let email = form.signal("email").expect("a field that was added");
let subscriptions = TextInput::bind(&email_field, &email, cx);

if let Some(values) = form.submit(cx) {
    host.sign_in(values);
}

A submission that fails answers None and records why, per field, on the ValidationState ladder every field control already publishes:

match form.validation("email", cx) {
    ValidationState::Pending => {}
    ValidationState::Validating => {}
    ValidationState::Invalid { reason } => field.invalid(true).message(reason),
    ValidationState::Valid => {}
}

There is no second vocabulary for a form result. Pending is a field nobody has judged, which is what an untouched form is made of.

After a submission, every judged field is re-validated as it is edited, so a reason a reader has fixed disappears without another submission. A field that was never judged is left alone: a field nobody has reached is not marked wrong for being empty.

Asynchronous checks belong to the host

Rules here are synchronous. A check that has to leave the machine is the host's, and the host says so on the same ladder:

form.set_validation(cx, "email", ValidationState::Validating);
// … later
form.set_validation(cx, "email", ValidationState::invalid(refusal));

A field left in Validating is never overwritten by a rule and never counts as a pass. An unfinished check is not a failure and is not a success.

The words a rule gives

A validator's reason comes from the installed Strings catalogue — FormRequired, FormEmail, FormMinLengthOne / FormMinLengthMany, FormFieldsDiffer — so a host that replaced those words gets its own words back out of a validation failure. A rule the caller writes returns whatever text the caller wants; nothing here authors it.

validators::email checks the least this library can check: one @, something either side of it, and a dot in the domain. Whether an address exists is a question for the host. Empty text passes it, because an optional field is not an invalid one and required is how a caller says otherwise.

What is not printed

A Signal prints its identity and never its value, and FormValues prints field names and never what was typed. A form field holds what somebody typed, and a credential is one of the things somebody types.