Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Signals

A config builds its node tree once; signals change it afterwards. Any node or surface property can hold a signal in place of a plain value. The engine reads the signal while it lays out, and a write to it re-resolves only the surfaces that read it.

The one rule

A property that holds a signal stays live. A property that holds a plain value, including whatever :get() returned, keeps that value until the next reload.

local minute = state("minute", 45)
local clock = minute:map(function(value) return string.format("12:%02d", value) end)

local function reading(label, value)
  return column { spacing = 2, children = {
    text { content = label, font_size = 12, foreground = "#a6adc8" },
    text { content = value, font_size = 20, font_weight = 700, foreground = "#cdd6f4" },
  } }
end

return row {
  spacing = 20,
  padding = 14,
  radius = 12,
  background = "#1e1e2e",
  children = {
    rect {
      padding = { left = 12, right = 12, top = 8, bottom = 8 },
      radius = 8,
      align_v = "center",
      background = "#89b4fa",
      on_click = function() minute:set(minute:get() + 1) end,
      children = { text { content = "Next minute", font_weight = 700, foreground = "#1e1e2e" } },
    },
    reading("Live", clock),
    reading("Snapshot", string.format("12:%02d", minute:get())),
  },
}

Click Next minute to update only the Live column. The screenshot captures that first click.

Property valueBehaviour
sigRead again once it is written
sig:map(fn)Live: fn of sig’s current value
sig:get()A plain value, taken when the config was evaluated
A table with a signal inside, e.g. { left = sig }Each signal is read again once it is written, as at the top level; nil drops a key but is refused as an array entry. Not inside children, a list’s items, a table with a metatable, or a table a signal returned

A capability (mantle.<name>, see capabilities) is a signal too. When a signal resolves to nil, its property counts as absent and takes the property’s default.

For values your config writes, start with named state. To transform or combine live values, use derived signals. The full API and error messages are in the reference.

Capabilities before their first push

A capability reads nil until its first snapshot arrives (hydration), and in mantle check’s first pass. lock and polkit push their idle state at startup; after a restart while locked, lock stays nil until the lock is taken again. :map(fn, before?) and computed(deps, fn, before?) skip fn and hold before until every capability dependency has pushed. Set before to what a guard would have returned ({}, 0, "", false); without it the signal reads nil. :get() and on_change’s previous still see nil.

Named state

state(name, initial) is the config’s own writable value. Its identity is its name. Every call with the same name returns the same signal, from any module and across reloads.

local open = state("details_open", false)

column { children = {
  rect {
    on_click = function() open:set(not open:get()) end,
    children = { text { content = "Details" } },
  },
  text { content = "More information", visible = open },
} }

The click writes open; visible = open reads it again. visible = open:get() would take only a snapshot.

RuleDetail
IdentityOne name, one signal. hover, pointer, scroll, geometry, elided, settled and image_status names are separate namespaces
ReloadKeeps its value across in-place reloads. Lost when the Renderer process is replaced (a crash respawn or a shell restart)
Changed seedA scalar initial (nil, boolean, number, string) that differs from the last evaluation’s re-seeds the value. 0 and 0.0 are equal. Two different scalar seeds for one name in one evaluation raise, as do a table seed and a scalar one
Table seedNever re-seeds: tables compare by identity, so a fresh table cannot count as a change
TypesNot checked at runtime; initial is the type LuaLS infers
CLImantle set <name> <value> and mantle toggle <name> [value] write it (cli). A bare toggle needs a boolean. Toggling to the value it already holds restores initial
Surface closesKeeps its value. List it in the surface’s reset_on_close to return it to initial instead

on_change: react to a write

s:on_change(fn) runs fn(current, previous) after a write changes the state, whoever wrote it: a click, a timer, another handler, or mantle set/mantle toggle from outside the shell. Use it for an effect that must follow the state however it changed, such as another surface’s state or a command.

local mode = state("mode", "balanced")
local last = state("last_mode", "balanced")

-- `mantle set mode '"performance"'` from a keybind runs this too.
mode:on_change(function(_, previous) last:set(previous) end)

