ESC
Type to search...
S
Soli Docs

The widget catalogue

There is no widget primitive. The protocol can express sixteen node kinds and nothing else — a button is a box with a click handler and a text child. Everything below is composed from those kinds by ordinary Soli functions that return hashes, which is why adding a widget is a library change and never a new client.

Where these live. The catalogue is not yet shipped as a Soli library: it is a single file, app/controllers/eui_builders.sl, in the counter-app example of the eui repository. Copy it into your application and edit it — that is the intended use, and it is why a widget you disagree with is yours to change.

Every preview below is an approximation drawn in HTML, because the real thing is painted on the GPU by a native client and cannot render in a browser. Treat them as a sketch of the shape, not of the pixels.

Layout and containers

Every container takes a style hash and a list of children. column, row and stack are the same box primitive with a different display.

column(style, children)

A box that stacks children vertically. The workhorse.

column({"gap": 3, "pad": 4}, [
  text("First", {}),
  text("Second", {})
])
FirstSecond
row(style, children)

Horizontal flow. Pair with spacer() to push the tail to the right.

row({"gap": 2, "align": "center"}, [
  text("Label", {}),
  spacer(),
  button("Go", "go")
])
LabelGo
stack(style, children)

Children painted on top of one another, in order. The base for badges on avatars and overlay content.

stack({}, [
  avatar(src, 40),
  badge("3", "danger")
])
3
card(style, children)

A raised surface: padding, radius and a border from the theme roles.

card({}, [
  h2("Billing"),
  muted("Next invoice on the 1st")
])
Billing
Next invoice on the 1st
scroll(style, children)

A clipping viewport. Children lay out fully; only the visible part is painted.

scroll({"height": 200}, rows)
Row one
Row two
Row three
Row four
list(style, item_height, children)

A scroll that virtualises: with a fixed item_height only the visible window is laid out. Use it past a few hundred rows.

list({"height": 400}, 32, rows)
Item 1
Item 2
Item 3
list_window(style, item_height, count, heights, children, on_window)

Virtualisation where the server holds the data: the client reports the visible range through on_window and you send only those rows. heights allows variable row heights.

list_window({"height": 400}, 32, total, heights,
            visible_rows, "window")
no visual — behaviour only
tile(basis, node)

Wraps a node with a flex basis, for grid-like rows that wrap.

row({"gap": 3, "wrap": true}, cards.map(fn(c) tile(240, c)))
no visual — behaviour only
labelled(title, child)

A caption above any node. The building block behind field.

labelled("Region", select(regions, value, open, "t", "pick"))
Region
eu-west-1
spacer()

Flexible empty space. Inert: no text, props or handlers.

row({}, [text("Left", {}), spacer(), text("Right", {})])
LeftRight
divider()

A hairline rule. Inert.

column({"gap": 3}, [h2("Members"), divider(), rows])
Members
keyed(key, node)

Gives a node a stable identity so the diff moves it instead of rebuilding it. Required on any list whose rows reorder.

items.map(fn(i) keyed(i["id"], row_for(i)))
no visual — behaviour only
pane_px(name)

No description in the source.

px >= pane_px(name)

Real use — eui_builders.sl (inside `pane_min`):1128

pane_bp(px)

No description in the source.

badge(pane_bp(px), tone)

Real use — app/controllers/live_controller.sl:864

pane_min(px, name)

No description in the source.

roomy = pane_min(px, "md")

Real use — app/controllers/live_controller.sl:860

split_span(extent, bar)

The room the two panels share, once the divider has taken its own.

span = split_span(extent, bar)

Real use — eui_builders.sl (inside `split_sizes`):1144

split_sizes(extent, fraction, min_a, min_b, bar)

`fraction` is per mille — an integer, so it survives a round trip through state and a local handler's props without ever being a float. Both conversions round rather than truncate, which is what makes the trip exact: a divider dropped at a pixel and rebuilt from its fraction lands on the same pixel,

sizes = split_sizes(extent, fraction, min_a, min_b, bar)

Real use — eui_builders.sl (inside `split_pane`):1244

split_at(extent, at, min_a, min_b, bar)

The pointer's position along the axis becomes the fraction the divider sits at. Clamped to both minimums, so a drag that runs past a panel's floor stops there rather than inverting the pair — and the clamp lives here, once, instead of in every application that draws a split.

state[name] = split_at(extent, at, min_a, min_b, bar)

Real use — eui_builders.sl (inside `split_drag`):1288

split_panel(build, px, across: Bool, cross)

No description in the source.

split_panel(o["a"], sizes[0], across, cross),

Real use — eui_builders.sl (inside `split_pane`):1258

split_divider(key, across: Bool, bar, cross, fraction, on_drag, dragging: Bool, label)

The divider is not a `control`: it is a separator, its press has to reach the server as well as restyle locally, and `control` would put its own `pointer_down` over the top of that. It is keyed so the local chunk can name it, and it holds `key_down`, which is what puts it in the Tab order — so a spl

split_divider(key + ":bar", across, bar, cross, fraction, on_drag, o["dragging"] == true, o["label"]),

Real use — eui_builders.sl (inside `split_pane`):1259

split_pane(o)

