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

Runtime

What runs a config: the Lua VM and its libraries, how require finds modules, what a reload re-runs and what it keeps, and the limits the engine enforces. Read it before splitting a config into modules, when a reload does something unexpected, or when a log line names a budget.

Only shell.lua is required. This layout puts the bar in bar.lua and its clock in widgets/clock.lua:

-- shell.lua
local bar = require("bar")
return { bar }
-- bar.lua
local clock = require("widgets.clock") -- widgets/clock.lua

return panel {
    id = "bar",
    layer = "Top",
    anchor = { top = true, left = true, right = true },
    height = 32,
    background = "#1e1e2eff",
    child = row { padding = 8, children = { clock } },
}
-- widgets/clock.lua
return text {
    content = mantle.system:map(function(system)
        return system and os.date("%H:%M", system.time) or "" -- nil before the first push
    end),
    foreground = "#cdd6f4ff",
}

Saving any of the three re-runs shell.lua in place, and the edited module is loaded fresh.

The VM

Mantle runs as two processes (glossary). The Supervisor is the mantle process: it owns the backends, watches the config and restarts the other one. The Renderer holds the Lua VM, the scene and the Wayland connection, all on one thread. There is one Lua 5.4 VM per generation. Anything that blocks it freezes every surface on every monitor, so the blocking parts of the standard library are removed (ADR-0048).

LibraryAvailableMissing
Baseassert, collectgarbage, dofile, error, getmetatable, ipairs, load, loadfile, next, pairs, pcall, print, rawequal, rawget, rawlen, rawset, require, select, setmetatable, tonumber, tostring, type, warn, xpcall, _G, _VERSION ("Lua 5.4")None. warn logs like log.warn, on by default; warn("@off") / warn("@on") toggle it
coroutineclose, create, isyieldable, resume, running, status, wrap, yieldNone
stringbyte, char, dump, find, format, gmatch, gsub, len, lower, match, pack, packsize, rep, reverse, sub, unpack, upper. Strings have the usual ("x"):upper() metatableNone
tableconcat, insert, move, pack, remove, sort, unpackNone
mathabs, acos, asin, atan, ceil, cos, deg, exp, floor, fmod, huge, log, max, maxinteger, min, mininteger, modf, pi, rad, random, randomseed, sin, sqrt, tan, tointeger, type, ult, plus the 5.3 compatibility functions atan2, cosh, frexp, ldexp, log10, pow, sinh, tanhNone
utf8char, charpattern, codepoint, codes, len, offsetNone
packageconfig, cpath (unused), loaded, path (the config directory only), preload, searchers, searchpathloadlib exists but raises. C modules never load
osclock, date, getenv, time. require("os") returns the same fourdifftime, execute, exit, remove, rename, setlocale, tmpname
ioNothingThe whole library. require("io") fails too
debugNothingThe whole library. debug.traceback included
FFI, native modulesNothingAll

load accepts text and precompiled chunks. dofile and loadfile remain and read any path, but they are synchronous file I/O on the render thread. To read a file, use process.run or persistent_table. To run a program, use process.run. For os.difftime(a, b), write a - b.

The engine adds these globals. Every module sees the same ones.

GlobalKindOwning page
panel, window, popup, lockSurface constructorssurfaces
rect, row, column, text, icon, image, capture, shader, button, list, textfieldNode constructorsnodes
state, computed, delay, pulse, geometrySignal constructorssignals
hover, hover_rect, scrollInput signalsinput
mantleCapabilities and renderer memberscapabilities
process, session_processProcessesprocesses
persistent_table, timer, action, json, log, fuzzy, palette, fontsScriptingscripting

Modules and require

The config is a directory, not a file. shell.lua is the entry, and require resolves module names inside that directory only.

RuleDetail
Search path<config>/?.lua;<config>/?/init.lua, nothing else. No system Lua paths, no ./
NamesEach . in a module name is a /: require("widgets.clock") loads widgets/clock.lua. require("widgets") also finds widgets/init.lua
Cachepackage.loaded behaves as usual within one evaluation. Every reload drops the config’s own modules first, so an edited module is re-read. Standard modules stay
Second valueLua 5.4’s require returns the module and its file path. In the last position of a table constructor both land in the table
SymlinksA symlinked subdirectory is followed, both by require and by the reload watcher