return rect {
  on_click = function() mode:set(last:get()) end,
  children = { text { content = last:map(function(m) return "Back to " .. m end) } },
}
RuleDetail
WhenAfter the write’s callback returns, before the next layout pass, so the pass paints what the handler wrote
Change onlyA write of the held value runs nothing, nor does a change undone before the handlers run. Declaring the state, and a reload re-seeding it, run nothing
ChainsA handler may write state, which runs that state’s handlers next. After 8 rounds the rest are dropped with a logged error, so two handlers undoing each other stop
FailureEach handler runs under the CPU budget. A raise is logged; the value stays written and the other handlers still run
ReloadHandlers are dropped before each evaluation, which registers them again. Several per state are allowed, and none can be removed: a call inside a callback adds another handler each time it runs, until the next reload
KindsState, capabilities, and a :map, computed or persistent_table key, which runs fn when a source’s write changes its value; a raise in its function is logged and the last value kept. nil on delay, pulse, hover, pointer, scroll, geometry, elided, settled and image_status

Derived signals (:map, computed, delay, pulse) have no name. Each evaluation builds them fresh, so a reload drops a pending delay and closes an open pulse window. See runtime for everything else a reload keeps.

Derived signals

Use :map(fn) to turn one signal’s value into another value. Use computed({ a, b }, fn) when the result needs several signals. Both return a signal you can bind to a property. Their functions should only calculate a value: write state or run commands from on_click, an on_change or a timer. The functions run under the CPU budget.

A derived colour:

local battery_color = mantle.battery:map(function(battery)
  if not battery.present then return "#6c7086" end
  return battery.percent <= 20 and "#f38ba8" or "#a6e3a1"
end, "#6c7086")

text { content = "●", foreground = battery_color }

Use computed to combine two sources, here a capability and a state that a click toggles:

local show_seconds = state("show_seconds", false)

local clock = computed({ mantle.clock, show_seconds }, function(system, seconds)
  return os.date(seconds and "%H:%M:%S" or "%H:%M", system.time)
end, "")

rect {
  on_click = function() show_seconds:set(not show_seconds:get()) end,
  children = { text { content = clock } },
}

A dropdown under a button is a popup bound to a state the click toggles: dismissal.

delay: hold a value

delay answers the old value until the new one has held for ms. That makes it a trailing debounce, and also a close-hold. OR-ing a signal with its delayed copy keeps a surface mapped for ms after it closes, long enough for an exit fade to play. Hiding a surface or node plays no exit animation of its own (animation).

local open = state("menu_open", false)
local mapped = computed({ open, delay(open, 150) }, function(now, was) return now or was end)

local menu = popup {
  id = "menu", parent = "bar", anchor_rect = { x = 0, y = 0, width = 60, height = 28 },
  anchor = "bottom", gravity = "bottom",
  visible = mapped, -- stays mapped 150 ms after `open` goes false
  on_dismiss = function() open:set(false) end,
  child = column {
    padding = 8, background = "#1e1e2e",
    opacity = open:map(function(is_open) return is_open and 1 or 0 end), -- fades out while held
    animate = { opacity = { duration = 150, from = 0 } },
    children = { text { content = "Settings" } },
  },
}

pulse: mark a change

pulse reports that a change just happened. Use it to fire a one-shot flash or a keyframe animation, which a config cannot restart any other way (animation):

local count = state("count", 0)
local flash = pulse(count, 300) -- true for 300 ms after each change

rect {
  padding = 6,
  background = flash:map(function(on) return on and "#f9e2af" or "#313244" end),
  animate = { background = 300 },
  on_click = function() count:set(count:get() + 1) end,
  children = { text { content = count:map(tostring) } },
}

To fire on only one edge, combine the pulse with its source: computed({ pulse(plugged, 400), plugged }, function(fired, on) return fired and on end).

geometry: read a node’s laid-out rect

Layout writes the rect after it solves. A change triggers one follow-up pass over the surfaces that read the rect, and never two passes in a row, so a binding that feeds its own measurement cannot loop.

local track = geometry("track")

column { width = 200, children = {
  rect { geometry = track, width = "fill", height = 4, background = "#45475a" },
  text { content = track:map(function(rect) return string.format("%d px wide", math.floor(rect.width)) end) },
} }

elided: read text truncation

Bind elided(name) as a text node’s elided property. It starts false; after layout it is true when elide removes content or max_lines drops wrapped lines, including without an ellipsis. Wrapping alone, box or ancestor clipping, scrolling and occlusion do not count.

The result follows the shaped text, styled runs, font metrics and available width. Content changes, resizing and layout animations recompute it. A changed result schedules one follow-up pass over its readers, sharing geometry’s limit of one follow-up. Failed layouts keep the previous result. The property itself writes the signal and does not count as a reader.

