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

Scripting

The imperative globals that are neither UI nor processes: keep settings on disk, schedule work, answer keybinds, and a few utilities. Reach for them from event handlers (on_click, on_change, callbacks) or a module’s top level. Running other programs is on processes; everything that renders goes through signals and nodes instead.

Terms used below (Supervisor, Renderer, generation, push) are in the glossary.

Which one do I use

ToUse
Run a command, launch an app, or keep a program runningprocesses
Remember a setting across restartspersistent_table
Do something later, or retrytimer
Repeat every N msinterval
Run code from a keybind or scriptaction + mantle call
Parse or write JSONjson.decode, json.encode
Write to the shell’s loglog.*
Rank search resultsfuzzy
Pull colours out of an imagepalette.quantize
Build Material 3 colours from a seed, or from an image (quantize, then score, then scheme; write them to app configs)palette.scheme, palette.score, palette.hct, palette.from_hct, palette.harmonize
Read or write a text filefile
Set the font fallback chainfonts

What each one keeps across a reload, a crash and a restart: runtime.

persistent_table

A JSON object on disk, read as one signal per key and written one key at a time.

local settings = persistent_table {
    path = "~/.local/state/myshell/settings.json",
    defaults = { clock_24h = true, recorder = { fps = 60, audio = "desktop" } },
}

-- Reads its declared default before the first push.
local clock_24h = settings.clock_24h

return panel {
    id = "clock", layer = "top", anchor = { top = true },
    child = rect {
        on_click = function() settings:set("clock_24h", not clock_24h:get()) end,
        children = {
            text { content = clock_24h:map(function(on) return on and "24h" or "12h" end) },
        },
    },
}
PartContract
Signaturepersistent_table { path, defaults? } → store. Another key raises
pathAbsolute, or ~/ under $HOME; relative, ending in /, or with an empty, . or .. component raises. Its directory is created if missing.
defaultsFills missing top-level keys; stored values win. Nested tables are one value and do not merge. Re-sent every evaluation, so a new default lands on reload
store.<key>Signal for that key: its defaults entry (else nil) until mantle.storage pushes this file, then the stored value; a key the pushed file lacks reads its defaults entry, else nil
store:set(key, value)Writes one key; nil deletes it, and the signal then reads its defaults entry (else nil). The signal updates on the next push, not inside set. The next evaluation writes the default back to the file
IdentityOne table per file: another call with the same path returns it, across reloads too
Raw statemantle.storage (capabilities)

On disk:

BehaviourDetail
Save1 s after the last write to that file, as pretty JSON through a temporary file and rename. A write still waiting at shell exit is lost
First runA missing file is created from defaults
Outside editsWatched with inotify. Another writer’s version replaces the in-memory one whole, dropping unsaved writes
Broken fileNot a JSON object, or unparsable: the last values stay, a warning is logged once, and nothing saves over it until it parses

timer

Runs a function once after a delay. To repeat at a fixed rate, use interval.

local status = state("status", "checking")
local BACKOFF_MS = { 2000, 4000, 8000 }

local function check(attempt)
    attempt = attempt or 1
    process.run("ping", { "-c", "1", "-W", "2", "1.1.1.1" }, function() end, function(code)
        if code == 0 then
            status:set("online")
        elseif BACKOFF_MS[attempt] then
            status:set("retrying")
            timer(BACKOFF_MS[attempt], function() check(attempt + 1) end)
        else
            status:set("offline")
        end
    end)
end
check()

-- Re-arming from the callback repeats on a varying delay, here aligned to the minute.
local clock = state("clock", "")
local function tick()
    clock:set(os.date("%H:%M"))
    timer(60000 - (os.time() % 60) * 1000, tick)
end
tick()
PartContract
Signaturetimer(ms, fn) → handle
ms1 to 86400000 (one day), fractions allowed, monotonic clock; outside raises
fnCalled with no arguments under the Lua CPU budget. A raise or blown budget is logged as a warning
Handlehandle:cancel() disarms it. A no-op once fired or cancelled. Dropping the handle does not disarm
OrderTimers due at the same moment fire in the order they were armed; one may cancel another in the same batch
LifetimeEvery evaluation clears all timers, including chains armed from callbacks. Timers armed during an evaluation start only once its result is applied

For a delayed or blinking value, delay and pulse (signals) need no callback.

When to use a timer

Each time a timer sets a signal, every :map reading it runs again. Once a minute is free; every 33 ms is 30 times a second, forever. Arm a timer as often as the data changes, never per frame.

