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 clock = mantle.system:map(function(system)
  if not system then return "--:--" end -- nil until the first push
  return os.date("%H:%M", system.time)
end)

return panel {
  id = "bar", layer = "Top", anchor = { top = true, left = true, right = true }, height = 28,
  child = row { spacing = 12, children = {
    text { content = clock },       -- live: follows every push
    text { content = clock:get() }, -- snapshot: "--:--" until the next reload
  } },
}
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 }Refused at layout. Derive the whole table: sig:map(function(v) return { left = v } end)

A capability (mantle.<name>, see capabilities) is a signal too. It reads nil until its first snapshot arrives (hydration), and in mantle check’s first pass, before a sample push. Every function that reads a capability must handle nil. When a signal resolves to nil, its property counts as absent and takes the property’s default.

Reference

ExpressionReturnsContract
sig:get()valueThe current value, read once. nil before a capability’s first push
sig:map(fn)signalfn(value), run again on every read. 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)
cap:on_change(fn), cap:<action>(...)nothingCapabilities only (capabilities)
computed({ a, b, ... }, fn)signalfn(a_value, b_value, ...): the values in list order, not the signals. Each entry must be a signal or capability: a nil, another value or a 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
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

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 works 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
Hoverhover(name), hover_rect(name) (input)The pointer
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 nil / is a tableThat computed list entry is not a signal or capability; nil is often a misspelled variable
computed() dependencies: keyThe computed list has a named key; list the signals in fn’s order
delay() takes a Signal / pulse() takes a SignalThe first argument is not a signal or capability
delay() hold must be within [1, 60000] ms / pulse() window 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, scroll or geometry signal
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 / takes a 1-based child index: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 5ms CPU budget for one evaluationA map or computed body ran too long (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 property table (see gotchas)

Derived signals

:map and computed run again once a signal they read is written, and their readers re-resolve only when the result changed (what a node reads again): a scalar or a plain-data table by value, anything holding a function, signal or metatable on every run. An HH:MM label or a { { text = hour, bold = true } } run list mapped from a per-second snapshot re-resolves once a minute. Within one pass, a derived signal read by several properties runs once. Keep their functions cheap and side-effect free: no :set, no process, no action. They run under the CPU budget and nesting limit described in runtime. Side effects belong in on_click, a capability’s on_change (capabilities) or a timer (scripting).

A derived colour:

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

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.system, show_seconds }, function(system, seconds)
  if not system then return "" end
  return os.date(seconds and "%H:%M:%S" or "%H:%M", system.time)
end)

button {
  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

button {
  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) },
} }

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.

RuleDetail
IdentityOne name, one signal. hover, scroll and geometry 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
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

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.

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 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

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, itemfn, 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.system:map(function(s) return s and os.date("%H:%M", s.time) or "" 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 source✓: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.

How do I…

TaskAnswer
Show a live clockThe one rule
Colour a node from a capabilityDerived signals
Derive one value from two capabilitiesBelow
Debounce a search fieldBelow
Open a dropdown under a buttonDismissal
Keep a popup mapped while its exit playsdelay
Flash a node when a value changespulse
Open or close UI from a compositor keybindBelow
Switch tabsSwitching views with ids
Size one node from another’s layoutgeometry
Keep a toggle across shell restartsNamed state is lost with the Renderer; use persistent_table (scripting)
Run a side effect when a capability changeson_change (capabilities), never a map

Derive from two capabilities

List every source in computed. Each one reads nil until its first push.

local status = computed({ mantle.network, mantle.audio }, function(network, audio)
  if not network or not audio then return "..." end
  local net = network.connected and "online" or "offline"
  local sound = audio.muted and "muted" or string.format("%d%%", math.floor((audio.volume or 0) * 100))
  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
A map errors with attempt to index a nil value at startupCapabilities read nil before hydration and in mantle check’s first pass; return a fallback for nil
visible = cap:map(function(c) return c and c.on end) shows the node before hydrationnil means absent, and visible defaults to true; return false explicitly
margin = { left = sig } fails at layout: `margin.left` is a Signal handleSignals inside a property table do not resolve. Derive the whole table with :map or computed; the error’s :get() advice gives a snapshot
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, monitor, 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. Declare it in one module and require that
delay(mantle.system, 2000) never updatesEach push is a fresh table, so the hold restarts every second. Delay a scalar derived with :map
pulse(cap, ms) fires on every pushTable payloads are never ==; pulse a mapped scalar
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.system’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.