Bind every module to a local before listing it, or the file path becomes a surface:

return { require("bar") }        -- { bar, "/home/me/.config/mantle/bar.lua" }: fails
local bar = require("bar")
return { bar }                   -- works

The failure reads surface 2 is a string, not a node, followed by this hint. Parentheses, (require("bar")), also truncate to one value.

Evaluation, reload and generations

shell.lua returns a surface (panel, window, popup or lock), an array of them, {}, or nothing. Running it top to bottom is one evaluation. The engine runs it at startup and again on every reload.

TermMeaning
EvaluationOne run of shell.lua and whatever it requires. Its top level has no CPU budget
ReloadAn evaluation in the same VM, triggered by a saved file or an output change, then one apply to the live scene
ApplyThe engine reconciles the new surface list with the scene on screen. A surface whose fingerprint changed is rebuilt; everything else updates in place
GenerationOne Renderer process and its VM. A reload never starts a new one. Only a crash does, when the Supervisor respawns the Renderer

What triggers a reload:

EventReloads?
A .lua or .frag file anywhere under the config directory is written, created, renamed in or deletedYes, 200 ms after the last event of a burst
A save with the same bytes as the last one the watcher saw for that fileNo
Any other extension (.json, images, editor swap files)No
A new subdirectoryWatched from then on, and walked if it already has files
An output is added, removed or reconfigured (mantle.screens changes)Yes, at once, by the Renderer itself
The whole directory disappears (a git checkout)Watched again once it is back, polled every second

The watcher follows the config path resolved at startup. Retargeting a symlink later changes nothing until a restart.

How each failure ends:

FailureResult
Startup evaluation raisesNo scene. Surfaces paint nothing. mantle.rescue is set as below. The next successful reload brings the shell up
Startup evaluation succeeds but the scene rejects itNo scene. mantle.rescue is set
Reload evaluation raises (syntax error, runtime error, bad top-level return)The previous scene stays on screen. mantle.rescue becomes { is_rescue = true, error_log = "<the error>" }. The error is logged
Reload evaluates but the scene rejects it (bad property value, a map over budget)The previous scene stays. mantle.rescue is set. The error is logged, one line per broken node, as mantle check prints it
A live update fails later (a pushed value breaks a map)The previous scene stays. mantle.rescue is set until a pass applies. Warning logged
The session lock is refused, or the compositor ends itmantle.rescue becomes { is_rescue = true, error_log = "<the reason>" }. The error is logged (lock)
A reload would recreate the lock surface while lockedRefused with a warning and mantle.rescue; save again after unlocking
The Renderer crashesThe Supervisor starts a new generation. After three crashes within 60 s, it waits 30 s before the next respawn
The compositor goes awayThe Renderer exits with code 71 and the Supervisor shuts down instead of respawning

A failed reload keeps only the scene. The actions, timers, on_change handlers and idle thresholds of the evaluation that drew it are already cleared (what survives). A failed evaluation leaves none registered. A failed apply keeps the new evaluation’s actions, handlers and thresholds but no timers. Fix and save to get them back.

mantle.rescue clears when a reload applies. A rescue from a failed live update or startup apply also clears when a later pass over the same scene applies. A config can draw its own error banner:

local rescue = mantle.rescue

return panel {
    id = "rescue",
    layer = "Overlay",
    anchor = { bottom = true, left = true, right = true },
    visible = rescue:map(function(state) return state ~= nil and state.is_rescue end),
    background = "#f38ba8ff",
    padding = 8,
    child = text {
        content = rescue:map(function(state) return state and state.error_log or "" end),
        foreground = "#11111bff",
    },
}

What survives a reload