ToUse
Retry with backoff, or act once after a delaytimer
Update a clocktimer, re-armed for the next minute, as above
Poll something with no capability and no output to followinterval at the slowest rate that stays useful (poll a command)
React to a program’s outputFollow it with process.run instead of polling (follow)
Hide something a few seconds after the last changetimer, cancelling the previous handle on each change; or delay
Show a value late, or blink itdelay or pulse
Move, spin, pulse or morph somethinganimate (Lua cost)

interval

interval(ms, fn) → handle. Runs fn every ms until handle:cancel(), with timer’s range, budget, order and lifetime. Turns stay on the original schedule; one that comes late runs once and the next is ms after it, so missed runs never queue. A raise is logged and the next run still happens. Cancelling from inside fn stops it.

local load = state("load", "")
interval(5000, function()
    process.run("cat", { "/proc/loadavg" }, function(line) load:set(line:match("^%S+")) end, function() end)
end)

action

Names a function that mantle call <name> [args...] runs, usually from a compositor keybind. Write a state when the shell should look different; call an action when it should do something.

-- Keybind: mantle call volume.up 10
action("volume.up", function(step)
    local audio = mantle.audio:get()
    for _, sink in ipairs(audio and audio.sinks or {}) do
        if sink.active and sink.volume then
            local volume = math.max(0, math.min(150, sink.volume + (tonumber(step) or 5)))
            mantle.audio:set_volume(sink.id, volume)
            return string.format("%d%%", math.floor(volume + 0.5))
        end
    end
    return "no output device"
end)
PartContract
Signatureaction(name, fn) → nothing
nameAny non-empty string; nothing splits on .. Empty, or declared twice in one evaluation, raises
Arguments, return, failureAs values and arguments: each argument JSON-decoded when it parses, the return printed bare or as JSON. A return that is not convertible to JSON fails the call
LimitsLua CPU budget
LifetimeCleared before every evaluation and again when one fails, so declare at the top level

CLI flags such as --pid: cli.

json.decode

json.decode(text) → value, or nil, message. It never raises on bad input.

JSONLua
null fieldAbsent key
null array elementA hole: ipairs stops there, # may still count past it
Top-level nullnil, same as a failure without the message
Non-UTF-8 inputnil, message

json.encode

json.encode(value) → string: compact JSON, object keys sorted. It raises on a value JSON cannot hold, since that is a config mistake rather than bad input.

LuaJSON
Table whose keys are all positive integers, {} includedArray; a hole inside it is null. More than half holes raises
Table with string keysObject
Table mixing both, or with other keysRaises
nilnull
Integer, float3, 3.0: json.decode reads each back as it was
Function, userdata, a table that contains itself, NaN, infinity, nesting past 127, over 2^20 values, a decoded array given a named keyRaises
local body = json.encode({ title = "Backup", done = true }) -- {"done":true,"title":"Backup"}
process.detach("curl", { "-s", "-H", "Content-Type: application/json", "-d", body, "http://localhost:8080/events" })

log

log.error(...), log.warn(...), log.info(...), log.debug(...). Arguments join with tabs like print, and the line is stamped with time, level and the config subsystem. All levels print by default. Filter with MANTLE_LOG=config=warn (or config=off); read with mantle log.

fuzzy

fzf’s scorer for one candidate. Iterating, sorting and tiebreaking stay in Lua.

local APPS = { "Firefox", "Files", "GIMP", "Terminal", "System Monitor" }
local query = state("query", "")

