Guides
Forms
The nine form widgets as one workflow — focus, layout, validation — and why there is no Form container.
Forms
The nine form widgets: TextInput,
TextArea, Select,
Checkbox, Radio,
Toggle, Tabs,
Button, KeyHint.
There is no Form container, on purpose
A Form type was deliberately not built. ADR 0004’s
constraint solver plus the layout package already cover composition, and a
Form would have been a second way to do the same thing.
That is the whole reason, and it has a consequence you should know before you start: a form is a layout and a focus order, both of which you own. It is about thirty lines of your code, and every form you build will differ anyway.
What a Form container would have added that you would then have to configure
around: its own layout policy, its own focus policy, and a Validate() hook
whose signature would not fit your data. If you want all three, write them for
your application; they are three lines of code each and they will be right.
What the package shares
From widgets/form’s own godoc, four rules, each a consequence of something
decided elsewhere:
- A widget’s space is
Bounds(), neverbuf.Size(). - Every widget repaints its entire
Bounds()before drawing content, because the renderer diffs and never clears. Drawis total for every rect, including 0×0 and 1×1 and anything belowMinSize(), and it clips rather than blanking.- Nothing size-derived is built inside
Draw.Wrap,Truncateand span construction allocate, so each widget caches against the rect it was computed for and rebuilds when the rect, the text or the styles change.
Plus three package-wide rules:
EventPasteis ONE undoable operation. A 10,000-character paste is one step onTextInput’s undo stack, so one Ctrl-Z removes the whole of it.Drawallocates nothing in steady state. The visible runs are rebuilt only when the text, the caret, the styles or the rect change.- Nothing consumes
KeyTab. Every widget lets Tab through, so a form can move focus out of any of them with the keyboard.
That last one is the reason a form composes. If a widget consumed Tab while
focused, the user would be trapped.
The workflow
1. Compose, with a Block for the chrome
Solve against Interior() so the fields never know the border exists — see
Composing with Block.
cmd/capture through
widgettest.Capture: colour at 3 widths
(40, 80, 120 columns), and the exact plain-text cell grid at
the widest of them.
MinSize() returns 1 × 1 cells.
That is the whole widget including its own chrome, and it is the
widget's assertion about itself — below it, Draw clips rather
than blanks, and what to do about that is the application's decision.
40 columns × 3 rows
github.com/serkanalgur/termmosaic
80 columns × 3 rows
github.com/serkanalgur/termmosaic
120 columns × 3 rows
github.com/serkanalgur/termmosaic
Plain text — the exact cell grid, trimmed per line. This is the honest form: Braille and block-element glyphs can take a different advance width in a web font than a terminal gives them, which breaks the alignment they depend on.
# TextInput — A single-line editable field with a cursor, a selection and undo. # package: widgets/form # constructor: form.NewTextInput(r buffer.Rect) *form.TextInput # 120 columns x 3 rows, rendered through widgettest.Capture github.com/serkanalgur/termmosaic
What you are looking at. This is the cell grid the renderer produced, in a web page. It is not a screenshot of a terminal, and it cannot show interaction, resize or animation — a capture is one settled frame after N frames of settling. Braille and Block Elements can take a different advance width in your browser's font than in a terminal, so if a gauge or a sparkline above looks wrong, the plain-text form below it is the exact output and the colour one is the decoration. The colour frames are at 40, 80, 120 columns; the plain-text frame is at 120. More on this.
2. Own the focus order
A slice of Focusable and an index. Both are cheap, and making “Tab moves here”
an explicit statement beats a tree walk.
type screen struct {
name *form.TextInput
agree *form.Checkbox
save *button.Button
focusable []termmosaic.Focusable
focus int
}
func (s *screen) setFocus(i int) {
s.focusable[s.focus].SetFocused(false)
s.focus = i
s.focusable[s.focus].SetFocused(true)
s.Invalidate()
}
Focusable is optional and discoverable by type assertion, and implementations
invalidate themselves — you do not have to.
3. Handle what the widgets did not consume
Keys reach the focused widget first, so application-level keys are exactly what is left:
func (s *screen) Handle(ev termmosaic.Event) bool {
if ev.Kind != termmosaic.EventKey {
return false
}
switch {
case ev.Key == termmosaic.KeyTab:
s.setFocus((s.focus + 1) % len(s.focusable))
return true
case ev.Key == termmosaic.KeyEscape, ev.Rune == 'q' && ev.Mod == 0:
return false // the loop quits
}
return false
}
4. Validate by polling
There is no change event. A widget mutates its own state and invalidates itself; it does not notify anyone. So a form that enables a button when its inputs become valid reads the state after the fact:
// In Handle, after the switch: a form with four widgets can afford to poll.
s.save.Disabled = s.name.Text() == "" || s.agree.State() != form.Checked
if s.save.Disabled != wasDisabled {
s.Invalidate()
}
That is not a workaround. On a form this size, polling is free, and a callback per keystroke on every widget would be a design commitment the project has deliberately not made.
5. Put the keys where the user is looking
KeyHint is the discoverability half of the catalog, and it is a widget rather
than a string helper because a hint is the one piece of a form that changes
whenever the state changes — a Select with no options has nothing to activate,
so its hint goes away.
cmd/capture through
widgettest.Capture: colour at 3 widths
(40, 80, 120 columns), and the exact plain-text cell grid at
the widest of them.
MinSize() returns 1 × 1 cells.
That is the whole widget including its own chrome, and it is the
widget's assertion about itself — below it, Draw clips rather
than blanks, and what to do about that is the application's decision.
40 columns × 3 rows
[tab] next pane [enter] activate [q] …
80 columns × 3 rows
[tab] next pane [enter] activate [q] quit [?] all keys
120 columns × 3 rows
[tab] next pane [enter] activate [q] quit [?] all keys
Plain text — the exact cell grid, trimmed per line. This is the honest form: Braille and block-element glyphs can take a different advance width in a web font than a terminal gives them, which breaks the alignment they depend on.
# KeyHint — A one-line bar of key names and their meanings. # package: widgets/form # constructor: form.NewKeyHint(r buffer.Rect, bindings []form.Binding) *form.KeyHint # 120 columns x 3 rows, rendered through widgettest.Capture [tab] next pane [enter] activate [q] quit [?] all keys
What you are looking at. This is the cell grid the renderer produced, in a web page. It is not a screenshot of a terminal, and it cannot show interaction, resize or animation — a capture is one settled frame after N frames of settling. Braille and Block Elements can take a different advance width in your browser's font than in a terminal, so if a gauge or a sparkline above looks wrong, the plain-text form below it is the exact output and the colour one is the decoration. The colour frames are at 40, 80, 120 columns; the plain-text frame is at 120. More on this.
Choosing between the one-of-N widgets
Three widgets mean “one of these”. They are not interchangeable:
| Shows options | Best for | |
|---|---|---|
Select |
below the field, scrollable | more options than fit, or a compact one-row control |
Radio |
all at once, one row each | few options, and seeing them all matters |
Tabs |
as a strip above the pane | switching between content, not choosing a value |
Tabs is navigation, not a control that produces data. If the user is choosing
one of N values, Tabs is the wrong widget even though the interaction looks
similar.
And Checkbox versus Toggle is a semantic difference, not a visual one:
A toggle means “this is running right now” and a checkbox means “include this in the operation”, and users read the two differently.
cmd/capture through
widgettest.Capture: colour at 3 widths
(40, 80, 120 columns), and the exact plain-text cell grid at
the widest of them.
MinSize() returns 1 × 5 cells.
That is the whole widget including its own chrome, and it is the
widget's assertion about itself — below it, Draw clips rather
than blanks, and what to do about that is the application's decision.
40 columns × 5 rows
[x] widgets (22) [-] visualisation (5) [ ] release (0)
80 columns × 5 rows
[x] widgets (22) [-] visualisation (5) [ ] release (0)
120 columns × 5 rows
[x] widgets (22) [-] visualisation (5) [ ] release (0)
Plain text — the exact cell grid, trimmed per line. This is the honest form: Braille and block-element glyphs can take a different advance width in a web font than a terminal gives them, which breaks the alignment they depend on.
# Checkbox — A tri-state box: unchecked, checked, or indeterminate. # package: widgets/form # constructor: form.NewCheckbox(r buffer.Rect, label string) *form.Checkbox # 120 columns x 5 rows, rendered through widgettest.Capture [x] widgets (22) [-] visualisation (5) [ ] release (0)
What you are looking at. This is the cell grid the renderer produced, in a web page. It is not a screenshot of a terminal, and it cannot show interaction, resize or animation — a capture is one settled frame after N frames of settling. Braille and Block Elements can take a different advance width in your browser's font than in a terminal, so if a gauge or a sparkline above looks wrong, the plain-text form below it is the exact output and the colour one is the decoration. The colour frames are at 40, 80, 120 columns; the plain-text frame is at 120. More on this.
cmd/capture through
widgettest.Capture: colour at 3 widths
(40, 80, 120 columns), and the exact plain-text cell grid at
the widest of them.
MinSize() returns 6 × 1 cells.
That is the whole widget including its own chrome, and it is the
widget's assertion about itself — below it, Draw clips rather
than blanks, and what to do about that is the application's decision.
40 columns × 6 rows
( ) horizontal ( ) vertical > (o) both
80 columns × 6 rows
( ) horizontal ( ) vertical > (o) both
120 columns × 6 rows
( ) horizontal ( ) vertical > (o) both
Plain text — the exact cell grid, trimmed per line. This is the honest form: Braille and block-element glyphs can take a different advance width in a web font than a terminal gives them, which breaks the alignment they depend on.
# Radio — A single-choice group of labels, navigated with the arrow keys. # package: widgets/form # constructor: form.NewRadio(r buffer.Rect, labels []string) *form.Radio # 120 columns x 6 rows, rendered through widgettest.Capture ( ) horizontal ( ) vertical > (o) both
What you are looking at. This is the cell grid the renderer produced, in a web page. It is not a screenshot of a terminal, and it cannot show interaction, resize or animation — a capture is one settled frame after N frames of settling. Braille and Block Elements can take a different advance width in your browser's font than in a terminal, so if a gauge or a sparkline above looks wrong, the plain-text form below it is the exact output and the colour one is the decoration. The colour frames are at 40, 80, 120 columns; the plain-text frame is at 120. More on this.
cmd/capture through
widgettest.Capture: colour at 3 widths
(40, 80, 120 columns), and the exact plain-text cell grid at
the widest of them.
MinSize() returns 1 × 1 cells.
That is the whole widget including its own chrome, and it is the
widget's assertion about itself — below it, Draw clips rather
than blanks, and what to do about that is the application's decision.
40 columns × 9 rows
alpine bookworm cachyos debian > endeavouros fedora gentoo nixos void
80 columns × 9 rows
alpine bookworm cachyos debian > endeavouros fedora gentoo nixos void
120 columns × 9 rows
alpine bookworm cachyos debian > endeavouros fedora gentoo nixos void
Plain text — the exact cell grid, trimmed per line. This is the honest form: Braille and block-element glyphs can take a different advance width in a web font than a terminal gives them, which breaks the alignment they depend on.
# Select — A single-choice control that expands into a scrollable list. # package: widgets/form # constructor: form.NewSelect(r buffer.Rect, labels []string) *form.Select # 120 columns x 9 rows, rendered through widgettest.Capture alpine bookworm cachyos debian > endeavouros fedora gentoo nixos void
What you are looking at. This is the cell grid the renderer produced, in a web page. It is not a screenshot of a terminal, and it cannot show interaction, resize or animation — a capture is one settled frame after N frames of settling. Braille and Block Elements can take a different advance width in your browser's font than in a terminal, so if a gauge or a sparkline above looks wrong, the plain-text form below it is the exact output and the colour one is the decoration. The colour frames are at 40, 80, 120 columns; the plain-text frame is at 120. More on this.
cmd/capture through
widgettest.Capture: colour at 3 widths
(40, 80, 120 columns), and the exact plain-text cell grid at
the widest of them.
MinSize() returns 6 × 1 cells.
That is the whole widget including its own chrome, and it is the
widget's assertion about itself — below it, Draw clips rather
than blanks, and what to do about that is the application's decision.
40 columns × 3 rows
[on ] wrap text
80 columns × 3 rows
[on ] wrap text
120 columns × 3 rows
[on ] wrap text
Plain text — the exact cell grid, trimmed per line. This is the honest form: Braille and block-element glyphs can take a different advance width in a web font than a terminal gives them, which breaks the alignment they depend on.
# Toggle — A two-state switch with an on and an off rendering. # package: widgets/form # constructor: form.NewToggle(r buffer.Rect, label string) *form.Toggle # 120 columns x 3 rows, rendered through widgettest.Capture [on ] wrap text
What you are looking at. This is the cell grid the renderer produced, in a web page. It is not a screenshot of a terminal, and it cannot show interaction, resize or animation — a capture is one settled frame after N frames of settling. Braille and Block Elements can take a different advance width in your browser's font than in a terminal, so if a gauge or a sparkline above looks wrong, the plain-text form below it is the exact output and the colour one is the decoration. The colour frames are at 40, 80, 120 columns; the plain-text frame is at 120. More on this.
cmd/capture through
widgettest.Capture: colour at 3 widths
(40, 80, 120 columns), and the exact plain-text cell grid at
the widest of them.
MinSize() returns 1 × 1 cells.
That is the whole widget including its own chrome, and it is the
widget's assertion about itself — below it, Draw clips rather
than blanks, and what to do about that is the application's decision.
40 columns × 4 rows
Logs [Metrics] Traces Config
80 columns × 4 rows
Logs [Metrics] Traces Config
120 columns × 4 rows
Logs [Metrics] Traces Config
Plain text — the exact cell grid, trimmed per line. This is the honest form: Braille and block-element glyphs can take a different advance width in a web font than a terminal gives them, which breaks the alignment they depend on.
# Tabs — A horizontal strip of labels, one of which is the active pane. # package: widgets/form # constructor: form.NewTabs(r buffer.Rect, labels []string) *form.Tabs # 120 columns x 4 rows, rendered through widgettest.Capture Logs [Metrics] Traces Config
What you are looking at. This is the cell grid the renderer produced, in a web page. It is not a screenshot of a terminal, and it cannot show interaction, resize or animation — a capture is one settled frame after N frames of settling. Braille and Block Elements can take a different advance width in your browser's font than in a terminal, so if a gauge or a sparkline above looks wrong, the plain-text form below it is the exact output and the colour one is the decoration. The colour frames are at 40, 80, 120 columns; the plain-text frame is at 120. More on this.
cmd/capture through
widgettest.Capture: colour at 3 widths
(40, 80, 120 columns), and the exact plain-text cell grid at
the widest of them.
MinSize() returns 3 × 1 cells.
That is the whole widget including its own chrome, and it is the
widget's assertion about itself — below it, Draw clips rather
than blanks, and what to do about that is the application's decision.
40 columns × 3 rows
[Render] Save
80 columns × 3 rows
[Render] Save
120 columns × 3 rows
[Render] Save
Plain text — the exact cell grid, trimmed per line. This is the honest form: Braille and block-element glyphs can take a different advance width in a web font than a terminal gives them, which breaks the alignment they depend on.
# Button — A labelled, activatable control with a default and a disabled state. # package: widgets/form # constructor: form.NewButton(r buffer.Rect, label string) *form.Button # 120 columns x 3 rows, rendered through widgettest.Capture [Render] Save
What you are looking at. This is the cell grid the renderer produced, in a web page. It is not a screenshot of a terminal, and it cannot show interaction, resize or animation — a capture is one settled frame after N frames of settling. Braille and Block Elements can take a different advance width in your browser's font than in a terminal, so if a gauge or a sparkline above looks wrong, the plain-text form below it is the exact output and the colour one is the decoration. The colour frames are at 40, 80, 120 columns; the plain-text frame is at 120. More on this.
cmd/capture through
widgettest.Capture: colour at 3 widths
(40, 80, 120 columns), and the exact plain-text cell grid at
the widest of them.
MinSize() returns 1 × 1 cells.
That is the whole widget including its own chrome, and it is the
widget's assertion about itself — below it, Draw clips rather
than blanks, and what to do about that is the application's decision.
40 columns × 7 rows
# capture notes The gauge dial is Braille: eight levels of vertical resolution per cell. The meter track is Block Elements. If a web font mis-advances either,
80 columns × 7 rows
# capture notes The gauge dial is Braille: eight levels of vertical resolution per cell. The meter track is Block Elements. If a web font mis-advances either, the plain-text capture is the
120 columns × 7 rows
# capture notes The gauge dial is Braille: eight levels of vertical resolution per cell. The meter track is Block Elements. If a web font mis-advances either, the plain-text capture is the
Plain text — the exact cell grid, trimmed per line. This is the honest form: Braille and block-element glyphs can take a different advance width in a web font than a terminal gives them, which breaks the alignment they depend on.
# TextArea — A multi-line editable field with wrapping, selection and undo. # package: widgets/form # constructor: form.NewTextArea(r buffer.Rect) *form.TextArea # 120 columns x 7 rows, rendered through widgettest.Capture # capture notes The gauge dial is Braille: eight levels of vertical resolution per cell. The meter track is Block Elements. If a web font mis-advances either, the plain-text capture is the
What you are looking at. This is the cell grid the renderer produced, in a web page. It is not a screenshot of a terminal, and it cannot show interaction, resize or animation — a capture is one settled frame after N frames of settling. Braille and Block Elements can take a different advance width in your browser's font than in a terminal, so if a gauge or a sparkline above looks wrong, the plain-text form below it is the exact output and the colour one is the decoration. The colour frames are at 40, 80, 120 columns; the plain-text frame is at 120. More on this.
Two-state and one-of-N are not the same as a button
A Button presses. It does not hold a state, and it emits no event — Handle
consumes the key and returns. If pressing it should switch something on and
pressing it again should switch it off, that is a Toggle,
because otherwise the screen cannot show the current state, which is the one
thing a control is for.
And a disabled Button consumes nothing — not a click, not a key. That is
deliberate, so a form can disable an action without removing the widget from the
tree. The cost is that the user gets no explanation: if they need to know why
an action is unavailable, leave it enabled and say so.
What the form set cannot do
Stated here rather than discovered, because each of these is a real gap and several are easy to assume otherwise:
TextAreahas no rendered selection. It tracks and moves a caret and supports editing, but the selected range is not drawn.TextInputrenders its selection;TextAreadoes not. If a user needs to see part of a value they are editing,TextAreais the wrong widget.- No redo stack, in either text field. There is undo.
- No IME or composition. Composing CJK in a
TextInputproduces wrong behaviour, not degraded behaviour — the committed text arrives as a burst of key events, so it inserts correctly but the undo stack gains one entry per character. See Input. - No validation. Nothing checks a field against a rule; that is your code.
- No multi-select.
Checkboxis one box. - Mouse capture is off by default, so clicking a field does nothing unless you enable it — and enabling it takes text selection and scrollback away from the user’s shell.
Accessibility, briefly
Every distinction in the form set is a shape or an attribute, so all of it
survives NO_COLOR and a monochrome terminal:
- Focus on a
Buttonis[Save]ringed, not a colour. - Selection in
TextInputandSelectdefaults to reverse video. Checkboxhas three marks, one per state.
And a form label is your job. A terminal grid has no accessibility tree, so
Placeholder is not a label — it disappears on the first keystroke, which is
exactly when a user filling in the form most needs to know what the field was
for. See Accessibility.
Reading next
TextInput— the reference implementation of the whole form set, because ADR 0005 §4 was written about it.- Input — the event model behind the key contracts.
- Composing with Block — the layout.
- Your first app — this page as one file.