ThingIn-place reloadNew generation (crash)Shell stops
state, hover, hover_rect, scroll, geometry values (by name)Kept. A changed scalar state seed re-seeds (named state)LostLost
Plain Lua globals the config assignsKept: same VMLostLost
Config modules in package.loadedDropped, re-requiredLostLost
Derived signals (:map, computed, delay, pulse)Rebuilt. A pending delay or open pulse resetsRebuiltLost
persistent_table (by file)Same tableSame file, new tableOn disk
session_process (by name)Keeps running, same tableKeeps running (the Supervisor holds it)Stopped
process.run childKilled with its process group, failed reloads included. Its exit_cb(nil) runs before the new evaluation; no out_cb followsKilled with its process groupKilled
process.detach programUnaffectedUnaffectedUnaffected
timerCleared. The new evaluation’s timers start when its result is appliedClearedGone
action, mantle.<cap>:on_changeCleared, re-registered by the new evaluationClearedGone
mantle.idle thresholdsCleared, re-registeredClearedGone
fonts { ... } chainNot re-readRe-readGone
Capability state (mantle.<cap>)UnchangedReplayed from the Supervisor’s last snapshotGone

Top-level side effects run again on every reload. A top-level timer chain or long-running process.run is restarted, not doubled, because the old ones are cleared first. To keep a program running through a save, declare it with session_process (processes).

Limits and budgets

Config Lua and the renderer share one thread, so the engine bounds how long config code can hold it.

LimitValueApplies toWhen exceeded
CPU budget5 ms of thread CPU timeEach :map and computed recompute, each delay/pulse read, each on_change handler, action handler and timer callback. Nested reads share the outermost deadlineThe call raises exceeded the 5ms CPU budget for one evaluation. pcall inside the callback does not hide it
Signal nesting32 levelsSignal reads nested inside other signal reads (a map of a map of …, a computed reading itself)Raises signal nesting exceeded its maximum depth of 32 levels
Layout pass2 sOne whole pass over the scene, including list itemfns and function child buildersThe pass fails and the previous scene stays
Tree depth64 levelsNested nodes in one surfaceThe pass fails
Scalar valuesNumbers finite, integers within ±(2^53 − 1), strings at most 64 KiBstate seeds, :set(), mantle set, and number or string node properties. Tables are not checkedstate and :set raise. mantle set is refused: it exits 1 and logs a warning. A node property fails the pass
Numeric properties[0, 8192] logical px for most sizes. [-8192, 8192] for translate, rotate, shader progress, shadow offset and spread. opacity and origin [0, 1], scale [0, 64], font_size [1, 8192]. margin, padding, spacing and icon size are unbounded (a tween still clamps them)Node and surface properties (nodes)The pass fails, naming the property
Array length10,000children of one node, items of one list (source, and limit is clamped to it), runs in one text contentThe pass fails
delay, pulse duration[1, 60000] msdelay(signal, ms), pulse(signal, ms) (signals)Raises at the call
timer delay[1, 86400000] ms (one day)timer(ms, fn) (scripting)Raises at the call
Action answer1 MiB of JSONWhat an action handler returnsThe mantle call fails
mantle call wait5 sThe CLI waiting for an answerThe CLI gives up. The handler may still have run
Process output line64 KiBOne line a process.run child writesThe line arrives cut; its tail is dropped
Reload debounce200 ms after the last file eventThe watcherA burst of saves reloads once
Respawn brake3 Renderer deaths within 60 sThe SupervisorThe next respawn waits 30 s

Not budgeted: an evaluation’s top level, input handlers (on_click, on_drag, on_wheel, on_hover, on_link, textfield callbacks, on_close, on_dismiss), process.run callbacks, palette callbacks and idle callbacks. A slow one stalls every surface until it returns.

Keep maps cheap. Build lookup tables once at the top level, which has no budget, and do only an index and a format inside the map:

-- Evaluation has no CPU budget: build lookup tables here, once.
local levels = { "empty", "low", "half", "high", "full" }