local results = query:map(function(needle)
    local scored = {}
    for _, name in ipairs(APPS) do
        local score, start = fuzzy(name, needle)
        if score then
            scored[#scored + 1] = { name = name, score = score, start = start }
        end
    end
    table.sort(scored, function(left, right)
        if left.score ~= right.score then return left.score > right.score end
        if left.start ~= right.start then return left.start < right.start end
        return left.name < right.name
    end)
    local names = {}
    for index, entry in ipairs(scored) do names[index] = entry.name end
    return names
end)

return panel {
    id = "launcher", layer = "overlay", keyboard_interactivity = "on_demand",
    child = column {
        width = 320, padding = 12, spacing = 6, background = "#1E1E2E",
        children = {
            textfield { autofocus = true, placeholder = "Search", on_change = function(text) query:set(text) end },
            list { items = results, each = function(name) return text { content = name } end },
        },
    },
}
PartContract
Signaturefuzzy(haystack, needle) → score, start
MatchInteger score (higher is better) and start, the 0-based byte offset of the needle’s first character at its earliest in-order hit (the best-scoring one for a one-character needle). For tiebreaks, not highlighting
No matchnil, nil, also for non-UTF-8 input
Empty needle0, 0
CaseSmart: an all-lowercase needle ignores case; one uppercase character makes the whole comparison exact. Don’t lowercase the query
Non-ASCIIA greedy scorer whose scores do not compare with the ASCII path’s

Tie order is the caller’s: table.sort is unstable, so end the comparator on a unique key.

palette.quantize

Dominant colours of an image by Material’s quantizers, computed on a background thread. Put a raster image at wallpaper.png in your config directory, or change the path below to an existing image. The book checks this file-dependent snippet’s syntax; the callback runs only in a live Renderer.

local swatches = state("swatches", {})

palette.quantize(mantle.config_dir .. "/wallpaper.png", { depth = 3 }, function(found)
    swatches:set(found or {})
end)

return panel {
    id = "palette", layer = "top", anchor = { bottom = true },
    child = row {
        children = swatches:map(function(list)
            local chips = {}
            for index, swatch in ipairs(list) do
                chips[index] = rect { width = math.max(4, 200 * swatch.share), height = 16, background = swatch.color }
            end
            return chips
        end),
    },
}
PartContract
Signaturepalette.quantize(path, opts?, on_done) → handle
pathLocal raster image; no SVG or URL
opts.depth0 to 8, default 3: up to 2^depth colours, fewer when the image has fewer. Out of range raises
opts.method"celebi" (default; Wu, then k-means in Lab, as matugen does) or "wu" (Wu alone, faster, rougher). Another raises
optsOnly depth, method and rescale; another key raises
opts.rescaleLongest edge in px before counting, default 128; 0 is full size. Negative raises. A cached freedesktop thumbnail that covers it is used instead of decoding
on_done(swatches){ color = "#RRGGBB", share = 0..1 } entries, most common first. share counts only non-transparent pixels. nil on failure, with a logged warning. Runs outside the CPU budget; a raise is logged as a warning
Handlehandle:cancel() drops the callback; the work still finishes

palette.scheme

Material 3 colour roles from one seed colour (to write them into other programs’ configs: Colour your apps), built by Material Color Utilities at spec 2021 on the phone platform. Synchronous: one scheme takes about 0.5 ms.

local roles = palette.scheme("#6750A4", { dark = true, variant = "tonal_spot" })

return rect {
    background = roles.surface_container, padding = 12,
    children = { text { content = "Material", foreground = roles.on_surface } },
}
PartContract
Signaturepalette.scheme(seed, opts?) → roles
seed"#RRGGBB"; anything else raises
opts.darkBoolean, default false
opts.variant"tonal_spot" (default), "vibrant", "expressive", "fidelity", "content", "neutral", "monochrome", "rainbow" or "fruit_salad". Another raises
opts.contrast-1 to 1, default 0; 1 is high contrast. Out of range raises
optsOnly those three keys; another raises
RolesA table of 49 "#RRGGBB" strings keyed by role in snake_case: primary, on_primary_container, surface_container_highest, … (PaletteScheme in the stubs)

palette.score

Ranks palette.quantize swatches as Material 3 picks a wallpaper’s seed: chromatic colours that cover more of the image, spread across hues. Quantize at depth 6 or 7, so Score has enough colours to choose from.

local roles = state("roles", palette.scheme("#6750A4"))

palette.quantize(mantle.config_dir .. "/wallpaper.png", { depth = 7 }, function(found)
    if found then roles:set(palette.scheme(palette.score(found)[1], { dark = true })) end
end)
PartContract
Signaturepalette.score(swatches) → { "#RRGGBB", ... }
swatches{ color = "#RRGGBB", share } entries; only the ratios of the shares count. A bad colour or a share not above 0 raises
ResultUp to 4 colours, best first, at least 15° of hue apart. Each is one input swatch’s colour as uppercase #RRGGBB, so a quantize swatch looks it up by color. Colours with chroma under 5 or hues under 1% of the image drop out; with none left it is { "#4285F4" }

palette.hct

A colour’s hue, chroma and tone in HCT, Material’s colour space, which Score and every scheme work in. A config can rank or filter swatches by its own rule: tonal spot’s primary palette uses chroma 36, so a seed far below that comes out muted.

local seed = "#6750A4"
local hct = palette.hct(seed) -- hue ≈ 299, chroma ≈ 48, tone ≈ 40

return text { content = hct.chroma >= 36 and "vivid" or "muted" }
PartContract
Signaturepalette.hct(color) → { hue, chroma, tone } (PaletteHct in the stubs)
color"#RRGGBB"; anything else, alpha included, raises
hueDegrees, 0 to under 360; a grey still gets a number
chromaColourfulness from 0. Greys stay under 5, the cutoff below which Score ignores a colour
toneLightness, 0 (black) to 100 (white)

palette.from_hct

palette.hct’s inverse: palette.from_hct{ hue, chroma, tone } → "#RRGGBB". hue wraps, chroma is 0 or more, tone 0 to 100, all numbers; anything else raises. A chroma sRGB cannot show at that hue and tone is reduced to what fits.

local hct = palette.hct("#6750A4")
return text { content = palette.from_hct { hue = hct.hue, chroma = hct.chroma, tone = 90 } }

palette.harmonize

palette.harmonize(color, source) → "#RRGGBB". Moves color’s hue toward source’s by half the gap, at most 15°, keeping chroma and tone: Material’s way to seat a fixed colour, such as an error red, beside a seed.

return text { content = palette.harmonize("#FF0000", "#6750A4"), foreground = "#FFFFFF" }

file

Whole-file read and write on a background thread. The config VM has no blocking IO: results arrive in a callback, like palette.quantize. Worked example: Colour your apps.

PartContract
file.read(path, on_done)on_done(text, err): the text, or nil and a message for a missing, unreadable, non-UTF-8 or over-8 MiB file
file.write(path, text, opts?, on_done?)Creates parent directories. When the file already holds exactly text, writes nothing and calls on_done(false); otherwise replaces it atomically (temporary in the same directory, then rename) and calls on_done(true). On failure on_done(nil, err). opts = { in_place = true } truncates and rewrites the same inode, for files another program watches by path. An existing file keeps its mode, a new one gets the default. A crash mid-write leaves a truncated file, and a watcher can read it empty or partial. A callback in the opts slot raises: pass nil first
pathAbsolute, or ~/ under $HOME. Anything else raises at the call
OrderWrites run one at a time in call order, so two writes to a path end with the later text. A read does not wait for them: read in the write’s on_done. A symlink keeps its link and its target is replaced; the file’s mode is kept
Handlehandle:cancel() drops the callback. A started write still lands
ReloadDrops pending callbacks, so keep what they touch in named state. A write begun before the reload still lands
mantle checkCalls return a handle and do nothing

fonts

fonts { family, ... } sets the fallback chain every text node uses. Each glyph takes the first family that covers it.

fonts { "Inter", "Symbols Nerd Font", "Noto Color Emoji" }
PartContract
ArgumentDense array of family-name strings. A hole, a named key or a non-string raises
ResolutionThrough fc-match. The first family that resolves is the primary and also loads its bold and italic faces; a family with no install is skipped (logged at -vvv)
DefaultWithout a call: sans-serif, Noto Sans CJK JP, Noto Color Emoji
Per nodeA text node’s font goes in front of the chain (nodes); font_variations sets a variable font’s axes (variable fonts)
Uncovered glyphfontconfig is asked for any installed face that covers it
SVG <text>Images, icons and file masks draw it with the loaded chain; declare a family it names (image)
LifetimeRead once at startup. Last call wins; an edit needs a shell restart

How do I…

TaskAnswer
Fetch JSON over HTTPcurl through process.run, then json.decode the output
Poll a command every N secondsprocesses
Search as you typefuzzy; for a slow source, debounce the query with delay

Gotchas

TrapFix
Callbacks from before a reloadpalette callbacks run the old closures after a reload. Keep what they touch in named state
timer or action declared only inside a callbackEvery evaluation clears both, so they vanish on the next save. Declare actions at the top level; start timer chains from the top level too
A timer re-armed every few milliseconds to move somethingEach tick runs Lua and resolves again. Use animate (Lua cost)
Two modules declare the same actionRaises. Pick unique names
store.key:get() right after store:setStill the old value. The signal updates on the next push
store.key is nil at startupThe key has no defaults entry and mantle.storage has not pushed, or the file lacks it. Give it a default
Storing a key named setstore.set is the method, so that key is unreadable

See also: processes, runtime (reloads, budgets, logging), signals (state, delay), storage capability (mantle.storage), cli (mantle call, mantle log).

Source: store, storage controller, timer, action, json, log, fuzzy, palette, file, scheme, fonts.