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.clock:map(function(clock)
        return os.date("%H:%M", clock.time)
    end, ""), -- "" until the first push
    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, error, getmetatable, ipairs, load, next, pairs, pcall, print, rawequal, rawget, rawlen, rawset, require, select, setmetatable, tonumber, tostring, type, warn, xpcall, _G, _VERSION ("Lua 5.4"). warn logs like log.warn, on by default; warn("@off") / warn("@on") toggle itdofile, loadfile
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 (the Lua file searcher only), 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. Use require for Lua files in the config directory. dofile and loadfile are unavailable because they read files on the render thread. To read other files, use process.run or persistent_table for JSON. 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, list, textfieldNode constructorsnodes
state, computed, delay, pulse, geometrySignal constructorssignals
hover, hover_rect, scroll, focus_targetInput signals and the textfield focus handleinput
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 a property value is invalidThe node uses that property’s default and everything else comes up. mantle.rescue is set until the value is fixed. The error is logged
Startup evaluation succeeds but the scene rejects part of it (a getter raises, a malformed children)Each surface it rejects paints nothing; the others come up. mantle.rescue is set. The error is logged
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 whole reload is refused, even if one surface broke: 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 makes a property value invalidThe node uses that property’s default and the update applies. mantle.rescue is set until the value is fixed. Logged as an error, once until the message changes
A live update fails later (a pushed value breaks a map)Each surface it breaks keeps its last applied tree; the others, an error banner included, keep updating. mantle.rescue is set until a pass applies every surface. Logged as an error, once until the message changes
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

An error from an engine function, a method on an engine object (signal:reveal, mantle.idle:register_threshold) or a capability action keeps its leading file:line: through pcall. Only an unknown capability field omits it there; the logged and mantle check forms carry it.

A failed reload keeps the old scene, but not the old evaluation’s actions, timers, on_change handlers or idle thresholds (what survives):

FailureRegistered afterwards
Evaluation (a Lua error)Nothing
ApplyThe new evaluation’s actions, handlers and thresholds; 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.is_rescue end),
    background = "#f38ba8ff",
    padding = 8,
    child = text {
        content = rescue:map(function(state) return state.error_log 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 on_exit(nil) runs before the new evaluation; no on_line followsKilled with its process groupKilled
process.detach programUnaffectedUnaffectedUnaffected
timer, intervalCleared. The new evaluation’s timers start when its result is appliedClearedGone
action, mantle.<cap>:on_change, state:on_change, a derived :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 limit50 ms of thread CPU timeEach :map and computed recompute, each delay/pulse read, each on_change handler, action handler and timer or interval callback. Nested reads share the outermost deadlineThe call raises exceeded the 50ms CPU limit for one evaluation; a runaway loop?. pcall inside the callback does not hide it
Frame target2.5 ms of thread CPU timeThe same evaluationsNothing fails. The log gets one warning naming the evaluation’s CPU time and the computed where most of its Lua instructions ran, or that it ran outside any signal. Each site warns once until the next reload, and a handler with no site shares one warning per reload. A slow getter costs frames
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 eachs and function child buildersThe pass fails and every surface keeps its last applied tree
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 is invalid, as below
Numeric properties[0, 8192] logical px for most sizes and each padding edge. [-8192, 8192] for translate, rotate, shader progress, shadow offset and spread. opacity and origin [0, 1], scale [0, 64], font_size [1, 8192]. margin, spacing and icon size are unbounded (a tween still clamps them)Node and surface properties (nodes)A startup or live pass uses the property’s default; a reload and mantle check fail. Each names the property
Array length10,000children of one node, items of one list (items, and limit is clamped to it), runs in one text contentThe pass fails; a startup or live pass gives a content its default instead
delay, pulse duration[1, 60000] msdelay(signal, ms), pulse(signal, ms) (signals)Raises at the call
timer and interval delay[1, 86400000] ms (one day)timer(ms, fn), interval(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_request, 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, well under the 2.5 ms frame target: one check, one index, one format.
local battery_label = mantle.battery:map(function(battery)
    if not battery.present then
        return "" -- 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_request, 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 missing the 2.5 ms frame targetBuild 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 50ms CPU limitA runaway loop, or work far past the frame target. Move the heavy work to the top level or to process.run. The map should only index and format
The log warns that an evaluation went over the 2.5 ms frame targetThe named signal is slow but still works. Index and format inside it, and move the rest out
dofile or loadfile is nilUse require for Lua modules; read other files through process.run or persistent_table for JSON
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 evaluation clears actions, timers and handlers; a failed apply keeps the new actions. 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.