Names persist across reloads. If several texts or output instances bind the same name, it is true when any visible binding is truncated. Hidden, leaving and removed bindings do not contribute; with no visible binding it becomes false.

Keep the expander visible while expanded so that restoring all lines does not remove the collapse button:

local clipped = elided("body")
local expanded = state("body_expanded", false)

return column { width = 240, children = {
    text { width = "fill", wrap = "word", elide = "end", elided = clipped,
        max_lines = expanded:map(function(open) return open and 0 or 2 end),
        content = "A longer message can wrap across several lines. Expand it to read the rest, then collapse it again." },
    rect {
        visible = computed({ expanded, clipped }, function(open, cut) return open or cut end),
        on_click = function() expanded:set(not expanded:get()) end,
        children = { text { content = expanded:map(function(open) return open and "Less" or "More" end) } },
    },
} }

image_status: read an image’s load

Bind image_status(name) as an image’s status. Two shown images on one name in one surface instance fail the pass, naming both. Across instances, such as a surface shown on several outputs, the name follows the first shown image by instance id (bar@DP-1 before bar@HDMI-A-1) whose state is known, else the first one. The value follows that image’s current source:

stateWhen
"empty"source is "", and before the first pass
"loading"The image’s new source has not finished decoding, including while retain or transition still draws the old picture. Paint starts the decode, so an image clipped or scrolled out of view stays here
"ready"Decoded. width and height are the file’s own size in pixels, absent if unreadable. When a cached thumbnail stands in, the size comes from reading the file’s header, a whole-file scan for a JPEG, paid only by an image that binds status
"failed"The file is missing or does not decode

An inline image (async = false) decodes in the frame that draws it, so "loading" lasts until that frame and moves to "ready" or "failed". A hidden image keeps its last status. Each change schedules one follow-up pass over the signal’s readers, as geometry does.

Fade a picture in once it has decoded:

local load = image_status("cover")

return image {
    width = 160, height = 90, async = true, status = load,
    source = "/usr/share/backgrounds/a.jpg",
    opacity = load:map(function(s) return s.state == "ready" and 1 or 0 end),
    animate = { opacity = 200 },
}

How re-resolution works

While a surface instance (one surface on one output) resolves, the engine records every signal it reads, including reads inside :map and computed bodies. A write marks the written signal dirty, and the next pass re-resolves only the instances that read it.

EventRe-resolves
:set, a capability push, a hover or scroll changeInstances that read that signal in their last resolve
The same, under a map or computedInstances that read it, once its result changed
A write to a signal no instance readsNothing
A delay coming due or a pulse window closingEvery instance
A geometry rect movingOne follow-up pass over the instances that read it
A text’s elided result changing, or a node’s settled flippingOne follow-up pass over the instances that read it
An image’s image_status changingOne follow-up pass over the instances that read it
A wheel over a container whose scroll signal nothing else readsNothing: its children move where they are
Any write while the session is lockedEvery instance
A reload, or a re-resolve that failedEvery instance

Readers of a :map or computed result update only when the result changes. Scalars and plain data tables compare by value. A table holding a function, signal or metatable counts as changed on every run; comparison also stops after 256 table entries. For example, an HH:MM label mapped from a per-second clock push updates once a minute. Within one pass, several properties reading the same derived signal calculate it once.

A node reads its children or child table once and keeps what it read while it holds that same table. A node table or children array changed in place is not seen; a signal answering a new table is.

What a node reads again

Within a re-resolved surface, each node keeps the properties it resolved last time until a signal that resolve read is written. A clock written every second resolves its one text again, not the whole bar. A list keeps its items the same way (when items rebuild).

ChangeThe node reads its properties again
A write to a signal bound to one of its properties, or one changing the result of a map or computed bound to one✓
A write to its own hover slot✓
A different table, function, signal or value in its declaration, as in a rebuilt list item✓
A reload✓
For a panel or lock root, a write to a signal its function child read. Every ✓ here runs that function again✓
A write to anything else, even on the same surface

The engine sees signal reads only. A map, computed, each, key or function child must answer from its arguments and the signals it reads; anything else it reads is taken as it was at the node’s last resolve:

Read inside the functionKept until a signal it read changesInstead
os.time(), os.date() with no time, os.clock(), math.random()✓mantle.clock:map(function(s) return os.date("%H:%M", s.time) end, ""), or a state a timer writes
A local or global changed without :set✓Keep it in a state
A file✓Read it in a timer and :set a state
A table changed in place, such as a list’s items✓:set the table again, or build a new one
A delay or pulseNothing: its readers resolve on every pass while one is pending or open

