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, repeat, or retrytimer
Run code from a keybind or scriptaction + mantle call
Parse JSONjson.decode
Write to the shell’s loglog.*
Rank search resultsfuzzy
Pull colours out of a wallpaperpalette.quantize
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 state_home = os.getenv("XDG_STATE_HOME")
if not state_home or state_home == "" then
    state_home = (os.getenv("HOME") or "") .. "/.local/state"
end

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

-- nil until the first push, so pick the default here too.
local clock_24h = settings.clock_24h:map(function(value) return value ~= false end)

return panel {
    id = "clock", layer = "Top", anchor = { top = true },
    child = button {
        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, name, defaults? } → store. Another key raises
pathAbsolute directory; relative raises. Created if missing. Build it from os.getenv or mantle.config_dir
nameOne file name, no /; empty raises
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: nil before mantle.storage first pushes, then the stored value
store:set(key, value)Writes one key; nil deletes it until the next reload re-fills its default. The signal updates on the next push, not inside set
IdentityOne table per file: another call with the same path/name 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. Re-arm inside the callback to repeat.

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()

-- A repeating timer re-arms itself; calling it at the top level restarts the chain on reload.
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 5 ms 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.

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 0.1
action("volume.up", function(step)
    local audio = mantle.audio:get()
    if not audio or not audio.volume then
        return "no output device"
    end
    local volume = math.min(1.5, audio.volume + (tonumber(step) or 0.05))
    mantle.audio:set_volume(volume)
    return string.format("%d%%", math.floor(volume * 100 + 0.5))
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
Limits5 ms 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

There is no json.encode; build JSON arguments with string.format.

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 = "OnDemand",
    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 { source = results, itemfn = 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

Median-cut dominant colours of an image, computed on a background thread.

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

palette.quantize("/usr/share/backgrounds/default.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?, cb) → 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
optsOnly depth 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
cb(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

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)
Uncovered glyphfontconfig is asked for any installed face that covers it
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
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 startupEvery key reads nil until mantle.storage pushes, even with defaults. Handle nil in every map
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, fonts.