components.md raw

The component language

A .gdc file declares one component: its typed interface (props, state, outputs, handlers) and its widget tree, as s-expressions. Comments run from ; to end of line.

(component CounterButton
  (prop label String)
  (prop step i32 :default 1)
  (output stepped i32)
  (state count i32 :init 0)
  (handler increment)

  (container :direction row :gap 8 :align-items center
    (container :width 130 :height 32 :corner-radius 6
               :background "#4682b4" :on-click increment
      (text :color "#ffffff" :text "{label} +{step}"))
    (text :text "= {count}")))

One component per file; the file bears the component’s name verbatim (CounterButton lives in CounterButton.gdc) and is found through the component search path.

Declarations

FormMeaning
(prop name Type)An input the instantiator must supply
(prop name Type :default lit)An input with a fallback
(state name Type :init lit)Internal reactive state
(output name Type)A typed event the component emits
(handler name)A logic method, implemented in Rust
(handler name Type)A logic method receiving a payload

The type vocabulary is i32 i64 f32 f64 bool String, plus (List T)(state todos (List String) :init (list "a" "b")) becomes a Signal<Vec<String>>. List literals are written (list …).

Records model structured rows:

(record Todo :id i64 :label String :done bool)
(state todos (List Todo) :init (list (Todo :id 1 :label "hi" :done false)))

A record declaration generates a Rust struct of the same name (pub id: i64, …). Literals mirror instance syntax and must supply every field. Fields are read with dots — todo.label in any expression, {todo.label} in interpolations, :key todo.id on a for. v1 scope, stated plainly: records are file-local and flat (scalar fields only), they live in (List …) state and for loops, and they do not cross component boundaries — props, outputs, and handler payloads stay scalar (pass a field, e.g. todo.id). Handler payloads may now number more than one: (handler moved i64 i64). Prop defaults and state inits must be literals. All declared names share one namespace — and record names may not collide with the component or each other.

Prop names may not use the spellings the instance syntax owns: class, key, anything starting with on-, or the item-layout names (width height grow shrink basis align-self).

Because an :init is a literal, state whose value comes from outside the component language — a file, a database — is not declared here at all. It is seeded from the logic’s mounted method, which the framework calls once the component is on screen; see rust-api.md. mounted is therefore not available as a handler name.

The widget tree

Exactly one root node. A node is (widget :property value … children…).

When a component is mounted as the tree’s root, its root node fills the window whatever size it declares: the root is the window, so there is no surrounding box for another size to be a share of. A component mounted as an instance is an ordinary flex item, and sizes itself as one — so :width on a root node is not an error, it simply has no one to answer to until the component is nested. Lowercase names are widgets (container, text, text-input, scroll-area, button, checkbox — see Widgets — plus any your app defines in Rust and declares in a manifest); a capitalized name instantiates another component (below).

Every node accepts:

  • Layout properties (static): :width, :height (a number in px, "N%", or auto), :padding, :gap (px), :direction (row/column), :align-items, :justify-content, :align-self (start/end/center/stretch; flex-start/flex-end are accepted synonyms), :grow, :shrink (numbers), :basis (a dimension). Flexbox semantics via taffy.

  • :class "a b" — space-separated style classes for theme rule matching.

  • :enabled expr — a boolean expression driving the widget’s enabled flag. A disabled widget (and its whole subtree) is inert: it still occludes what is beneath it, but receives no pointer events, takes no :hover/:active state, and cannot be focused; its enabled container reacts as if the pointer were on its own padding. Themes can target the state with :disabled rules.

  • :focused expr — a boolean expression driving whether the widget holds keyboard focus. See Focus.

  • Event wires: :on-click, :on-counted-click, :on-pointer-enter, :on-pointer-leave, :on-pointer-down, :on-pointer-up, :on-file-drop, :on-focus-change, and :on-key on any widget, plus widget-specific events like text-input’s :on-change. The value is a declared handler name — or an invocation passing arguments evaluated at dispatch time in the node’s scope, loop variables included: :on-click (remove todo.id). Argument types must match the handler’s declared payloads. On wires that deliver a value — :on-change, :on-link, :on-file-drop, and instance output wires — the reserved name payload refers to it inside the arguments, so an editable row can report both what changed and where:

    (for item items :key item.id
      (text-input :text item.label
                  :on-change (rename item.id payload)))
    

    with (handler rename i64 String). A bare name on a payload wire remains shorthand for passing the payload as the only argument.

  • Widget properties (:text, :background, …) — per-widget; see Widgets. These are where expressions go.

Expressions and bindings

A widget property’s value is an expression. If it is a literal, it is applied once at construction. Anything that reads a prop or state becomes a binding: it re-evaluates when any signal it read changes, and updates exactly that widget property.

  • Literals: 1, 2.5, true, "text", colors as strings ("#rgb", "#rrggbb", "#rrggbbaa").
  • Names: any declared prop or state, read reactively.
  • String interpolation: "{label}: {count}" — the one embedded syntax.
  • Operators, prefix form: (+ - * / %), comparisons (< <= > >= = !=), boolean (and or not) (short-circuiting), and (if cond then else) as an expression.
