mantle.<name> reads one slice of the system (audio, network, battery, workspaces and the rest) as
a read-only signal, and its action methods ask its backend to act. This page holds the rules every
capability shares; each capability’s page holds its state, actions and backend.
button {
on_click = function() mantle.audio:toggle_mute() end,
on_wheel = function(_, steps)
local audio = mantle.audio:get()
if audio and audio.volume then
mantle.audio:set_volume(audio.volume + steps * 0.05) -- clamped to [0, 1.5]
end
end,
children = {
text {
content = mantle.audio:map(function(audio)
if audio == nil or audio.volume == nil then
return "--" -- nil before the first push; no volume without a default sink
end
return audio.muted and "muted" or string.format("%d%%", math.floor(audio.volume * 100 + 0.5))
end),
},
},
}
A capability is a signal the backend writes: pass it, or a :map of it, to
a property and the property stays live. Each push replaces the whole snapshot.
Member
Contract
:get()
The last pushed snapshot; nil before the first
:map(fn)
Derived signal; fn must handle nil. A capability also works as a computed dependency
:on_change(fn)
fn(current, previous) once per push, after it lands; previous is nil on the first. Runs under the 5 ms callback budget and may call actions, process.run or write state. A raise logs a warning and the next handler still runs. Every evaluation clears them before shell.lua registers its own
:<action>(...)
One method per action on the capability’s page, e.g. mantle.audio:set_volume(0.5). Queues one command and returns nothing. Read the state it changes for the outcome. Call it with :; a . call raises
There is no :set on the state; mantle.brightness:set and mantle.storage:set are actions. mantle.idle has no actions; it takes methods instead.
The Supervisor starts that backend, once. mantle.idle starts on its first method call, :get, :map and :on_change included
Before the first push
Every read is nil. A missing backend may keep it nil for good
Running
A started backend runs for the Supervisor’s lifetime. Its state survives reloads and Renderer replacement: a new generation gets every last snapshot replayed (hydration)
Shared readers
audio and privacy share one PipeWire thread; workspaces and windows share one niri/Hyprland reader. Whichever is read first starts it
Buses
One system-bus connection for all. tray, notifications, mpris and idle each open their own session bus. Every D-Bus method call times out after 25 s
Pushes
On backend events. system, sysinfo, updates, notification expiry, mpris’s position recheck and the brightness fallback also run timers
Renderer replaced
The departed generation’s Bluetooth discovery stops, its pending Wi-Fi prompt is cancelled, its files watches, idle thresholds and inhibits are dropped, and its process.run children are reaped (processes). An in-place reload keeps the generation
Missing backend
Logged; the capability goes inert or stays nil. Each page’s Backend section says which. Only network retries: a failed NetworkManager connection is rebuilt on the next start, which each new generation sends
Built at boot
lock, so the session can relock after a Renderer dies. The polkit controller also exists at boot, but its agent registers on the first read of mantle.polkit or a secure_submit naming it
Unknown name
mantle.audioo is plain nil, so the :get() after it raises on that line
Arguments are positional, in the order each page’s Actions table lists them, and JSON-shaped:
numbers, strings, booleans and tables. The Renderer checks the action name and marshalling; the
Supervisor checks types and count.
Mistake
Result
A state field read off the capability (mantle.audio.volume)
Raises at the read: did you mean mantle.audio:get().volume?
A misspelled action (mantle.audio:set_volum(1))
Raises at the read: did you mean mantle.audio:set_volume(...)?
Any other unknown name
Raises at the read, listing the actions the capability takes
Any other method but get, map and on_change on battery, privacy or system
Raises: they have no actions
A function or userdata argument
Raises at the call, naming its slot
Wrong type or argument count
Logged (mantle log) and dropped
A float where an integer goes
Dropped: 5.0 is refused, 5 works. math.floor(x + 0.5) returns an integer
Arguments to an action that takes none
Dropped: mantle.network:scan(1) is refused
A trailing nil counts as omitted, so an optional last argument can be passed as nil.
Convention
Rule
Targets
Pass the ID from the snapshot (sinks[].id, feed[].id, players[].id, windows[].id). IDs are opaque: compare them, never build them
Volume
1.0 is 100%
Percentages
Integers 0 to 100
Indices
Zero-based
lua-meta/mantle.lua is generated from the same Rust types as the
pages. On the LuaLS library path, mantle.audio:get(). completes fields and
mantle.audio: offers every action, and a wrong argument type is a warning.
Five members come from the Renderer, not a backend, so they are never nil and start nothing.
Member
Kind
Contract
mantle.screens
Signal
Connected outputs, one Screen each. Starts as {}, so a loop runs zero times before the first output arrives. An output with no known size is left out
mantle.rescue
Signal
{ is_rescue, error_log }. is_rescue turns true when an evaluation raises, a scene fails to apply, a live update fails, a reload renames the lock surface while locked, or the session lock is refused or torn down; error_log holds the reason, ready to draw. The next reload that applies clears it, and so does a later pass after a failed startup apply or live update
mantle.version
Plain table
{ major, minor, patch } integers, for guarding newer API
mantle.config_dir
Plain string
Directory shell.lua was loaded from, for naming files shipped beside it
mantle.pid
Plain integer
The Supervisor’s pid, as mantle list shows it. mantle stop --pid with it ends this shell
A JSON null arrives as an absent key. Fields marked ? need their own guard (audio.volume with no default sink)
local ok = mantle.audio:set_volume(...) is always nil
Bind the state the action changes; read mantle log for dropped commands
An action silently does nothing
Wrong argument type or count, often a float where an integer goes (brightness:set(50.0)). Check mantle log
on_change fires at startup with previous == nil
That push is learned state, not a change; return early. A replacement Renderer gets every snapshot replayed the same way. An in-place reload keeps the last value, so its next push has a real previous
on_change fires with nothing visibly changed
Every push carries the whole snapshot. Compare the fields you care about
See also: signals for :map, computed and named state;
input for click and wheel handlers; installation
for what each backend needs.