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

Capabilities

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

Reading and acting

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.

MemberContract
: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.

Lifecycle

StageWhat happens
First read of mantle.<name>The Supervisor starts that backend, once. mantle.idle starts on its first method call, :get, :map and :on_change included
Before the first pushEvery read is nil. A missing backend may keep it nil for good
RunningA 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 readersaudio and privacy share one PipeWire thread; workspaces and windows share one niri/Hyprland reader. Whichever is read first starts it
BusesOne 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
PushesOn backend events. system, sysinfo, updates, notification expiry, mpris’s position recheck and the brightness fallback also run timers
Renderer replacedThe 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 backendLogged; 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 bootlock, 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 namemantle.audioo is plain nil, so the :get() after it raises on that line

Actions

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.

MistakeResult
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 nameRaises at the read, listing the actions the capability takes
Any other method but get, map and on_change on battery, privacy or systemRaises: they have no actions
A function or userdata argumentRaises at the call, naming its slot
Wrong type or argument countLogged (mantle log) and dropped
A float where an integer goesDropped: 5.0 is refused, 5 works. math.floor(x + 0.5) returns an integer
Arguments to an action that takes noneDropped: mantle.network:scan(1) is refused

A trailing nil counts as omitted, so an optional last argument can be passed as nil.

ConventionRule
TargetsPass the ID from the snapshot (sinks[].id, feed[].id, players[].id, windows[].id). IDs are opaque: compare them, never build them
Volume1.0 is 100%
PercentagesIntegers 0 to 100
IndicesZero-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.

Capability list

NameWhat it gives youNote
applicationsDesktop entries, window app_id index, launching
audioOutput and input volume and mute, devices, per-app streams, Bluetooth codecs
batteryCharge, state, time estimatesCheck present first
bluetoothAdapter power, devices, discovery, pairing prompts
brightnessScreen backlight percentagenil without a backlight
filesListings of watched foldersLists nothing until watch
idleWho holds the session awake; idle thresholds and inhibitsMethods, not actions
keyboardActive layout, lock keys, keyboard backlightnil until the compositor reports a layout or a lock key changes
lockLock held, authentication progress, last failureNo unlock: only a correct password unlocks
mprisMedia players, metadata, position
networkConnectivity, Wi-Fi scan, join progressSecured joins take the key through secure_submit
notificationsThe newest 20 notifications, do-not-disturbMantle is the notification daemon
polkitThe pending authentication requestMantle is the polkit agent; the password goes through secure_submit
powerPower profiles, on battery, power draw
privacyApps using the camera, microphone or screen capture
processesPrograms declared with session_processUse session_process, not its actions
storageEach persistent_table fileUse persistent_table, not its actions
sysinfoCPU, memory, swap, temperaturesnil until configure
systemWall and monotonic clocks, once a second
trayTray items, artwork, menusMantle hosts the StatusNotifierWatcher
updatesPending packages, install progress, reboot neededNo schedule until configure
windowsEvery toplevel: title, app ID, workspace, output, state
workspacesPer-output workspaces, specials, focused windowniri or Hyprland only

Renderer members

Five members come from the Renderer, not a backend, so they are never nil and start nothing.

MemberKindContract
mantle.screensSignalConnected 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.rescueSignal{ 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.versionPlain table{ major, minor, patch } integers, for guarding newer API
mantle.config_dirPlain stringDirectory shell.lua was loaded from, for naming files shipped beside it
mantle.pidPlain integerThe Supervisor’s pid, as mantle list shows it. mantle stop --pid with it ends this shell

Screen

FieldTypeMeaning
namestringConnector name, e.g. "eDP-1", as a surface’s monitor takes it; "output-N" below wl_output v4
xintegerLeft edge in compositor space: xdg_output’s logical position, else wl_output’s
yintegerTop edge, on the same terms as x
widthintegerLogical pixels, already divided by scale; the mode’s pixels when no logical size is known
heightintegerLogical pixels, on the same terms as width
scaleintegerInteger scale factor, e.g. 2 on HiDPI
fractional_scalenumberReal scale, e.g. 1.5: mode width over logical width; scale without both
refreshnumberRefresh rate in Hz; 0 without a current mode, e.g. a virtual output
orientationOrientationThe wl_output transform
modelstringMonitor model, e.g. "DELL U2720Q"; stable across connector renames. Empty when unadvertised
description?stringThe compositor’s human label; format varies (Hyprland’s has the serial). Absent below wl_output v4

Orientation

ValueMeaning
"normal"No transform
"90", "180", "270"Rotated that many degrees counter-clockwise
"flipped"Mirrored around a vertical axis, no rotation
"flipped_90", "flipped_180", "flipped_270"Mirrored, then rotated that many degrees counter-clockwise

How do I…

TaskAnswer
Show a value that may not have arrived yetGuard nil in the map: battery label
Change volume or brightness with the wheelon_wheel plus :get() and an action, as in the example above; brightness
Show an OSD when volume changes:on_change writing state: Volume OSD
Show a clockos.date over mantle.system.time: system
Give each monitor its own bar and workspacesA function child gets the connector name; match it in workspaces.outputs: workspaces
Show CPU and memory useconfigure once at top level, then map: sysinfo
Name or iconify the focused appworkspaces.active_client.class through applications.by_app_id: applications
Play or pause whatever is playingcontrol on players[1].id: mpris
Show a microphone or camera indicatorprivacy
Keep the screen awake (caffeine)idle
Know whether an action workedWatch the state it changes: a failed Wi-Fi join

Gotchas

TrapFix
attempt to index a nil value in a :map at startupGuard the whole payload before its fields
An optional field is nilA 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 nilBind the state the action changes; read mantle log for dropped commands
An action silently does nothingWrong 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 == nilThat 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 changedEvery 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.

Source: namespace, capability, idle, screens, lazy start and dispatch, argument decoding, payload and action types under supervisor/src/capabilities/.