:background (if pressed "#483d8b" (if hovered "#6495ed" "#4682b4"))
:text "{label}: {count} of {step}"

Current restrictions, enforced with pointed diagnostics: layout properties take literals only (reactive layout is planned alongside the style engine’s growth); color-valued expressions are built from color literals and if (color-typed props are a planned extension); dotted paths are reserved.

Events and payloads

Plain events wire to plain handlers. Payload-carrying events require the handler to declare the matching payload type, and the compiler checks the contract in both directions:

PayloadEvents
String:on-change, :on-link, :on-file-drop
bool:on-toggle, :on-focus-change
f64:on-value-change
none:on-select, :on-close, :on-key, the pointer family

The name says the payload, which is why a slider reports :on-value-change rather than :on-change: both a slider and a text field “change”, and one wire name meaning two payload types is how a checked contract stops being one.

(handler edited String)
(text-input :text current :on-change edited)
fn edited(&mut self, cx: FormCx, value: &str) { … }

The payload also composes with dispatch-time arguments through the payload keyword, so a row can report both its identity and the new value in one wire:

(handler set-flag i64 bool)
(checkbox :label opt.label :checked opt.on
          :on-toggle (set-flag opt.id payload))

Dynamic structure: (if …) and (for …)

Two structural forms make the tree data-driven:

(if (> count 0)
  (text :text "{count} items")
  (text :class "empty" :text "nothing yet"))   ; else-node optional

(for todo todos :key todo :index i
  (container :class "row"
    (text :text "{i}: {todo}")))

if takes a boolean expression and one or two nodes; when the condition flips, the old branch unmounts (its state with it) and the other mounts in the same place among its siblings.

for iterates a (List T) prop or state by name, instantiating its single body node per item. The loop variable reads like any signal; :index name optionally binds the row’s position. Rows reconcile by key — the :key expression evaluates over each item (defaults to the position): rows whose key survives a change keep their widgets and state (the loop variable updates in place), new keys mount, vanished keys unmount, and reordered keys move. Give lists a stable :key whenever items can move; positional keying rebuilds rows on reorder.

Restrictions, checked at compile time: if/for cannot be the component root; :autofocus, (slot), and instance :key cannot live inside them (their cardinality is unknowable); the for body is exactly one node — wrap siblings in a container.

Structural forms nest: an (if …) can be a (for …)’s body and vice versa, so a list of rows that each swap between two shapes needs no wrapper box to hold it together. :autofocus still cannot live inside them; :focused is the property that can, and it is how a row-scoped editor gets the caret.

Focus: :autofocus and :focused

The framework maintains a focus invariant: if any focusable widget exists, one of them has focus. Two properties let a file say which.

:autofocus true picks the widget that takes focus at mount. At most one widget in the composed tree (your file plus everything it instantiates) may carry it, and it cannot live inside if or for — it is an unconditional declaration, so a form that might produce none of it or ten of it has no answer.

:focused expr drives focus from application state, and works anywhere — including inside if and for, which is what makes a row-scoped editor possible:

(for row rows :key row.id
  (container
    (if (= row.id editing)
      (text-input :text row.text :focused true
                  :on-key (maybe-commit event.key)
                  :on-focus-change (focus-changed row.id payload))
      (text :text row.text
            :on-counted-click (open row.id event.click-count)))))

It is controlled, like :checked and :open: your state owns it, and the widget never flips it. Setting it true takes focus; setting it false gives focus up only if this widget currently holds it. That asymmetry is what makes it order-independent — when focus moves between two widgets driven by one piece of state, one binding falls as another rises, and either order lands in the same place. Exclusivity is a fact about your data, exactly as it is for a radio group’s :checked.

:on-focus-change is the other half. It delivers a bool — the new state — and fires for every transition, including the ones your state did not cause: a click elsewhere, Tab, a widget being disabled. Answering it is how the state that drives :focused stays true instead of drifting out of step with the screen. It is delivered to the widget itself and does not bubble: “am I focused” is not a fact about an ancestor.

Keys: :on-key

:on-key offers a widget a key the focused widget declined. It fires on the focused widget first and then bubbles outward, so a handler can sit on the control or on a container around it:

(handler pressed String)
(text-input :text draft :on-key (pressed event.key))

The order is what makes it safe: the focused widget’s own handling always comes first, so a text input’s Ctrl+C and every character you type stay the text input’s and never reach a wire. Only what it declines — Escape, function keys, unclaimed shortcuts — is offered. After the wires come overlay dismissal (Escape), menu accelerators, and Tab traversal, so a wire on the focused widget is more specific than any of them.

A handler claims the key with cx.consume(); nothing below sees it:

fn pressed(&mut self, cx: EditorCx, key: &str) {
    if key == "Escape" {
        cx.editing.set(0);
        cx.consume();
    }
}

event.key is the keystroke’s canonical spelling — the same notation an :accel is written in, modifiers first in a fixed order: "Escape", "S", "Ctrl+S", "Ctrl+Shift+S", "Alt+Left". One string means one keystroke wherever it is written. A key the framework does not model has no spelling, and is not offered to a wire at all.

