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 value | Behaviour |
|---|---|
sig | Read 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.
| Rule | Detail |
|---|---|
| Identity | One name, one signal. hover, pointer, scroll, geometry, elided, settled and image_status names are separate namespaces |
| Reload | Keeps its value across in-place reloads. Lost when the Renderer process is replaced (a crash respawn or a shell restart) |
| Changed seed | A 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 seed | Never re-seeds: tables compare by identity, so a fresh table cannot count as a change |
| Types | Not checked at runtime; initial is the type LuaLS infers |
| CLI | mantle 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 closes | Keeps 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) } },
}
| Rule | Detail |
|---|---|
| When | After the write’s callback returns, before the next layout pass, so the pass paints what the handler wrote |
| Change only | A 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 |
| Chains | A 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 |
| Failure | Each handler runs under the CPU budget. A raise is logged; the value stays written and the other handlers still run |
| Reload | Handlers 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 |
| Kinds | State, 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:
state | When |
|---|---|
"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.
| Event | Re-resolves |
|---|---|
:set, a capability push, a hover or scroll change | Instances that read that signal in their last resolve |
The same, under a map or computed | Instances that read it, once its result changed |
| A write to a signal no instance reads | Nothing |
A delay coming due or a pulse window closing | Every instance |
A geometry rect moving | One follow-up pass over the instances that read it |
A text’s elided result changing, or a node’s settled flipping | One follow-up pass over the instances that read it |
An image’s image_status changing | One follow-up pass over the instances that read it |
A wheel over a container whose scroll signal nothing else reads | Nothing: its children move where they are |
| Any write while the session is locked | Every instance |
| A reload, or a re-resolve that failed | Every 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).
| Change | The 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 function | Kept until a signal it read changes | Instead |
|---|---|---|
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 pulse | Nothing: 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
| Expression | Returns | Contract |
|---|---|---|
sig:get() | value | The current value, read once. nil before a capability’s first push (why) |
sig:map(fn) | signal | fn(value), run again when read, at most once per layout pass. Works on capabilities |
sig:set(value) | nothing | State signals only; see who writes each kind |
sig:reveal(index) | nothing | scroll signals only; scrolls the index-th child into view (input) |
sig:scroll_to(px), sig:scroll_by(px) | nothing | scroll signals only; moves to an offset, or by a distance from where it is headed (input) |
sig:on_change(fn) | nothing | State, :map, computed, persistent_table keys and capabilities; fn(current, previous) after each change (state, capabilities) |
cap:<action>(...) | nothing | Capabilities only (capabilities) |
computed({ a, b, ... }, fn, before?) | signal | fn(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 signal | Writable named state, written with :set(value) |
delay(sig, ms) | signal | sig’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 signal | true for ms after sig changes, false otherwise. A change inside the window restarts it. Starts false |
pointer(name) | { x, y } or nil signal | Bind 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 signal | Bind 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 signal | Bind it as a text node’s elided. Reports content removed by elide or max_lines; false until measured |
settled(name) | boolean signal | Bind 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? } signal | Bind 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.
| Kind | Made by | :set | :reveal | Written by |
|---|---|---|---|---|
| State | state(name, initial) | ✓ | The config, mantle set, mantle toggle | |
| Capability | mantle.<name> | The capability’s snapshot pushes | ||
| Derived | :map, computed, delay, pulse | Nobody: recomputed on read | ||
| Stored | A persistent_table key (scripting) | The table’s own :set(key, value) | ||
| Geometry | geometry(name) | Layout | ||
| Elision | elided(name) | Text fitting | ||
| Settled | settled(name) | Layout, tween ticks and scroll runs | ||
| Image status | image_status(name) | Layout and paint | ||
| Hover | hover(name), hover_rect(name) (input) | The pointer | ||
| Pointer | pointer(name) (input) | The pointer, while read | ||
| Scroll | scroll(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 with | Cause |
|---|---|
computed() dependency 2 is nil | That computed list entry is nil, often a misspelled variable |
computed() dependencies: key | The 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 pulse | The first argument is not a signal or capability |
delay: ms must be within [1, 60000] / pulse: ms must be within | ms 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 boundary | NaN, infinity, an integer past ±(2^53−1) or a string over 64 KiB |
state("name", ...) refused its initial value | The 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 levels | A derived chain deeper than 32, or one that reads itself |
exceeded the 50ms CPU limit for one evaluation | A map or computed body ran away (runtime) |
a Signal resolved to another Signal | A map returned a signal; return a plain value |
`x` is a Signal handle, not a plain value | A signal in a structural property, or inside a table a signal returned (see gotchas) |
How do I…
| Task | Answer |
|---|---|
| Open a dropdown under a button | Dismissal |
| Switch tabs | Switching views with ids |
| Keep a toggle across shell restarts | Named state is lost with the Renderer; use persistent_table (scripting) |
| Run a side effect when a capability or a state changes | on_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 }
Debounce a search
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
| Trap | Fix |
|---|---|
content = sig:get() never updates | Pass sig or sig:map(...); :get() is a snapshot |
:get() errors with attempt to index a nil value at startup | The 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 hydration | The signal holds nil until the first push, and visible defaults to true; pass before: cap:map(fn, false) |
`margin.left` is a Signal handle | The 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 Signal | Resolution 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 refused | These are structural and take plain values only (surfaces) |
| A named state resets on every reload | Its scalar seed changed between evaluations. Keep it stable |
state("x", ...) is declared twice in this evaluation | Two 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 push | Payloads are tables, which never compare ==. Delay or pulse a scalar derived with :map |
Hiding a view with visible = false keeps its whole subtree | Switch views through children = sig:map(...) |
A :set inside a map or computed | Maps must be side-effect free; write state from on_click, on_change or a timer |
A clock from os.date() alone stops updating | Nothing 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.