Controls

How a value becomes an interactive input, and which control it renders as. The decision is made by the value’s type — the methods it carries — never by a directive. A directive (see directives) only refines an input that already exists.

What makes a value an input

A cell is an input (a “leaf”) when it is a parameterless cell whose result the rest of the graph consumes, and whose type is editable. Editability is decided by capability — the methods the type implements — probed the same way the analyzer probes everything else. A plain scalar with no special methods is the simplest input: a number or a string.

//notebook:slider min=-40 max=120 step=1
func celsius() (c int) { return 20 }     // a scalar input → slider / number box

You do not register an input or flag it. The type says whether it is one; the graph position says whether it is shown.

The control types

Each control comes from a method the value’s type carries. The analyzer classifies by capability, and the client renders the matching widget:

Type carries… Control Meaning
(a bare number / string) slider or text box a scalar input; //notebook:slider refines its range
underlying bool checkbox a toggle
Options() + a scalar field select one choice from a list
Options() + a slice field multi many choices from a list
Bounds() range a from/to span (a two-handled slider)
a slice Value field + Grip() draggable direct-manipulation points you drag
a slice Value field of structs table an editable grid (columns from the struct’s fields)

The order matters: Options() wins over Bounds(), which wins over Grip(), which wins over a plain slice. This is why a value that is both choosable and bounded renders as a choice.

The capability methods, exactly

There is no interface to import — the analyzer matches these method shapes structurally, so the signatures below are the contract. Declare them on the value receiver, and match the return types exactly:

Options() []string        // select / multi — the choices
Bounds() (lo, hi float64) // range — float64 even for an integer range
Grip(i int) Ref           // draggable — the leaf-index handle for the i-th point
Reconcile(saved any) any  // how a saved value re-enters after a rebuild

The runtime reads the value back through a real Go interface (Bounds() (lo, hi float64) etc.), so the return types are the contract. check enforces this: a near-miss like Bounds() (lo, hi int) — named right, shaped wrong — is reported as an error naming the actual and required signatures (cell "rate" declares Bounds() (int, int), but a control requires Bounds() (float64, float64)), so you learn it before you run, not from a silently missing control. Match the signatures above exactly. (Bounds, Options, and Reconcile are signature-checked; Grip’s Ref return is a type the notebook defines, with no fixed engine contract, so only its presence is used — see the draggable recipe.)

Cookbook — need this UI, write this shape

Each row links to a complete, buildable notebook in examples/minimal/ — copy one and change the domain.

Need this UI Write this shape Complete example
Number / text input a parameterless cell returning a bare scalar: func x() (n int) slider, textinput
Bounded slider add //notebook:slider min=… max=… step=… slider
Checkbox return a bool scalar checkbox
Select (one choice) a type with Options() []string + a scalar Value field selectbox
Multi-select Options() []string + a slice Value field multiselect
Range (from/to) a type with Bounds() (lo, hi float64) rangecontrol
Draggable points a slice Value + a Grip(i) method, drawn as Handle grips in a Render draggable
Editable table a type with a slice-of-struct Value field table

A few specifics the table can’t hold:

Bounds — a ranged slider

A type with a Bounds() method renders as a range control on its own — no directive needed.

type Rate struct{ V, Lo, Hi int }
func (r Rate) Bounds() (lo, hi float64) { return float64(r.Lo), float64(r.Hi) }

The method returns float64 even for an integer range — that is the exact shape the runtime probes for (Bounds() (lo, hi float64)); a Bounds() (lo, hi int) would be detected as a range by name but never deliver its bounds. See the rangecontrol recipe.

Options — select and multi

A type with Options() becomes a chooser. If its editable Value is a single value, it is a select (one choice); if a slice, a multi (several).

type Pick struct{ Value string; All []string }
func (p Pick) Options() []string { return p.All }

Reconcile — stateful widgets

A widget that carries state across edits (the current selection, the dragged positions, the table rows) implements Reconcile(saved any) any: given the value that arrived over the wire, it returns the reconciled widget. This is how a Multi, a Range, a Table, or a draggable keeps its state as the notebook recomputes. Reconcile is not what makes something an input (Bounds/Options do that) — it is how a stateful input survives a wave.

type Series struct{ CSV string }
func (s Series) Reconcile(saved any) any {
	if str, ok := saved.(string); ok {
		return Series{CSV: str}
	}
	return s
}

The degradation ladder

Every richer control degrades to a simpler one without losing correctness. A draggable is still a set of numbers you can type; a select is still a value. Strip the widget methods and you get a plain scalar input — the value still flows through the graph. Losing the control costs interaction polish, never the computation. The same ladder governs rendering on the output side.