A visible = false node’s subtree is frozen. Its children keep their nodes, ids, properties and last geometry. None of their signals is read, no list item function runs and nothing re-lays out until the node is shown again. Signals that only a hidden subtree reads therefore trigger nothing.

Switching views

visible = false keeps a subtree in the tree, frozen: right for a section shown and hidden in place. For views that replace each other, bind the parent’s children to a signal that returns only the current view. The old view leaves the tree, playing its animate.exit, and the new one builds fresh. Give each view its own id: switching views with ids.

Reference

ExpressionReturnsContract
sig:get()valueThe current value, read once. nil before a capability’s first push (why)
sig:map(fn)signalfn(value), run again when read, at most once per layout pass. Works on capabilities
sig:set(value)nothingState signals only; see who writes each kind
sig:reveal(index)nothingscroll signals only; scrolls the index-th child into view (input)
sig:scroll_to(px), sig:scroll_by(px)nothingscroll signals only; moves to an offset, or by a distance from where it is headed (input)
sig:on_change(fn)nothingState, :map, computed, persistent_table keys and capabilities; fn(current, previous) after each change (state, capabilities)
cap:<action>(...)nothingCapabilities only (capabilities)
computed({ a, b, ... }, fn, before?)signalfn(a_value, b_value, ...): the values in list order, not the signals. Signals and capabilities are read live; a string, number, boolean or table is a constant passed to fn unchanged. Until every capability among them has pushed, fn is skipped and the signal holds before (details). A nil, function, userdata or named key raises, naming the entry
state(name, initial)state signalWritable named state, written with :set(value)
delay(sig, ms)signalsig’s value once a new value has held for ms, and the old value until then. A change that reverts sooner is dropped
pulse(sig, ms)boolean signaltrue for ms after sig changes, false otherwise. A change inside the window restarts it. Starts false
pointer(name){ x, y } or nil signalBind it as a node’s pointer. The pointer’s position from that node’s top-left corner while it is over the node, nil otherwise (input)
geometry(name)rect signalBind it as a node’s geometry. Layout writes that node’s { x, y, width, height } in surface coordinates. Zero until the first layout
elided(name)boolean signalBind it as a text node’s elided. Reports content removed by elide or max_lines; false until measured
settled(name)boolean signalBind it as any node’s settled. false while that node has an animate tween or move running; true at rest (animation)
image_status(name){ state, width?, height? } signalBind it as an image’s status. Its source’s load: "empty", "loading", "ready" with the file’s size, or "failed" (details)

delay and pulse take ms in [1, 60000] rounded to whole milliseconds, and raise outside that range. Both compare values with ==, so a table value (every capability payload, for example) counts as a new value on every push.

Who writes each kind

Only state can be written from Lua. :set on any other kind raises an error that names the kind. :reveal, :scroll_to and :scroll_by work only on a scroll signal.

KindMade by:set:revealWritten by
Statestate(name, initial)✓The config, mantle set, mantle toggle
Capabilitymantle.<name>The capability’s snapshot pushes
Derived:map, computed, delay, pulseNobody: recomputed on read
StoredA persistent_table key (scripting)The table’s own :set(key, value)
Geometrygeometry(name)Layout
Elisionelided(name)Text fitting
Settledsettled(name)Layout, tween ticks and scroll runs
Image statusimage_status(name)Layout and paint
Hoverhover(name), hover_rect(name) (input)The pointer
Pointerpointer(name) (input)The pointer, while read
Scrollscroll(name) (input)✓The wheel and the layout clamp

:set refuses the scalars outside the engine’s value limits and leaves tables unchecked. It checks no types: state("x", 1):set({}) succeeds, and only LuaLS flags it. A :set of what the state already holds changes nothing: 1 over 1 is skipped, 1.0 over 1 is a write. A fresh plain-data table (no metatable, only scalars and such tables inside, 256 entries in all) equal entry for entry is skipped too; the same table written again after changing it in place is a write.

Errors

