Inputs
Two groups, by how they hold state:
- Controlled builders —
Checkbox,Switch,Radio,Chip,Rating, plus the group wrappersRadioGroup/CheckboxGroup. The parent owns the value; wire changes withcx.listener(or a binding). - Stateful entities —
TextInput,TextArea,NumberInput,PasswordInput,PinInput,ColorInput,TagsInput,Select,Combobox,SegmentedControl,Slider,RangeSlider. Created withcx.new, they own their buffer/selection and emit events.
Field is the shared label/description/error chrome that wraps a control;
NumberInput, TextArea, and Combobox compose it.
Binding inputs
Every input can skip the hand-written change handler and two-way bind to a
Signal instead. The two shapes mirror the two component
patterns:
- Controlled builders take a
Binding—.bind(signal.binding())for the whole value, or.bind(signal.lens(...))for one field of a struct signal. The binding overrides the plain value setter; user actions write back through it, then run anyon_change. - Stateful entities bind once after creation with the associated function
X::bind(&entity, &signal, cx)— the entity adopts the signal's value now, edits write back, and signal writes update the entity.
// Controlled: the binding replaces `checked` + `on_change`.
let dark = use_state(cx, false);
Switch::new("dark-mode").label("Dark mode").bind(dark.binding())
// Entity: bind after cx.new; edits and signal writes stay in sync.
let name = use_state(cx, String::new());
let input = cx.new(|cx| TextInput::new(cx).label("Name"));
TextInput::bind(&input, &name, cx);
Writes land in set_if_changed on both directions, so an echoed value is a
no-op and updates can't loop. The full story — lenses, map, constant — is
in Reactive → Bindings.
Checkbox
Checkbox::new("agree")
.label("I agree to the terms")
.checked(self.agree)
.color(ColorName::Blue)
.on_change(cx.listener(|this, _ev, _w, cx| {
this.agree = !this.agree;
cx.notify();
}))
| Method | Default |
|---|---|
new(id) |
— |
checked(bool) |
false |
indeterminate(bool) |
false |
label(impl Into<SharedString>) |
none |
size(Size) |
Sm |
color(ColorName) |
Blue |
disabled(bool) |
false |
on_change(handler) |
— |
Switch
Same controlled API as Checkbox, rendered as a sliding track.
Switch::new("notify")
.label("Enable notifications")
.checked(self.notify)
.on_change(cx.listener(|this, _, _, cx| { this.notify = !this.notify; cx.notify(); }))
Methods: new(id), checked, label, size (default Md), color,
disabled, on_change.
Radio
A single radio button. Exclusivity is the parent's job — give each one a
checked derived from your selection and an on_change that sets it.
let plans = ["Free", "Pro", "Enterprise"];
let group = plans.iter().enumerate().fold(Group::new(), |g, (i, label)| {
g.child(
Radio::new(("plan", i))
.label(*label)
.checked(self.plan == i)
.on_change(cx.listener(move |this, _, _, cx| { this.plan = i; cx.notify(); })),
)
});
Methods: new(id), checked, label, size (default Sm), color,
disabled, on_change.
Chip
A selectable pill (controlled), good for tag-style multi/single selection.
Chip::new("chip", "Notifications")
.checked(self.on)
.color(ColorName::Blue)
.on_change(cx.listener(|this, _, _, cx| { this.on = !this.on; cx.notify(); }))
Methods: new(id, label), checked, color, size (default Md), on_change.
Rating
A row of clickable stars (controlled). Clicking star i sets the value to
i; hovering an unfilled star previews it in the accent color. The f32
value is rounded to whole stars for display.
Rating::new("stars")
.value(self.stars)
.color(ColorName::Yellow)
.on_change(cx.listener(|this, value: &f32, _w, cx| {
this.stars = *value;
cx.notify();
}))
| Method | Default | Notes |
|---|---|---|
new(id) |
— | |
value(f32) |
0.0 |
rounded to whole stars, clamped to count |
count(usize) |
5 |
how many stars |
color(impl Into<ColorValue>) |
Yellow |
|
size(Size) |
Md |
glyph sizes: xs 14 … xl 36 |
readonly(bool) |
false |
display-only — no hover preview, no clicks |
on_change(handler) |
— | Fn(&f32, &mut Window, &mut App) |
bind(Binding<f32>) |
— | overrides value; clicks write back, then run on_change |
TextInput (entity)
A single-line text field that owns its buffer, focus, and caret. Create it with
cx.new and keep the Entity.
let name = cx.new(|cx| {
TextInput::new(cx)
.label("Name")
.placeholder("Ada Lovelace")
.description("Click to focus, then type.")
});
| Builder method | Notes |
|---|---|
new(cx) |
construct inside cx.new(|cx| ...) |
value(&str) |
initial text |
placeholder / label / description / error |
chrome (error supersedes description, turns the border red) |
size(Size) |
default Sm |
radius(Size) |
|
disabled(bool) |
|
password(bool) |
masks characters |
Runtime: input.read(cx).text() reads the value; input.update(cx, |ti, cx| ti.set_text("…", cx)) sets it. It emits:
pub enum TextInputEvent { Change(String), Submit(String) } // Submit fires on Enter
cx.subscribe(&name, |_this, _input, event, cx| {
if let TextInputEvent::Submit(value) = event {
// ...
}
}).detach();
Editing follows the macOS/Linux conventions: Option+←/→ moves by word,
Cmd+←/→ to line start/end, Option+Backspace deletes a word, Cmd+Backspace /
Cmd+Delete clears to the line edge, plus Ctrl+A / Ctrl+E / Ctrl+K. It is
unicode-correct (the underlying TextEdit model is unit-tested). Escape and
Tab are left to bubble so a host (a dialog, a form) can cancel or move focus.
Driving a field yourself
The single-line model and its key map are public, for hosts that render their
own chrome (a search bar, a command palette) instead of embedding the full
TextInput:
use guise::{apply_key, KeyOutcome, TextEdit};
// state: edit: TextEdit
match apply_key(&mut self.edit, &keystroke) {
KeyOutcome::Submit => { /* commit */ }
KeyOutcome::Cancel => { /* dismiss */ }
KeyOutcome::Edited => { /* redraw; read self.edit.split() for the caret */ }
KeyOutcome::Pass => { /* not ours — Tab, Cmd+W, … */ }
}
apply_key is exactly what TextInput uses internally, so an inline field and
the full component stay keystroke-for-keystroke identical.
Select (entity)
A dropdown picker that owns its open state and selection, rendered as a trigger plus a deferred (overlaid) list.
let framework = cx.new(|cx| {
Select::new(cx)
.label("Framework")
.placeholder("Choose one…")
.data(["gpui", "Mantine", "SwiftUI", "Flutter"])
.selected(0)
});
Methods: new(cx), data(iter), selected(usize), placeholder, label,
size, disabled. Read with selected_index() / selected_value(). Emits
SelectEvent(usize).
SegmentedControl (entity)
A single-choice segmented switch.
let range = cx.new(|cx| {
SegmentedControl::new(cx).data(["Day", "Week", "Month"]).selected(1)
});
Methods: new(cx), data(iter), selected(usize), size. Read with
selected_index(). Emits SegmentedControlEvent(usize).
RadioGroup
A controlled set of mutually-exclusive radios — the ergonomic layer over bare
Radio. The parent owns the selected index; the group wires exclusivity and
reports the new index.
RadioGroup::new()
.label("Plan")
.options(["Free", "Pro", "Enterprise"])
.value(self.plan)
.on_change(cx.listener(|this, index, _w, cx| { this.plan = index; cx.notify(); }))
Methods: new(), options(iter), value(usize), label, color, size,
on_change(Fn(usize, &mut Window, &mut App)).
CheckboxGroup
A controlled set over a shared selection. The parent owns a sorted Vec<usize>;
each toggle reports the next full selection.
CheckboxGroup::new()
.label("Notify me about")
.options(["Mentions", "Replies", "Releases"])
.value(self.notify.clone())
.on_change(cx.listener(|this, next, _w, cx| { this.notify = next; cx.notify(); }))
Methods: new(), options(iter), value(iter), label, color, size,
on_change(Fn(Vec<usize>, &mut Window, &mut App)).
Field
The label / description / error wrapper every input draws. Use it directly to give any control the same chrome.
Field::new()
.label("API key")
.description("Found in your account settings.")
.child(my_control)
Methods: new(), label, description, error (supersedes description),
child(impl IntoElement).
NumberInput (entity)
A numeric field with stepper buttons. Constrains input to digits/./-, clamps
to min/max, and nudges by step (steppers or ↑/↓). Composes Field.
let qty = cx.new(|cx| {
NumberInput::new(cx).label("Quantity").min(0.0).max(99.0).step(1.0).value(1.0)
});
Methods: new(cx), value(f64), min, max, step, label, description,
error, size, disabled. Read with value_f64() -> Option<f64>. Emits
NumberInputEvent(f64).
TextArea (entity)
A multiline field. Enter inserts a newline; ↑/↓ move between lines keeping the
column. Reuses the unicode-correct TextEdit model and composes Field.
let bio = cx.new(|cx| {
TextArea::new(cx).label("Bio").placeholder("Tell us about yourself").rows(4)
});
Methods: new(cx), value(&str), placeholder, label, description,
error, rows(usize), size, disabled. Read with text(); set with
set_text(value, cx). Emits TextAreaEvent(String).
Combobox (entity)
A searchable Select. The trigger is an editable query; the deferred list
filters by case-insensitive substring. Single-select closes on choice;
multiple(true) keeps a set and stays open. Type to filter, Enter picks the
first match, Esc closes.
let city = cx.new(|cx| {
Combobox::new(cx).label("City").data(["Austin", "Boston", "Chicago", "Denver"])
});
Methods: new(cx), data(iter), multiple(bool), selected(iter),
placeholder, label, size, disabled. Read with selected_indices().
Emits ComboboxEvent(usize) (the toggled index).
Slider (entity)
A horizontal value track in min..=max snapped to step. Click the track to
set a value; arrow keys nudge by one step. (gpui doesn't hand elements their own
bounds, so position is derived from discrete segment cells rather than the raw
pointer x.)
let volume = cx.new(|cx| Slider::new(cx).min(0.0).max(100.0).step(5.0).value(40.0));
Methods: new(cx), value(f64), min, max, step, color, disabled.
Read with value_f64() -> f64. Emits SliderEvent(f64).
RangeSlider (entity)
A two-thumb slider holding a (low, high) pair in min..=max. Each thumb is
a real gpui drag source (on_drag + on_drag_move), so dragging tracks the
pointer even outside the control; clicking the track jumps the nearest thumb;
arrow keys (and Home/End) nudge the last active thumb. Values snap to step
and keep at least min_gap apart.
let range = cx.new(|cx| {
RangeSlider::new(cx).min(0.0).max(100.0).min_gap(10.0).value((20.0, 80.0))
});
cx.subscribe(&range, |_this, _slider, event: &RangeSliderEvent, _cx| {
let (low, high) = event.0;
}).detach();
| Method | Default | Notes |
|---|---|---|
new(cx) |
— | construct inside cx.new(|cx| ...) |
value((f64, f64)) |
(25.0, 75.0) |
set min/max/step/min_gap first — the pair is normalized against them |
min(f64) / max(f64) |
0.0 / 100.0 |
|
step(f64) |
1.0 |
|
min_gap(f64) |
0.0 |
minimum distance between the thumbs |
color(ColorName) |
Blue |
|
size(Size) |
Md |
thumb and track dimensions |
disabled(bool) |
false |
|
value_pair() |
— | read the current (low, high) |
RangeSlider::bind(&entity, &Signal<(f64, f64)>, cx) |
— | two-way binding |
Emits RangeSliderEvent((f64, f64)) on change. (Unlike Slider, click
positions are hit-tested against real track bounds — an invisible canvas
captures them each frame, since gpui doesn't hand elements their own bounds.)
PasswordInput (entity)
A masked text field with an eye toggle that reveals the plain text. Same
buffer, focus, and key handling as TextInput in password mode; the eye flips
visibility without losing the value.
let secret = cx.new(|cx| {
PasswordInput::new(cx)
.label("Password")
.placeholder("At least 8 characters")
});
cx.subscribe(&secret, |_this, _input, event, _cx| {
if let PasswordInputEvent::Submit(value) = event { /* log in */ }
}).detach();
| Method | Default | Notes |
|---|---|---|
new(cx) |
— | construct inside cx.new(|cx| ...) |
value(&str) |
"" |
initial text |
placeholder / label / description / error |
none | chrome; error turns the border red |
visible(bool) |
false |
start revealed; the eye still toggles |
size(Size) |
Sm |
|
disabled(bool) |
false |
|
text() / set_text(&str, cx) |
— | read / write at runtime |
PasswordInput::bind(&entity, &Signal<String>, cx) |
— | two-way binding |
Emits PasswordInputEvent::{Change(String), Submit(String)} — Submit fires
on Enter. Escape and Tab bubble to the host, as in TextInput.
PinInput (entity)
Segmented one-character code boxes — the one-time-code field. Typing advances, Backspace clears and retreats, arrows move between boxes, and Cmd+V fills them from the clipboard (whitespace stripped, extra characters dropped).
let pin = cx.new(|cx| PinInput::new(cx).length(6).mask(true));
cx.subscribe(&pin, |_this, _pin, event, _cx| {
if let PinInputEvent::Complete(code) = event { /* verify */ }
}).detach();
| Method | Default | Notes |
|---|---|---|
new(cx) |
— | construct inside cx.new(|cx| ...) |
length(usize) |
4 |
number of boxes |
mask(bool) |
false |
render filled boxes as bullets |
value(&str) |
"" |
initial code; characters beyond length are dropped |
size(Size) |
Sm |
box dimensions |
disabled(bool) |
false |
|
text() / set_text(&str, cx) |
— | read / write at runtime |
PinInput::bind(&entity, &Signal<String>, cx) |
— | two-way binding |
Emits PinInputEvent::{Change(String), Complete(String)} — Complete fires
when every box is filled (its Change fires first). text() returns the
filled characters in slot order.
ColorInput (entity)
A swatch plus an editable hex/CSS text field. Clicking the swatch opens the
full theme palette (14 colors × 10 shades) in a deferred dropdown; typing any
css()-parsable color — #40c057, rgb(64, 192, 87), teal — updates the
swatch live. Enter normalizes the buffer to hex; Escape closes the dropdown.
let brand = cx.new(|cx| {
ColorInput::new(cx).label("Brand color").value(rgb(34, 139, 230))
});
cx.subscribe(&brand, |_this, _input, event: &ColorInputEvent, _cx| {
let color: Hsla = event.0;
}).detach();
| Method | Default | Notes |
|---|---|---|
new(cx) |
— | construct inside cx.new(|cx| ...) |
value(impl Into<Hsla>) |
black | also rewrites the buffer as hex |
label / description / error |
none | chrome; error turns the border red |
size(Size) |
Sm |
|
disabled(bool) |
false |
|
color_value() |
— | read the current Hsla |
ColorInput::bind(&entity, &Signal<Hsla>, cx) |
— | two-way binding |
Emits ColorInputEvent(Hsla) whenever the color changes (typed or picked).
Alpha is dropped when the buffer is normalized — the field holds opaque hex.
TagsInput (entity)
A pill list with an inline editor. Enter or comma commits the query as a tag (trimmed, non-empty, unique); Backspace in an empty query pops the last pill; each pill has a remove button.
let topics = cx.new(|cx| {
TagsInput::new(cx)
.label("Topics")
.placeholder("Type and press Enter…")
.max_tags(5)
});
cx.subscribe(&topics, |_this, _input, event: &TagsInputEvent, _cx| {
let tags: &Vec<String> = &event.0;
}).detach();
| Method | Default | Notes |
|---|---|---|
new(cx) |
— | construct inside cx.new(|cx| ...) |
tags(impl IntoIterator) |
[] |
initial tags |
placeholder / label / description / error |
none | chrome; error turns the border red |
max_tags(usize) |
unlimited | commits beyond the cap are ignored |
size(Size) |
Sm |
|
disabled(bool) |
false |
|
tag_values() / set_tags(Vec<String>, cx) |
— | read / write at runtime (set_tags emits no event) |
TagsInput::bind(&entity, &Signal<Vec<String>>, cx) |
— | two-way binding |
Emits TagsInputEvent(Vec<String>) with the full list on every add/remove.
Committing a duplicate clears the query without emitting.