Buttons and form controls¶
The small controls a form is made of: buttons, check boxes, radio groups,
switches, sliders, a drop-down select field, collapsible sections, and
spinners, with a node shown as loading. Each is a node bound to a signal
(a button, to an action), takes the keys and the mouse, tells assistive
technology what it is and what state it is in, and draws in theme styles,
so a theme, a theme file or .theme_styles(..) restyles it. Text fields,
calendars and date pickers are on Inputs, text and dates.
examples/controls.rs is a settings form built from all of them:
╭─ Settings ───────────────────────────────────────────────╮
│ Appearance │
│ Theme Solarized ▾ │
│ Font size ━━━━━━━━●──────────────── 16pt │
│ [ ] Show line numbers │
│ ──● Dark title bar │
│ │
│ Editor │
│ Indent with ( ) Spaces │
│ (•) Tabs │
│ │
│ ▾ Advanced │
│ [ ] Send anonymous usage data │
│ Autosave ━━━━━━●────────────────── every 30s │
│ │
│ │
╰──────────────────────────────────────────────────────────╯
Not saved yet
Save Reset Delete profile
tab moves · enter opens · space ticks · ←/→ slide · q quits
Each control is a function at the crate's root:
use intuituive::{
button, checkbox, collapsible, radio, select_field, slider, spinner, switch_toggle,
ButtonVariant,
};
// Labels that change, three states, and options known by key:
use intuituive::{
button_of, checkbox_of, checkbox_tri, radio_keyed, select_field_keyed, switch_of,
CheckState, SelectOption,
};
Sized .auto(), a control takes one row in a column and its natural
width in a row; a column stretches it across unless it is given a size.
.disabled_when(..) greys any of them out (the theme's
control.disabled, else dim), takes it out of the Tab order and stops
its keys and clicks.
Buttons¶
let saved = signal(0);
row([
button("Save", move |_| saved.update(|n| *n += 1)).variant(ButtonVariant::Primary),
button("Cancel", |cx| cx.pop()),
button("Delete", delete).variant(ButtonVariant::Danger).disabled_when(move || locked.get()),
])
.gap(2)
button(label, on_press) runs on_press (it gets the Ctx) when Enter
or Space is pressed while it has the focus, or when it is clicked: a press
let go over it. While the mouse holds it, it looks pressed: [Save].
Its label is console markup, with a cell of padding either side, centred
in whatever width it is given.
Its face is a theme style: button, or with .variant(..)
button.primary or button.danger. Over the face goes .focus while it
has the focus or the pointer, or is held (button.primary.focus, else
button.focus, else bold reverse), and .disabled while it is
disabled (else dim). The dark, light and mono themes each name the three
faces.
Three rows tall, a button draws a lit top edge and a shaded bottom one
when the theme names their colours (button.light and button.shade,
button.focus.light and so on), swapped while it is held. The
widget gallery's
Custom tab gives each of its three buttons its own colours this way:
button("Red", press)
.theme_styles([
("button", "#f0d0d0 on #802020"),
("button.light", "#c04040"),
("button.shade", "#401010"),
("button.focus", "#ffffff on #b03030"),
("button.focus.light", "#f06060"),
("button.focus.shade", "#601818"),
])
.fixed(15)
A screen reader hears Save, button, and disabled when it is.
Labels that change¶
let playing = signal(false);
button_of(
move || if playing.get() { "Pause".into() } else { "Play".into() },
move |_| playing.update(|p| *p = !*p),
)
button_of(label, on_press) reads its label from a closure, as
text(|| ..) does: when a signal it reads changes, the button draws
again, and lays its row out again if the label's width changed. It is the
same node all the while, so it keeps the focus, and a press the mouse is
holding, as its label goes from Play to Pause. Its accessible name follows
the label. button(label, f) is button_of with a label that never
changes. checkbox_of and switch_of do the same for a check box and a
switch.
Check boxes and switches¶
let wrap = signal(true);
let dark = signal(false);
column([
checkbox("Wrap lines", wrap),
switch_toggle("Dark title bar", dark),
])
checkbox(label, on) and switch_toggle(label, on) hold a
Signal<bool>: Space, Enter or a click flips it, and a switch also takes
→ for on and ← for off. The tick is in the theme's checkbox.on style
(else accent); the switch's track is in switch.on (else good) or
switch.off (else muted). With the focus, the control's text takes
control.focus (else reverse). (The node switch(..) is the content
switcher of Nodes and layout, hence switch_toggle.)
They are read as Wrap lines, check box, checked and
Dark title bar, switch, not checked. For a node of your own that is on
or off, .checked_when(..) gives it the same state (see
Accessibility).
Three states¶
A parent of children that are partly chosen is neither on nor off.
checkbox_tri(label, state) holds a Signal<CheckState>, one of Off,
On and Mixed, drawn [ ], [x] and [-]. Space, Enter or a click
turn it on from off and off from on; from mixed they turn it on, or what
.on_mixed(CheckState::Off) says. Only code makes it mixed: the parent
sets it from its children, and a change from outside redraws it, focused
or not. A disabled box stays as it is, mixed included.
checkbox_of(label, state) is the same with a label that changes.
A mixed box is read as All files, check box, mixed: its AccessState
has mixed set and checked None, and a browser gets
aria-checked="mixed".
Radio groups¶
let indent = signal(0usize);
radio(|| vec!["Spaces".into(), "Tabs".into()], indent).label("Indent with")
radio(options, selected) draws one option a row, the one selected
holds marked. ↑/↓ (or ←/→) move the choice within the group, round from
the last to the first, Home and End go to the ends, and a click chooses an
option. The dot is in radio.on (else accent); with the focus, the
chosen option takes control.focus. Name the group with .label(..): it
is read as Indent with, radio group, 1 of 2: Spaces, checked.
A group whose options are known by key is
radio_keyed, below.
Sliders¶
let size = signal(14.0);
row([
slider(size, 8.0..=32.0, 1.0).label("Font size").fixed(25),
text!(" {size}pt"),
])
slider(value, range, step) holds a Signal<f64>, kept in the range and
on a step (a step of 0 moves freely). ←/↓ and →/↑ move it a step,
PageDown and PageUp a tenth of the range, Home and End to its ends. A
click puts the knob under the pointer and a drag moves it (the slider
keeps the mouse until the button is let go); the wheel moves it a step.
The filled side is slider.filled (else accent), the rest
slider.track (else muted), the knob slider.knob; with the focus the
knob takes control.focus. It is twenty cells wide unless sized.
It is read as Font size, slider: 14, its value with as many decimals as
its step has, and a browser gets aria-valuenow.
Select fields¶
let theme = signal(0usize);
select_field(|| vec!["Dark".into(), "Light".into(), "Solarized".into()], theme)
.label("Theme")
.fixed(16)
select_field(options, selected) shows the chosen option and a ▾.
Enter, Space or a click opens a menu of the options under
it, at the chosen one: ↑/↓ move, Enter or a click chooses, Esc closes it.
Closed, ↑/↓ choose the option before or after. In either, letters jump: a
letter goes to the next option it starts (again, round those it starts),
and letters typed together, under a second apart, to the option they
start. A letter no option starts goes on up to the node's ancestors, so
q still quits. The field is drawn in the theme's select style with
control.focus over it; the menu is a menu's.
It is read as Theme, combo box, 3 of 3: Solarized, collapsed
(expanded while its menu is open).
Options known by key¶
An index says which place is chosen, not which option. When the options
change under an open form (devices plugged in, branches fetched, accounts
added), the choice should stay with its option. select_field_keyed and
radio_keyed take options with a key each, and the chosen key:
#[derive(Clone, PartialEq)]
enum Output { Speakers, Hdmi, Headset }
let output = signal(Some(Output::Speakers));
select_field_keyed(
move || {
let headset = SelectOption::new(Output::Headset, "USB headset");
vec![
SelectOption::new(Output::Speakers, "Speakers"),
SelectOption::new(Output::Hdmi, "HDMI"),
match plugged.get() {
true => headset,
false => headset.disabled("unplugged"),
},
]
},
output,
)
.label("Output")
A SelectOption<K> has a key, a label (console markup), enabled
and a reason. SelectOption::new(key, label) makes one that can be
chosen; .disabled(reason) one that cannot. The selected signal is a
Signal<Option<K>>:
- A reorder or a relabel keeps the choice: it is found by its key.
- When the chosen key goes from the options, the signal becomes
None: the field shows(none)and is read asOutput, combo box: none, and a group shows no dot. ↓ then goes to the first option that can be chosen. - A disabled option is shown greyed with its reason (
USB headset (unplugged)in a group; the reason at the right in the field's menu). The arrows, Home and End pass it by, a click on it does nothing, and typing its first letters does not reach it: the letter goes on up to the node's ancestors.
A keyed radio group also jumps to an option when its first letters are
typed, as a select field does. The index forms, select_field and
radio, are unchanged; menu items can be disabled the
same way, with MenuItem::disabled(reason).
Collapsible sections¶
let open = signal(false);
collapsible(
"Advanced",
open,
column([checkbox("Send usage data", telemetry), slider(autosave, 0.0..=120.0, 10.0)]),
)
collapsible(title, open, child) is a header over child, shown while
open holds. Enter, Space or a click on the header opens or closes it, →
opens it and ← closes it. Closed, the child takes no room and is out of
the Tab order. The section is as tall as what it shows. The title is in
collapsible.title (else bold); in text mode the marker is [+] or
[-]. The header is read as Advanced, button, collapsed (or
expanded).
Titles that follow state, a status and a summary¶
New in 0.0.24: a tool call's card, as an agent's front end draws one.
use intuituive::{collapsible_with, Status};
collapsible_with(move || format!("[b]bash[/] {}", command.get()), open, output)
.status(move || match run.get() {
Run::Going => Status::Spinner("dots"),
Run::Ok => Status::Markup("[green]✓[/]".into()),
Run::Failed(code) => Status::Markup(format!("[red]✗ {code}[/]")),
})
.summary(move || last_line.get())
.symbols("▶", "▼")
collapsible_with(title, open, child)takes the title as a closure, read again whenever a signal it reads changes. Only the header draws again..status(|| Status)draws at the right of the header:Status::Spinner(name), one of rich's spinners turning on the app's clock (the header is busy for a screen reader meanwhile), orStatus::Markup(markup), which is said after the title (bash ls, ✓, button, collapsed). Incollapsible.status(else unstyled)..summary(|| line)is one line under the header while it is closed: plain text, its first line, cut off at the width, incollapsible.summary(elsedim). Opening the section shows the child in its place..symbols(closed, open)draws its own markers over▸and▾(Textual'scollapsed_symbolandexpanded_symbol). Text mode keeps[+]and[-].
Spinners and loading¶
let saving = signal(false);
row([
spinner("dots").spinning_when(move || saving.get()).fixed(2),
text!("{status}"),
])
.live()
spinner(name) is one of rich's spinners (dots, line, arc,
bouncingBar, … all of rich::spinner::spinner_names(); dots for a
name it does not know), turning at its own speed on the app's clock, in
the theme's spinner style (else accent). It asks for a frame only when
its next glyph is due, and none while it is stopped:
.spinning_when(condition) spins it while the condition holds and leaves
it blank otherwise. Its glyph is decoration, hidden from screen readers:
put what is happening in text beside it, as above. In text mode it is a
still *.
.loading_when(condition) shows any node as loading while the condition
holds: dimmed (the theme's loading style, else dim) under a spinner in
its middle, and busy, so a screen reader hears
Settings, region, busy. The node still takes keys and clicks: give it
.disabled_when(..) with the same condition to stop them.
The theme styles¶
| Style | Fallback | What it styles |
|---|---|---|
button, button.primary, button.danger |
named by each theme | a button's face |
<face>.focus, then button.focus |
bold reverse |
over the face, picked or held |
<face>.disabled, then button.disabled |
dim |
over the face, disabled |
<face>.light, <face>.shade (and .focus.light, …) |
none | a tall button's edges |
checkbox.on, radio.on |
accent |
the tick, the dot |
switch.on, switch.off |
good, muted |
a switch's track |
slider.filled, slider.track, slider.knob |
accent, muted, slider.filled |
a slider |
select |
none | a select field |
collapsible.title |
bold |
a section's header |
collapsible.status |
none | a section's status (.status) |
collapsible.summary |
dim |
a closed section's summary |
spinner |
accent |
a spinner's glyph |
loading |
dim |
a node shown as loading |
control.focus |
reverse |
a focused control's text, a slider's knob |
control.disabled |
dim |
a disabled control |
A theme file names them as it names any style
(button.primary = bold white on purple), and .theme_styles(..) sets
them for one node and what is inside it.