Message starts withCause
computed() dependency 2 is nilThat computed list entry is nil, often a misspelled variable
computed() dependencies: keyThe computed list has a named key; list the signals in fn’s order
delay: source must be a Signal or a `mantle` capability, got integer 5 / the same for pulseThe first argument is not a signal or capability
delay: ms must be within [1, 60000] / pulse: ms must be withinms out of range, or rounds to 0
signal:set() is only valid on a state(name, initial) signal:set on a derived, capability, hover, pointer, scroll, geometry, elided, settled or image_status signal
attempt to call a nil value (method 'on_change'):on_change on a delay, pulse, hover, pointer, scroll, geometry, elided, settled or image_status signal, which has none
signal:set() refused its value at the marshalling boundaryNaN, infinity, an integer past ±(2^53−1) or a string over 64 KiB
state("name", ...) refused its initial valueThe same checks on initial
signal:reveal() is only valid on a scroll(name) signal / index must be 1 or more:reveal on another kind, or an index below 1
signal nesting exceeded its maximum depth of 32 levelsA derived chain deeper than 32, or one that reads itself
exceeded the 50ms CPU limit for one evaluationA map or computed body ran away (runtime)
a Signal resolved to another SignalA map returned a signal; return a plain value
`x` is a Signal handle, not a plain valueA signal in a structural property, or inside a table a signal returned (see gotchas)

How do I…

TaskAnswer
Open a dropdown under a buttonDismissal
Switch tabsSwitching views with ids
Keep a toggle across shell restartsNamed state is lost with the Renderer; use persistent_table (scripting)
Run a side effect when a capability or a state changeson_change (state, capabilities), never a map

Derive from two capabilities

List every source in computed; before here is "..." (why).

local status = computed({ mantle.network, mantle.audio }, function(network, audio)
  local net = network.connected and "online" or "offline"
  local sink = audio.sinks[1]
  for _, each in ipairs(audio.sinks) do
    if each.active then sink = each end
  end
  local sound = not sink and "no output"
    or sink.muted and "muted" or string.format("%d%%", math.floor(sink.volume or 0))
  return net .. " / " .. sound
end, "...")

text { content = status }

The field writes every keystroke to a state. The filter reads a delay of that state, so the filter runs once typing pauses for 250 ms.

local query = state("search_query", "")
local settled = delay(query, 250)

local results = settled:map(function(needle)
  local found = {}
  for _, name in ipairs({ "Firefox", "Files", "Terminal", "Settings" }) do
    if needle ~= "" and name:lower():find(needle:lower(), 1, true) then
      found[#found + 1] = text { id = name, content = name }
    end
  end
  return found
end)

column { width = 240, spacing = 4, children = {
  textfield { width = "fill", height = 28, placeholder = "Search", on_change = function(text) query:set(text) end },
  column { spacing = 2, children = results },
} }

Drive UI from a keybind

Bind the surface’s visible to a named state. A compositor keybind that runs mantle toggle launcher_open flips it, and mantle set launcher_open false closes it. Example and compositor syntax: cli. For a keybind that runs Lua code, use action.

Gotchas

TrapFix
content = sig:get() never updatesPass sig or sig:map(...); :get() is a snapshot
:get() errors with attempt to index a nil value at startupThe capability has not pushed yet. Guard there, or use :map/computed, which skip fn until the first push (details)
visible = cap:map(function(c) return c.on end) shows the node before hydrationThe signal holds nil until the first push, and visible defaults to true; pass before: cap:map(fn, false)
`margin.left` is a Signal handleThe signal sits where it is not read: in a table a signal returned, under a metatable, or 8 tables deep. Return plain values from the map, or move the signal up
A map that returns a signal fails with a Signal resolved to another SignalResolution happens once; return a plain value, or combine the sources with computed
layer, anchor, output, namespace, parent or an id bound to a signal is refusedThese are structural and take plain values only (surfaces)
A named state resets on every reloadIts scalar seed changed between evaluations. Keep it stable
state("x", ...) is declared twice in this evaluationTwo state calls give one name different seeds, or a table and a scalar. Often a component’s name (a slider’s held drag) shares it. Declare it in one module and require that, or rename one
delay on a capability never updates, or pulse fires on every pushPayloads are tables, which never compare ==. Delay or pulse a scalar derived with :map
Hiding a view with visible = false keeps its whole subtreeSwitch views through children = sig:map(...)
A :set inside a map or computedMaps must be side-effect free; write state from on_click, on_change or a timer
A clock from os.date() alone stops updatingNothing it read is a signal, so its node keeps the first answer. Derive it from mantle.clock’s time (what a node reads again)

See also: runtime (budgets, reload), capabilities, input (hover, scroll), animation, cli, glossary (generation, hydration).

Source: signal core, globals, read tracking, re-resolve, property resolution, kept nodes, frozen subtrees.