-- The map runs on every push, under 5 ms: one nil check, one index, one format.
local battery_label = mantle.battery:map(function(battery)
    if not battery or not battery.present then
        return "" -- nil until the first push; no battery on a desktop
    end
    local level = levels[math.min(#levels, battery.percent // 20 + 1)]
    return string.format("%s %d%%", level, battery.percent)
end)

Work that is slow by nature (parsing a large file, searching many entries) belongs in a program started with process.run, whose output callback sets a state.

Output and logging

CallGoes to
print(...)The Renderer’s stdout, unstamped
log.error/warn/info/debug(...)Stamped lines under the config subsystem (log)
A raise from any callback: input handlers, on_close, on_dismiss, on_change, timer, process.run, palette or idleA warning
A process.run or process.detach that cannot spawn, a failing mantle callA warning
An icon name no theme has, an image that does not decodeA warning, once per name

Both streams land in the shell’s log file. Read it with mantle log. A terminal that started mantle in the foreground also gets a copy.

How do I…

TaskAnswer
Find out why a reload did nothingDebug a reload
Split a config into filesPut modules beside shell.lua and bind each require to a local, as in the example at the top
Share values between modulesShare values
Run something once, not on every reloadRun once
Keep a program running across reloadssession_process. A reload kills every process.run child
Do heavy work without blowing the 5 ms budgetBuild tables at the top level and keep maps to an index and a format (example). Move anything slower into a program run with process.run
Name a file shipped beside shell.luamantle.config_dir .. "/shaders/wave.frag". os.getenv("HOME") and the rest of the shell’s environment work too
Guard code that needs a newer engineCompare mantle.version.major, .minor and .patch (renderer members)
See what print wrotemantle log, or mantle check, which prints it above its report when the config evaluates

Find out why a reload did nothing

StepCommandTells you
1mantle checkSyntax and top-level errors, with file and line. Node and layout errors as laid out with every capability nil, then with sample data (what check covers)
2mantle logshell.lua re-evaluation failed (evaluation error) or the re-evaluated config failed to apply (layout error, previous scene kept), and errors raised in callbacks
3Draw mantle.rescueThe evaluation or apply error on screen, as in the banner above

Share values between modules

A module runs once per evaluation, and every later require of it returns the same table. For a value that changes, use a named state: the same name gives the same signal in any module.

-- lib/palette.lua: every module that requires it in one evaluation gets this same table.
return {
    accent = "#89b4faff",
    surface = "#1e1e2eff",
    dnd = state("dnd", false), -- named state: one signal per name, from any module
}
-- shell.lua
local palette = require("lib.palette")

return panel {
    id = "bar",
    layer = "Top",
    anchor = { top = true, left = true, right = true },
    height = 32,
    background = palette.surface,
    child = text {
        content = palette.dnd:map(function(on) return on and "silent" or "" end),
        foreground = palette.accent,
    },
}

Run something once, not on every reload

Globals survive a reload, so a global guard runs once per Renderer start:

-- A global lives in the VM: it survives reloads and resets when the Renderer restarts.
if not started_at then
    started_at = os.time()
    log.info("renderer started")
end

return panel {
    id = "bar",
    layer = "Top",
    anchor = { top = true, left = true, right = true },
    height = 32,
    child = text { content = "up since " .. os.date("%H:%M", started_at) },
}

Timers and actions cannot be guarded this way, because every reload clears them. Declare them at the top level every time.

Gotchas

TrapFix
return { require("a"), require("b") } fails with surface 3 is a stringBind each module to a local first
require("lib.json") from a luarocks install is not foundOnly the config directory is searched. Copy the pure-Lua module into it
A map raises exceeded the 5ms CPU budgetMove the heavy work to the top level or to process.run. The map should only index and format
dofile("/big/file") stutters every frame it runsRead files through process.run or persistent_table
A global counter keeps growing across reloadsGlobals live in the VM, and a reload reuses the VM. Use local, or state when it should persist on purpose
After a broken save, mantle call says no action existsA failed reload clears actions, timers and handlers. Fix the error and save again
A config edit to fonts { ... } does nothingThe font chain is read when the Renderer starts. Restart the shell
Saving a .json or an image beside shell.lua does not reloadOnly .lua and .frag changes trigger a reload

See also: cli · signals · processes · scripting · capabilities · surfaces · glossary for generation, Supervisor and Renderer.

Source: VM setup and require, reload, apply, watcher, budget, scalar checks, property ranges, respawn, respawn brake.