Component composition

A capitalized name in widget position instantiates another component, resolved through the search path:

(CounterButton :key "fives" :label "fives so far {total}" :step 5
               :grow 1 :class "hero"
               :on-stepped tally)

On an instance:

  • Props — every keyword that isn’t reserved is a child prop. Required props (no :default) must be given; this is checked against the child’s declaration at compile time, with the error in your file. Prop expressions evaluate in your scope and stay live: a binding like "fives so far {total}" keeps driving the child’s prop as your state changes.
  • :on-X handler — wires the child’s output X to your declared handler; the payload types must match.
  • :class — merges into the child’s root widget classes, so themes can target this instance.
  • Item-layout properties:width :height :grow :shrink :basis :align-self patch the instance root’s style: you control the instance as a flex item. Interior layout (:direction, :gap, :padding, :align-items, :justify-content) belongs to the child’s own file and is rejected here.
  • :key "name" — explicit identity for hot-reload state matching (see below). A literal, unique per component type within the file.

On the Rust side, your component’s generated Logic trait gains one factory method per child type — fn counter_button(&mut self) -> impl CounterButtonLogic — called once per instance. See the Rust side.

Instantiation cycles are compile errors, reported with the chain (A → B → A).

Slots

A component opts into receiving content by placing (slot) in its tree — at most one, not at the root, no properties or children of its own:

(component Card
  (prop title String)
  (container :class "card" :direction column :padding 8 :gap 6
    (text :class "card-title" :text "{title}")
    (slot)
    (text :class "card-footer" :text "fin")))

An instance of a slotted component may then have children, which project in at the marker — order-preserved between the component’s own children:

(Card :title "First"
  (text :text "note: {note}")
  (Badge :text "{note}"))

Slot content is your content rendered in the child’s frame: its expressions read your props and state, its events dispatch to your handlers, and components inside it use your logic factories. Instances of slotless components take no children (a compile error says so).

Hot reload and instance identity

In dev mode (DevMount) a component file is re-interpreted on save. State survives the reload when it still matches:

  • Top-level state: by name and type.
  • State inside component instances: instances match by identity — their component type plus :key, or, unkeyed, their occurrence order among unkeyed same-type siblings. Give instances a :key when you expect to reorder them; keyed state follows its key.

A state whose type changed restarts from its :init. A save that no longer compiles keeps the previous UI and prints the diagnostics.

The event pseudo-record

Inside the arguments of a wire that has an input event behind it, event names that event:

(handler pick i64 i32)
(container :on-click (pick todo.id event.click-count))

What it offers depends on the wire, because the two families describe different things — a pointer wire has no keystroke and a key wire has no position. Reading the wrong one is a compile error naming what is available there.

On pointer wires — :on-click, :on-counted-click, and the :on-pointer-… family:

FieldTypeNotes
event.x, event.yf64in the widget’s own space
event.click-counti321, 2, 3 … for single, double, triple

Coordinates are local — the frame the wire is written in, and the one that stays meaningful when the widget moves.

On :on-key:

FieldTypeNotes
event.keyStringcanonical spelling, e.g. "Ctrl+Shift+S"

:on-click vs :on-counted-click

:on-click fires immediately on release, carrying the running count — the right wire for a button or menu, where a click should act at once. But its event.click-count on the first press of a double is 1: the framework cannot yet know a second press is coming, so an :on-click that navigated on 1 would fire before a double could override it.

:on-counted-click resolves that. It fires once per click burst, after a short window (≤200ms from the first press), carrying the settled count — so event.click-count == 1 means a confirmed single, 2 a double. Use it when a single and a double must do different things and the single must not act prematurely:

(handler act i32)
(container :on-counted-click (act event.click-count))

A widget opts a press’s path into this accumulate-and-resolve behavior only by handling :on-counted-click (or being a paragraph with links, which follow a link on a confirmed single) — a plain :on-click is never delayed.

Consuming an event

A handler can stop an event from reaching handlers later in the dispatch — a bubbling ancestor, a deeper capture — by calling cx.consume(). An event bubbles from the innermost widget outward, so an inner handler can keep a click from also reaching the container around it:

(container :on-click (open-card)
  (container :class "button" :on-click (pin-card)))
fn pin_card(&mut self, cx: CardCx) {
    // …pin the card…
    cx.consume(); // the outer card's :on-click does not also fire
}

Consumption is per-dispatch and opt-in: a handler that never calls consume() changes nothing, and off the event path (an instance output, a query) it is a no-op.

The controls that own their click already do this for you: a button or a checkbox consumes its click, so clicking one inside a container with an :on-click fires only the control, not the container. A plain container is transparent — a click on a text label inside a clickable card still reaches the card.

Like payload, event is a reserved name, readable only where it exists: payload on wires that deliver a value, event on wires that have a pointer behind them. Reading it elsewhere, or reading a field it does not have, is a compile error that says so. It is flat, because records are — event.x, never event.pos.x.

It is not a value in its own right: read a field. There is no record type behind it and it never becomes a struct.