key required; the divider is keyed from it dir "row" | "column" size the container's extent along the split axis, in px cross the extent across it; "100%" if absent fraction per mille, 0..1000 min_a / min_b the smallest each panel may become, in px bar the divider's thi

split_pane({

Real use — app/controllers/live_controller.sl:825

split_drag(state, params, name, dir, extent, min_a, min_b, bar)

The four events a split sends, folded into a component's state. `name` is the state key holding the fraction; `name + "_drag"` holds whether a drag is in flight. An application writes one line in its handler and is done. One `pointer_move` while the divider is held: the point along the axis is where

state = split_drag(state, params, name, dir, extent, min_a, min_b, bar)

Real use — eui_builders.sl (inside `split_event`):1319

split_keys(state, params, name, dir, step)

The keyboard's half of the same divider: an arrow moves it by a step, Home puts it back in the middle, and the result is held inside the thousandths the fraction is measured in.

state = split_keys(state, params, name, dir, step)

Real use — eui_builders.sl (inside `split_event`):1321

split_event(state, params, name, dir, extent, min_a, min_b, bar)

No description in the source.

"split_x" => split_event(state, params, "split_x", "row", gallery_split_extent(state), 220, 260, 6),

Real use — app/controllers/live_controller.sl:2500

table_row(key, values, widths)

No description in the source.

table_row(i, [

Real use — app/controllers/live_controller.sl:274

Overlays and structure

Anything that floats uses the overlay primitive, which paints after its siblings. Open and closed is your state — the client holds none of it.

dialog(title, body_children, actions)

A modal over a scrim. actions is a list of buttons.

dialog("Delete project?",
       [muted("This cannot be undone.")],
       [secondary_button("Cancel", "close"),
        danger_button("Delete", "destroy")])
Delete project?
This cannot be undone.
CancelDelete
sheet(side, children)

A panel anchored to an edge — "left", "right", "bottom".

sheet("right", [h2("Filters"), filter_controls])
Filters
drawer(children)

A left sheet, the navigation case, at one argument.

drawer(sidebar(links, active, "go"))
no visual — behaviour only
popover(anchor, content, open)

A panel positioned against a node rather than the viewport.

popover(button("Share", "toggle"), share_panel, s["open"])
no visual — behaviour only
menu(items, on_pick)

A list of choices in an overlay. Each item carries its own id back.

menu([{"id": "dup", "label": "Duplicate"},
      {"id": "del", "label": "Delete"}], "pick")
Duplicate
Delete
tabs(names, active, on_select)

A row of labels with the active one underlined.

tabs(["Overview", "Usage", "Billing"], s["tab"], "select_tab")
OverviewUsageBilling
accordion(sections, open_id, on_toggle)

Sections that expand one at a time.

accordion([{"id": "a", "title": "General", "body": general}],
          s["open"], "toggle_section")
General
Name, region, tags
Advanced
stepper(steps, current)

Progress through a sequence. Display only.

stepper(["Account", "Plan", "Payment"], 1)
23
toolbar(children)

A dense row on a raised surface, for actions above a table or canvas.

toolbar([icon_button("+", "add", {}), icon_button("⟳", "refresh", {}),
         spacer(), select_sized(views, v, o, "t", "p", 120, false)])
All ▾

Navigation

Navigation is application state, not a URL: a click is an event and your handler decides what the next tree is.

navbar(brand, links, active, on_go)

A top bar with a brand and a link row.

navbar("Acme", ["Home", "Docs", "Pricing"], s["page"], "go")
AcmeHomeDocsPricing
sidebar(links, active, on_go)

A vertical nav column with the active entry highlighted.

sidebar(["Inbox", "Sent", "Drafts"], s["box"], "go")
Inbox
Sent
Drafts
breadcrumb(crumbs, on_go)

A trail. The last entry is the current place and is not a link.

breadcrumb([{"id": "root", "label": "Projects"},
            {"id": "p1", "label": "Acme"}], "go")
Projects/Acme
pagination(page, pages, on_page)

Previous, page numbers, next. Emits the page it wants.

pagination(s["page"], total_pages, "go_page")
123
segmented(options, selected, on_select)

Mutually exclusive options in one control. Fewer than five, or use select.

segmented(["Day", "Week", "Month"], s["range"], "set_range")
DayWeekMonth
tree_view(nodes, open_ids, on_toggle, depth)

A nested, collapsible tree. Recursive: it calls itself at depth + 1.

tree_view(files, s["open"], "toggle_node", 0)
▾ src
▾ serve
mod.rs
main.rs

Theme and responsiveness

The client resolves roles against light or dark mode, density and font scale. The server never learns which.

theme_toggle()

Flips the client's mode locally — theme.toggle() in a local handler, so no round trip and no server state.

row({}, [spacer(), theme_toggle()])
bp(width) · bp_min(width, name) · bp_px(name)

Breakpoint helpers. The viewport reaches the handler, so the tree you build is the responsive answer — there is no media query.

cols = bp(params["viewport"]["w"]) == "sm" ? 1 : 3
no visual — behaviour only
with_state(state, root)

Attaches the state a local handler may read and write, at the root of the tree.

with_state({"count": s["count"]}, column({}, children))
no visual — behaviour only